Attest or withdraw the pre-existing-list marketing declaration (admin, or manage_org_settings)
/api/marketing-consentWrites book_companies' marketing_attested_at/marketing_attested_by/marketing_attested_text (migration 0143). Deliberately its own route, not folded into PATCH /api/companies or PATCH /api/booking-policy: these three columns carry NO grant to authenticated at all, being a legal declaration about permission to contact other people, so a column grant would let any staff login mint or withdraw it via raw PostgREST (0143's own comment on that column group). Written with service-role, past this route's own admin gate, the same posture booking-policy.ts already uses for its own ungranted columns.
{ action: "attest" } sets marketing_attested_at to now, marketing_attested_by to the caller's user id, and snapshots MARKETING_ATTESTATION_TEXT (consent.ts) into marketing_attested_text verbatim, so the string an admin agreed to and the string kept on file are the same value by construction. { action: "withdraw" } clears marketing_attested_at back to null and leaves marketing_attested_by/marketing_attested_text alone as a record of the last declaration made: the audience rule (canReceiveMarketing, consent.ts) is time-scoped by attested_at alone, so this one flip instantly narrows the emailable audience back to explicit opt-ins for every customer at once, with nothing to backfill on the book_customers side.
The widget-visibility toggle (booking_marketing_optin_enabled) is a SEPARATE column with its own grant to authenticated, and goes through the ordinary PATCH /api/companies save instead, since it is a display setting, not itself a consent record, so it does not belong on this route.
Authorization
sessionCookie 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
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X PATCH "https://example.com/api/marketing-consent" \ -H "Content-Type: application/json" \ -d '{ "action": "attest" }'{ "ok": true}Set the guest self-service policy (admin, or manage_org_settings) PATCH
Deliberately NOT folded into PATCH /api/companies: these two columns sit outside migration 0022's tenant column grant and are written with service-role, while most of /api/companies is tenant-writable. A true partial update, same `has()` discipline as /api/companies: only a key actually present in the body is written, and at least one of the two must be sent or the whole request is a 400. Gated to admin, or a staff login granted `manage_org_settings` (migration 0082): this decides what every customer of the org may do to their own booking without asking staff, which is an org-settings decision rather than front-of-house work.
Accept the current Terms of Service for this business (admin) POST
Called by the dashboard's "We've updated our terms" prompt (TermsUpdatePrompt.tsx), which an admin sees when their business has no acceptance of the current `TERMS_VERSION` (legal.ts) on record. Records `{ company_id, user_id, version, accepted_at }` in `book_terms_acceptances` (migration 20261001120000), a service-role-only table, so no tenant session can forge or erase an acceptance through PostgREST. One row per business per version: a second admin accepting the same version is a no-op. The body names the version the admin was shown. If `TERMS_VERSION` moved between the prompt rendering and the click, the route answers 409 instead of recording acceptance of a text they never saw.