17 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
18 changed files with 1760 additions and 234 deletions
+17
View File
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
## Operations ## Operations
### Health checks
Monitor these production checks:
- Frontend: `GET https://your-host/healthz`
- Verifies Nginx is serving the frontend container.
- Backend liveness: `GET https://your-host/api/health/live`
- Verifies the API process is running.
- Backend readiness: `GET https://your-host/api/health/ready`
- Verifies the API can reach Postgres and Redis. Returns `503` if either dependency is unavailable.
- Backend metrics: `GET https://your-host/api/metrics`
- Admin-authenticated process, request, and queue metrics.
- Postgres and Redis:
- Use the Docker health checks in `docker-compose.prod.yml`.
- Worker:
- Use the Docker health check in `docker-compose.prod.yml`; it validates worker dependencies. The worker does not expose HTTP.
### Backups ### Backups
Create a compressed Postgres backup from the Docker Compose Postgres service: Create a compressed Postgres backup from the Docker Compose Postgres service:
+451 -8
View File
@@ -12,9 +12,11 @@ import nodemailer, { type SendMailOptions } from 'nodemailer';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { db } from './db/client.js';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js'; import { adoptionReportQueueEvents, enqueueAdoptionReportJob, getAdoptionReportQueueCounts } from './queues/adoptionReportQueue.js';
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js'; import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
import { import {
consumeMagicLinkToken, consumeMagicLinkToken,
consumeOAuthState, consumeOAuthState,
@@ -36,6 +38,7 @@ import {
completePendingBirdTransfersForOwner, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createMedicationReminderDelivery,
createBirdTransferCode, createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
createPendingBirdTransfer, createPendingBirdTransfer,
@@ -51,6 +54,7 @@ import {
getOpenBirdTransferCodeForBird, getOpenBirdTransferCodeForBird,
listBirds, listBirds,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listDueMedicationReminders,
listMemorializedBirds, listMemorializedBirds,
listMedicationAdministrationsForBird, listMedicationAdministrationsForBird,
listMedicationsForBird, listMedicationsForBird,
@@ -62,6 +66,7 @@ import {
updateBird, updateBird,
updateMemorialReminderPreference, updateMemorialReminderPreference,
updateMedicationForBird, updateMedicationForBird,
updateWeightForBird,
upsertMedicationAdministrationForBird, upsertMedicationAdministrationForBird,
updateVetVisitForBird, updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
@@ -105,6 +110,7 @@ import {
setWorkspaceSubscriptionStatusByStripeSubscriptionId, setWorkspaceSubscriptionStatusByStripeSubscriptionId,
updateRescueVerificationStatus, updateRescueVerificationStatus,
updateWorkspace, updateWorkspace,
updateWorkspaceMemberRole,
upsertWorkspaceMember, upsertWorkspaceMember,
} from './repositories/workspaceRepository.js'; } from './repositories/workspaceRepository.js';
import type { import type {
@@ -118,8 +124,9 @@ import type {
FlockNoteRow, FlockNoteRow,
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationReminderCandidateRow,
MedicationRow,
ProviderKey, ProviderKey,
RescueVerificationStatus, RescueVerificationStatus,
SubscriptionStatus, SubscriptionStatus,
@@ -148,10 +155,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`; const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
const sessionDays = 30; const sessionDays = 30;
const trustProxy = process.env.TRUST_PROXY?.trim() ?? ''; const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
if (trustProxy) { if (trustProxy) {
@@ -203,7 +211,7 @@ const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
const billingIntervalSchema = z.enum(['monthly', 'yearly']); const billingIntervalSchema = z.enum(['monthly', 'yearly']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const birdGenderSchema = z.enum(['unknown', 'male', 'female', 'male_dna', 'female_dna']);
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
const rescueOnboardingSchema = z.object({ const rescueOnboardingSchema = z.object({
name: z.string().trim().max(160).optional().or(z.literal('')), name: z.string().trim().max(160).optional().or(z.literal('')),
@@ -325,6 +333,7 @@ const medicationSchema = z
startDate: dateStringSchema, startDate: dateStringSchema,
endDate: dateStringSchema.optional().or(z.literal('')), endDate: dateStringSchema.optional().or(z.literal('')),
notes: z.string().trim().max(1000).optional().or(z.literal('')), notes: z.string().trim().max(1000).optional().or(z.literal('')),
remindersEnabled: z.boolean().optional(),
}) })
.refine((value) => !value.endDate || value.endDate >= value.startDate, { .refine((value) => !value.endDate || value.endDate >= value.startDate, {
message: 'End date must be on or after start date.', message: 'End date must be on or after start date.',
@@ -720,6 +729,7 @@ const normalizeMedication = (row: MedicationRow) => ({
startDate: row.start_date, startDate: row.start_date,
endDate: row.end_date, endDate: row.end_date,
notes: row.notes, notes: row.notes,
remindersEnabled: row.reminders_enabled,
}); });
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
@@ -1218,6 +1228,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}; };
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
return `${hour === '24' ? '00' : hour}:${minute}`;
};
const formatOrdinal = (value: number) => { const formatOrdinal = (value: number) => {
const remainder = value % 100; const remainder = value % 100;
if (remainder >= 11 && remainder <= 13) { if (remainder >= 11 && remainder <= 13) {
@@ -1725,6 +1747,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
}; };
}; };
const formatReminderDoseTime = (time: string) => {
const [rawHour, rawMinute] = time.split(':');
const hour = Number(rawHour);
const minute = rawMinute ?? '00';
if (Number.isNaN(hour)) {
return time;
}
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minute} ${period}`;
};
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
const doseTime = formatReminderDoseTime(reminder.administration_time);
const slotLabel = reminder.administration_label || 'Dose';
const route = reminder.route ? ` by ${reminder.route}` : '';
return {
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
eyebrow: 'Medication Reminder',
headline: `${slotLabel} time for ${reminder.name}`,
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
body: `Dose: ${reminder.dosage}${route}.`,
detailLabel: `${slotLabel} at ${doseTime}`,
};
};
const sendBirdMilestoneReminderNotification = async ({ const sendBirdMilestoneReminderNotification = async ({
reminder, reminder,
recipients, recipients,
@@ -1831,6 +1880,120 @@ const sendBirdMilestoneReminderNotification = async ({
return { delivered: true }; return { delivered: true };
}; };
const sendMedicationReminderNotification = async ({
reminder,
recipients,
}: {
reminder: MedicationReminderCandidateRow;
recipients: string[];
}) => {
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
if (!uniqueRecipients.length) {
return { delivered: false };
}
const copy = buildMedicationReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
if (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const medicationNotesHtml = reminder.medication_notes
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
: '';
const lines = [
copy.headline,
'',
copy.intro,
copy.body,
'',
`Bird: ${reminder.name}`,
`Medication: ${reminder.medication_name}`,
`Dose: ${reminder.dosage}`,
`Scheduled dose: ${copy.detailLabel}`,
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
'',
`Open FlockPal: ${frontendBaseUrl}`,
].filter(Boolean);
if (!mailTransport) {
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
return { delivered: false };
}
await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: smtpFromEmail,
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
${medicationNotesHtml}
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
});
return { delivered: true };
};
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
const reminders = await listDueBirdMilestoneReminders(runDate); const reminders = await listDueBirdMilestoneReminders(runDate);
let sent = 0; let sent = 0;
@@ -1875,7 +2038,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
}; };
}; };
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
const reminders = await listDueMedicationReminders(runDate, currentTime);
let sent = 0;
let skipped = 0;
let failed = 0;
for (const reminder of reminders) {
try {
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
const result = await sendMedicationReminderNotification({ reminder, recipients });
if (!result.delivered) {
skipped += 1;
continue;
}
const delivery = await createMedicationReminderDelivery({
medicationId: reminder.medication_id,
birdId: reminder.id,
workspaceId: reminder.workspace_id,
scheduledOn: runDate,
administrationSlot: reminder.administration_slot,
});
if (delivery) {
sent += 1;
} else {
skipped += 1;
}
} catch (error) {
failed += 1;
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
}
}
return {
runDate,
currentTime,
checked: reminders.length,
sent,
skipped,
failed,
};
};
let lastMilestoneReminderRunDate = ''; let lastMilestoneReminderRunDate = '';
let lastMedicationReminderRunKey = '';
export const startBirdMilestoneReminderScheduler = () => { export const startBirdMilestoneReminderScheduler = () => {
if (!milestoneRemindersEnabled) { if (!milestoneRemindersEnabled) {
@@ -1909,6 +2118,42 @@ export const startBirdMilestoneReminderScheduler = () => {
}, milestoneReminderCheckIntervalMs); }, milestoneReminderCheckIntervalMs);
}; };
export const startMedicationReminderScheduler = () => {
if (!medicationRemindersEnabled) {
console.log('Medication reminders are disabled.');
return;
}
const runIfNeeded = async () => {
const now = new Date();
const runDate = getDateInTimeZone(now);
const currentTime = getTimeInTimeZone(now);
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
if (lastMedicationReminderRunKey === runKey) {
return;
}
lastMedicationReminderRunKey = runKey;
const job = await enqueueMedicationReminderJob(runDate, currentTime);
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
};
setTimeout(() => {
void runIfNeeded().catch((error) => {
lastMedicationReminderRunKey = '';
console.error('Medication reminder scheduler failed', error);
});
}, 30_000);
setInterval(() => {
void runIfNeeded().catch((error) => {
lastMedicationReminderRunKey = '';
console.error('Medication reminder scheduler failed', error);
});
}, milestoneReminderCheckIntervalMs);
};
const readBearerToken = (authorizationHeader?: string) => { const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) { if (!authorizationHeader) {
return ''; return '';
@@ -2022,6 +2267,59 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
return false; return false;
}; };
type HealthCheckResult = {
ok: boolean;
latencyMs?: number;
error?: string;
};
const withHealthTimeout = async <T,>(operation: Promise<T>, timeoutMs = 2_000): Promise<T> => {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Health check timed out')), timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
const checkPostgresHealth = async (): Promise<HealthCheckResult> => {
const startedAt = Date.now();
try {
await withHealthTimeout(db.query('SELECT 1'));
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : 'Postgres health check failed',
};
}
};
const checkRedisHealth = async (): Promise<HealthCheckResult> => {
const startedAt = Date.now();
try {
await withHealthTimeout(getBirdMilestoneReminderQueueCounts());
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (error) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : 'Redis health check failed',
};
}
};
const writeAuditLog = async ( const writeAuditLog = async (
auth: AuthContext, auth: AuthContext,
action: string, action: string,
@@ -2050,8 +2348,46 @@ const isBillingOnlyWorkspaceUpdate = (
payload: z.infer<typeof workspaceSchema>, payload: z.infer<typeof workspaceSchema>,
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name; ) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
app.get('/api/health', (_req: Request, res: Response) => { app.get('/api/health/live', (_req: Request, res: Response) => {
res.json({ ok: true }); res.json({
ok: true,
service: 'flockpal-backend',
status: 'live',
uptimeSeconds: Math.round(process.uptime()),
checkedAt: new Date().toISOString(),
});
});
app.get('/api/health/ready', async (_req: Request, res: Response) => {
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
const ok = postgres.ok && redis.ok;
res.status(ok ? 200 : 503).json({
ok,
service: 'flockpal-backend',
status: ok ? 'ready' : 'degraded',
checkedAt: new Date().toISOString(),
dependencies: {
postgres,
redis,
},
});
});
app.get('/api/health', async (_req: Request, res: Response) => {
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
const ok = postgres.ok && redis.ok;
res.status(ok ? 200 : 503).json({
ok,
service: 'flockpal-backend',
status: ok ? 'ready' : 'degraded',
checkedAt: new Date().toISOString(),
dependencies: {
postgres,
redis,
},
});
}); });
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
@@ -2060,6 +2396,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
try { try {
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts(); const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
res.json({ res.json({
startedAt: requestMetrics.startedAt, startedAt: requestMetrics.startedAt,
@@ -2081,6 +2418,8 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
}, },
queues: { queues: {
birdMilestoneReminders: birdMilestoneReminderQueueCounts, birdMilestoneReminders: birdMilestoneReminderQueueCounts,
medicationReminders: medicationReminderQueueCounts,
adoptionReports: await getAdoptionReportQueueCounts(),
}, },
}); });
} catch (error) { } catch (error) {
@@ -2964,9 +3303,57 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
} }
}); });
app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() });
return;
}
try {
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
if (parsed.data.role === 'owner' && !requesterIsBillingOwner) {
res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' });
return;
}
const member = await updateWorkspaceMemberRole({
memberId: req.params.memberId,
workspaceId: req.auth!.workspace.id,
role: parsed.data.role,
requesterMemberId: req.auth!.membership.id,
requesterIsBillingOwner,
requesterRole: req.auth!.membership.role,
billingEmail,
});
if (!member) {
res.status(404).json({ error: 'Flock member not found or cannot be changed.' });
return;
}
await writeAuditLog(req.auth!, 'workspace_member.role_updated', 'workspace_member', member.id, member.name, {
role: member.role,
});
res.json({ member: normalizeWorkspaceMember(member) });
} catch (error) {
next(error);
}
});
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
const deleted = await deleteWorkspaceMember({
memberId: req.params.memberId,
workspaceId: req.auth!.workspace.id,
requesterMemberId: req.auth!.membership.id,
requesterIsBillingOwner,
});
if (!deleted) { if (!deleted) {
res.status(404).json({ error: 'Flock member not found or cannot be removed.' }); res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
@@ -2974,7 +3361,6 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
} }
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId); await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3683,6 +4069,61 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
} }
}); });
app.put(
'/api/birds/:birdId/weights/:weightId',
requireAuth,
requireWriteAccess,
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = weightSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid weight payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
if (!bird) {
res.status(404).json({ error: 'Bird not found.' });
return;
}
if (!ensureBirdWritable(bird, res)) {
return;
}
const weight = await updateWeightForBird(
req.params.weightId,
req.params.birdId,
parsed.data.weightGrams,
parsed.data.recordedOn,
emptyToNull(parsed.data.notes),
);
if (!weight) {
res.status(404).json({ error: 'Weight entry not found or no longer editable.' });
return;
}
await writeAuditLog(req.auth!, 'weight.updated', 'weight', weight.id, bird.name, {
birdId: bird.id,
weightGrams: parsed.data.weightGrams,
recordedOn: parsed.data.recordedOn,
});
res.json({ weight: normalizeWeight(weight) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
return;
}
next(error);
}
},
);
app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => { app.get('/api/birds/:birdId/vet-visits', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id); const vetVisits = await listVetVisitsForBird(req.params.birdId, req.auth!.workspace.id);
@@ -3844,6 +4285,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
parsed.data.startDate, parsed.data.startDate,
emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
); );
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, { await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
@@ -3887,6 +4329,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
parsed.data.startDate, parsed.data.startDate,
emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.endDate),
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
parsed.data.remindersEnabled ?? false,
); );
if (!medication) { if (!medication) {
+24 -6
View File
@@ -342,9 +342,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes ( CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -368,9 +368,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE completed_at IS NULL WHERE completed_at IS NULL
AND revoked_at IS NULL; AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes ( CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL, bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL, title VARCHAR(160) NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
@@ -437,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE, end_date DATE,
notes VARCHAR(1000), notes VARCHAR(1000),
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (end_date IS NULL OR end_date >= start_date) CHECK (end_date IS NULL OR end_date >= start_date)
); );
@@ -444,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medications ALTER TABLE medications
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb; ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
ALTER TABLE medications
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries ( CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -477,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1'; ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
scheduled_on DATE NOT NULL,
administration_slot VARCHAR(80) NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (medication_id, scheduled_on, administration_slot)
);
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key; DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
@@ -495,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC); ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
ON medication_administrations (bird_id, administered_on DESC); ON medication_administrations (bird_id, administered_on DESC);
+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()]);
}
@@ -41,3 +41,4 @@ export const closeAdoptionReportQueue = async () => {
await adoptionReportQueueEvents.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');
+8 -2
View File
@@ -61,11 +61,17 @@ const formatWeight = (value: string | number | null) => {
}; };
const genderLabel = (value: string) => { const genderLabel = (value: string) => {
if (value === 'female_dna') {
return 'Female (DNA confirmed)';
}
if (value === 'male_dna') {
return 'Male (DNA confirmed)';
}
if (value === 'female') { if (value === 'female') {
return 'Female'; return 'Female (assumed)';
} }
if (value === 'male') { if (value === 'male') {
return 'Male'; return 'Male (assumed)';
} }
return 'Unknown'; return 'Unknown';
}; };
+114 -8
View File
@@ -9,6 +9,8 @@ import type {
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
MedicationReminderCandidateRow,
MedicationReminderDeliveryRow,
MedicationRow, MedicationRow,
PendingBirdTransferRow, PendingBirdTransferRow,
VetVisitRow, VetVisitRow,
@@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
const result = await db.query<MedicationReminderCandidateRow>(
`SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
medications.id AS medication_id,
medications.name AS medication_name,
medications.dosage,
medications.frequency,
medications.dose_schedule,
medications.route,
medications.start_date::text AS medication_start_date,
medications.end_date::text AS medication_end_date,
medications.notes AS medication_notes,
$1::date::text AS scheduled_on,
dose.key AS administration_slot,
dose.label AS administration_label,
dose.time AS administration_time
FROM medications
INNER JOIN birds ON birds.id = medications.bird_id
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE medications.reminders_enabled = TRUE
AND birds.memorialized_at IS NULL
AND medications.start_date <= $1::date
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
AND dose.time <= $2
AND NOT EXISTS (
SELECT 1
FROM medication_reminder_deliveries deliveries
WHERE deliveries.medication_id = medications.id
AND deliveries.scheduled_on = $1::date
AND deliveries.administration_slot = dose.key
)
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
[runDate, currentTime],
);
return result.rows;
};
export const createMedicationReminderDelivery = async ({
medicationId,
birdId,
workspaceId,
scheduledOn,
administrationSlot,
}: {
medicationId: string;
birdId: string;
workspaceId: number;
scheduledOn: string;
administrationSlot: string;
}) => {
const result = await db.query<MedicationReminderDeliveryRow>(
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
);
return result.rows[0] ?? null;
};
export const createBird = async ({ export const createBird = async ({
birdId, birdId,
workspaceId, workspaceId,
@@ -836,6 +911,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const updateWeightForBird = async (
weightId: string,
birdId: string,
weightGrams: number,
recordedOn: string,
notes: string | null,
) => {
const result = await db.query<WeightRow>(
`UPDATE weight_records
SET weight_grams = $3,
recorded_on = $4,
notes = $5
WHERE id = $1
AND bird_id = $2
AND id IN (
SELECT recent.id
FROM weight_records recent
WHERE recent.bird_id = $2
ORDER BY recent.recorded_on DESC, recent.created_at DESC
LIMIT 3
)
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
[weightId, birdId, weightGrams, recordedOn, notes],
);
return result.rows[0] ?? null;
};
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => { export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<VetVisitRow>( const result = await db.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes `SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
@@ -902,7 +1005,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes `SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
FROM medications FROM medications
WHERE bird_id = $1 WHERE bird_id = $1
AND EXISTS ( AND EXISTS (
@@ -928,12 +1031,13 @@ export const createMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -950,6 +1054,7 @@ export const updateMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`UPDATE medications `UPDATE medications
@@ -960,11 +1065,12 @@ export const updateMedicationForBird = async (
route = $7, route = $7,
start_date = $8, start_date = $8,
end_date = $9, end_date = $9,
notes = $10 notes = $10,
reminders_enabled = $11
WHERE id = $1 WHERE id = $1
AND bird_id = $2 AND bird_id = $2
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -3,6 +3,7 @@ import test from 'node:test';
import { import {
createWorkspace, createWorkspace,
deleteWorkspaceMember,
deleteWorkspaceIfEmpty, deleteWorkspaceIfEmpty,
ensureDefaultWorkspaceForUser, ensureDefaultWorkspaceForUser,
ensurePersonalWorkspaceForUser, ensurePersonalWorkspaceForUser,
@@ -10,6 +11,7 @@ import {
getPlatformAdminSummary, getPlatformAdminSummary,
listOwnedWorkspacesByOwnerEmail, listOwnedWorkspacesByOwnerEmail,
updateWorkspace, updateWorkspace,
updateWorkspaceMemberRole,
} from './workspaceRepository.js'; } from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
import type { UserRow } from '../types.js'; import type { UserRow } from '../types.js';
@@ -259,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
assert.match(calls[0].text, /workspaces\.id <> \$2/); assert.match(calls[0].text, /workspaces\.id <> \$2/);
}); });
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'member-1',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'helper@example.com',
name: 'Helper',
role: 'viewer',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'viewer',
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'viewer');
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /UPDATE workspace_members/);
assert.match(calls[0].text, /role <> 'owner'/);
});
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'owner-member',
workspaceId: 42,
role: 'viewer',
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'other-owner',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'other@example.com',
name: 'Other Owner',
role: 'assistant',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'other-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'assistant');
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /id <> \$5/);
});
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'billing-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'other-owner',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'other@example.com',
name: 'Other Owner',
role: 'assistant',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'other-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'assistant');
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
});
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'billing-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'member-1',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'helper@example.com',
name: 'Helper',
role: 'owner',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'owner');
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /\$3 <> 'owner'/);
});
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ id: 'member-1' }],
});
const deleted = await deleteWorkspaceMember({
memberId: 'member-1',
workspaceId: 42,
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
});
assert.equal(deleted, true);
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
assert.match(calls[0].text, /role <> 'owner'/);
});
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ id: 'other-owner' }],
});
const deleted = await deleteWorkspaceMember({
memberId: 'other-owner',
workspaceId: 42,
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
});
assert.equal(deleted, true);
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
assert.match(calls[0].text, /id <> \$4/);
});
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const deleted = await deleteWorkspaceMember({
memberId: 'billing-owner',
workspaceId: 42,
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
});
assert.equal(deleted, false);
});
test('getPlatformAdminSummary counts memorialized birds separately', async () => { test('getPlatformAdminSummary counts memorialized birds separately', async () => {
const { calls } = mockDb({ const { calls } = mockDb({
rowCount: 1, rowCount: 1,
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => { export const deleteWorkspaceMember = async ({
memberId,
workspaceId,
requesterMemberId,
requesterIsBillingOwner,
}: {
memberId: string;
workspaceId: number;
requesterMemberId: string;
requesterIsBillingOwner: boolean;
}) => {
const result = await db.query<{ id: string }>( const result = await db.query<{ id: string }>(
`DELETE FROM workspace_members `DELETE FROM workspace_members
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND role <> 'owner' AND (
role <> 'owner'
OR (
$3 = TRUE
AND id <> $4
)
)
RETURNING id`, RETURNING id`,
[memberId, workspaceId], [memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
); );
return Boolean(result.rowCount); return Boolean(result.rowCount);
}; };
export const updateWorkspaceMemberRole = async ({
memberId,
workspaceId,
role,
requesterMemberId,
requesterIsBillingOwner,
requesterRole,
billingEmail,
}: {
memberId: string;
workspaceId: number;
role: WorkspaceMemberRow['role'];
requesterMemberId: string;
requesterIsBillingOwner: boolean;
requesterRole: WorkspaceMemberRow['role'];
billingEmail: string;
}) => {
const result = await db.query<WorkspaceMemberRow>(
`UPDATE workspace_members
SET role = $3
WHERE id = $1
AND workspace_id = $2
AND (
$3 <> 'owner'
OR $4 = TRUE
)
AND (
role <> 'owner'
OR (
id <> $5
AND (
$4 = TRUE
OR (
$7 = 'owner'
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
)
)
)
)
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
);
return result.rows[0] ?? null;
};
export const listRescueWorkspacesForAdmin = async () => { export const listRescueWorkspacesForAdmin = async () => {
const result = await db.query< const result = await db.query<
WorkspaceRow & { WorkspaceRow & {
+29 -1
View File
@@ -6,7 +6,7 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type ProviderKey = 'google' | 'microsoft' | 'apple';
export type IntegrationTokenScope = 'read_only' | 'read_write'; export type IntegrationTokenScope = 'read_only' | 'read_write';
export type BirdGender = 'unknown' | 'male' | 'female'; export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
export type UserRow = { export type UserRow = {
id: string; id: string;
@@ -202,6 +202,7 @@ export type MedicationRow = {
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
notes: string | null; notes: string | null;
reminders_enabled: boolean;
}; };
export type MedicationDoseScheduleItem = { export type MedicationDoseScheduleItem = {
@@ -210,6 +211,33 @@ export type MedicationDoseScheduleItem = {
time: string; time: string;
}; };
export type MedicationReminderCandidateRow = BirdRow & {
workspace_name: string;
medication_id: string;
medication_name: string;
dosage: string;
frequency: string;
dose_schedule: MedicationDoseScheduleItem[];
route: string | null;
medication_start_date: string;
medication_end_date: string | null;
medication_notes: string | null;
scheduled_on: string;
administration_slot: string;
administration_label: string;
administration_time: string;
};
export type MedicationReminderDeliveryRow = {
id: string;
medication_id: string;
bird_id: string;
workspace_id: number;
scheduled_on: string;
administration_slot: string;
delivered_at: string;
};
export type MedicationAdministrationRow = { export type MedicationAdministrationRow = {
id: string; id: string;
medication_id: string; medication_id: string;
+35 -1
View File
@@ -2,7 +2,12 @@ import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js'; import { db } from './db/client.js';
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js'; import {
runBirdMilestoneReminders,
runMedicationReminders,
startBirdMilestoneReminderScheduler,
startMedicationReminderScheduler,
} from './app.js';
import { import {
adoptionReportQueueName, adoptionReportQueueName,
closeAdoptionReportQueue, closeAdoptionReportQueue,
@@ -15,10 +20,17 @@ import {
type BirdMilestoneReminderJobData, type BirdMilestoneReminderJobData,
type BirdMilestoneReminderJobResult, type BirdMilestoneReminderJobResult,
} from './queues/birdMilestoneReminderQueue.js'; } from './queues/birdMilestoneReminderQueue.js';
import {
closeMedicationReminderQueue,
medicationReminderQueueName,
type MedicationReminderJobData,
type MedicationReminderJobResult,
} from './queues/medicationReminderQueue.js';
import { redisConnection } from './queues/redisConnection.js'; import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null; let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null; let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
const startWorker = async () => { const startWorker = async () => {
@@ -43,6 +55,25 @@ const startWorker = async () => {
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
}); });
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
medicationReminderQueueName,
async (job) => {
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
console.log(
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
return result;
},
{
connection: redisConnection,
concurrency: 1,
},
);
medicationReminderWorker.on('failed', (job, error) => {
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
});
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>( adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
adoptionReportQueueName, adoptionReportQueueName,
async (job) => { async (job) => {
@@ -63,14 +94,17 @@ const startWorker = async () => {
}); });
startBirdMilestoneReminderScheduler(); startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler();
console.log('FlockPal worker started.'); console.log('FlockPal worker started.');
}; };
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`); console.log(`FlockPal worker received ${signal}; shutting down.`);
await birdMilestoneWorker?.close(); await birdMilestoneWorker?.close();
await medicationReminderWorker?.close();
await adoptionReportWorker?.close(); await adoptionReportWorker?.close();
await closeBirdMilestoneReminderQueue(); await closeBirdMilestoneReminderQueue();
await closeMedicationReminderQueue();
await closeAdoptionReportQueue(); await closeAdoptionReportQueue();
await db.close(); await db.close();
process.exit(0); process.exit(0);
+20
View File
@@ -59,6 +59,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -95,6 +96,12 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik - traefik.docker.network=traefik
@@ -139,6 +146,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
@@ -152,6 +160,12 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped restart: unless-stopped
frontend: frontend:
@@ -163,6 +177,12 @@ services:
container_name: flockpal-frontend container_name: flockpal-frontend
depends_on: depends_on:
- backend - backend
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik - traefik.docker.network=traefik
+2
View File
@@ -57,6 +57,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -132,6 +133,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
+38 -5
View File
@@ -212,7 +212,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"vetClinicAddress": "123 Feather Lane, Raleigh, NC", "vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001", "vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera", "vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female_dna",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "chartColor": "#cb3a35",
@@ -299,7 +299,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
- Dates use `YYYY-MM-DD` - Dates use `YYYY-MM-DD`
- `workspaceType` is `standard` or `rescue` - `workspaceType` is `standard` or `rescue`
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer` - member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
- bird `gender` is `unknown`, `male`, or `female` - bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex
- bird `chartColor` must be a `#RRGGBB` hex color - bird `chartColor` must be a `#RRGGBB` hex color
- `photoDataUrl` must be a base64 `data:image/...` URL - `photoDataUrl` must be a base64 `data:image/...` URL
- `weightGrams` must be a positive number up to `10000` - `weightGrams` must be a positive number up to `10000`
@@ -319,14 +319,47 @@ Validation failures return `400` with this shape:
#### `GET /api/health` #### `GET /api/health`
Public health check. Public readiness-compatible health check. Verifies backend dependencies.
Response `200`: Response `200`:
```json ```json
{ "ok": true } {
"ok": true,
"service": "flockpal-backend",
"status": "ready",
"checkedAt": "2026-06-06T00:00:00.000Z",
"dependencies": {
"postgres": { "ok": true, "latencyMs": 3 },
"redis": { "ok": true, "latencyMs": 4 }
}
}
``` ```
Response `503` when Postgres or Redis is unavailable.
#### `GET /api/health/live`
Public liveness check. Verifies the backend process is running without checking dependencies.
Response `200`:
```json
{
"ok": true,
"service": "flockpal-backend",
"status": "live",
"uptimeSeconds": 120,
"checkedAt": "2026-06-06T00:00:00.000Z"
}
```
#### `GET /api/health/ready`
Public readiness check. Verifies the backend can reach Postgres and Redis.
Response `200` uses the same shape as `GET /api/health`; response `503` means at least one dependency failed.
### Metrics ### Metrics
#### `GET /api/metrics` #### `GET /api/metrics`
@@ -801,7 +834,7 @@ Request body:
"vetClinicAddress": "123 Feather Lane, Raleigh, NC", "vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001", "vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera", "vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female_dna",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "chartColor": "#cb3a35",
+6
View File
@@ -12,6 +12,12 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+464 -196
View File
@@ -14,7 +14,7 @@ type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
type IntegrationTokenScope = 'read_only' | 'read_write'; type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female'; type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
type Bird = { type Bird = {
id: string; id: string;
@@ -75,6 +75,7 @@ type Medication = {
startDate: string; startDate: string;
endDate: string | null; endDate: string | null;
notes: string | null; notes: string | null;
remindersEnabled: boolean;
}; };
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed'; type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
@@ -505,6 +506,14 @@ const parseImportGender = (value: unknown): BirdGender | null => {
return 'unknown'; return 'unknown';
} }
if (['male dna', 'dna male', 'male_dna', 'dna confirmed male', 'male dna confirmed'].includes(gender)) {
return 'male_dna';
}
if (['female dna', 'dna female', 'female_dna', 'dna confirmed female', 'female dna confirmed'].includes(gender)) {
return 'female_dna';
}
if (gender === 'male' || gender === 'female') { if (gender === 'male' || gender === 'female') {
return gender; return gender;
} }
@@ -652,6 +661,8 @@ const emptyBirdForm: BirdFormState = {
publicProfileEnabled: false, publicProfileEnabled: false,
}; };
const birdGenderOptions: BirdGender[] = ['female', 'female_dna', 'male', 'male_dna', 'unknown'];
const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { const emptyVeterinaryInfoForm: VeterinaryInfoFormState = {
vetClinicName: '', vetClinicName: '',
vetClinicAddress: '', vetClinicAddress: '',
@@ -825,25 +836,89 @@ const formatShortDate = (value: string | null) => {
}; };
const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => { const getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female_dna') {
return 'Female (DNA confirmed)';
}
if (bird.gender === 'male_dna') {
return 'Male (DNA confirmed)';
}
if (bird.gender === 'female') { if (bird.gender === 'female') {
return 'Female'; return 'Female (assumed)';
} }
if (bird.gender === 'male') { if (bird.gender === 'male') {
return 'Male'; return 'Male (assumed)';
} }
return 'Unknown'; return 'Unknown';
}; };
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => { const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female') { if (bird.gender === 'female' || bird.gender === 'female_dna') {
return '♀'; return '♀';
} }
if (bird.gender === 'male') { if (bird.gender === 'male' || bird.gender === 'male_dna') {
return '♂'; return '♂';
} }
return '?'; return '?';
}; };
const getBirdGenderClass = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'female' || bird.gender === 'female_dna') {
return 'female';
}
if (bird.gender === 'male' || bird.gender === 'male_dna') {
return 'male';
}
return 'unknown';
};
const isDnaConfirmedGender = (bird: Pick<Bird, 'gender'>) => bird.gender === 'male_dna' || bird.gender === 'female_dna';
const getBirdGenderSourceLabel = (bird: Pick<Bird, 'gender'>) => {
if (bird.gender === 'unknown') {
return 'Sex unknown';
}
return isDnaConfirmedGender(bird) ? 'DNA confirmed' : 'Assumed sex';
};
const BirdGenderSourceIcon = ({ bird }: { bird: Pick<Bird, 'gender'> }) => {
if (bird.gender === 'unknown') {
return null;
}
const sourceLabel = getBirdGenderSourceLabel(bird);
if (isDnaConfirmedGender(bird)) {
return (
<span className="gender-source-icon dna" aria-label={sourceLabel} title={sourceLabel}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle className="dna-ring" cx="12" cy="12" r="8.7" />
<path className="dna-strand" d="M14.9 5.4c-4.6 2.6-6.2 4.6-5.8 7.1.3 2.2 2.1 3.8 5.8 6.1" />
<path className="dna-strand" d="M9.1 5.4c4.6 2.6 6.2 4.6 5.8 7.1-.3 2.2-2.1 3.8-5.8 6.1" />
<path className="dna-rung" d="M10.4 8.1h3.2" />
<path className="dna-rung" d="M9.4 10.7h5.2" />
<path className="dna-rung" d="M9.5 13.3h5" />
<path className="dna-rung" d="M10.4 15.9h3.2" />
<circle className="dna-dot" cx="14.9" cy="5.4" r="0.7" />
<circle className="dna-dot" cx="10.4" cy="8.1" r="0.85" />
<circle className="dna-dot" cx="9.4" cy="10.7" r="0.85" />
<circle className="dna-dot" cx="9.5" cy="13.3" r="0.85" />
<circle className="dna-dot" cx="10.4" cy="15.9" r="0.85" />
<circle className="dna-dot" cx="9.1" cy="18.6" r="0.7" />
</svg>
</span>
);
}
return (
<span className="gender-source-icon assumed" aria-label={sourceLabel} title={sourceLabel}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path className="assumed-eye" d="M3.8 12s3-4.6 8.2-4.6 8.2 4.6 8.2 4.6-3 4.6-8.2 4.6S3.8 12 3.8 12Z" />
<circle className="assumed-pupil" cx="12" cy="12" r="2.1" />
</svg>
</span>
);
};
const escapeReportHtml = (value: string | number | null | undefined) => const escapeReportHtml = (value: string | number | null | undefined) =>
String(value ?? '') String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -875,6 +950,8 @@ const formatAuditAction = (value: string) =>
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const getEditableWeights = (entries: WeightRecord[]) =>
[...entries].sort((left, right) => right.recordedOn.localeCompare(left.recordedOn)).slice(0, 3);
const daysBetweenDates = (startDate: string, endDate: string) => const daysBetweenDates = (startDate: string, endDate: string) =>
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000); Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
const addYearsToDate = (date: Date, years: number) => { const addYearsToDate = (date: Date, years: number) => {
@@ -1573,6 +1650,7 @@ function App() {
recordedOn: new Date().toISOString().slice(0, 10), recordedOn: new Date().toISOString().slice(0, 10),
notes: '', notes: '',
}); });
const [editingWeightId, setEditingWeightId] = useState('');
const [vetVisitForm, setVetVisitForm] = useState({ const [vetVisitForm, setVetVisitForm] = useState({
visitedOn: new Date().toISOString().slice(0, 10), visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '', clinicName: '',
@@ -1589,6 +1667,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
const [flockTransferForm, setFlockTransferForm] = useState({ const [flockTransferForm, setFlockTransferForm] = useState({
birdId: '', birdId: '',
@@ -1619,6 +1698,7 @@ function App() {
const [editingMedicationId, setEditingMedicationId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState('');
const [deletingMedicationId, setDeletingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState('');
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState(''); const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
const [updatingWorkspaceMemberId, setUpdatingWorkspaceMemberId] = useState('');
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null); const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
@@ -1626,8 +1706,13 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId], [birds, selectedBirdId],
); );
const isBillingOwner = Boolean(
authSession?.user.email &&
workspace?.billingEmail &&
authSession.user.email.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
);
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : ''; const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo( const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null, () => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId], [birds, editingBirdId],
); );
@@ -1719,6 +1804,8 @@ function App() {
), ),
[allBirdWeights, birds, overviewWindowStartDate], [allBirdWeights, birds, overviewWindowStartDate],
); );
const editableWeights = useMemo(() => getEditableWeights(weights), [weights]);
const editableWeightIds = useMemo(() => new Set(editableWeights.map((weight) => weight.id)), [editableWeights]);
const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird); const showFlockDetailColumn = bulkWeightOpen || birdEditorOpen || Boolean(selectedBird);
@@ -3347,8 +3434,9 @@ function App() {
setError(''); setError('');
try { try {
const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, { const isEditingWeight = Boolean(editingWeightId);
method: 'POST', const response = await apiFetch(isEditingWeight ? `/birds/${selectedBird.id}/weights/${editingWeightId}` : `/birds/${selectedBird.id}/weights`, authToken, {
method: isEditingWeight ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
weightGrams: Number(weightForm.weightGrams), weightGrams: Number(weightForm.weightGrams),
@@ -3365,7 +3453,13 @@ function App() {
if (!data?.weight) { if (!data?.weight) {
throw new Error('Unable to save weight.'); throw new Error('Unable to save weight.');
} }
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn)); const nextWeights = (
isEditingWeight ? weights.map((weight) => (weight.id === data.weight.id ? data.weight : weight)) : [...weights, data.weight]
).sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
const latestWeight = nextWeights.reduce<WeightRecord | null>(
(latest, weight) => (!latest || weight.recordedOn >= latest.recordedOn ? weight : latest),
null,
);
setWeights(nextWeights); setWeights(nextWeights);
setAllBirdWeights((current) => ({ setAllBirdWeights((current) => ({
@@ -3381,18 +3475,39 @@ function App() {
bird.id === selectedBird.id bird.id === selectedBird.id
? { ? {
...bird, ...bird,
latestWeightGrams: data.weight.weightGrams, latestWeightGrams: latestWeight?.weightGrams ?? null,
latestRecordedOn: data.weight.recordedOn, latestRecordedOn: latestWeight?.recordedOn ?? null,
} }
: bird, : bird,
), ),
); );
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' }); setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
setEditingWeightId('');
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.'); setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
} }
}; };
const handleEditWeight = (weight: WeightRecord) => {
if (!editableWeightIds.has(weight.id)) {
setError('Only the 3 most recent weight entries can be edited.');
return;
}
setEditingWeightId(weight.id);
setWeightForm({
weightGrams: String(weight.weightGrams),
recordedOn: weight.recordedOn,
notes: weight.notes ?? '',
});
setError('');
};
const handleCancelWeightEdit = () => {
setEditingWeightId('');
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
};
const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => { const handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
setBulkWeightRows((current) => ({ setBulkWeightRows((current) => ({
...current, ...current,
@@ -3653,6 +3768,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
setEditingMedicationId(''); setEditingMedicationId('');
} catch (submitError) { } catch (submitError) {
@@ -3672,6 +3788,7 @@ function App() {
startDate: medication.startDate, startDate: medication.startDate,
endDate: medication.endDate ?? '', endDate: medication.endDate ?? '',
notes: medication.notes ?? '', notes: medication.notes ?? '',
remindersEnabled: medication.remindersEnabled,
}); });
setError(''); setError('');
}; };
@@ -3687,6 +3804,7 @@ function App() {
startDate: new Date().toISOString().slice(0, 10), startDate: new Date().toISOString().slice(0, 10),
endDate: '', endDate: '',
notes: '', notes: '',
remindersEnabled: false,
}); });
}; };
@@ -4225,150 +4343,185 @@ function App() {
} }
body { body {
background: ${bodyBackground}; background: ${bodyBackground};
box-sizing: border-box;
color: var(--ink); color: var(--ink);
display: flex;
font-family: Inter, Arial, sans-serif; font-family: Inter, Arial, sans-serif;
line-height: 1.45; font-size: 13px;
margin: 32px; justify-content: center;
line-height: 1.38;
margin: 0;
min-height: 100vh; min-height: 100vh;
padding: 24px;
position: relative; position: relative;
} }
*, *::before, *::after { box-sizing: inherit; }
img, svg { max-width: 100%; }
${backgroundOverlayCss} ${backgroundOverlayCss}
.report-page {
background: ${printFriendly ? '#fff' : 'rgba(255, 253, 249, 0.82)'};
border: 1px solid ${printFriendly ? 'transparent' : 'rgba(53, 129, 98, 0.12)'};
border-radius: ${printFriendly ? '0' : '18px'};
box-shadow: ${printFriendly ? 'none' : '0 18px 42px rgba(86, 63, 34, 0.16)'};
max-width: 100%;
padding: 24px;
width: 720px;
}
header { header {
background: ${headerBackground}; background: ${headerBackground};
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14); box-shadow: 0 10px 22px rgba(86, 63, 34, 0.12);
display: grid; display: grid;
gap: 22px; gap: 10px;
grid-template-columns: 210px 1fr 320px; grid-template-columns: 100px minmax(220px, 1fr) 124px;
min-height: 228px; min-height: 120px;
padding: 18px; padding: 12px;
} }
h1, h2, h3, p { margin: 0; } h1, h2, h3, p { margin: 0; }
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; } h1 { color: var(--red); font-size: 21px; letter-spacing: 0; }
h2 { h2 {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--green); color: var(--green);
font-size: 19px; font-size: 16px;
margin: 28px 0 12px; margin: 20px 0 8px;
padding-bottom: 8px; padding-bottom: 6px;
} }
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; } h3 { color: var(--blue); font-size: 12px; margin: 14px 0 6px; text-transform: uppercase; }
.muted { color: var(--muted); margin-top: 6px; } .muted { color: var(--muted); margin-top: 6px; }
.brand-logo { .brand-logo {
align-self: center; align-self: center;
height: 210px; height: auto;
justify-self: start; justify-self: start;
max-height: 98px;
object-fit: contain; object-fit: contain;
width: 210px; width: 98px;
} }
.report-title { .report-title {
align-self: center; align-self: center;
justify-self: center; justify-self: center;
text-align: center; text-align: center;
} }
.report-title .muted { margin-top: 8px; } .report-title .muted { margin-top: 4px; }
.profile-photo { .profile-photo {
aspect-ratio: 1; aspect-ratio: 1;
background: #fff; background: #fff;
border: 3px solid var(--paper); border: 2px solid var(--paper);
border-radius: 18px; border-radius: 12px;
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16); box-shadow: 0 8px 16px rgba(86, 63, 34, 0.14);
height: 132px; height: 70px;
margin: 0 auto 12px; margin: 0 auto 5px;
object-fit: cover; object-fit: cover;
width: 132px; width: 70px;
} }
.qr { align-self: center; justify-self: end; text-align: center; width: 320px; } .qr { align-self: center; justify-self: end; max-width: 124px; text-align: center; width: 100%; }
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; } .qr svg { background: #fff; border: 1px solid var(--border); border-radius: 9px; padding: 5px; width: 70px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; } .code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; overflow-wrap: anywhere; }
.qr-join-label { .qr-join-label {
color: var(--green); color: var(--green);
font-size: 12px; font-size: 9px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
line-height: 1; line-height: 1;
margin-bottom: -28px; margin-bottom: -10px;
position: relative; position: relative;
text-transform: uppercase; text-transform: uppercase;
z-index: 1; z-index: 1;
} }
.qr-wordmark { .qr-wordmark {
display: block; display: block;
height: 150px; height: auto;
margin: -28px auto -12px; margin: -10px auto -4px;
max-height: 50px;
object-fit: contain; object-fit: contain;
width: 340px; width: min(100%, 124px);
} }
.qr-note { .qr-note {
color: var(--blue); color: var(--blue);
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif; font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
font-size: 12px; font-size: 9px;
font-weight: 800; font-weight: 800;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.28; line-height: 1.28;
margin-top: 8px; margin-top: 3px;
} }
.grid { stroke: rgba(53, 129, 98, 0.16); } .grid { stroke: rgba(53, 129, 98, 0.16); }
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; } .current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; } .historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; } .dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); } .facts { display: grid; gap: 7px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } .fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 7px 9px; }
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; } .fact span { color: var(--muted); display: block; font-size: 10px; margin-bottom: 3px; text-transform: uppercase; }
table { border-collapse: collapse; width: 100%; } table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; } th, td { border-bottom: 1px solid var(--border); padding: 6px 7px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; } th { color: var(--muted); font-size: 10px; text-transform: uppercase; }
.note { border-bottom: 1px solid var(--border); padding: 10px 0; } .note { border-bottom: 1px solid var(--border); padding: 7px 0; }
.note p { margin-top: 6px; white-space: pre-wrap; } .note p { margin-top: 4px; white-space: pre-wrap; }
main { margin-top: 24px; } main { margin-top: 16px; }
@media print { @media print {
body { margin: 14mm; } @page { margin: 0.4in; size: letter; }
body { display: block; margin: 0; padding: 0; }
.report-page { border: 0; border-radius: 0; box-shadow: none; padding: 0; width: 7.7in; }
header { box-shadow: none; break-inside: avoid; } header { box-shadow: none; break-inside: avoid; }
button { display: none; } button { display: none; }
} }
@media (max-width: 820px) {
body { padding: 18px; }
.report-page { padding: 18px; width: 100%; }
header {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.brand-logo,
.qr {
justify-self: center;
}
}
</style> </style>
</head> </head>
<body> <body>
<header> <div class="report-page">
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo"> <header>
<div class="report-title"> <img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo"> <div class="report-title">
<h1>${escapeReportHtml(selectedBird.name)}</h1> <img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
<p class="muted">Adoption Report</p> <h1>${escapeReportHtml(selectedBird.name)}</h1>
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p> <p class="muted">Adoption Report</p>
</div> <p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
<div class="qr"> </div>
<p class="qr-join-label">Join</p> <div class="qr">
<img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal"> <p class="qr-join-label">Join</p>
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR"> <img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal">
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect> <svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path> <rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
</svg> <path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
<p class="code">${escapeReportHtml(transferCode)}</p> </svg>
<p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p> <p class="code">${escapeReportHtml(transferCode)}</p>
</div> <p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p>
</header> </div>
<main> </header>
<h2>Flock Member Info</h2> <main>
<section class="facts"> <h2>Flock Member Info</h2>
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')} <section class="facts">
</section> ${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
${detailList('Motivators', selectedBird.motivators)} </section>
${detailList('Demotivators', selectedBird.demotivators)} ${detailList('Motivators', selectedBird.motivators)}
<h2>Weight Graph</h2> ${detailList('Demotivators', selectedBird.demotivators)}
${chartSvg} <h2>Weight Graph</h2>
<h2>Weight History</h2> ${chartSvg}
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table> <h2>Weight History</h2>
<h2>Veterinary Clinic Info</h2> <table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
<section class="facts"> <h2>Veterinary Clinic Info</h2>
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')} <section class="facts">
</section> ${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
<h2>Vet Visit History</h2> </section>
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table> <h2>Vet Visit History</h2>
<h2>Notes</h2> <table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
${noteRows} <h2>Notes</h2>
</main> ${noteRows}
</main>
</div>
</body> </body>
</html>`); </html>`);
reportWindow.document.close(); reportWindow.document.close();
@@ -4672,7 +4825,15 @@ function App() {
email: data.member.inviteEmail, email: data.member.inviteEmail,
}; };
setWorkspaceMembers((current) => [...current, nextMember]); setWorkspaceMembers((current) => {
const existingIndex = current.findIndex((member) => member.id === nextMember.id);
if (existingIndex === -1) {
return [...current, nextMember];
}
return current.map((member) => (member.id === nextMember.id ? nextMember : member));
});
setWorkspaceMemberForm(emptyWorkspaceMemberForm); setWorkspaceMemberForm(emptyWorkspaceMemberForm);
} catch (memberError) { } catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.'); setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
@@ -4681,6 +4842,40 @@ function App() {
} }
}; };
const handleUpdateWorkspaceMemberRole = async (memberId: string, role: WorkspaceRole) => {
setError('');
setUpdatingWorkspaceMemberId(memberId);
try {
const response = await apiFetch(`/workspace/members/${memberId}`, authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update collaborator role.'));
}
const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {};
if (!data.member) {
throw new Error('Unable to update collaborator role.');
}
const updatedMember = {
...data.member,
email: data.member.inviteEmail,
};
setWorkspaceMembers((current) => current.map((member) => (member.id === memberId ? updatedMember : member)));
} catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to update collaborator role.');
} finally {
setUpdatingWorkspaceMemberId('');
}
};
const handleRemoveWorkspaceMember = async (memberId: string) => { const handleRemoveWorkspaceMember = async (memberId: string) => {
setError(''); setError('');
setRemovingWorkspaceMemberId(memberId); setRemovingWorkspaceMemberId(memberId);
@@ -4752,9 +4947,10 @@ function App() {
<div className="public-profile-copy"> <div className="public-profile-copy">
<h1> <h1>
<span>{publicProfile.name}</span> <span>{publicProfile.name}</span>
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}> <span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${getBirdGenderClass(publicProfile)}`}>
{getBirdGenderSymbol(publicProfile)} <span className="gender-symbol-mark">{getBirdGenderSymbol(publicProfile)}</span>
</span> </span>
<BirdGenderSourceIcon bird={publicProfile} />
</h1> </h1>
<article className="summary-card"> <article className="summary-card">
<span>Hatch Day</span> <span>Hatch Day</span>
@@ -4953,6 +5149,7 @@ function App() {
<small> <small>
{formatDate(medication.startDate)} to {formatDate(medication.endDate)} {formatDate(medication.startDate)} to {formatDate(medication.endDate)}
</small> </small>
<small>{medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'}</small>
<small>{medication.notes || 'No notes recorded.'}</small> <small>{medication.notes || 'No notes recorded.'}</small>
{latestAdministration ? ( {latestAdministration ? (
<small> <small>
@@ -5429,8 +5626,11 @@ function App() {
<div className="bird-card-copy"> <div className="bird-card-copy">
<span className="bird-card-title"> <span className="bird-card-title">
<span>{bird.name}</span> <span>{bird.name}</span>
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}> <span className="bird-card-gender-cluster">
{getBirdGenderSymbol(bird)} <span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${getBirdGenderClass(bird)}`}>
{getBirdGenderSymbol(bird)}
</span>
<BirdGenderSourceIcon bird={bird} />
</span> </span>
</span> </span>
<small>{bird.species}</small> <small>{bird.species}</small>
@@ -5627,7 +5827,7 @@ function App() {
<div className="segmented-field wide-field"> <div className="segmented-field wide-field">
<span>Gender</span> <span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender"> <div className="segmented-control" role="radiogroup" aria-label="Bird gender">
{(['unknown', 'male', 'female'] as BirdGender[]).map((gender) => ( {birdGenderOptions.map((gender) => (
<button <button
key={gender} key={gender}
className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`} className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
@@ -5636,9 +5836,10 @@ function App() {
role="radio" role="radio"
aria-checked={birdForm.gender === gender} aria-checked={birdForm.gender === gender}
> >
<span className={`gender-symbol ${gender}`} aria-hidden="true"> <span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
{getBirdGenderSymbol({ gender })} <span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
</span> </span>
<BirdGenderSourceIcon bird={{ gender }} />
{getBirdGenderLabel({ gender })} {getBirdGenderLabel({ gender })}
</button> </button>
))} ))}
@@ -5960,6 +6161,17 @@ function App() {
))} ))}
</div> </div>
</label> </label>
<label className="checkbox-row wide-field">
<input
type="checkbox"
checked={medicationForm.remindersEnabled}
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
/>
<span>
<strong>Send medication reminders</strong>
<small>Uses the medication frequency, dose times, and start/end dates.</small>
</span>
</label>
<label className="wide-field"> <label className="wide-field">
Notes Notes
<textarea <textarea
@@ -6073,26 +6285,26 @@ function App() {
aria-selected={selectedBirdTab === 'notes'} aria-selected={selectedBirdTab === 'notes'}
aria-label="Notes" aria-label="Notes"
title="Notes" title="Notes"
> >
<svg className="note-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false"> <svg className="note-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" /> <path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
</svg> </svg>
</button> </button>
<button <button
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`} className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('reports')} onClick={() => setSelectedBirdTab('reports')}
type="button" type="button"
role="tab" role="tab"
aria-selected={selectedBirdTab === 'reports'} aria-selected={selectedBirdTab === 'reports'}
aria-label="Reports" aria-label="Reports"
title="Reports" title="Reports"
> >
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false"> <svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M280-280h80v-240h-80v240Zm160 0h80v-400h-80v400Zm160 0h80v-120h-80v120ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Z" /> <path d="M280-280h80v-240h-80v240Zm160 0h80v-400h-80v400Zm160 0h80v-120h-80v120ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Z" />
</svg> </svg>
</button> </button>
<button <button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`} className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')} onClick={() => setSelectedBirdTab('audit')}
type="button" type="button"
role="tab" role="tab"
@@ -6186,10 +6398,11 @@ function App() {
<span>{selectedBird.name}</span> <span>{selectedBird.name}</span>
<span <span
aria-label={getBirdGenderLabel(selectedBird)} aria-label={getBirdGenderLabel(selectedBird)}
className={`gender-symbol ${selectedBird.gender}`} className={`gender-symbol ${getBirdGenderClass(selectedBird)}`}
> >
{getBirdGenderSymbol(selectedBird)} <span className="gender-symbol-mark">{getBirdGenderSymbol(selectedBird)}</span>
</span> </span>
<BirdGenderSourceIcon bird={selectedBird} />
</h3> </h3>
<p className="muted"> <p className="muted">
{selectedBird.species} {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'} {selectedBird.species} {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
@@ -6426,11 +6639,11 @@ function App() {
<label> <label>
Recorded on Recorded on
<input <input
type="date" type="date"
value={weightForm.recordedOn} value={weightForm.recordedOn}
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })} onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
required required
/> />
</label> </label>
<label className="wide-field"> <label className="wide-field">
Notes Notes
@@ -6441,22 +6654,45 @@ function App() {
placeholder="Optional notes about appetite, molt, meds, or behavior" placeholder="Optional notes about appetite, molt, meds, or behavior"
/> />
</label> </label>
<button className="primary-button" type="submit"> <div className="button-row care-form-actions">
Save weight <button className="primary-button" type="submit">
</button> {editingWeightId ? 'Save weight changes' : 'Save weight'}
</form> </button>
</section> {editingWeightId ? (
<button className="secondary-button" onClick={handleCancelWeightEdit} type="button">
Cancel edit
</button>
) : null}
</div>
</form>
<div className="recent-list">
{editableWeights
.map((weight) => (
<article className="vet-visit-card" key={weight.id}>
<strong>{formatWeight(weight.weightGrams)}</strong>
<span>{formatDate(weight.recordedOn)}</span>
<small>{weight.notes || 'No notes recorded.'}</small>
<div className="button-row">
<button className="secondary-button" onClick={() => handleEditWeight(weight)} type="button">
Edit
</button>
</div>
</article>
))}
{!editableWeights.length ? <p className="muted">No weight entries recorded yet.</p> : null}
</div>
</section>
</div> </div>
) : null} ) : null}
{selectedBirdTab === 'vet' ? ( {selectedBirdTab === 'vet' ? (
<div className="flock-member-sections" role="tabpanel"> <div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel"> <section className="panel inset-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Veterinary</p> <p className="eyebrow">Veterinary</p>
<h2>Clinic account</h2> <h2>Clinic account</h2>
</div> </div>
{!editingVeterinaryInfo ? ( {!editingVeterinaryInfo ? (
<button <button
className="profile-icon-button" className="profile-icon-button"
@@ -6550,9 +6786,9 @@ function App() {
)} )}
</section> </section>
<section className="panel inset-panel"> <section className="panel inset-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Vet visits</p> <p className="eyebrow">Vet visits</p>
<h2>Care history and notes</h2> <h2>Care history and notes</h2>
</div> </div>
@@ -6695,13 +6931,13 @@ function App() {
</article> </article>
)} )}
</div> </div>
</section> </section>
</div> </div>
) : null} ) : null}
{selectedBirdTab === 'reports' ? ( {selectedBirdTab === 'reports' ? (
<div className="flock-member-sections" role="tabpanel"> <div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel"> <section className="panel inset-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Reports</p> <p className="eyebrow">Reports</p>
@@ -6731,11 +6967,11 @@ function App() {
{adoptionReportError} {adoptionReportError}
</p> </p>
) : null} ) : null}
</section> </section>
</div> </div>
) : null} ) : null}
{selectedBirdTab === 'audit' ? ( {selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel"> <div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel"> <section className="panel inset-panel">
<div className="panel-header"> <div className="panel-header">
@@ -7300,7 +7536,7 @@ function App() {
}) })
} }
> >
<option value="owner">Owner</option> {isBillingOwner ? <option value="owner">Owner</option> : null}
<option value="assistant">Assistant</option> <option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option> <option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>
@@ -7313,23 +7549,64 @@ function App() {
<div className="recent-list"> <div className="recent-list">
{workspaceMembers.length ? ( {workspaceMembers.length ? (
workspaceMembers.map((member) => ( workspaceMembers.map((member) => {
<article key={member.id} className="vet-visit-card"> const memberEmail = member.email || member.inviteEmail || '';
<strong>{member.name}</strong> const memberIsBillingOwner = Boolean(
<span> workspace?.billingEmail &&
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail} memberEmail.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
</span> );
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small> const canRemoveOwner = member.role === 'owner' && isBillingOwner && member.id !== activeMembership?.id;
<button const canChangeOwnerRole =
className="secondary-button" member.role === 'owner' &&
onClick={() => handleRemoveWorkspaceMember(member.id)} activeMembership?.role === 'owner' &&
type="button" member.id !== activeMembership.id &&
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'} (isBillingOwner || !memberIsBillingOwner);
> const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'} const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
</button>
</article> return (
)) <article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<label>
Role
<select
value={member.role}
onChange={(event) => handleUpdateWorkspaceMemberRole(member.id, event.target.value as WorkspaceRole)}
disabled={
(member.role === 'owner' && !canChangeOwnerRole) ||
updatingWorkspaceMemberId === member.id ||
removingWorkspaceMemberId === member.id
}
>
{member.role === 'owner' || canPromoteToOwner ? <option value="owner">Owner</option> : null}
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button
className="danger-button"
onClick={() => handleRemoveWorkspaceMember(member.id)}
type="button"
disabled={
removingWorkspaceMemberId === member.id ||
updatingWorkspaceMemberId === member.id ||
!canRemoveMember
}
>
{removingWorkspaceMemberId === member.id
? 'Removing...'
: canRemoveMember
? 'Remove access'
: 'Owner'}
</button>
</article>
);
})
) : ( ) : (
<article className="vet-visit-card empty-card"> <article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong> <strong>No collaborators yet</strong>
@@ -7553,44 +7830,24 @@ function App() {
<div className="segmented-field wide-field"> <div className="segmented-field wide-field">
<span>Gender</span> <span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender"> <div className="segmented-control" role="radiogroup" aria-label="Bird gender">
<button {birdGenderOptions.map((gender) => (
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`} <button
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })} key={gender}
type="button" className={`segmented-option ${birdForm.gender === gender ? 'active' : ''}`}
role="radio" onClick={() => setBirdForm({ ...birdForm, gender })}
aria-checked={birdForm.gender === 'unknown'} type="button"
> role="radio"
<span className="gender-symbol unknown" aria-hidden="true"> aria-checked={birdForm.gender === gender}
? >
</span> <span className={`gender-symbol ${getBirdGenderClass({ gender })}`} aria-hidden="true">
Unknown <span className="gender-symbol-mark">{getBirdGenderSymbol({ gender })}</span>
</button> </span>
<button <BirdGenderSourceIcon bird={{ gender }} />
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`} {getBirdGenderLabel({ gender })}
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })} </button>
type="button" ))}
role="radio"
aria-checked={birdForm.gender === 'male'}
>
<span className="gender-symbol male" aria-hidden="true">
</span>
Male
</button>
<button
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
type="button"
role="radio"
aria-checked={birdForm.gender === 'female'}
>
<span className="gender-symbol female" aria-hidden="true">
</span>
Female
</button>
</div> </div>
<small className="muted">Shown on the bird profile card as a symbol.</small> <small className="muted">Shown on the bird profile card and reports.</small>
</div> </div>
<div className="settings-inline-header wide-field"> <div className="settings-inline-header wide-field">
<p className="eyebrow">Dates</p> <p className="eyebrow">Dates</p>
@@ -7930,6 +8187,17 @@ function App() {
))} ))}
</div> </div>
</label> </label>
<label className="checkbox-row wide-field">
<input
type="checkbox"
checked={medicationForm.remindersEnabled}
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
/>
<span>
<strong>Send medication reminders</strong>
<small>Uses the medication frequency, dose times, and start/end dates.</small>
</span>
</label>
<label className="wide-field"> <label className="wide-field">
Notes Notes
<textarea <textarea
+118 -4
View File
@@ -883,10 +883,19 @@ textarea {
display: inline; display: inline;
} }
.bird-card-title .bird-card-gender-cluster {
display: inline-flex;
align-items: center;
gap: 0.28rem;
line-height: 1;
}
.gender-inline { .gender-inline {
font-size: 1.2rem; font-size: 1.45rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
} }
.gender-inline.male { .gender-inline.male {
@@ -901,6 +910,99 @@ textarea {
color: var(--muted); color: var(--muted);
} }
.gender-source-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.45rem;
height: 1.45rem;
flex: 0 0 1.45rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
vertical-align: middle;
}
.gender-source-icon svg {
width: 1.28rem;
height: 1.28rem;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.gender-source-icon.dna {
width: 1.9rem;
height: 1.9rem;
flex-basis: 1.9rem;
background: rgba(91, 74, 161, 0.1);
color: #5b4aa1;
}
.gender-source-icon.dna svg {
width: 1.72rem;
height: 1.72rem;
}
.gender-source-icon .dna-ring {
fill: none;
stroke: currentColor;
stroke-width: 1.15;
}
.gender-source-icon .dna-strand {
stroke: currentColor;
stroke-width: 1.35;
}
.gender-source-icon .dna-rung {
stroke: currentColor;
stroke-width: 1.05;
opacity: 0.82;
}
.gender-source-icon .dna-dot {
fill: currentColor;
}
.gender-source-icon.assumed {
width: 1.9rem;
height: 1.9rem;
flex-basis: 1.9rem;
background: rgba(93, 95, 89, 0.12);
color: var(--muted);
}
.gender-source-icon.assumed svg {
width: 1.72rem;
height: 1.72rem;
}
.gender-source-icon .assumed-eye {
stroke: currentColor;
stroke-width: 1.7;
}
.gender-source-icon .assumed-pupil {
fill: currentColor;
}
.bird-card-title .gender-source-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.05rem;
height: 1.05rem;
flex-basis: 1.05rem;
}
.bird-card-title .gender-source-icon svg {
display: block;
width: 0.92rem;
height: 0.92rem;
}
.bird-avatar, .bird-avatar,
.profile-photo { .profile-photo {
width: 56px; width: 56px;
@@ -1258,14 +1360,26 @@ textarea {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 1.9rem; width: 1.9rem;
height: 1.9rem; height: 1.9rem;
flex: 0 0 1.9rem;
border-radius: 999px; border-radius: 999px;
font-size: 1.2rem; font-size: 1.45rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.gender-symbol-mark {
display: block;
line-height: 0.82;
transform: scale(1.16);
transform-origin: center;
}
.profile-title .gender-symbol-mark {
transform: scale(1.28);
}
.gender-symbol.male { .gender-symbol.male {
background: rgba(39, 105, 179, 0.12); background: rgba(39, 105, 179, 0.12);
color: var(--accent-blue); color: var(--accent-blue);
@@ -1288,7 +1402,7 @@ textarea {
.segmented-control { .segmented-control {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.55rem; gap: 0.55rem;
} }