import { Router } from 'express'; import { and, or, asc, gte, eq, sql, SQL, AnyColumn, getTableColumns } from 'drizzle-orm'; import multer from 'multer'; import path from 'path'; import { db, uploadsDir } from '../db/client'; import { rabbits } from '../db/schema'; import { toCamelCase, toSnakeCase, pick } from '../utils/case'; const router = Router(); // Mass-assignment guard: accept only real schema columns from the request body, // and treat id / timestamps / photo_path as server-managed (photo is set via the // dedicated upload route). Derived from the schema so new columns are covered // automatically — anything unexpected in the body is silently dropped. const RABBIT_SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'photoPath']); const RABBIT_WRITABLE = Object.keys(getTableColumns(rabbits)).filter( (key) => !RABBIT_SYSTEM_FIELDS.has(key) ); const VALID_SEX = new Set(['buck', 'doe', 'unknown']); const VALID_STATUS = new Set(['active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other']); // These are also enforced by DB CHECK constraints, but validating here turns a // bad value into a clear 400 instead of a generic 500 from the constraint. function invalidEnumError(values: Record): string | null { if (values.sex !== undefined && !VALID_SEX.has(values.sex as string)) { return `Invalid sex: ${values.sex}`; } if (values.status !== undefined && !VALID_STATUS.has(values.status as string)) { return `Invalid status: ${values.status}`; } return null; } // Case-insensitive substring match on name. The user's text is escaped so LIKE // wildcards (% and _) and the escape char itself are treated literally — a // search for "a_b" matches the string "a_b", not "axb". The ESCAPE clause is // required because SQLite's LIKE has no escape character by default. function nameContains(term: string): SQL { const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`); return sql`${rabbits.name} like ${`%${escaped}%`} escape '\\'`; } // Quick-search "wildcard at the end": a prefix match (`term%`), so typing "Ju" // matches "Jim Bean"… no, "Jupiter" — only the start of the field. The user's // text is escaped so literal % / _ aren't treated as wildcards. SQLite's LIKE is // case-insensitive for ASCII, which is the behavior we want here. function fieldStartsWith(column: AnyColumn, term: string): SQL { const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`); return sql`${column} like ${`${escaped}%`} escape '\\'`; } // The fields the quick-search bar can target, keyed by the snake_case name the // client sends in `?fields=` (and the same keys returned in `matched_fields`). const QUICK_SEARCH_FIELDS = { farm: rabbits.farm, name: rabbits.name, ear_number: rabbits.earNumber, color: rabbits.colorName, } as const; type QuickSearchField = keyof typeof QUICK_SEARCH_FIELDS; const ALLOWED_PHOTO_TYPES: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', }; const photoUpload = multer({ storage: multer.diskStorage({ destination: uploadsDir, filename: (req, file, cb) => { cb(null, `${req.params.id}${ALLOWED_PHOTO_TYPES[file.mimetype]}`); }, }), fileFilter: (req, file, cb) => { if (!ALLOWED_PHOTO_TYPES[file.mimetype]) { cb(new Error('INVALID_FILE_TYPE')); return; } cb(null, true); }, }).single('photo'); type RabbitRow = typeof rabbits.$inferSelect; type Direction = 'sire' | 'dam'; interface AncestorNode { id: number | null; name: string | null; farm: string | null; sex: string | null; ear_number: string | null; weight_lbs: number | null; dob: string | null; color_name: string | null; sire_id: number | null; dam_id: number | null; sire: AncestorNode | null; dam: AncestorNode | null; } function fetchRabbitById(id: number): RabbitRow | undefined { return db.select().from(rabbits).where(eq(rabbits.id, id)).get(); } function dbAncestorNode(ancestor: RabbitRow): AncestorNode { return { id: ancestor.id, name: ancestor.name, farm: ancestor.farm, sex: ancestor.sex, ear_number: ancestor.earNumber, weight_lbs: ancestor.weightLbs, dob: ancestor.dob, color_name: ancestor.colorName, sire_id: ancestor.sireId, dam_id: ancestor.damId, sire: null, dam: null, }; } function freeformAncestorNode(text: string): AncestorNode { return { id: null, name: text, farm: null, sex: null, ear_number: null, weight_lbs: null, dob: null, color_name: null, sire_id: null, dam_id: null, sire: null, dam: null, }; } // Chain-authority rule: a flat `_name_text` column is only read when the chain // has already broken at an earlier generation (see memory-bank/architecture.md). function flatAncestorText(record: RabbitRow, path: Direction[]): string | null { switch (path.join(',')) { case 'sire,sire': return record.paternalGrandsireNameText; case 'sire,dam': return record.paternalGranddamNameText; case 'dam,sire': return record.maternalGrandsireNameText; case 'dam,dam': return record.maternalGranddamNameText; case 'sire,sire,sire': return record.ppGrandsireNameText; case 'sire,sire,dam': return record.ppGranddamNameText; case 'sire,dam,sire': return record.pmGrandsireNameText; case 'sire,dam,dam': return record.pmGranddamNameText; case 'dam,sire,sire': return record.mpGrandsireNameText; case 'dam,sire,dam': return record.mpGranddamNameText; case 'dam,dam,sire': return record.mmGrandsireNameText; case 'dam,dam,dam': return record.mmGranddamNameText; default: return null; } } function resolveAncestorNode(record: RabbitRow, path: Direction[]): AncestorNode | null { const [head, ...rest] = path; const fkValue = head === 'sire' ? record.sireId : record.damId; if (fkValue != null) { const ancestor = fetchRabbitById(fkValue); if (!ancestor) return null; if (rest.length === 0) return dbAncestorNode(ancestor); return resolveAncestorNode(ancestor, rest); } if (rest.length === 0) { const text = head === 'sire' ? record.sireNameText : record.damNameText; return text ? freeformAncestorNode(text) : null; } const text = flatAncestorText(record, path); return text ? freeformAncestorNode(text) : null; } const KIT_AGE_WINDOW_DAYS = 56; const sortColumns = { name: rabbits.name, dob: rabbits.dob, weight_lbs: rabbits.weightLbs, created_at: rabbits.createdAt, } as const; router.get('/', (req, res) => { const { sex, status, search, sort } = req.query; const conditions: SQL[] = []; if (sex === 'kits') { const cutoff = new Date(Date.now() - KIT_AGE_WINDOW_DAYS * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); conditions.push(gte(rabbits.dob, cutoff)); } else if (typeof sex === 'string' && sex) { conditions.push(eq(rabbits.sex, sex)); } if (typeof status === 'string' && status) { conditions.push(eq(rabbits.status, status)); } if (typeof search === 'string' && search) { conditions.push(nameContains(search)); } const sortColumn = typeof sort === 'string' && sort in sortColumns ? sortColumns[sort as keyof typeof sortColumns] : sortColumns.name; const results = db .select() .from(rabbits) .where(conditions.length ? and(...conditions) : undefined) .orderBy(asc(sortColumn)) .all(); res.json(results.map(toSnakeCase)); }); router.get('/search', (req, res) => { const { q, sex } = req.query; const conditions: SQL[] = []; if (typeof q === 'string' && q) { conditions.push(nameContains(q)); } if (typeof sex === 'string' && sex) { conditions.push(eq(rabbits.sex, sex)); } const results = db .select({ id: rabbits.id, name: rabbits.name, farm: rabbits.farm, sex: rabbits.sex, }) .from(rabbits) .where(conditions.length ? and(...conditions) : undefined) .orderBy(asc(rabbits.name)) .limit(10) .all(); res.json(results.map(toSnakeCase)); }); // Quick-search bar (top of every page): prefix-matches `q` against any of the // selected fields (defaults to all), and tags each hit with the field(s) it // matched on so the dropdown can show why it surfaced. Must be registered // before `GET /:id` so "quick-search" isn't swallowed as an id. router.get('/quick-search', (req, res) => { const { q, fields } = req.query; const term = typeof q === 'string' ? q.trim() : ''; if (!term) { res.json([]); return; } // Default to every field; when `fields` is given, keep only known keys. const requested: QuickSearchField[] = typeof fields === 'string' && fields ? (fields.split(',').filter((f): f is QuickSearchField => f in QUICK_SEARCH_FIELDS)) : (Object.keys(QUICK_SEARCH_FIELDS) as QuickSearchField[]); if (requested.length === 0) { res.json([]); return; } const matchers = requested.map((f) => fieldStartsWith(QUICK_SEARCH_FIELDS[f], term)); const results = db .select({ id: rabbits.id, name: rabbits.name, farm: rabbits.farm, sex: rabbits.sex, earNumber: rabbits.earNumber, colorName: rabbits.colorName, }) .from(rabbits) .where(or(...matchers)) .orderBy(asc(rabbits.name)) .limit(10) .all(); // Re-derive which of the requested fields each row matched, mirroring the // LIKE prefix match, so the client can label the hit (e.g. "Ear #", "Color"). const lowerTerm = term.toLowerCase(); const labeled = results.map(toSnakeCase).map((row: any) => { const valueByField: Record = { farm: row.farm, name: row.name, ear_number: row.ear_number, color: row.color_name, }; const matched_fields = requested.filter((f) => (valueByField[f] ?? '').toLowerCase().startsWith(lowerTerm) ); return { ...row, matched_fields }; }); res.json(labeled); }); router.get('/:id', (req, res) => { const rabbit = fetchRabbitById(Number(req.params.id)); if (!rabbit) { res.status(404).json({ error: 'Rabbit not found' }); return; } res.json(toSnakeCase(rabbit)); }); router.get('/:id/ancestors', (req, res) => { const root = fetchRabbitById(Number(req.params.id)); if (!root) { res.status(404).json({ error: 'Rabbit not found' }); return; } const sire = resolveAncestorNode(root, ['sire']); const dam = resolveAncestorNode(root, ['dam']); const paternalGrandsire = resolveAncestorNode(root, ['sire', 'sire']); const paternalGranddam = resolveAncestorNode(root, ['sire', 'dam']); const maternalGrandsire = resolveAncestorNode(root, ['dam', 'sire']); const maternalGranddam = resolveAncestorNode(root, ['dam', 'dam']); if (sire) { sire.sire = paternalGrandsire; sire.dam = paternalGranddam; } if (dam) { dam.sire = maternalGrandsire; dam.dam = maternalGranddam; } if (paternalGrandsire) { paternalGrandsire.sire = resolveAncestorNode(root, ['sire', 'sire', 'sire']); paternalGrandsire.dam = resolveAncestorNode(root, ['sire', 'sire', 'dam']); } if (paternalGranddam) { paternalGranddam.sire = resolveAncestorNode(root, ['sire', 'dam', 'sire']); paternalGranddam.dam = resolveAncestorNode(root, ['sire', 'dam', 'dam']); } if (maternalGrandsire) { maternalGrandsire.sire = resolveAncestorNode(root, ['dam', 'sire', 'sire']); maternalGrandsire.dam = resolveAncestorNode(root, ['dam', 'sire', 'dam']); } if (maternalGranddam) { maternalGranddam.sire = resolveAncestorNode(root, ['dam', 'dam', 'sire']); maternalGranddam.dam = resolveAncestorNode(root, ['dam', 'dam', 'dam']); } res.json({ id: root.id, name: root.name, farm: root.farm, sex: root.sex, ear_number: root.earNumber, weight_lbs: root.weightLbs, dob: root.dob, color_name: root.colorName, sire_id: root.sireId, dam_id: root.damId, sire, dam, }); }); router.post('/', (req, res) => { const { name } = req.body; if (typeof name !== 'string' || name.trim() === '') { res.status(400).json({ error: 'Name is required' }); return; } const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert; const enumError = invalidEnumError(values); if (enumError) { res.status(400).json({ error: enumError }); return; } const created = db.insert(rabbits).values(values).returning().get(); res.status(201).json(toSnakeCase(created)); }); router.patch('/:id', (req, res) => { const id = Number(req.params.id); const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as Partial< typeof rabbits.$inferInsert >; const enumError = invalidEnumError(values); if (enumError) { res.status(400).json({ error: enumError }); return; } const updated = db .update(rabbits) .set({ ...values, updatedAt: new Date().toISOString() }) .where(eq(rabbits.id, id)) .returning() .get(); if (!updated) { res.status(404).json({ error: 'Rabbit not found' }); return; } res.json(toSnakeCase(updated)); }); router.delete('/:id', (req, res) => { const id = Number(req.params.id); const existing = db.select().from(rabbits).where(eq(rabbits.id, id)).get(); if (!existing) { res.status(404).json({ error: 'Rabbit not found' }); return; } // Chain-authority: when a rabbit is deleted, its offspring's link to it is // broken, so snapshot the deleted rabbit (and the two generations above it // that the offspring used to see through it) into the offspring's freeform // `_name_text` columns. Without this, deleting a sire would silently erase // that whole branch of every descendant's pedigree instead of degrading it to // text. All offspring of a given rabbit share the same snapshot, so it's // computed once. Wrapped in a transaction with the delete so a partial failure // can't leave dangling FKs. const nameAt = (path: Direction[]) => resolveAncestorNode(existing, path)?.name ?? null; const sireSnapshot = { sireId: null, sireNameText: existing.name, paternalGrandsireNameText: nameAt(['sire']), paternalGranddamNameText: nameAt(['dam']), ppGrandsireNameText: nameAt(['sire', 'sire']), ppGranddamNameText: nameAt(['sire', 'dam']), pmGrandsireNameText: nameAt(['dam', 'sire']), pmGranddamNameText: nameAt(['dam', 'dam']), }; const damSnapshot = { damId: null, damNameText: existing.name, maternalGrandsireNameText: nameAt(['sire']), maternalGranddamNameText: nameAt(['dam']), mpGrandsireNameText: nameAt(['sire', 'sire']), mpGranddamNameText: nameAt(['sire', 'dam']), mmGrandsireNameText: nameAt(['dam', 'sire']), mmGranddamNameText: nameAt(['dam', 'dam']), }; db.transaction((tx) => { tx.update(rabbits).set(sireSnapshot).where(eq(rabbits.sireId, id)).run(); tx.update(rabbits).set(damSnapshot).where(eq(rabbits.damId, id)).run(); tx.delete(rabbits).where(eq(rabbits.id, id)).run(); }); res.status(204).end(); }); router.post('/:id/photo', (req, res) => { const id = Number(req.params.id); if (!fetchRabbitById(id)) { res.status(404).json({ error: 'Rabbit not found' }); return; } photoUpload(req, res, (err) => { if (err) { res.status(400).json({ error: 'Invalid file type' }); return; } const file = req.file; if (!file) { res.status(400).json({ error: 'Invalid file type' }); return; } const photoPath = `/uploads/${path.basename(file.path)}`; const updated = db .update(rabbits) .set({ photoPath, updatedAt: new Date().toISOString() }) .where(eq(rabbits.id, id)) .returning() .get()!; res.json(toSnakeCase(updated)); }); }); export default router;