Added memorial settings

This commit is contained in:
Corey Blais
2026-04-22 10:42:43 -04:00
parent 36e074c1fd
commit 646f895ed6
8 changed files with 466 additions and 32 deletions
+166 -17
View File
@@ -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: `<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>`,
contentType: 'image/svg+xml',
cid: 'flockpal-x-pattern',
contentDisposition: 'inline' as const,
});
const getEmailTrackPatternDataUrl = () => {
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>`;
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<SendMailOptions['attachments']> = [];
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: `
<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;">
<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 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);">
@@ -1232,7 +1262,7 @@ const sendBirdMilestoneReminderNotification = async ({
</div>
</div>
<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>
`,
@@ -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<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) => {
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,
+17 -2
View File
@@ -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,
+109 -6
View File
@@ -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<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) => {
const result = await db.query<LostBirdMatchRow>(
`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<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)
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<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) => {
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
@@ -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 };
}
+5 -1
View File
@@ -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;