32 Commits

Author SHA1 Message Date
blaisadmin 4a43a450f3 Additional Genders
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m43s
2026-06-17 22:04:42 -04:00
Corey Blais 46605d8717 Limit editable weight entries to three
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m30s
2026-06-17 10:08:24 -04:00
Corey Blais 8f1144de1a Allow four editable weight entries
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 3m2s
2026-06-17 09:57:23 -04:00
Corey Blais 53b75588a2 weight edit fixes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m36s
2026-06-16 18:13:54 -04:00
Corey Blais 1849ecd73b Updated weight edit 2026-06-16 18:13:54 -04:00
Corey Blais 53b7d34520 trimmed weight edit 2026-06-16 18:13:54 -04:00
Corey Blais f65a4bed24 adding weight edits 2026-06-16 18:13:54 -04:00
blaisadmin cc4a2382c6 Add production health checks
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m46s
2026-06-05 22:28:48 -04:00
blaisadmin 5735bb7735 Adding promoting to owner
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-05 21:45:58 -04:00
blaisadmin 88ff06237e Adjusting role actions 2026-06-05 21:45:58 -04:00
Corey Blais fbb13561b0 Medication reminder and pdr worker 2026-06-05 21:45:58 -04:00
Corey Blais b15861c856 Use fixed screen size for adoption report sheet 2026-06-05 21:42:20 -04:00
Corey Blais 2aeaa119f7 Wrap adoption report in letter sheet 2026-06-05 21:42:20 -04:00
Corey Blais 36690c0174 Tighten adoption report page scale 2026-06-05 21:42:20 -04:00
Corey Blais b76ad35c07 Size adoption report for letter paper 2026-06-05 21:42:20 -04:00
Corey Blais 6918b55a58 Stabilize adoption report layout 2026-06-05 21:42:20 -04:00
blaisadmin 49f1713e26 Add flock member notes and audit tabs 2026-06-05 21:42:20 -04:00
Corey Blais c9fa7e4246 Cover consumed adoption transfer codes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m45s
2026-06-03 14:12:10 -04:00
Corey Blais 0411ec5175 Keep adoption transfer codes stable
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m17s
2026-06-03 12:10:27 -04:00
Corey Blais 7b7171c109 Prevent report card text overlap
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m47s
2026-06-03 11:54:44 -04:00
Corey Blais c02bb4d6d8 Convert report photos for PDF embedding
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m37s
2026-06-03 11:20:52 -04:00
Corey Blais 603b4eee4d Render adoption reports in worker
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m43s
2026-06-03 10:55:09 -04:00
Corey Blais 52008f5b43 Fix adoption report photos and section order
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-03 10:05:48 -04:00
Corey Blais 5b57cdd6bf Fix adoption report QR and chart layout 2026-06-03 10:05:48 -04:00
Corey Blais 60eadf0847 Refine adoption report header layout 2026-06-03 10:05:48 -04:00
Corey Blais 682ccfd41f Generate adoption reports as PDFs 2026-06-03 10:05:48 -04:00
Corey Blais 59c6b19ad6 fixing analytics icon pt2
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m15s
2026-06-02 12:55:35 -04:00
Corey Blais aa1a4cf6ff fixing analytics icon 2026-06-02 12:55:35 -04:00
Corey Blais 5f0fad3cbb Updated adoption report 2026-06-02 12:55:35 -04:00
Corey Blais 545fae59b2 Added adoption report and transfer code 2026-06-02 12:55:35 -04:00
Corey Blais d748d2db21 Added vet info
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m14s
2026-06-01 15:20:47 -04:00
blaisadmin 095c91e56d Merge branch 'release/flock-member-notes-audit'
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m16s
2026-05-30 22:50:54 -04:00
25 changed files with 4757 additions and 212 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+17
View File
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
## 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
Create a compressed Postgres backup from the Docker Compose Postgres service:
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+1049 -1
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -23,7 +23,10 @@
"helmet": "8.1.0",
"morgan": "1.10.0",
"nodemailer": "^8.0.5",
"pdfkit": "^0.18.0",
"pg": "8.13.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"stripe": "^22.0.2",
"zod": "3.24.1"
},
@@ -32,7 +35,9 @@
"@types/express": "4.17.21",
"@types/morgan": "1.9.9",
"@types/node": "22.10.2",
"@types/pdfkit": "^0.17.6",
"@types/pg": "8.11.10",
"@types/qrcode": "^1.5.6",
"tsx": "4.19.2",
"typescript": "5.7.2"
}
+680 -9
View File
@@ -12,8 +12,11 @@ import nodemailer, { type SendMailOptions } from 'nodemailer';
import Stripe from 'stripe';
import { z } from 'zod';
import { db } from './db/client.js';
import { ensureSchema } from './db/schema.js';
import { adoptionReportQueueEvents, enqueueAdoptionReportJob, getAdoptionReportQueueCounts } from './queues/adoptionReportQueue.js';
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
import {
consumeMagicLinkToken,
consumeOAuthState,
@@ -35,6 +38,8 @@ import {
completePendingBirdTransfersForOwner,
createBird,
createBirdMilestoneReminderDelivery,
createMedicationReminderDelivery,
createBirdTransferCode,
createMedicationForBird,
createPendingBirdTransfer,
findBirdsByBandId,
@@ -45,18 +50,23 @@ import {
deleteVetVisitForBird,
getBirdById,
getBirdByPublicProfileCode,
getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listBirds,
listDueBirdMilestoneReminders,
listDueMedicationReminders,
listMemorializedBirds,
listMedicationAdministrationsForBird,
listMedicationsForBird,
listVetVisitsForBird,
listWeightsForBird,
memorializeBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
updateBird,
updateMemorialReminderPreference,
updateMedicationForBird,
updateWeightForBird,
upsertMedicationAdministrationForBird,
updateVetVisitForBird,
} from './repositories/birdRepository.js';
@@ -100,6 +110,7 @@ import {
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
updateRescueVerificationStatus,
updateWorkspace,
updateWorkspaceMemberRole,
upsertWorkspaceMember,
} from './repositories/workspaceRepository.js';
import type {
@@ -113,8 +124,9 @@ import type {
FlockNoteRow,
IntegrationTokenRow,
LostBirdMatchRow,
MedicationRow,
MedicationAdministrationRow,
MedicationReminderCandidateRow,
MedicationRow,
ProviderKey,
RescueVerificationStatus,
SubscriptionStatus,
@@ -143,7 +155,9 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
const sessionDays = 30;
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 medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
@@ -197,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 billingIntervalSchema = z.enum(['monthly', 'yearly']);
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 rescueOnboardingSchema = z.object({
name: z.string().trim().max(160).optional().or(z.literal('')),
@@ -244,6 +258,7 @@ const lostBirdReportSchema = z.object({
});
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
const birdTransferCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{12,32}$/);
const birdProfileListSchema = z
.string()
.trim()
@@ -262,6 +277,10 @@ const birdSchema = z.object({
motivators: birdProfileListSchema,
demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -314,6 +333,7 @@ const medicationSchema = z
startDate: dateStringSchema,
endDate: dateStringSchema.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, {
message: 'End date must be on or after start date.',
@@ -617,6 +637,10 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators,
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number,
vetDoctorName: row.vet_doctor_name,
gender: row.gender,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
@@ -638,6 +662,35 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on,
});
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) => ({
id: row.id,
workspaceId: row.workspace_id,
@@ -676,6 +729,7 @@ const normalizeMedication = (row: MedicationRow) => ({
startDate: row.start_date,
endDate: row.end_date,
notes: row.notes,
remindersEnabled: row.reminders_enabled,
});
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
@@ -767,6 +821,7 @@ app.disable('x-powered-by');
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(
cors({
exposedHeaders: ['X-FlockPal-Transfer-Code'],
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
@@ -1173,6 +1228,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
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 remainder = value % 100;
if (remainder >= 11 && remainder <= 13) {
@@ -1680,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 ({
reminder,
recipients,
@@ -1786,6 +1880,120 @@ const sendBirdMilestoneReminderNotification = async ({
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()) => {
const reminders = await listDueBirdMilestoneReminders(runDate);
let sent = 0;
@@ -1830,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 lastMedicationReminderRunKey = '';
export const startBirdMilestoneReminderScheduler = () => {
if (!milestoneRemindersEnabled) {
@@ -1864,6 +2118,42 @@ export const startBirdMilestoneReminderScheduler = () => {
}, 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) => {
if (!authorizationHeader) {
return '';
@@ -1977,6 +2267,59 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
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 (
auth: AuthContext,
action: string,
@@ -2005,8 +2348,46 @@ const isBillingOnlyWorkspaceUpdate = (
payload: z.infer<typeof workspaceSchema>,
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true });
app.get('/api/health/live', (_req: Request, res: Response) => {
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) => {
@@ -2015,6 +2396,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
try {
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
res.json({
startedAt: requestMetrics.startedAt,
@@ -2036,6 +2418,8 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
},
queues: {
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
medicationReminders: medicationReminderQueueCounts,
adoptionReports: await getAdoptionReportQueueCounts(),
},
});
} catch (error) {
@@ -2919,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) => {
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) {
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
@@ -2929,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!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
res.status(204).send();
} catch (error) {
next(error);
@@ -3118,6 +3549,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3142,7 +3577,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3224,7 +3659,7 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3232,6 +3667,181 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
}
});
app.post(
'/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;
}
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 writeAuditLog(req.auth!, 'bird.transfer_code_created', 'bird', sourceBird.id, sourceBird.name, {
transferCodeId: transferCode.id,
});
res.status(201).json({
transferCode: {
code: transferCode.code,
bird: normalizeBird(sourceBird),
},
});
} catch (error) {
next(error);
}
},
);
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(
'/api/bird-transfer-codes/:code/accept',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdTransferCodeSchema.safeParse(req.params.code);
if (!parsed.success) {
res.status(404).json({ error: 'Bird transfer code not found.' });
return;
}
try {
const transferCode = await getOpenBirdTransferCode(parsed.data);
if (!transferCode) {
res.status(404).json({ error: 'Bird transfer code not found or already used.' });
return;
}
if (transferCode.source_workspace_id === req.auth!.workspace.id) {
res.status(409).json({ error: 'This bird is already in your active flock.' });
return;
}
const bird = await transferBirdToWorkspace(transferCode.id, transferCode.source_workspace_id, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird is no longer available for transfer.' });
return;
}
await markBirdTransferCodeCompleted(transferCode.transfer_code_id, req.auth!.workspace.id);
await writeAuditLog(req.auth!, 'bird.transfer_code_accepted', 'bird', bird.id, bird.name, {
sourceWorkspaceId: transferCode.source_workspace_id,
sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id,
});
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
next(error);
}
},
);
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) => {
const parsed = birdSchema.safeParse(req.body);
@@ -3271,6 +3881,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -3301,7 +3915,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
return;
}
@@ -3455,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) => {
try {
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
@@ -3616,6 +4285,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
parsed.data.startDate,
emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
);
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
@@ -3659,6 +4329,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
parsed.data.startDate,
emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
);
if (!medication) {
+56 -8
View File
@@ -215,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120),
vet_doctor_name VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE,
gotcha_day DATE,
@@ -239,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
@@ -284,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, LOWER(tag_id))
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
@@ -334,13 +342,35 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
ON bird_transfer_codes (bird_id, created_at DESC)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
ON bird_transfer_codes (code)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL,
body TEXT NOT NULL,
@@ -407,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
start_date DATE NOT NULL,
end_date DATE,
notes VARCHAR(1000),
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (end_date IS NULL OR end_date >= start_date)
);
@@ -414,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medications
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -447,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medication_administrations
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
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
@@ -465,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
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
ON medication_administrations (bird_id, administered_on DESC);
+54
View File
@@ -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()]);
}
+44
View File
@@ -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');
+440
View File
@@ -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;
};
+103
View File
@@ -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,
createPendingBirdTransfer,
getBirdById,
getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listWeightsForBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
} from './birdRepository.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.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/);
});
+271 -29
View File
@@ -5,9 +5,12 @@ import type {
BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType,
BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow,
MedicationAdministrationRow,
MedicationDoseScheduleItem,
MedicationReminderCandidateRow,
MedicationReminderDeliveryRow,
MedicationRow,
PendingBirdTransferRow,
VetVisitRow,
@@ -23,6 +26,10 @@ const birdSelectFields = `
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
birds.vet_doctor_name,
birds.gender,
birds.date_of_birth::text,
birds.gotcha_day::text,
@@ -278,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
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 ({
birdId,
workspaceId,
@@ -287,6 +367,10 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
vetDoctorName = null,
gender,
dateOfBirth,
gotchaDay,
@@ -308,6 +392,10 @@ export const createBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
vetDoctorName?: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -322,9 +410,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean;
}) => {
const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[
birdId ?? null,
workspaceId,
@@ -334,6 +422,10 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -361,6 +453,10 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -382,6 +478,10 @@ export const updateBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
vetDoctorName: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -403,22 +503,26 @@ export const updateBird = async ({
motivators = $5,
demotivators = $6,
favorite_snack = $7,
gender = $8,
date_of_birth = $9,
gotcha_day = $10,
chart_color = $11,
photo_data_url = $12,
photo_object_key = $13,
photo_content_type = $14,
photo_updated_at = $15,
notify_on_dob = $16,
notify_on_gotcha_day = $17,
public_profile_code = $18,
public_profile_enabled = $19
vet_clinic_name = $8,
vet_clinic_address = $9,
vet_account_number = $10,
vet_doctor_name = $11,
gender = $12,
date_of_birth = $13,
gotcha_day = $14,
chart_color = $15,
photo_data_url = $16,
photo_object_key = $17,
photo_content_type = $18,
photo_updated_at = $19,
notify_on_dob = $20,
notify_on_gotcha_day = $21,
public_profile_code = $22,
public_profile_enabled = $23
WHERE id = $1
AND workspace_id = $20
AND workspace_id = $24
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -441,6 +545,10 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
@@ -482,7 +590,7 @@ export const memorializeBird = async ({
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -518,7 +626,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -558,7 +666,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -659,7 +767,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
failed += 1;
const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.'
? 'That band/tag ID is already in use in FlockPal.'
: error instanceof Error
? error.message
: 'Unable to complete pending bird transfer.';
@@ -670,6 +778,109 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
return { completed, failed };
};
export const createBirdTransferCode = async ({
code,
birdId,
sourceWorkspaceId,
requestedByUserId,
}: {
code: string;
birdId: string;
sourceWorkspaceId: number;
requestedByUserId: string;
}) => {
await db.query(
`UPDATE bird_transfer_codes
SET revoked_at = CURRENT_TIMESTAMP
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL`,
[birdId, sourceWorkspaceId],
);
const result = await db.query<BirdTransferCodeRow>(
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
[code, birdId, sourceWorkspaceId, requestedByUserId],
);
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) => {
const result = await db.query<
BirdRow & {
transfer_code_id: string;
code: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
transfer_code_created_at: string;
workspace_name: string;
}
>(
`SELECT
bird_transfer_codes.id AS transfer_code_id,
bird_transfer_codes.code,
bird_transfer_codes.source_workspace_id,
bird_transfer_codes.requested_by_user_id,
bird_transfer_codes.completed_at::text,
bird_transfer_codes.completed_workspace_id,
bird_transfer_codes.revoked_at::text,
bird_transfer_codes.created_at AS transfer_code_created_at,
workspaces.name AS workspace_name,
${birdSelectFields}
FROM bird_transfer_codes
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
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 bird_transfer_codes.code = $1
AND bird_transfer_codes.completed_at IS NULL
AND bird_transfer_codes.revoked_at IS NULL
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
AND birds.memorialized_at IS NULL`,
[code],
);
return result.rows[0] ?? null;
};
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE bird_transfer_codes
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2
WHERE id = $1`,
[codeId, completedWorkspaceId],
);
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -700,6 +911,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
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) => {
const result = await db.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
@@ -766,7 +1005,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
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
WHERE bird_id = $1
AND EXISTS (
@@ -792,12 +1031,13 @@ export const createMedicationForBird = async (
startDate: string,
endDate: string | null,
notes: string | null,
remindersEnabled: boolean,
) => {
const result = await db.query<MedicationRow>(
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, $10)
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, remindersEnabled],
);
return result.rows[0] ?? null;
@@ -814,6 +1054,7 @@ export const updateMedicationForBird = async (
startDate: string,
endDate: string | null,
notes: string | null,
remindersEnabled: boolean,
) => {
const result = await db.query<MedicationRow>(
`UPDATE medications
@@ -824,11 +1065,12 @@ export const updateMedicationForBird = async (
route = $7,
start_date = $8,
end_date = $9,
notes = $10
notes = $10,
reminders_enabled = $11
WHERE id = $1
AND bird_id = $2
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, remindersEnabled],
);
return result.rows[0] ?? null;
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createWorkspace,
deleteWorkspaceMember,
deleteWorkspaceIfEmpty,
ensureDefaultWorkspaceForUser,
ensurePersonalWorkspaceForUser,
@@ -10,6 +11,7 @@ import {
getPlatformAdminSummary,
listOwnedWorkspacesByOwnerEmail,
updateWorkspace,
updateWorkspaceMemberRole,
} from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.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/);
});
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 () => {
const { calls } = mockDb({
rowCount: 1,
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
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 }>(
`DELETE FROM workspace_members
WHERE id = $1
AND workspace_id = $2
AND role <> 'owner'
AND (
role <> 'owner'
OR (
$3 = TRUE
AND id <> $4
)
)
RETURNING id`,
[memberId, workspaceId],
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
);
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 () => {
const result = await db.query<
WorkspaceRow & {
+45 -1
View File
@@ -6,7 +6,7 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
export type ProviderKey = 'google' | 'microsoft' | 'apple';
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 = {
id: string;
@@ -101,6 +101,10 @@ export type BirdRow = {
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
vet_doctor_name: string | null;
gender: BirdGender;
date_of_birth: string | null;
gotcha_day: string | null;
@@ -158,6 +162,18 @@ export type PendingBirdTransferRow = {
created_at: string;
};
export type BirdTransferCodeRow = {
id: string;
code: string;
bird_id: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
created_at: string;
};
export type WeightRow = {
id: string;
bird_id: string;
@@ -186,6 +202,7 @@ export type MedicationRow = {
start_date: string;
end_date: string | null;
notes: string | null;
reminders_enabled: boolean;
};
export type MedicationDoseScheduleItem = {
@@ -194,6 +211,33 @@ export type MedicationDoseScheduleItem = {
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 = {
id: string;
medication_id: string;
+64 -1
View File
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.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 {
birdMilestoneReminderQueueName,
closeBirdMilestoneReminderQueue,
type BirdMilestoneReminderJobData,
type BirdMilestoneReminderJobResult,
} from './queues/birdMilestoneReminderQueue.js';
import {
closeMedicationReminderQueue,
medicationReminderQueueName,
type MedicationReminderJobData,
type MedicationReminderJobResult,
} from './queues/medicationReminderQueue.js';
import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
const startWorker = async () => {
await ensureSchema();
@@ -35,14 +55,57 @@ const startWorker = async () => {
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();
startMedicationReminderScheduler();
console.log('FlockPal worker started.');
};
const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`);
await birdMilestoneWorker?.close();
await medicationReminderWorker?.close();
await adoptionReportWorker?.close();
await closeBirdMilestoneReminderQueue();
await closeMedicationReminderQueue();
await closeAdoptionReportQueue();
await db.close();
process.exit(0);
};
+21
View File
@@ -43,6 +43,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
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}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
@@ -58,6 +59,7 @@ services:
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}
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}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -94,6 +96,12 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels:
- traefik.enable=true
- traefik.docker.network=traefik
@@ -138,6 +146,7 @@ services:
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}
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}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
@@ -151,6 +160,12 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
frontend:
@@ -162,6 +177,12 @@ services:
container_name: flockpal-frontend
depends_on:
- 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:
- traefik.enable=true
- traefik.docker.network=traefik
+3
View File
@@ -41,6 +41,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
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}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
@@ -56,6 +57,7 @@ services:
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}
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}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -131,6 +133,7 @@ services:
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}
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}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
+81 -6
View File
@@ -208,7 +208,11 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"name": "Kiwi",
"tagId": "FP-001",
"species": "Cockatiel",
"gender": "female",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female_dna",
"dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21",
"chartColor": "#cb3a35",
@@ -295,7 +299,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
- Dates use `YYYY-MM-DD`
- `workspaceType` is `standard` or `rescue`
- 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
- `photoDataUrl` must be a base64 `data:image/...` URL
- `weightGrams` must be a positive number up to `10000`
@@ -315,14 +319,47 @@ Validation failures return `400` with this shape:
#### `GET /api/health`
Public health check.
Public readiness-compatible health check. Verifies backend dependencies.
Response `200`:
```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
#### `GET /api/metrics`
@@ -793,7 +830,11 @@ Request body:
"name": "Kiwi",
"tagId": "FP-001",
"species": "Cockatiel",
"gender": "female",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female_dna",
"dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21",
"chartColor": "#cb3a35",
@@ -805,7 +846,7 @@ Request body:
Notes:
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings
- `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
- `chartColor` defaults to `#cb3a35`
Response `201`:
@@ -889,6 +930,40 @@ Possible errors:
- `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId`
#### `POST /api/birds/:birdId/transfer-code`
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
Response `201`:
```json
{
"transferCode": {
"code": "secure-code",
"bird": {}
}
}
```
#### `POST /api/bird-transfer-codes/:code/accept`
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
Response `200`:
```json
{
"bird": {},
"sourceWorkspaceName": "Previous Flock",
"workspace": {}
}
```
Possible errors:
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
#### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+6
View File
@@ -12,6 +12,12 @@ server {
add_header X-Frame-Options "SAMEORIGIN" 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 / {
try_files $uri $uri/ /index.html;
}
+1246 -150
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+157 -4
View File
@@ -883,10 +883,19 @@ textarea {
display: inline;
}
.bird-card-title .bird-card-gender-cluster {
display: inline-flex;
align-items: center;
gap: 0.28rem;
line-height: 1;
}
.gender-inline {
font-size: 1.2rem;
font-size: 1.45rem;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
}
.gender-inline.male {
@@ -901,6 +910,99 @@ textarea {
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,
.profile-photo {
width: 56px;
@@ -1211,6 +1313,7 @@ textarea {
.bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
@@ -1257,14 +1360,26 @@ textarea {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.9rem;
width: 1.9rem;
height: 1.9rem;
flex: 0 0 1.9rem;
border-radius: 999px;
font-size: 1.2rem;
font-size: 1.45rem;
font-weight: 700;
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 {
background: rgba(39, 105, 179, 0.12);
color: var(--accent-blue);
@@ -1287,7 +1402,7 @@ textarea {
.segmented-control {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.55rem;
}
@@ -1706,6 +1821,44 @@ label {
accent-color: var(--accent-green);
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: min(100%, 420px);
margin: 0.35rem 0 0;
padding: 0.85rem 1rem;
border: 1px solid rgba(53, 129, 98, 0.24);
border-radius: 16px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
}
.checkbox-row input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: 0.1rem 0 0;
padding: 0;
accent-color: var(--accent-green);
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
line-height: 1.3;
}
.checkbox-row strong {
color: var(--ink);
font-size: 0.98rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
}
.primary-button {
border: 0;
border-radius: 18px;