Files
solelog/docs/reference/legacy-backend.md

22 KiB

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()
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()
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:
    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:
    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:
    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):
    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:
    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):
    {
      "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_typenotes 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:
    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:
    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:
    { "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):

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_timetoLocaleDateString('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_timetoLocaleTimeString('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:
    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:
    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:

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

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:

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):

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)

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:

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: {
  cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7 }, // 7 days
}

user.additionalFields

Adds an optional image string field to the user:

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":

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:

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

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'.

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 the404inPUT /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).