Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5735bb7735 | |||
| 88ff06237e | |||
| fbb13561b0 | |||
| b15861c856 | |||
| 2aeaa119f7 | |||
| 36690c0174 | |||
| b76ad35c07 | |||
| 6918b55a58 | |||
| 49f1713e26 |
+299
-5
@@ -13,8 +13,9 @@ import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ensureSchema } from './db/schema.js';
|
||||
import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js';
|
||||
import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
|
||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||
import {
|
||||
consumeMagicLinkToken,
|
||||
consumeOAuthState,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
completePendingBirdTransfersForOwner,
|
||||
createBird,
|
||||
createBirdMilestoneReminderDelivery,
|
||||
createMedicationReminderDelivery,
|
||||
createBirdTransferCode,
|
||||
createMedicationForBird,
|
||||
createPendingBirdTransfer,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
getOpenBirdTransferCodeForBird,
|
||||
listBirds,
|
||||
listDueBirdMilestoneReminders,
|
||||
listDueMedicationReminders,
|
||||
listMemorializedBirds,
|
||||
listMedicationAdministrationsForBird,
|
||||
listMedicationsForBird,
|
||||
@@ -105,6 +108,7 @@ import {
|
||||
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
||||
updateRescueVerificationStatus,
|
||||
updateWorkspace,
|
||||
updateWorkspaceMemberRole,
|
||||
upsertWorkspaceMember,
|
||||
} from './repositories/workspaceRepository.js';
|
||||
import type {
|
||||
@@ -118,8 +122,9 @@ import type {
|
||||
FlockNoteRow,
|
||||
IntegrationTokenRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationRow,
|
||||
MedicationAdministrationRow,
|
||||
MedicationReminderCandidateRow,
|
||||
MedicationRow,
|
||||
ProviderKey,
|
||||
RescueVerificationStatus,
|
||||
SubscriptionStatus,
|
||||
@@ -148,10 +153,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
||||
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
||||
const sessionDays = 30;
|
||||
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
||||
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||
|
||||
if (trustProxy) {
|
||||
@@ -325,6 +331,7 @@ const medicationSchema = z
|
||||
startDate: dateStringSchema,
|
||||
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
remindersEnabled: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
||||
message: 'End date must be on or after start date.',
|
||||
@@ -720,6 +727,7 @@ const normalizeMedication = (row: MedicationRow) => ({
|
||||
startDate: row.start_date,
|
||||
endDate: row.end_date,
|
||||
notes: row.notes,
|
||||
remindersEnabled: row.reminders_enabled,
|
||||
});
|
||||
|
||||
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
||||
@@ -1218,6 +1226,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
|
||||
return `${hour === '24' ? '00' : hour}:${minute}`;
|
||||
};
|
||||
|
||||
const formatOrdinal = (value: number) => {
|
||||
const remainder = value % 100;
|
||||
if (remainder >= 11 && remainder <= 13) {
|
||||
@@ -1725,6 +1745,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
||||
};
|
||||
};
|
||||
|
||||
const formatReminderDoseTime = (time: string) => {
|
||||
const [rawHour, rawMinute] = time.split(':');
|
||||
const hour = Number(rawHour);
|
||||
const minute = rawMinute ?? '00';
|
||||
if (Number.isNaN(hour)) {
|
||||
return time;
|
||||
}
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour % 12 || 12;
|
||||
return `${displayHour}:${minute} ${period}`;
|
||||
};
|
||||
|
||||
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
|
||||
const doseTime = formatReminderDoseTime(reminder.administration_time);
|
||||
const slotLabel = reminder.administration_label || 'Dose';
|
||||
const route = reminder.route ? ` by ${reminder.route}` : '';
|
||||
|
||||
return {
|
||||
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
||||
eyebrow: 'Medication Reminder',
|
||||
headline: `${slotLabel} time for ${reminder.name}`,
|
||||
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
|
||||
body: `Dose: ${reminder.dosage}${route}.`,
|
||||
detailLabel: `${slotLabel} at ${doseTime}`,
|
||||
};
|
||||
};
|
||||
|
||||
const sendBirdMilestoneReminderNotification = async ({
|
||||
reminder,
|
||||
recipients,
|
||||
@@ -1831,6 +1878,120 @@ const sendBirdMilestoneReminderNotification = async ({
|
||||
return { delivered: true };
|
||||
};
|
||||
|
||||
const sendMedicationReminderNotification = async ({
|
||||
reminder,
|
||||
recipients,
|
||||
}: {
|
||||
reminder: MedicationReminderCandidateRow;
|
||||
recipients: string[];
|
||||
}) => {
|
||||
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
||||
|
||||
if (!uniqueRecipients.length) {
|
||||
return { delivered: false };
|
||||
}
|
||||
|
||||
const copy = buildMedicationReminderCopy(reminder);
|
||||
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
||||
const logoAttachment = getFlockPalLogoAttachment();
|
||||
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
|
||||
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
||||
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
||||
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
||||
|
||||
if (logoAttachment) {
|
||||
attachments.push(logoAttachment);
|
||||
}
|
||||
|
||||
if (uploadedBirdPhoto) {
|
||||
attachments.push({
|
||||
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
|
||||
content: uploadedBirdPhoto.content,
|
||||
contentType: uploadedBirdPhoto.contentType,
|
||||
cid: birdPhotoCid,
|
||||
contentDisposition: 'inline',
|
||||
});
|
||||
} else if (defaultBirdPhoto) {
|
||||
attachments.push(defaultBirdPhoto);
|
||||
}
|
||||
|
||||
const birdPhotoHtml = birdPhotoCid
|
||||
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
|
||||
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
|
||||
const medicationNotesHtml = reminder.medication_notes
|
||||
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
|
||||
: '';
|
||||
const lines = [
|
||||
copy.headline,
|
||||
'',
|
||||
copy.intro,
|
||||
copy.body,
|
||||
'',
|
||||
`Bird: ${reminder.name}`,
|
||||
`Medication: ${reminder.medication_name}`,
|
||||
`Dose: ${reminder.dosage}`,
|
||||
`Scheduled dose: ${copy.detailLabel}`,
|
||||
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
|
||||
'',
|
||||
`Open FlockPal: ${frontendBaseUrl}`,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!mailTransport) {
|
||||
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
||||
return { delivered: false };
|
||||
}
|
||||
|
||||
await mailTransport.sendMail({
|
||||
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||
to: smtpFromEmail,
|
||||
bcc: uniqueRecipients,
|
||||
subject: copy.subject,
|
||||
text: lines.join('\n'),
|
||||
attachments,
|
||||
html: `
|
||||
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
|
||||
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||
</div>
|
||||
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
|
||||
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
|
||||
${
|
||||
logoAttachment
|
||||
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
|
||||
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
|
||||
}
|
||||
</div>
|
||||
<div style="padding: 30px 28px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
|
||||
${birdPhotoHtml}
|
||||
</td>
|
||||
<td style="vertical-align: top; padding: 0 0 20px;">
|
||||
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
|
||||
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
||||
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
||||
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
|
||||
${medicationNotesHtml}
|
||||
<p style="margin: 0;">
|
||||
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width: 680px; margin: 18px auto 0;">
|
||||
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return { delivered: true };
|
||||
};
|
||||
|
||||
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
||||
const reminders = await listDueBirdMilestoneReminders(runDate);
|
||||
let sent = 0;
|
||||
@@ -1875,7 +2036,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
|
||||
};
|
||||
};
|
||||
|
||||
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const reminder of reminders) {
|
||||
try {
|
||||
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
|
||||
const result = await sendMedicationReminderNotification({ reminder, recipients });
|
||||
|
||||
if (!result.delivered) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const delivery = await createMedicationReminderDelivery({
|
||||
medicationId: reminder.medication_id,
|
||||
birdId: reminder.id,
|
||||
workspaceId: reminder.workspace_id,
|
||||
scheduledOn: runDate,
|
||||
administrationSlot: reminder.administration_slot,
|
||||
});
|
||||
|
||||
if (delivery) {
|
||||
sent += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runDate,
|
||||
currentTime,
|
||||
checked: reminders.length,
|
||||
sent,
|
||||
skipped,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
let lastMilestoneReminderRunDate = '';
|
||||
let lastMedicationReminderRunKey = '';
|
||||
|
||||
export const startBirdMilestoneReminderScheduler = () => {
|
||||
if (!milestoneRemindersEnabled) {
|
||||
@@ -1909,6 +2116,42 @@ export const startBirdMilestoneReminderScheduler = () => {
|
||||
}, milestoneReminderCheckIntervalMs);
|
||||
};
|
||||
|
||||
export const startMedicationReminderScheduler = () => {
|
||||
if (!medicationRemindersEnabled) {
|
||||
console.log('Medication reminders are disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runIfNeeded = async () => {
|
||||
const now = new Date();
|
||||
const runDate = getDateInTimeZone(now);
|
||||
const currentTime = getTimeInTimeZone(now);
|
||||
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
|
||||
|
||||
if (lastMedicationReminderRunKey === runKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastMedicationReminderRunKey = runKey;
|
||||
const job = await enqueueMedicationReminderJob(runDate, currentTime);
|
||||
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
void runIfNeeded().catch((error) => {
|
||||
lastMedicationReminderRunKey = '';
|
||||
console.error('Medication reminder scheduler failed', error);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
setInterval(() => {
|
||||
void runIfNeeded().catch((error) => {
|
||||
lastMedicationReminderRunKey = '';
|
||||
console.error('Medication reminder scheduler failed', error);
|
||||
});
|
||||
}, milestoneReminderCheckIntervalMs);
|
||||
};
|
||||
|
||||
const readBearerToken = (authorizationHeader?: string) => {
|
||||
if (!authorizationHeader) {
|
||||
return '';
|
||||
@@ -2060,6 +2303,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
||||
|
||||
try {
|
||||
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
||||
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
|
||||
|
||||
res.json({
|
||||
startedAt: requestMetrics.startedAt,
|
||||
@@ -2081,6 +2325,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
||||
},
|
||||
queues: {
|
||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||
medicationReminders: medicationReminderQueueCounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2964,9 +3209,57 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
|
||||
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
|
||||
|
||||
if (parsed.data.role === 'owner' && !requesterIsBillingOwner) {
|
||||
res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: req.params.memberId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
role: parsed.data.role,
|
||||
requesterMemberId: req.auth!.membership.id,
|
||||
requesterIsBillingOwner,
|
||||
requesterRole: req.auth!.membership.role,
|
||||
billingEmail,
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
res.status(404).json({ error: 'Flock member not found or cannot be changed.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req.auth!, 'workspace_member.role_updated', 'workspace_member', member.id, member.name, {
|
||||
role: member.role,
|
||||
});
|
||||
res.json({ member: normalizeWorkspaceMember(member) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id);
|
||||
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
|
||||
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
|
||||
const deleted = await deleteWorkspaceMember({
|
||||
memberId: req.params.memberId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
requesterMemberId: req.auth!.membership.id,
|
||||
requesterIsBillingOwner,
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
||||
@@ -2974,7 +3267,6 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
|
||||
}
|
||||
|
||||
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
|
||||
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -3844,6 +4136,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
||||
parsed.data.startDate,
|
||||
emptyToNull(parsed.data.endDate),
|
||||
emptyToNull(parsed.data.notes),
|
||||
parsed.data.remindersEnabled ?? false,
|
||||
);
|
||||
|
||||
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||
@@ -3887,6 +4180,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
||||
parsed.data.startDate,
|
||||
emptyToNull(parsed.data.endDate),
|
||||
emptyToNull(parsed.data.notes),
|
||||
parsed.data.remindersEnabled ?? false,
|
||||
);
|
||||
|
||||
if (!medication) {
|
||||
|
||||
@@ -342,9 +342,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -368,9 +368,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
WHERE completed_at IS NULL
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
@@ -437,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
notes VARCHAR(1000),
|
||||
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||
);
|
||||
@@ -444,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ALTER TABLE medications
|
||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||
|
||||
ALTER TABLE medications
|
||||
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
@@ -477,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ALTER TABLE medication_administrations
|
||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
scheduled_on DATE NOT NULL,
|
||||
administration_slot VARCHAR(80) NOT NULL,
|
||||
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (medication_id, scheduled_on, administration_slot)
|
||||
);
|
||||
|
||||
ALTER TABLE medication_administrations
|
||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||
|
||||
@@ -495,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
||||
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||
ON medication_administrations (bird_id, administered_on DESC);
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ export const closeAdoptionReportQueue = async () => {
|
||||
await adoptionReportQueue.close();
|
||||
await adoptionReportQueueEvents.close();
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
LostBirdMatchRow,
|
||||
MedicationAdministrationRow,
|
||||
MedicationDoseScheduleItem,
|
||||
MedicationReminderCandidateRow,
|
||||
MedicationReminderDeliveryRow,
|
||||
MedicationRow,
|
||||
PendingBirdTransferRow,
|
||||
VetVisitRow,
|
||||
@@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
||||
const result = await db.query<MedicationReminderCandidateRow>(
|
||||
`SELECT
|
||||
${birdSelectFields},
|
||||
workspaces.name AS workspace_name,
|
||||
medications.id AS medication_id,
|
||||
medications.name AS medication_name,
|
||||
medications.dosage,
|
||||
medications.frequency,
|
||||
medications.dose_schedule,
|
||||
medications.route,
|
||||
medications.start_date::text AS medication_start_date,
|
||||
medications.end_date::text AS medication_end_date,
|
||||
medications.notes AS medication_notes,
|
||||
$1::date::text AS scheduled_on,
|
||||
dose.key AS administration_slot,
|
||||
dose.label AS administration_label,
|
||||
dose.time AS administration_time
|
||||
FROM medications
|
||||
INNER JOIN birds ON birds.id = medications.bird_id
|
||||
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
FROM weight_records
|
||||
WHERE weight_records.bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
WHERE medications.reminders_enabled = TRUE
|
||||
AND birds.memorialized_at IS NULL
|
||||
AND medications.start_date <= $1::date
|
||||
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
||||
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
||||
AND dose.time <= $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM medication_reminder_deliveries deliveries
|
||||
WHERE deliveries.medication_id = medications.id
|
||||
AND deliveries.scheduled_on = $1::date
|
||||
AND deliveries.administration_slot = dose.key
|
||||
)
|
||||
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
||||
[runDate, currentTime],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createMedicationReminderDelivery = async ({
|
||||
medicationId,
|
||||
birdId,
|
||||
workspaceId,
|
||||
scheduledOn,
|
||||
administrationSlot,
|
||||
}: {
|
||||
medicationId: string;
|
||||
birdId: string;
|
||||
workspaceId: number;
|
||||
scheduledOn: string;
|
||||
administrationSlot: string;
|
||||
}) => {
|
||||
const result = await db.query<MedicationReminderDeliveryRow>(
|
||||
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
||||
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
||||
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const createBird = async ({
|
||||
birdId,
|
||||
workspaceId,
|
||||
@@ -902,7 +977,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
||||
|
||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<MedicationRow>(
|
||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
||||
FROM medications
|
||||
WHERE bird_id = $1
|
||||
AND EXISTS (
|
||||
@@ -928,12 +1003,13 @@ export const createMedicationForBird = async (
|
||||
startDate: string,
|
||||
endDate: string | null,
|
||||
notes: string | null,
|
||||
remindersEnabled: boolean,
|
||||
) => {
|
||||
const result = await db.query<MedicationRow>(
|
||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
@@ -950,6 +1026,7 @@ export const updateMedicationForBird = async (
|
||||
startDate: string,
|
||||
endDate: string | null,
|
||||
notes: string | null,
|
||||
remindersEnabled: boolean,
|
||||
) => {
|
||||
const result = await db.query<MedicationRow>(
|
||||
`UPDATE medications
|
||||
@@ -960,11 +1037,12 @@ export const updateMedicationForBird = async (
|
||||
route = $7,
|
||||
start_date = $8,
|
||||
end_date = $9,
|
||||
notes = $10
|
||||
notes = $10,
|
||||
reminders_enabled = $11
|
||||
WHERE id = $1
|
||||
AND bird_id = $2
|
||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWorkspace,
|
||||
deleteWorkspaceMember,
|
||||
deleteWorkspaceIfEmpty,
|
||||
ensureDefaultWorkspaceForUser,
|
||||
ensurePersonalWorkspaceForUser,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
getPlatformAdminSummary,
|
||||
listOwnedWorkspacesByOwnerEmail,
|
||||
updateWorkspace,
|
||||
updateWorkspaceMemberRole,
|
||||
} from './workspaceRepository.js';
|
||||
import { mockDb } from '../test/mockDb.js';
|
||||
import type { UserRow } from '../types.js';
|
||||
@@ -259,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
|
||||
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'member-1',
|
||||
workspace_id: 42,
|
||||
user_id: 'user-2',
|
||||
invite_email: 'helper@example.com',
|
||||
name: 'Helper',
|
||||
role: 'viewer',
|
||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'member-1',
|
||||
workspaceId: 42,
|
||||
role: 'viewer',
|
||||
requesterMemberId: 'owner-member',
|
||||
requesterIsBillingOwner: false,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member?.role, 'viewer');
|
||||
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
||||
assert.match(calls[0].text, /UPDATE workspace_members/);
|
||||
assert.match(calls[0].text, /role <> 'owner'/);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
||||
mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'owner-member',
|
||||
workspaceId: 42,
|
||||
role: 'viewer',
|
||||
requesterMemberId: 'owner-member',
|
||||
requesterIsBillingOwner: false,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member, null);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'other-owner',
|
||||
workspace_id: 42,
|
||||
user_id: 'user-2',
|
||||
invite_email: 'other@example.com',
|
||||
name: 'Other Owner',
|
||||
role: 'assistant',
|
||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'other-owner',
|
||||
workspaceId: 42,
|
||||
role: 'assistant',
|
||||
requesterMemberId: 'billing-owner',
|
||||
requesterIsBillingOwner: true,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member?.role, 'assistant');
|
||||
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||
assert.match(calls[0].text, /id <> \$5/);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
||||
mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'billing-owner',
|
||||
workspaceId: 42,
|
||||
role: 'assistant',
|
||||
requesterMemberId: 'billing-owner',
|
||||
requesterIsBillingOwner: true,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member, null);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'other-owner',
|
||||
workspace_id: 42,
|
||||
user_id: 'user-2',
|
||||
invite_email: 'other@example.com',
|
||||
name: 'Other Owner',
|
||||
role: 'assistant',
|
||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'other-owner',
|
||||
workspaceId: 42,
|
||||
role: 'assistant',
|
||||
requesterMemberId: 'non-billing-owner',
|
||||
requesterIsBillingOwner: false,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member?.role, 'assistant');
|
||||
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
||||
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
||||
mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'billing-owner',
|
||||
workspaceId: 42,
|
||||
role: 'assistant',
|
||||
requesterMemberId: 'non-billing-owner',
|
||||
requesterIsBillingOwner: false,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member, null);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'member-1',
|
||||
workspace_id: 42,
|
||||
user_id: 'user-2',
|
||||
invite_email: 'helper@example.com',
|
||||
name: 'Helper',
|
||||
role: 'owner',
|
||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||
created_at: '2026-04-14T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'member-1',
|
||||
workspaceId: 42,
|
||||
role: 'owner',
|
||||
requesterMemberId: 'billing-owner',
|
||||
requesterIsBillingOwner: true,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member?.role, 'owner');
|
||||
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
||||
});
|
||||
|
||||
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
||||
mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const member = await updateWorkspaceMemberRole({
|
||||
memberId: 'member-1',
|
||||
workspaceId: 42,
|
||||
role: 'owner',
|
||||
requesterMemberId: 'non-billing-owner',
|
||||
requesterIsBillingOwner: false,
|
||||
requesterRole: 'owner',
|
||||
billingEmail: 'billing@example.com',
|
||||
});
|
||||
|
||||
assert.equal(member, null);
|
||||
});
|
||||
|
||||
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [{ id: 'member-1' }],
|
||||
});
|
||||
|
||||
const deleted = await deleteWorkspaceMember({
|
||||
memberId: 'member-1',
|
||||
workspaceId: 42,
|
||||
requesterMemberId: 'owner-member',
|
||||
requesterIsBillingOwner: false,
|
||||
});
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
||||
assert.match(calls[0].text, /role <> 'owner'/);
|
||||
});
|
||||
|
||||
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [{ id: 'other-owner' }],
|
||||
});
|
||||
|
||||
const deleted = await deleteWorkspaceMember({
|
||||
memberId: 'other-owner',
|
||||
workspaceId: 42,
|
||||
requesterMemberId: 'billing-owner',
|
||||
requesterIsBillingOwner: true,
|
||||
});
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
||||
assert.match(calls[0].text, /id <> \$4/);
|
||||
});
|
||||
|
||||
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
||||
mockDb({
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const deleted = await deleteWorkspaceMember({
|
||||
memberId: 'billing-owner',
|
||||
workspaceId: 42,
|
||||
requesterMemberId: 'billing-owner',
|
||||
requesterIsBillingOwner: true,
|
||||
});
|
||||
|
||||
assert.equal(deleted, false);
|
||||
});
|
||||
|
||||
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
|
||||
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
||||
export const deleteWorkspaceMember = async ({
|
||||
memberId,
|
||||
workspaceId,
|
||||
requesterMemberId,
|
||||
requesterIsBillingOwner,
|
||||
}: {
|
||||
memberId: string;
|
||||
workspaceId: number;
|
||||
requesterMemberId: string;
|
||||
requesterIsBillingOwner: boolean;
|
||||
}) => {
|
||||
const result = await db.query<{ id: string }>(
|
||||
`DELETE FROM workspace_members
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND role <> 'owner'
|
||||
AND (
|
||||
role <> 'owner'
|
||||
OR (
|
||||
$3 = TRUE
|
||||
AND id <> $4
|
||||
)
|
||||
)
|
||||
RETURNING id`,
|
||||
[memberId, workspaceId],
|
||||
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
||||
);
|
||||
|
||||
return Boolean(result.rowCount);
|
||||
};
|
||||
|
||||
export const updateWorkspaceMemberRole = async ({
|
||||
memberId,
|
||||
workspaceId,
|
||||
role,
|
||||
requesterMemberId,
|
||||
requesterIsBillingOwner,
|
||||
requesterRole,
|
||||
billingEmail,
|
||||
}: {
|
||||
memberId: string;
|
||||
workspaceId: number;
|
||||
role: WorkspaceMemberRow['role'];
|
||||
requesterMemberId: string;
|
||||
requesterIsBillingOwner: boolean;
|
||||
requesterRole: WorkspaceMemberRow['role'];
|
||||
billingEmail: string;
|
||||
}) => {
|
||||
const result = await db.query<WorkspaceMemberRow>(
|
||||
`UPDATE workspace_members
|
||||
SET role = $3
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND (
|
||||
$3 <> 'owner'
|
||||
OR $4 = TRUE
|
||||
)
|
||||
AND (
|
||||
role <> 'owner'
|
||||
OR (
|
||||
id <> $5
|
||||
AND (
|
||||
$4 = TRUE
|
||||
OR (
|
||||
$7 = 'owner'
|
||||
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
||||
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listRescueWorkspacesForAdmin = async () => {
|
||||
const result = await db.query<
|
||||
WorkspaceRow & {
|
||||
|
||||
@@ -202,6 +202,7 @@ export type MedicationRow = {
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
notes: string | null;
|
||||
reminders_enabled: boolean;
|
||||
};
|
||||
|
||||
export type MedicationDoseScheduleItem = {
|
||||
@@ -210,6 +211,33 @@ export type MedicationDoseScheduleItem = {
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type MedicationReminderCandidateRow = BirdRow & {
|
||||
workspace_name: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
dosage: string;
|
||||
frequency: string;
|
||||
dose_schedule: MedicationDoseScheduleItem[];
|
||||
route: string | null;
|
||||
medication_start_date: string;
|
||||
medication_end_date: string | null;
|
||||
medication_notes: string | null;
|
||||
scheduled_on: string;
|
||||
administration_slot: string;
|
||||
administration_label: string;
|
||||
administration_time: string;
|
||||
};
|
||||
|
||||
export type MedicationReminderDeliveryRow = {
|
||||
id: string;
|
||||
medication_id: string;
|
||||
bird_id: string;
|
||||
workspace_id: number;
|
||||
scheduled_on: string;
|
||||
administration_slot: string;
|
||||
delivered_at: string;
|
||||
};
|
||||
|
||||
export type MedicationAdministrationRow = {
|
||||
id: string;
|
||||
medication_id: string;
|
||||
|
||||
+35
-1
@@ -2,7 +2,12 @@ import { Worker } from 'bullmq';
|
||||
|
||||
import { ensureSchema } from './db/schema.js';
|
||||
import { db } from './db/client.js';
|
||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
||||
import {
|
||||
runBirdMilestoneReminders,
|
||||
runMedicationReminders,
|
||||
startBirdMilestoneReminderScheduler,
|
||||
startMedicationReminderScheduler,
|
||||
} from './app.js';
|
||||
import {
|
||||
adoptionReportQueueName,
|
||||
closeAdoptionReportQueue,
|
||||
@@ -15,10 +20,17 @@ import {
|
||||
type BirdMilestoneReminderJobData,
|
||||
type BirdMilestoneReminderJobResult,
|
||||
} from './queues/birdMilestoneReminderQueue.js';
|
||||
import {
|
||||
closeMedicationReminderQueue,
|
||||
medicationReminderQueueName,
|
||||
type MedicationReminderJobData,
|
||||
type MedicationReminderJobResult,
|
||||
} from './queues/medicationReminderQueue.js';
|
||||
import { redisConnection } from './queues/redisConnection.js';
|
||||
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||
|
||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||
|
||||
const startWorker = async () => {
|
||||
@@ -43,6 +55,25 @@ const startWorker = async () => {
|
||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||
});
|
||||
|
||||
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
||||
medicationReminderQueueName,
|
||||
async (job) => {
|
||||
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
||||
console.log(
|
||||
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
},
|
||||
);
|
||||
|
||||
medicationReminderWorker.on('failed', (job, error) => {
|
||||
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||
});
|
||||
|
||||
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||
adoptionReportQueueName,
|
||||
async (job) => {
|
||||
@@ -63,14 +94,17 @@ const startWorker = async () => {
|
||||
});
|
||||
|
||||
startBirdMilestoneReminderScheduler();
|
||||
startMedicationReminderScheduler();
|
||||
console.log('FlockPal worker started.');
|
||||
};
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||
await birdMilestoneWorker?.close();
|
||||
await medicationReminderWorker?.close();
|
||||
await adoptionReportWorker?.close();
|
||||
await closeBirdMilestoneReminderQueue();
|
||||
await closeMedicationReminderQueue();
|
||||
await closeAdoptionReportQueue();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
|
||||
@@ -59,6 +59,7 @@ services:
|
||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
@@ -139,6 +140,7 @@ services:
|
||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
|
||||
@@ -57,6 +57,7 @@ services:
|
||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
@@ -132,6 +133,7 @@ services:
|
||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
|
||||
+282
-130
@@ -75,6 +75,7 @@ type Medication = {
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
notes: string | null;
|
||||
remindersEnabled: boolean;
|
||||
};
|
||||
|
||||
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
|
||||
@@ -1589,6 +1590,7 @@ function App() {
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
endDate: '',
|
||||
notes: '',
|
||||
remindersEnabled: false,
|
||||
});
|
||||
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||
birdId: '',
|
||||
@@ -1619,6 +1621,7 @@ function App() {
|
||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
||||
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
||||
const [updatingWorkspaceMemberId, setUpdatingWorkspaceMemberId] = useState('');
|
||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||
|
||||
@@ -1626,8 +1629,13 @@ function App() {
|
||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||
[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 editingBird = useMemo(
|
||||
const editingBird = useMemo(
|
||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||
[birds, editingBirdId],
|
||||
);
|
||||
@@ -3653,6 +3661,7 @@ function App() {
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
endDate: '',
|
||||
notes: '',
|
||||
remindersEnabled: false,
|
||||
});
|
||||
setEditingMedicationId('');
|
||||
} catch (submitError) {
|
||||
@@ -3672,6 +3681,7 @@ function App() {
|
||||
startDate: medication.startDate,
|
||||
endDate: medication.endDate ?? '',
|
||||
notes: medication.notes ?? '',
|
||||
remindersEnabled: medication.remindersEnabled,
|
||||
});
|
||||
setError('');
|
||||
};
|
||||
@@ -3687,6 +3697,7 @@ function App() {
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
endDate: '',
|
||||
notes: '',
|
||||
remindersEnabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4225,150 +4236,185 @@ function App() {
|
||||
}
|
||||
body {
|
||||
background: ${bodyBackground};
|
||||
box-sizing: border-box;
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
line-height: 1.45;
|
||||
margin: 32px;
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
line-height: 1.38;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: inherit; }
|
||||
img, svg { max-width: 100%; }
|
||||
${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 {
|
||||
background: ${headerBackground};
|
||||
border: 1px solid var(--border);
|
||||
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;
|
||||
gap: 22px;
|
||||
grid-template-columns: 210px 1fr 320px;
|
||||
min-height: 228px;
|
||||
padding: 18px;
|
||||
gap: 10px;
|
||||
grid-template-columns: 100px minmax(220px, 1fr) 124px;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
}
|
||||
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 {
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--green);
|
||||
font-size: 19px;
|
||||
margin: 28px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
font-size: 16px;
|
||||
margin: 20px 0 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; }
|
||||
.brand-logo {
|
||||
align-self: center;
|
||||
height: 210px;
|
||||
height: auto;
|
||||
justify-self: start;
|
||||
max-height: 98px;
|
||||
object-fit: contain;
|
||||
width: 210px;
|
||||
width: 98px;
|
||||
}
|
||||
.report-title {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
.report-title .muted { margin-top: 8px; }
|
||||
.report-title .muted { margin-top: 4px; }
|
||||
.profile-photo {
|
||||
aspect-ratio: 1;
|
||||
background: #fff;
|
||||
border: 3px solid var(--paper);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
|
||||
height: 132px;
|
||||
margin: 0 auto 12px;
|
||||
border: 2px solid var(--paper);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 16px rgba(86, 63, 34, 0.14);
|
||||
height: 70px;
|
||||
margin: 0 auto 5px;
|
||||
object-fit: cover;
|
||||
width: 132px;
|
||||
width: 70px;
|
||||
}
|
||||
.qr { align-self: center; justify-self: end; text-align: center; width: 320px; }
|
||||
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
|
||||
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
|
||||
.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: 9px; padding: 5px; width: 70px; }
|
||||
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; overflow-wrap: anywhere; }
|
||||
.qr-join-label {
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
margin-bottom: -28px;
|
||||
margin-bottom: -10px;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
z-index: 1;
|
||||
}
|
||||
.qr-wordmark {
|
||||
display: block;
|
||||
height: 150px;
|
||||
margin: -28px auto -12px;
|
||||
height: auto;
|
||||
margin: -10px auto -4px;
|
||||
max-height: 50px;
|
||||
object-fit: contain;
|
||||
width: 340px;
|
||||
width: min(100%, 124px);
|
||||
}
|
||||
.qr-note {
|
||||
color: var(--blue);
|
||||
font-family: "Avenir Next", "Arial Rounded MT Bold", Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.28;
|
||||
margin-top: 8px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.grid { stroke: rgba(53, 129, 98, 0.16); }
|
||||
.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; }
|
||||
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
|
||||
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
|
||||
.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: 7px 9px; }
|
||||
.fact span { color: var(--muted); display: block; font-size: 10px; margin-bottom: 3px; text-transform: uppercase; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
||||
.note p { margin-top: 6px; white-space: pre-wrap; }
|
||||
main { margin-top: 24px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 6px 7px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
||||
.note { border-bottom: 1px solid var(--border); padding: 7px 0; }
|
||||
.note p { margin-top: 4px; white-space: pre-wrap; }
|
||||
main { margin-top: 16px; }
|
||||
@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; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
|
||||
<div class="report-title">
|
||||
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
|
||||
<h1>${escapeReportHtml(selectedBird.name)}</h1>
|
||||
<p class="muted">Adoption Report</p>
|
||||
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
|
||||
</div>
|
||||
<div class="qr">
|
||||
<p class="qr-join-label">Join</p>
|
||||
<img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal">
|
||||
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
|
||||
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
|
||||
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
|
||||
</svg>
|
||||
<p class="code">${escapeReportHtml(transferCode)}</p>
|
||||
<p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Flock Member Info</h2>
|
||||
<section class="facts">
|
||||
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
${detailList('Motivators', selectedBird.motivators)}
|
||||
${detailList('Demotivators', selectedBird.demotivators)}
|
||||
<h2>Weight Graph</h2>
|
||||
${chartSvg}
|
||||
<h2>Weight History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
|
||||
<h2>Veterinary Clinic Info</h2>
|
||||
<section class="facts">
|
||||
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
<h2>Vet Visit History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
|
||||
<h2>Notes</h2>
|
||||
${noteRows}
|
||||
</main>
|
||||
<div class="report-page">
|
||||
<header>
|
||||
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
|
||||
<div class="report-title">
|
||||
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
|
||||
<h1>${escapeReportHtml(selectedBird.name)}</h1>
|
||||
<p class="muted">Adoption Report</p>
|
||||
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
|
||||
</div>
|
||||
<div class="qr">
|
||||
<p class="qr-join-label">Join</p>
|
||||
<img class="qr-wordmark" src="${escapeReportHtml(reportWordmarkUrl)}" alt="FlockPal">
|
||||
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
|
||||
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
|
||||
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
|
||||
</svg>
|
||||
<p class="code">${escapeReportHtml(transferCode)}</p>
|
||||
<p class="qr-note">Enter this code to keep ${escapeReportHtml(selectedBird.name)}'s care history flying forward.</p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Flock Member Info</h2>
|
||||
<section class="facts">
|
||||
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
${detailList('Motivators', selectedBird.motivators)}
|
||||
${detailList('Demotivators', selectedBird.demotivators)}
|
||||
<h2>Weight Graph</h2>
|
||||
${chartSvg}
|
||||
<h2>Weight History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
|
||||
<h2>Veterinary Clinic Info</h2>
|
||||
<section class="facts">
|
||||
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
|
||||
</section>
|
||||
<h2>Vet Visit History</h2>
|
||||
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
|
||||
<h2>Notes</h2>
|
||||
${noteRows}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
reportWindow.document.close();
|
||||
@@ -4672,7 +4718,15 @@ function App() {
|
||||
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);
|
||||
} catch (memberError) {
|
||||
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
|
||||
@@ -4681,6 +4735,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) => {
|
||||
setError('');
|
||||
setRemovingWorkspaceMemberId(memberId);
|
||||
@@ -4953,6 +5041,7 @@ function App() {
|
||||
<small>
|
||||
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
|
||||
</small>
|
||||
<small>{medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'}</small>
|
||||
<small>{medication.notes || 'No notes recorded.'}</small>
|
||||
{latestAdministration ? (
|
||||
<small>
|
||||
@@ -5960,6 +6049,17 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
Notes
|
||||
<textarea
|
||||
@@ -6073,26 +6173,26 @@ function App() {
|
||||
aria-selected={selectedBirdTab === 'notes'}
|
||||
aria-label="Notes"
|
||||
title="Notes"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('reports')}
|
||||
type="button"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('reports')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedBirdTab === 'reports'}
|
||||
aria-label="Reports"
|
||||
title="Reports"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedBirdTab('audit')}
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -6452,11 +6552,11 @@ function App() {
|
||||
{selectedBirdTab === 'vet' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Veterinary</p>
|
||||
<h2>Clinic account</h2>
|
||||
</div>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Veterinary</p>
|
||||
<h2>Clinic account</h2>
|
||||
</div>
|
||||
{!editingVeterinaryInfo ? (
|
||||
<button
|
||||
className="profile-icon-button"
|
||||
@@ -6550,9 +6650,9 @@ function App() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel inset-panel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<div>
|
||||
<p className="eyebrow">Vet visits</p>
|
||||
<h2>Care history and notes</h2>
|
||||
</div>
|
||||
@@ -6695,13 +6795,13 @@ function App() {
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'reports' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
{selectedBirdTab === 'reports' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Reports</p>
|
||||
@@ -6731,11 +6831,11 @@ function App() {
|
||||
{adoptionReportError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedBirdTab === 'audit' ? (
|
||||
{selectedBirdTab === 'audit' ? (
|
||||
<div className="flock-member-sections" role="tabpanel">
|
||||
<section className="panel inset-panel">
|
||||
<div className="panel-header">
|
||||
@@ -7300,7 +7400,7 @@ function App() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
{isBillingOwner ? <option value="owner">Owner</option> : null}
|
||||
<option value="assistant">Assistant</option>
|
||||
<option value="caregiver">Caregiver</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
@@ -7313,23 +7413,64 @@ function App() {
|
||||
|
||||
<div className="recent-list">
|
||||
{workspaceMembers.length ? (
|
||||
workspaceMembers.map((member) => (
|
||||
<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>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => handleRemoveWorkspaceMember(member.id)}
|
||||
type="button"
|
||||
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
|
||||
>
|
||||
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
</article>
|
||||
))
|
||||
workspaceMembers.map((member) => {
|
||||
const memberEmail = member.email || member.inviteEmail || '';
|
||||
const memberIsBillingOwner = Boolean(
|
||||
workspace?.billingEmail &&
|
||||
memberEmail.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
|
||||
);
|
||||
const canRemoveOwner = member.role === 'owner' && isBillingOwner && member.id !== activeMembership?.id;
|
||||
const canChangeOwnerRole =
|
||||
member.role === 'owner' &&
|
||||
activeMembership?.role === 'owner' &&
|
||||
member.id !== activeMembership.id &&
|
||||
(isBillingOwner || !memberIsBillingOwner);
|
||||
const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
|
||||
const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
|
||||
|
||||
return (
|
||||
<article key={member.id} className="vet-visit-card">
|
||||
<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">
|
||||
<strong>No collaborators yet</strong>
|
||||
@@ -7930,6 +8071,17 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
Notes
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user