diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bd8b6f4..dacbd70 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { health } from './routes/health'; import { me } from './routes/me'; +import { activitiesRoutes } from './routes/activities'; import { auth } from './auth'; export function createApp(): Hono { @@ -8,5 +9,6 @@ export function createApp(): Hono { app.route('/', health); app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); app.route('/', me); + app.route('/', activitiesRoutes); return app; } diff --git a/apps/api/src/lib/require-user.ts b/apps/api/src/lib/require-user.ts new file mode 100644 index 0000000..f2cd7d2 --- /dev/null +++ b/apps/api/src/lib/require-user.ts @@ -0,0 +1,8 @@ +import type { Context } from 'hono'; +import { auth } from '../auth'; + +export async function getSessionUser(c: Context): Promise<{ id: string } | null> { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (!session) return null; + return { id: session.user.id }; +} diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts new file mode 100644 index 0000000..5636709 --- /dev/null +++ b/apps/api/src/routes/activities.ts @@ -0,0 +1,78 @@ +import { Hono } from 'hono'; +import { eq, asc } from 'drizzle-orm'; +import { CreateActivityInput, UpdateActivityInput } from '@solelog/shared'; +import type { Activity } from '@solelog/shared'; +import { db } from '../db/client'; +import { activities, workSessions } from '../db/schema'; +import { getSessionUser } 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(), + }; +} + +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 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); + + const parsed = CreateActivityInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + + const [row] = await db + .insert(activities) + .values({ name: parsed.data.name, insoleTypes: parsed.data.insole_types }) + .returning(); + return c.json(toActivity(row)); +}); + +activitiesRoutes.put('/api/activities/:id', async (c) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + + 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); + + 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 }); +}); diff --git a/apps/api/test/activities.test.ts b/apps/api/test/activities.test.ts new file mode 100644 index 0000000..acb2161 --- /dev/null +++ b/apps/api/test/activities.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import type { Hono } from 'hono'; +import { createApp } from '../src/app'; +import { db } from '../src/db/client'; +import { workSessions, user } from '../src/db/schema'; +import { eq } from 'drizzle-orm'; + +const json = { 'content-type': 'application/json' }; + +// Sign up + sign in a user, returning the bearer token. +async function authToken(app: Hono, email: string): Promise { + const password = 'sterk-wachtwoord-123'; + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password, name: email.split('@')[0] }), + }); + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password }), + }); + const token = signin.headers.get('set-auth-token'); + if (!token) throw new Error('no token'); + return token; +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; +} + +describe('activities routes', () => { + it('401s GET /api/activities without a token', async () => { + const app = createApp(); + const res = await app.request('/api/activities'); + expect(res.status).toBe(401); + }); + + it('401s POST /api/activities without a token', async () => { + const app = createApp(); + const res = await app.request('/api/activities', { + method: 'POST', + headers: json, + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(401); + }); + + it('creates an activity and lists it', async () => { + const app = createApp(); + const token = await authToken(app, 'act-create@example.com'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk', 'Berk'] }), + }); + expect(createRes.status).toBe(200); + const created = await createRes.json(); + expect(created.name).toBe('Frezen'); + expect(created.insole_types).toEqual(['Kurk', 'Berk']); + expect(typeof created.id).toBe('number'); + expect(typeof created.created_at).toBe('string'); + + const listRes = await app.request('/api/activities', { headers: bearer(token) }); + expect(listRes.status).toBe(200); + const list = await listRes.json(); + expect(Array.isArray(list)).toBe(true); + expect(list.some((a: { id: number }) => a.id === created.id)).toBe(true); + }); + + it('defaults insole_types to all three when omitted', async () => { + const app = createApp(); + const token = await authToken(app, 'act-default@example.com'); + + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Slijpen' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.insole_types).toEqual(['Kurk', 'Berk', '3D']); + }); + + it('filters by ?insole_type', async () => { + const app = createApp(); + const token = await authToken(app, 'act-filter@example.com'); + + const printRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Printen-only-3D', insole_types: ['3D'] }), + }); + const print = await printRes.json(); + const corkRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Kurk-only', insole_types: ['Kurk'] }), + }); + const cork = await corkRes.json(); + + const res = await app.request('/api/activities?insole_type=3D', { headers: bearer(token) }); + expect(res.status).toBe(200); + const list: Array<{ id: number }> = await res.json(); + expect(list.some((a) => a.id === print.id)).toBe(true); + expect(list.some((a) => a.id === cork.id)).toBe(false); + }); + + it('400s POST with an empty name', async () => { + const app = createApp(); + const token = await authToken(app, 'act-emptyname@example.com'); + + const res = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: ' ', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(400); + }); + + it('updates an activity', async () => { + const app = createApp(); + const token = await authToken(app, 'act-update@example.com'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'Oud', insole_types: ['Kurk'] }), + }); + const created = await createRes.json(); + + const res = await app.request(`/api/activities/${created.id}`, { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ name: 'Nieuw', insole_types: ['Berk', '3D'] }), + }); + expect(res.status).toBe(200); + const updated = await res.json(); + expect(updated.id).toBe(created.id); + expect(updated.name).toBe('Nieuw'); + expect(updated.insole_types).toEqual(['Berk', '3D']); + }); + + it('404s PUT for a missing id', async () => { + const app = createApp(); + const token = await authToken(app, 'act-put404@example.com'); + + const res = await app.request('/api/activities/999999', { + method: 'PUT', + headers: bearer(token), + body: JSON.stringify({ name: 'X', insole_types: ['Kurk'] }), + }); + expect(res.status).toBe(404); + }); + + it('deletes an activity and its sessions', async () => { + const app = createApp(); + const token = await authToken(app, 'act-delete@example.com'); + + const createRes = await app.request('/api/activities', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ name: 'TeVerwijderen', insole_types: ['Kurk'] }), + }); + const created = await createRes.json(); + + // Find the test user's id to attach a work_sessions row directly. + const [u] = await db.select().from(user).where(eq(user.email, 'act-delete@example.com')); + expect(u).toBeTruthy(); + + await db.insert(workSessions).values({ + userId: u.id, + activityId: created.id, + startTime: new Date(), + }); + + const before = await db + .select() + .from(workSessions) + .where(eq(workSessions.activityId, created.id)); + expect(before.length).toBe(1); + + const res = await app.request(`/api/activities/${created.id}`, { + method: 'DELETE', + headers: bearer(token), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true }); + + const after = await db + .select() + .from(workSessions) + .where(eq(workSessions.activityId, created.id)); + expect(after.length).toBe(0); + }); +});