MyKindlebox/mykindlebox/server/routes/events.ts

114 lines
3.4 KiB
TypeScript

import { Router } from 'express';
import { asc, eq, getTableColumns } from 'drizzle-orm';
import { db } from '../db/client';
import { rabbitEvents, rabbits } 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 EVENT_SYSTEM_FIELDS = new Set(['id', 'createdAt']);
const EVENT_WRITABLE = Object.keys(getTableColumns(rabbitEvents)).filter(
(key) => !EVENT_SYSTEM_FIELDS.has(key)
);
const VALID_TYPES = new Set(['show', 'sale', 'other']);
// List a rabbit's manually-logged events oldest-first so the timeline can splice
// them straight in. `rabbit_id` is required — there is no all-events view in v1.
router.get('/', (req, res) => {
const rabbitId = Number(req.query.rabbit_id);
if (!rabbitId) {
res.status(400).json({ error: 'rabbit_id is required' });
return;
}
const results = db
.select()
.from(rabbitEvents)
.where(eq(rabbitEvents.rabbitId, rabbitId))
.orderBy(asc(rabbitEvents.eventDate))
.all();
res.json(results.map(toSnakeCase));
});
router.post('/', (req, res) => {
const { rabbit_id, type, event_date, title } = req.body;
if (!rabbit_id || typeof rabbit_id !== 'number') {
res.status(400).json({ error: 'rabbit_id is required' });
return;
}
if (typeof type !== 'string' || !VALID_TYPES.has(type)) {
res.status(400).json({ error: `Invalid event type: ${type}` });
return;
}
if (typeof event_date !== 'string' || event_date.trim() === '') {
res.status(400).json({ error: 'A date is required' });
return;
}
if (typeof title !== 'string' || title.trim() === '') {
res.status(400).json({ error: 'A title is required' });
return;
}
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as typeof rabbitEvents.$inferInsert;
// A sale also marks the rabbit Sold so the dashboard counts stay accurate.
// Done in the same transaction as the insert so the two can't drift.
const created = db.transaction((tx) => {
const event = tx.insert(rabbitEvents).values(values).returning().get();
if (event.type === 'sale') {
tx.update(rabbits)
.set({ status: 'sold', updatedAt: new Date().toISOString() })
.where(eq(rabbits.id, event.rabbitId))
.run();
}
return event;
});
res.status(201).json(toSnakeCase(created));
});
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
// rabbit_id and type are immutable once set — a logged event belongs to its
// rabbit and keeps its kind; only its fields are editable.
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as Partial<
typeof rabbitEvents.$inferInsert
>;
delete (values as Record<string, unknown>).rabbitId;
delete (values as Record<string, unknown>).type;
const updated = db
.update(rabbitEvents)
.set(values)
.where(eq(rabbitEvents.id, id))
.returning()
.get();
if (!updated) {
res.status(404).json({ error: 'Event not found' });
return;
}
res.json(toSnakeCase(updated));
});
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const existing = db.select().from(rabbitEvents).where(eq(rabbitEvents.id, id)).get();
if (!existing) {
res.status(404).json({ error: 'Event not found' });
return;
}
db.delete(rabbitEvents).where(eq(rabbitEvents.id, id)).run();
res.status(204).end();
});
export default router;