docs: extract port-worthy reference from legacy code before full cleanup

This commit is contained in:
Bas van Rossem
2026-06-17 14:35:00 +02:00
parent 3f2c5f0179
commit c72086550d
3 changed files with 1384 additions and 0 deletions

View File

@@ -0,0 +1,543 @@
# 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 <token>` 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": "<int>",
"start_time": "<ISO 8601>",
"end_time": "<ISO 8601>",
"duration_seconds": "<int, client stopwatch count>",
"pair_count": "<int, optional, default 2>",
"insole_type": "<'Kurk'|'Berk'|'3D', optional, default 'Kurk'>",
"notes": "<string|null, optional, default null>"
}
```
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": "<session.token>", "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: <session.token>, user: { id, email, name } }`
- failure: `{ type: 'AUTH_ERROR', error: 'Unauthorized' }`
The mobile iframe (`apps/mobile/src/utils/auth/AuthWebView.tsx`) loads
`${proxyURL}/account/<mode>?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 <session-token>` 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<false, false> & {
query: NeonQueryFunction<false, false>;
};
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<false, false>['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`).