MyKindlebox/mykindlebox/server/routes/rabbits.ts

527 lines
16 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 } 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, 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();
}
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;
}
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;