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, completePendingBirdTransfersForOwner,
createBird, createBird,
createBirdMilestoneReminderDelivery, createBirdMilestoneReminderDelivery,
createBirdTimelineEvent,
createMedicationReminderDelivery, createMedicationReminderDelivery,
createBirdTransferCode, createBirdTransferCode,
createMedicationForBird, createMedicationForBird,
@@ -51,6 +52,7 @@ import {
getBirdByPublicProfileCode, getBirdByPublicProfileCode,
getOpenBirdTransferCode, getOpenBirdTransferCode,
listBirds, listBirds,
listBirdTimelineEvents,
listDueBirdMilestoneReminders, listDueBirdMilestoneReminders,
listDueMedicationReminders, listDueMedicationReminders,
listMemorializedBirds, listMemorializedBirds,
@@ -133,6 +135,8 @@ import type {
BirdGender, BirdGender,
BirdMilestoneReminderCandidateRow, BirdMilestoneReminderCandidateRow,
BirdRow, BirdRow,
BirdTimelineEventType,
BirdTimelineEventRow,
FlockNoteRow, FlockNoteRow,
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
@@ -289,6 +293,7 @@ const birdSchema = z.object({
motivators: birdProfileListSchema, motivators: birdProfileListSchema,
demotivators: birdProfileListSchema, demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), 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('')), vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')), vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
vetAccountNumber: z.string().trim().max(120).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(), 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({ const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000), weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema, recordedOn: dateStringSchema,
@@ -689,6 +706,7 @@ const normalizeBird = (row: BirdRow) => ({
motivators: row.motivators, motivators: row.motivators,
demotivators: row.demotivators, demotivators: row.demotivators,
favoriteSnack: row.favorite_snack, favoriteSnack: row.favorite_snack,
locationLabel: row.location_label,
vetClinicName: row.vet_clinic_name, vetClinicName: row.vet_clinic_name,
vetClinicAddress: row.vet_clinic_address, vetClinicAddress: row.vet_clinic_address,
vetAccountNumber: row.vet_account_number, vetAccountNumber: row.vet_account_number,
@@ -796,6 +814,23 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
createdAt: row.created_at, 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) => ({ const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
id: row.id, id: row.id,
userId: row.user_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 = ( const isBillingOnlyWorkspaceUpdate = (
workspace: WorkspaceRow, workspace: WorkspaceRow,
payload: z.infer<typeof workspaceSchema>, 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) => { app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const token = typeof req.query.token === 'string' ? req.query.token : ''; 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), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -3642,6 +3776,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
species: bird!.species, species: bird!.species,
tagId: bird!.tag_id, 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!) }); res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
@@ -3726,6 +3867,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
destinationOwnerEmail, destinationOwnerEmail,
destinationWorkspaceId: targetWorkspace.id, 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) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -3836,6 +3984,13 @@ app.post(
sourceWorkspaceName: transferCode.workspace_name, sourceWorkspaceName: transferCode.workspace_name,
transferCodeId: transferCode.transfer_code_id, 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) }); res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
} catch (error) { } catch (error) {
@@ -3963,6 +4118,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
motivators: emptyToNull(parsed.data.motivators), motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators), demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack), favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
locationLabel: emptyToNull(parsed.data.locationLabel),
vetClinicName: emptyToNull(parsed.data.vetClinicName), vetClinicName: emptyToNull(parsed.data.vetClinicName),
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
@@ -3992,6 +4148,15 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
previousName: existingBird.name, previousName: existingBird.name,
species: bird.species, 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) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
+26
View File
@@ -249,6 +249,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000), motivators VARCHAR(1000),
demotivators VARCHAR(1000), demotivators VARCHAR(1000),
favorite_snack VARCHAR(160), favorite_snack VARCHAR(160),
location_label VARCHAR(160),
vet_clinic_name VARCHAR(160), vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500), vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120), vet_account_number VARCHAR(120),
@@ -277,6 +278,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160), ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120), ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
@@ -402,6 +404,30 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE completed_at IS NULL WHERE completed_at IS NULL
AND revoked_at IS NULL; AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_timeline_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
event_type VARCHAR(40) NOT NULL,
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
from_workspace_name VARCHAR(160),
to_workspace_name VARCHAR(160),
from_owner_email VARCHAR(255),
to_owner_email VARCHAR(255),
location_label VARCHAR(160),
note TEXT,
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE bird_timeline_events
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
CREATE TABLE IF NOT EXISTS flock_notes ( CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -188,6 +188,45 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
], ],
}, },
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
{
rowCount: 1,
rows: [
{
workspace_id: 10,
workspace_name: 'Original Flock',
owner_email: 'sender@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
workspace_id: 22,
workspace_name: 'Receiving Flock',
owner_email: 'receiver@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
id: 'timeline-1',
bird_id: 'bird-1',
event_type: 'transferred',
from_workspace_id: 10,
to_workspace_id: 22,
from_workspace_name: 'Original Flock',
to_workspace_name: 'Receiving Flock',
from_owner_email: 'sender@example.com',
to_owner_email: 'receiver@example.com',
location_label: 'Receiving Flock',
created_by_user_id: 'user-1',
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
); );
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22); const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
@@ -197,4 +236,18 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
assert.deepEqual(calls[5].params, [
'bird-1',
'transferred',
10,
22,
'Original Flock',
'Receiving Flock',
'sender@example.com',
'receiver@example.com',
null,
null,
null,
'user-1',
]);
}); });
+151 -24
View File
@@ -5,6 +5,8 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow, BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
@@ -26,6 +28,7 @@ const birdSelectFields = `
birds.motivators, birds.motivators,
birds.demotivators, birds.demotivators,
birds.favorite_snack, birds.favorite_snack,
birds.location_label,
birds.vet_clinic_name, birds.vet_clinic_name,
birds.vet_clinic_address, birds.vet_clinic_address,
birds.vet_account_number, birds.vet_account_number,
@@ -51,6 +54,34 @@ const birdSelectFields = `
latest.recorded_on::text AS latest_recorded_on latest.recorded_on::text AS latest_recorded_on
`; `;
type WorkspaceTimelineSnapshot = {
workspace_id: number;
workspace_name: string;
owner_email: string | null;
};
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
const result = await db.query<WorkspaceTimelineSnapshot>(
`SELECT
workspaces.id AS workspace_id,
workspaces.name AS workspace_name,
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
FROM workspaces
LEFT JOIN LATERAL (
SELECT invite_email, email
FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.role = 'owner'
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
LIMIT 1
) owner_member ON TRUE
WHERE workspaces.id = $1`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getBirdById = async (birdId: string, workspaceId: number) => { export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`SELECT `SELECT
@@ -134,6 +165,84 @@ export const listMemorializedBirds = async (workspaceId: number) => {
return result.rows; return result.rows;
}; };
export const createBirdTimelineEvent = 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;
}) => {
const [fromWorkspace, toWorkspace] = await Promise.all([
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
]);
const result = await db.query<BirdTimelineEventRow>(
`INSERT INTO bird_timeline_events (
bird_id,
event_type,
from_workspace_id,
to_workspace_id,
from_workspace_name,
to_workspace_name,
from_owner_email,
to_owner_email,
location_label,
note,
event_date,
created_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, $6, $5), $10, COALESCE($11::date, CURRENT_DATE), $12)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
eventType,
fromWorkspaceId ?? null,
toWorkspaceId ?? null,
fromWorkspace?.workspace_name ?? null,
toWorkspace?.workspace_name ?? null,
fromWorkspace?.owner_email ?? null,
toWorkspace?.owner_email ?? null,
locationLabel ?? null,
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
],
);
return result.rows[0] ?? null;
};
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdTimelineEventRow>(
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = bird_timeline_events.bird_id
AND birds.workspace_id = $2
)
ORDER BY event_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => { export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>( const result = await db.query<LostBirdMatchRow>(
`SELECT `SELECT
@@ -367,6 +476,7 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel = null,
vetClinicName = null, vetClinicName = null,
vetClinicAddress = null, vetClinicAddress = null,
vetAccountNumber = null, vetAccountNumber = null,
@@ -392,6 +502,7 @@ export const createBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel?: string | null;
vetClinicName?: string | null; vetClinicName?: string | null;
vetClinicAddress?: string | null; vetClinicAddress?: string | null;
vetAccountNumber?: string | null; vetAccountNumber?: string | null;
@@ -410,9 +521,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean; publicProfileEnabled?: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, 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`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, 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`,
[ [
birdId ?? null, birdId ?? null,
workspaceId, workspaceId,
@@ -422,6 +533,7 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -453,6 +565,7 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -478,6 +591,7 @@ export const updateBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel: string | null;
vetClinicName: string | null; vetClinicName: string | null;
vetClinicAddress: string | null; vetClinicAddress: string | null;
vetAccountNumber: string | null; vetAccountNumber: string | null;
@@ -503,26 +617,27 @@ export const updateBird = async ({
motivators = $5, motivators = $5,
demotivators = $6, demotivators = $6,
favorite_snack = $7, favorite_snack = $7,
vet_clinic_name = $8, location_label = $8,
vet_clinic_address = $9, vet_clinic_name = $9,
vet_account_number = $10, vet_clinic_address = $10,
vet_doctor_name = $11, vet_account_number = $11,
gender = $12, vet_doctor_name = $12,
date_of_birth = $13, gender = $13,
gotcha_day = $14, date_of_birth = $14,
chart_color = $15, gotcha_day = $15,
photo_data_url = $16, chart_color = $16,
photo_object_key = $17, photo_data_url = $17,
photo_content_type = $18, photo_object_key = $18,
photo_updated_at = $19, photo_content_type = $19,
notify_on_dob = $20, photo_updated_at = $20,
notify_on_gotcha_day = $21, notify_on_dob = $21,
public_profile_code = $22, notify_on_gotcha_day = $22,
public_profile_enabled = $23 public_profile_code = $23,
public_profile_enabled = $24
WHERE id = $1 WHERE id = $1
AND workspace_id = $24 AND workspace_id = $25
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -545,6 +660,7 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -590,7 +706,7 @@ export const memorializeBird = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -626,7 +742,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NOT NULL AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -666,7 +782,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -762,6 +878,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
} }
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId); await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
try {
await createBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transfer.source_workspace_id,
toWorkspaceId: targetWorkspaceId,
createdByUserId: transfer.requested_by_user_id,
});
} catch (timelineError) {
console.error('Unable to write bird timeline event', timelineError);
}
completed += 1; completed += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
+20
View File
@@ -130,6 +130,7 @@ export type BirdRow = {
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favorite_snack: string | null; favorite_snack: string | null;
location_label: string | null;
vet_clinic_name: string | null; vet_clinic_name: string | null;
vet_clinic_address: string | null; vet_clinic_address: string | null;
vet_account_number: string | null; vet_account_number: string | null;
@@ -203,6 +204,25 @@ export type BirdTransferCodeRow = {
created_at: string; created_at: string;
}; };
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
export type BirdTimelineEventRow = {
id: string;
bird_id: string;
event_type: BirdTimelineEventType;
from_workspace_id: number | null;
to_workspace_id: number | null;
from_workspace_name: string | null;
to_workspace_name: string | null;
from_owner_email: string | null;
to_owner_email: string | null;
location_label: string | null;
note: string | null;
event_date: string;
created_by_user_id: string | null;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
+347 -598
View File
File diff suppressed because it is too large Load Diff
+115 -9
View File
@@ -616,14 +616,6 @@ textarea {
break-inside: avoid; break-inside: avoid;
} }
.settings-card-bird-profiles {
order: 1;
}
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-collaborators { .settings-card-collaborators {
order: 2; order: 2;
} }
@@ -1332,6 +1324,115 @@ textarea {
align-items: center; align-items: center;
} }
.bird-timeline-card {
grid-template-columns: 18px minmax(0, 1fr);
gap: 0.75rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
}
.bird-timeline-graph-card {
padding: 0.85rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
overflow: hidden;
}
.bird-timeline-graph {
display: block;
width: 100%;
height: auto;
min-height: 140px;
}
.bird-timeline-graph-line {
stroke: url(#timelineLine);
stroke-width: 4;
stroke-linecap: round;
}
.bird-timeline-graph-connector {
stroke: rgba(39, 105, 179, 0.18);
stroke-width: 2;
stroke-dasharray: 4 5;
}
.bird-timeline-graph-dot {
fill: #fffdf9;
stroke: var(--accent-green);
stroke-width: 4;
}
.bird-timeline-graph-dot.owner_changed {
stroke: var(--accent-blue);
}
.bird-timeline-graph-dot.transferred {
stroke: var(--accent-red);
}
.bird-timeline-graph-label,
.bird-timeline-graph-meta {
font-size: 0.72rem;
fill: var(--ink);
}
.bird-timeline-graph-label {
font-weight: 700;
}
.bird-timeline-graph-meta {
fill: var(--muted);
}
.bird-timeline-form {
padding: 0.85rem;
border: 1px solid rgba(35, 138, 90, 0.14);
border-radius: 8px;
background: rgba(240, 248, 244, 0.54);
}
.bird-timeline-marker {
width: 12px;
height: 12px;
margin-top: 0.25rem;
border-radius: 999px;
background: var(--accent-green);
box-shadow: 0 0 0 4px rgba(35, 138, 90, 0.12);
}
.bird-timeline-content {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.bird-timeline-content > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.bird-timeline-content strong,
.bird-timeline-content span,
.bird-timeline-content small,
.bird-timeline-content p {
overflow-wrap: anywhere;
}
.bird-timeline-content span,
.bird-timeline-content small {
color: var(--muted);
}
.bird-timeline-content p {
margin: 0;
color: var(--ink);
}
.legend-grid, .legend-grid,
.detail-grid, .detail-grid,
.summary-grid { .summary-grid {
@@ -1431,6 +1532,7 @@ textarea {
.bird-detail-tab .info-tab-icon, .bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon, .bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon, .bird-detail-tab .report-tab-icon,
.bird-detail-tab .timeline-tab-icon,
.bird-detail-tab .audit-tab-icon, .bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon { .bird-detail-tab .vet-tab-icon {
width: 24px; width: 24px;
@@ -1459,7 +1561,7 @@ textarea {
.profile-copy { .profile-copy {
display: grid; display: grid;
gap: 0.3rem; gap: 0.18rem;
} }
.profile-copy h3 { .profile-copy h3 {
@@ -1467,6 +1569,10 @@ textarea {
font-size: 1.6rem; font-size: 1.6rem;
} }
.profile-copy p {
margin: 0;
}
.profile-title { .profile-title {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;