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 CASCADEin the schema.- The legacy
DELETE /api/tasks/:idroute 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_secondsis authoritative for elapsed time — it is the client's stopwatch counter, notend_time - start_time. Pause time means the two can differ. Preserve this: the CSV "Total Duration" formatsduration_seconds, not the wall-clock delta.
Default-value notes worth porting
- A task with no/empty
insole_typesdefaults to all three:['Kurk','Berk','3D']— enforced both in the DB default and in the route code (POST/PUT tasks). - On
POST /api/logs, missingpair_countdefaults to2, missinginsole_typedefaults to'Kurk', missingnotesdefaults tonull(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_tasksrows (SELECT *), ordered byname 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:
namerequired →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:
namerequired →400. Not found (no row updated) →404 { error: 'Task not found' }. - Default: same
insole_typesfallback 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.tsxsends on Stop & Save):Mobile call site ({ "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>" }apps/mobile/src/app/(tabs)/index.tsx) sendstask_id, start_time, end_time, duration_seconds, pair_count, insole_type—notesis never sent by the client. - Validation: requires
task_id,start_time,end_time, andduration_seconds !== undefined, else400 { error: 'Missing required fields' }. (Note:duration_seconds: 0is 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 viatoNextJsHandler(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.401if no session; else returns the exact shape the mobileAuthWebView.tsxparses:{ "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 towindow.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-successand listens for exactly that message shape.
- success:
GET /api/__create/check-social-secrets?provider=google|apple— dev-only helper.404unlessNEXT_PUBLIC_CREATE_ENV === 'DEVELOPMENT';400on 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_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-BEfor both date and time. Date isdd/mm/yyyy, time is 24-hourHH:MM:SS. (Server-sidetoLocale*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 fromend_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 cookie cache
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_URLis unset,sqlis a stub that throws a clear, specific error only when actually invoked — importing the module never crashes at load time. Bothsql()andsql.transaction()throw the same explanatory message. sql.query = sql: the same function is exposed under.query, so call sites can use eithersql`...`(tagged template) orsql.query(...). Both forms are bind-parameterised.- Array binding: Postgres
text[]columns are written by binding a JSstring[]directly (e.g.VALUES (${name}, ${types})wheretypes: string[]). The Neon driver maps the array to a Postgres array param. The SQLite/Drizzle port has no native array type —insole_typesmust 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 yieldsundefined(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 byt.insole_types.includes(insoleType).POST /api/tasksbody{ name, insole_types }— Settings add.PUT /api/tasks/:idbody{ name, insole_types }— Settings edit (id is a number).DELETE /api/tasks/:id— Settings delete (also invalidates thelogsquery client-side).GET /api/logs— History ((tabs)/history.tsx).POST /api/logsbody{ task_id, start_time, end_time, duration_seconds, pair_count, insole_type }— Stopwatch Stop & Save (notesomitted).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).