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);
+26
View File
@@ -249,6 +249,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
location_label VARCHAR(160),
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
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 demotivators VARCHAR(1000),
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_address VARCHAR(500),
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
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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: [
{
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);
@@ -197,4 +236,18 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
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,
BirdMilestoneReminderType,
BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow,
LostBirdMatchRow,
MedicationAdministrationRow,
@@ -26,6 +28,7 @@ const birdSelectFields = `
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.location_label,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
@@ -51,6 +54,34 @@ const birdSelectFields = `
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) => {
const result = await db.query<BirdRow>(
`SELECT
@@ -134,6 +165,84 @@ export const listMemorializedBirds = async (workspaceId: number) => {
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) => {
const result = await db.query<LostBirdMatchRow>(
`SELECT
@@ -367,6 +476,7 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel = null,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
@@ -392,6 +502,7 @@ export const createBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel?: string | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
@@ -410,9 +521,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean;
}) => {
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)
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)
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`,
`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, $25)
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,
workspaceId,
@@ -422,6 +533,7 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -453,6 +565,7 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -478,6 +591,7 @@ export const updateBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel: string | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
@@ -503,26 +617,27 @@ export const updateBird = async ({
motivators = $5,
demotivators = $6,
favorite_snack = $7,
vet_clinic_name = $8,
vet_clinic_address = $9,
vet_account_number = $10,
vet_doctor_name = $11,
gender = $12,
date_of_birth = $13,
gotcha_day = $14,
chart_color = $15,
photo_data_url = $16,
photo_object_key = $17,
photo_content_type = $18,
photo_updated_at = $19,
notify_on_dob = $20,
notify_on_gotcha_day = $21,
public_profile_code = $22,
public_profile_enabled = $23
location_label = $8,
vet_clinic_name = $9,
vet_clinic_address = $10,
vet_account_number = $11,
vet_doctor_name = $12,
gender = $13,
date_of_birth = $14,
gotcha_day = $15,
chart_color = $16,
photo_data_url = $17,
photo_object_key = $18,
photo_content_type = $19,
photo_updated_at = $20,
notify_on_dob = $21,
notify_on_gotcha_day = $22,
public_profile_code = $23,
public_profile_enabled = $24
WHERE id = $1
AND workspace_id = $24
AND workspace_id = $25
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
FROM weight_records
@@ -545,6 +660,7 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -590,7 +706,7 @@ export const memorializeBird = async ({
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -626,7 +742,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -666,7 +782,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1
AND workspace_id = $2
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
FROM weight_records
@@ -762,6 +878,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
}
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;
} catch (error) {
failed += 1;
+20
View File
@@ -130,6 +130,7 @@ export type BirdRow = {
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
location_label: string | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
@@ -203,6 +204,25 @@ export type BirdTransferCodeRow = {
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 = {
id: string;
bird_id: string;
+348 -599
View File
File diff suppressed because it is too large Load Diff
+115 -9
View File
@@ -616,14 +616,6 @@ textarea {
break-inside: avoid;
}
.settings-card-bird-profiles {
order: 1;
}
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-collaborators {
order: 2;
}
@@ -1332,6 +1324,115 @@ textarea {
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,
.detail-grid,
.summary-grid {
@@ -1431,6 +1532,7 @@ textarea {
.bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .timeline-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
@@ -1459,7 +1561,7 @@ textarea {
.profile-copy {
display: grid;
gap: 0.3rem;
gap: 0.18rem;
}
.profile-copy h3 {
@@ -1467,6 +1569,10 @@ textarea {
font-size: 1.6rem;
}
.profile-copy p {
margin: 0;
}
.profile-title {
display: inline-flex;
align-items: center;