176 lines
5.6 KiB
TypeScript
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;
|