From 646f895ed62892500f1227680bfa046eb99b2fe8 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Wed, 22 Apr 2026 10:42:43 -0400 Subject: [PATCH] Added memorial settings --- backend/src/app.ts | 183 ++++++++++++++++-- backend/src/db/schema.ts | 19 +- backend/src/repositories/birdRepository.ts | 115 ++++++++++- .../src/repositories/workspaceRepository.ts | 14 +- backend/src/types.ts | 6 +- frontend/src/App.tsx | 157 ++++++++++++++- frontend/tsconfig.json | 2 +- frontend/tsconfig.node.json | 2 +- 8 files changed, 466 insertions(+), 32 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index f7d9f26..d5dc186 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -44,10 +44,12 @@ import { getBirdById, listBirds, listDueBirdMilestoneReminders, + listMemorializedBirds, listMedicationAdministrationsForBird, listMedicationsForBird, listVetVisitsForBird, listWeightsForBird, + memorializeBird, transferBirdToWorkspace, updateBird, updateMedicationForBird, @@ -63,11 +65,11 @@ import { deleteWorkspaceIfEmpty, ensurePersonalWorkspaceForUser, findAlternateWorkspaceForUser, - getWorkspaceBirdCount, getPlatformAdminSummary, getMembershipForUser, getNextWorkspaceId, getWorkspaceById, + getWorkspaceTotalBirdCount, listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, listMembershipsForUser, @@ -221,6 +223,12 @@ const birdSchema = z.object({ 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({ weightGrams: z.coerce.number().positive().max(10000), recordedOn: dateStringSchema, @@ -435,6 +443,10 @@ const normalizeBird = (row: BirdRow) => ({ photoDataUrl: row.photo_data_url, notifyOnDob: row.notify_on_dob, 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, latestWeightGrams: row.latest_weight_grams ? Number(row.latest_weight_grams) : null, latestRecordedOn: row.latest_recorded_on, @@ -833,7 +845,12 @@ const formatOrdinal = (value: number) => { }; 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)); return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0; }; @@ -854,13 +871,10 @@ const getFlockPalLogoAttachment = () => { }; }; -const getEmailTrackPatternAttachment = () => ({ - filename: 'flockpal-x-pattern.svg', - content: ``, - contentType: 'image/svg+xml', - cid: 'flockpal-x-pattern', - contentDisposition: 'inline' as const, -}); +const getEmailTrackPatternDataUrl = () => { + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}; const parseDataImage = (dataUrl: string) => { 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 { subject: `It's ${reminder.name}'s Gotcha Day!`, eyebrow: 'Gotcha Day', @@ -1150,7 +1181,7 @@ const sendBirdMilestoneReminderNotification = async ({ const copy = buildBirdMilestoneReminderCopy(reminder); const attachments: NonNullable = []; const logoAttachment = getFlockPalLogoAttachment(); - const trackPatternAttachment = getEmailTrackPatternAttachment(); + 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 : ''; @@ -1158,7 +1189,6 @@ const sendBirdMilestoneReminderNotification = async ({ if (logoAttachment) { attachments.push(logoAttachment); } - attachments.push(trackPatternAttachment); if (uploadedBirdPhoto) { attachments.push({ @@ -1201,9 +1231,9 @@ const sendBirdMilestoneReminderNotification = async ({ text: lines.join('\n'), attachments, html: ` -
+
- +
@@ -1232,7 +1262,7 @@ const sendBirdMilestoneReminderNotification = async ({
- +
`, @@ -1422,6 +1452,18 @@ const requireWorkspaceRole = (allowedRoles: WorkspaceRole[]) => (req: Request, r 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 = ( workspace: WorkspaceRow, payload: z.infer, @@ -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) => { 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.' }); 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) => { try { - const birds = await listBirds(req.auth!.workspace.id); - res.json({ birds: birds.map(normalizeBird) }); + const [birds, memorializedBirds] = await Promise.all([ + listBirds(req.auth!.workspace.id), + listMemorializedBirds(req.auth!.workspace.id), + ]); + res.json({ birds: birds.map(normalizeBird), memorializedBirds: memorializedBirds.map(normalizeBird) }); } catch (error) { next(error); } @@ -2286,6 +2331,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require return; } + if (!ensureBirdWritable(sourceBird, res)) { + return; + } + const targetWorkspaces = await listOwnedWorkspacesByOwnerEmail(destinationOwnerEmail, req.auth!.workspace.id); if (!targetWorkspaces.length) { @@ -2349,6 +2398,17 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR } 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({ birdId: req.params.birdId, 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) => { 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); 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) => { try { 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; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); res.status(201).json({ weight: normalizeWeight(weight!) }); } catch (error) { @@ -2458,6 +2572,10 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const vetVisit = await createVetVisitForBird( req.params.birdId, parsed.data.visitedOn, @@ -2488,6 +2606,10 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const vetVisit = await updateVetVisitForBird( req.params.visitId, req.params.birdId, @@ -2517,6 +2639,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const deleted = await deleteVetVisitForBird(req.params.visitId, req.params.birdId); if (!deleted) { @@ -2555,6 +2681,10 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const medication = await createMedicationForBird( req.params.birdId, parsed.data.name, @@ -2589,6 +2719,10 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const medication = await updateMedicationForBird( req.params.medicationId, req.params.birdId, @@ -2622,6 +2756,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW return; } + if (!ensureBirdWritable(bird, res)) { + return; + } + const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId); if (!deleted) { @@ -2653,6 +2791,17 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require } 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( req.params.medicationId, req.params.birdId, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5582963..9bca7ef 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -208,6 +208,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => { photo_data_url TEXT, notify_on_dob 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 ); @@ -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 photo_data_url TEXT, 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 $$ BEGIN @@ -303,13 +311,20 @@ export const ensureSchema = async (database: DatabaseClient = db) => { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(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, delivered_on DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE, diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 7eb4931..51f1dfc 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -27,6 +27,10 @@ const birdSelectFields = ` birds.photo_data_url, birds.notify_on_dob, birds.notify_on_gotcha_day, + birds.memorialized_at, + birds.memorialized_on::text, + birds.memorial_note, + birds.notify_on_memorial_day, birds.created_at, latest.weight_grams AS latest_weight_grams, latest.recorded_on::text AS latest_recorded_on @@ -65,6 +69,7 @@ export const listBirds = async (workspaceId: number) => { LIMIT 1 ) latest ON TRUE WHERE birds.workspace_id = $1 + AND birds.memorialized_at IS NULL ORDER BY birds.name ASC`, [workspaceId], ); @@ -72,6 +77,27 @@ export const listBirds = async (workspaceId: number) => { return result.rows; }; +export const listMemorializedBirds = async (workspaceId: number) => { + const result = await db.query( + `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) => { const result = await db.query( `SELECT @@ -87,9 +113,10 @@ export const findBirdsByBandId = async (tagId: string) => { ORDER BY recorded_on DESC LIMIT 1 ) latest ON TRUE - WHERE LOWER(birds.tag_id) = LOWER($1) - ORDER BY birds.created_at ASC - LIMIT 10`, + WHERE LOWER(birds.tag_id) = LOWER($1) + AND birds.memorialized_at IS NULL + ORDER BY birds.created_at ASC + LIMIT 10`, [tagId], ); @@ -119,6 +146,7 @@ export const listDueBirdMilestoneReminders = async (runDate: string) => { LIMIT 1 ) latest ON TRUE WHERE birds.notify_on_dob = TRUE + AND birds.memorialized_at IS 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(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 ) latest ON TRUE WHERE birds.notify_on_gotcha_day = TRUE + AND birds.memorialized_at IS NULL AND birds.gotcha_day IS NOT NULL 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) @@ -157,6 +186,35 @@ export const listDueBirdMilestoneReminders = async (runDate: string) => { AND deliveries.reminder_type = 'gotcha_day' 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`, [runDate], ); @@ -216,7 +274,7 @@ export const createBird = async ({ const result = await db.query( `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) - 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], ); @@ -264,7 +322,8 @@ export const updateBird = async ({ notify_on_gotcha_day = $11 WHERE id = $1 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 FROM weight_records @@ -285,6 +344,49 @@ export const updateBird = async ({ 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( + `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) => { const result = await db.query<{ id: string }>( `DELETE FROM birds @@ -303,7 +405,8 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: SET workspace_id = $3 WHERE id = $1 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 FROM weight_records diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 960e33f..cd472b5 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -302,6 +302,18 @@ export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, exclud }; 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 }>( `SELECT COUNT(*)::text AS count FROM birds @@ -313,7 +325,7 @@ export const getWorkspaceBirdCount = 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 }; } diff --git a/backend/src/types.ts b/backend/src/types.ts index 7673fb6..9bfc7a4 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -105,6 +105,10 @@ export type BirdRow = { photo_data_url: string | null; notify_on_dob: 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; latest_weight_grams: string | null; latest_recorded_on: string | null; @@ -115,7 +119,7 @@ export type LostBirdMatchRow = BirdRow & { 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 & { workspace_name: string; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7a08bf..6a1a154 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,10 @@ type Bird = { photoDataUrl: string | null; notifyOnDob: boolean; notifyOnGotchaDay: boolean; + memorializedAt: string | null; + memorializedOn: string | null; + memorialNote: string | null; + notifyOnMemorialDay: boolean; createdAt: string; latestWeightGrams: number | null; latestRecordedOn: string | null; @@ -182,6 +186,12 @@ type BirdFormState = { notifyOnGotchaDay: boolean; }; +type MemorializeBirdFormState = { + memorializedOn: string; + memorialNote: string; + notifyOnMemorialDay: boolean; +}; + type WorkspaceFormState = { name: string; workspaceType: WorkspaceType; @@ -297,6 +307,12 @@ const emptyBirdForm: BirdFormState = { notifyOnGotchaDay: false, }; +const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ + memorializedOn: new Date().toISOString().slice(0, 10), + memorialNote: '', + notifyOnMemorialDay: true, +}); + const emptyWorkspaceForm: WorkspaceFormState = { name: 'My Flock', workspaceType: 'standard', @@ -1068,6 +1084,7 @@ function App() { const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [birds, setBirds] = useState([]); + const [memorializedBirds, setMemorializedBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); const [editingBirdId, setEditingBirdId] = useState(''); const [weights, setWeights] = useState([]); @@ -1084,6 +1101,7 @@ function App() { const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); + const [memorializeBirdForm, setMemorializeBirdForm] = useState(emptyMemorializeBirdForm); const [birdPhotoName, setBirdPhotoName] = useState(''); const [photoCrop, setPhotoCrop] = useState(null); const [photoDrag, setPhotoDrag] = useState(null); @@ -1138,6 +1156,7 @@ function App() { previewUrl?: string | null; } | null>(null); const [deletingBird, setDeletingBird] = useState(false); + const [memorializingBird, setMemorializingBird] = useState(false); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState(''); @@ -1583,6 +1602,7 @@ function App() { setAdminSummary(null); setAdminRescueWorkspaces([]); setBirds([]); + setMemorializedBirds([]); setWeights([]); setVetVisits([]); setMedications([]); @@ -1686,10 +1706,11 @@ function App() { 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 ?? []; setBirds(nextBirds); + setMemorializedBirds(data.memorializedBirds ?? []); setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : '')); if (membersResponse.ok) { @@ -2845,6 +2866,71 @@ function App() { } }; + const handleMemorializeBird = async (event: React.FormEvent) => { + 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) => { event.preventDefault(); if (transferringBird) { @@ -4878,6 +4964,32 @@ function App() { ))}
+ {memorializedBirds.length ? ( +
+
+
+

Memorials

+

Memorialized birds

+

+ These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts. +

+
+
+
+ {memorializedBirds.map((bird) => ( +
+ {bird.name} + + {bird.species} • Memorialized {formatDate(bird.memorializedOn)} + + {bird.notifyOnMemorialDay ? Memorial day reminders enabled. : Memorial day reminders off.} + {bird.memorialNote ?

{bird.memorialNote}

: null} +
+ ))} +
+
+ ) : null} +
@@ -5269,12 +5381,51 @@ function App() {

Danger zone

-

Remove bird profile

+

Destructive profile actions

- 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.

+ + + +