feat(api): orderable activities + admin reorder endpoint
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc, sql } from 'drizzle-orm';
|
||||||
import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared';
|
import { CreateActivityInput, UpdateActivityInput, ReorderActivitiesInput } from '@solelog/shared';
|
||||||
import type { Activity } from '@solelog/shared';
|
import type { Activity } from '@solelog/shared';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { activities, workSessions } from '../db/schema';
|
import { activities, workSessions } from '../db/schema';
|
||||||
@@ -24,7 +24,10 @@ activitiesRoutes.get('/api/activities', async (c) => {
|
|||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
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 insoleType = c.req.query('insole_type');
|
||||||
const filtered = insoleType
|
const filtered = insoleType
|
||||||
? rows.filter((r) => (r.insoleTypes as string[]).includes(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));
|
const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null));
|
||||||
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
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
|
const [row] = await db
|
||||||
.insert(activities)
|
.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();
|
.returning();
|
||||||
return c.json(toActivity(row));
|
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) => {
|
activitiesRoutes.put('/api/activities/:id', async (c) => {
|
||||||
const sessionUser = await getSessionUser(c);
|
const sessionUser = await getSessionUser(c);
|
||||||
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|||||||
@@ -150,6 +150,120 @@ describe('activities routes', () => {
|
|||||||
expect(res.status).toBe(404);
|
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 () => {
|
it('deletes an activity and its sessions', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const token = await authToken(app, 'act-delete@example.com', 'admin');
|
const token = await authToken(app, 'act-delete@example.com', 'admin');
|
||||||
|
|||||||
Reference in New Issue
Block a user