import { Router } from 'express'; import { asc, eq, getTableColumns } from 'drizzle-orm'; import { db } from '../db/client'; import { rabbitEvents, rabbits } from '../db/schema'; import { toCamelCase, toSnakeCase, pick } from '../utils/case'; const router = Router(); // Mass-assignment guard (see routes/rabbits.ts): id/created_at are server-managed. const EVENT_SYSTEM_FIELDS = new Set(['id', 'createdAt']); const EVENT_WRITABLE = Object.keys(getTableColumns(rabbitEvents)).filter( (key) => !EVENT_SYSTEM_FIELDS.has(key) ); const VALID_TYPES = new Set(['show', 'sale', 'other']); // List a rabbit's manually-logged events oldest-first so the timeline can splice // them straight in. `rabbit_id` is required — there is no all-events view in v1. router.get('/', (req, res) => { const rabbitId = Number(req.query.rabbit_id); if (!rabbitId) { res.status(400).json({ error: 'rabbit_id is required' }); return; } const results = db .select() .from(rabbitEvents) .where(eq(rabbitEvents.rabbitId, rabbitId)) .orderBy(asc(rabbitEvents.eventDate)) .all(); res.json(results.map(toSnakeCase)); }); router.post('/', (req, res) => { const { rabbit_id, type, event_date, title } = req.body; if (!rabbit_id || typeof rabbit_id !== 'number') { res.status(400).json({ error: 'rabbit_id is required' }); return; } if (typeof type !== 'string' || !VALID_TYPES.has(type)) { res.status(400).json({ error: `Invalid event type: ${type}` }); return; } if (typeof event_date !== 'string' || event_date.trim() === '') { res.status(400).json({ error: 'A date is required' }); return; } if (typeof title !== 'string' || title.trim() === '') { res.status(400).json({ error: 'A title is required' }); return; } const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as typeof rabbitEvents.$inferInsert; // A sale also marks the rabbit Sold so the dashboard counts stay accurate. // Done in the same transaction as the insert so the two can't drift. const created = db.transaction((tx) => { const event = tx.insert(rabbitEvents).values(values).returning().get(); if (event.type === 'sale') { tx.update(rabbits) .set({ status: 'sold', updatedAt: new Date().toISOString() }) .where(eq(rabbits.id, event.rabbitId)) .run(); } return event; }); res.status(201).json(toSnakeCase(created)); }); router.patch('/:id', (req, res) => { const id = Number(req.params.id); // rabbit_id and type are immutable once set — a logged event belongs to its // rabbit and keeps its kind; only its fields are editable. const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as Partial< typeof rabbitEvents.$inferInsert >; delete (values as Record).rabbitId; delete (values as Record).type; const updated = db .update(rabbitEvents) .set(values) .where(eq(rabbitEvents.id, id)) .returning() .get(); if (!updated) { res.status(404).json({ error: 'Event not found' }); return; } res.json(toSnakeCase(updated)); }); router.delete('/:id', (req, res) => { const id = Number(req.params.id); const existing = db.select().from(rabbitEvents).where(eq(rabbitEvents.id, id)).get(); if (!existing) { res.status(404).json({ error: 'Event not found' }); return; } db.delete(rabbitEvents).where(eq(rabbitEvents.id, id)).run(); res.status(204).end(); }); export default router;