Added memorial settings
This commit is contained in:
+166
-17
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user