diff --git a/backend/src/app.ts b/backend/src/app.ts index 0b8afa7..eb59e17 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -61,6 +61,13 @@ import { updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; +import { + createAuditLogEntry, + createFlockNote, + deleteFlockNote, + listAuditLogEntries, + listFlockNotes, +} from './repositories/auditRepository.js'; import { deleteDailyEducation, deleteEducationQuestion, @@ -108,14 +115,16 @@ import { upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; import type { - AuthContext, - BillingInterval, - BillingPlan, - DailyEducationRow, - EducationQuestionRow, - BirdGender, + AuthContext, + AuditLogEntryRow, + BillingInterval, + BillingPlan, + DailyEducationRow, + EducationQuestionRow, + BirdGender, BirdMilestoneReminderCandidateRow, - BirdRow, + BirdRow, + FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, MedicationRow, @@ -332,6 +341,11 @@ const medicationAdministrationSchema = z.object({ notes: z.string().trim().max(500).optional().or(z.literal('')), }); +const flockNoteSchema = z.object({ + birdId: z.string().uuid().optional().nullable().or(z.literal('')), + body: z.string().trim().min(1).max(5000), +}); + const integrationTokenCreateSchema = z.object({ name: z.string().trim().min(1).max(160), scope: integrationTokenScopeSchema.default('read_write'), @@ -730,6 +744,33 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => createdAt: row.created_at, }); +const normalizeFlockNote = (row: FlockNoteRow) => ({ + id: row.id, + workspaceId: row.workspace_id, + birdId: row.bird_id, + birdName: row.bird_name, + title: row.title, + body: row.body, + createdByUserId: row.created_by_user_id, + createdByName: row.created_by_name, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({ + id: row.id, + workspaceId: row.workspace_id, + userId: row.user_id, + actorName: row.actor_name, + actorEmail: row.actor_email, + action: row.action, + entityType: row.entity_type, + entityId: row.entity_id, + entityName: row.entity_name, + details: row.details, + createdAt: row.created_at, +}); + const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ id: row.id, userId: row.user_id, @@ -1970,6 +2011,29 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => { return false; }; +const writeAuditLog = async ( + auth: AuthContext, + action: string, + entityType: string, + entityId?: string | null, + entityName?: string | null, + details?: Record, +) => { + try { + await createAuditLogEntry({ + workspaceId: auth.workspace.id, + auth, + action, + entityType, + entityId, + entityName, + details, + }); + } catch (error) { + console.error('Unable to write audit log entry', error); + } +}; + const isBillingOnlyWorkspaceUpdate = ( workspace: WorkspaceRow, payload: z.infer, @@ -2746,7 +2810,11 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWrit expiresAt, }); - res.status(201).json({ + await writeAuditLog(req.auth!, 'integration_token.created', 'integration_token', integrationToken!.id, integrationToken!.name, { + scope: integrationToken!.scope, + expiresAt: integrationToken!.expires_at, + }); + res.status(201).json({ integrationToken: normalizeIntegrationToken(integrationToken!), token: rawToken, }); @@ -2900,7 +2968,11 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole( }); } - res.json({ workspace: normalizeWorkspace(workspace!) }); + await writeAuditLog(req.auth!, 'workspace.updated', 'workspace', String(workspace!.id), workspace!.name, { + workspaceType: workspace!.workspace_type, + billingPlan: workspace!.billing_plan, + }); + res.json({ workspace: normalizeWorkspace(workspace!) }); } catch (error) { next(error); } @@ -3014,7 +3086,11 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks existingUser, }); - res.status(201).json({ member: normalizeWorkspaceMember(member!) }); + await writeAuditLog(req.auth!, 'workspace_member.upserted', 'workspace_member', member!.id, member!.name, { + inviteEmail: member!.invite_email, + role: member!.role, + }); + res.status(201).json({ member: normalizeWorkspaceMember(member!) }); } catch (error) { next(error); } @@ -3029,12 +3105,80 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, return; } + await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId); + await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.get('/api/notes', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const notes = await listFlockNotes(req.auth!.workspace.id); + res.json({ notes: notes.map(normalizeFlockNote) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/notes', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = flockNoteSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid note payload', details: parsed.error.flatten() }); + return; + } + + try { + const note = await createFlockNote({ + workspaceId: req.auth!.workspace.id, + birdId: emptyToNull(parsed.data.birdId ?? ''), + body: parsed.data.body, + createdByUserId: req.auth!.user.id, + }); + + if (!note) { + res.status(404).json({ error: 'Flock member not found for this note.' }); + return; + } + + await writeAuditLog(req.auth!, 'note.created', 'note', note.id, note.bird_name ?? 'Note', { + birdId: note.bird_id, + birdName: note.bird_name, + }); + res.status(201).json({ note: normalizeFlockNote(note) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/notes/:noteId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + try { + const deleted = await deleteFlockNote(req.params.noteId, req.auth!.workspace.id); + + if (!deleted) { + res.status(404).json({ error: 'Note not found.' }); + return; + } + + await writeAuditLog(req.auth!, 'note.deleted', 'note', deleted.id, deleted.title); res.status(204).send(); } catch (error) { next(error); } }); +app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 250); + const entries = await listAuditLogEntries(req.auth!.workspace.id, limit); + res.json({ entries: entries.map(normalizeAuditLogEntry) }); + } catch (error) { + next(error); + } +}); + app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const [birds, memorializedBirds] = await Promise.all([ @@ -3148,8 +3292,12 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o publicProfileEnabled: parsed.data.publicProfileEnabled ?? false, }); - uploadedObjectKeyToCleanup = null; - res.status(201).json({ bird: normalizeBird(bird!) }); + uploadedObjectKeyToCleanup = null; + await writeAuditLog(req.auth!, 'bird.created', 'bird', bird!.id, bird!.name, { + species: bird!.species, + tagId: bird!.tag_id, + }); + res.status(201).json({ bird: normalizeBird(bird!) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); @@ -3200,7 +3348,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require redirectTo: frontendBaseUrl, }); - res.status(202).json({ + await writeAuditLog(req.auth!, 'bird.transfer_invited', 'bird', sourceBird.id, sourceBird.name, { + destinationOwnerEmail, + }); + res.status(202).json({ ok: true, bird: normalizeBird(sourceBird), destinationOwnerEmail, @@ -3226,7 +3377,11 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require return; } - res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); + await writeAuditLog(req.auth!, 'bird.transferred', 'bird', bird.id, bird.name, { + destinationOwnerEmail, + destinationWorkspaceId: targetWorkspace.id, + }); + res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' }); @@ -3295,9 +3450,13 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR return; } - uploadedObjectKeyToCleanup = null; - await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete); - res.json({ bird: normalizeBird(bird) }); + uploadedObjectKeyToCleanup = null; + await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete); + await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, { + previousName: existingBird.name, + species: bird.species, + }); + res.json({ bird: normalizeBird(bird) }); } catch (error) { await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); @@ -3330,7 +3489,8 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa return; } - res.status(204).send(); + await writeAuditLog(req.auth!, 'bird.deleted', 'bird', bird.id, bird.name); + res.status(204).send(); await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key); } catch (error) { next(error); @@ -3370,7 +3530,10 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ return; } - res.json({ bird: normalizeBird(bird) }); + await writeAuditLog(req.auth!, 'bird.memorialized', 'bird', bird.id, bird.name, { + memorializedOn: bird.memorialized_on, + }); + res.json({ bird: normalizeBird(bird) }); } catch (error) { next(error); } @@ -3396,7 +3559,10 @@ app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAcce return; } - res.json({ bird: normalizeBird(bird) }); + await writeAuditLog(req.auth!, 'bird.memorial_reminder_updated', 'bird', bird.id, bird.name, { + notifyOnMemorialDay: bird.notify_on_memorial_day, + }); + res.json({ bird: normalizeBird(bird) }); } catch (error) { next(error); } @@ -3432,8 +3598,13 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW return; } - const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); - res.status(201).json({ weight: normalizeWeight(weight!) }); + const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); + await writeAuditLog(req.auth!, 'weight.created', 'weight', weight!.id, bird.name, { + birdId: bird.id, + weightGrams: parsed.data.weightGrams, + recordedOn: parsed.data.recordedOn, + }); + res.status(201).json({ weight: normalizeWeight(weight!) }); } catch (error) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' }); @@ -3481,7 +3652,12 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi emptyToNull(parsed.data.notes), ); - res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) }); + await writeAuditLog(req.auth!, 'vet_visit.created', 'vet_visit', vetVisit!.id, bird.name, { + birdId: bird.id, + visitedOn: parsed.data.visitedOn, + reason: parsed.data.reason, + }); + res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) }); } catch (error) { next(error); } @@ -3521,7 +3697,12 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces return; } - res.json({ vetVisit: normalizeVetVisit(vetVisit) }); + await writeAuditLog(req.auth!, 'vet_visit.updated', 'vet_visit', vetVisit.id, bird.name, { + birdId: bird.id, + visitedOn: parsed.data.visitedOn, + reason: parsed.data.reason, + }); + res.json({ vetVisit: normalizeVetVisit(vetVisit) }); } catch (error) { next(error); } @@ -3547,7 +3728,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc return; } - res.status(204).send(); + await writeAuditLog(req.auth!, 'vet_visit.deleted', 'vet_visit', req.params.visitId, bird.name, { + birdId: bird.id, + }); + res.status(204).send(); } catch (error) { next(error); } @@ -3594,7 +3778,11 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ emptyToNull(parsed.data.notes), ); - res.status(201).json({ medication: normalizeMedication(medication!) }); + await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, { + birdId: bird.id, + birdName: bird.name, + }); + res.status(201).json({ medication: normalizeMedication(medication!) }); } catch (error) { next(error); } @@ -3638,7 +3826,11 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit return; } - res.json({ medication: normalizeMedication(medication) }); + await writeAuditLog(req.auth!, 'medication.updated', 'medication', medication.id, medication.name, { + birdId: bird.id, + birdName: bird.name, + }); + res.json({ medication: normalizeMedication(medication) }); } catch (error) { next(error); } @@ -3664,7 +3856,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW return; } - res.status(204).send(); + await writeAuditLog(req.auth!, 'medication.deleted', 'medication', req.params.medicationId, bird.name, { + birdId: bird.id, + }); + res.status(204).send(); } catch (error) { next(error); } @@ -3715,7 +3910,13 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require return; } - res.status(201).json({ administration: normalizeMedicationAdministration(administration) }); + await writeAuditLog(req.auth!, 'medication_administration.recorded', 'medication_administration', administration.id, bird.name, { + birdId: bird.id, + medicationId: req.params.medicationId, + administeredOn: parsed.data.administeredOn, + status: parsed.data.status, + }); + res.status(201).json({ administration: normalizeMedicationAdministration(administration) }); } catch (error) { next(error); } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index beb31c0..408c6f7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -347,8 +347,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ON birds (public_profile_code) WHERE public_profile_code IS NOT NULL; - CREATE TABLE IF NOT EXISTS pending_bird_transfers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + CREATE TABLE IF NOT EXISTS pending_bird_transfers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, destination_owner_email VARCHAR(255) NOT NULL, @@ -368,11 +368,49 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) WHERE completed_at IS NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird - ON pending_bird_transfers (bird_id) - WHERE completed_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird + ON pending_bird_transfers (bird_id) + WHERE completed_at IS NULL; - CREATE TABLE IF NOT EXISTS weight_records ( + 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, + bird_id UUID REFERENCES birds(id) ON DELETE SET NULL, + title VARCHAR(160) NOT NULL, + body TEXT NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated + ON flock_notes (workspace_id, updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated + ON flock_notes (bird_id, updated_at DESC) + WHERE bird_id IS NOT NULL; + + CREATE TABLE IF NOT EXISTS audit_log_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_name VARCHAR(160), + actor_email VARCHAR(255), + action VARCHAR(80) NOT NULL, + entity_type VARCHAR(80) NOT NULL, + entity_id VARCHAR(120), + entity_name VARCHAR(255), + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created + ON audit_log_entries (workspace_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity + ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS weight_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0), diff --git a/backend/src/repositories/auditRepository.ts b/backend/src/repositories/auditRepository.ts new file mode 100644 index 0000000..df49149 --- /dev/null +++ b/backend/src/repositories/auditRepository.ts @@ -0,0 +1,133 @@ +import { db } from '../db/client.js'; +import type { AuditLogEntryRow, AuthContext, FlockNoteRow } from '../types.js'; + +type AuditLogInput = { + workspaceId: number; + auth?: AuthContext; + action: string; + entityType: string; + entityId?: string | null; + entityName?: string | null; + details?: Record; +}; + +export const createAuditLogEntry = async ({ + workspaceId, + auth, + action, + entityType, + entityId = null, + entityName = null, + details = {}, +}: AuditLogInput) => { + const result = await db.query( + `INSERT INTO audit_log_entries (workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at`, + [ + workspaceId, + auth?.user.id ?? null, + auth?.user.name ?? null, + auth?.user.email ?? null, + action, + entityType, + entityId, + entityName, + JSON.stringify(details), + ], + ); + + return result.rows[0] ?? null; +}; + +export const listAuditLogEntries = async (workspaceId: number, limit = 100) => { + const result = await db.query( + `SELECT id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at + FROM audit_log_entries + WHERE workspace_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [workspaceId, limit], + ); + + return result.rows; +}; + +export const listFlockNotes = async (workspaceId: number) => { + const result = await db.query( + `SELECT flock_notes.id, + flock_notes.workspace_id, + flock_notes.bird_id, + birds.name AS bird_name, + flock_notes.title, + flock_notes.body, + flock_notes.created_by_user_id, + users.name AS created_by_name, + flock_notes.created_at, + flock_notes.updated_at + FROM flock_notes + LEFT JOIN birds ON birds.id = flock_notes.bird_id + LEFT JOIN users ON users.id = flock_notes.created_by_user_id + WHERE flock_notes.workspace_id = $1 + ORDER BY flock_notes.updated_at DESC`, + [workspaceId], + ); + + return result.rows; +}; + +export const createFlockNote = async ({ + workspaceId, + birdId, + body, + createdByUserId, +}: { + workspaceId: number; + birdId: string | null; + body: string; + createdByUserId: string | null; +}) => { + const title = body.split(/\s+/).join(' ').slice(0, 160) || 'Note'; + const result = await db.query( + `WITH inserted_note AS ( + INSERT INTO flock_notes (workspace_id, bird_id, title, body, created_by_user_id) + SELECT $1, $2, $3, $4, $5 + WHERE $2::uuid IS NULL + OR EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = $2 + AND birds.workspace_id = $1 + ) + RETURNING id, workspace_id, bird_id, title, body, created_by_user_id, created_at, updated_at + ) + SELECT inserted_note.id, + inserted_note.workspace_id, + inserted_note.bird_id, + birds.name AS bird_name, + inserted_note.title, + inserted_note.body, + inserted_note.created_by_user_id, + users.name AS created_by_name, + inserted_note.created_at, + inserted_note.updated_at + FROM inserted_note + LEFT JOIN birds ON birds.id = inserted_note.bird_id + LEFT JOIN users ON users.id = inserted_note.created_by_user_id`, + [workspaceId, birdId, title, body, createdByUserId], + ); + + return result.rows[0] ?? null; +}; + +export const deleteFlockNote = async (noteId: string, workspaceId: number) => { + const result = await db.query<{ id: string; title: string }>( + `DELETE FROM flock_notes + WHERE id = $1 + AND workspace_id = $2 + RETURNING id, title`, + [noteId, workspaceId], + ); + + return result.rows[0] ?? null; +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 97c2e22..5c2de8c 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -235,6 +235,33 @@ export type MedicationAdministrationRow = { created_at: string; }; +export type FlockNoteRow = { + id: string; + workspace_id: number; + bird_id: string | null; + bird_name: string | null; + title: string; + body: string; + created_by_user_id: string | null; + created_by_name: string | null; + created_at: string; + updated_at: string; +}; + +export type AuditLogEntryRow = { + id: string; + workspace_id: number; + user_id: string | null; + actor_name: string | null; + actor_email: string | null; + action: string; + entity_type: string; + entity_id: string | null; + entity_name: string | null; + details: Record; + created_at: string; +}; + export type AuthContext = { user: UserRow; session: AuthSessionRow; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec1a70e..c81712e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -192,12 +192,43 @@ type IntegrationTokenSummary = { createdAt: string; }; +type FlockNote = { + id: string; + workspaceId: number; + birdId: string | null; + birdName: string | null; + body: string; + createdByUserId: string | null; + createdByName: string | null; + createdAt: string; + updatedAt: string; +}; + +type AuditLogEntry = { + id: string; + workspaceId: number; + userId: string | null; + actorName: string | null; + actorEmail: string | null; + action: string; + entityType: string; + entityId: string | null; + entityName: string | null; + details: Record; + createdAt: string; +}; + type IntegrationTokenFormState = { name: string; scope: IntegrationTokenScope; expiresInDays: string; }; +type FlockNoteFormState = { + birdId: string; + body: string; +}; + type BirdFormState = { name: string; tagId: string; @@ -360,6 +391,7 @@ type WeightDropAlert = { }; type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit'; +type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit'; type DismissedAlertMap = Record; type PhotoCropState = { @@ -690,6 +722,11 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = { expiresInDays: '', }; +const emptyFlockNoteForm: FlockNoteFormState = { + birdId: '', + body: '', +}; + const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({ prompt: '', options: ['', '', '', ''], @@ -829,6 +866,12 @@ const formatDateTime = (value: string | null) => { }).format(new Date(value)); }; +const formatAuditAction = (value: string) => + value + .split('.') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).replace(/_/g, ' ')) + .join(' '); + const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending'); const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`; const parseDateValue = (value: string) => new Date(`${value}T00:00:00`); @@ -1469,6 +1512,8 @@ function App() { const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); const [integrationTokens, setIntegrationTokens] = useState([]); + const [flockNotes, setFlockNotes] = useState([]); + const [auditLogEntries, setAuditLogEntries] = useState([]); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); const [adminDailyEducation, setAdminDailyEducation] = useState([]); @@ -1488,6 +1533,7 @@ function App() { const [birds, setBirds] = useState([]); const [memorializedBirds, setMemorializedBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); + const [selectedBirdTab, setSelectedBirdTab] = useState('info'); const [editingBirdId, setEditingBirdId] = useState(''); const [birdEditorOpen, setBirdEditorOpen] = useState(false); const [weights, setWeights] = useState([]); @@ -1503,6 +1549,7 @@ function App() { const [workspaceMemberForm, setWorkspaceMemberForm] = useState(emptyWorkspaceMemberForm); const [workspaceCreateForm, setWorkspaceCreateForm] = useState(emptyWorkspaceCreateForm); const [integrationTokenForm, setIntegrationTokenForm] = useState(emptyIntegrationTokenForm); + const [flockNoteForm, setFlockNoteForm] = useState(emptyFlockNoteForm); const [birdForm, setBirdForm] = useState(emptyBirdForm); const [birdImportPreview, setBirdImportPreview] = useState(null); const [birdImportFileName, setBirdImportFileName] = useState(''); @@ -1521,6 +1568,9 @@ function App() { const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [deletingWorkspace, setDeletingWorkspace] = useState(false); const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false); + const [savingFlockNote, setSavingFlockNote] = useState(false); + const [deletingFlockNoteId, setDeletingFlockNoteId] = useState(''); + const [auditLogLoading, setAuditLogLoading] = useState(false); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); @@ -1578,15 +1628,35 @@ function App() { () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); - const editingBird = useMemo( - () => birds.find((bird) => bird.id === editingBirdId) ?? null, - [birds, editingBirdId], - ); + const editingBird = useMemo( + () => birds.find((bird) => bird.id === editingBirdId) ?? null, + [birds, editingBirdId], + ); + const selectedBirdNotes = useMemo( + () => (selectedBird ? flockNotes.filter((note) => note.birdId === selectedBird.id) : []), + [flockNotes, selectedBird], + ); + const selectedBirdAuditLogEntries = useMemo( + () => + selectedBird + ? auditLogEntries.filter( + (entry) => + entry.entityId === selectedBird.id || + entry.details.birdId === selectedBird.id || + (entry.entityType === 'bird' && entry.entityName === selectedBird.name), + ) + : [], + [auditLogEntries, selectedBird], + ); useEffect(() => { setDismissedAlerts(readDismissedAlerts()); }, [workspace?.id]); + useEffect(() => { + setSelectedBirdTab('info'); + }, [selectedBirdId]); + const overviewWindowStartDate = useMemo(() => { const startDate = new Date(); startDate.setHours(0, 0, 0, 0); @@ -2023,8 +2093,10 @@ function App() { setAuthSession(null); setWorkspace(null); setActiveMembership(null); - setWorkspaceMembers([]); - setIntegrationTokens([]); + setWorkspaceMembers([]); + setIntegrationTokens([]); + setFlockNotes([]); + setAuditLogEntries([]); setAdminSummary(null); setAdminRescueWorkspaces([]); setAdminDailyEducation([]); @@ -2048,7 +2120,8 @@ function App() { setEditingBirdId(''); setWorkspaceForm(emptyWorkspaceForm); setWorkspaceCreateForm(emptyWorkspaceCreateForm); - setIntegrationTokenForm(emptyIntegrationTokenForm); + setIntegrationTokenForm(emptyIntegrationTokenForm); + setFlockNoteForm(emptyFlockNoteForm); setNewIntegrationTokenSecret(''); setAuthNotice(null); setBillingNotice(null); @@ -2216,11 +2289,12 @@ function App() { const loadWorkspaceData = async () => { try { setLoading(true); - const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([ - apiFetch('/birds', authToken), - apiFetch('/workspace/members', authToken), - apiFetch('/integration-tokens', authToken), - ]); + const [birdsResponse, membersResponse, integrationTokensResponse, notesResponse] = await Promise.all([ + apiFetch('/birds', authToken), + apiFetch('/workspace/members', authToken), + apiFetch('/integration-tokens', authToken), + apiFetch('/notes', authToken), + ]); if (!birdsResponse.ok) { if (birdsResponse.status === 401) { @@ -2250,13 +2324,20 @@ function App() { setWorkspaceMembers([]); } - if (integrationTokensResponse.ok) { - const integrationTokensData = - (await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {}; - setIntegrationTokens(integrationTokensData.integrationTokens ?? []); - } else { - setIntegrationTokens([]); - } + if (integrationTokensResponse.ok) { + const integrationTokensData = + (await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {}; + setIntegrationTokens(integrationTokensData.integrationTokens ?? []); + } else { + setIntegrationTokens([]); + } + + if (notesResponse.ok) { + const notesData = (await readJsonSafely<{ notes?: FlockNote[] }>(notesResponse)) ?? {}; + setFlockNotes(notesData.notes ?? []); + } else { + setFlockNotes([]); + } } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.'); } finally { @@ -2265,9 +2346,35 @@ function App() { }; void loadWorkspaceData(); - }, [authToken, workspace?.id]); + }, [authToken, workspace?.id]); - useEffect(() => { + useEffect(() => { + if (!authToken || selectedBirdTab !== 'audit' || !selectedBird || !['owner', 'assistant'].includes(activeMembership?.role ?? '')) { + return; + } + + const loadAuditLog = async () => { + try { + setAuditLogLoading(true); + const response = await apiFetch('/audit-log', authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load audit log.')); + } + + const data = (await readJsonSafely<{ entries?: AuditLogEntry[] }>(response)) ?? {}; + setAuditLogEntries(data.entries ?? []); + } catch (auditError) { + setError(auditError instanceof Error ? auditError.message : 'Unable to load audit log.'); + } finally { + setAuditLogLoading(false); + } + }; + + void loadAuditLog(); + }, [activeMembership?.role, authToken, selectedBird, selectedBirdTab]); + + useEffect(() => { if (!authToken || !authSession?.isAdmin || activePage !== 'admin') { return; } @@ -2678,7 +2785,7 @@ function App() { } }; - const handleRevokeIntegrationToken = async (tokenId: string) => { + const handleRevokeIntegrationToken = async (tokenId: string) => { if (!authToken) { return; } @@ -2701,9 +2808,71 @@ function App() { } finally { setRevokingIntegrationTokenId(''); } - }; + }; - const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => { + const handleFlockNoteSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!authToken) { + return; + } + + setError(''); + setSavingFlockNote(true); + + try { + const response = await apiFetch('/notes', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + birdId: selectedBirdTab === 'notes' && selectedBird ? selectedBird.id : flockNoteForm.birdId || null, + body: flockNoteForm.body.trim(), + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to save note.')); + } + + const data = (await readJsonSafely<{ note?: FlockNote }>(response)) ?? {}; + + if (!data.note) { + throw new Error('Unable to save note.'); + } + + setFlockNotes((current) => [data.note!, ...current]); + setFlockNoteForm(emptyFlockNoteForm); + } catch (noteError) { + setError(noteError instanceof Error ? noteError.message : 'Unable to save note.'); + } finally { + setSavingFlockNote(false); + } + }; + + const handleDeleteFlockNote = async (noteId: string) => { + if (!authToken) { + return; + } + + setError(''); + setDeletingFlockNoteId(noteId); + + try { + const response = await apiFetch(`/notes/${noteId}`, authToken, { method: 'DELETE' }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to delete note.')); + } + + setFlockNotes((current) => current.filter((note) => note.id !== noteId)); + } catch (noteError) { + setError(noteError instanceof Error ? noteError.message : 'Unable to delete note.'); + } finally { + setDeletingFlockNoteId(''); + } + }; + + const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => { if (!authToken) { return; } @@ -4635,12 +4804,12 @@ function App() { - - + + {authSession.isAdmin ? ( + + + + + +
+
+

Flock member

+
+
{selectedBirdWeightRangeAlert || selectedBirdWeightDropAlerts.length || selectedBirdHasVetVisitAlert ? (
{selectedBirdWeightRangeAlert ? ( @@ -5859,25 +6094,29 @@ function App() { ) : null}
) : null} -
- -
-
-
+ + - <> -
- {`${selectedBird.name}`} - {selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? ( - - ) : null} -
+ <> +
+ {`${selectedBird.name}`} +
+ {selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? ( + + ) : null} + +
+

{selectedBird.name}

{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'} -

-

Added {formatDate(selectedBird.createdAt.slice(0, 10))}

-

-
+

+

Added {formatDate(selectedBird.createdAt.slice(0, 10))}

+
+
-
-
- Hatch Day - {formatDate(selectedBird.dateOfBirth)} -
-
- Gotcha day - {formatDate(selectedBird.gotchaDay)} -
-
- Favorite snack - {selectedBird.favoriteSnack || 'Not recorded'} -
-
- Motivators - {parseBirdProfileList(selectedBird.motivators).length ? ( -
    - {parseBirdProfileList(selectedBird.motivators).map((motivator, index) => ( -
  • {motivator}
  • - ))} -
- ) : ( - Not recorded - )} -
-
- Demotivators - {parseBirdProfileList(selectedBird.demotivators).length ? ( -
    - {parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => ( -
  • {demotivator}
  • - ))} -
- ) : ( - Not recorded - )} -
-
+
+ {selectedBirdTab === 'info' ? ( +
+
+
+ Hatch Day + {formatDate(selectedBird.dateOfBirth)} +
+
+ Gotcha day + {formatDate(selectedBird.gotchaDay)} +
+
+ Favorite snack + {selectedBird.favoriteSnack || 'Not recorded'} +
+
+ Motivators + {parseBirdProfileList(selectedBird.motivators).length ? ( +
    + {parseBirdProfileList(selectedBird.motivators).map((motivator, index) => ( +
  • {motivator}
  • + ))} +
+ ) : ( + Not recorded + )} +
+
+ Demotivators + {parseBirdProfileList(selectedBird.demotivators).length ? ( +
    + {parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => ( +
  • {demotivator}
  • + ))} +
+ ) : ( + Not recorded + )} +
+
-
-
-
-
+ {medications.length ? ( +
+
+
+

Medication

+

Medication schedule

+
+
+
{renderMedicationList({ showAdministrationControls: true })}
+
+ ) : null} +
+ ) : null} + + {selectedBirdTab === 'weight' ? ( +
+
+
+

Weight

Trend and log

@@ -6122,24 +6379,16 @@ function App() { - -
+ +
+
+ ) : null} - {medications.length ? ( -
-
-
-

Medication

-

Medication schedule

-
-
-
{renderMedicationList({ showAdministrationControls: true })}
-
- ) : null} - -
-
-
+ {selectedBirdTab === 'vet' ? ( +
+
+
+

Vet visits

Care history and notes

@@ -6224,19 +6473,117 @@ function App() { Add the first visit above to start this care history. )} -
-
-
- +
+
+
+ ) : null} + + {selectedBirdTab === 'notes' ? ( +
+
+
+
+

Notes

+

{selectedBird.name} notes

+
+

{selectedBirdNotes.length} total

+
+
+