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);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+115
-9
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user