feat(api): user-scoped activities CRUD with shared auth helper
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { health } from './routes/health';
|
import { health } from './routes/health';
|
||||||
import { me } from './routes/me';
|
import { me } from './routes/me';
|
||||||
|
import { activitiesRoutes } from './routes/activities';
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
|
|
||||||
export function createApp(): Hono {
|
export function createApp(): Hono {
|
||||||
@@ -8,5 +9,6 @@ export function createApp(): Hono {
|
|||||||
app.route('/', health);
|
app.route('/', health);
|
||||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
|
||||||
app.route('/', me);
|
app.route('/', me);
|
||||||
|
app.route('/', activitiesRoutes);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/api/src/lib/require-user.ts
Normal file
8
apps/api/src/lib/require-user.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
78
apps/api/src/routes/activities.ts
Normal file
78
apps/api/src/routes/activities.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
198
apps/api/test/activities.test.ts
Normal file
198
apps/api/test/activities.test.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string, string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user