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