MyKindlebox/mykindlebox/server/routes/litters.ts

176 lines
5.6 KiB
TypeScript

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;