Added memorial settings
This commit is contained in:
+166
-17
@@ -44,10 +44,12 @@ import {
|
|||||||
getBirdById,
|
getBirdById,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
|
listMemorializedBirds,
|
||||||
listMedicationAdministrationsForBird,
|
listMedicationAdministrationsForBird,
|
||||||
listMedicationsForBird,
|
listMedicationsForBird,
|
||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
|
memorializeBird,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
updateMedicationForBird,
|
updateMedicationForBird,
|
||||||
@@ -63,11 +65,11 @@ import {
|
|||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
findAlternateWorkspaceForUser,
|
findAlternateWorkspaceForUser,
|
||||||
getWorkspaceBirdCount,
|
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
|
getWorkspaceTotalBirdCount,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
@@ -221,6 +223,12 @@ const birdSchema = z.object({
|
|||||||
notifyOnGotchaDay: z.boolean().optional(),
|
notifyOnGotchaDay: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const memorializeBirdSchema = z.object({
|
||||||
|
memorializedOn: dateStringSchema,
|
||||||
|
memorialNote: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
|
notifyOnMemorialDay: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const weightSchema = z.object({
|
const weightSchema = z.object({
|
||||||
weightGrams: z.coerce.number().positive().max(10000),
|
weightGrams: z.coerce.number().positive().max(10000),
|
||||||
recordedOn: dateStringSchema,
|
recordedOn: dateStringSchema,
|
||||||
@@ -435,6 +443,10 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
photoDataUrl: row.photo_data_url,
|
photoDataUrl: row.photo_data_url,
|
||||||
notifyOnDob: row.notify_on_dob,
|
notifyOnDob: row.notify_on_dob,
|
||||||
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
||||||
|
memorializedAt: row.memorialized_at,
|
||||||
|
memorializedOn: row.memorialized_on,
|
||||||
|
memorialNote: row.memorial_note,
|
||||||
|
notifyOnMemorialDay: row.notify_on_memorial_day,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
|
latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null,
|
||||||
latestRecordedOn: row.latest_recorded_on,
|
latestRecordedOn: row.latest_recorded_on,
|
||||||
@@ -833,7 +845,12 @@ const formatOrdinal = (value: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
|
const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
|
||||||
const sourceDate = reminder.reminder_type === 'hatch_day' ? reminder.date_of_birth : reminder.gotcha_day;
|
const sourceDate =
|
||||||
|
reminder.reminder_type === 'hatch_day'
|
||||||
|
? reminder.date_of_birth
|
||||||
|
: reminder.reminder_type === 'memorial_day'
|
||||||
|
? reminder.memorialized_on
|
||||||
|
: reminder.gotcha_day;
|
||||||
const sourceYear = Number(sourceDate?.slice(0, 4));
|
const sourceYear = Number(sourceDate?.slice(0, 4));
|
||||||
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
|
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
|
||||||
};
|
};
|
||||||
@@ -854,13 +871,10 @@ const getFlockPalLogoAttachment = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmailTrackPatternAttachment = () => ({
|
const getEmailTrackPatternDataUrl = () => {
|
||||||
filename: 'flockpal-x-pattern.svg',
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></svg>`;
|
||||||
content: `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></svg>`,
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
contentType: 'image/svg+xml',
|
};
|
||||||
cid: 'flockpal-x-pattern',
|
|
||||||
contentDisposition: 'inline' as const,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseDataImage = (dataUrl: string) => {
|
const parseDataImage = (dataUrl: string) => {
|
||||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
||||||
@@ -1120,6 +1134,23 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reminder.reminder_type === 'memorial_day') {
|
||||||
|
return {
|
||||||
|
subject: `Remembering ${reminder.name} today`,
|
||||||
|
eyebrow: 'Memorial Day',
|
||||||
|
headline: `Remembering ${reminder.name}`,
|
||||||
|
eventName: 'Memorial Day',
|
||||||
|
intro:
|
||||||
|
yearCount > 0
|
||||||
|
? `From our flock to yours, holding ${reminder.name}'s memory close on this ${formatOrdinal(yearCount)} memorial day.`
|
||||||
|
: `From our flock to yours, holding ${reminder.name}'s memory close today.`,
|
||||||
|
body: reminder.memorial_note
|
||||||
|
? reminder.memorial_note
|
||||||
|
: 'A quiet moment for the feathers, songs, routines, and happy memories that still stay with you.',
|
||||||
|
milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Memorial Day` : 'Memorial Day on file',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: `It's ${reminder.name}'s Gotcha Day!`,
|
subject: `It's ${reminder.name}'s Gotcha Day!`,
|
||||||
eyebrow: 'Gotcha Day',
|
eyebrow: 'Gotcha Day',
|
||||||
@@ -1150,7 +1181,7 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
const copy = buildBirdMilestoneReminderCopy(reminder);
|
const copy = buildBirdMilestoneReminderCopy(reminder);
|
||||||
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
||||||
const logoAttachment = getFlockPalLogoAttachment();
|
const logoAttachment = getFlockPalLogoAttachment();
|
||||||
const trackPatternAttachment = getEmailTrackPatternAttachment();
|
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
|
||||||
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
||||||
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
||||||
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
||||||
@@ -1158,7 +1189,6 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
if (logoAttachment) {
|
if (logoAttachment) {
|
||||||
attachments.push(logoAttachment);
|
attachments.push(logoAttachment);
|
||||||
}
|
}
|
||||||
attachments.push(trackPatternAttachment);
|
|
||||||
|
|
||||||
if (uploadedBirdPhoto) {
|
if (uploadedBirdPhoto) {
|
||||||
attachments.push({
|
attachments.push({
|
||||||
@@ -1201,9 +1231,9 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
attachments,
|
attachments,
|
||||||
html: `
|
html: `
|
||||||
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('cid:flockpal-x-pattern'), 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="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;">
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||||
<img src="cid:flockpal-x-pattern" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
</div>
|
</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="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);">
|
<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);">
|
||||||
@@ -1232,7 +1262,7 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="max-width: 680px; margin: 18px auto 0;">
|
<div style="max-width: 680px; margin: 18px auto 0;">
|
||||||
<img src="cid:flockpal-x-pattern" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -1422,6 +1452,18 @@ const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, r
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
||||||
|
if (!bird.memorialized_at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'This bird has been memorialized and is read-only.',
|
||||||
|
code: 'bird_memorialized',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const isBillingOnlyWorkspaceUpdate = (
|
const isBillingOnlyWorkspaceUpdate = (
|
||||||
workspace: WorkspaceRow,
|
workspace: WorkspaceRow,
|
||||||
payload: z.infer<typeof workspaceSchema>,
|
payload: z.infer<typeof workspaceSchema>,
|
||||||
@@ -2102,7 +2144,7 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
|
|
||||||
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
if ((await getWorkspaceBirdCount(req.auth!.workspace.id)) > 0) {
|
if ((await getWorkspaceTotalBirdCount(req.auth!.workspace.id)) > 0) {
|
||||||
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
|
res.status(409).json({ error: 'Remove or transfer all birds from this flock before deleting it.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2228,8 +2270,11 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
|
|||||||
|
|
||||||
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const birds = await listBirds(req.auth!.workspace.id);
|
const [birds, memorializedBirds] = await Promise.all([
|
||||||
res.json({ birds: birds.map(normalizeBird) });
|
listBirds(req.auth!.workspace.id),
|
||||||
|
listMemorializedBirds(req.auth!.workspace.id),
|
||||||
|
]);
|
||||||
|
res.json({ birds: birds.map(normalizeBird), memorializedBirds: memorializedBirds.map(normalizeBird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -2286,6 +2331,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(sourceBird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
|
const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!targetWorkspaces.length) {
|
if (!targetWorkspaces.length) {
|
||||||
@@ -2349,6 +2398,17 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!existingBird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(existingBird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bird = await updateBird({
|
const bird = await updateBird({
|
||||||
birdId: req.params.birdId,
|
birdId: req.params.birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
@@ -2382,6 +2442,17 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
|
|
||||||
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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 deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
|
const deleted = await deleteBird(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -2395,6 +2466,45 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = memorializeBirdSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid memorial payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!existingBird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(existingBird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bird = await memorializeBird({
|
||||||
|
birdId: req.params.birdId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
memorializedOn: parsed.data.memorializedOn,
|
||||||
|
memorialNote: emptyToNull(parsed.data.memorialNote),
|
||||||
|
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
|
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
|
||||||
@@ -2421,6 +2531,10 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
||||||
res.status(201).json({ weight: normalizeWeight(weight!) });
|
res.status(201).json({ weight: normalizeWeight(weight!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2458,6 +2572,10 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const vetVisit = await createVetVisitForBird(
|
const vetVisit = await createVetVisitForBird(
|
||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
parsed.data.visitedOn,
|
parsed.data.visitedOn,
|
||||||
@@ -2488,6 +2606,10 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const vetVisit = await updateVetVisitForBird(
|
const vetVisit = await updateVetVisitForBird(
|
||||||
req.params.visitId,
|
req.params.visitId,
|
||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
@@ -2517,6 +2639,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId);
|
const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId);
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -2555,6 +2681,10 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const medication = await createMedicationForBird(
|
const medication = await createMedicationForBird(
|
||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
parsed.data.name,
|
parsed.data.name,
|
||||||
@@ -2589,6 +2719,10 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const medication = await updateMedicationForBird(
|
const medication = await updateMedicationForBird(
|
||||||
req.params.medicationId,
|
req.params.medicationId,
|
||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
@@ -2622,6 +2756,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId);
|
const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId);
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -2653,6 +2791,17 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 administration = await upsertMedicationAdministrationForBird(
|
const administration = await upsertMedicationAdministrationForBird(
|
||||||
req.params.medicationId,
|
req.params.medicationId,
|
||||||
req.params.birdId,
|
req.params.birdId,
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
photo_data_url TEXT,
|
photo_data_url TEXT,
|
||||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
memorialized_at TIMESTAMPTZ,
|
||||||
|
memorialized_on DATE,
|
||||||
|
memorial_note VARCHAR(1000),
|
||||||
|
notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -219,7 +223,11 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||||
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE;
|
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
||||||
|
ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -303,13 +311,20 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
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,
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
reminder_type VARCHAR(24) NOT NULL CHECK (reminder_type IN ('hatch_day', 'gotcha_day')),
|
reminder_type VARCHAR(24) NOT NULL CHECK (reminder_type IN ('hatch_day', 'gotcha_day', 'memorial_day')),
|
||||||
reminder_year INTEGER NOT NULL,
|
reminder_year INTEGER NOT NULL,
|
||||||
delivered_on DATE NOT NULL,
|
delivered_on DATE NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (bird_id, reminder_type, reminder_year)
|
UNIQUE (bird_id, reminder_type, reminder_year)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE bird_milestone_reminder_deliveries
|
||||||
|
DROP CONSTRAINT IF EXISTS bird_milestone_reminder_deliveries_reminder_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE bird_milestone_reminder_deliveries
|
||||||
|
ADD CONSTRAINT bird_milestone_reminder_deliveries_reminder_type_check
|
||||||
|
CHECK (reminder_type IN ('hatch_day', 'gotcha_day', 'memorial_day'));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS medication_administrations (
|
CREATE TABLE IF NOT EXISTS medication_administrations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const birdSelectFields = `
|
|||||||
birds.photo_data_url,
|
birds.photo_data_url,
|
||||||
birds.notify_on_dob,
|
birds.notify_on_dob,
|
||||||
birds.notify_on_gotcha_day,
|
birds.notify_on_gotcha_day,
|
||||||
|
birds.memorialized_at,
|
||||||
|
birds.memorialized_on::text,
|
||||||
|
birds.memorial_note,
|
||||||
|
birds.notify_on_memorial_day,
|
||||||
birds.created_at,
|
birds.created_at,
|
||||||
latest.weight_grams AS latest_weight_grams,
|
latest.weight_grams AS latest_weight_grams,
|
||||||
latest.recorded_on::text AS latest_recorded_on
|
latest.recorded_on::text AS latest_recorded_on
|
||||||
@@ -65,6 +69,7 @@ export const listBirds = async (workspaceId: number) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE
|
) latest ON TRUE
|
||||||
WHERE birds.workspace_id = $1
|
WHERE birds.workspace_id = $1
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
ORDER BY birds.name ASC`,
|
ORDER BY birds.name ASC`,
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
@@ -72,6 +77,27 @@ export const listBirds = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listMemorializedBirds = async (workspaceId: number) => {
|
||||||
|
const result = await db.query<BirdRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields}
|
||||||
|
FROM birds
|
||||||
|
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 birds.workspace_id = $1
|
||||||
|
AND birds.memorialized_at IS NOT NULL
|
||||||
|
ORDER BY birds.memorialized_on DESC NULLS LAST, birds.name ASC`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
export const findBirdsByBandId = async (tagId: string) => {
|
export const findBirdsByBandId = async (tagId: string) => {
|
||||||
const result = await db.query<LostBirdMatchRow>(
|
const result = await db.query<LostBirdMatchRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -87,9 +113,10 @@ export const findBirdsByBandId = async (tagId: string) => {
|
|||||||
ORDER BY recorded_on DESC
|
ORDER BY recorded_on DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE
|
) latest ON TRUE
|
||||||
WHERE LOWER(birds.tag_id) = LOWER($1)
|
WHERE LOWER(birds.tag_id) = LOWER($1)
|
||||||
ORDER BY birds.created_at ASC
|
AND birds.memorialized_at IS NULL
|
||||||
LIMIT 10`,
|
ORDER BY birds.created_at ASC
|
||||||
|
LIMIT 10`,
|
||||||
[tagId],
|
[tagId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,6 +146,7 @@ export const listDueBirdMilestoneReminders = async (runDate: string) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE
|
) latest ON TRUE
|
||||||
WHERE birds.notify_on_dob = TRUE
|
WHERE birds.notify_on_dob = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
AND birds.date_of_birth IS NOT NULL
|
AND birds.date_of_birth IS NOT NULL
|
||||||
AND EXTRACT(MONTH FROM birds.date_of_birth) = EXTRACT(MONTH FROM reminder_context.run_date)
|
AND EXTRACT(MONTH FROM birds.date_of_birth) = EXTRACT(MONTH FROM reminder_context.run_date)
|
||||||
AND EXTRACT(DAY FROM birds.date_of_birth) = EXTRACT(DAY FROM reminder_context.run_date)
|
AND EXTRACT(DAY FROM birds.date_of_birth) = EXTRACT(DAY FROM reminder_context.run_date)
|
||||||
@@ -147,6 +175,7 @@ export const listDueBirdMilestoneReminders = async (runDate: string) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE
|
) latest ON TRUE
|
||||||
WHERE birds.notify_on_gotcha_day = TRUE
|
WHERE birds.notify_on_gotcha_day = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
AND birds.gotcha_day IS NOT NULL
|
AND birds.gotcha_day IS NOT NULL
|
||||||
AND EXTRACT(MONTH FROM birds.gotcha_day) = EXTRACT(MONTH FROM reminder_context.run_date)
|
AND EXTRACT(MONTH FROM birds.gotcha_day) = EXTRACT(MONTH FROM reminder_context.run_date)
|
||||||
AND EXTRACT(DAY FROM birds.gotcha_day) = EXTRACT(DAY FROM reminder_context.run_date)
|
AND EXTRACT(DAY FROM birds.gotcha_day) = EXTRACT(DAY FROM reminder_context.run_date)
|
||||||
@@ -157,6 +186,35 @@ export const listDueBirdMilestoneReminders = async (runDate: string) => {
|
|||||||
AND deliveries.reminder_type = 'gotcha_day'
|
AND deliveries.reminder_type = 'gotcha_day'
|
||||||
AND deliveries.reminder_year = reminder_context.reminder_year
|
AND deliveries.reminder_year = reminder_context.reminder_year
|
||||||
)
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
'memorial_day'::text AS reminder_type,
|
||||||
|
birds.memorialized_on::text AS reminder_date,
|
||||||
|
reminder_context.reminder_year
|
||||||
|
FROM birds
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN reminder_context
|
||||||
|
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 birds.notify_on_memorial_day = TRUE
|
||||||
|
AND birds.memorialized_at IS NOT NULL
|
||||||
|
AND birds.memorialized_on IS NOT NULL
|
||||||
|
AND EXTRACT(MONTH FROM birds.memorialized_on) = EXTRACT(MONTH FROM reminder_context.run_date)
|
||||||
|
AND EXTRACT(DAY FROM birds.memorialized_on) = EXTRACT(DAY FROM reminder_context.run_date)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM bird_milestone_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.bird_id = birds.id
|
||||||
|
AND deliveries.reminder_type = 'memorial_day'
|
||||||
|
AND deliveries.reminder_year = reminder_context.reminder_year
|
||||||
|
)
|
||||||
ORDER BY workspace_name ASC, name ASC, reminder_type ASC`,
|
ORDER BY workspace_name ASC, name ASC, reminder_type ASC`,
|
||||||
[runDate],
|
[runDate],
|
||||||
);
|
);
|
||||||
@@ -216,7 +274,7 @@ export const createBird = async ({
|
|||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
|
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -264,7 +322,8 @@ export const updateBird = async ({
|
|||||||
notify_on_gotcha_day = $11
|
notify_on_gotcha_day = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $12
|
AND workspace_id = $12
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
|
AND memorialized_at IS NULL
|
||||||
|
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -285,6 +344,49 @@ export const updateBird = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const memorializeBird = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
memorializedOn,
|
||||||
|
memorialNote,
|
||||||
|
notifyOnMemorialDay,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
memorializedOn: string;
|
||||||
|
memorialNote: string | null;
|
||||||
|
notifyOnMemorialDay: boolean;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<BirdRow>(
|
||||||
|
`UPDATE birds
|
||||||
|
SET memorialized_at = CURRENT_TIMESTAMP,
|
||||||
|
memorialized_on = $3,
|
||||||
|
memorial_note = $4,
|
||||||
|
notify_on_memorial_day = $5
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND memorialized_at IS NULL
|
||||||
|
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
|
(
|
||||||
|
SELECT weight_grams::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_weight_grams,
|
||||||
|
(
|
||||||
|
SELECT recorded_on::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_recorded_on`,
|
||||||
|
[birdId, workspaceId, memorializedOn, memorialNote, notifyOnMemorialDay],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteBird = async (birdId: string, workspaceId: number) => {
|
export const deleteBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM birds
|
`DELETE FROM birds
|
||||||
@@ -303,7 +405,8 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
SET workspace_id = $3
|
SET workspace_id = $3
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, created_at,
|
AND memorialized_at IS NULL
|
||||||
|
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
|
|||||||
@@ -302,6 +302,18 @@ export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, exclud
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getWorkspaceBirdCount = async (workspaceId: number) => {
|
export const getWorkspaceBirdCount = async (workspaceId: number) => {
|
||||||
|
const birdCount = await db.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count
|
||||||
|
FROM birds
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
AND memorialized_at IS NULL`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(birdCount.rows[0]?.count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceTotalBirdCount = async (workspaceId: number) => {
|
||||||
const birdCount = await db.query<{ count: string }>(
|
const birdCount = await db.query<{ count: string }>(
|
||||||
`SELECT COUNT(*)::text AS count
|
`SELECT COUNT(*)::text AS count
|
||||||
FROM birds
|
FROM birds
|
||||||
@@ -313,7 +325,7 @@ export const getWorkspaceBirdCount = async (workspaceId: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWorkspaceIfEmpty = async (workspaceId: number) => {
|
export const deleteWorkspaceIfEmpty = async (workspaceId: number) => {
|
||||||
if ((await getWorkspaceBirdCount(workspaceId)) > 0) {
|
if ((await getWorkspaceTotalBirdCount(workspaceId)) > 0) {
|
||||||
return { deleted: false as const, reason: 'birds_present' as const };
|
return { deleted: false as const, reason: 'birds_present' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export type BirdRow = {
|
|||||||
photo_data_url: string | null;
|
photo_data_url: string | null;
|
||||||
notify_on_dob: boolean;
|
notify_on_dob: boolean;
|
||||||
notify_on_gotcha_day: boolean;
|
notify_on_gotcha_day: boolean;
|
||||||
|
memorialized_at: string | null;
|
||||||
|
memorialized_on: string | null;
|
||||||
|
memorial_note: string | null;
|
||||||
|
notify_on_memorial_day: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
latest_weight_grams: string | null;
|
latest_weight_grams: string | null;
|
||||||
latest_recorded_on: string | null;
|
latest_recorded_on: string | null;
|
||||||
@@ -115,7 +119,7 @@ export type LostBirdMatchRow = BirdRow & {
|
|||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BirdMilestoneReminderType = 'hatch_day' | 'gotcha_day';
|
export type BirdMilestoneReminderType = 'hatch_day' | 'gotcha_day' | 'memorial_day';
|
||||||
|
|
||||||
export type BirdMilestoneReminderCandidateRow = BirdRow & {
|
export type BirdMilestoneReminderCandidateRow = BirdRow & {
|
||||||
workspace_name: string;
|
workspace_name: string;
|
||||||
|
|||||||
+154
-3
@@ -26,6 +26,10 @@ type Bird = {
|
|||||||
photoDataUrl: string | null;
|
photoDataUrl: string | null;
|
||||||
notifyOnDob: boolean;
|
notifyOnDob: boolean;
|
||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
|
memorializedAt: string | null;
|
||||||
|
memorializedOn: string | null;
|
||||||
|
memorialNote: string | null;
|
||||||
|
notifyOnMemorialDay: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
latestWeightGrams: number | null;
|
latestWeightGrams: number | null;
|
||||||
latestRecordedOn: string | null;
|
latestRecordedOn: string | null;
|
||||||
@@ -182,6 +186,12 @@ type BirdFormState = {
|
|||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MemorializeBirdFormState = {
|
||||||
|
memorializedOn: string;
|
||||||
|
memorialNote: string;
|
||||||
|
notifyOnMemorialDay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type WorkspaceFormState = {
|
type WorkspaceFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
@@ -297,6 +307,12 @@ const emptyBirdForm: BirdFormState = {
|
|||||||
notifyOnGotchaDay: false,
|
notifyOnGotchaDay: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
|
||||||
|
memorializedOn: new Date().toISOString().slice(0, 10),
|
||||||
|
memorialNote: '',
|
||||||
|
notifyOnMemorialDay: true,
|
||||||
|
});
|
||||||
|
|
||||||
const emptyWorkspaceForm: WorkspaceFormState = {
|
const emptyWorkspaceForm: WorkspaceFormState = {
|
||||||
name: 'My Flock',
|
name: 'My Flock',
|
||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
@@ -1068,6 +1084,7 @@ function App() {
|
|||||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||||
const [birds, setBirds] = useState<Bird[]>([]);
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
|
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||||
@@ -1084,6 +1101,7 @@ function App() {
|
|||||||
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
||||||
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
||||||
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||||
|
const [memorializeBirdForm, setMemorializeBirdForm] = useState<MemorializeBirdFormState>(emptyMemorializeBirdForm);
|
||||||
const [birdPhotoName, setBirdPhotoName] = useState('');
|
const [birdPhotoName, setBirdPhotoName] = useState('');
|
||||||
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
|
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
|
||||||
const [photoDrag, setPhotoDrag] = useState<PhotoDragState | null>(null);
|
const [photoDrag, setPhotoDrag] = useState<PhotoDragState | null>(null);
|
||||||
@@ -1138,6 +1156,7 @@ function App() {
|
|||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deletingBird, setDeletingBird] = useState(false);
|
const [deletingBird, setDeletingBird] = useState(false);
|
||||||
|
const [memorializingBird, setMemorializingBird] = useState(false);
|
||||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||||
@@ -1583,6 +1602,7 @@ function App() {
|
|||||||
setAdminSummary(null);
|
setAdminSummary(null);
|
||||||
setAdminRescueWorkspaces([]);
|
setAdminRescueWorkspaces([]);
|
||||||
setBirds([]);
|
setBirds([]);
|
||||||
|
setMemorializedBirds([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
setVetVisits([]);
|
setVetVisits([]);
|
||||||
setMedications([]);
|
setMedications([]);
|
||||||
@@ -1686,10 +1706,11 @@ function App() {
|
|||||||
throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.'));
|
throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJsonSafely<{ birds?: Bird[] }>(birdsResponse)) ?? {};
|
const data = (await readJsonSafely<{ birds?: Bird[]; memorializedBirds?: Bird[] }>(birdsResponse)) ?? {};
|
||||||
const nextBirds = data.birds ?? [];
|
const nextBirds = data.birds ?? [];
|
||||||
|
|
||||||
setBirds(nextBirds);
|
setBirds(nextBirds);
|
||||||
|
setMemorializedBirds(data.memorializedBirds ?? []);
|
||||||
setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : ''));
|
setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : ''));
|
||||||
|
|
||||||
if (membersResponse.ok) {
|
if (membersResponse.ok) {
|
||||||
@@ -2845,6 +2866,71 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMemorializeBird = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedBird || memorializingBird) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Memorialize ${selectedBird.name}?\n\nThis cannot be undone by you. ${selectedBird.name} will become read-only, hidden from the standard flock view, and excluded from the subscription bird count.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMemorializingBird(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${selectedBird.id}/memorialize`, authToken, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(memorializeBirdForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to memorialize bird.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readJsonSafely<{ bird: Bird }>(response);
|
||||||
|
if (!data?.bird) {
|
||||||
|
throw new Error('Unable to memorialize bird.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const memorializedBird = data.bird;
|
||||||
|
setBirds((current) => current.filter((bird) => bird.id !== memorializedBird.id));
|
||||||
|
setMemorializedBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== memorializedBird.id), memorializedBird]));
|
||||||
|
setSelectedBirdId('');
|
||||||
|
setEditingBirdId('');
|
||||||
|
setBirdForm(emptyBirdForm);
|
||||||
|
setBirdPhotoName('');
|
||||||
|
setPhotoCrop(null);
|
||||||
|
setPhotoDrag(null);
|
||||||
|
setMemorializeBirdForm(emptyMemorializeBirdForm());
|
||||||
|
setWeights([]);
|
||||||
|
setVetVisits([]);
|
||||||
|
setMedications([]);
|
||||||
|
setMedicationAdministrations([]);
|
||||||
|
setAllBirdWeights((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[memorializedBird.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAllBirdVetVisits((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[memorializedBird.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (memorializeError) {
|
||||||
|
setError(memorializeError instanceof Error ? memorializeError.message : 'Unable to memorialize bird.');
|
||||||
|
} finally {
|
||||||
|
setMemorializingBird(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (transferringBird) {
|
if (transferringBird) {
|
||||||
@@ -4878,6 +4964,32 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{memorializedBirds.length ? (
|
||||||
|
<section className="settings-subsection settings-nested-card">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Memorials</p>
|
||||||
|
<h3>Memorialized birds</h3>
|
||||||
|
<p className="muted">
|
||||||
|
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-list">
|
||||||
|
{memorializedBirds.map((bird) => (
|
||||||
|
<article className="vet-visit-card" key={bird.id}>
|
||||||
|
<strong>{bird.name}</strong>
|
||||||
|
<small>
|
||||||
|
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
|
||||||
|
</small>
|
||||||
|
{bird.notifyOnMemorialDay ? <small>Memorial day reminders enabled.</small> : <small>Memorial day reminders off.</small>}
|
||||||
|
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
||||||
<section className="settings-nested-card">
|
<section className="settings-nested-card">
|
||||||
<div className="settings-nested-header">
|
<div className="settings-nested-header">
|
||||||
@@ -5269,12 +5381,51 @@ function App() {
|
|||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Danger zone</p>
|
<p className="eyebrow">Danger zone</p>
|
||||||
<h3>Remove bird profile</h3>
|
<h3>Destructive profile actions</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Remove {selectedBird.name} from this flock. This also removes weight records, vet visits, and medication history for this bird.
|
Memorializing is not reversible by you. It makes {selectedBird.name} read-only, hides the profile from the standard flock view,
|
||||||
|
and removes the bird from subscription counting.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form className="form-panel inline-form care-entry-form" onSubmit={handleMemorializeBird}>
|
||||||
|
<label>
|
||||||
|
Memorial date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={memorializeBirdForm.memorializedOn}
|
||||||
|
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorializedOn: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="toggle-card">
|
||||||
|
<span>Send memorial day reminders</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memorializeBirdForm.notifyOnMemorialDay}
|
||||||
|
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, notifyOnMemorialDay: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<small className="muted">Send an annual remembrance notification using the same delivery workflow as Hatch Day reminders.</small>
|
||||||
|
</label>
|
||||||
|
<label className="wide-field">
|
||||||
|
Memorial note
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={memorializeBirdForm.memorialNote}
|
||||||
|
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, memorialNote: event.target.value })}
|
||||||
|
placeholder="Optional words to include in future memorial reminders."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="button-row care-form-actions">
|
||||||
|
<button className="danger-button" type="submit" disabled={memorializingBird || activeMembership?.role !== 'owner'}>
|
||||||
|
{memorializingBird ? 'Memorializing...' : `Memorialize ${selectedBird.name}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeMembership?.role !== 'owner' ? <p className="muted">Only flock owners can memorialize a bird profile.</p> : null}
|
||||||
|
</form>
|
||||||
|
<p className="muted">
|
||||||
|
Removing is separate from memorializing and permanently deletes weight records, vet visits, and medication history for this bird.
|
||||||
|
</p>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
|
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
|
||||||
{deletingBird ? 'Removing...' : 'Remove from flock'}
|
{deletingBird ? 'Removing...' : 'Remove from flock'}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
|
|||||||
Reference in New Issue
Block a user