# Legacy backend reference (`apps/web`) > Captured from the inherited Create / Anything export before the legacy code is removed. > Source of truth at capture time: `apps/web/src/app/api/*`, `apps/web/src/app/api/utils/sql.ts`, > `apps/web/src/lib/auth.ts`, `apps/web/db/schema.sql`. > > This is a **port reference** for the new `apps/api` (Hono + better-auth + Drizzle + libsql/SQLite) > backend. It records what the old Next.js 16 backend actually did, with real SQL/code quoted where > the wording is load-bearing. Nothing here prescribes the new implementation; it documents the > contract the mobile app (`apps/mobile`) was built against, so behaviour can be preserved. --- ## 1. Overview The legacy backend was a **Next.js 16 App Router** app whose only real job was to serve the `/api/*` HTTP surface that `apps/mobile` calls. The DB was **Neon serverless Postgres**, reached **only** from `apps/web` via `DATABASE_URL`. Auth was **better-auth** (cookie sessions for web, `Authorization: Bearer ` for mobile). Two app tables: `production_tasks` and `time_logs`. Auth tables (better-auth user/session/account/ verification) were managed separately by the better-auth CLI and are **not** in the repo schema. A worker picks an insole type (`Type zool`: Kurk / Berk / 3D), a handling/task, and a count (`Aantal zolen`, default 2), runs a stopwatch, and on stop the session is POSTed to `/api/logs`. History reads `GET /api/logs`; CSV comes from `GET /api/export`; settings manage tasks via `/api/tasks*`. --- ## 2. Data model Reverse-engineered schema (`apps/web/db/schema.sql` — there was no shipped migration). Postgres dialect. Note the new backend is SQLite, so `text[]`, `GENERATED ALWAYS AS IDENTITY`, and `timestamptz` will need equivalents (e.g. JSON/CSV text column for `insole_types`, integer PK autoincrement, ISO-8601 text or epoch for timestamps). ### `production_tasks` — the handelingen (tasks) per zooltype | Column | Type | Notes | |---|---|---| | `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK | | `name` | `text NOT NULL` | task / handeling name | | `insole_types` | `text[] NOT NULL DEFAULT ARRAY['Kurk','Berk','3D']::text[]` | subset of `Kurk` / `Berk` / `3D`; which zooltypes this task applies to | | `created_at` | `timestamptz NOT NULL DEFAULT now()` | | ```sql CREATE TABLE IF NOT EXISTS production_tasks ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, insole_types text[] NOT NULL DEFAULT ARRAY['Kurk', 'Berk', '3D']::text[], created_at timestamptz NOT NULL DEFAULT now() ); ``` ### `time_logs` — one finished stopwatch session | Column | Type | Notes | |---|---|---| | `id` | `integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY` | auto PK | | `task_id` | `integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE` | FK → `production_tasks` | | `start_time` | `timestamptz NOT NULL` | stopwatch start (ISO from client) | | `end_time` | `timestamptz` | nullable; stopwatch stop | | `duration_seconds` | `integer NOT NULL DEFAULT 0` | elapsed seconds (client-counted, not derived from start/end) | | `pair_count` | `integer NOT NULL DEFAULT 2` | "number of insoles" for the session (`Aantal zolen`) | | `insole_type` | `text` | nullable; `'Kurk' \| 'Berk' \| '3D'` | | `notes` | `text` | nullable; never written by the mobile app | | `created_at` | `timestamptz NOT NULL DEFAULT now()` | | ```sql CREATE TABLE IF NOT EXISTS time_logs ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, task_id integer NOT NULL REFERENCES production_tasks(id) ON DELETE CASCADE, start_time timestamptz NOT NULL, end_time timestamptz, duration_seconds integer NOT NULL DEFAULT 0, pair_count integer NOT NULL DEFAULT 2, insole_type text, notes text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS time_logs_task_id_idx ON time_logs (task_id); CREATE INDEX IF NOT EXISTS time_logs_start_time_idx ON time_logs (start_time DESC); ``` ### Relationships & cascade behaviour - `time_logs.task_id → production_tasks.id`, `ON DELETE CASCADE` in the schema. - The legacy `DELETE /api/tasks/:id` route **does not rely on the cascade** — it deletes the logs first, explicitly, then the task (see §3). The new backend should reproduce this *behaviour* (deleting a task removes its logs) whether via FK cascade, an explicit delete, or both. - `duration_seconds` is authoritative for elapsed time — it is the client's stopwatch counter, not `end_time - start_time`. Pause time means the two can differ. Preserve this: the CSV "Total Duration" formats `duration_seconds`, not the wall-clock delta. ### Default-value notes worth porting - A task with no/empty `insole_types` defaults to all three: `['Kurk','Berk','3D']` — enforced both in the DB default **and** in the route code (POST/PUT tasks). - On `POST /api/logs`, missing `pair_count` defaults to `2`, missing `insole_type` defaults to `'Kurk'`, missing `notes` defaults to `null` (route-level coalescing, see §3). --- ## 3. API surface All routes are Next.js App Router handlers under `apps/web/src/app/api/`. They return `Response.json(...)` (the export route returns `text/csv`). **None of the data routes check a session** — tasks/logs/export are open; only `/api/session` and `/api/auth/token` require auth. ### `GET /api/tasks` — list tasks - File: `api/tasks/route.ts` - Request: none. - Response: JSON array of full `production_tasks` rows (`SELECT *`), ordered by `name ASC`. - SQL: ```sql SELECT * FROM production_tasks ORDER BY name ASC ``` ### `POST /api/tasks` — create task - File: `api/tasks/route.ts` - Request body: `{ name: string, insole_types?: string[] }`. - Validation: `name` required → `400 { error: 'Name is required' }` if missing. - Default: empty/non-array `insole_types` → `['Kurk','Berk','3D']`. - Response: the inserted row (`RETURNING *`). - SQL: ```sql INSERT INTO production_tasks (name, insole_types) VALUES (${name}, ${types}) RETURNING * ``` ### `PUT /api/tasks/:id` — update task - File: `api/tasks/[id]/route.ts` - Request body: `{ name: string, insole_types?: string[] }`. - Validation: `name` required → `400`. Not found (no row updated) → `404 { error: 'Task not found' }`. - Default: same `insole_types` fallback as POST. - Response: the updated row (`RETURNING *`). - SQL: ```sql UPDATE production_tasks SET name = ${name}, insole_types = ${types} WHERE id = ${id} RETURNING * ``` ### `DELETE /api/tasks/:id` — delete task (and its logs) - File: `api/tasks/[id]/route.ts` - Request: none. - Response: `{ success: true }`. - Deletes logs first, then the task (two statements): ```sql DELETE FROM time_logs WHERE task_id = ${id} DELETE FROM production_tasks WHERE id = ${id} ``` ### `GET /api/logs` — history list - File: `api/logs/route.ts` - Request: none. - Response: JSON array, joined to task name, **newest first** (`start_time DESC`). - Fields returned: `id, task_name, task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes, created_at`. - SQL: ```sql SELECT tl.id, pt.name AS task_name, tl.task_id, tl.start_time, tl.end_time, tl.duration_seconds, tl.pair_count, tl.insole_type, tl.notes, tl.created_at FROM time_logs tl JOIN production_tasks pt ON tl.task_id = pt.id ORDER BY tl.start_time DESC ``` ### `POST /api/logs` — save a finished session - File: `api/logs/route.ts` - Request body (this is exactly what `apps/mobile` `(tabs)/index.tsx` sends on Stop & Save): ```json { "task_id": "", "start_time": "", "end_time": "", "duration_seconds": "", "pair_count": "", "insole_type": "<'Kurk'|'Berk'|'3D', optional, default 'Kurk'>", "notes": "" } ``` Mobile call site (`apps/mobile/src/app/(tabs)/index.tsx`) sends `task_id, start_time, end_time, duration_seconds, pair_count, insole_type` — `notes` is never sent by the client. - Validation: requires `task_id`, `start_time`, `end_time`, and `duration_seconds !== undefined`, else `400 { error: 'Missing required fields' }`. (Note: `duration_seconds: 0` is valid — the check is `=== undefined`, not falsy.) - Defaults applied in the route: `pair_count ?? 2`, `insole_type ?? 'Kurk'`, `notes ?? null`. - Response: the inserted row (`RETURNING *`). - SQL: ```sql INSERT INTO time_logs (task_id, start_time, end_time, duration_seconds, pair_count, insole_type, notes) VALUES (${task_id}, ${start_time}, ${end_time}, ${duration_seconds}, ${pair_count ?? 2}, ${insole_type ?? 'Kurk'}, ${notes ?? null}) RETURNING * ``` ### `GET /api/export` — CSV download - File: `api/export/route.ts` - Detailed in §4 below. ### `GET /api/session` — current session (auth) - File: `api/session/route.ts` - Reads the better-auth session from request headers; `401 { error: 'Unauthorized' }` if none. - Response: `{ user: session.user, session: session.session }`. - Code: ```ts const session = await auth.api.getSession({ headers: request.headers }); if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ user: session.user, session: session.session }); ``` ### Auth routes (`/api/auth/*`) — platform-managed These carry `⚠ ANYTHING PLATFORM — DO NOT REWRITE` headers; their contracts are consumed by the mobile `AuthWebView`. Summarised for porting; see §5 for the better-auth config. - **`GET|POST /api/auth/[...all]`** — better-auth catch-all via `toNextJsHandler(auth)`. Wires every better-auth endpoint (`/sign-up/email`, `/sign-in/email`, `/get-session`, …). Do not hand-roll these paths. - **`GET /api/auth/token`** — mobile token exchange. `401` if no session; else returns the exact shape the mobile `AuthWebView.tsx` parses: ```json { "jwt": "", "user": { "id": "...", "email": "...", "name": "..." } } ``` - **`GET /api/auth/expo-web-success`** — Expo-web postMessage bridge. Returns an **HTML page** (not JSON) whose inline script posts to `window.parent`: - success: `{ type: 'AUTH_SUCCESS', jwt: , user: { id, email, name } }` - failure: `{ type: 'AUTH_ERROR', error: 'Unauthorized' }` The mobile iframe (`apps/mobile/src/utils/auth/AuthWebView.tsx`) loads `${proxyURL}/account/?callbackUrl=/api/auth/expo-web-success` and listens for exactly that message shape. - **`GET /api/__create/check-social-secrets?provider=google|apple`** — dev-only helper. `404` unless `NEXT_PUBLIC_CREATE_ENV === 'DEVELOPMENT'`; `400` on unknown provider; else `{ provider, missing: string[] }` listing which OAuth env vars are unset. Builder-preview only, almost certainly not worth porting. ### Error handling pattern (all data routes) Every route wraps its body in `try/catch`, `console.error(error)`, and returns a `500` with a route-specific message (`'Failed to fetch tasks'`, `'Failed to create task'`, `'Failed to update task'`, `'Failed to delete task'`, `'Failed to fetch logs'`, `'Failed to save log'`, `'Failed to export data'`). --- ## 4. CSV export format (`GET /api/export`) Returns `text/csv; charset=utf-8` with `Content-Disposition: attachment; filename="insole-production-report.csv"`. Source query — same join as `/api/logs` but ordered **`start_time ASC`** (oldest first, opposite of the history list): ```sql SELECT tl.id, pt.name as task_name, tl.start_time, tl.end_time, tl.duration_seconds, tl.pair_count, tl.insole_type, tl.notes FROM time_logs tl JOIN production_tasks pt ON tl.task_id = pt.id ORDER BY tl.start_time ASC ``` ### Columns (in order) | # | Header | Source / formatting | |---|---|---| | 1 | `ID` | `log.id` | | 2 | `Task` | `log.task_name` | | 3 | `Insole Type` | `log.insole_type ?? 'Kurk'` | | 4 | `No. of Insoles` | `log.pair_count ?? 2` | | 5 | `Date` | `start_time` → `toLocaleDateString('nl-BE', { day:'2-digit', month:'2-digit', year:'numeric' })` | | 6 | `Total Duration` | `duration_seconds` formatted `HH:MM:SS` (zero-padded; hours can exceed 99) | | 7 | `Start Time` | `start_time` → `toLocaleTimeString('nl-BE', { hour:'2-digit', minute:'2-digit', second:'2-digit' })` | | 8 | `End Time` | `end_time` → same time format, or `''` if `end_time` is null | Formatting details to preserve: - **Locale `nl-BE`** for both date and time. Date is `dd/mm/yyyy`, time is 24-hour `HH:MM:SS`. (Server-side `toLocale*` uses the server's timezone — a known fragility worth noting when porting; the new backend should decide explicitly which timezone to format in.) - **Duration** is computed from `duration_seconds`, **not** from `end_time - start_time`: ```ts const totalSeconds = log.duration_seconds || 0; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; const totalFormatted = `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(seconds).padStart(2,'0')}`; ``` - **Quoting:** every cell (and every header) is wrapped with a `quote()` that double-quotes the value and escapes embedded `"` by doubling it — standard CSV escaping: ```ts const quote = (value) => `"${String(value).replace(/"/g, '""')}"`; ``` - **Line endings:** rows joined with `\n` (LF, not CRLF). Header row first, then data rows. - No UTF-8 BOM is emitted (relevant if Excel mangles accented characters — consider adding one in the port if needed). --- ## 5. better-auth configuration (`apps/web/src/lib/auth.ts`) ⚠ Platform-managed file. The new backend uses better-auth too, so these specifics are the most directly portable. Each piece below is documented as load-bearing by the file's own header comment. ### DB connection Postgres via a `@neondatabase/serverless` `Pool` with the WebSocket constructor set to `ws`: ```ts neonConfig.webSocketConstructor = ws; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); // ... export const auth = betterAuth({ database: pool, /* ... */ }); ``` In the SQLite port this becomes the libsql/Drizzle adapter instead of a Neon `Pool`. ### Email + password, with argon2 compatibility ```ts emailAndPassword: { enabled: true, requireEmailVerification: false, password: { verify: verifyCompatiblePassword }, } ``` `verifyCompatiblePassword` falls back to **argon2** verification when a stored hash starts with `$argon2`, otherwise uses better-auth's default `verifyPassword`. This exists so users created under an older argon2-based scheme still authenticate: ```ts async function verifyCompatiblePassword({ hash, password }) { if (hash.startsWith('$argon2')) return argon2Verify({ hash, password }); return verifyPassword({ hash, password }); } ``` (Only relevant if migrating existing user rows; a greenfield user table can use better-auth's default scrypt hashing and drop this shim.) ### `hooks.before` — name backfill on signup (load-bearing) better-auth's `/sign-up/email` schema requires `name`. The mobile signup form collects only email+password, so a middleware backfills `name` from the email local-part. Removing this broke **every** signup with `[body.name]` validation errors (per the header comment): ```ts hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path !== '/sign-up/email') return; const body = ctx.body as { email?: unknown; name?: unknown } | undefined; if (!body || typeof body.email !== 'string') return; if (typeof body.name === 'string' && body.name.trim().length > 0) return; const derived = body.email.split('@')[0]; body.name = derived && derived.length > 0 ? derived : 'User'; }), } ``` **Port this** if the new signup UI likewise omits a name field. ### `bearer()` plugin — mobile token auth (load-bearing) ```ts plugins: [bearer()], ``` Enables `Authorization: Bearer ` so the mobile WebView (which can't carry cookies) authenticates API calls with the token from `/api/auth/token`. Required for mobile. ### Cookies — `sameSite:'none'` for iframes (load-bearing) Required for the Create preview / mobile iframe; do not change without understanding the iframe constraint: ```ts advanced: { cookiePrefix: 'better-auth', defaultCookieAttributes: { sameSite: 'none', // Required for iframes secure: true, httpOnly: true, path: '/', }, cookies: { sessionToken: { attributes: { sameSite: 'none', secure: true }, }, }, } ``` Note: `secure: true` + `sameSite:'none'` means cookies only flow over HTTPS. For local-only dev the new backend may need to relax this; weigh against the iframe requirement. ### Session cookie cache ```ts session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7 }, // 7 days } ``` ### `user.additionalFields` Adds an optional `image` string field to the user: ```ts user: { additionalFields: { image: { type: 'string', required: false } } } ``` ### `trustedOrigins` — CSRF allow-list (load-bearing) Built from env at startup, filtering out falsy entries. Every URL the app may be served under must be present or better-auth rejects the request as "Invalid origin": ```ts const trustedOrigins = [ process.env.BETTER_AUTH_URL, process.env.EXPO_PUBLIC_PROXY_BASE_URL, process.env.NEXT_PUBLIC_CREATE_BASE_URL, process.env.NEXT_PUBLIC_CREATE_HOST ? `https://${process.env.NEXT_PUBLIC_CREATE_HOST}` : null, ].filter((v): v is string => Boolean(v)); ``` ### `socialProviders` — self-activating Google / Apple Providers register only when their OAuth env vars are present (platform injects them when enabled in project settings). A provider with missing credentials is simply not registered, so its sign-in button never hits a half-configured backend. Apple additionally wires `appBundleIdentifier` (for native "Sign in with Apple") when `APPLE_APP_BUNDLE_IDENTIFIER` is set: ```ts const socialProviders = { ...(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET ? { google: { clientId, clientSecret } } : {}), ...(APPLE_CLIENT_ID && APPLE_CLIENT_SECRET ? { apple: { clientId, clientSecret, ...(APPLE_APP_BUNDLE_IDENTIFIER ? { appBundleIdentifier } : {}) }, } : {}), }; ``` (Probably not needed for a local SoleLog rebuild unless social login is a requirement.) ### Session type export ```ts export type Session = typeof auth.$Infer.Session; ``` ### Auth tables The better-auth user/session/account/verification tables are **not** in `db/schema.sql`. The legacy comment says to generate them with `npx @better-auth/cli migrate`. The new backend uses Drizzle, so these come from better-auth's Drizzle schema generation instead. --- ## 6. Parameterised tagged-template SQL pattern (`apps/web/src/app/api/utils/sql.ts`) The DB layer is a single default-exported Neon tagged template. **All interpolations in `` sql`...` `` are bind parameters, not string concatenation** — i.e. it is parameterised and safe against injection. Routes import it as `import sql from '@/app/api/utils/sql'`. ```ts import { neon, NeonQueryFunction } from '@neondatabase/serverless'; type SqlQueryFunction = NeonQueryFunction & { query: NeonQueryFunction; }; const NullishQueryFunction = (() => { throw new Error( 'No database connection string was provided to `neon()`. Perhaps process.env.DATABASE_URL has not been set' ); }) as any as SqlQueryFunction; NullishQueryFunction.transaction = (() => { throw new Error(/* same message */); }) as any as NeonQueryFunction['transaction']; NullishQueryFunction.query = NullishQueryFunction; const sql = ( process.env.DATABASE_URL ? neon(process.env.DATABASE_URL) : NullishQueryFunction ) as SqlQueryFunction; sql.query = sql; export default sql; ``` Key behaviours to preserve in the port: - **Lazy / forgiving init:** if `DATABASE_URL` is unset, `sql` is a stub that throws a clear, specific error *only when actually invoked* — importing the module never crashes at load time. Both `sql()` and `sql.transaction()` throw the same explanatory message. - **`sql.query = sql`:** the same function is exposed under `.query`, so call sites can use either `` sql`...` `` (tagged template) or `sql.query(...)`. Both forms are bind-parameterised. - **Array binding:** Postgres `text[]` columns are written by binding a JS `string[]` directly (e.g. `VALUES (${name}, ${types})` where `types: string[]`). The Neon driver maps the array to a Postgres array param. The SQLite/Drizzle port has no native array type — `insole_types` must be stored as JSON/CSV text (or a join table) and serialised at the boundary. - **Row access:** single-row inserts/updates use array destructuring on the result — `const [task] = await sql\`... RETURNING *\``; a missing row yields `undefined` (the basis for the `404` in `PUT /api/tasks/:id`). --- ## 7. Mobile consumer contract (for parity verification) What `apps/mobile` actually calls, so the port can be verified against real callers: - `GET /api/tasks` — Stopwatch (`(tabs)/index.tsx`) and Settings (`(tabs)/tasks.tsx`); Stopwatch filters by `t.insole_types.includes(insoleType)`. - `POST /api/tasks` body `{ name, insole_types }` — Settings add. - `PUT /api/tasks/:id` body `{ name, insole_types }` — Settings edit (id is a number). - `DELETE /api/tasks/:id` — Settings delete (also invalidates the `logs` query client-side). - `GET /api/logs` — History (`(tabs)/history.tsx`). - `POST /api/logs` body `{ task_id, start_time, end_time, duration_seconds, pair_count, insole_type }` — Stopwatch Stop & Save (`notes` omitted). - `GET /api/export` — History CSV download/share. - `GET /api/auth/token`, `GET /api/auth/expo-web-success`, `/api/auth/[...all]` — auth plumbing. All client calls go through the monkey-patched global `fetch` that rewrites `/api/...` onto `EXPO_PUBLIC_BASE_URL` and attaches the Bearer JWT (see mobile `src/__create/fetch.ts`).