Added timeline feature
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
Corey Blais
2026-06-28 12:30:36 -04:00
parent 56068e02a3
commit a988d9662b
7 changed files with 878 additions and 632 deletions
+165
View File
@@ -37,6 +37,7 @@ import {
completePendingBirdTransfersForOwner,
createBird,
createBirdMilestoneReminderDelivery,
createBirdTimelineEvent,
createMedicationReminderDelivery,
createBirdTransferCode,
createMedicationForBird,
@@ -51,6 +52,7 @@ import {
getBirdByPublicProfileCode,
getOpenBirdTransferCode,
listBirds,
listBirdTimelineEvents,
listDueBirdMilestoneReminders,
listDueMedicationReminders,
listMemorializedBirds,
@@ -133,6 +135,8 @@ import type {
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
BirdTimelineEventType,
BirdTimelineEventRow,
FlockNoteRow,
IntegrationTokenRow,
LostBirdMatchRow,
@@ -289,6 +293,7 @@ const birdSchema = z.object({
motivators: birdProfileListSchema,
demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
@@ -313,6 +318,18 @@ const memorialReminderPreferenceSchema = z.object({
notifyOnMemorialDay: z.boolean(),
});
const birdTimelineEventSchema = z
.object({
eventType: z.enum(['location_updated', 'owner_changed', 'manual_note']),
eventDate: dateStringSchema.optional().or(z.literal('')),
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
note: z.string().trim().max(500).optional().or(z.literal('')),
})
.refine(
(value) => value.eventType === 'owner_changed' || Boolean(value.locationLabel?.trim() || value.note?.trim()),
'Add a location or note for this timeline item.',
);
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
@@ -689,6 +706,7 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators,
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
locationLabel: row.location_label,
vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number,
@@ -796,6 +814,23 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
createdAt: row.created_at,
});
const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({
id: row.id,
birdId: row.bird_id,
eventType: row.event_type,
fromWorkspaceId: row.from_workspace_id,
toWorkspaceId: row.to_workspace_id,
fromWorkspaceName: row.from_workspace_name,
toWorkspaceName: row.to_workspace_name,
fromOwnerEmail: row.from_owner_email,
toOwnerEmail: row.to_owner_email,
locationLabel: row.location_label,
note: row.note,
eventDate: row.event_date,
createdByUserId: row.created_by_user_id,
createdAt: row.created_at,
});
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
id: row.id,
userId: row.user_id,
@@ -2326,6 +2361,41 @@ const writeAuditLog = async (
}
};
const writeBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
try {
await createBirdTimelineEvent({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
note,
eventDate,
createdByUserId,
});
} catch (error) {
console.error('Unable to write bird timeline event', error);
}
};
const isBillingOnlyWorkspaceUpdate = (
workspace: WorkspaceRow,
payload: z.infer<typeof workspaceSchema>,
@@ -3532,6 +3602,69 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
}
});
app.get('/api/birds/:birdId/timeline', requireAuth, 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;
}
const events = await listBirdTimelineEvents(req.params.birdId, req.auth!.workspace.id);
res.json({ events: events.map(normalizeBirdTimelineEvent) });
} catch (error) {
next(error);
}
});
app.post(
'/api/birds/:birdId/timeline',
requireAuth,
requireWriteAccess,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdTimelineEventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid timeline payload', details: parsed.error.flatten() });
return;
}
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 event = await createBirdTimelineEvent({
birdId: bird.id,
eventType: parsed.data.eventType as BirdTimelineEventType,
toWorkspaceId: req.auth!.workspace.id,
locationLabel: emptyToNull(parsed.data.locationLabel),
note: emptyToNull(parsed.data.note),
eventDate: emptyToNull(parsed.data.eventDate),
createdByUserId: req.auth!.user.id,
});
await writeAuditLog(req.auth!, 'bird.timeline_event_created', 'bird', bird.id, bird.name, {
eventType: parsed.data.eventType,
});
res.status(201).json({ event: normalizeBirdTimelineEvent(event!) });
} catch (error) {
next(error);
}
},
);
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
try {
const token = typeof req.query.token === 'string' ? req.query.token : '';
@@ -3619,6 +3752,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -3642,6 +3776,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
species: bird!.species,
tagId: bird!.tag_id,
});
await writeBirdTimelineEvent({
birdId: bird!.id,
eventType: 'profile_created',
toWorkspaceId: req.auth!.workspace.id,
locationLabel: bird!.location_label,
createdByUserId: req.auth!.user.id,
});
res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
@@ -3726,6 +3867,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
destinationOwnerEmail,
destinationWorkspaceId: targetWorkspace.id,
});
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: req.auth!.workspace.id,
toWorkspaceId: targetWorkspace.id,
createdByUserId: req.auth!.user.id,
});
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -3836,6 +3984,13 @@ app.post(
sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id,
});
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transferCode.source_workspace_id,
toWorkspaceId: req.auth!.workspace.id,
createdByUserId: req.auth!.user.id,
});
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) {
@@ -3963,6 +4118,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -3992,6 +4148,15 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
previousName: existingBird.name,
species: bird.species,
});
if ((existingBird.location_label ?? '') !== (bird.location_label ?? '')) {
await writeBirdTimelineEvent({
birdId: bird.id,
eventType: 'location_updated',
toWorkspaceId: req.auth!.workspace.id,
locationLabel: bird.location_label,
createdByUserId: req.auth!.user.id,
});
}
res.json({ bird: normalizeBird(bird) });
} catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);