Add flock member notes and audit tabs
This commit is contained in:
+230
-29
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export const createAuditLogEntry = async ({
|
||||
workspaceId,
|
||||
auth,
|
||||
action,
|
||||
entityType,
|
||||
entityId = null,
|
||||
entityName = null,
|
||||
details = {},
|
||||
}: AuditLogInput) => {
|
||||
const result = await db.query<AuditLogEntryRow>(
|
||||
`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<AuditLogEntryRow>(
|
||||
`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<FlockNoteRow>(
|
||||
`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<FlockNoteRow>(
|
||||
`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;
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuthContext = {
|
||||
user: UserRow;
|
||||
session: AuthSessionRow;
|
||||
|
||||
Reference in New Issue
Block a user