MyKindlebox/mykindlebox/server/routes/rabbits.ts

607 lines
19 KiB
TypeScript

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, rabbitEvents, litters, showEventEntries } from '../db/schema';
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
import {
findExistingSubject,
materializeAncestors,
mergeIntoExisting,
MergeReport,
} from '../lib/ancestors';
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, unknown>): 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<string, string> = {
'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();
}
// Manual-ancestor JSON for a resolved node (see client lib/pedigree.js).
// Freeform nodes already hold their stored text.
function snapshotText(node: AncestorNode | null): string | null {
if (!node) return null;
if (node.id == null) return node.name;
return JSON.stringify({
name: node.name ?? '',
farm: node.farm ?? '',
ear_number: node.ear_number ?? '',
weight_lbs: node.weight_lbs ?? 3,
dob: node.dob ?? 'Unknown',
});
}
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<QuickSearchField, string | null> = {
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;
}
// Adding a rabbit that already exists (same ear number, or same name + farm)
// tops up that record instead of duplicating it; otherwise ancestors entered
// as text become linked reference rabbits. See lib/ancestors.ts. One
// transaction so a failure can't leave half-made ancestor records behind.
const outcome = db.transaction((tx) => {
const existing = findExistingSubject(tx, values);
if (existing) {
const report = mergeIntoExisting(tx, existing, values);
const row = tx.select().from(rabbits).where(eq(rabbits.id, existing.id)).get()!;
return { row, merged: report };
}
materializeAncestors(tx, values);
return { row: tx.insert(rabbits).values(values).returning().get(), merged: null };
});
res
.status(outcome.merged ? 200 : 201)
.json({ ...toSnakeCase(outcome.row), merged: outcome.merged && mergeReportJson(outcome.merged) });
});
function mergeReportJson(report: MergeReport) {
return {
filled: Object.keys(toSnakeCase(Object.fromEntries(report.filled.map((key) => [key, true])))),
ancestors_added: report.ancestorsAdded,
ancestors_updated: report.ancestorsUpdated,
conflicts: report.conflicts,
had_photo: report.hadPhoto,
};
}
// Lets the Add Rabbit review step say up front that saving will top up an
// existing rabbit. Same matching as POST / uses.
router.post('/find-existing', (req, res) => {
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert;
const existing = findExistingSubject(db, values);
res.json(
existing
? toSnakeCase({ id: existing.id, name: existing.name, farm: existing.farm, earNumber: existing.earNumber, status: existing.status })
: null,
);
});
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;
}
// Show entries are history the user would lose silently — make them remove
// those first (the UI suggests Archive instead).
const showEntryCount = db
.select({ id: showEventEntries.id })
.from(showEventEntries)
.where(eq(showEventEntries.rabbitId, id))
.all().length;
if (showEntryCount > 0) {
res.status(409).json({
error: `${existing.name} is entered in ${showEntryCount} show${showEntryCount === 1 ? '' : 's'}. Remove those entries first, or archive the rabbit instead.`,
});
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.
// Snapshots keep the full manual-ancestor JSON (name, farm, ear #, weight,
// DOB), not just a name, so the entry can be matched/re-linked later.
const nameAt = (path: Direction[]) => snapshotText(resolveAncestorNode(existing, path));
const self = snapshotText(dbAncestorNode(existing));
const sireSnapshot = {
sireId: null,
sireNameText: self,
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: self,
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();
// SQLite FK enforcement is off, so nothing cascades on its own: litters
// keep their dates/kit counts with that parent shown as unknown, and the
// rabbit's own timeline events go with it.
tx.update(litters).set({ sireId: null }).where(eq(litters.sireId, id)).run();
tx.update(litters).set({ damId: null }).where(eq(litters.damId, id)).run();
tx.delete(rabbitEvents).where(eq(rabbitEvents.rabbitId, 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;