import { Router } from 'express'; import { alias } from 'drizzle-orm/sqlite-core'; import { and, desc, eq, isNull, getTableColumns, sql } from 'drizzle-orm'; import { db } from '../db/client'; import { litters, rabbits, settings } 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 LITTER_SYSTEM_FIELDS = new Set(['id', 'createdAt']); const LITTER_WRITABLE = Object.keys(getTableColumns(litters)).filter( (key) => !LITTER_SYSTEM_FIELDS.has(key) ); // Auto-creates `count` kit Rabbit rows for a newly-recorded litter, named // from the `kit_prefix` setting (falling back to "Kit" if unset) plus a // global, never-resetting `kit_counter` — so kit names stay unique across // every litter ever recorded, not just within one litter. Wrapped in a // transaction so the counter and the inserted rows can't drift apart if // something fails partway through. function generateKits(sireId: number | null, damId: number | null, dob: string, count: number) { db.transaction((tx) => { const prefixRow = tx.select().from(settings).where(eq(settings.key, 'kit_prefix')).get(); const counterRow = tx.select().from(settings).where(eq(settings.key, 'kit_counter')).get(); const prefix = prefixRow?.value || 'Kit'; let counter = Number(counterRow?.value ?? '0'); for (let i = 0; i < count; i++) { counter += 1; tx.insert(rabbits) .values({ name: `${prefix}${counter}`, sireId, damId, dob }) .run(); } tx.update(settings).set({ value: String(counter) }).where(eq(settings.key, 'kit_counter')).run(); }); } const sireRabbits = alias(rabbits, 'sire_rabbits'); const damRabbits = alias(rabbits, 'dam_rabbits'); function litterWithParents() { return db .select({ id: litters.id, sireId: litters.sireId, damId: litters.damId, breedDate: litters.breedDate, kindleDate: litters.kindleDate, kitCount: litters.kitCount, notes: litters.notes, createdAt: litters.createdAt, sireName: sireRabbits.name, damName: damRabbits.name, }) .from(litters) .leftJoin(sireRabbits, eq(litters.sireId, sireRabbits.id)) .leftJoin(damRabbits, eq(litters.damId, damRabbits.id)); } function serializeLitter(row: { id: number; sireId: number | null; damId: number | null; breedDate: string | null; kindleDate: string | null; kitCount: number | null; notes: string | null; createdAt: string; sireName: string | null; damName: string | null; }) { return { id: row.id, breed_date: row.breedDate, kindle_date: row.kindleDate, kit_count: row.kitCount, notes: row.notes, created_at: row.createdAt, sire: row.sireId != null ? { id: row.sireId, name: row.sireName } : null, dam: row.damId != null ? { id: row.damId, name: row.damName } : null, }; } router.get('/', (req, res) => { // Breedings without a kindle date yet fall back to their breed date so an // in-progress breeding sorts by when it happened, not to the bottom. const results = litterWithParents() .orderBy(desc(sql`coalesce(${litters.kindleDate}, ${litters.breedDate})`)) .all(); res.json(results.map(serializeLitter)); }); router.get('/:id', (req, res) => { const litter = litterWithParents() .where(eq(litters.id, Number(req.params.id))) .get(); if (!litter) { res.status(404).json({ error: 'Litter not found' }); return; } // Kits are derived by matching sire/dam/DOB against the kindle date, so a // breeding that hasn't kindled yet (no kindle date) simply has no kits. const kits = litter.kindleDate ? db .select() .from(rabbits) .where( and( litter.sireId != null ? eq(rabbits.sireId, litter.sireId) : isNull(rabbits.sireId), litter.damId != null ? eq(rabbits.damId, litter.damId) : isNull(rabbits.damId), eq(rabbits.dob, litter.kindleDate) ) ) .all() : []; res.json({ ...serializeLitter(litter), kits: kits.map(toSnakeCase) }); }); router.post('/', (req, res) => { const { breed_date, kindle_date, kit_count } = req.body; const hasBreedDate = typeof breed_date === 'string' && breed_date.trim() !== ''; const hasKindleDate = typeof kindle_date === 'string' && kindle_date.trim() !== ''; if (!hasBreedDate && !hasKindleDate) { res.status(400).json({ error: 'A breeding date or kindle date is required' }); return; } const values = pick(toCamelCase(req.body), LITTER_WRITABLE) as typeof litters.$inferInsert; const created = db.insert(litters).values(values).returning().get(); if (created.kindleDate && typeof kit_count === 'number' && kit_count > 0) { generateKits(created.sireId, created.damId, created.kindleDate, kit_count); } res.status(201).json(toSnakeCase(created)); }); router.patch('/:id', (req, res) => { const id = Number(req.params.id); const values = pick(toCamelCase(req.body), LITTER_WRITABLE) as Partial< typeof litters.$inferInsert >; const updated = db.update(litters).set(values).where(eq(litters.id, id)).returning().get(); if (!updated) { res.status(404).json({ error: 'Litter not found' }); return; } res.json(toSnakeCase(updated)); }); router.delete('/:id', (req, res) => { const id = Number(req.params.id); const existing = db.select().from(litters).where(eq(litters.id, id)).get(); if (!existing) { res.status(404).json({ error: 'Litter not found' }); return; } db.delete(litters).where(eq(litters.id, id)).run(); res.status(204).end(); }); export default router;