Add flock member notes and audit tabs
This commit is contained in:
+223
-22
@@ -61,6 +61,13 @@ import {
|
|||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
|
import {
|
||||||
|
createAuditLogEntry,
|
||||||
|
createFlockNote,
|
||||||
|
deleteFlockNote,
|
||||||
|
listAuditLogEntries,
|
||||||
|
listFlockNotes,
|
||||||
|
} from './repositories/auditRepository.js';
|
||||||
import {
|
import {
|
||||||
buildBirdPhotoObjectKey,
|
buildBirdPhotoObjectKey,
|
||||||
getImageExtensionFromContentType,
|
getImageExtensionFromContentType,
|
||||||
@@ -97,11 +104,13 @@ import {
|
|||||||
} from './repositories/workspaceRepository.js';
|
} from './repositories/workspaceRepository.js';
|
||||||
import type {
|
import type {
|
||||||
AuthContext,
|
AuthContext,
|
||||||
|
AuditLogEntryRow,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
FlockNoteRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
@@ -318,6 +327,11 @@ const medicationAdministrationSchema = z.object({
|
|||||||
notes: z.string().trim().max(500).optional().or(z.literal('')),
|
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({
|
const integrationTokenCreateSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
scope: integrationTokenScopeSchema.default('read_write'),
|
scope: integrationTokenScopeSchema.default('read_write'),
|
||||||
@@ -676,6 +690,33 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) =>
|
|||||||
createdAt: row.created_at,
|
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) => ({
|
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
@@ -1936,6 +1977,29 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
|||||||
return false;
|
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 = (
|
const isBillingOnlyWorkspaceUpdate = (
|
||||||
workspace: WorkspaceRow,
|
workspace: WorkspaceRow,
|
||||||
payload: z.infer<typeof workspaceSchema>,
|
payload: z.infer<typeof workspaceSchema>,
|
||||||
@@ -2569,7 +2633,11 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWrit
|
|||||||
expiresAt,
|
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!),
|
integrationToken: normalizeIntegrationToken(integrationToken!),
|
||||||
token: rawToken,
|
token: rawToken,
|
||||||
});
|
});
|
||||||
@@ -2723,7 +2791,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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -2837,7 +2909,11 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
|||||||
existingUser,
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -2852,12 +2928,80 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
|
|||||||
return;
|
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();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const [birds, memorializedBirds] = await Promise.all([
|
const [birds, memorializedBirds] = await Promise.all([
|
||||||
@@ -2988,8 +3132,12 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadedObjectKeyToCleanup = null;
|
uploadedObjectKeyToCleanup = null;
|
||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
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) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
@@ -3040,7 +3188,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
redirectTo: frontendBaseUrl,
|
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,
|
ok: true,
|
||||||
bird: normalizeBird(sourceBird),
|
bird: normalizeBird(sourceBird),
|
||||||
destinationOwnerEmail,
|
destinationOwnerEmail,
|
||||||
@@ -3066,7 +3217,11 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
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.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
||||||
@@ -3135,9 +3290,13 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadedObjectKeyToCleanup = null;
|
uploadedObjectKeyToCleanup = null;
|
||||||
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||||
res.json({ bird: normalizeBird(bird) });
|
await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, {
|
||||||
|
previousName: existingBird.name,
|
||||||
|
species: bird.species,
|
||||||
|
});
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
@@ -3170,7 +3329,8 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
|||||||
return;
|
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);
|
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -3210,7 +3370,10 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3236,7 +3399,10 @@ app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAcce
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3272,8 +3438,13 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
||||||
res.status(201).json({ weight: normalizeWeight(weight!) });
|
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) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
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.' });
|
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
||||||
@@ -3321,7 +3492,12 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
|
|||||||
emptyToNull(parsed.data.notes),
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3361,7 +3537,12 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3387,7 +3568,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3434,7 +3618,11 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
emptyToNull(parsed.data.notes),
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3478,7 +3666,11 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3504,7 +3696,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3555,7 +3750,13 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,8 +313,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON birds (public_profile_code)
|
ON birds (public_profile_code)
|
||||||
WHERE public_profile_code IS NOT NULL;
|
WHERE public_profile_code IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
destination_owner_email VARCHAR(255) NOT NULL,
|
destination_owner_email VARCHAR(255) NOT NULL,
|
||||||
@@ -334,11 +334,49 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
||||||
WHERE completed_at IS NULL;
|
WHERE completed_at IS NULL;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||||
ON pending_bird_transfers (bird_id)
|
ON pending_bird_transfers (bird_id)
|
||||||
WHERE completed_at IS NULL;
|
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(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
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;
|
||||||
|
};
|
||||||
@@ -206,6 +206,33 @@ export type MedicationAdministrationRow = {
|
|||||||
created_at: string;
|
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 = {
|
export type AuthContext = {
|
||||||
user: UserRow;
|
user: UserRow;
|
||||||
session: AuthSessionRow;
|
session: AuthSessionRow;
|
||||||
|
|||||||
+472
-125
@@ -175,12 +175,43 @@ type IntegrationTokenSummary = {
|
|||||||
createdAt: string;
|
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<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type IntegrationTokenFormState = {
|
type IntegrationTokenFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
scope: IntegrationTokenScope;
|
scope: IntegrationTokenScope;
|
||||||
expiresInDays: string;
|
expiresInDays: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FlockNoteFormState = {
|
||||||
|
birdId: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BirdFormState = {
|
type BirdFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string;
|
tagId: string;
|
||||||
@@ -331,6 +362,7 @@ type WeightDropAlert = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
|
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
|
||||||
|
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
|
||||||
type DismissedAlertMap = Record<string, boolean>;
|
type DismissedAlertMap = Record<string, boolean>;
|
||||||
|
|
||||||
type PhotoCropState = {
|
type PhotoCropState = {
|
||||||
@@ -661,6 +693,11 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
|||||||
expiresInDays: '',
|
expiresInDays: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyFlockNoteForm: FlockNoteFormState = {
|
||||||
|
birdId: '',
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
|
||||||
const defaultAuthProviders: AuthProvider[] = [
|
const defaultAuthProviders: AuthProvider[] = [
|
||||||
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
||||||
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
||||||
@@ -788,6 +825,12 @@ const formatDateTime = (value: string | null) => {
|
|||||||
}).format(new Date(value));
|
}).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 formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
||||||
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
||||||
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
||||||
@@ -1428,11 +1471,14 @@ function App() {
|
|||||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||||
|
const [flockNotes, setFlockNotes] = useState<FlockNote[]>([]);
|
||||||
|
const [auditLogEntries, setAuditLogEntries] = useState<AuditLogEntry[]>([]);
|
||||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||||
const [birds, setBirds] = useState<Bird[]>([]);
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||||
|
const [selectedBirdTab, setSelectedBirdTab] = useState<BirdDetailTab>('info');
|
||||||
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
const [editingBirdId, setEditingBirdId] = useState<string>('');
|
||||||
const [birdEditorOpen, setBirdEditorOpen] = useState(false);
|
const [birdEditorOpen, setBirdEditorOpen] = useState(false);
|
||||||
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
const [weights, setWeights] = useState<WeightRecord[]>([]);
|
||||||
@@ -1448,6 +1494,7 @@ function App() {
|
|||||||
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
|
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
|
||||||
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
|
||||||
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
const [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
||||||
|
const [flockNoteForm, setFlockNoteForm] = useState<FlockNoteFormState>(emptyFlockNoteForm);
|
||||||
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
|
||||||
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
|
const [birdImportPreview, setBirdImportPreview] = useState<BirdImportPreview | null>(null);
|
||||||
const [birdImportFileName, setBirdImportFileName] = useState('');
|
const [birdImportFileName, setBirdImportFileName] = useState('');
|
||||||
@@ -1466,6 +1513,9 @@ function App() {
|
|||||||
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
||||||
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
|
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
|
||||||
const [creatingIntegrationToken, setCreatingIntegrationToken] = 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 [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||||
@@ -1523,15 +1573,35 @@ function App() {
|
|||||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||||
[birds, selectedBirdId],
|
[birds, selectedBirdId],
|
||||||
);
|
);
|
||||||
const editingBird = useMemo(
|
const editingBird = useMemo(
|
||||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||||
[birds, editingBirdId],
|
[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(() => {
|
useEffect(() => {
|
||||||
setDismissedAlerts(readDismissedAlerts());
|
setDismissedAlerts(readDismissedAlerts());
|
||||||
}, [workspace?.id]);
|
}, [workspace?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedBirdTab('info');
|
||||||
|
}, [selectedBirdId]);
|
||||||
|
|
||||||
const overviewWindowStartDate = useMemo(() => {
|
const overviewWindowStartDate = useMemo(() => {
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setHours(0, 0, 0, 0);
|
startDate.setHours(0, 0, 0, 0);
|
||||||
@@ -1968,8 +2038,10 @@ function App() {
|
|||||||
setAuthSession(null);
|
setAuthSession(null);
|
||||||
setWorkspace(null);
|
setWorkspace(null);
|
||||||
setActiveMembership(null);
|
setActiveMembership(null);
|
||||||
setWorkspaceMembers([]);
|
setWorkspaceMembers([]);
|
||||||
setIntegrationTokens([]);
|
setIntegrationTokens([]);
|
||||||
|
setFlockNotes([]);
|
||||||
|
setAuditLogEntries([]);
|
||||||
setAdminSummary(null);
|
setAdminSummary(null);
|
||||||
setAdminRescueWorkspaces([]);
|
setAdminRescueWorkspaces([]);
|
||||||
setBirds([]);
|
setBirds([]);
|
||||||
@@ -1984,7 +2056,8 @@ function App() {
|
|||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setWorkspaceForm(emptyWorkspaceForm);
|
setWorkspaceForm(emptyWorkspaceForm);
|
||||||
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
|
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
|
||||||
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
||||||
|
setFlockNoteForm(emptyFlockNoteForm);
|
||||||
setNewIntegrationTokenSecret('');
|
setNewIntegrationTokenSecret('');
|
||||||
setAuthNotice(null);
|
setAuthNotice(null);
|
||||||
setBillingNotice(null);
|
setBillingNotice(null);
|
||||||
@@ -2152,11 +2225,12 @@ function App() {
|
|||||||
const loadWorkspaceData = async () => {
|
const loadWorkspaceData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([
|
const [birdsResponse, membersResponse, integrationTokensResponse, notesResponse] = await Promise.all([
|
||||||
apiFetch('/birds', authToken),
|
apiFetch('/birds', authToken),
|
||||||
apiFetch('/workspace/members', authToken),
|
apiFetch('/workspace/members', authToken),
|
||||||
apiFetch('/integration-tokens', authToken),
|
apiFetch('/integration-tokens', authToken),
|
||||||
]);
|
apiFetch('/notes', authToken),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!birdsResponse.ok) {
|
if (!birdsResponse.ok) {
|
||||||
if (birdsResponse.status === 401) {
|
if (birdsResponse.status === 401) {
|
||||||
@@ -2186,13 +2260,20 @@ function App() {
|
|||||||
setWorkspaceMembers([]);
|
setWorkspaceMembers([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integrationTokensResponse.ok) {
|
if (integrationTokensResponse.ok) {
|
||||||
const integrationTokensData =
|
const integrationTokensData =
|
||||||
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
|
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
|
||||||
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
|
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
|
||||||
} else {
|
} else {
|
||||||
setIntegrationTokens([]);
|
setIntegrationTokens([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notesResponse.ok) {
|
||||||
|
const notesData = (await readJsonSafely<{ notes?: FlockNote[] }>(notesResponse)) ?? {};
|
||||||
|
setFlockNotes(notesData.notes ?? []);
|
||||||
|
} else {
|
||||||
|
setFlockNotes([]);
|
||||||
|
}
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2201,9 +2282,35 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void loadWorkspaceData();
|
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') {
|
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2579,7 +2686,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokeIntegrationToken = async (tokenId: string) => {
|
const handleRevokeIntegrationToken = async (tokenId: string) => {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2602,9 +2709,71 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRevokingIntegrationTokenId('');
|
setRevokingIntegrationTokenId('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
const handleFlockNoteSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
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) {
|
if (!authToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4321,12 +4490,12 @@ function App() {
|
|||||||
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
|
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
|
||||||
Flock
|
Flock
|
||||||
</button>
|
</button>
|
||||||
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
{authSession.isAdmin ? (
|
{authSession.isAdmin ? (
|
||||||
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
|
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
|
||||||
Admin
|
Admin
|
||||||
@@ -5258,14 +5427,80 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedBird && !birdEditorOpen ? (
|
{selectedBird && !birdEditorOpen ? (
|
||||||
<section className="panel flock-member-panel">
|
<section className="panel flock-member-panel bird-detail-panel">
|
||||||
<div className="panel-header">
|
<div className="bird-detail-tabs" role="tablist" aria-label={`${selectedBird.name} detail sections`}>
|
||||||
<div>
|
<button
|
||||||
<p className="eyebrow">Flock member</p>
|
className={`bird-detail-tab ${selectedBirdTab === 'info' ? 'active' : ''}`}
|
||||||
<h2>{selectedBird.name}</h2>
|
onClick={() => setSelectedBirdTab('info')}
|
||||||
</div>
|
type="button"
|
||||||
<div className="member-header-actions">
|
role="tab"
|
||||||
|
aria-selected={selectedBirdTab === 'info'}
|
||||||
|
aria-label="Info"
|
||||||
|
title="Info"
|
||||||
|
>
|
||||||
|
<svg className="info-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M11 17h2v-6h-2v6Zm1-14C6.48 3 2 7.48 2 13s4.48 10 10 10 10-4.48 10-10S17.52 3 12 3Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Zm-1-12h2V7h-2v2Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`bird-detail-tab ${selectedBirdTab === 'weight' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedBirdTab('weight')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedBirdTab === 'weight'}
|
||||||
|
aria-label="Weight"
|
||||||
|
title="Weight"
|
||||||
|
>
|
||||||
|
<svg className="weight-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M240-200h480l-57-400H297l-57 400Zm240-480q17 0 28.5-11.5T520-720q0-17-11.5-28.5T480-760q-17 0-28.5 11.5T440-720q0 17 11.5 28.5T480-680Zm113 0h70q30 0 52 20t27 49l57 400q5 36-18.5 63.5T720-120H240q-37 0-60.5-27.5T161-211l57-400q5-29 27-49t52-20h70q-3-10-5-19.5t-2-20.5q0-50 35-85t85-35q50 0 85 35t35 85q0 11-2 20.5t-5 19.5ZM240-200h480-480Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`bird-detail-tab ${selectedBirdTab === 'vet' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedBirdTab('vet')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedBirdTab === 'vet'}
|
||||||
|
aria-label="Vet"
|
||||||
|
title="Vet"
|
||||||
|
>
|
||||||
|
<svg className="vet-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20 6h-4V4c0-1.11-.89-2-2-2h-4c-1.11 0-2 .89-2 2v2H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2ZM10 4h4v2h-4V4Zm6 11h-3v3h-2v-3H8v-2h3v-3h2v3h3v2Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`bird-detail-tab ${selectedBirdTab === 'notes' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedBirdTab('notes')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedBirdTab === 'notes'}
|
||||||
|
aria-label="Notes"
|
||||||
|
title="Notes"
|
||||||
|
>
|
||||||
|
<svg className="note-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedBirdTab('audit')}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedBirdTab === 'audit'}
|
||||||
|
aria-label="Audit log"
|
||||||
|
title="Audit log"
|
||||||
|
>
|
||||||
|
<svg className="audit-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M480-120q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-480h80q0 117 81.5 198.5T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-96 88h105v80H120v-240h80v94q47-64 120.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Flock member</p>
|
||||||
|
</div>
|
||||||
|
<div className="member-header-actions">
|
||||||
{selectedBirdWeightRangeAlert || selectedBirdWeightDropAlerts.length || selectedBirdHasVetVisitAlert ? (
|
{selectedBirdWeightRangeAlert || selectedBirdWeightDropAlerts.length || selectedBirdHasVetVisitAlert ? (
|
||||||
<div className="bird-alert-stack" aria-label={`Critical alerts for ${selectedBird.name}`}>
|
<div className="bird-alert-stack" aria-label={`Critical alerts for ${selectedBird.name}`}>
|
||||||
{selectedBirdWeightRangeAlert ? (
|
{selectedBirdWeightRangeAlert ? (
|
||||||
@@ -5315,25 +5550,29 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="button-row">
|
</div>
|
||||||
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
|
</div>
|
||||||
Edit details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<section className="profile-hero">
|
<section className="profile-hero">
|
||||||
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
||||||
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
|
<div className="profile-actions">
|
||||||
<button className="qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`}>
|
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<button className="profile-icon-button qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`} title="QR code">
|
||||||
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
</svg>
|
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
|
||||||
</button>
|
</svg>
|
||||||
) : null}
|
</button>
|
||||||
<div className="profile-copy">
|
) : null}
|
||||||
|
<button className="profile-icon-button" onClick={() => startEditBird(selectedBird)} type="button" aria-label={`Edit details for ${selectedBird.name}`} title="Edit details">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4 20h4l10.5-10.5-4-4L4 16v4Z" />
|
||||||
|
<path d="m13.5 6.5 4 4" />
|
||||||
|
<path d="M15 5l1.5-1.5a2.1 2.1 0 0 1 3 3L18 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="profile-copy">
|
||||||
<h3 className="profile-title">
|
<h3 className="profile-title">
|
||||||
<span>{selectedBird.name}</span>
|
<span>{selectedBird.name}</span>
|
||||||
<span
|
<span
|
||||||
@@ -5345,54 +5584,72 @@ function App() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="detail-grid">
|
<div className="bird-detail-tab-panel">
|
||||||
<article className="detail-card">
|
{selectedBirdTab === 'info' ? (
|
||||||
<span>Hatch Day</span>
|
<div className="flock-member-sections" role="tabpanel">
|
||||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
<div className="detail-grid">
|
||||||
</article>
|
<article className="detail-card">
|
||||||
<article className="detail-card">
|
<span>Hatch Day</span>
|
||||||
<span>Gotcha day</span>
|
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||||
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
</article>
|
||||||
</article>
|
<article className="detail-card">
|
||||||
<article className="detail-card">
|
<span>Gotcha day</span>
|
||||||
<span>Favorite snack</span>
|
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
||||||
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
|
</article>
|
||||||
</article>
|
<article className="detail-card">
|
||||||
<article className="detail-card">
|
<span>Favorite snack</span>
|
||||||
<span>Motivators</span>
|
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
|
||||||
{parseBirdProfileList(selectedBird.motivators).length ? (
|
</article>
|
||||||
<ul className="detail-item-list">
|
<article className="detail-card">
|
||||||
{parseBirdProfileList(selectedBird.motivators).map((motivator, index) => (
|
<span>Motivators</span>
|
||||||
<li key={`${motivator}-${index}`}>{motivator}</li>
|
{parseBirdProfileList(selectedBird.motivators).length ? (
|
||||||
))}
|
<ul className="detail-item-list">
|
||||||
</ul>
|
{parseBirdProfileList(selectedBird.motivators).map((motivator, index) => (
|
||||||
) : (
|
<li key={`${motivator}-${index}`}>{motivator}</li>
|
||||||
<strong>Not recorded</strong>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</article>
|
) : (
|
||||||
<article className="detail-card">
|
<strong>Not recorded</strong>
|
||||||
<span>Demotivators</span>
|
)}
|
||||||
{parseBirdProfileList(selectedBird.demotivators).length ? (
|
</article>
|
||||||
<ul className="detail-item-list">
|
<article className="detail-card">
|
||||||
{parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => (
|
<span>Demotivators</span>
|
||||||
<li key={`${demotivator}-${index}`}>{demotivator}</li>
|
{parseBirdProfileList(selectedBird.demotivators).length ? (
|
||||||
))}
|
<ul className="detail-item-list">
|
||||||
</ul>
|
{parseBirdProfileList(selectedBird.demotivators).map((demotivator, index) => (
|
||||||
) : (
|
<li key={`${demotivator}-${index}`}>{demotivator}</li>
|
||||||
<strong>Not recorded</strong>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</article>
|
) : (
|
||||||
</div>
|
<strong>Not recorded</strong>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flock-member-sections">
|
{medications.length ? (
|
||||||
<section className="panel inset-panel">
|
<section className="panel inset-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
<p className="eyebrow">Medication</p>
|
||||||
|
<h2>Medication schedule</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedBirdTab === 'weight' ? (
|
||||||
|
<div className="flock-member-sections" role="tabpanel">
|
||||||
|
<section className="panel inset-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
<p className="eyebrow">Weight</p>
|
<p className="eyebrow">Weight</p>
|
||||||
<h2>Trend and log</h2>
|
<h2>Trend and log</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -5578,24 +5835,16 @@ function App() {
|
|||||||
<button className="primary-button" type="submit">
|
<button className="primary-button" type="submit">
|
||||||
Save weight
|
Save weight
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{medications.length ? (
|
{selectedBirdTab === 'vet' ? (
|
||||||
<section className="panel inset-panel">
|
<div className="flock-member-sections" role="tabpanel">
|
||||||
<div className="panel-header">
|
<section className="panel inset-panel">
|
||||||
<div>
|
<div className="panel-header">
|
||||||
<p className="eyebrow">Medication</p>
|
<div>
|
||||||
<h2>Medication schedule</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="recent-list">{renderMedicationList({ showAdministrationControls: true })}</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="panel inset-panel">
|
|
||||||
<div className="panel-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Vet visits</p>
|
<p className="eyebrow">Vet visits</p>
|
||||||
<h2>Care history and notes</h2>
|
<h2>Care history and notes</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -5680,19 +5929,117 @@ function App() {
|
|||||||
<small>Add the first visit above to start this care history.</small>
|
<small>Add the first visit above to start this care history.</small>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : null}
|
||||||
|
|
||||||
|
{selectedBirdTab === 'notes' ? (
|
||||||
|
<div className="flock-member-sections" role="tabpanel">
|
||||||
|
<section className="panel inset-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Notes</p>
|
||||||
|
<h2>{selectedBird.name} notes</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{selectedBirdNotes.length} total</p>
|
||||||
|
</div>
|
||||||
|
<form className="form-panel care-entry-form" onSubmit={handleFlockNoteSubmit}>
|
||||||
|
<label className="wide-field">
|
||||||
|
Note
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={flockNoteForm.body}
|
||||||
|
onChange={(event) => setFlockNoteForm({ ...flockNoteForm, body: event.target.value })}
|
||||||
|
maxLength={5000}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit" disabled={savingFlockNote}>
|
||||||
|
{savingFlockNote ? 'Saving...' : 'Save note'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="recent-list note-list">
|
||||||
|
{selectedBirdNotes.length ? (
|
||||||
|
selectedBirdNotes.map((note) => (
|
||||||
|
<article key={note.id} className="note-card">
|
||||||
|
<div>
|
||||||
|
<span>Updated {formatDateTime(note.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p>{note.body}</p>
|
||||||
|
<div className="button-row">
|
||||||
|
<small>{note.createdByName ? `By ${note.createdByName}` : 'Author unavailable'}</small>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteFlockNote(note.id)}
|
||||||
|
disabled={deletingFlockNoteId === note.id}
|
||||||
|
>
|
||||||
|
{deletingFlockNoteId === note.id ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<article className="empty-card">
|
||||||
|
<strong>No notes yet</strong>
|
||||||
|
<small>Add the first note for {selectedBird.name} above.</small>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedBirdTab === 'audit' ? (
|
||||||
|
<div className="flock-member-sections" role="tabpanel">
|
||||||
|
<section className="panel inset-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Audit log</p>
|
||||||
|
<h2>{selectedBird.name} activity</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{auditLogLoading ? 'Loading...' : `${selectedBirdAuditLogEntries.length} recent events`}</p>
|
||||||
|
</div>
|
||||||
|
{['owner', 'assistant'].includes(activeMembership?.role ?? '') ? (
|
||||||
|
<div className="recent-list audit-log-list">
|
||||||
|
{selectedBirdAuditLogEntries.length ? (
|
||||||
|
selectedBirdAuditLogEntries.map((entry) => (
|
||||||
|
<article key={entry.id} className="audit-log-card">
|
||||||
|
<div>
|
||||||
|
<strong>{formatAuditAction(entry.action)}</strong>
|
||||||
|
<span>{formatDateTime(entry.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{entry.actorName || entry.actorEmail || 'Unknown user'}
|
||||||
|
{entry.entityName ? ` • ${entry.entityName}` : ` • ${entry.entityType}`}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<article className="empty-card">
|
||||||
|
<strong>No audit events yet</strong>
|
||||||
|
<small>New activity for {selectedBird.name} will appear here.</small>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">Only owners and assistants can view the audit log.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activePage === 'settings' ? (
|
{activePage === 'settings' ? (
|
||||||
<section className="forms-grid settings-grid">
|
<section className="forms-grid settings-grid">
|
||||||
<div className="settings-column settings-column-left">
|
<div className="settings-column settings-column-left">
|
||||||
<article className="panel form-panel settings-card-flock-profile">
|
<article className="panel form-panel settings-card-flock-profile">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
|
|||||||
+142
-4
@@ -713,6 +713,11 @@ textarea {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-detail-panel {
|
||||||
|
margin-right: 3rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.flock-detail-column {
|
.flock-detail-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -1079,6 +1084,35 @@ textarea {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-card,
|
||||||
|
.audit-log-card {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card div,
|
||||||
|
.audit-log-card div {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card span,
|
||||||
|
.audit-log-card span,
|
||||||
|
.audit-log-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card .button-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.legend-grid,
|
.legend-grid,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -1090,17 +1124,22 @@ textarea {
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem 6.4rem 1rem 1rem;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
||||||
border: 1px solid rgba(39, 105, 179, 0.1);
|
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button {
|
.profile-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.85rem;
|
top: 0.85rem;
|
||||||
right: 0.85rem;
|
right: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
@@ -1111,15 +1150,91 @@ textarea {
|
|||||||
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button:hover {
|
.profile-icon-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(35, 138, 90, 0.34);
|
border-color: rgba(35, 138, 90, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button svg {
|
.profile-icon-button svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-blue);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-profile-button svg {
|
||||||
fill: var(--accent-blue);
|
fill: var(--accent-blue);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tabs {
|
||||||
|
position: absolute;
|
||||||
|
top: 5rem;
|
||||||
|
right: -3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.14);
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0 14px 14px 0;
|
||||||
|
background: rgba(255, 254, 250, 0.92);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab .weight-tab-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab .info-tab-icon,
|
||||||
|
.bird-detail-tab .note-tab-icon,
|
||||||
|
.bird-detail-tab .audit-tab-icon,
|
||||||
|
.bird-detail-tab .vet-tab-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab:hover {
|
||||||
|
border-color: rgba(35, 138, 90, 0.28);
|
||||||
|
color: var(--ink);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab.active {
|
||||||
|
border-color: rgba(35, 138, 90, 0.42);
|
||||||
|
background: rgba(240, 248, 244, 0.95);
|
||||||
|
color: var(--accent-green);
|
||||||
|
box-shadow: 10px 10px 20px rgba(39, 105, 179, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
@@ -1886,6 +2001,29 @@ label {
|
|||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-detail-panel {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tabs {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 1px solid rgba(39, 105, 179, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: static;
|
position: static;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user