Compare commits
26 Commits
59c6b19ad6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a43a450f3 | |||
| 46605d8717 | |||
| 8f1144de1a | |||
| 53b75588a2 | |||
| 1849ecd73b | |||
| 53b7d34520 | |||
| f65a4bed24 | |||
| cc4a2382c6 | |||
| 5735bb7735 | |||
| 88ff06237e | |||
| fbb13561b0 | |||
| b15861c856 | |||
| 2aeaa119f7 | |||
| 36690c0174 | |||
| b76ad35c07 | |||
| 6918b55a58 | |||
| 49f1713e26 | |||
| c9fa7e4246 | |||
| 0411ec5175 | |||
| 7b7171c109 | |||
| c02bb4d6d8 | |||
| 603b4eee4d | |||
| 52008f5b43 | |||
| 5b57cdd6bf | |||
| 60eadf0847 | |||
| 682ccfd41f |
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
|
|||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
### Health checks
|
||||||
|
|
||||||
|
Monitor these production checks:
|
||||||
|
|
||||||
|
- Frontend: `GET https://your-host/healthz`
|
||||||
|
- Verifies Nginx is serving the frontend container.
|
||||||
|
- Backend liveness: `GET https://your-host/api/health/live`
|
||||||
|
- Verifies the API process is running.
|
||||||
|
- Backend readiness: `GET https://your-host/api/health/ready`
|
||||||
|
- Verifies the API can reach Postgres and Redis. Returns `503` if either dependency is unavailable.
|
||||||
|
- Backend metrics: `GET https://your-host/api/metrics`
|
||||||
|
- Admin-authenticated process, request, and queue metrics.
|
||||||
|
- Postgres and Redis:
|
||||||
|
- Use the Docker health checks in `docker-compose.prod.yml`.
|
||||||
|
- Worker:
|
||||||
|
- Use the Docker health check in `docker-compose.prod.yml`; it validates worker dependencies. The worker does not expose HTTP.
|
||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
|
|
||||||
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Generated
+1049
-1
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,10 @@
|
|||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
"pdfkit": "^0.18.0",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^22.0.2",
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -32,7 +35,9 @@
|
|||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/morgan": "1.9.9",
|
"@types/morgan": "1.9.9",
|
||||||
"@types/node": "22.10.2",
|
"@types/node": "22.10.2",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/pg": "8.11.10",
|
"@types/pg": "8.11.10",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+562
-25
@@ -12,8 +12,11 @@ import nodemailer, { type SendMailOptions } from 'nodemailer';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db } from './db/client.js';
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
|
import { adoptionReportQueueEvents, enqueueAdoptionReportJob, getAdoptionReportQueueCounts } from './queues/adoptionReportQueue.js';
|
||||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||||
import {
|
import {
|
||||||
consumeMagicLinkToken,
|
consumeMagicLinkToken,
|
||||||
consumeOAuthState,
|
consumeOAuthState,
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
createBirdMilestoneReminderDelivery,
|
createBirdMilestoneReminderDelivery,
|
||||||
|
createMedicationReminderDelivery,
|
||||||
createBirdTransferCode,
|
createBirdTransferCode,
|
||||||
createMedicationForBird,
|
createMedicationForBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
@@ -47,8 +51,10 @@ import {
|
|||||||
getBirdById,
|
getBirdById,
|
||||||
getBirdByPublicProfileCode,
|
getBirdByPublicProfileCode,
|
||||||
getOpenBirdTransferCode,
|
getOpenBirdTransferCode,
|
||||||
|
getOpenBirdTransferCodeForBird,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
|
listDueMedicationReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
listMedicationAdministrationsForBird,
|
listMedicationAdministrationsForBird,
|
||||||
listMedicationsForBird,
|
listMedicationsForBird,
|
||||||
@@ -60,6 +66,7 @@ import {
|
|||||||
updateBird,
|
updateBird,
|
||||||
updateMemorialReminderPreference,
|
updateMemorialReminderPreference,
|
||||||
updateMedicationForBird,
|
updateMedicationForBird,
|
||||||
|
updateWeightForBird,
|
||||||
upsertMedicationAdministrationForBird,
|
upsertMedicationAdministrationForBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
@@ -103,6 +110,7 @@ import {
|
|||||||
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
||||||
updateRescueVerificationStatus,
|
updateRescueVerificationStatus,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
upsertWorkspaceMember,
|
upsertWorkspaceMember,
|
||||||
} from './repositories/workspaceRepository.js';
|
} from './repositories/workspaceRepository.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -116,8 +124,9 @@ import type {
|
|||||||
FlockNoteRow,
|
FlockNoteRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationRow,
|
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
RescueVerificationStatus,
|
RescueVerificationStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -146,7 +155,9 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
|||||||
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
||||||
const sessionDays = 30;
|
const sessionDays = 30;
|
||||||
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
||||||
|
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
|
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||||
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||||
@@ -200,7 +211,7 @@ const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']
|
|||||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
|
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
|
||||||
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
const birdGenderSchema = z.enum(['unknown', 'male', 'female', 'male_dna', 'female_dna']);
|
||||||
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
||||||
const rescueOnboardingSchema = z.object({
|
const rescueOnboardingSchema = z.object({
|
||||||
name: z.string().trim().max(160).optional().or(z.literal('')),
|
name: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
@@ -322,6 +333,7 @@ const medicationSchema = z
|
|||||||
startDate: dateStringSchema,
|
startDate: dateStringSchema,
|
||||||
endDate: dateStringSchema.optional().or(z.literal('')),
|
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
|
remindersEnabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
||||||
message: 'End date must be on or after start date.',
|
message: 'End date must be on or after start date.',
|
||||||
@@ -652,6 +664,33 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
|
|
||||||
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
|
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
|
||||||
|
|
||||||
|
const ensureOpenBirdTransferCode = async (birdId: string, sourceWorkspaceId: number, requestedByUserId: string) => {
|
||||||
|
const existingTransferCode = await getOpenBirdTransferCodeForBird(birdId, sourceWorkspaceId);
|
||||||
|
|
||||||
|
if (existingTransferCode) {
|
||||||
|
return existingTransferCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await createBirdTransferCode({
|
||||||
|
code: createBirdTransferCodeValue(),
|
||||||
|
birdId,
|
||||||
|
sourceWorkspaceId,
|
||||||
|
requestedByUserId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
@@ -690,6 +729,7 @@ const normalizeMedication = (row: MedicationRow) => ({
|
|||||||
startDate: row.start_date,
|
startDate: row.start_date,
|
||||||
endDate: row.end_date,
|
endDate: row.end_date,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
|
remindersEnabled: row.reminders_enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
||||||
@@ -781,6 +821,7 @@ app.disable('x-powered-by');
|
|||||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
|
exposedHeaders: ['X-FlockPal-Transfer-Code'],
|
||||||
origin(origin, callback) {
|
origin(origin, callback) {
|
||||||
if (!origin || allowedOrigins.includes(origin)) {
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
@@ -1187,6 +1228,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
|
||||||
|
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
|
||||||
|
return `${hour === '24' ? '00' : hour}:${minute}`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatOrdinal = (value: number) => {
|
const formatOrdinal = (value: number) => {
|
||||||
const remainder = value % 100;
|
const remainder = value % 100;
|
||||||
if (remainder >= 11 && remainder <= 13) {
|
if (remainder >= 11 && remainder <= 13) {
|
||||||
@@ -1694,6 +1747,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatReminderDoseTime = (time: string) => {
|
||||||
|
const [rawHour, rawMinute] = time.split(':');
|
||||||
|
const hour = Number(rawHour);
|
||||||
|
const minute = rawMinute ?? '00';
|
||||||
|
if (Number.isNaN(hour)) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
const period = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHour = hour % 12 || 12;
|
||||||
|
return `${displayHour}:${minute} ${period}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
|
||||||
|
const doseTime = formatReminderDoseTime(reminder.administration_time);
|
||||||
|
const slotLabel = reminder.administration_label || 'Dose';
|
||||||
|
const route = reminder.route ? ` by ${reminder.route}` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
||||||
|
eyebrow: 'Medication Reminder',
|
||||||
|
headline: `${slotLabel} time for ${reminder.name}`,
|
||||||
|
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
|
||||||
|
body: `Dose: ${reminder.dosage}${route}.`,
|
||||||
|
detailLabel: `${slotLabel} at ${doseTime}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const sendBirdMilestoneReminderNotification = async ({
|
const sendBirdMilestoneReminderNotification = async ({
|
||||||
reminder,
|
reminder,
|
||||||
recipients,
|
recipients,
|
||||||
@@ -1800,6 +1880,120 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
return { delivered: true };
|
return { delivered: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendMedicationReminderNotification = async ({
|
||||||
|
reminder,
|
||||||
|
recipients,
|
||||||
|
}: {
|
||||||
|
reminder: MedicationReminderCandidateRow;
|
||||||
|
recipients: string[];
|
||||||
|
}) => {
|
||||||
|
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!uniqueRecipients.length) {
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = buildMedicationReminderCopy(reminder);
|
||||||
|
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
||||||
|
const logoAttachment = getFlockPalLogoAttachment();
|
||||||
|
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
|
||||||
|
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
||||||
|
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
||||||
|
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
||||||
|
|
||||||
|
if (logoAttachment) {
|
||||||
|
attachments.push(logoAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedBirdPhoto) {
|
||||||
|
attachments.push({
|
||||||
|
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
|
||||||
|
content: uploadedBirdPhoto.content,
|
||||||
|
contentType: uploadedBirdPhoto.contentType,
|
||||||
|
cid: birdPhotoCid,
|
||||||
|
contentDisposition: 'inline',
|
||||||
|
});
|
||||||
|
} else if (defaultBirdPhoto) {
|
||||||
|
attachments.push(defaultBirdPhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const birdPhotoHtml = birdPhotoCid
|
||||||
|
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
|
||||||
|
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
|
||||||
|
const medicationNotesHtml = reminder.medication_notes
|
||||||
|
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
|
||||||
|
: '';
|
||||||
|
const lines = [
|
||||||
|
copy.headline,
|
||||||
|
'',
|
||||||
|
copy.intro,
|
||||||
|
copy.body,
|
||||||
|
'',
|
||||||
|
`Bird: ${reminder.name}`,
|
||||||
|
`Medication: ${reminder.medication_name}`,
|
||||||
|
`Dose: ${reminder.dosage}`,
|
||||||
|
`Scheduled dose: ${copy.detailLabel}`,
|
||||||
|
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
|
||||||
|
'',
|
||||||
|
`Open FlockPal: ${frontendBaseUrl}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (!mailTransport) {
|
||||||
|
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailTransport.sendMail({
|
||||||
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||||
|
to: smtpFromEmail,
|
||||||
|
bcc: uniqueRecipients,
|
||||||
|
subject: copy.subject,
|
||||||
|
text: lines.join('\n'),
|
||||||
|
attachments,
|
||||||
|
html: `
|
||||||
|
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
|
||||||
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||||
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
|
||||||
|
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
|
||||||
|
${
|
||||||
|
logoAttachment
|
||||||
|
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
|
||||||
|
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style="padding: 30px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
|
||||||
|
${birdPhotoHtml}
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align: top; padding: 0 0 20px;">
|
||||||
|
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
|
||||||
|
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
||||||
|
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
||||||
|
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
|
||||||
|
${medicationNotesHtml}
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 680px; margin: 18px auto 0;">
|
||||||
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { delivered: true };
|
||||||
|
};
|
||||||
|
|
||||||
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
||||||
const reminders = await listDueBirdMilestoneReminders(runDate);
|
const reminders = await listDueBirdMilestoneReminders(runDate);
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
@@ -1844,7 +2038,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||||
|
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const reminder of reminders) {
|
||||||
|
try {
|
||||||
|
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
|
||||||
|
const result = await sendMedicationReminderNotification({ reminder, recipients });
|
||||||
|
|
||||||
|
if (!result.delivered) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivery = await createMedicationReminderDelivery({
|
||||||
|
medicationId: reminder.medication_id,
|
||||||
|
birdId: reminder.id,
|
||||||
|
workspaceId: reminder.workspace_id,
|
||||||
|
scheduledOn: runDate,
|
||||||
|
administrationSlot: reminder.administration_slot,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (delivery) {
|
||||||
|
sent += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
checked: reminders.length,
|
||||||
|
sent,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
let lastMilestoneReminderRunDate = '';
|
let lastMilestoneReminderRunDate = '';
|
||||||
|
let lastMedicationReminderRunKey = '';
|
||||||
|
|
||||||
export const startBirdMilestoneReminderScheduler = () => {
|
export const startBirdMilestoneReminderScheduler = () => {
|
||||||
if (!milestoneRemindersEnabled) {
|
if (!milestoneRemindersEnabled) {
|
||||||
@@ -1878,6 +2118,42 @@ export const startBirdMilestoneReminderScheduler = () => {
|
|||||||
}, milestoneReminderCheckIntervalMs);
|
}, milestoneReminderCheckIntervalMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const startMedicationReminderScheduler = () => {
|
||||||
|
if (!medicationRemindersEnabled) {
|
||||||
|
console.log('Medication reminders are disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runIfNeeded = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const runDate = getDateInTimeZone(now);
|
||||||
|
const currentTime = getTimeInTimeZone(now);
|
||||||
|
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
|
||||||
|
|
||||||
|
if (lastMedicationReminderRunKey === runKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMedicationReminderRunKey = runKey;
|
||||||
|
const job = await enqueueMedicationReminderJob(runDate, currentTime);
|
||||||
|
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
void runIfNeeded().catch((error) => {
|
||||||
|
lastMedicationReminderRunKey = '';
|
||||||
|
console.error('Medication reminder scheduler failed', error);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
void runIfNeeded().catch((error) => {
|
||||||
|
lastMedicationReminderRunKey = '';
|
||||||
|
console.error('Medication reminder scheduler failed', error);
|
||||||
|
});
|
||||||
|
}, milestoneReminderCheckIntervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
const readBearerToken = (authorizationHeader?: string) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
return '';
|
||||||
@@ -1991,6 +2267,59 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HealthCheckResult = {
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withHealthTimeout = async <T,>(operation: Promise<T>, timeoutMs = 2_000): Promise<T> => {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error('Health check timed out')), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkPostgresHealth = async (): Promise<HealthCheckResult> => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withHealthTimeout(db.query('SELECT 1'));
|
||||||
|
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : 'Postgres health check failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkRedisHealth = async (): Promise<HealthCheckResult> => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withHealthTimeout(getBirdMilestoneReminderQueueCounts());
|
||||||
|
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : 'Redis health check failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const writeAuditLog = async (
|
const writeAuditLog = async (
|
||||||
auth: AuthContext,
|
auth: AuthContext,
|
||||||
action: string,
|
action: string,
|
||||||
@@ -2019,8 +2348,46 @@ const isBillingOnlyWorkspaceUpdate = (
|
|||||||
payload: z.infer<typeof workspaceSchema>,
|
payload: z.infer<typeof workspaceSchema>,
|
||||||
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
||||||
|
|
||||||
app.get('/api/health', (_req: Request, res: Response) => {
|
app.get('/api/health/live', (_req: Request, res: Response) => {
|
||||||
res.json({ ok: true });
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
service: 'flockpal-backend',
|
||||||
|
status: 'live',
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health/ready', async (_req: Request, res: Response) => {
|
||||||
|
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
|
||||||
|
const ok = postgres.ok && redis.ok;
|
||||||
|
|
||||||
|
res.status(ok ? 200 : 503).json({
|
||||||
|
ok,
|
||||||
|
service: 'flockpal-backend',
|
||||||
|
status: ok ? 'ready' : 'degraded',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
dependencies: {
|
||||||
|
postgres,
|
||||||
|
redis,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', async (_req: Request, res: Response) => {
|
||||||
|
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
|
||||||
|
const ok = postgres.ok && redis.ok;
|
||||||
|
|
||||||
|
res.status(ok ? 200 : 503).json({
|
||||||
|
ok,
|
||||||
|
service: 'flockpal-backend',
|
||||||
|
status: ok ? 'ready' : 'degraded',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
dependencies: {
|
||||||
|
postgres,
|
||||||
|
redis,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -2029,6 +2396,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
||||||
|
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
startedAt: requestMetrics.startedAt,
|
startedAt: requestMetrics.startedAt,
|
||||||
@@ -2050,6 +2418,8 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
|||||||
},
|
},
|
||||||
queues: {
|
queues: {
|
||||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||||
|
medicationReminders: medicationReminderQueueCounts,
|
||||||
|
adoptionReports: await getAdoptionReportQueueCounts(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2933,9 +3303,57 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
|
||||||
|
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
|
||||||
|
|
||||||
|
if (parsed.data.role === 'owner' && !requesterIsBillingOwner) {
|
||||||
|
res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: req.params.memberId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
role: parsed.data.role,
|
||||||
|
requesterMemberId: req.auth!.membership.id,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
requesterRole: req.auth!.membership.role,
|
||||||
|
billingEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
res.status(404).json({ error: 'Flock member not found or cannot be changed.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'workspace_member.role_updated', 'workspace_member', member.id, member.name, {
|
||||||
|
role: member.role,
|
||||||
|
});
|
||||||
|
res.json({ member: normalizeWorkspaceMember(member) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
|
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
|
||||||
|
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: req.params.memberId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
requesterMemberId: req.auth!.membership.id,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
});
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
||||||
@@ -2943,7 +3361,6 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
|
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
|
||||||
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -3269,25 +3686,7 @@ app.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let transferCode = null;
|
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
try {
|
|
||||||
transferCode = await createBirdTransferCode({
|
|
||||||
code: createBirdTransferCodeValue(),
|
|
||||||
birdId: sourceBird.id,
|
|
||||||
sourceWorkspaceId: req.auth!.workspace.id,
|
|
||||||
requestedByUserId: req.auth!.user.id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transferCode) {
|
if (!transferCode) {
|
||||||
throw new Error('Unable to create bird transfer code.');
|
throw new Error('Unable to create bird transfer code.');
|
||||||
@@ -3309,6 +3708,30 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get('/api/birds/:birdId/transfer-code', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!sourceBird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferCode = await getOpenBirdTransferCodeForBird(sourceBird.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
transferCode: transferCode
|
||||||
|
? {
|
||||||
|
code: transferCode.code,
|
||||||
|
bird: normalizeBird(sourceBird),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/api/bird-transfer-codes/:code/accept',
|
'/api/bird-transfer-codes/:code/accept',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -3362,6 +3785,63 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/birds/:birdId/reports/adoption',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireSessionAuth,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!sourceBird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(sourceBird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
|
||||||
|
|
||||||
|
if (!transferCode) {
|
||||||
|
throw new Error('Unable to create bird transfer code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await adoptionReportQueueEvents.waitUntilReady();
|
||||||
|
const reportJob = await enqueueAdoptionReportJob({
|
||||||
|
birdId: sourceBird.id,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
transferCode: transferCode.code,
|
||||||
|
printFriendly: req.query.printFriendly === 'true',
|
||||||
|
});
|
||||||
|
const reportResult = await reportJob.waitUntilFinished(adoptionReportQueueEvents, adoptionReportRenderTimeoutMs);
|
||||||
|
const pdf = Buffer.from(reportResult.pdfBase64, 'base64');
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, {
|
||||||
|
transferCodeId: transferCode.id,
|
||||||
|
printFriendly: req.query.printFriendly === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeName = sourceBird.name
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/gi, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.toLowerCase() || 'bird';
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="flockpal-adoption-report-${safeName}.pdf"`);
|
||||||
|
res.setHeader('Content-Length', pdf.length.toString());
|
||||||
|
res.setHeader('X-FlockPal-Transfer-Code', transferCode.code);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
@@ -3589,6 +4069,61 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
'/api/birds/:birdId/weights/:weightId',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = weightSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = await updateWeightForBird(
|
||||||
|
req.params.weightId,
|
||||||
|
req.params.birdId,
|
||||||
|
parsed.data.weightGrams,
|
||||||
|
parsed.data.recordedOn,
|
||||||
|
emptyToNull(parsed.data.notes),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!weight) {
|
||||||
|
res.status(404).json({ error: 'Weight entry not found or no longer editable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'weight.updated', 'weight', weight.id, bird.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
weightGrams: parsed.data.weightGrams,
|
||||||
|
recordedOn: parsed.data.recordedOn,
|
||||||
|
});
|
||||||
|
res.json({ weight: normalizeWeight(weight) });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
|
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
|
||||||
@@ -3750,6 +4285,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
|
parsed.data.remindersEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||||
@@ -3793,6 +4329,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
|
parsed.data.remindersEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!medication) {
|
if (!medication) {
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
notes VARCHAR(1000),
|
notes VARCHAR(1000),
|
||||||
|
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
);
|
);
|
||||||
@@ -444,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE medications
|
||||||
|
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -477,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
scheduled_on DATE NOT NULL,
|
||||||
|
administration_slot VARCHAR(80) NOT NULL,
|
||||||
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (medication_id, scheduled_on, administration_slot)
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||||
|
|
||||||
@@ -495,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
||||||
|
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { db } from './db/client.js';
|
||||||
|
import { closeBirdMilestoneReminderQueue, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
|
||||||
|
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS ?? 5_000);
|
||||||
|
|
||||||
|
const withTimeout = async <T>(operation: Promise<T>, label: string): Promise<T> => {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkHttp = async (path: string) => {
|
||||||
|
const port = process.env.PORT ?? '5000';
|
||||||
|
const response = await withTimeout(fetch(`http://127.0.0.1:${port}${path}`), path);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${path} returned ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkWorkerDependencies = async () => {
|
||||||
|
await withTimeout(db.query('SELECT 1'), 'postgres');
|
||||||
|
await withTimeout(getBirdMilestoneReminderQueueCounts(), 'redis');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = process.argv[2] ?? 'api-ready';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'api-live') {
|
||||||
|
await checkHttp('/api/health/live');
|
||||||
|
} else if (mode === 'api-ready') {
|
||||||
|
await checkHttp('/api/health/ready');
|
||||||
|
} else if (mode === 'worker') {
|
||||||
|
await checkWorkerDependencies();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown healthcheck mode: ${mode}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled([closeBirdMilestoneReminderQueue(), db.close()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Queue, QueueEvents, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type AdoptionReportJobData = {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
transferCode: string;
|
||||||
|
printFriendly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdoptionReportJobResult = {
|
||||||
|
pdfBase64: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adoptionReportQueueName = 'adoption-reports';
|
||||||
|
|
||||||
|
export const adoptionReportQueue = new Queue<AdoptionReportJobData, AdoptionReportJobResult>(adoptionReportQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 10_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adoptionReportQueueEvents = new QueueEvents(adoptionReportQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueAdoptionReportJob = (
|
||||||
|
data: AdoptionReportJobData,
|
||||||
|
): Promise<Job<AdoptionReportJobData, AdoptionReportJobResult>> => adoptionReportQueue.add('render-adoption-report', data);
|
||||||
|
|
||||||
|
export const closeAdoptionReportQueue = async () => {
|
||||||
|
await adoptionReportQueue.close();
|
||||||
|
await adoptionReportQueueEvents.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Queue, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type MedicationReminderJobData = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
requestedBy: 'scheduler';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderJobResult = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
checked: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const medicationReminderQueueName = 'medication-reminders';
|
||||||
|
|
||||||
|
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 60_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueMedicationReminderJob = (
|
||||||
|
runDate: string,
|
||||||
|
currentTime: string,
|
||||||
|
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
|
||||||
|
medicationReminderQueue.add(
|
||||||
|
'run-medication-reminders',
|
||||||
|
{
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
requestedBy: 'scheduler',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const closeMedicationReminderQueue = async () => {
|
||||||
|
await medicationReminderQueue.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow } from '../types.js';
|
||||||
|
|
||||||
|
type AdoptionReportInput = {
|
||||||
|
bird: BirdRow;
|
||||||
|
weights: WeightRow[];
|
||||||
|
vetVisits: VetVisitRow[];
|
||||||
|
notes: FlockNoteRow[];
|
||||||
|
transferCode: string;
|
||||||
|
birdPhotoBuffer?: Buffer | null;
|
||||||
|
assets: {
|
||||||
|
logoPath: string;
|
||||||
|
wordmarkPath: string;
|
||||||
|
defaultBirdPhotoPath: string;
|
||||||
|
};
|
||||||
|
printFriendly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = { width: 612, height: 792, margin: 42 };
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
ink: '#1f2a2a',
|
||||||
|
muted: '#5d5f59',
|
||||||
|
red: '#cb3a35',
|
||||||
|
green: '#238a5a',
|
||||||
|
blue: '#2769b3',
|
||||||
|
border: '#cfe0d5',
|
||||||
|
panel: '#fbf7ee',
|
||||||
|
paper: '#fffdf9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not recorded';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(
|
||||||
|
new Date(`${value.slice(0, 10)}T00:00:00Z`),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not recorded';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShortDate = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'No data yet';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWeight = (value: string | number | null) => {
|
||||||
|
const numericValue = value === null ? null : Number(value);
|
||||||
|
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const genderLabel = (value: string) => {
|
||||||
|
if (value === 'female_dna') {
|
||||||
|
return 'Female (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (value === 'male_dna') {
|
||||||
|
return 'Male (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (value === 'female') {
|
||||||
|
return 'Female (assumed)';
|
||||||
|
}
|
||||||
|
if (value === 'male') {
|
||||||
|
return 'Male (assumed)';
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseList = (value: string | null) =>
|
||||||
|
(value ?? '')
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const dataUrlToBuffer = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/);
|
||||||
|
return match ? Buffer.from(match[1], 'base64') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectPdf = (doc: PDFKit.PDFDocument) =>
|
||||||
|
new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => {
|
||||||
|
doc.text(text, x, y, { width, lineGap: 1.5, ...options });
|
||||||
|
return doc.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10);
|
||||||
|
const textHeight = doc.heightOfString(value, {
|
||||||
|
width: width - 16,
|
||||||
|
lineGap: 1,
|
||||||
|
});
|
||||||
|
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
|
||||||
|
const cardHeight = height ?? measureFactHeight(doc, value, width);
|
||||||
|
doc.roundedRect(x, y, width, cardHeight, 6).fillAndStroke(colors.panel, colors.border);
|
||||||
|
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
||||||
|
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, {
|
||||||
|
width: width - 16,
|
||||||
|
height: cardHeight - 27,
|
||||||
|
lineGap: 1,
|
||||||
|
ellipsis: true,
|
||||||
|
});
|
||||||
|
return cardHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
|
||||||
|
doc.roundedRect(x, y, width, height, 6).fillAndStroke(colors.panel, colors.border);
|
||||||
|
doc.fillColor(colors.blue).fontSize(8).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
||||||
|
doc.fillColor(colors.ink).fontSize(9.2).font('Helvetica').text(value, x + 8, y + 23, {
|
||||||
|
width: width - 16,
|
||||||
|
height: height - 31,
|
||||||
|
ellipsis: true,
|
||||||
|
lineGap: 1.2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSectionTitle = (doc: PDFKit.PDFDocument, title: string, y: number) => {
|
||||||
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(14).text(title, page.margin, y);
|
||||||
|
doc.moveTo(page.margin, y + 19).lineTo(page.width - page.margin, y + 19).strokeColor(colors.border).lineWidth(1).stroke();
|
||||||
|
return y + 27;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], birdColor: string, x: number, y: number, width: number, height: number) => {
|
||||||
|
const plottedWeights = weights
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.recorded_on.localeCompare(right.recorded_on))
|
||||||
|
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
|
||||||
|
.filter((entry) => Number.isFinite(entry.numericWeight));
|
||||||
|
|
||||||
|
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#fffdf9', colors.border);
|
||||||
|
|
||||||
|
if (!plottedWeights.length) {
|
||||||
|
doc.fillColor(colors.muted).fontSize(10).text('Add more weight records to show a trend graph.', x + 14, y + height / 2 - 6, {
|
||||||
|
width: width - 28,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
|
const earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
|
const startDate = new Date(latestDate);
|
||||||
|
startDate.setUTCDate(startDate.getUTCDate() - 13);
|
||||||
|
if (earliestDate > startDate) {
|
||||||
|
startDate.setTime(earliestDate.getTime());
|
||||||
|
}
|
||||||
|
const visibleWeights = plottedWeights.filter((entry) => {
|
||||||
|
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
|
return recordedOn >= startDate && recordedOn <= latestDate;
|
||||||
|
});
|
||||||
|
const rawMinWeight = Math.min(...visibleWeights.map((entry) => entry.numericWeight));
|
||||||
|
const rawMaxWeight = Math.max(...visibleWeights.map((entry) => entry.numericWeight));
|
||||||
|
const rangePadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
||||||
|
const minWeight = Math.max(0, rawMinWeight - rangePadding);
|
||||||
|
const maxWeight = rawMaxWeight + rangePadding;
|
||||||
|
const weightRange = Math.max(1, maxWeight - minWeight);
|
||||||
|
const padding = { top: 16, right: 18, bottom: 32, left: 48 };
|
||||||
|
const plotWidth = width - padding.left - padding.right;
|
||||||
|
const plotHeight = height - padding.top - padding.bottom;
|
||||||
|
const startMs = startDate.getTime();
|
||||||
|
const endMs = latestDate.getTime();
|
||||||
|
const dateRange = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
|
||||||
|
const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green;
|
||||||
|
const midWeight = minWeight + (maxWeight - minWeight) / 2;
|
||||||
|
const midDate = new Date((startMs + endMs) / 2);
|
||||||
|
const yTicks = [
|
||||||
|
{ label: `${maxWeight.toFixed(0)} g`, y: y + padding.top },
|
||||||
|
{ label: `${midWeight.toFixed(0)} g`, y: y + padding.top + plotHeight / 2 },
|
||||||
|
{ label: `${minWeight.toFixed(0)} g`, y: y + padding.top + plotHeight },
|
||||||
|
];
|
||||||
|
const xTicks = [
|
||||||
|
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: x + padding.left },
|
||||||
|
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth / 2 },
|
||||||
|
{ label: formatShortDate(latestDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth },
|
||||||
|
];
|
||||||
|
|
||||||
|
const points = visibleWeights.map((entry) => {
|
||||||
|
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
x: x + padding.left + ((recordedOn.getTime() - startMs) / dateRange) * plotWidth,
|
||||||
|
y: y + padding.top + (1 - (entry.numericWeight - minWeight) / weightRange) * plotHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.font('Helvetica').fontSize(7).fillColor(colors.muted);
|
||||||
|
yTicks.forEach((tick) => {
|
||||||
|
doc.text(tick.label, x + 4, tick.y - 3, { width: padding.left - 12, align: 'right' });
|
||||||
|
doc
|
||||||
|
.save()
|
||||||
|
.dash(4, { space: 6 })
|
||||||
|
.strokeColor('#d8e5ef')
|
||||||
|
.lineWidth(0.8)
|
||||||
|
.moveTo(x + padding.left, tick.y)
|
||||||
|
.lineTo(x + width - padding.right, tick.y)
|
||||||
|
.stroke()
|
||||||
|
.restore();
|
||||||
|
});
|
||||||
|
doc.strokeColor('#c7cdca').lineWidth(1).moveTo(x + padding.left, y + padding.top + plotHeight).lineTo(x + width - padding.right, y + padding.top + plotHeight).stroke();
|
||||||
|
xTicks.forEach((tick) => {
|
||||||
|
doc.fillColor(colors.muted).fontSize(7).text(tick.label, tick.x - 28, y + height - 18, { width: 56, align: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
points.forEach((entry, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
doc.moveTo(entry.x, entry.y);
|
||||||
|
} else {
|
||||||
|
doc.lineTo(entry.x, entry.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (points.length > 1) {
|
||||||
|
doc.lineCap('round').strokeColor(chartColor).lineWidth(2.4).stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
points.forEach((entry) => {
|
||||||
|
doc.circle(entry.x, entry.y, 3.5).fillAndStroke(chartColor, '#fffdf9');
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestPoint = points[points.length - 1];
|
||||||
|
const calloutOnLeft = latestPoint.x > x + width - padding.right - 84;
|
||||||
|
const calloutX = calloutOnLeft ? latestPoint.x - 82 : latestPoint.x + 8;
|
||||||
|
const calloutY = latestPoint.y < y + padding.top + 18 ? latestPoint.y + 8 : latestPoint.y - 22;
|
||||||
|
doc.roundedRect(calloutX, calloutY, 74, 18, 5).fillAndStroke('#fffdf9', '#d9dedb');
|
||||||
|
doc.fillColor(colors.ink).font('Helvetica-Bold').fontSize(7.5).text(`Latest ${formatWeight(latestPoint.numericWeight)}`, calloutX + 5, calloutY + 5, {
|
||||||
|
width: 64,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawTable = (doc: PDFKit.PDFDocument, headers: string[], rows: string[][], x: number, y: number, widths: number[], rowHeight = 28) => {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(8).fillColor(colors.muted);
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
doc.text(header.toUpperCase(), x + widths.slice(0, index).reduce((sum, value) => sum + value, 0), y, { width: widths[index] - 8 });
|
||||||
|
});
|
||||||
|
y += 15;
|
||||||
|
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
||||||
|
|
||||||
|
doc.font('Helvetica').fontSize(8.5).fillColor(colors.ink);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (y + rowHeight > page.height - page.margin) {
|
||||||
|
doc.addPage();
|
||||||
|
y = page.margin;
|
||||||
|
}
|
||||||
|
row.forEach((value, index) => {
|
||||||
|
doc.text(value, x + widths.slice(0, index).reduce((sum, columnWidth) => sum + columnWidth, 0), y, {
|
||||||
|
width: widths[index] - 8,
|
||||||
|
height: rowHeight - 6,
|
||||||
|
ellipsis: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
y += rowHeight;
|
||||||
|
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
return y + 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderAdoptionReportPdf = async ({
|
||||||
|
bird,
|
||||||
|
weights,
|
||||||
|
vetVisits,
|
||||||
|
notes,
|
||||||
|
transferCode,
|
||||||
|
birdPhotoBuffer = null,
|
||||||
|
assets,
|
||||||
|
printFriendly = false,
|
||||||
|
}: AdoptionReportInput) => {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'LETTER',
|
||||||
|
margin: page.margin,
|
||||||
|
info: { Title: `FlockPal Adoption Report - ${bird.name}`, Author: 'FlockPal', Subject: `Adoption report for ${bird.name}` },
|
||||||
|
});
|
||||||
|
const output = collectPdf(doc);
|
||||||
|
|
||||||
|
if (!printFriendly) {
|
||||||
|
doc.rect(0, 0, page.width, page.height).fill(colors.paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoPath = fs.existsSync(assets.logoPath) ? assets.logoPath : null;
|
||||||
|
const wordmarkPath = fs.existsSync(assets.wordmarkPath) ? assets.wordmarkPath : logoPath;
|
||||||
|
const defaultPhotoPath = fs.existsSync(assets.defaultBirdPhotoPath) ? assets.defaultBirdPhotoPath : null;
|
||||||
|
const photoBuffer = birdPhotoBuffer ?? dataUrlToBuffer(bird.photo_data_url);
|
||||||
|
const contentWidth = page.width - page.margin * 2;
|
||||||
|
const headerY = page.margin;
|
||||||
|
const headerHeight = 136;
|
||||||
|
|
||||||
|
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
|
||||||
|
if (logoPath) {
|
||||||
|
doc.image(logoPath, page.margin + 10, headerY + 18, { fit: [92, 84], align: 'center', valign: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoX = page.margin + 235;
|
||||||
|
const photoY = headerY + 13;
|
||||||
|
if (photoBuffer) {
|
||||||
|
doc.image(photoBuffer, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
||||||
|
} else if (defaultPhotoPath) {
|
||||||
|
doc.image(defaultPhotoPath, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
||||||
|
}
|
||||||
|
doc.roundedRect(photoX, photoY, 58, 58, 10).strokeColor('#ffffff').lineWidth(2).stroke();
|
||||||
|
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(22).text(bird.name, page.margin + 140, headerY + 75, { width: 250, align: 'center' });
|
||||||
|
doc.fillColor(colors.muted).font('Helvetica').fontSize(9).text('Adoption Report', page.margin + 140, headerY + 98, { width: 250, align: 'center' });
|
||||||
|
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
|
||||||
|
const qrBuffer = dataUrlToBuffer(qrDataUrl);
|
||||||
|
const qrX = page.width - page.margin - 132;
|
||||||
|
const qrWidth = 124;
|
||||||
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 7, { width: qrWidth, align: 'center' });
|
||||||
|
if (wordmarkPath) {
|
||||||
|
doc.image(wordmarkPath, qrX + 7, headerY + 18, { fit: [110, 34], align: 'center', valign: 'center' });
|
||||||
|
}
|
||||||
|
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(7.5).text('Keep my story growing', qrX, headerY + 51, {
|
||||||
|
width: qrWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
if (qrBuffer) {
|
||||||
|
doc.image(qrBuffer, qrX + 37, headerY + 62, { width: 50 });
|
||||||
|
}
|
||||||
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(6.8).text('Scan to continue tracking in FlockPal', qrX, headerY + 114, {
|
||||||
|
width: qrWidth,
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
doc.fillColor(colors.ink).font('Helvetica').fontSize(6.5).text(transferCode, qrX, headerY + 126, { width: qrWidth, align: 'center' });
|
||||||
|
|
||||||
|
let y = headerY + headerHeight + 16;
|
||||||
|
const factGap = 8;
|
||||||
|
const factWidth = (contentWidth - factGap) / 2;
|
||||||
|
const facts = [
|
||||||
|
['Species', bird.species],
|
||||||
|
['Band/tag ID', bird.tag_id || 'Not recorded'],
|
||||||
|
['Sex', genderLabel(bird.gender)],
|
||||||
|
['Hatch day', formatDate(bird.date_of_birth)],
|
||||||
|
['Favorite snack', bird.favorite_snack || 'Not recorded'],
|
||||||
|
['Latest weight', bird.latest_weight_grams ? `${formatWeight(bird.latest_weight_grams)}${bird.latest_recorded_on ? ` on ${formatDate(bird.latest_recorded_on)}` : ''}` : 'Pending'],
|
||||||
|
];
|
||||||
|
facts.forEach(([label, value], index) => {
|
||||||
|
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
||||||
|
});
|
||||||
|
y += Math.ceil(facts.length / 2) * 50 + 8;
|
||||||
|
|
||||||
|
const motivators = parseList(bird.motivators);
|
||||||
|
const demotivators = parseList(bird.demotivators);
|
||||||
|
drawTextCard(doc, 'Motivators', motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y, factWidth);
|
||||||
|
drawTextCard(
|
||||||
|
doc,
|
||||||
|
'Demotivators',
|
||||||
|
demotivators.length ? demotivators.join(', ') : 'Not recorded',
|
||||||
|
page.margin + factWidth + factGap,
|
||||||
|
y,
|
||||||
|
factWidth,
|
||||||
|
);
|
||||||
|
y += 72;
|
||||||
|
|
||||||
|
if (y > 610) {
|
||||||
|
doc.addPage();
|
||||||
|
y = page.margin;
|
||||||
|
}
|
||||||
|
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
||||||
|
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
|
||||||
|
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
|
||||||
|
y += 50;
|
||||||
|
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
|
||||||
|
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
|
||||||
|
y += clinicAddressHeight + 7;
|
||||||
|
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
|
||||||
|
y += 50;
|
||||||
|
|
||||||
|
y = drawSectionTitle(doc, 'Vet Visit History', y);
|
||||||
|
y = drawTable(
|
||||||
|
doc,
|
||||||
|
['Date', 'Clinic', 'Reason', 'Notes'],
|
||||||
|
vetVisits.length ? vetVisits.map((visit) => [formatDate(visit.visited_on), visit.clinic_name, visit.reason, visit.notes || '']) : [['No vet visits recorded.', '', '', '']],
|
||||||
|
page.margin,
|
||||||
|
y,
|
||||||
|
[70, 115, 120, contentWidth - 305],
|
||||||
|
28,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (y > 575) {
|
||||||
|
doc.addPage();
|
||||||
|
y = page.margin;
|
||||||
|
}
|
||||||
|
y = drawSectionTitle(doc, 'Weight Graph', y);
|
||||||
|
drawSimpleWeightChart(doc, weights, bird.chart_color, page.margin, y, contentWidth, 120);
|
||||||
|
y += 140;
|
||||||
|
|
||||||
|
y = drawSectionTitle(doc, 'Weight History', y);
|
||||||
|
y = drawTable(
|
||||||
|
doc,
|
||||||
|
['Date', 'Weight', 'Notes'],
|
||||||
|
weights.length ? weights.map((entry) => [formatDate(entry.recorded_on), formatWeight(entry.weight_grams), entry.notes || '']) : [['No weights recorded.', '', '']],
|
||||||
|
page.margin,
|
||||||
|
y,
|
||||||
|
[95, 70, contentWidth - 165],
|
||||||
|
24,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (notes.length) {
|
||||||
|
if (y > 635) {
|
||||||
|
doc.addPage();
|
||||||
|
y = page.margin;
|
||||||
|
}
|
||||||
|
y = drawSectionTitle(doc, 'Notes', y);
|
||||||
|
notes.slice(0, 8).forEach((note) => {
|
||||||
|
if (y > page.height - page.margin - 48) {
|
||||||
|
doc.addPage();
|
||||||
|
y = page.margin;
|
||||||
|
}
|
||||||
|
doc.fillColor(colors.muted).font('Helvetica-Bold').fontSize(8).text(formatDateTime(note.updated_at), page.margin, y);
|
||||||
|
y = fitText(doc, note.body, page.margin, y + 12, contentWidth, { height: 44, ellipsis: true });
|
||||||
|
y += 8;
|
||||||
|
doc.moveTo(page.margin, y).lineTo(page.width - page.margin, y).strokeColor(colors.border).stroke();
|
||||||
|
y += 8;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return output;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBirdById,
|
||||||
|
listVetVisitsForBird,
|
||||||
|
listWeightsForBird,
|
||||||
|
} from '../repositories/birdRepository.js';
|
||||||
|
import { listFlockNotes } from '../repositories/auditRepository.js';
|
||||||
|
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
|
||||||
|
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
|
||||||
|
import type { BirdRow } from '../types.js';
|
||||||
|
import { renderAdoptionReportPdf } from './adoptionReport.js';
|
||||||
|
|
||||||
|
const adoptionReportWeightHistoryDays = 14;
|
||||||
|
|
||||||
|
const parseDataImage = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,(.+)$/);
|
||||||
|
return match ? Buffer.from(match[1], 'base64') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeReportPhotoBuffer = async (imageBuffer: Buffer | null) => {
|
||||||
|
if (!imageBuffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await sharp(imageBuffer).rotate().png().toBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to normalize bird photo for adoption report:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
|
||||||
|
if (!bird.photo_object_key) {
|
||||||
|
return normalizeReportPhotoBuffer(parseDataImage(bird.photo_data_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = getSignedS3ObjectUrl({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey: bird.photo_object_key,
|
||||||
|
expiresInSeconds: 5 * 60,
|
||||||
|
});
|
||||||
|
const imageResponse = await fetch(signedUrl);
|
||||||
|
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeReportPhotoBuffer(Buffer.from(await imageResponse.arrayBuffer()));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderAdoptionReportForBird = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
transferCode,
|
||||||
|
printFriendly,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
transferCode: string;
|
||||||
|
printFriendly: boolean;
|
||||||
|
}) => {
|
||||||
|
const bird = await getBirdById(birdId, workspaceId);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
throw new Error('Bird not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([
|
||||||
|
listWeightsForBird(bird.id, workspaceId, adoptionReportWeightHistoryDays),
|
||||||
|
listVetVisitsForBird(bird.id, workspaceId),
|
||||||
|
listFlockNotes(workspaceId),
|
||||||
|
loadBirdReportPhotoBuffer(bird),
|
||||||
|
]);
|
||||||
|
const birdNotes = notes.filter((note) => note.bird_id === bird.id);
|
||||||
|
|
||||||
|
return renderAdoptionReportPdf({
|
||||||
|
bird,
|
||||||
|
weights,
|
||||||
|
vetVisits,
|
||||||
|
notes: birdNotes,
|
||||||
|
transferCode,
|
||||||
|
birdPhotoBuffer,
|
||||||
|
printFriendly,
|
||||||
|
assets: {
|
||||||
|
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
|
||||||
|
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
|
||||||
|
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
createBird,
|
createBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
|
getOpenBirdTransferCode,
|
||||||
|
getOpenBirdTransferCodeForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
|
markBirdTransferCodeCompleted,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
} from './birdRepository.js';
|
} from './birdRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
@@ -198,3 +201,36 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
||||||
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
const transferCode = await getOpenBirdTransferCode('ADOPT-123');
|
||||||
|
|
||||||
|
assert.equal(transferCode, null);
|
||||||
|
assert.deepEqual(calls[0].params, ['ADOPT-123']);
|
||||||
|
assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOpenBirdTransferCodeForBird ignores consumed codes', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
const transferCode = await getOpenBirdTransferCodeForBird('bird-1', 10);
|
||||||
|
|
||||||
|
assert.equal(transferCode, null);
|
||||||
|
assert.deepEqual(calls[0].params, ['bird-1', 10]);
|
||||||
|
assert.match(calls[0].text, /completed_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /revoked_at IS NULL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
await markBirdTransferCodeCompleted('code-1', 22);
|
||||||
|
|
||||||
|
assert.deepEqual(calls[0].params, ['code-1', 22]);
|
||||||
|
assert.match(calls[0].text, /SET completed_at = CURRENT_TIMESTAMP/);
|
||||||
|
assert.match(calls[0].text, /completed_workspace_id = \$2/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationReminderDeliveryRow,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
||||||
|
const result = await db.query<MedicationReminderCandidateRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
medications.id AS medication_id,
|
||||||
|
medications.name AS medication_name,
|
||||||
|
medications.dosage,
|
||||||
|
medications.frequency,
|
||||||
|
medications.dose_schedule,
|
||||||
|
medications.route,
|
||||||
|
medications.start_date::text AS medication_start_date,
|
||||||
|
medications.end_date::text AS medication_end_date,
|
||||||
|
medications.notes AS medication_notes,
|
||||||
|
$1::date::text AS scheduled_on,
|
||||||
|
dose.key AS administration_slot,
|
||||||
|
dose.label AS administration_label,
|
||||||
|
dose.time AS administration_time
|
||||||
|
FROM medications
|
||||||
|
INNER JOIN birds ON birds.id = medications.bird_id
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE medications.reminders_enabled = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
|
AND medications.start_date <= $1::date
|
||||||
|
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
||||||
|
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
||||||
|
AND dose.time <= $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM medication_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.medication_id = medications.id
|
||||||
|
AND deliveries.scheduled_on = $1::date
|
||||||
|
AND deliveries.administration_slot = dose.key
|
||||||
|
)
|
||||||
|
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
||||||
|
[runDate, currentTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMedicationReminderDelivery = async ({
|
||||||
|
medicationId,
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
scheduledOn,
|
||||||
|
administrationSlot,
|
||||||
|
}: {
|
||||||
|
medicationId: string;
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
scheduledOn: string;
|
||||||
|
administrationSlot: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<MedicationReminderDeliveryRow>(
|
||||||
|
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
||||||
|
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
||||||
|
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -734,6 +809,22 @@ export const createBirdTransferCode = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => {
|
||||||
|
const result = await db.query<BirdTransferCodeRow>(
|
||||||
|
`SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at
|
||||||
|
FROM bird_transfer_codes
|
||||||
|
WHERE bird_id = $1
|
||||||
|
AND source_workspace_id = $2
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[birdId, sourceWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const getOpenBirdTransferCode = async (code: string) => {
|
export const getOpenBirdTransferCode = async (code: string) => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
BirdRow & {
|
BirdRow & {
|
||||||
@@ -820,6 +911,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWeightForBird = async (
|
||||||
|
weightId: string,
|
||||||
|
birdId: string,
|
||||||
|
weightGrams: number,
|
||||||
|
recordedOn: string,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<WeightRow>(
|
||||||
|
`UPDATE weight_records
|
||||||
|
SET weight_grams = $3,
|
||||||
|
recorded_on = $4,
|
||||||
|
notes = $5
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
AND id IN (
|
||||||
|
SELECT recent.id
|
||||||
|
FROM weight_records recent
|
||||||
|
WHERE recent.bird_id = $2
|
||||||
|
ORDER BY recent.recorded_on DESC, recent.created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
)
|
||||||
|
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
||||||
|
[weightId, birdId, weightGrams, recordedOn, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<VetVisitRow>(
|
const result = await db.query<VetVisitRow>(
|
||||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||||
@@ -886,7 +1005,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
||||||
FROM medications
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -912,12 +1031,13 @@ export const createMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
|
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -934,6 +1054,7 @@ export const updateMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`UPDATE medications
|
`UPDATE medications
|
||||||
@@ -944,11 +1065,12 @@ export const updateMedicationForBird = async (
|
|||||||
route = $7,
|
route = $7,
|
||||||
start_date = $8,
|
start_date = $8,
|
||||||
end_date = $9,
|
end_date = $9,
|
||||||
notes = $10
|
notes = $10,
|
||||||
|
reminders_enabled = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensureDefaultWorkspaceForUser,
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
} from './workspaceRepository.js';
|
} from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
@@ -259,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
|
|||||||
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'viewer',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'viewer');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /UPDATE workspace_members/);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'owner-member',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'owner',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'owner');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'member-1' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'other-owner' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||||
const { calls } = mockDb({
|
const { calls } = mockDb({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
|
|||||||
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
export const deleteWorkspaceMember = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
}) => {
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM workspace_members
|
`DELETE FROM workspace_members
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND role <> 'owner'
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
$3 = TRUE
|
||||||
|
AND id <> $4
|
||||||
|
)
|
||||||
|
)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[memberId, workspaceId],
|
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWorkspaceMemberRole = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
role,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
requesterRole,
|
||||||
|
billingEmail,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
role: WorkspaceMemberRow['role'];
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
requesterRole: WorkspaceMemberRow['role'];
|
||||||
|
billingEmail: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<WorkspaceMemberRow>(
|
||||||
|
`UPDATE workspace_members
|
||||||
|
SET role = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND (
|
||||||
|
$3 <> 'owner'
|
||||||
|
OR $4 = TRUE
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
id <> $5
|
||||||
|
AND (
|
||||||
|
$4 = TRUE
|
||||||
|
OR (
|
||||||
|
$7 = 'owner'
|
||||||
|
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
||||||
|
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
|
|||||||
+29
-1
@@ -6,7 +6,7 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
|
|||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
export type BirdGender = 'unknown' | 'male' | 'female';
|
export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -202,6 +202,7 @@ export type MedicationRow = {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
reminders_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationDoseScheduleItem = {
|
export type MedicationDoseScheduleItem = {
|
||||||
@@ -210,6 +211,33 @@ export type MedicationDoseScheduleItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderCandidateRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
medication_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
dose_schedule: MedicationDoseScheduleItem[];
|
||||||
|
route: string | null;
|
||||||
|
medication_start_date: string;
|
||||||
|
medication_end_date: string | null;
|
||||||
|
medication_notes: string | null;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
administration_label: string;
|
||||||
|
administration_time: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderDeliveryRow = {
|
||||||
|
id: string;
|
||||||
|
medication_id: string;
|
||||||
|
bird_id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
delivered_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
|
|||||||
+64
-1
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
|
|||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
import {
|
||||||
|
runBirdMilestoneReminders,
|
||||||
|
runMedicationReminders,
|
||||||
|
startBirdMilestoneReminderScheduler,
|
||||||
|
startMedicationReminderScheduler,
|
||||||
|
} from './app.js';
|
||||||
|
import {
|
||||||
|
adoptionReportQueueName,
|
||||||
|
closeAdoptionReportQueue,
|
||||||
|
type AdoptionReportJobData,
|
||||||
|
type AdoptionReportJobResult,
|
||||||
|
} from './queues/adoptionReportQueue.js';
|
||||||
import {
|
import {
|
||||||
birdMilestoneReminderQueueName,
|
birdMilestoneReminderQueueName,
|
||||||
closeBirdMilestoneReminderQueue,
|
closeBirdMilestoneReminderQueue,
|
||||||
type BirdMilestoneReminderJobData,
|
type BirdMilestoneReminderJobData,
|
||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import {
|
||||||
|
closeMedicationReminderQueue,
|
||||||
|
medicationReminderQueueName,
|
||||||
|
type MedicationReminderJobData,
|
||||||
|
type MedicationReminderJobResult,
|
||||||
|
} from './queues/medicationReminderQueue.js';
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
|
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||||
|
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
@@ -35,14 +55,57 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
||||||
|
medicationReminderQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
||||||
|
console.log(
|
||||||
|
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
medicationReminderWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||||
|
adoptionReportQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const pdf = await renderAdoptionReportForBird(job.data);
|
||||||
|
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
|
||||||
|
return {
|
||||||
|
pdfBase64: pdf.toString('base64'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
adoptionReportWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
startBirdMilestoneReminderScheduler();
|
startBirdMilestoneReminderScheduler();
|
||||||
|
startMedicationReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
|
await medicationReminderWorker?.close();
|
||||||
|
await adoptionReportWorker?.close();
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
|
await closeMedicationReminderQueue();
|
||||||
|
await closeAdoptionReportQueue();
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -58,6 +59,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -94,6 +96,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
@@ -138,6 +146,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
@@ -151,6 +160,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -162,6 +177,12 @@ services:
|
|||||||
container_name: flockpal-frontend
|
container_name: flockpal-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -56,6 +57,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -131,6 +133,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
+38
-5
@@ -212,7 +212,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
@@ -299,7 +299,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
- `workspaceType` is `standard` or `rescue`
|
- `workspaceType` is `standard` or `rescue`
|
||||||
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
||||||
- bird `gender` is `unknown`, `male`, or `female`
|
- bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex
|
||||||
- bird `chartColor` must be a `#RRGGBB` hex color
|
- bird `chartColor` must be a `#RRGGBB` hex color
|
||||||
- `photoDataUrl` must be a base64 `data:image/...` URL
|
- `photoDataUrl` must be a base64 `data:image/...` URL
|
||||||
- `weightGrams` must be a positive number up to `10000`
|
- `weightGrams` must be a positive number up to `10000`
|
||||||
@@ -319,14 +319,47 @@ Validation failures return `400` with this shape:
|
|||||||
|
|
||||||
#### `GET /api/health`
|
#### `GET /api/health`
|
||||||
|
|
||||||
Public health check.
|
Public readiness-compatible health check. Verifies backend dependencies.
|
||||||
|
|
||||||
Response `200`:
|
Response `200`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok": true }
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "flockpal-backend",
|
||||||
|
"status": "ready",
|
||||||
|
"checkedAt": "2026-06-06T00:00:00.000Z",
|
||||||
|
"dependencies": {
|
||||||
|
"postgres": { "ok": true, "latencyMs": 3 },
|
||||||
|
"redis": { "ok": true, "latencyMs": 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Response `503` when Postgres or Redis is unavailable.
|
||||||
|
|
||||||
|
#### `GET /api/health/live`
|
||||||
|
|
||||||
|
Public liveness check. Verifies the backend process is running without checking dependencies.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "flockpal-backend",
|
||||||
|
"status": "live",
|
||||||
|
"uptimeSeconds": 120,
|
||||||
|
"checkedAt": "2026-06-06T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/health/ready`
|
||||||
|
|
||||||
|
Public readiness check. Verifies the backend can reach Postgres and Redis.
|
||||||
|
|
||||||
|
Response `200` uses the same shape as `GET /api/health`; response `503` means at least one dependency failed.
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
#### `GET /api/metrics`
|
#### `GET /api/metrics`
|
||||||
@@ -801,7 +834,7 @@ Request body:
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
+439
-101
@@ -14,7 +14,7 @@ type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
|||||||
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
type IntegrationTokenScope = 'read_only' | 'read_write';
|
type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
type BirdGender = 'unknown' | 'male' | 'female';
|
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
type Bird = {
|
type Bird = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -75,6 +75,7 @@ type Medication = {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
remindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
|
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
|
||||||
@@ -505,6 +506,14 @@ const parseImportGender = (value: unknown): BirdGender | null => {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (['male dna', 'dna male', 'male_dna', 'dna confirmed male', 'male dna confirmed'].includes(gender)) {
|
||||||
|
return 'male_dna';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['female dna', 'dna female', 'female_dna', 'dna confirmed female', 'female dna confirmed'].includes(gender)) {
|
||||||
|
return 'female_dna';
|
||||||
|
}
|
||||||
|
|
||||||
if (gender === 'male' || gender === 'female') {
|
if (gender === 'male' || gender === 'female') {
|
||||||
return gender;
|
return gender;
|
||||||
}
|
}
|
||||||
@@ -652,6 +661,8 @@ const emptyBirdForm: BirdFormState = {
|
|||||||
publicProfileEnabled: false,
|
publicProfileEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown'];
|
||||||
|
|
||||||
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
|
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
|
||||||
vetClinicName: '',
|
vetClinicName: '',
|
||||||
vetClinicAddress: '',
|
vetClinicAddress: '',
|
||||||
@@ -825,25 +836,89 @@ const formatShortDate = (value: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
|
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
if (bird.gender === 'female_dna') {
|
||||||
|
return 'Female (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (bird.gender === 'male_dna') {
|
||||||
|
return 'Male (DNA confirmed)';
|
||||||
|
}
|
||||||
if (bird.gender === 'female') {
|
if (bird.gender === 'female') {
|
||||||
return 'Female';
|
return 'Female (assumed)';
|
||||||
}
|
}
|
||||||
if (bird.gender === 'male') {
|
if (bird.gender === 'male') {
|
||||||
return 'Male';
|
return 'Male (assumed)';
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
||||||
if (bird.gender === 'female') {
|
if (bird.gender === 'female' || bird.gender === 'female_dna') {
|
||||||
return '♀';
|
return '♀';
|
||||||
}
|
}
|
||||||
if (bird.gender === 'male') {
|
if (bird.gender === 'male' || bird.gender === 'male_dna') {
|
||||||
return '♂';
|
return '♂';
|
||||||
}
|
}
|
||||||
return '?';
|
return '?';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBirdGenderClass = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
if (bird.gender === 'female' || bird.gender === 'female_dna') {
|
||||||
|
return 'female';
|
||||||
|
}
|
||||||
|
if (bird.gender === 'male' || bird.gender === 'male_dna') {
|
||||||
|
return 'male';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDnaConfirmedGender = (bird: Pick<Bird, 'gender'>) => bird.gender === 'male_dna' || bird.gender === 'female_dna';
|
||||||
|
|
||||||
|
const getBirdGenderSourceLabel = (bird: Pick<Bird, 'gender'>) => {
|
||||||
|
if (bird.gender === 'unknown') {
|
||||||
|
return 'Sex unknown';
|
||||||
|
}
|
||||||
|
return isDnaConfirmedGender(bird) ? 'DNA confirmed' : 'Assumed sex';
|
||||||
|
};
|
||||||
|
|
||||||
|
const BirdGenderSourceIcon = ({ bird }: { bird: Pick<Bird, 'gender'> }) => {
|
||||||
|
if (bird.gender === 'unknown') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = getBirdGenderSourceLabel(bird);
|
||||||
|
|
||||||
|
if (isDnaConfirmedGender(bird)) {
|
||||||
|
return (
|
||||||
|
<span className="gender-source-icon dna" aria-label={sourceLabel} title={sourceLabel}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle className="dna-ring" cx="12" cy="12" r="8.7" />
|
||||||
|
<path className="dna-strand" d="M14.9 5.4c-4.6 2.6-6.2 4.6-5.8 7.1.3 2.2 2.1 3.8 5.8 6.1" />
|
||||||
|
<path className="dna-strand" d="M9.1 5.4c4.6 2.6 6.2 4.6 5.8 7.1-.3 2.2-2.1 3.8-5.8 6.1" />
|
||||||
|
<path className="dna-rung" d="M10.4 8.1h3.2" />
|
||||||
|
<path className="dna-rung" d="M9.4 10.7h5.2" />
|
||||||
|
<path className="dna-rung" d="M9.5 13.3h5" />
|
||||||
|
<path className="dna-rung" d="M10.4 15.9h3.2" />
|
||||||
|
<circle className="dna-dot" cx="14.9" cy="5.4" r="0.7" />
|
||||||
|
<circle className="dna-dot" cx="10.4" cy="8.1" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.4" cy="10.7" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.5" cy="13.3" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="10.4" cy="15.9" r="0.85" />
|
||||||
|
<circle className="dna-dot" cx="9.1" cy="18.6" r="0.7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="gender-source-icon assumed" aria-label={sourceLabel} title={sourceLabel}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path className="assumed-eye" d="M3.8 12s3-4.6 8.2-4.6 8.2 4.6 8.2 4.6-3 4.6-8.2 4.6S3.8 12 3.8 12Z" />
|
||||||
|
<circle className="assumed-pupil" cx="12" cy="12" r="2.1" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const escapeReportHtml = (value: string | number | null | undefined) =>
|
const escapeReportHtml = (value: string | number | null | undefined) =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -875,6 +950,8 @@ const formatAuditAction = (value: string) =>
|
|||||||
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
||||||
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
||||||
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
||||||
|
const getEditableWeights = (entries: WeightRecord[]) =>
|
||||||
|
[...entries].sort((left, right) => right.recordedOn.localeCompare(left.recordedOn)).slice(0, 3);
|
||||||
const daysBetweenDates = (startDate: string, endDate: string) =>
|
const daysBetweenDates = (startDate: string, endDate: string) =>
|
||||||
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
||||||
const addYearsToDate = (date: Date, years: number) => {
|
const addYearsToDate = (date: Date, years: number) => {
|
||||||
@@ -1573,6 +1650,7 @@ function App() {
|
|||||||
recordedOn: new Date().toISOString().slice(0, 10),
|
recordedOn: new Date().toISOString().slice(0, 10),
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const [editingWeightId, setEditingWeightId] = useState('');
|
||||||
const [vetVisitForm, setVetVisitForm] = useState({
|
const [vetVisitForm, setVetVisitForm] = useState({
|
||||||
visitedOn: new Date().toISOString().slice(0, 10),
|
visitedOn: new Date().toISOString().slice(0, 10),
|
||||||
clinicName: '',
|
clinicName: '',
|
||||||
@@ -1589,6 +1667,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
const [flockTransferForm, setFlockTransferForm] = useState({
|
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||||
birdId: '',
|
birdId: '',
|
||||||
@@ -1619,6 +1698,7 @@ function App() {
|
|||||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||||
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
||||||
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
||||||
|
const [updatingWorkspaceMemberId, setUpdatingWorkspaceMemberId] = useState('');
|
||||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||||
|
|
||||||
@@ -1626,6 +1706,11 @@ function App() {
|
|||||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||||
[birds, selectedBirdId],
|
[birds, selectedBirdId],
|
||||||
);
|
);
|
||||||
|
const isBillingOwner = Boolean(
|
||||||
|
authSession?.user.email &&
|
||||||
|
workspace?.billingEmail &&
|
||||||
|
authSession.user.email.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
|
||||||
|
);
|
||||||
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
|
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
|
||||||
const editingBird = useMemo(
|
const editingBird = useMemo(
|
||||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||||
@@ -1665,6 +1750,46 @@ function App() {
|
|||||||
setEditingVeterinaryInfo(false);
|
setEditingVeterinaryInfo(false);
|
||||||
}, [selectedBird]);
|
}, [selectedBird]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedBirdId = selectedBird?.id;
|
||||||
|
|
||||||
|
if (!selectedBirdId || !authToken || adoptionTransferCodes[selectedBirdId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
|
const loadOpenTransferCode = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${selectedBirdId}/transfer-code`, authToken);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
(await readJsonSafely<{
|
||||||
|
transferCode?: {
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
}>(response)) ?? {};
|
||||||
|
const code = data.transferCode?.code;
|
||||||
|
|
||||||
|
if (!canceled && code) {
|
||||||
|
setAdoptionTransferCodes((current) => ({ ...current, [selectedBirdId]: code }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transfer codes are optional until a report/code is created.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadOpenTransferCode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [adoptionTransferCodes, authToken, selectedBird?.id]);
|
||||||
|
|
||||||
const overviewWindowStartDate = useMemo(() => {
|
const overviewWindowStartDate = useMemo(() => {
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setHours(0, 0, 0, 0);
|
startDate.setHours(0, 0, 0, 0);
|
||||||
@@ -1679,6 +1804,8 @@ function App() {
|
|||||||
),
|
),
|
||||||
[allBirdWeights, birds, overviewWindowStartDate],
|
[allBirdWeights, birds, overviewWindowStartDate],
|
||||||
);
|
);
|
||||||
|
const editableWeights = useMemo(() => getEditableWeights(weights), [weights]);
|
||||||
|
const editableWeightIds = useMemo(() => new Set(editableWeights.map((weight) => weight.id)), [editableWeights]);
|
||||||
|
|
||||||
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
|
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
|
||||||
|
|
||||||
@@ -3307,8 +3434,9 @@ function App() {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, {
|
const isEditingWeight = Boolean(editingWeightId);
|
||||||
method: 'POST',
|
const response = await apiFetch(isEditingWeight ? `/birds/${selectedBird.id}/weights/${editingWeightId}` : `/birds/${selectedBird.id}/weights`, authToken, {
|
||||||
|
method: isEditingWeight ? 'PUT' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
weightGrams: Number(weightForm.weightGrams),
|
weightGrams: Number(weightForm.weightGrams),
|
||||||
@@ -3325,7 +3453,13 @@ function App() {
|
|||||||
if (!data?.weight) {
|
if (!data?.weight) {
|
||||||
throw new Error('Unable to save weight.');
|
throw new Error('Unable to save weight.');
|
||||||
}
|
}
|
||||||
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
|
const nextWeights = (
|
||||||
|
isEditingWeight ? weights.map((weight) => (weight.id === data.weight.id ? data.weight : weight)) : [...weights, data.weight]
|
||||||
|
).sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
|
||||||
|
const latestWeight = nextWeights.reduce<WeightRecord | null>(
|
||||||
|
(latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
setWeights(nextWeights);
|
setWeights(nextWeights);
|
||||||
setAllBirdWeights((current) => ({
|
setAllBirdWeights((current) => ({
|
||||||
@@ -3341,18 +3475,39 @@ function App() {
|
|||||||
bird.id === selectedBird.id
|
bird.id === selectedBird.id
|
||||||
? {
|
? {
|
||||||
...bird,
|
...bird,
|
||||||
latestWeightGrams: data.weight.weightGrams,
|
latestWeightGrams: latestWeight?.weightGrams ?? null,
|
||||||
latestRecordedOn: data.weight.recordedOn,
|
latestRecordedOn: latestWeight?.recordedOn ?? null,
|
||||||
}
|
}
|
||||||
: bird,
|
: bird,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
||||||
|
setEditingWeightId('');
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditWeight = (weight: WeightRecord) => {
|
||||||
|
if (!editableWeightIds.has(weight.id)) {
|
||||||
|
setError('Only the 3 most recent weight entries can be edited.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingWeightId(weight.id);
|
||||||
|
setWeightForm({
|
||||||
|
weightGrams: String(weight.weightGrams),
|
||||||
|
recordedOn: weight.recordedOn,
|
||||||
|
notes: weight.notes ?? '',
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelWeightEdit = () => {
|
||||||
|
setEditingWeightId('');
|
||||||
|
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
|
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
|
||||||
setBulkWeightRows((current) => ({
|
setBulkWeightRows((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -3613,6 +3768,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
setEditingMedicationId('');
|
setEditingMedicationId('');
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
@@ -3632,6 +3788,7 @@ function App() {
|
|||||||
startDate: medication.startDate,
|
startDate: medication.startDate,
|
||||||
endDate: medication.endDate ?? '',
|
endDate: medication.endDate ?? '',
|
||||||
notes: medication.notes ?? '',
|
notes: medication.notes ?? '',
|
||||||
|
remindersEnabled: medication.remindersEnabled,
|
||||||
});
|
});
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
@@ -3647,6 +3804,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4185,111 +4343,145 @@ function App() {
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
background: ${bodyBackground};
|
background: ${bodyBackground};
|
||||||
|
box-sizing: border-box;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
display: flex;
|
||||||
font-family: Inter, Arial, sans-serif;
|
font-family: Inter, Arial, sans-serif;
|
||||||
line-height: 1.45;
|
font-size: 13px;
|
||||||
margin: 32px;
|
justify-content: center;
|
||||||
|
line-height: 1.38;
|
||||||
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
img, svg { max-width: 100%; }
|
||||||
${backgroundOverlayCss}
|
${backgroundOverlayCss}
|
||||||
|
.report-page {
|
||||||
|
background: ${printFriendly ? '#fff' : 'rgba(255, 253, 249, 0.82)'};
|
||||||
|
border: 1px solid ${printFriendly ? 'transparent' : 'rgba(53, 129, 98, 0.12)'};
|
||||||
|
border-radius: ${printFriendly ? '0' : '18px'};
|
||||||
|
box-shadow: ${printFriendly ? 'none' : '0 18px 42px rgba(86, 63, 34, 0.16)'};
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
width: 720px;
|
||||||
|
}
|
||||||
header {
|
header {
|
||||||
background: ${headerBackground};
|
background: ${headerBackground};
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
|
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.12);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 22px;
|
gap: 10px;
|
||||||
grid-template-columns: 210px 1fr 320px;
|
grid-template-columns: 100px minmax(220px, 1fr) 124px;
|
||||||
min-height: 228px;
|
min-height: 120px;
|
||||||
padding: 18px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
h1, h2, h3, p { margin: 0; }
|
h1, h2, h3, p { margin: 0; }
|
||||||
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
|
h1 { color: var(--red); font-size: 21px; letter-spacing: 0; }
|
||||||
h2 {
|
h2 {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
font-size: 19px;
|
font-size: 16px;
|
||||||
margin: 28px 0 12px;
|
margin: 20px 0 8px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
|
h3 { color: var(--blue); font-size: 12px; margin: 14px 0 6px; text-transform: uppercase; }
|
||||||
.muted { color: var(--muted); margin-top: 6px; }
|
.muted { color: var(--muted); margin-top: 6px; }
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
height: 210px;
|
height: auto;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
|
max-height: 98px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
width: 210px;
|
width: 98px;
|
||||||
}
|
}
|
||||||
.report-title {
|
.report-title {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.report-title .muted { margin-top: 8px; }
|
.report-title .muted { margin-top: 4px; }
|
||||||
.profile-photo {
|
.profile-photo {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 3px solid var(--paper);
|
border: 2px solid var(--paper);
|
||||||
border-radius: 18px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
|
box-shadow: 0 8px 16px rgba(86, 63, 34, 0.14);
|
||||||
height: 132px;
|
height: 70px;
|
||||||
margin: 0 auto 12px;
|
margin: 0 auto 5px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
width: 132px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
.qr { align-self: center; justify-self: end; text-align: center; width: 320px; }
|
.qr { align-self: center; justify-self: end; max-width: 124px; text-align: center; width: 100%; }
|
||||||
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
|
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 9px; padding: 5px; width: 70px; }
|
||||||
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
|
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; overflow-wrap: anywhere; }
|
||||||
.qr-join-label {
|
.qr-join-label {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
font-size: 12px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: -28px;
|
margin-bottom: -10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.qr-wordmark {
|
.qr-wordmark {
|
||||||
display: block;
|
display: block;
|
||||||
height: 150px;
|
height: auto;
|
||||||
margin: -28px auto -12px;
|
margin: -10px auto -4px;
|
||||||
|
max-height: 50px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
width: 340px;
|
width: min(100%, 124px);
|
||||||
}
|
}
|
||||||
.qr-note {
|
.qr-note {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
|
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
margin-top: 8px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
.grid { stroke: rgba(53, 129, 98, 0.16); }
|
.grid { stroke: rgba(53, 129, 98, 0.16); }
|
||||||
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
|
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
|
||||||
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
|
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
|
||||||
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
|
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
|
||||||
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.facts { display: grid; gap: 7px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px; }
|
||||||
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
|
.fact span { color: var(--muted); display: block; font-size: 10px; margin-bottom: 3px; text-transform: uppercase; }
|
||||||
table { border-collapse: collapse; width: 100%; }
|
table { border-collapse: collapse; width: 100%; }
|
||||||
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
th, td { border-bottom: 1px solid var(--border); padding: 6px 7px; text-align: left; vertical-align: top; }
|
||||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
th { color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
||||||
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
.note { border-bottom: 1px solid var(--border); padding: 7px 0; }
|
||||||
.note p { margin-top: 6px; white-space: pre-wrap; }
|
.note p { margin-top: 4px; white-space: pre-wrap; }
|
||||||
main { margin-top: 24px; }
|
main { margin-top: 16px; }
|
||||||
@media print {
|
@media print {
|
||||||
body { margin: 14mm; }
|
@page { margin: 0.4in; size: letter; }
|
||||||
|
body { display: block; margin: 0; padding: 0; }
|
||||||
|
.report-page { border: 0; border-radius: 0; box-shadow: none; padding: 0; width: 7.7in; }
|
||||||
header { box-shadow: none; break-inside: avoid; }
|
header { box-shadow: none; break-inside: avoid; }
|
||||||
button { display: none; }
|
button { display: none; }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
body { padding: 18px; }
|
||||||
|
.report-page { padding: 18px; width: 100%; }
|
||||||
|
header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.brand-logo,
|
||||||
|
.qr {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="report-page">
|
||||||
<header>
|
<header>
|
||||||
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
|
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
|
||||||
<div class="report-title">
|
<div class="report-title">
|
||||||
@@ -4329,6 +4521,7 @@ function App() {
|
|||||||
<h2>Notes</h2>
|
<h2>Notes</h2>
|
||||||
${noteRows}
|
${noteRows}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
reportWindow.document.close();
|
reportWindow.document.close();
|
||||||
@@ -4342,11 +4535,41 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode());
|
if (!selectedBird) {
|
||||||
if (code) {
|
|
||||||
openAdoptionReport(code, reportWindow, printFriendly);
|
|
||||||
} else {
|
|
||||||
reportWindow.close();
|
reportWindow.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdoptionReportError('');
|
||||||
|
setCreatingAdoptionReportCode(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/birds/${selectedBird.id}/reports/adoption${printFriendly ? '?printFriendly=true' : ''}`,
|
||||||
|
authToken,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to create adoption report.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferCode = response.headers.get('X-FlockPal-Transfer-Code');
|
||||||
|
if (transferCode) {
|
||||||
|
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: transferCode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportBlob = await response.blob();
|
||||||
|
const reportUrl = URL.createObjectURL(reportBlob);
|
||||||
|
reportWindow.location.href = reportUrl;
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(reportUrl), 60_000);
|
||||||
|
} catch (reportError) {
|
||||||
|
reportWindow.close();
|
||||||
|
const message = reportError instanceof Error ? reportError.message : 'Unable to create adoption report.';
|
||||||
|
setAdoptionReportError(message);
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setCreatingAdoptionReportCode(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4602,7 +4825,15 @@ function App() {
|
|||||||
email: data.member.inviteEmail,
|
email: data.member.inviteEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
setWorkspaceMembers((current) => [...current, nextMember]);
|
setWorkspaceMembers((current) => {
|
||||||
|
const existingIndex = current.findIndex((member) => member.id === nextMember.id);
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [...current, nextMember];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.map((member) => (member.id === nextMember.id ? nextMember : member));
|
||||||
|
});
|
||||||
setWorkspaceMemberForm(emptyWorkspaceMemberForm);
|
setWorkspaceMemberForm(emptyWorkspaceMemberForm);
|
||||||
} catch (memberError) {
|
} catch (memberError) {
|
||||||
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
|
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
|
||||||
@@ -4611,6 +4842,40 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateWorkspaceMemberRole = async (memberId: string, role: WorkspaceRole) => {
|
||||||
|
setError('');
|
||||||
|
setUpdatingWorkspaceMemberId(memberId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/workspace/members/${memberId}`, authToken, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to update collaborator role.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.member) {
|
||||||
|
throw new Error('Unable to update collaborator role.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember = {
|
||||||
|
...data.member,
|
||||||
|
email: data.member.inviteEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
setWorkspaceMembers((current) => current.map((member) => (member.id === memberId ? updatedMember : member)));
|
||||||
|
} catch (memberError) {
|
||||||
|
setError(memberError instanceof Error ? memberError.message : 'Unable to update collaborator role.');
|
||||||
|
} finally {
|
||||||
|
setUpdatingWorkspaceMemberId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveWorkspaceMember = async (memberId: string) => {
|
const handleRemoveWorkspaceMember = async (memberId: string) => {
|
||||||
setError('');
|
setError('');
|
||||||
setRemovingWorkspaceMemberId(memberId);
|
setRemovingWorkspaceMemberId(memberId);
|
||||||
@@ -4682,9 +4947,10 @@ function App() {
|
|||||||
<div className="public-profile-copy">
|
<div className="public-profile-copy">
|
||||||
<h1>
|
<h1>
|
||||||
<span>{publicProfile.name}</span>
|
<span>{publicProfile.name}</span>
|
||||||
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}>
|
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${getBirdGenderClass(publicProfile)}`}>
|
||||||
{getBirdGenderSymbol(publicProfile)}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol(publicProfile)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={publicProfile} />
|
||||||
</h1>
|
</h1>
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<span>Hatch Day</span>
|
<span>Hatch Day</span>
|
||||||
@@ -4883,6 +5149,7 @@ function App() {
|
|||||||
<small>
|
<small>
|
||||||
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
|
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
|
||||||
</small>
|
</small>
|
||||||
|
<small>{medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'}</small>
|
||||||
<small>{medication.notes || 'No notes recorded.'}</small>
|
<small>{medication.notes || 'No notes recorded.'}</small>
|
||||||
{latestAdministration ? (
|
{latestAdministration ? (
|
||||||
<small>
|
<small>
|
||||||
@@ -5359,9 +5626,12 @@ function App() {
|
|||||||
<div className="bird-card-copy">
|
<div className="bird-card-copy">
|
||||||
<span className="bird-card-title">
|
<span className="bird-card-title">
|
||||||
<span>{bird.name}</span>
|
<span>{bird.name}</span>
|
||||||
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
|
<span className="bird-card-gender-cluster">
|
||||||
|
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${getBirdGenderClass(bird)}`}>
|
||||||
{getBirdGenderSymbol(bird)}
|
{getBirdGenderSymbol(bird)}
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={bird} />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<small>{bird.species}</small>
|
<small>{bird.species}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -5557,7 +5827,7 @@ function App() {
|
|||||||
<div className="segmented-field wide-field">
|
<div className="segmented-field wide-field">
|
||||||
<span>Gender</span>
|
<span>Gender</span>
|
||||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||||
{(['unknown', 'male', 'female'] as BirdGender[]).map((gender) => (
|
{birdGenderOptions.map((gender) => (
|
||||||
<button
|
<button
|
||||||
key={gender}
|
key={gender}
|
||||||
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
||||||
@@ -5566,9 +5836,10 @@ function App() {
|
|||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={birdForm.gender === gender}
|
aria-checked={birdForm.gender === gender}
|
||||||
>
|
>
|
||||||
<span className={`gender-symbol ${gender}`} aria-hidden="true">
|
<span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
|
||||||
{getBirdGenderSymbol({ gender })}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={{ gender }} />
|
||||||
{getBirdGenderLabel({ gender })}
|
{getBirdGenderLabel({ gender })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -5890,6 +6161,17 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="checkbox-row wide-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={medicationForm.remindersEnabled}
|
||||||
|
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Send medication reminders</strong>
|
||||||
|
<small>Uses the medication frequency, dose times, and start/end dates.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@@ -6116,10 +6398,11 @@ function App() {
|
|||||||
<span>{selectedBird.name}</span>
|
<span>{selectedBird.name}</span>
|
||||||
<span
|
<span
|
||||||
aria-label={getBirdGenderLabel(selectedBird)}
|
aria-label={getBirdGenderLabel(selectedBird)}
|
||||||
className={`gender-symbol ${selectedBird.gender}`}
|
className={`gender-symbol ${getBirdGenderClass(selectedBird)}`}
|
||||||
>
|
>
|
||||||
{getBirdGenderSymbol(selectedBird)}
|
<span className="gender-symbol-mark">{getBirdGenderSymbol(selectedBird)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<BirdGenderSourceIcon bird={selectedBird} />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||||
@@ -6371,10 +6654,33 @@ function App() {
|
|||||||
placeholder="Optional notes about appetite, molt, meds, or behavior"
|
placeholder="Optional notes about appetite, molt, meds, or behavior"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="button-row care-form-actions">
|
||||||
<button className="primary-button" type="submit">
|
<button className="primary-button" type="submit">
|
||||||
Save weight
|
{editingWeightId ? 'Save weight changes' : 'Save weight'}
|
||||||
</button>
|
</button>
|
||||||
|
{editingWeightId ? (
|
||||||
|
<button className="secondary-button" onClick={handleCancelWeightEdit} type="button">
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="recent-list">
|
||||||
|
{editableWeights
|
||||||
|
.map((weight) => (
|
||||||
|
<article className="vet-visit-card" key={weight.id}>
|
||||||
|
<strong>{formatWeight(weight.weightGrams)}</strong>
|
||||||
|
<span>{formatDate(weight.recordedOn)}</span>
|
||||||
|
<small>{weight.notes || 'No notes recorded.'}</small>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" onClick={() => handleEditWeight(weight)} type="button">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{!editableWeights.length ? <p className="muted">No weight entries recorded yet.</p> : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -7230,7 +7536,7 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="owner">Owner</option>
|
{isBillingOwner ? <option value="owner">Owner</option> : null}
|
||||||
<option value="assistant">Assistant</option>
|
<option value="assistant">Assistant</option>
|
||||||
<option value="caregiver">Caregiver</option>
|
<option value="caregiver">Caregiver</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
@@ -7243,23 +7549,64 @@ function App() {
|
|||||||
|
|
||||||
<div className="recent-list">
|
<div className="recent-list">
|
||||||
{workspaceMembers.length ? (
|
{workspaceMembers.length ? (
|
||||||
workspaceMembers.map((member) => (
|
workspaceMembers.map((member) => {
|
||||||
|
const memberEmail = member.email || member.inviteEmail || '';
|
||||||
|
const memberIsBillingOwner = Boolean(
|
||||||
|
workspace?.billingEmail &&
|
||||||
|
memberEmail.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
const canRemoveOwner = member.role === 'owner' && isBillingOwner && member.id !== activeMembership?.id;
|
||||||
|
const canChangeOwnerRole =
|
||||||
|
member.role === 'owner' &&
|
||||||
|
activeMembership?.role === 'owner' &&
|
||||||
|
member.id !== activeMembership.id &&
|
||||||
|
(isBillingOwner || !memberIsBillingOwner);
|
||||||
|
const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
|
||||||
|
const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
|
||||||
|
|
||||||
|
return (
|
||||||
<article key={member.id} className="vet-visit-card">
|
<article key={member.id} className="vet-visit-card">
|
||||||
<strong>{member.name}</strong>
|
<strong>{member.name}</strong>
|
||||||
<span>
|
<span>
|
||||||
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
|
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
|
||||||
</span>
|
</span>
|
||||||
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
||||||
|
<label>
|
||||||
|
Role
|
||||||
|
<select
|
||||||
|
value={member.role}
|
||||||
|
onChange={(event) => handleUpdateWorkspaceMemberRole(member.id, event.target.value as WorkspaceRole)}
|
||||||
|
disabled={
|
||||||
|
(member.role === 'owner' && !canChangeOwnerRole) ||
|
||||||
|
updatingWorkspaceMemberId === member.id ||
|
||||||
|
removingWorkspaceMemberId === member.id
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.role === 'owner' || canPromoteToOwner ? <option value="owner">Owner</option> : null}
|
||||||
|
<option value="assistant">Assistant</option>
|
||||||
|
<option value="caregiver">Caregiver</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="danger-button"
|
||||||
onClick={() => handleRemoveWorkspaceMember(member.id)}
|
onClick={() => handleRemoveWorkspaceMember(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
|
disabled={
|
||||||
|
removingWorkspaceMemberId === member.id ||
|
||||||
|
updatingWorkspaceMemberId === member.id ||
|
||||||
|
!canRemoveMember
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
|
{removingWorkspaceMemberId === member.id
|
||||||
|
? 'Removing...'
|
||||||
|
: canRemoveMember
|
||||||
|
? 'Remove access'
|
||||||
|
: 'Owner'}
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<article className="vet-visit-card empty-card">
|
<article className="vet-visit-card empty-card">
|
||||||
<strong>No collaborators yet</strong>
|
<strong>No collaborators yet</strong>
|
||||||
@@ -7483,44 +7830,24 @@ function App() {
|
|||||||
<div className="segmented-field wide-field">
|
<div className="segmented-field wide-field">
|
||||||
<span>Gender</span>
|
<span>Gender</span>
|
||||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||||
|
{birdGenderOptions.map((gender) => (
|
||||||
<button
|
<button
|
||||||
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
|
key={gender}
|
||||||
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
|
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
|
||||||
|
onClick={() => setBirdForm({ ...birdForm, gender })}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={birdForm.gender === 'unknown'}
|
aria-checked={birdForm.gender === gender}
|
||||||
>
|
>
|
||||||
<span className="gender-symbol unknown" aria-hidden="true">
|
<span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
|
||||||
?
|
<span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
|
||||||
</span>
|
</span>
|
||||||
Unknown
|
<BirdGenderSourceIcon bird={{ gender }} />
|
||||||
</button>
|
{getBirdGenderLabel({ gender })}
|
||||||
<button
|
|
||||||
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
|
|
||||||
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={birdForm.gender === 'male'}
|
|
||||||
>
|
|
||||||
<span className="gender-symbol male" aria-hidden="true">
|
|
||||||
♂
|
|
||||||
</span>
|
|
||||||
Male
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
|
|
||||||
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={birdForm.gender === 'female'}
|
|
||||||
>
|
|
||||||
<span className="gender-symbol female" aria-hidden="true">
|
|
||||||
♀
|
|
||||||
</span>
|
|
||||||
Female
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<small className="muted">Shown on the bird profile card as a symbol.</small>
|
<small className="muted">Shown on the bird profile card and reports.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-inline-header wide-field">
|
<div className="settings-inline-header wide-field">
|
||||||
<p className="eyebrow">Dates</p>
|
<p className="eyebrow">Dates</p>
|
||||||
@@ -7860,6 +8187,17 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="checkbox-row wide-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={medicationForm.remindersEnabled}
|
||||||
|
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Send medication reminders</strong>
|
||||||
|
<small>Uses the medication frequency, dose times, and start/end dates.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
+118
-4
@@ -883,10 +883,19 @@ textarea {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-card-title .bird-card-gender-cluster {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.gender-inline {
|
.gender-inline {
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gender-inline.male {
|
.gender-inline.male {
|
||||||
@@ -901,6 +910,99 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gender-source-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
flex: 0 0 1.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon svg {
|
||||||
|
width: 1.28rem;
|
||||||
|
height: 1.28rem;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.dna {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
flex-basis: 1.9rem;
|
||||||
|
background: rgba(91, 74, 161, 0.1);
|
||||||
|
color: #5b4aa1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.dna svg {
|
||||||
|
width: 1.72rem;
|
||||||
|
height: 1.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-strand {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-rung {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.05;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-dot {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.assumed {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
flex-basis: 1.9rem;
|
||||||
|
background: rgba(93, 95, 89, 0.12);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.assumed svg {
|
||||||
|
width: 1.72rem;
|
||||||
|
height: 1.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .assumed-eye {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .assumed-pupil {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-title .gender-source-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
flex-basis: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-title .gender-source-icon svg {
|
||||||
|
display: block;
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bird-avatar,
|
.bird-avatar,
|
||||||
.profile-photo {
|
.profile-photo {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -1258,14 +1360,26 @@ textarea {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.9rem;
|
width: 1.9rem;
|
||||||
height: 1.9rem;
|
height: 1.9rem;
|
||||||
|
flex: 0 0 1.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gender-symbol-mark {
|
||||||
|
display: block;
|
||||||
|
line-height: 0.82;
|
||||||
|
transform: scale(1.16);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-title .gender-symbol-mark {
|
||||||
|
transform: scale(1.28);
|
||||||
|
}
|
||||||
|
|
||||||
.gender-symbol.male {
|
.gender-symbol.male {
|
||||||
background: rgba(39, 105, 179, 0.12);
|
background: rgba(39, 105, 179, 0.12);
|
||||||
color: var(--accent-blue);
|
color: var(--accent-blue);
|
||||||
@@ -1288,7 +1402,7 @@ textarea {
|
|||||||
|
|
||||||
.segmented-control {
|
.segmented-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user