544 lines
22 KiB
Markdown
544 lines
22 KiB
Markdown
# 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`).
|