Added timeline feature
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user