Shared

Dry-run an import file (admin)

post/api/data/preview

Reads an uploaded file, guesses the column alignment, validates every row against the real database, and writes nothing at all: not a client, not a booking, and not even the uploaded file.

Storing nothing is the decision worth knowing about. The obvious design uploads once and re-validates against the stored copy each time the operator changes a column; the cost is a bucket slowly filling with the abandoned first attempts of every venue that opened this screen and thought better of it, each one somebody's entire client list. The browser already holds the file, so it re-sends it.

Not gated on billing status, unlike export: importing is how a venue arrives, and refusing it during a trial would be refusing the customer.

A mapping sent back with the file WINS over the auto-guess, so an operator's corrections survive touching a second column. It is filtered against this file's real headers and this entity's real fields before use.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.

Every request re-validates it against the Auth server (getUser()), never by decoding the cookie locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified app_metadata.company_id claim and enforced by row-level security; it is never read from request input, on any route, ever.

role (admin / staff) is deliberately not in RLS. It gates specific actions in route code, the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's own check is the enforcement.

In: cookie

Request Body

multipart/form-data

The file plus what is in it. multipart/form-data.

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/api/data/preview" \  -F file="string" \  -F entity="clients"
{  "format": "csv",  "sourceName": "string",  "headers": [    "string"  ],  "sample": [    [      "string"    ]  ],  "fields": [    {      "key": "string",      "label": "string",      "required": true,      "hint": "string"    }  ],  "mapping": {    "property1": "string",    "property2": "string"  },  "missingRequired": [    "string"  ],  "summary": {}}

Start an export (admin, active subscription only) POST

Queues or runs an export of this org's clients or bookings. **THE PLAN BOUNDARY LIVES HERE**, and it is a SUBSCRIPTION-STATUS gate rather than a `PlanFeature` one, which no other route in this API does. `exportAccess()` in src/lib/plan.ts reads `book_billing.subscription_status` raw: `active` passes, `trialing` is refused, and everything else (`past_due`, `canceled`, or no billing row at all) is refused with different copy. A trial deliberately fails even though it grants every other paid feature, because taking the data out is not what a trial is for. The hub disables its own buttons for the same three states, but that is cosmetic in the sense require-member.ts means it. Small exports run inline and are downloadable the moment this returns (`queued: false`); anything over 50 rows becomes a `data_job` on `book_job_queue` for the worker to drain.

Commit an import (admin) POST

Stores the file in the private `data-imports` bucket, records the mapping the operator CONFIRMED, and either does the work inline (50 rows or fewer) or enqueues a `data_job` for the worker. The mapping is stored rather than re-derived, because the auto-mapper is a heuristic that will change: a job re-run six months from now must apply what a human approved, not what this build would guess today. The file is parsed here as well as in the runner. That is not waste: it is the only way to know the row count before choosing inline-vs-queued, and it means an unreadable file is a 400 the operator sees rather than a queued job failing somewhere they have to go looking for. **Not gated on billing status**, same reasoning as `/api/data/preview`.