208 lines
7.1 KiB
TypeScript
208 lines
7.1 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-kindled 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. Kits get the same
|
|
// defaults a hand-added rabbit would: the rabbitry name as farm, and the
|
|
// dam's (else sire's) breed. 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 setting = (key: string) =>
|
|
tx.select().from(settings).where(eq(settings.key, key)).get()?.value;
|
|
const parentBreed = (id: number | null) =>
|
|
id != null ? tx.select({ breed: rabbits.breed }).from(rabbits).where(eq(rabbits.id, id)).get()?.breed : null;
|
|
|
|
const prefix = setting('kit_prefix') || 'Kit';
|
|
const farm = setting('rabbitry_name') || null;
|
|
const breed = parentBreed(damId) || parentBreed(sireId) || null;
|
|
let counter = Number(setting('kit_counter') ?? '0');
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
counter += 1;
|
|
tx.insert(rabbits)
|
|
.values({ name: `${prefix}${counter}`, sireId, damId, dob, farm, breed })
|
|
.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 existing = db.select().from(litters).where(eq(litters.id, id)).get();
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Litter not found' });
|
|
return;
|
|
}
|
|
|
|
const updated = db.update(litters).set(values).where(eq(litters.id, id)).returning().get();
|
|
|
|
if (existing.kindleDate) {
|
|
// Kits are matched to their litter by sire/dam/DOB, so correcting any of
|
|
// those on a kindled litter moves its existing kits along with it rather
|
|
// than silently orphaning them.
|
|
const moved =
|
|
existing.kindleDate !== updated.kindleDate ||
|
|
existing.sireId !== updated.sireId ||
|
|
existing.damId !== updated.damId;
|
|
if (moved && updated.kindleDate) {
|
|
db.update(rabbits)
|
|
.set({ sireId: updated.sireId, damId: updated.damId, dob: updated.kindleDate, updatedAt: new Date().toISOString() })
|
|
.where(
|
|
and(
|
|
existing.sireId != null ? eq(rabbits.sireId, existing.sireId) : isNull(rabbits.sireId),
|
|
existing.damId != null ? eq(rabbits.damId, existing.damId) : isNull(rabbits.damId),
|
|
eq(rabbits.dob, existing.kindleDate)
|
|
)
|
|
)
|
|
.run();
|
|
}
|
|
} else if (updated.kindleDate && updated.kitCount && updated.kitCount > 0) {
|
|
// Recording the kindle on a breeding creates its kits, exactly as recording
|
|
// a kindled litter directly does.
|
|
generateKits(updated.sireId, updated.damId, updated.kindleDate, updated.kitCount);
|
|
}
|
|
|
|
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;
|