import { Hono, type Context, type MiddlewareHandler } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; import type { AppDatabase, AuthEnv, createAuth } from '../auth'; import { HabitService, ApiError } from './service'; import { calendarSettingsSchema, chartInput, chartPatch, dateSchema, doneInput, habitInput, habitPatch, progressInput, taskInput, taskPatch, timezoneInput } from './contracts'; import { dayNumber } from './calendar'; type Env = AuthEnv & { Variables: { service: HabitService; payload: { value: unknown; malformed: boolean } } }; function body(c: Context, schema: T): z.output { if (c.req.header('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw new ApiError(400, 'Expected application/json'); const { value, malformed } = c.get('payload'); if (malformed) throw new ApiError(400, 'Malformed JSON'); const result = schema.safeParse(value); if (!result.success) throw new ApiError(422, result.error.issues.map(i => `${i.path.join('.') || 'body'}: ${i.message}`).join('; ')); return result.data; } function date(c: Context) { const result = dateSchema.safeParse(c.req.param('date')); if (!result.success) throw new ApiError(422, 'Expected a real YYYY-MM-DD date'); return result.data; } function range(c: Context) { const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query()); if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days'); return result.data; } export function createHabitRoutes(db: AppDatabase, auth: ReturnType, now: () => number) { const app = new Hono(); const protectedPath = (path: string, method: string) => /^\/api\/(habits|charts|today|days)(\/|$)/.test(path) || (path === '/api/me' && method === 'PATCH'); const authenticate = auth.requireAuth as unknown as MiddlewareHandler; const origin = auth.requireSameOrigin as unknown as MiddlewareHandler; const limit = bodyLimit({ maxSize: 65536, onError: c => c.json({ error: 'Request body exceeds 64 KiB' }, 413) }); app.use('*', async (c, next) => { if (!protectedPath(c.req.path, c.req.method)) return next(); return authenticate(c, async () => { const proceed = async () => { const response = await limit(c, async () => { // Finish all asynchronous body reads before taking a dated database snapshot. // The handlers below then read, validate and write synchronously in one turn. const payload: Env['Variables']['payload'] = { value: undefined, malformed: false }; if (['POST', 'PATCH', 'PUT'].includes(c.req.method)) { try { payload.value = await c.req.json(); } catch { payload.malformed = true; } } c.set('payload', payload); await HabitService.withPrepared(db, c.get('user'), now, async service => { c.set('service', service); await next(); }); }); if (response) c.res = response; }; if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { const response = await origin(c, proceed); if (response) c.res = response; } else await proceed(); }); }); const service = (c: Context) => c.get('service'); const id = (c: Context) => c.req.param('id')!; app.get('/habits', c => { const q = z.object({ archived: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query()); if (!q.success) throw new ApiError(422, 'archived must be true or false'); return c.json({ habits: service(c).list(q.data.archived === 'true') }); }); app.post('/habits', c => { const habit = service(c).create(body(c, habitInput)); c.header('Location', `/api/habits/${habit.id}`); return c.json(habit, 201); }); app.get('/habits/:id', c => c.json(service(c).current(id(c)))); app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch)))); app.delete('/habits/:id/permanent', c => { service(c).permanentlyDelete(id(c)); return c.body(null, 204); }); app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); }); app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) })); app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) })); app.post('/habits/:id/tasks', c => c.json(service(c).changeTask(id(c), undefined, body(c, taskInput)), 201)); app.get('/habits/:id/tasks/:taskId', c => { const task = service(c).tasks(id(c)).find(t => t.id === c.req.param('taskId')); if (!task) throw new ApiError(404, 'Not found'); return c.json(task); }); app.patch('/habits/:id/tasks/:taskId', c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), body(c, taskPatch)))); app.delete('/habits/:id/tasks/:taskId', c => { service(c).changeTask(id(c), c.req.param('taskId'), null); return c.body(null, 204); }); app.get('/habits/:id/days/:date', c => c.json(service(c).day(id(c), date(c)))); app.get('/habits/:id/days/:date/audit', c => c.json({ records: service(c).audit(id(c), date(c)) })); app.put('/habits/:id/days/:date/progress', c => c.json(service(c).progress(id(c), date(c), body(c, progressInput)))); app.put('/habits/:id/days/:date/tasks/:taskId', c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (body(c, doneInput)).done))); app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c)))); app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema)))); app.get('/habits/:id/calendar', c => { const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true')); }); app.get('/today', c => { const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today)); return c.json({ date: s.today, timezone: s.user.timezone, habits, due: habits.filter(h => h.due).length, completed: habits.filter(h => h.complete).length }); }); app.get('/days/:date', c => { const s = service(c); const day = date(c); return c.json({ date: day, habits: s.list(true).map(h => s.day(h.id, day)) }); }); app.get('/charts', c => c.json({ charts: service(c).charts() })); app.post('/charts', c => { const chart = service(c).saveChart(body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201); }); app.get('/charts/:id', c => c.json(service(c).chart(id(c)))); app.patch('/charts/:id', c => { const patch = body(c, chartPatch); const old = service(c).chart(id(c)); return c.json(service(c).saveChart({ name: patch.name ?? old.name, habitIds: patch.habitIds ?? old.habitIds, settings: patch.settings ?? old.settings }, id(c))); }); app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); }); app.get('/charts/:id/calendar', c => { const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true')); }); app.get('/charts/:id/days/:date', c => { const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]); }); app.patch('/me', c => c.json(service(c).setTimezone((body(c, timezoneInput)).timezone))); return app; }