Add flock member notes and audit tabs
Deploy / deploy-dev (push) Successful in 2m27s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-05-30 22:30:35 -04:00
parent d2763744eb
commit 6ade13a8be
6 changed files with 1048 additions and 164 deletions
+230 -29
View File
@@ -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<string, unknown>,
) => {
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<typeof workspaceSchema>,
@@ -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);
}