diff --git a/docs/plans/phase-0-foundation.md b/docs/plans/phase-0-foundation.md new file mode 100644 index 0000000..b050b1f --- /dev/null +++ b/docs/plans/phase-0-foundation.md @@ -0,0 +1,958 @@ +# Phase 0 — Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up a dockerized, dedicated backend service (Hono + better-auth + Drizzle + SQLite/libsql) where a client can sign up, sign in, and use the returned token to call a protected endpoint — proven end-to-end by automated tests and `docker compose up`. + +**Architecture:** A new greenfield monorepo structure is added alongside the inherited code (which stays untouched as reference). A single backend service (`apps/api`) owns auth and all DB access; a shared package (`packages/shared`) holds the TypeScript/zod API contracts. The database is a local SQLite file accessed through libsql (no native compilation — Windows-friendly and identical in Docker). + +**Tech Stack:** Node 22, TypeScript (ESM), Hono, better-auth, Drizzle ORM + drizzle-kit, `@libsql/client`, zod, Vitest, tsx, Docker. + +## Global Constraints + +- **Package manager:** Yarn 4 (Berry), already configured at the repo root. Run via corepack. +- **Do NOT touch** the inherited `apps/mobile` or `apps/web` in this phase — they are reference only. +- **Package names:** scoped `@solelog/*` (project is "SoleLog"). This phase adds `@solelog/api` and `@solelog/shared`. +- **Module system:** ESM everywhere (`"type": "module"`), `moduleResolution: "Bundler"` or `"NodeNext"` in tsconfig. +- **DB driver:** libsql (`@libsql/client`) only — never `better-sqlite3` (avoids native build on Windows). +- **Dependency majors:** `hono@^4`, `@hono/node-server@^1`, `better-auth@^1`, `drizzle-orm@^0.36`, `drizzle-kit@^0.30`, `@libsql/client@^0.14`, `zod@^3`, `vitest@^3`, `tsx@^4`. Install the latest within each major. +- **API port:** `3000` (the inherited web app used 4000 — avoid the clash). +- **Env vars (apps/api):** `DATABASE_URL` (e.g. `file:./data/app.db`), `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` (e.g. `http://localhost:3000`), `PORT`. +- **Commit style:** Conventional Commits (`feat:`, `chore:`, `test:`, `docs:`), one commit per task step where indicated. + +--- + +## File Structure + +``` +apps/api/ + package.json + tsconfig.json + drizzle.config.ts + .env.example + src/ + index.ts entry — starts the HTTP server + app.ts builds the Hono app (exported for tests) + env.ts reads + validates env vars + db/ + client.ts libsql + drizzle client (singleton) + schema.ts better-auth tables (Drizzle) + migrate.ts applies drizzle migrations + auth.ts better-auth config + routes/ + health.ts GET /health (public) + me.ts GET /api/me (protected) + drizzle/ generated migration SQL (created by drizzle-kit) + test/ + setup.ts vitest global setup: temp DB + migrate + health.test.ts + auth.test.ts + vitest.config.ts + Dockerfile +packages/shared/ + package.json + tsconfig.json + src/index.ts zod schemas + types (API contracts) +docker-compose.yml (repo root) +``` + +--- + +## Task 1: Monorepo wiring + shared contracts package + +**Files:** +- Modify: `package.json` (root) — add `packages/*` to `workspaces` +- Create: `packages/shared/package.json` +- Create: `packages/shared/tsconfig.json` +- Create: `packages/shared/src/index.ts` + +**Interfaces:** +- Produces: `@solelog/shared` exporting `HealthResponse` (zod schema + type) and `MeResponse` (zod schema + type), consumed by `apps/api`. + +- [ ] **Step 1: Add `packages/*` to root workspaces** + +Edit root `package.json` so the `workspaces` array is: + +```json +"workspaces": [ + "apps/*", + "packages/*" +] +``` + +- [ ] **Step 2: Create the shared package manifest** + +`packages/shared/package.json`: + +```json +{ + "name": "@solelog/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "zod": "^3.23.8" + } +} +``` + +- [ ] **Step 3: Create the shared tsconfig** + +`packages/shared/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 4: Write the shared contracts** + +`packages/shared/src/index.ts`: + +```ts +import { z } from 'zod'; + +export const HealthResponse = z.object({ + status: z.literal('ok'), +}); +export type HealthResponse = z.infer; + +export const PublicUser = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), +}); +export type PublicUser = z.infer; + +export const MeResponse = z.object({ + user: PublicUser, +}); +export type MeResponse = z.infer; +``` + +- [ ] **Step 5: Install and verify the workspace resolves** + +Run: `yarn install` +Expected: completes; `yarn workspaces list` includes `@solelog/shared`. + +Run: `yarn workspaces list` +Expected output includes a line for `packages/shared`. + +- [ ] **Step 6: Commit** + +```bash +git add package.json yarn.lock packages/shared +git commit -m "feat(shared): add @solelog/shared contracts package and wire packages/* workspace" +``` + +--- + +## Task 2: Hono backend skeleton + health endpoint + +**Files:** +- Create: `apps/api/package.json` +- Create: `apps/api/tsconfig.json` +- Create: `apps/api/src/env.ts` +- Create: `apps/api/src/app.ts` +- Create: `apps/api/src/routes/health.ts` +- Create: `apps/api/src/index.ts` +- Create: `apps/api/vitest.config.ts` +- Test: `apps/api/test/health.test.ts` + +**Interfaces:** +- Consumes: `@solelog/shared` (`HealthResponse`). +- Produces: `createApp(): Hono` from `apps/api/src/app.ts` (used by every later task and all tests); `env` object from `apps/api/src/env.ts`. + +- [ ] **Step 1: Create the api package manifest** + +`apps/api/package.json`: + +```json +{ + "name": "@solelog/api", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@libsql/client": "^0.14.0", + "@solelog/shared": "workspace:*", + "better-auth": "^1.1.7", + "drizzle-orm": "^0.36.4", + "hono": "^4.6.14", + "zod": "^3.23.8" + }, + "devDependencies": { + "drizzle-kit": "^0.30.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 2: Install dependencies** + +Run: `yarn install` +Expected: resolves and installs the new api dependencies without errors. + +- [ ] **Step 3: Create the api tsconfig** + +`apps/api/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 4: Create the env reader** + +`apps/api/src/env.ts`: + +```ts +export const env = { + DATABASE_URL: process.env.DATABASE_URL ?? 'file:./data/app.db', + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? 'dev-insecure-secret-change-me', + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000', + PORT: Number(process.env.PORT ?? 3000), +}; +``` + +- [ ] **Step 5: Create the health route** + +`apps/api/src/routes/health.ts`: + +```ts +import { Hono } from 'hono'; +import { HealthResponse } from '@solelog/shared'; + +export const health = new Hono(); + +health.get('/health', (c) => { + const body: HealthResponse = { status: 'ok' }; + return c.json(body); +}); +``` + +- [ ] **Step 6: Create the app factory** + +`apps/api/src/app.ts`: + +```ts +import { Hono } from 'hono'; +import { health } from './routes/health'; + +export function createApp(): Hono { + const app = new Hono(); + app.route('/', health); + return app; +} +``` + +- [ ] **Step 7: Create the server entry** + +`apps/api/src/index.ts`: + +```ts +import { serve } from '@hono/node-server'; +import { createApp } from './app'; +import { env } from './env'; + +const app = createApp(); +serve({ fetch: app.fetch, port: env.PORT }, (info) => { + console.log(`API listening on http://localhost:${info.port}`); +}); +``` + +- [ ] **Step 8: Create the vitest config** + +`apps/api/vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./test/setup.ts'], + fileParallelism: false, + }, +}); +``` + +- [ ] **Step 9: Create a no-op setup (real DB setup arrives in Task 3)** + +`apps/api/test/setup.ts`: + +```ts +// Placeholder until Task 3 adds DB migration to the test setup. +export {}; +``` + +- [ ] **Step 10: Write the failing health test** + +`apps/api/test/health.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; + +describe('GET /health', () => { + it('returns { status: "ok" }', async () => { + const app = createApp(); + const res = await app.request('/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); +}); +``` + +- [ ] **Step 11: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test` +Expected: PASS (1 test). If `createApp` or `health` is missing, fix the files above. + +- [ ] **Step 12: Commit** + +```bash +git add apps/api yarn.lock +git commit -m "feat(api): Hono backend skeleton with /health endpoint and test" +``` + +--- + +## Task 3: Database — Drizzle + libsql + better-auth schema + migrations + +**Files:** +- Create: `apps/api/src/db/schema.ts` +- Create: `apps/api/src/db/client.ts` +- Create: `apps/api/drizzle.config.ts` +- Create: `apps/api/src/db/migrate.ts` +- Modify: `apps/api/test/setup.ts` +- Test: `apps/api/test/db.test.ts` + +**Interfaces:** +- Produces: `db` (drizzle client) from `apps/api/src/db/client.ts`; the better-auth tables `user`, `session`, `account`, `verification` from `apps/api/src/db/schema.ts`; `runMigrations()` from `apps/api/src/db/migrate.ts` (consumed by the test setup and the Docker start). + +- [ ] **Step 1: Define the better-auth Drizzle schema** + +`apps/api/src/db/schema.ts` (this is better-auth's standard core schema for SQLite): + +```ts +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; + +export const user = sqliteTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const session = sqliteTable('session', { + id: text('id').primaryKey(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + token: text('token').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), +}); + +export const account = sqliteTable('account', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const verification = sqliteTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }), + updatedAt: integer('updated_at', { mode: 'timestamp' }), +}); +``` + +> **Verification (do this during execution):** run `npx @better-auth/cli@latest generate --config src/auth.ts` after Task 4 and confirm the generated schema matches the columns above. If better-auth's installed version differs, adopt the generated schema (it is authoritative). + +- [ ] **Step 2: Create the drizzle client** + +`apps/api/src/db/client.ts`: + +```ts +import { drizzle } from 'drizzle-orm/libsql'; +import { createClient } from '@libsql/client'; +import { env } from '../env'; +import * as schema from './schema'; + +const client = createClient({ url: env.DATABASE_URL }); +export const db = drizzle(client, { schema }); +``` + +- [ ] **Step 3: Create the drizzle-kit config** + +`apps/api/drizzle.config.ts`: + +```ts +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DATABASE_URL ?? 'file:./data/app.db', + }, +}); +``` + +- [ ] **Step 4: Generate the initial migration** + +Run: `yarn workspace @solelog/api db:generate` +Expected: a `apps/api/drizzle/0000_*.sql` file is created containing `CREATE TABLE` statements for `user`, `session`, `account`, `verification`. + +- [ ] **Step 5: Create the migrate runner** + +`apps/api/src/db/migrate.ts`: + +```ts +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import { createClient } from '@libsql/client'; +import { env } from '../env'; + +export async function runMigrations(): Promise { + const client = createClient({ url: env.DATABASE_URL }); + const db = drizzle(client); + await migrate(db, { migrationsFolder: './drizzle' }); + client.close(); +} + +// Allow running directly: `tsx src/db/migrate.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + runMigrations() + .then(() => { + console.log('Migrations applied.'); + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} +``` + +- [ ] **Step 6: Wire migrations into the test setup** + +Replace `apps/api/test/setup.ts` with: + +```ts +import { beforeAll } from 'vitest'; +import { rmSync, mkdirSync } from 'node:fs'; + +// Use a dedicated, freshly-migrated file DB for the test run. +process.env.DATABASE_URL = 'file:./.tmp/test.db'; +process.env.BETTER_AUTH_SECRET = 'test-secret'; +process.env.BETTER_AUTH_URL = 'http://localhost:3000'; + +beforeAll(async () => { + rmSync('./.tmp', { recursive: true, force: true }); + mkdirSync('./.tmp', { recursive: true }); + const { runMigrations } = await import('../src/db/migrate'); + await runMigrations(); +}); +``` + +- [ ] **Step 7: Ignore the test/db scratch dirs** + +Append to root `.gitignore`: + +``` +# api local data +apps/api/.tmp/ +apps/api/data/ +``` + +- [ ] **Step 8: Write the DB smoke test** + +`apps/api/test/db.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { db } from '../src/db/client'; +import { user } from '../src/db/schema'; + +describe('database', () => { + it('can query the migrated user table (empty)', async () => { + const rows = await db.select().from(user); + expect(rows).toEqual([]); + }); +}); +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `yarn workspace @solelog/api test` +Expected: PASS (health + db). The migration runs against `./.tmp/test.db` in setup; the query returns an empty array. + +- [ ] **Step 10: Commit** + +```bash +git add apps/api .gitignore yarn.lock +git commit -m "feat(api): Drizzle + libsql DB layer with better-auth schema and migrations" +``` + +--- + +## Task 4: better-auth wired into Hono (sign-up / sign-in) + +**Files:** +- Create: `apps/api/src/auth.ts` +- Modify: `apps/api/src/app.ts` +- Test: `apps/api/test/auth.test.ts` (sign-up + sign-in only; protected route in Task 5) + +**Interfaces:** +- Consumes: `db` and `schema` (Task 3). +- Produces: `auth` (better-auth instance) from `apps/api/src/auth.ts`; mounted handler at `ALL /api/auth/*`. + +- [ ] **Step 1: Create the better-auth config** + +`apps/api/src/auth.ts`: + +```ts +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { bearer } from 'better-auth/plugins'; +import { db } from './db/client'; +import * as schema from './db/schema'; +import { env } from './env'; + +export const auth = betterAuth({ + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, + trustedOrigins: [env.BETTER_AUTH_URL, 'http://localhost:3000'], + database: drizzleAdapter(db, { provider: 'sqlite', schema }), + emailAndPassword: { + enabled: true, + requireEmailVerification: false, + }, + plugins: [bearer()], +}); +``` + +- [ ] **Step 2: Mount the auth handler in the app** + +Modify `apps/api/src/app.ts` to: + +```ts +import { Hono } from 'hono'; +import { health } from './routes/health'; +import { auth } from './auth'; + +export function createApp(): Hono { + const app = new Hono(); + app.route('/', health); + app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); + return app; +} +``` + +- [ ] **Step 3: Write the failing sign-up / sign-in test** + +`apps/api/test/auth.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; + +const json = { 'content-type': 'application/json' }; + +describe('auth', () => { + it('signs a user up and signs them in, returning a bearer token', async () => { + const app = createApp(); + const creds = { email: 'worker@example.com', password: 'sterk-wachtwoord-123', name: 'Worker' }; + + const signup = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify(creds), + }); + expect(signup.status).toBe(200); + + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email: creds.email, password: creds.password }), + }); + expect(signin.status).toBe(200); + + const token = signin.headers.get('set-auth-token'); + expect(token).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test test/auth.test.ts` +Expected: PASS. The `set-auth-token` response header is the bearer plugin's token. +If the header is absent, confirm the `bearer()` plugin is registered and check the installed better-auth docs for the token-exposure header name; adjust the assertion to match. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api yarn.lock +git commit -m "feat(api): mount better-auth (email+password + bearer) on /api/auth" +``` + +--- + +## Task 5: Protected route + full auth round-trip (the "done when") + +**Files:** +- Create: `apps/api/src/routes/me.ts` +- Modify: `apps/api/src/app.ts` +- Test: `apps/api/test/me.test.ts` + +**Interfaces:** +- Consumes: `auth` (Task 4), `MeResponse` from `@solelog/shared`. +- Produces: `GET /api/me` — returns `401` without a valid token, `200` with `{ user }` when the bearer token is valid. + +- [ ] **Step 1: Create the protected route** + +`apps/api/src/routes/me.ts`: + +```ts +import { Hono } from 'hono'; +import type { MeResponse } from '@solelog/shared'; +import { auth } from '../auth'; + +export const me = new Hono(); + +me.get('/api/me', async (c) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (!session) { + return c.json({ error: 'Unauthorized' }, 401); + } + const body: MeResponse = { + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + }, + }; + return c.json(body); +}); +``` + +- [ ] **Step 2: Mount the route** + +Modify `apps/api/src/app.ts` to add the `me` route (full file): + +```ts +import { Hono } from 'hono'; +import { health } from './routes/health'; +import { me } from './routes/me'; +import { auth } from './auth'; + +export function createApp(): Hono { + const app = new Hono(); + app.route('/', health); + app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); + app.route('/', me); + return app; +} +``` + +- [ ] **Step 3: Write the failing round-trip test** + +`apps/api/test/me.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createApp } from '../src/app'; + +const json = { 'content-type': 'application/json' }; + +describe('GET /api/me', () => { + it('rejects an unauthenticated request', async () => { + const app = createApp(); + const res = await app.request('/api/me'); + expect(res.status).toBe(401); + }); + + it('returns the user for a valid bearer token (sign-up -> sign-in -> me)', async () => { + const app = createApp(); + const creds = { email: 'me@example.com', password: 'sterk-wachtwoord-123', name: 'Me' }; + + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: json, + body: JSON.stringify(creds), + }); + const signin = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: json, + body: JSON.stringify({ email: creds.email, password: creds.password }), + }); + const token = signin.headers.get('set-auth-token'); + + const res = await app.request('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.user.email).toBe(creds.email); + }); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test` +Expected: PASS (health, db, auth, me — all green). This proves the Phase 0 "done when" criterion in code. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api +git commit -m "feat(api): add protected GET /api/me and full auth round-trip test" +``` + +--- + +## Task 6: Dockerize the backend + compose + run docs + +**Files:** +- Create: `apps/api/Dockerfile` +- Create: `apps/api/.env.example` +- Create: `docker-compose.yml` (repo root) +- Create: `apps/api/.dockerignore` +- Modify: `docs/README.md` (run instructions — create if absent) + +**Interfaces:** +- Produces: `docker compose up` serving the API on `localhost:3000` with a persisted SQLite volume and migrations applied at start. + +- [ ] **Step 1: Create the env example** + +`apps/api/.env.example`: + +``` +DATABASE_URL=file:./data/app.db +BETTER_AUTH_SECRET=change-me-to-a-long-random-string +BETTER_AUTH_URL=http://localhost:3000 +PORT=3000 +``` + +- [ ] **Step 2: Create the dockerignore** + +`apps/api/.dockerignore`: + +``` +node_modules +.tmp +data +drizzle/meta +``` + +- [ ] **Step 3: Create the Dockerfile** + +`apps/api/Dockerfile` (multi-arch friendly; runs migrations then starts): + +```dockerfile +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /repo + +# Copy workspace manifests for cached install +COPY package.json yarn.lock .yarnrc.yml ./ +COPY .yarn/ ./.yarn/ +COPY packages/shared/package.json ./packages/shared/package.json +COPY apps/api/package.json ./apps/api/package.json +# Copy the other workspace manifests too, so the workspace graph matches the +# lockfile (otherwise Yarn errors on a mismatch). `focus` still installs ONLY +# the @solelog/api subtree, so mobile/web deps are not pulled. +COPY apps/mobile/package.json ./apps/mobile/package.json +COPY apps/web/package.json ./apps/web/package.json + +RUN yarn workspaces focus @solelog/api + +# Copy sources +COPY packages/shared/ ./packages/shared/ +COPY apps/api/ ./apps/api/ + +WORKDIR /repo/apps/api +ENV NODE_ENV=production +EXPOSE 3000 + +# Apply migrations, then start the server +CMD ["sh", "-c", "yarn db:migrate && yarn start"] +``` + +> **Note:** `yarn workspaces focus` requires the `plugin-workspace-tools` (bundled with Yarn 4). If it is unavailable, substitute `RUN yarn install --immutable`. + +- [ ] **Step 4: Create the compose file** + +`docker-compose.yml` (repo root): + +```yaml +services: + api: + build: + context: . + dockerfile: apps/api/Dockerfile + ports: + - '3000:3000' + environment: + DATABASE_URL: file:/data/app.db + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-change-me-to-a-long-random-string} + BETTER_AUTH_URL: http://localhost:3000 + PORT: '3000' + volumes: + - solelog_db:/data + +volumes: + solelog_db: +``` + +- [ ] **Step 5: Build and start the stack** + +Run: `docker compose up --build -d` +Expected: the `api` service builds and starts; `docker compose logs api` shows "Migrations applied." then "API listening on http://localhost:3000". + +- [ ] **Step 6: Smoke-test the running container** + +Run: `curl -s http://localhost:3000/health` +Expected: `{"status":"ok"}` + +Run (full round-trip against the container): + +```bash +curl -s -X POST http://localhost:3000/api/auth/sign-up/email \ + -H 'content-type: application/json' \ + -d '{"email":"smoke@example.com","password":"sterk-wachtwoord-123","name":"Smoke"}' + +TOKEN=$(curl -s -D - -o /dev/null -X POST http://localhost:3000/api/auth/sign-in/email \ + -H 'content-type: application/json' \ + -d '{"email":"smoke@example.com","password":"sterk-wachtwoord-123"}' \ + | grep -i '^set-auth-token:' | awk '{print $2}' | tr -d '\r') + +curl -s http://localhost:3000/api/me -H "authorization: Bearer $TOKEN" +``` + +Expected: the final call returns `{"user":{...,"email":"smoke@example.com"}}`, confirming the token authorises `/api/me`. + +- [ ] **Step 7: Tear down** + +Run: `docker compose down` +Expected: container stops; the `solelog_db` volume persists for next time. + +- [ ] **Step 8: Write run instructions** + +Create `docs/README.md`: + +```markdown +# SoleLog docs + +- [Roadmap & project overview](./roadmap.md) +- [Plans](./plans/) + +## Running the backend (Phase 0) + +Local (dev): +\`\`\`bash +yarn install +cp apps/api/.env.example apps/api/.env # edit BETTER_AUTH_SECRET +yarn workspace @solelog/api db:migrate +yarn workspace @solelog/api dev +\`\`\` + +Docker (whole stack): +\`\`\`bash +docker compose up --build +# health: curl http://localhost:3000/health +\`\`\` +``` + +- [ ] **Step 9: Commit** + +```bash +git add apps/api/Dockerfile apps/api/.env.example apps/api/.dockerignore docker-compose.yml docs/README.md +git commit -m "feat(api): dockerize backend with compose, migrations-on-start, and run docs" +``` + +--- + +## Definition of Done (Phase 0) + +- `yarn workspace @solelog/api test` is green (health, db, auth, me). +- `docker compose up --build` serves `/health` and the sign-up → sign-in → `/api/me` round-trip works against the container. +- The inherited `apps/mobile` and `apps/web` are untouched. +- All work committed; `docs/roadmap.md` and this plan are tracked. + +## Notes for the executor (version-drift safety) + +better-auth, Drizzle, and drizzle-kit evolve quickly. Three checkpoints where the +installed version is authoritative over this plan's sample code: +1. **Schema (Task 3):** confirm with `npx @better-auth/cli@latest generate`. +2. **Bearer token header (Tasks 4–5):** the assertion reads `set-auth-token`; verify against the installed bearer-plugin docs. +3. **drizzle-kit config (Task 3):** if `dialect: 'sqlite'` rejects the libsql `file:` URL, switch to `dialect: 'turso'` with `dbCredentials.url`. +The TDD loop (write test → run → fix) will surface any mismatch immediately. +``` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..27b246f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,200 @@ +# Insole Production Time Tracker — Rebuild Roadmap & Project Overview + +- **Created:** 2026-06-17 +- **Status:** Approved — living project doc; Phase 0 plan written (`docs/plans/phase-0-foundation.md`) +- **Type:** Greenfield rebuild of an inherited app +- **Tracked in git** under `docs/` (the project's documentation source of truth). + +--- + +## 1. Context + +The codebase was inherited as an export from the **Create / Anything AI** platform — a +working time-tracking app for **insole (orthotic) production**, originally built by a +friend through a chat-driven workflow. It arrived with no history, no documentation, and +no clarity on what was complete or where the backend/data lived. + +Reverse-engineering established: + +- It is a **two-app monorepo**: `apps/mobile` (Expo/React Native) + `apps/web` + (Next.js 16 backend on Neon Postgres). Plus `publisher/` (deploy tooling). +- It is **heavily coupled to the Create platform**: "do-not-edit" platform files, + patched dependencies, a web-sandbox iframe layer, and ~80 dependencies for what is + functionally a stopwatch. +- **Auth** is `better-auth` (email+password, argon2 hashes, bearer tokens for mobile, + cookies for web), but the core flow does not actually require login. +- **The data and accounts are not ours.** Users, password hashes, and all task/time + data live in the platform's managed Neon Postgres, bound to the friend's Create + account/organization. This repo contains only the *code* and an + `ANYTHING_PROJECT_TOKEN` — no database credentials. Nothing runs locally and no + database is reachable from this code. + +Work already done this session (committed): + +- Git repository initialised; baseline + doc corrections committed. +- Found and fixed `/api/logs` — it was both git-ignored (an over-broad `logs` rule) and + broken at runtime (missing its `import sql`). That route is History + Stop&Save. +- Wrote a reverse-engineered `schema.sql` (no migration shipped). +- Installed dependencies (~1.7 GiB) and confirmed Docker is available. + +> The existing code is treated as a **working reference / seed**, not the foundation. + +## 2. Vision + +A **multi-user, backend-driven shop-floor time-tracking system**. The phone is one of +several clients, not the source of truth. + +A worker, on **their own phone with their own account**, logs in → goes to a workbench → +**scans it (QR) or selects manually** → picks the activity → times the work → ends it → +it is saved → repeats. Because the **backend is the source of truth**: + +- An active session lives **server-side**, so if the phone dies the worker can **end or + log the work from a computer** instead. +- There is a **manual-entry fallback** wherever something can fail. +- An **admin panel** shows live who-is-working-on-what, generates reports, and manages + users and activities. + +Goals: it must **genuinely work in the workshop** (reliability, no data loss) *and* serve +as a **learning vehicle** the owner extends himself. + +## 3. Decisions (resolved during brainstorming) + +| # | Decision | Choice | +|---|---|---| +| 1 | Purpose | Real workshop tool **and** a learning vehicle | +| 2 | Platform relationship | **Clean break** from Create/Anything | +| 3 | Hosting | **Dockerized** stack; runs locally now, portable to any cloud later | +| 4 | Build strategy | **Greenfield rebuild**, porting only the good parts | +| 5 | Backend topology | **Dedicated backend service** (Option A): single owner of auth + DB; UIs are interchangeable clients | +| 6 | Auth | **better-auth** (don't hand-roll security for real use) | +| 7 | Database | **SQLite** (small userbase; one file, easy backup/Docker) | +| 8 | Language | **TypeScript everywhere** so types flow end-to-end | + +## 4. Architecture + +``` +┌─────────────┐ ┌─────────────┐ +│ Mobile app │ │ Admin web │ clients — UI only, no DB access +│ (Expo/RN) │ │ (React SPA) │ +└──────┬──────┘ └──────┬──────┘ + │ HTTP (Bearer) │ HTTP (cookie/Bearer) + └─────────┬─────────┘ + ▼ + ┌──────────────────┐ + │ Backend service │ the ONLY thing that touches auth + DB + │ better-auth + │ + │ business logic │ + └────────┬─────────┘ + ▼ + ┌─────────┐ + │ SQLite │ single file on a Docker volume + └─────────┘ +``` + +**Auth flow** +- **Mobile** — native email+password screen → `POST /api/auth/sign-in/email` → backend + returns a bearer **token** → stored in Expo SecureStore → sent as + `Authorization: Bearer ` on every request. (Drops the current app's embedded + login WebView.) +- **Admin** — standard better-auth **session cookies**. +- **Backend** — mounts better-auth, owns all DB access, enforces roles. + +## 5. Tech stack (recommended picks, approved) + +| Layer | Pick | Notable alternative | +|---|---|---| +| Backend framework | **Hono** | Fastify | +| Auth | **better-auth** | — | +| DB access + migrations | **Drizzle ORM** | Kysely / Prisma | +| Database | **SQLite** | libsql/Turso (if cloud later) | +| Admin UI | **Vite + React** (SPA) | Next.js (if SSR wanted) | +| Mobile | **Expo / React Native** | — | +| Shared contracts | **`packages/shared`** (TS types + zod) | — | + +## 6. Monorepo shape + +``` +apps/ + mobile/ Expo worker app (client) + admin/ Vite + React panel (client) + api/ Hono + better-auth + Drizzle + SQLite (backend service) +packages/ + shared/ TS types + zod schemas (API contracts) +docker-compose.yml + Dockerfiles +``` + +## 7. Data model + +Server-authoritative; an open session (`end_time` null) is "active work". + +- **users** — id, email, name, password hash (better-auth managed), **role** (`worker` | `admin`) +- **workbenches** — id, name, `qr_code` (seeded / hardcoded for now) +- **activities** — id, name, `insole_types[]` (subset of `Kurk` | `Berk` | `3D`) +- **sessions** — id, `user_id`, `activity_id`, `workbench_id`, `insole_type`, + `pair_count`, `start_time`, `end_time` (null = running), + `status` (`active` | `completed` | `discarded`), + `source` (`app` | `manual`), `notes`, `created_at` + +(better-auth also creates its own `session`/`account`/`verification` tables; those are +distinct from the domain `sessions` table above — naming to be disambiguated in Phase 0, +e.g. `work_sessions`.) + +## 8. Port vs. leave behind + +**Port (the good parts):** the data model, the API route logic, the screen flows/UX, the +CSV export logic, the Dutch UI strings, and the lessons captured in the current code's +comments (Android font/freeze fixes, SecureStore quirks). + +**Leave behind:** the `__create` plumbing, the web-sandbox iframe layer, analytics / +Sentry / anything-menu, the patched dependencies, and the ~60 unused libraries (ads, IAP, +maps, 3D, audio, calendar, contacts, sensors, …). + +## 9. Phased roadmap + +Each phase keeps the system working and is its own spec → plan → build cycle. + +- **Phase 0 — Foundation.** Greenfield monorepo; dockerized Hono backend with better-auth + + Drizzle + SQLite; `docker compose up` brings it up; health check + one auth + round-trip proven end-to-end. *Done when:* a client can sign in against the + containerised backend and the token authorises a protected call. +- **Phase 1 — Worker timing.** Activities + server-authoritative work-sessions + (open/close) + history + CSV export in the backend; mobile app rebuilt to use it. + *Done when:* a worker can pick an activity, start/stop a server-side session, and see + history; CSV exports. +- **Phase 2 — Accounts & roles.** Worker/admin roles, admin-creates-users, per-user data + scoping. *Done when:* workers see only their own sessions; an admin account exists. +- **Phase 3 — Admin panel.** The React admin app: live active-work view, reports/export, + user management, **manual entry/edit (the fallback)**. *Done when:* an admin can see + who's working now, manage users, and hand-correct a session. +- **Phase 4 — Workbench scanning.** QR at the bench → select workbench/activity, with + manual selection fallback. *Done when:* scanning a bench QR pre-fills the session. +- **Phase 5 — Polish & deploy.** Reporting niceties, dependency slimming, push the + container to a chosen cloud host. + +## 10. Open questions & assumptions (confirm as we go) + +- **Scanning = QR codes** on benches, read by the phone camera (no special hardware), + always with manual fallback. *(Assumption.)* +- **Start fresh, no data migration.** Assumes no live workshop data/users worth + preserving in the friend's platform instance. If there are, they must be **exported + from the friend's Create account** — unreachable from this repo. +- **Workbench↔activity mapping** is hardcoded/seeded for now; real mapping TBD with the + friend. +- **Deployment host** is deferred (Phase 5); the dockerized stack keeps options open. +- **Domain term:** "insole" / orthotic; Dutch UI (`Type zool`, `handeling`, `aantal + zolen`, etc.). + +## 11. Risks + +- **Greenfield is more work than evolving in place** — mitigated by porting proven logic + and keeping the old code as a running reference. +- **SQLite is single-writer / single-instance** — fine at this scale; swap to + Postgres/libsql is a config change via Drizzle/better-auth if ever needed. +- **Offline on the shop floor** — the friend hit connectivity issues. Backend-as-source- + of-truth assumes reachable network; true offline-first is explicitly out of scope for + now (revisit if it bites). + +## 12. Next step + +Brainstorm **Phase 0** in detail, then hand it to the writing-plans skill for an +implementation plan. No code before that plan exists.