MyKindlebox/mykindlebox/server/routes/showEvents.ts

658 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from 'express';
import { and, asc, desc, eq, getTableColumns, inArray, sql } from 'drizzle-orm';
import { db } from '../db/client';
import {
showEvents,
showEventShows,
showEventEntries,
showEventJudges,
showEventPlacements,
rabbits,
settings,
} from '../db/schema';
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
const router = Router();
// Mass-assignment guard (see routes/events.ts): only `name`/`notes` are
// client-writable on an event; id/timestamps are server-managed.
const EVENT_SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
const EVENT_WRITABLE = Object.keys(getTableColumns(showEvents)).filter(
(key) => !EVENT_SYSTEM_FIELDS.has(key)
);
function nowIso(): string {
return new Date().toISOString();
}
// The show within an event identified by both ids, or undefined — used to verify
// a /:showId path actually belongs to the /:id event before mutating its rows.
function getEventShow(eventId: number, showId: number) {
return db
.select()
.from(showEventShows)
.where(and(eq(showEventShows.id, showId), eq(showEventShows.showEventId, eventId)))
.get();
}
// Entries for a set of shows, each joined to its rabbit's display fields, keyed
// by `showEventShowId`. Rows are camelCase here; callers snake-case at the
// response boundary (the report builder uses them camel). leftJoin so an entry
// whose rabbit was later deleted still returns (with null rabbit fields) rather
// than vanishing.
function entriesByShow(showIds: number[]) {
const map = new Map<number, Record<string, unknown>[]>();
if (showIds.length === 0) return map;
const rows = db
.select({
id: showEventEntries.id,
showEventShowId: showEventEntries.showEventShowId,
rabbitId: showEventEntries.rabbitId,
entryClass: showEventEntries.entryClass,
entryNumber: showEventEntries.entryNumber,
notes: showEventEntries.notes,
createdAt: showEventEntries.createdAt,
rabbitName: rabbits.name,
rabbitFarm: rabbits.farm,
rabbitEarNumber: rabbits.earNumber,
rabbitBreed: rabbits.breed,
rabbitColorName: rabbits.colorName,
rabbitSex: rabbits.sex,
})
.from(showEventEntries)
.leftJoin(rabbits, eq(showEventEntries.rabbitId, rabbits.id))
.where(inArray(showEventEntries.showEventShowId, showIds))
.orderBy(asc(showEventEntries.id))
.all();
for (const row of rows) {
const list = map.get(row.showEventShowId) ?? [];
list.push(row);
map.set(row.showEventShowId, list);
}
return map;
}
// Judges for a set of shows, keyed by `showEventShowId`.
function judgesByShow(showIds: number[]) {
const map = new Map<number, Record<string, unknown>[]>();
if (showIds.length === 0) return map;
const rows = db
.select()
.from(showEventJudges)
.where(inArray(showEventJudges.showEventShowId, showIds))
.orderBy(asc(showEventJudges.id))
.all();
for (const row of rows) {
const list = map.get(row.showEventShowId) ?? [];
list.push(row);
map.set(row.showEventShowId, list);
}
return map;
}
// Placements keyed by `entryId` (at most one per entry — see the UNIQUE column).
function placementsByEntry(entryIds: number[]) {
const map = new Map<number, Record<string, unknown>>();
if (entryIds.length === 0) return map;
const rows = db
.select()
.from(showEventPlacements)
.where(inArray(showEventPlacements.entryId, entryIds))
.all();
for (const row of rows) map.set(row.entryId, row);
return map;
}
// List events with derived counts (number of shows) and the date span of their
// shows, computed in one grouped query and merged in JS — no cached counters,
// matching the dashboard-stats approach. Newest-updated first.
router.get('/', (req, res) => {
const events = db.select().from(showEvents).orderBy(desc(showEvents.updatedAt)).all();
const agg = db
.select({
showEventId: showEventShows.showEventId,
showCount: sql<number>`count(*)`,
firstDate: sql<string | null>`min(${showEventShows.showDate})`,
lastDate: sql<string | null>`max(${showEventShows.showDate})`,
})
.from(showEventShows)
.groupBy(showEventShows.showEventId)
.all();
const byEvent = new Map(agg.map((row) => [row.showEventId, row]));
const result = events.map((event) => {
const stats = byEvent.get(event.id);
return {
...toSnakeCase(event),
show_count: stats?.showCount ?? 0,
first_show_date: stats?.firstDate ?? null,
last_show_date: stats?.lastDate ?? null,
};
});
res.json(result);
});
// Event detail: the event plus its snapshotted shows (date order). Judges and
// entries (Phases 3–4) will be embedded here later.
router.get('/:id', (req, res) => {
const id = Number(req.params.id);
const event = db.select().from(showEvents).where(eq(showEvents.id, id)).get();
if (!event) {
res.status(404).json({ error: 'Event not found' });
return;
}
const shows = db
.select()
.from(showEventShows)
.where(eq(showEventShows.showEventId, id))
.orderBy(asc(showEventShows.showDate), asc(showEventShows.id))
.all();
const entriesMap = entriesByShow(shows.map((s) => s.id));
const judgesMap = judgesByShow(shows.map((s) => s.id));
const allEntryIds = [...entriesMap.values()].flat().map((e) => Number(e.id));
const placementsMap = placementsByEntry(allEntryIds);
const showsOut = shows.map((s) => ({
...toSnakeCase(s),
judges: (judgesMap.get(s.id) ?? []).map(toSnakeCase),
entries: (entriesMap.get(s.id) ?? []).map((e) => {
const placement = placementsMap.get(Number(e.id));
return {
...toSnakeCase(e),
placement: placement ? toSnakeCase(placement) : null,
};
}),
}));
res.json({ ...toSnakeCase(event), shows: showsOut });
});
router.post('/', (req, res) => {
const { name } = req.body;
if (typeof name !== 'string' || name.trim() === '') {
res.status(400).json({ error: 'A name is required' });
return;
}
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as typeof showEvents.$inferInsert;
const created = db.insert(showEvents).values(values).returning().get();
res.status(201).json(toSnakeCase(created));
});
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const values = pick(toCamelCase(req.body), EVENT_WRITABLE) as Partial<
typeof showEvents.$inferInsert
>;
values.updatedAt = nowIso();
const updated = db
.update(showEvents)
.set(values)
.where(eq(showEvents.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(showEvents).where(eq(showEvents.id, id)).get();
if (!existing) {
res.status(404).json({ error: 'Event not found' });
return;
}
// show_event_shows cascades via the FK.
db.delete(showEvents).where(eq(showEvents.id, id)).run();
res.status(204).end();
});
// Add an ARBA show to an event. The request body is an ARBA show object (the
// snake_case shape GET /api/shows/arba returns); we snapshot the relevant
// fields rather than storing a reference, since the ARBA listing is transient.
// Idempotent per (event, sanction_number): re-adding returns the existing row.
router.post('/:id/shows', (req, res) => {
const eventId = Number(req.params.id);
const event = db.select().from(showEvents).where(eq(showEvents.id, eventId)).get();
if (!event) {
res.status(404).json({ error: 'Event not found' });
return;
}
const body = req.body ?? {};
const sanctionNumber = body.sanction_number;
const clubName = body.club_name;
if (typeof sanctionNumber !== 'string' || sanctionNumber.trim() === '') {
res.status(400).json({ error: 'sanction_number is required' });
return;
}
if (typeof clubName !== 'string' || clubName.trim() === '') {
res.status(400).json({ error: 'club_name is required' });
return;
}
const existing = db
.select()
.from(showEventShows)
.where(
and(
eq(showEventShows.showEventId, eventId),
eq(showEventShows.sanctionNumber, sanctionNumber)
)
)
.get();
if (existing) {
res.status(200).json(toSnakeCase(existing));
return;
}
const snapshot: typeof showEventShows.$inferInsert = {
showEventId: eventId,
sanctionNumber,
showDate: body.date || body.raw_date || null,
clubName,
showType: body.show_type_full || body.show_type || null,
specialtyBreed: body.special_breed || null,
city: body.city || null,
state: body.state || null,
address: body.address || null,
secretary: body.secretary || null,
secretaryEmail: body.email || null,
catalogUrl: body.catalog_url || null,
mapsUrl: body.maps_url || null,
};
const created = db.transaction((tx) => {
const row = tx.insert(showEventShows).values(snapshot).returning().get();
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
return row;
});
res.status(201).json(toSnakeCase(created));
});
router.delete('/:id/shows/:showId', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const existing = db
.select()
.from(showEventShows)
.where(and(eq(showEventShows.id, showId), eq(showEventShows.showEventId, eventId)))
.get();
if (!existing) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
db.transaction((tx) => {
tx.delete(showEventShows).where(eq(showEventShows.id, showId)).run();
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
});
res.status(204).end();
});
// ---- Entries (a rabbit entered into a specific show within the event) ----
// Returns the entry joined to its rabbit's display fields (the same shape the
// detail endpoint embeds), so the client can render it without a refetch.
function entryWithRabbit(entryId: number) {
const [row] = entriesByShowEntryIds([entryId]);
return row;
}
function entriesByShowEntryIds(ids: number[]): Record<string, unknown>[] {
if (ids.length === 0) return [];
return db
.select({
id: showEventEntries.id,
showEventShowId: showEventEntries.showEventShowId,
rabbitId: showEventEntries.rabbitId,
entryClass: showEventEntries.entryClass,
entryNumber: showEventEntries.entryNumber,
notes: showEventEntries.notes,
createdAt: showEventEntries.createdAt,
rabbitName: rabbits.name,
rabbitFarm: rabbits.farm,
rabbitEarNumber: rabbits.earNumber,
rabbitBreed: rabbits.breed,
rabbitColorName: rabbits.colorName,
rabbitSex: rabbits.sex,
})
.from(showEventEntries)
.leftJoin(rabbits, eq(showEventEntries.rabbitId, rabbits.id))
.where(inArray(showEventEntries.id, ids))
.all();
}
router.post('/:id/shows/:showId/entries', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const rabbitId = req.body?.rabbit_id;
if (typeof rabbitId !== 'number') {
res.status(400).json({ error: 'rabbit_id is required' });
return;
}
const rabbit = db.select().from(rabbits).where(eq(rabbits.id, rabbitId)).get();
if (!rabbit) {
res.status(400).json({ error: 'Rabbit not found' });
return;
}
const values: typeof showEventEntries.$inferInsert = {
showEventShowId: showId,
rabbitId,
entryClass: req.body.entry_class ?? null,
entryNumber: req.body.entry_number ?? null,
notes: req.body.notes ?? null,
};
const created = db.transaction((tx) => {
const row = tx.insert(showEventEntries).values(values).returning().get();
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
return row;
});
res.status(201).json(toSnakeCase(entryWithRabbit(created.id)));
});
const ENTRY_WRITABLE = ['entryClass', 'entryNumber', 'notes'];
router.patch('/:id/shows/:showId/entries/:entryId', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const entryId = Number(req.params.entryId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const entry = db
.select()
.from(showEventEntries)
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
.get();
if (!entry) {
res.status(404).json({ error: 'Entry not found' });
return;
}
// Only the editable fields — never the rabbit or which show it's in.
const values = pick(toCamelCase(req.body), ENTRY_WRITABLE);
db.update(showEventEntries).set(values).where(eq(showEventEntries.id, entryId)).run();
res.json(toSnakeCase(entryWithRabbit(entryId)));
});
router.delete('/:id/shows/:showId/entries/:entryId', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const entryId = Number(req.params.entryId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const entry = db
.select()
.from(showEventEntries)
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
.get();
if (!entry) {
res.status(404).json({ error: 'Entry not found' });
return;
}
db.transaction((tx) => {
tx.delete(showEventEntries).where(eq(showEventEntries.id, entryId)).run();
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
});
res.status(204).end();
});
// ---- Entries report (plain text, for emailing a show secretary) ----
// ISO YYYY-MM-DD → MM/DD/YYYY (the format show secretaries expect); pass through
// anything that isn't a clean ISO date.
function reportDate(value: string | null): string {
const m = (value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${m[2]}/${m[3]}/${m[1]}` : (value ?? '');
}
router.get('/:id/report', (req, res) => {
const id = Number(req.params.id);
const event = db.select().from(showEvents).where(eq(showEvents.id, id)).get();
if (!event) {
res.status(404).json({ error: 'Event not found' });
return;
}
const shows = db
.select()
.from(showEventShows)
.where(eq(showEventShows.showEventId, id))
.orderBy(asc(showEventShows.showDate), asc(showEventShows.id))
.all();
const entriesMap = entriesByShow(shows.map((s) => s.id));
// Owner line comes from the rabbitry name in settings (per the resolved
// decision: no ARBA membership number is collected, so it's omitted).
const rabbitryRow = db.select().from(settings).where(eq(settings.key, 'rabbitry_name')).get();
const rabbitryName = rabbitryRow?.value ?? '';
const lines: string[] = [];
lines.push(`SHOW ENTRIES — ${event.name}`);
lines.push(`Generated: ${reportDate(new Date().toISOString().slice(0, 10))}`);
lines.push('');
let anyEntries = false;
for (const show of shows) {
const entries = entriesMap.get(show.id) ?? [];
if (entries.length === 0) continue;
anyEntries = true;
const sep = '='.repeat(46);
lines.push(sep);
lines.push(`${show.clubName}${show.showType ? ` — ${show.showType}` : ''}`);
lines.push(`Sanction #: ${show.sanctionNumber}`);
lines.push(`Date: ${reportDate(show.showDate)}`);
if (show.secretary || show.secretaryEmail) {
const email = show.secretaryEmail ? ` <${show.secretaryEmail}>` : '';
lines.push(`Secretary: ${show.secretary ?? ''}${email}`.trim());
}
lines.push(sep);
lines.push('');
lines.push('RABBITS ENTERED:');
lines.push('');
entries.forEach((e, idx) => {
const tattoo = e.rabbitEarNumber ? ` (${e.rabbitEarNumber})` : '';
lines.push(`${idx + 1}. ${e.rabbitName ?? 'Unknown'}${tattoo}`);
const breedVariety = [e.rabbitBreed, e.rabbitColorName].filter(Boolean).join(' / ');
if (breedVariety) lines.push(` Breed: ${breedVariety}`);
if (e.entryClass) lines.push(` Class: ${e.entryClass}`);
if (rabbitryName) lines.push(` Owner: ${rabbitryName}`);
if (e.notes) lines.push(` Notes: ${e.notes}`);
lines.push('');
});
}
if (!anyEntries) {
lines.push('No rabbits have been entered yet.');
lines.push('');
}
res.json({ report: lines.join('\n') });
});
// ---- Judges (assigned per show within an event) ----
const JUDGE_WRITABLE = ['judgeName', 'judgeArbaNumber', 'notes'];
router.post('/:id/shows/:showId/judges', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const judgeName = req.body?.judge_name;
if (typeof judgeName !== 'string' || judgeName.trim() === '') {
res.status(400).json({ error: 'judge_name is required' });
return;
}
const created = db.transaction((tx) => {
const row = tx
.insert(showEventJudges)
.values({
showEventShowId: showId,
judgeName: judgeName.trim(),
judgeArbaNumber: req.body.judge_arba_number ?? null,
notes: req.body.notes ?? null,
})
.returning()
.get();
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
return row;
});
res.status(201).json(toSnakeCase(created));
});
router.patch('/:id/shows/:showId/judges/:judgeId', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const judgeId = Number(req.params.judgeId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const judge = db
.select()
.from(showEventJudges)
.where(and(eq(showEventJudges.id, judgeId), eq(showEventJudges.showEventShowId, showId)))
.get();
if (!judge) {
res.status(404).json({ error: 'Judge not found' });
return;
}
const values = pick(toCamelCase(req.body), JUDGE_WRITABLE);
const updated = db
.update(showEventJudges)
.set(values)
.where(eq(showEventJudges.id, judgeId))
.returning()
.get();
res.json(toSnakeCase(updated));
});
router.delete('/:id/shows/:showId/judges/:judgeId', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const judgeId = Number(req.params.judgeId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const judge = db
.select()
.from(showEventJudges)
.where(and(eq(showEventJudges.id, judgeId), eq(showEventJudges.showEventShowId, showId)))
.get();
if (!judge) {
res.status(404).json({ error: 'Judge not found' });
return;
}
db.delete(showEventJudges).where(eq(showEventJudges.id, judgeId)).run();
res.status(204).end();
});
// ---- Placement (one per entry; PUT is a full-object upsert) ----
function clampLegs(value: unknown): number {
const n = Math.trunc(Number(value));
if (Number.isNaN(n)) return 0;
return Math.max(0, Math.min(3, n));
}
router.put('/:id/shows/:showId/entries/:entryId/placement', (req, res) => {
const eventId = Number(req.params.id);
const showId = Number(req.params.showId);
const entryId = Number(req.params.entryId);
if (!getEventShow(eventId, showId)) {
res.status(404).json({ error: 'Show not found in this event' });
return;
}
const entry = db
.select()
.from(showEventEntries)
.where(and(eq(showEventEntries.id, entryId), eq(showEventEntries.showEventShowId, showId)))
.get();
if (!entry) {
res.status(404).json({ error: 'Entry not found' });
return;
}
const body = req.body ?? {};
const fields = {
placement: typeof body.placement === 'string' && body.placement.trim() ? body.placement.trim() : null,
legsEarned: clampLegs(body.legs_earned),
bob: Boolean(body.bob),
bos: Boolean(body.bos),
bis: Boolean(body.bis),
ris: Boolean(body.ris),
notes: typeof body.notes === 'string' && body.notes.trim() ? body.notes.trim() : null,
};
const existing = db
.select()
.from(showEventPlacements)
.where(eq(showEventPlacements.entryId, entryId))
.get();
const saved = db.transaction((tx) => {
let row;
if (existing) {
row = tx
.update(showEventPlacements)
.set(fields)
.where(eq(showEventPlacements.id, existing.id))
.returning()
.get();
} else {
row = tx
.insert(showEventPlacements)
.values({ entryId, ...fields })
.returning()
.get();
}
tx.update(showEvents).set({ updatedAt: nowIso() }).where(eq(showEvents.id, eventId)).run();
return row;
});
res.json(toSnakeCase(saved));
});
export default router;