114 lines
3.4 KiB
TypeScript
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;
|