120 lines
4.5 KiB
TypeScript
120 lines
4.5 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { eq, asc, sql } from 'drizzle-orm';
|
|
import { CreateActivityInput, UpdateActivityInput, ReorderActivitiesInput } from '@solelog/shared';
|
|
import type { Activity } from '@solelog/shared';
|
|
import { db } from '../db/client';
|
|
import { activities, workSessions } from '../db/schema';
|
|
import { getSessionUser, isAdmin } from '../lib/require-user';
|
|
|
|
export const activitiesRoutes = new Hono();
|
|
|
|
type ActivityRow = typeof activities.$inferSelect;
|
|
|
|
function toActivity(row: ActivityRow): Activity {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
insole_types: row.insoleTypes as Activity['insole_types'],
|
|
created_at: new Date(row.createdAt).toISOString(),
|
|
sort_order: row.sortOrder ?? 0,
|
|
};
|
|
}
|
|
|
|
activitiesRoutes.get('/api/activities', async (c) => {
|
|
const sessionUser = await getSessionUser(c);
|
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(activities)
|
|
.orderBy(asc(activities.sortOrder), asc(activities.name));
|
|
const insoleType = c.req.query('insole_type');
|
|
const filtered = insoleType
|
|
? rows.filter((r) => (r.insoleTypes as string[]).includes(insoleType))
|
|
: rows;
|
|
return c.json(filtered.map(toActivity));
|
|
});
|
|
|
|
activitiesRoutes.post('/api/activities', async (c) => {
|
|
const sessionUser = await getSessionUser(c);
|
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
|
|
|
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
|
|
|
// Append: place the new activity after the current highest sort_order.
|
|
const [{ max }] = await db
|
|
.select({ max: sql<number>`COALESCE(MAX(${activities.sortOrder}), -1)` })
|
|
.from(activities);
|
|
|
|
const [row] = await db
|
|
.insert(activities)
|
|
.values({
|
|
name: parsed.data.name,
|
|
insoleTypes: parsed.data.insole_types,
|
|
sortOrder: max + 1,
|
|
})
|
|
.returning();
|
|
return c.json(toActivity(row));
|
|
});
|
|
|
|
activitiesRoutes.put('/api/activities/reorder', async (c) => {
|
|
const sessionUser = await getSessionUser(c);
|
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
|
|
|
const parsed = ReorderActivitiesInput.safeParse(await c.req.json().catch(() => null));
|
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
|
|
|
// The payload must be the full ordered set: same size, same ids.
|
|
const existing = await db.select({ id: activities.id }).from(activities);
|
|
const known = new Set(existing.map((r) => r.id));
|
|
if (parsed.data.ids.length !== known.size || parsed.data.ids.some((id) => !known.has(id)))
|
|
return c.json({ error: 'Invalid input' }, 400);
|
|
|
|
for (let i = 0; i < parsed.data.ids.length; i++) {
|
|
await db.update(activities).set({ sortOrder: i }).where(eq(activities.id, parsed.data.ids[i]));
|
|
}
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(activities)
|
|
.orderBy(asc(activities.sortOrder), asc(activities.name));
|
|
return c.json(rows.map(toActivity));
|
|
});
|
|
|
|
activitiesRoutes.put('/api/activities/:id', async (c) => {
|
|
const sessionUser = await getSessionUser(c);
|
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
|
|
|
const id = Number.parseInt(c.req.param('id'), 10);
|
|
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
|
|
|
const parsed = UpdateActivityInput.safeParse(await c.req.json().catch(() => null));
|
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
|
|
|
const [row] = await db
|
|
.update(activities)
|
|
.set({ name: parsed.data.name, insoleTypes: parsed.data.insole_types })
|
|
.where(eq(activities.id, id))
|
|
.returning();
|
|
if (!row) return c.json({ error: 'Activity not found' }, 404);
|
|
return c.json(toActivity(row));
|
|
});
|
|
|
|
activitiesRoutes.delete('/api/activities/:id', async (c) => {
|
|
const sessionUser = await getSessionUser(c);
|
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
|
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
|
|
|
|
const id = Number.parseInt(c.req.param('id'), 10);
|
|
if (Number.isNaN(id)) return c.json({ error: 'Activity not found' }, 404);
|
|
|
|
// FK has no cascade declared: delete the activity's sessions first, then the activity.
|
|
await db.delete(workSessions).where(eq(workSessions.activityId, id));
|
|
await db.delete(activities).where(eq(activities.id, id));
|
|
return c.json({ success: true });
|
|
});
|