diff --git a/src/api.ts b/src/api.ts index 165e95f..97626c9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,3 +1,5 @@ +import { createHabitRoutes } from "./habits/routes"; +import { ApiError } from "./habits/service"; import { Hono } from "hono"; import { sql } from "drizzle-orm"; import { createAuth, type AppDatabase, type AuthEnv } from "./auth"; @@ -13,9 +15,11 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi }); app.route("/api/auth", auth.routes); app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); + app.route("/api", createHabitRoutes(db, auth, now ?? Date.now)); app.notFound(c => c.json({ error: "Not found" }, 404)); app.onError((_error, c) => { c.header("Cache-Control", "no-store"); + if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status); return c.json({ error: "Internal server error" }, 500); }); return app; diff --git a/src/habits/api.test.ts b/src/habits/api.test.ts new file mode 100644 index 0000000..58c8145 --- /dev/null +++ b/src/habits/api.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { fixture } from './test-fixture'; +let f: ReturnType; +beforeEach(() => { f = fixture(); }); +afterEach(() => f.close()); +const create = (input: object) => f.json('/habits', 'POST', input, 201); +const count = () => create({ name: 'Hydration', method: 'count', target: 8, unit: 'glasses' }); +const manual = () => create({ name: 'Read', method: 'manual' }); +const tasks = () => create({ name: 'Routine', method: 'tasks', tasks: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] }); +const day = (id: string, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}`); +const log = (id: string, input: object, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', input); +const tick = (id: string, taskId: string, done = true, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/tasks/${taskId}`, 'PUT', { done }); +const combined = (ids: string[]) => f.json('/charts', 'POST', { name: 'Combined', habitIds: ids }, 201); +const square = (id: string, date = '2026-09-04') => f.json(`/charts/${id}/days/${date}`); + +test('habit CRUD, defaults, archive, restore, history, and date inspection', async () => { + const h = await count(); expect(h.schedule).toEqual({ type: 'daily' }); expect(h.archived).toBe(false); + expect((await f.json('/habits')).habits).toHaveLength(1); + expect((await f.json(`/habits/${h.id}`)).name).toBe('Hydration'); + expect((await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Water' })).name).toBe('Water'); + expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2); + expect((await f.json('/days/2026-09-04')).habits).toHaveLength(1); + expect((await f.request(`/habits/${h.id}`, 'DELETE')).status).toBe(204); + expect((await f.json('/habits')).habits).toHaveLength(0); + expect((await f.json('/habits?archived=true')).habits).toHaveLength(1); + expect((await day(h.id)).due).toBe(false); + await f.json(`/habits/${h.id}`, 'PATCH', { archived: false }); + expect((await day(h.id)).due).toBe(true); +}); +test('count exact underlying values, over-target logging, and correction', async () => { + const h = await count(); const c = await combined([h.id]); + expect((await log(h.id, { count: 7 })).ratio).toBe(7 / 8); + expect((await square(c.id)).completed).toBe(0); + expect((await log(h.id, { count: 8 })).complete).toBe(true); + expect((await square(c.id)).ratio).toBe(1); + expect((await log(h.id, { count: 12 })).value).toBe(12); + await log(h.id, { count: 2 }); expect((await square(c.id)).ratio).toBe(0); + const detail = await day(h.id); expect(detail.target).toBe(8); expect(detail.unit).toBe('glasses'); + const audit = await f.json(`/habits/${h.id}/days/2026-09-04/audit`); + expect(audit.records[0].events).toHaveLength(4); +}); +test('manual habits expose only binary progress', async () => { + const h = await manual(); expect((await day(h.id)).ratio).toBe(0); + expect((await log(h.id, { done: true })).ratio).toBe(1); + expect((await log(h.id, { done: false })).ratio).toBe(0); + expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 1 })).status).toBe(422); +}); +test('task completion is derived; 2/3 is partial and contributes zero to every combined chart', async () => { + const h = await tasks(); const a = await combined([h.id]); const b = await combined([h.id]); + await tick(h.id, h.tasks[0].id); const partial = await tick(h.id, h.tasks[1].id); + expect(partial.ratio).toBe(2 / 3); expect(partial.complete).toBe(false); + expect((await square(a.id)).ratio).toBe(0); expect((await square(b.id)).ratio).toBe(0); + await tick(h.id, h.tasks[2].id); expect((await square(a.id)).ratio).toBe(1); + await tick(h.id, h.tasks[1].id, false); expect((await square(b.id)).ratio).toBe(0); + expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true })).status).toBe(409); +}); +test('combined equal weights, 3/4, partial contributes zero, scheduled denominator, full and neutral', async () => { + const a = await manual(); const b = await manual(); const c = await manual(); const d = await count(); + const chart = await combined([a.id,b.id,c.id,d.id]); + for (const h of [a,b,c]) await log(h.id, { done: true }); + await log(d.id, { count: 7 }); + expect(await square(chart.id)).toMatchObject({ due: 4, completed: 3, ratio: 0.75, level: 3 }); + await log(d.id, { count: 8 }); const full = await square(chart.id); + expect(full).toMatchObject({ due: 4, completed: 4, ratio: 1 }); + const one = await combined([a.id]); expect((await square(one.id)).color).toBe(full.color); + for (const h of [b,c,d]) await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } }); + expect(await square(chart.id)).toMatchObject({ due: 1, completed: 1, ratio: 1 }); + await f.json(`/habits/${a.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } }); + expect(await square(chart.id)).toMatchObject({ due: 0, ratio: null, status: 'not_due', level: null }); +}); +test('task schedule intersects parent and zero due tasks never auto-completes', async () => { + const h = await create({ name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } }] }); + const c = await combined([h.id]); expect((await square(c.id)).ratio).toBeNull(); + expect((await day(h.id)).tasks).toHaveLength(0); + expect((await day(h.id, '2026-09-07')).due).toBe(false); + const empty = await create({ name: 'Empty', method: 'tasks' }); expect((await day(empty.id)).due).toBe(false); +}); +test('incomplete occurrences expire at exact local midnight and correction retains expiry', async () => { + const h = await tasks(); await tick(h.id, h.tasks[0].id); + f.setTime('2026-09-04T21:59:59.999Z'); expect((await day(h.id)).tasks.every((t: any) => t.expiredAt === null)).toBe(true); + f.setTime('2026-09-04T22:00:00Z'); + const previous = await day(h.id); const a = previous.tasks.find((t: any) => t.taskId === h.tasks[0].id); const b = previous.tasks.find((t: any) => t.taskId === h.tasks[1].id); + expect(a.expiredAt).toBeNull(); expect(b.expiredAt).toBe(Date.parse('2026-09-04T22:00Z')); + expect((await day(h.id, '2026-09-05')).tasks.every((t: any) => !t.done && t.expiredAt === null)).toBe(true); + const corrected = await tick(h.id, h.tasks[1].id); expect(corrected.tasks.find((t: any) => t.taskId === h.tasks[1].id).expiredAt).toBe(b.expiredAt); + expect((await day(h.id, '2026-09-05')).value).toBe(0); +}); +test('downtime materializes missing occurrences with original expiry deadlines', async () => { + const h = await tasks(); f.setTime('2026-09-08T12:00Z'); + expect((await day(h.id, '2026-09-06')).tasks.every((t: any) => t.expiredAt === Date.parse('2026-09-06T22:00Z'))).toBe(true); + expect((await day(h.id, '2026-09-08')).tasks.every((t: any) => t.expiredAt === null)).toBe(true); +}); +test('target and method edits preserve yesterday, same-day carry and superseded audit records', async () => { + const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z'); + await log(h.id, { count: 8 }, '2026-09-05'); + await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }); + expect(await day(h.id)).toMatchObject({ value: 8, target: 8, complete: true }); + expect(await day(h.id, '2026-09-05')).toMatchObject({ value: 8, target: 10, complete: false }); + await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' }); + expect(await day(h.id, '2026-09-05')).toMatchObject({ method: 'manual', value: 0 }); + await log(h.id, { count: 7 }); expect((await day(h.id)).ratio).toBe(7 / 8); + const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`); + expect(audit.records).toHaveLength(3); expect(audit.records[0].count).toBe(8); + expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'count' })).status).toBe(422); +}); +test('task CRUD and recurrence edits preserve historical tasks, expiry and requirements', async () => { + const h = await tasks(); const t = h.tasks[0]; + expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(3); + expect((await f.json(`/habits/${h.id}/tasks/${t.id}`)).name).toBe('A'); + await tick(h.id, t.id); f.setTime('2026-09-05T12:00Z'); + const newTask = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'New' }, 201); + expect((await day(h.id)).target).toBe(3); expect((await day(h.id, '2026-09-05')).target).toBe(4); + await f.json(`/habits/${h.id}/tasks/${newTask.id}`, 'PATCH', { schedule: { type: 'interval', every: 3, anchor: '2026-09-05' }, name: 'Every three days' }); + expect((await f.request(`/habits/${h.id}/tasks/${t.id}`, 'DELETE')).status).toBe(204); + expect((await day(h.id)).tasks.find((v: any) => v.taskId === t.id).done).toBe(true); + expect((await f.request(`/habits/${h.id}/tasks/${t.id}`)).status).toBe(404); + await tick(h.id, t.id, false); expect((await day(h.id)).value).toBe(0); + expect((await day(h.id, '2026-09-06')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(false); + expect((await day(h.id, '2026-09-08')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(true); +}); +test('schedule edits and archiving apply today while earlier logs remain correctable', async () => { + const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } }); + expect((await day(h.id)).complete).toBe(true); expect((await day(h.id, '2026-09-05')).due).toBe(false); + await f.request(`/habits/${h.id}`, 'DELETE'); await log(h.id, { count: 6 }); expect((await day(h.id)).value).toBe(6); +}); +test('future and pre-creation dates are inspectable but not writable', async () => { + const h = await count(); expect((await day(h.id, '2026-09-05')).status).toBe('future'); + expect((await day(h.id, '2026-09-03')).status).toBe('not_due'); + for (const d of ['2026-09-05', '2026-09-03']) expect((await f.request(`/habits/${h.id}/days/${d}/progress`, 'PUT', { count: 8 })).status).toBe(409); +}); +test('combined CRUD, membership edits recompute history, calendar data and settings', async () => { + const h = await count(); const m = await manual(); await log(h.id, { count: 8 }); + const c = await combined([h.id]); expect((await f.json('/charts')).charts).toHaveLength(1); + expect((await f.json(`/charts/${c.id}`)).habitIds).toEqual([h.id]); + await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Both', habitIds: [h.id, m.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }); + expect((await square(c.id)).ratio).toBe(0.5); + const cal = await f.json(`/charts/${c.id}/calendar?from=2026-09-03&to=2026-09-05`); + expect(cal.data).toHaveLength(3); expect(cal.data[0].value).toBe(-1); expect(cal.data[2].value).toBe(-2); + expect(cal.settings.mainColor).toBe('#ff0000'); + const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 }); + expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings); + const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`); + expect(individual.data).toEqual([{ day: '2026-09-04', value: 8 }]); expect(individual.days[0].color).toBe('#123456'); + expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204); + expect((await f.request(`/charts/${c.id}`)).status).toBe(404); +}); +test('timezone changes preserve recorded deadlines and never rewind the tracking date', async () => { + const h = await tasks(); const before = await day(h.id); + expect((await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' })).timezone).toBe('America/Los_Angeles'); + expect((await f.json('/me')).timezone).toBe('America/Los_Angeles'); + expect((await day(h.id)).endsAt).toBe(before.endsAt); + f.setTime('2026-09-05T12:00Z'); const next = await day(h.id, '2026-09-05'); + expect(next.timezone).toBe('America/Los_Angeles'); expect(next.endsAt).toBe(Date.parse('2026-09-06T07:00Z')); + await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' }); + expect((await f.json('/today')).date).toBe('2026-09-05'); +}); diff --git a/src/habits/routes.ts b/src/habits/routes.ts new file mode 100644 index 0000000..d6aefdd --- /dev/null +++ b/src/habits/routes.ts @@ -0,0 +1,102 @@ +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 } }; +async function body(c: Context, schema: T): Promise> { + if (!c.req.header('Content-Type')?.toLowerCase().startsWith('application/json')) throw new ApiError(400, 'Expected application/json'); + let value: unknown; + try { value = await c.req.json(); } catch { 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 }).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 () => { + const service = new HabitService(db, c.get('user'), now()); + service.sync(); 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', async c => { + const habit = service(c).create(await 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', async c => c.json(service(c).edit(id(c), await body(c, habitPatch)))); + 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', async c => c.json(service(c).changeTask(id(c), undefined, await 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', async c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), await 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', async c => c.json(service(c).progress(id(c), date(c), await body(c, progressInput)))); + app.put('/habits/:id/days/:date/tasks/:taskId', async c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (await body(c, doneInput)).done))); + app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c)))); + app.put('/habits/:id/calendar-settings', async c => c.json(service(c).saveSettings(id(c), await body(c, calendarSettingsSchema)))); + app.get('/habits/:id/calendar', c => { + const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false)); + }); + 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', async c => { + const chart = service(c).saveChart(await 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', async c => { + const patch = await 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 } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, 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', async c => c.json(service(c).setTimezone((await body(c, timezoneInput)).timezone))); + return app; +} diff --git a/src/habits/security.test.ts b/src/habits/security.test.ts new file mode 100644 index 0000000..9004174 --- /dev/null +++ b/src/habits/security.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { fixture } from './test-fixture'; +let f: ReturnType; +beforeEach(() => { f = fixture(); }); afterEach(() => f.close()); +test('every protected resource route enforces authentication, ownership and mutation origin', async () => { + const h = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201); + const t = h.tasks[0].id; + const chart = await f.json('/charts', 'POST', { name: 'Private chart', habitIds: [h.id] }, 201); + const cases: [string, string, any?][] = [ + [`/habits/${h.id}`, 'GET'], [`/habits/${h.id}`, 'PATCH', { name: 'Stolen' }], [`/habits/${h.id}`, 'DELETE'], + [`/habits/${h.id}/history`, 'GET'], [`/habits/${h.id}/tasks`, 'GET'], [`/habits/${h.id}/tasks`, 'POST', { name: 'Bad' }], + [`/habits/${h.id}/tasks/${t}`, 'GET'], [`/habits/${h.id}/tasks/${t}`, 'PATCH', { name: 'Bad' }], [`/habits/${h.id}/tasks/${t}`, 'DELETE'], + [`/habits/${h.id}/days/2026-09-04`, 'GET'], [`/habits/${h.id}/days/2026-09-04/audit`, 'GET'], + [`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true }], [`/habits/${h.id}/days/2026-09-04/tasks/${t}`, 'PUT', { done: true }], + [`/habits/${h.id}/calendar-settings`, 'GET'], [`/habits/${h.id}/calendar-settings`, 'PUT', {}], [`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'], + [`/charts/${chart.id}`, 'GET'], [`/charts/${chart.id}`, 'PATCH', { name: 'Bad' }], [`/charts/${chart.id}`, 'DELETE'], + [`/charts/${chart.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'], [`/charts/${chart.id}/days/2026-09-04`, 'GET'], + ]; + for (const [path, method, body] of cases) { + const unauthorized = await f.request(path, method, body, 'z'); expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get('Cache-Control')).toBe('no-store'); + expect((await f.request(path, method, body, 'b')).status).toBe(404); + if (method !== 'GET') for (const origin of ['', 'https://evil.example']) expect((await f.request(path, method, body, 'a', { Origin: origin })).status).toBe(403); + } + for (const path of ['/habits', '/charts', '/today', '/days/2026-09-04']) expect((await f.request(path, 'GET', undefined, 'z')).status).toBe(401); + expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits).toHaveLength(0); + expect((await f.json('/charts', 'GET', undefined, 200, 'b')).charts).toHaveLength(0); + expect((await f.json('/today', 'GET', undefined, 200, 'b')).habits).toHaveLength(0); + expect((await f.json('/days/2026-09-04', 'GET', undefined, 200, 'b')).habits).toHaveLength(0); + expect((await f.request('/charts', 'POST', { name: 'Steal', habitIds: [h.id] }, 'b')).status).toBe(404); + for (const path of ['/habits', '/charts', '/me']) { + const method = path === '/me' ? 'PATCH' : 'POST'; + expect((await f.request(path, method, {}, 'z')).status).toBe(401); + expect((await f.request(path, method, {}, 'a', { Origin: '' })).status).toBe(403); + } +}); +test('strict JSON, dates, bounds, methods, task membership and non-render settings validation', async () => { + const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201); + const invalidHabits = [{}, { name: ' ', method: 'manual' }, { name: 'Mixed', method: 'manual', target: 8 }, { name: 'Water', method: 'count', target: -1 }, { name: 'Huge', method: 'count', target: 10001 }, { name: 'Odd', method: 'manual', schedule: { type: 'interval', every: 0, anchor: '2026-09-04' } }]; + for (const body of invalidHabits) expect((await f.request('/habits', 'POST', body)).status).toBe(422); + for (const body of [{ count: -1 }, { count: 1.5 }, { done: true }, { count: 2, done: true }, { count: 1e20 }]) expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', body)).status).toBe(422); + for (const date of ['2026-02-30', 'garbage', '2026-9-4']) expect((await f.request(`/habits/${h.id}/days/${date}`)).status).toBe(422); + for (const query of ['', '?from=2026-09-04', '?from=2026-09-05&to=2026-09-04', '?from=2020-01-01&to=2026-01-01', '?from=2026-09-04&to=2026-09-05&bad=x']) expect((await f.request(`/habits/${h.id}/calendar${query}`)).status).toBe(422); + expect((await f.request('/habits?archived=yes')).status).toBe(422); + expect((await f.request(`/habits/${h.id}`, 'PATCH', {})).status).toBe(422); + expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'manual', target: 4 })).status).toBe(422); + expect((await f.request(`/habits/${h.id}/tasks`)).status).toBe(409); + expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'Wrong method' })).status).toBe(409); + expect((await f.request(`/habits/${h.id}/days/2026-09-04/tasks/missing`, 'PUT', { done: true })).status).toBe(404); + for (const settings of [{ mainColor: 'red' }, { shadeCount: 1 }, { daySpacing: 5 }]) expect((await f.request(`/habits/${h.id}/calendar-settings`, 'PUT', settings)).status).toBe(422); + for (const timezone of ['', 'invalid', '+02:00']) expect((await f.request('/me', 'PATCH', { timezone })).status).toBe(422); + for (const habitIds of [[], [h.id, h.id], ['not-uuid']]) expect((await f.request('/charts', 'POST', { name: 'Bad', habitIds })).status).toBe(422); + const response = await f.app.request(`${f.origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' }, body: '{' }); expect(response.status).toBe(400); + expect((await f.request('/habits', 'POST', {}, 'a', { 'Content-Type': 'text/plain' })).status).toBe(400); + expect((await f.request('/habits', 'POST', { name: 'x'.repeat(70000) })).status).toBe(413); +}); +test('missing tasks and archived editing fail without partial writes', async () => { + const h = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks' }, 201); + expect((await f.request(`/habits/${h.id}/tasks/nope`, 'PATCH', { name: 'No' })).status).toBe(404); + await f.request(`/habits/${h.id}`, 'DELETE'); + expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'No' })).status).toBe(409); + expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2); +}); diff --git a/src/habits/service.ts b/src/habits/service.ts new file mode 100644 index 0000000..990e9af --- /dev/null +++ b/src/habits/service.ts @@ -0,0 +1,228 @@ +import { and, asc, desc, eq, inArray, isNull, lte, sql } from 'drizzle-orm'; +import type { AppDatabase } from '../auth'; +import type { PublicUser } from '../shared/user'; +import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema'; +import { addDays, endOfDay, localDate, scheduled, shade } from './calendar'; +import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts'; + +export class ApiError extends Error { + constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); } +} +const missing = () => new ApiError(404, 'Not found'); +export type Habit = typeof habits.$inferSelect; +export type Revision = typeof habitRevisions.$inferSelect; +export type HabitDay = typeof habitDays.$inferSelect; + +export class HabitService { + readonly today: string; + constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) { + // Travel west must never reopen or rewrite earlier requirements. + const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date; + this.today = [localDate(now, user.timezone), latest ?? ''].sort().at(-1)!; + } + owned(id: string): Habit { + const habit = this.db.select().from(habits).where(and(eq(habits.id, id), eq(habits.userId, this.user.id))).get(); + if (!habit) throw missing(); + return habit; + } + revision(id: string, date = this.today): Revision | undefined { + return this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, date))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get(); + } + current(id: string) { + const habit = this.owned(id); const revision = this.revision(id)!; + return { id: habit.id, ...revision.config, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; + } + list(includeArchived = false) { + return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all() + .map(h => this.current(h.id)).filter(h => includeArchived || !h.archived); + } + create(input: ReturnType) { + const id = crypto.randomUUID(); + const config: HabitConfig = input.method === 'tasks' + ? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) } + : { ...input, archived: false }; + this.db.transaction(() => { + this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run(); + this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); + this.materialize(this.owned(id)); + }); + return this.current(id); + } + edit(id: string, patch: Record) { + const old = this.current(id); + const method = patch.method ?? old.method; + const base: Record = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method }; + if (method === 'count') { + base.target = patch.target ?? (old.method === 'count' ? old.target : undefined); + base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps'); + } else if ('target' in patch || 'unit' in patch) throw new ApiError(422, 'Target and unit require the count method'); + if (method === 'tasks') base.tasks = []; + const parsed = habitInput.safeParse(base); + if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings'); + const config: HabitConfig = parsed.data.method === 'tasks' + ? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean } + : { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean }; + return this.revise(id, config); + } + revise(id: string, config: HabitConfig) { + this.owned(id); + this.db.transaction(() => { + this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); + this.materialize(this.owned(id)); + }); + return this.current(id); + } + tasks(id: string) { + const config = this.current(id); + if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks'); + return config.tasks; + } + changeTask(id: string, taskId: string | undefined, input: { name?: string; schedule?: HabitConfig['schedule'] } | null) { + const config = this.revision(this.owned(id).id)!.config; + if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks'); + if (config.archived) throw new ApiError(409, 'Restore the habit before editing tasks'); + if (taskId && !config.tasks.some(t => t.id === taskId)) throw missing(); + const nextId = taskId ?? crypto.randomUUID(); + const tasks = taskId ? config.tasks.flatMap(t => t.id !== taskId ? [t] : input ? [{ ...t, ...input }] : []) + : [...config.tasks, { id: nextId, name: input!.name!, schedule: input!.schedule! }]; + if (tasks.length > 100) throw new ApiError(422, 'A habit supports at most 100 tasks'); + this.revise(id, { ...config, tasks }); + return input ? tasks.find(t => t.id === nextId)! : undefined; + } + sync() { + this.db.transaction(() => { + for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit); + // Record the original deadline even after downtime. Only active day snapshots expire. + this.db.run(sql`UPDATE task_occurrences SET expired_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) + WHERE done = 0 AND expired_at IS NULL AND day_id IN ( + SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id + WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now} + AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`); + }); + } + private materialize(habit: Habit) { + let date = habit.materializedThrough ?? habit.createdDate; + while (date <= this.today) { + const revision = this.revision(habit.id, date)!; + const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get(); + if (!existing || existing.revisionId !== revision.id) { + const prior = existing ? this.db.select().from(habitRevisions).where(eq(habitRevisions.id, existing.revisionId)).get() : undefined; + const sameMethod = prior?.config.method === revision.config.method; + const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date, + timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? endOfDay(date, this.user.timezone), + count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get(); + if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) { + const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : []; + for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) { + const old = previous.find(t => t.taskId === task.id); + this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name, + done: old?.done ?? false, expiredAt: old?.expiredAt ?? (day.endsAt <= this.now && !old?.done ? day.endsAt : null), updatedAt: this.now }).run(); + } + } + } + date = addDays(date, 1); + } + this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run(); + } + day(id: string, date: string) { + this.owned(id); + const revision = this.revision(id, date); + const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get(); + const config = revision?.config; + const future = date > this.today; + const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date) + ? record ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, record.id)).orderBy(asc(taskOccurrences.id)).all() + : config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, updatedAt: null })) : []; + const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0); + const value = !due ? 0 : config!.method === 'count' ? record?.count ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length; + const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length; + const ratio = due ? Math.min(1, value / target!) : null; + return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null, + timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future, + status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty', + value, target, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion', + ratio, complete: due && ratio === 1, tasks, requirements: config ?? null }; + } + writable(id: string, date: string) { + const detail = this.day(id, date); + if (detail.future) throw new ApiError(409, 'Future progress cannot be logged'); + if (!detail.due) throw new ApiError(409, 'Nothing is scheduled for this date'); + const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get()!; + return { detail, record }; + } + progress(id: string, date: string, input: { count: number } | { done: boolean }) { + const { detail, record } = this.writable(id, date); + if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences'); + if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date'); + this.db.transaction(() => { + this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, after: input, createdAt: this.now }).run(); + this.db.update(habitDays).set(input).where(eq(habitDays.id, record.id)).run(); + }); + return this.day(id, date); + } + completeTask(id: string, date: string, taskId: string, done: boolean) { + const { record } = this.writable(id, date); + const occurrence = this.db.select().from(taskOccurrences).where(and(eq(taskOccurrences.dayId, record.id), eq(taskOccurrences.taskId, taskId))).get(); + if (!occurrence) throw missing(); + this.db.transaction(() => { + this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run(); + this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run(); + }); + return this.day(id, date); + } + history(id: string) { + this.owned(id); + return this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.id)).all(); + } + audit(id: string, date: string) { + this.owned(id); + return this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(asc(habitDays.id)).all().map(day => ({ + ...day, requirements: this.db.select().from(habitRevisions).where(eq(habitRevisions.id, day.revisionId)).get()!.config, + occurrences: this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, day.id)).all(), + events: this.db.select().from(progressEvents).where(eq(progressEvents.dayId, day.id)).orderBy(asc(progressEvents.id)).all(), + })); + } + settings(id: string): CalendarSettings { + this.owned(id); + return this.db.select().from(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).get()?.settings ?? calendarSettingsSchema.parse({}); + } + saveSettings(id: string, settings: CalendarSettings) { + this.owned(id); + this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run(); + return settings; + } + chart(id: string) { + const chart = this.db.select().from(combinedCharts).where(and(eq(combinedCharts.id, id), eq(combinedCharts.userId, this.user.id))).get(); + if (!chart) throw missing(); + return chart; + } + charts() { return this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).orderBy(asc(combinedCharts.createdAt), asc(combinedCharts.id)).all(); } + saveChart(input: { name: string; habitIds: string[]; settings: CalendarSettings }, id?: string) { + if (id) this.chart(id); + input.habitIds.forEach(h => this.owned(h)); + if (id) this.db.update(combinedCharts).set({ ...input, updatedAt: this.now }).where(eq(combinedCharts.id, id)).run(); + else { id = crypto.randomUUID(); this.db.insert(combinedCharts).values({ ...input, id, userId: this.user.id, createdAt: this.now, updatedAt: this.now }).run(); } + return this.chart(id); + } + deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); } + calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) { + ids.forEach(id => this.owned(id)); + const days = []; + for (let date = from; date <= to; date = addDays(date, 1)) { + const details = ids.map(id => this.day(id, date)); + const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length; + const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio; + const progress = shade(ratio, combined ? settings.shadeCount : details[0]!.target ?? 1, settings, !combined); + const future = date > this.today; + days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty', + ...progress, color: future ? settings.futureColor : progress.color, habits: details }); + } + return { from, to, today: this.today, timezone: this.user.timezone, settings, days, + // Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters. + data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) }; + } + setTimezone(timezone: string) { + this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run(); + return { ...this.user, timezone }; + } +} diff --git a/src/habits/test-fixture.ts b/src/habits/test-fixture.ts new file mode 100644 index 0000000..417e828 --- /dev/null +++ b/src/habits/test-fixture.ts @@ -0,0 +1,26 @@ +import { Database } from 'bun:sqlite'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; +import { createApi } from '../api'; +import { hashToken } from '../auth'; +import * as schema from '../db/schema'; +export function fixture(path = ':memory:') { + const sqlite = new Database(path); sqlite.exec('PRAGMA foreign_keys = ON'); + const db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: './drizzle' }); + let time = Date.parse('2026-09-04T12:00:00Z'); + const origin = 'http://127.0.0.1:3000'; + const app = createApi(db, { origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, () => time); + for (const [id, token] of [['alice', 'a'], ['bob', 'b']]) { + db.insert(schema.users).values({ id: id!, discordId: id!, username: id!, timezone: 'Europe/Belgrade', createdAt: time, updatedAt: time }).onConflictDoNothing().run(); + db.insert(schema.sessions).values({ tokenHash: hashToken(token!.repeat(43)), userId: id!, createdAt: time, expiresAt: time + 10 * 365 * 86400000 }).onConflictDoNothing().run(); + } + async function request(path: string, method = 'GET', body?: unknown, user = 'a', extra?: Record) { + return app.request(`${origin}/api${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, 'Content-Type': 'application/json', ...extra }, body: body === undefined ? undefined : JSON.stringify(body) }); + } + async function json(path: string, method = 'GET', body?: unknown, status = 200, user = 'a'): Promise { + const response = await request(path, method, body, user); const data = await response.json(); + if (response.status !== status) throw new Error(`${method} ${path}: expected ${status}, got ${response.status}: ${JSON.stringify(data)}`); + return data; + } + return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => sqlite.close() }; +}