diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts index 7fd31a7..21eb97e 100644 --- a/apps/api/src/routes/activities.ts +++ b/apps/api/src/routes/activities.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; -import { eq, asc } from 'drizzle-orm'; -import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared'; +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'; @@ -24,7 +24,10 @@ 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.name)); + 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)) @@ -40,13 +43,47 @@ activitiesRoutes.post('/api/activities', async (c) => { 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`COALESCE(MAX(${activities.sortOrder}), -1)` }) + .from(activities); + const [row] = await db .insert(activities) - .values({ name: parsed.data.name, insoleTypes: parsed.data.insole_types }) + .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); diff --git a/apps/api/test/activities.test.ts b/apps/api/test/activities.test.ts index 5acba2d..a28d47b 100644 --- a/apps/api/test/activities.test.ts +++ b/apps/api/test/activities.test.ts @@ -150,6 +150,120 @@ describe('activities routes', () => { expect(res.status).toBe(404); }); + it('orders GET by sort_order then name', async () => { + const app = createApp(); + const token = await authToken(app, 'act-order@example.com', 'admin'); + + // Create three activities; they append with increasing sort_order. + const names = ['Order-C', 'Order-A', 'Order-B']; + const created: Array<{ id: number; name: string }> = []; + for (const name of names) { + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name, insole_types: ['Kurk'] }), + }); + created.push(await res.json()); + } + + const listRes = await app.request('/api/activities', { headers: bearer(token) }); + const list: Array<{ id: number; sort_order: number }> = await listRes.json(); + // Filtered to just our three, in returned order. + const ours = list.filter((a) => created.some((c) => c.id === a.id)); + expect(ours.map((a) => a.id)).toEqual(created.map((c) => c.id)); + // sort_order is non-decreasing across the whole list. + for (let i = 1; i < list.length; i++) { + expect(list[i].sort_order).toBeGreaterThanOrEqual(list[i - 1].sort_order); + } + }); + + it('appends a new activity with a higher sort_order than existing ones', async () => { + const app = createApp(); + const token = await authToken(app, 'act-append@example.com', 'admin'); + + const firstRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Append-1', insole_types: ['Kurk'] }), + }); + const first = await firstRes.json(); + + const secondRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Append-2', insole_types: ['Kurk'] }), + }); + const second = await secondRes.json(); + + expect(second.sort_order).toBeGreaterThan(first.sort_order); + }); + + it('lets an admin reorder activities (PUT /api/activities/reorder)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-reorder@example.com', 'admin'); + + // Existing activities (possibly seeded by other tests) plus our two. + const aRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Reorder-A', insole_types: ['Kurk'] }), + }); + const a = await aRes.json(); + const bRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Reorder-B', insole_types: ['Kurk'] }), + }); + const b = await bRes.json(); + + // Build the full ordered id list, then swap a and b so b comes first. + const beforeRes = await app.request('/api/activities', { headers: bearer(token) }); + const before: Array<{ id: number }> = await beforeRes.json(); + const ids = before.map((r) => r.id); + const ia = ids.indexOf(a.id); + const ib = ids.indexOf(b.id); + [ids[ia], ids[ib]] = [ids[ib], ids[ia]]; + + const reorderRes = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ ids }), + }); + expect(reorderRes.status).toBe(200); + + const afterRes = await app.request('/api/activities', { headers: bearer(token) }); + const after: Array<{ id: number }> = await afterRes.json(); + expect(after.map((r) => r.id)).toEqual(ids); + // In particular, b now precedes a. + expect(after.findIndex((r) => r.id === b.id)).toBeLessThan( + after.findIndex((r) => r.id === a.id) + ); + }); + + it('forbids a worker from reordering activities (403)', async () => { + const app = createApp(); + const token = await authToken(app, 'act-reorder-worker@example.com'); + + const res = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ ids: [1] }), + }); + expect(res.status).toBe(403); + }); + + it('400s reorder when ids do not match the full set', async () => { + const app = createApp(); + const token = await authToken(app, 'act-reorder-badids@example.com', 'admin'); + + const res = await app.request('/api/activities/reorder', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ ids: [999999] }), + }); + expect(res.status).toBe(400); + }); + it('deletes an activity and its sessions', async () => { const app = createApp(); const token = await authToken(app, 'act-delete@example.com', 'admin');