Skip to content

Admin, Approvals & System API

Staff users, roles & permissions, audit, communications, internal settings, finance config, storage upload, and approvals routes.


Users (staff)

Handlers: handleUsers at src/lib/api.ts:12968, handleUsersById at src/lib/api.ts:13056, handleUserPassword at src/lib/api.ts:13075, handleUserRoles at src/lib/api.ts:13103.

Route Method Permission Purpose
/users GET (read-only) List staff users + their roles, and each login's employeeCode (EM-0007, PTY-003) and joinedOn (the employment joining date, YYYY-MM-DD) read under EmployeeProfile's own policy — null without admin.users.view. createdAt is when the login was made. ?includeInactive=true adds deactivated logins. ?view=directory (the Employees page) also returns hasProfile, designation, department, reportsToUserId, reportsToName, hasParentName and hasEmergencyContact (yes/no only — the values are not returned) and lastSignInAt (the last successful sign-in, from the computed field employee_last_sign_in_at inside the profile embed — yours, or anyone's with admin.users.view, otherwise null) and canManage (may the caller manage this login's access — pause, email, username, password, MFA, deactivation: can_manage_user(caller, person), the check the server makes; computed field employee_can_manage; null for a login with no employee profile), in the same one call
/users POST admin.users.create Provision staff user (email must end in @alhuda.co.in or @alhudatravels.in)
/users/:id PATCH { action: 'pause', reason } / { action: 'resume' } admin.users.edit Pause (read-only) or resume a login — admin_pause_user(p_user_id, p_reason) / admin_resume_user(p_user_id) (ACC-070). The function also accepts partners.edit for a partner login and customers.edit for a traveller login, refuses self, anyone above you and a paused caller, audits the reason and notifies the person. Returns { id, accessMode, alreadyPaused | alreadyActive }.
/users/:id/profile GET admin.users.view The merged profile — admin_user_profile(p_user_id): { user, roles, kind, employee, documents, partner, profileEdit } (ACC-071). profileEdit is { allowed, scope: 'all' \| 'details' \| null, fields: [key…] \| null, reason, canManage } — whether the caller may save this profile and which fields, and whether they may manage the login's access (can_manage_user, migration 20261004100000) (ACC-084)
/users/:id/profile PATCH { patch } admin.users.edit or hr.profiles.edit admin_update_employee_profile(p_user_id, p_patch) — with admin.users.edit over someone whose every right you hold (or your own row), every allow-listed field; with hr.profiles.edit, the profile details of a staff login that is not an IT Admin or Super Admin and not yourself — fullName, phone or employeeCode in the patch is refused whole (403), an admin target answers "HR can't edit an IT Admin or Super Admin profile." (ACC-084). Allow-listed fields (including parentName, the father's / guardian's name, at most 200 characters — EA-013); a reportsToUserId must be active staff and make no loop (ACC-081); employeeCode is permanent: an unchanged value saves, a changed one is refused (PTY-002); aadhaarLast4 / panLast4 refuse more than four characters; audited with changedFields. Returns the profile.
/users/:id/reporting-options GET admin.users.view or hr.agreements.issue staff_reporting_options(p_user_id): { staff: [{ userId, name, designation, employeeCode }], designations: [text] } — the active staff the person may report to (never themself or anyone below them; two CEOs may report to each other) and the designations to suggest (ACC-081, EA-012). A reportsToUserId outside that list is refused by the database on PATCH /users/:id/profile too.
/users/:id/documents/:docId DELETE admin.users.edit employee_remove_document(p_id) — removes the record; the file stays on the drive
/users/:id/record GET admin.users.view The employee record — employee_record(p_user_id): { user, kind, employee, reportsTo, reports, roles, permissions: { count, names }, partner, documents, access: { sessions, devices, signIns }, groupsLed, activity, profileEdit } (profileEdit as on GET /users/:id/profile, ACC-084; ACC-071, PTR-083). Identity numbers are the last four only; activity rows are { at, source, kind, title, detail, actor, actorId, about?, ref: { type, id, entity?, entityId?, bookingId? } }, newest first, at most 100. 404 when the login does not exist.
/users/:id/documents/:docId/review POST { decision: 'ok' \| 'rejected', note? } admin.users.edit employee_document_review(p_id, p_decision, p_note) — a rejection needs a note (400/409 otherwise); audited; the person is told. Returns { id, userId, docType, reviewDecision, reviewNote, reviewedAt, reviewedByName }.
/me/profile GET none — own row The signed-in person's merged profile — profile_me(): { user, roles, kind, employee, documents, partner } (ACC-071); 404 when the login has no User row
/me/profile PATCH { patch } none — own row profile_update_me(p_patch) — staff only; the allow-list is phone, personalEmail, address, district, state, pinCode, emergencyContact*, dateOfBirth, bloodGroup, gender; any other key is refused (400), a paused login is refused (409, "This account is paused (read-only)"). Returns the profile. A partner saves through PATCH /portals/agent/profile instead.
/me/partner-profile PATCH { patch } none — own agency A partner's own address and website — partner_update_my_profile(p_patch): allowed keys addressLine, city, district, state, pinCode, country, website; any other key is refused; a paused login or a closed agency is refused (PTR-086).
/me/partner-contacts POST { contact } none — own agency partner_contact_save(null, p_contact) — the agency comes from the session (PTR-086).
/me/partner-contacts/:id DELETE { reason } none — own agency partner_contact_remove(p_id, p_reason) — refused for another agency's contact.
/me/documents GET none — own rows The person's documents: EmployeeDocument (select own or admin) for staff, AgentDocument (select agency) for a partner login. [{ id, docType, fileName, mimeType, driveViewLink, createdAt }]. Each opens through drive-file (kind employee_file / partner_file); driveViewLink works only for Shared Drive members. Upload: upload-employee-doc (the person, or admin.users.edit with userId) / upload-partner-doc (the partner).
/users/:id PATCH { action: 'change_email', email, reason } admin.users.edit Change a staff login's sign-in email (ACC-075) through admin-users change_email. The function also checks admin.users.edit, refuses anyone above you, a customer or partner login (400), a non-company address (400), a reason under three characters (400) and an address someone else holds, exactly or as its other-domain alias (409). The sign-in and User.email change together; audited with the reason; the person is emailed at the new address. Returns { ok, unchanged, userId, oldEmail, email, noticeSent, warning? } — unchanged: true when it was already their address.
/users/workspace-domain POST { dryRun, reason? } admin.users.edit Move every live staff login on @alhuda.co.in to the same name at @alhudatravels.in (ACC-075) through admin-users move_staff_to_workspace_domain. dryRun defaults to true and changes nothing; a real run needs a reason. Returns { dryRun, moves: [{ userId, fullName, oldEmail, newEmail, conflict }] }; after a real run each move also has status (moved / skipped / failed), error, noticeSent and warning. A login with a conflict, without a sign-in, or above your station is listed with the reason and not moved. A second run returns an empty list.
/users/:id DELETE admin.users.delete Soft-delete user
/users/:id/password POST Admin resets user password via Supabase admin API
/users/:id/roles POST roles.request Retired 2026-09-30: answers 410. A staff role is requested, approved by a CEO and applied by an admin — see Role requests (ACC-077, ACC-079)

POST /users

Cite: src/lib/api.ts:13021.

Calls the admin-users Supabase edge function (requires the service role key server-side). Enforces domain restriction on staff email: only @alhuda.co.in and @alhudatravels.in are accepted.

Body — { email, fullName?, phone?, password?, username?, roles?, reason? }. The login is made with no staff role; each role in roles becomes a role request filed by the caller with reason (ACC-082). An administrative role (Super Admin, IT Admin, HR Admin, GM, CEO today — role_is_administrative()) in roles needs roles.apply (IT Admin, Super Admin); anyone else gets 403 "Only IT Admin or Super Admin can ask for the … role." before the login is made (ACC-083).

Returns — { id, tempPassword, roleRequests, roleRequestErrors }.

Changing a role (retired route)

POST /users/:id/roles was retired on 2026-09-30 and answers 410. It used to delete a login's UserRole rows and insert new ones in one step; the database now refuses any write of a staff role outside an applied role request (ACC-079). Use POST /role-requests (Role requests API).


Permissions admin

Handler: handlePermissions in src/lib/api.ts. The old requireAdminUser() role-name shortcut was removed — it was exactly the anti-pattern ACC-001 forbids. Every sub-route names a permission.

Route Method Permission Purpose
/permissions GET admin.permissions.view List the permission catalog
/permissions/roles GET admin.permissions.view Roles with their permission grants
/permissions/roles/:roleId PUT admin.permissions.edit Replace a role's RolePermission rows
/permissions/users GET admin.permissions.view Users with roles + overrides
/permissions/users/:userId PUT { overrides } admin.permissions.edit Replace a user's UserPermission overrides. Sending roleIds answers 410 since 2026-09-30: roles change by a role request (ACC-077)

admin.permissions.edit and the role rights are on the super-admin-only list, so no role bundle can acquire them (ACC-012). The privileged half of this surface runs in the permissions-admin edge function, not in the browser.

Check the route against the code

Exact permission names on this surface were not re-verified line by line in the September 2026 documentation pass. src/test/api.permission-coverage.test.ts proves every write branch has a requirePermission, and PERMISSIONS.md §6.16 is the canonical list.


Agents (partner admin)

See Customers — GET/POST/PATCH/DELETE /agents are documented there. GET /agents carries loginAccessMode ('active' | 'read_only' | null) for the linked login (ACC-070). Admin-only uses of /agents (bulk status changes, partner approval on pending signups) flow through PATCH /agents/:id with status='active'|'inactive'|'suspended', which also tells the partner's login what changed.

The partner record (PTR-083), migration 20260929230000; the profile routes and the record's profile, contacts, notes, performance and access (PTR-084 … PTR-090), migration 20261001234000:

Route Method Permission Purpose
/partners/:id/record GET agents.view partner_record(p_agent_id): { agency, profile, contacts, notes, notesTotal, performance, travellers, travellersTotal, login, access: { sessions, devices, signIns }, statusHistory, documents, requiredDocuments, missingDocuments, bookings: { total, byStatus, recent }, money: { currency, outstanding, creditLimit, creditLeft, overLimit, payments, pendingClaims, invoices }, requests, groupsLed, activity }. profile is every PartnerProfile field with relationshipManager: { id, name, email, phone, isActive }; notes are the last 50 ({ id, kind, body, followUpOn, createdAt, createdByName }); performance is { currency, yearStart, thisYear, lastYear: { label, bookings, travellers, revenue }, revenue, paid, due, bookings, cancelled, partiallyCancelled, cancellationRate, lastBookingAt, otherCurrencyBookings, avgDaysToPay, paidInFull } (PTR-089). documents carry expiresOn, filedByOffice, uploadedByName, reviewedAt, reviewedByName, reviewDecision, reviewNote; travellers are the latest 50 travellers on the agency's live bookings by departure ({ id, name, bookingId, bookingNo, status, groupId, groupCode, groupName, departureDate }) and travellersTotal counts them all (PTR-091); activity is the merged timeline ({ at, source, kind, title, detail, actor, actorId, ref }, newest first, at most 100; a booking row's detail names its travellers and group; audit rows without their values — AUD-004). 404 when the partner does not exist.
/partners/:id/documents/:docId/review POST { decision: 'ok' \| 'rejected', note? } partners.edit partner_document_review(p_id, p_decision, p_note) — a rejection needs a note; audited; the partner is told (the note is written for them — PTR-080). Returns { id, agentId, docType, reviewDecision, reviewNote, reviewedAt, reviewedByName }.
/partners/:id/documents/:docId DELETE { reason } partners.edit partner_remove_document(p_id, p_reason) — removes the record, the file stays on the drive; a reason is required; audited on the agency (PTR-087). Returns { id, agentId, removed }.
/partners/:id/profile PATCH { patch } partners.edit partner_update_profile_admin(p_agent_id, p_patch) — only the keys sent; allowed: legalName, tradeName, businessType (proprietorship | partnership | llp | private_limited | other), ownerName, establishedYear, website, addressLine, city, district, state, pinCode, country, iataNumber, associationMembership, stateTourismRegistration, hajUmrahLicenceNo, hajUmrahLicenceExpiry, relationshipManagerId (an active staff login), region, tier, onboardedOn, source, internalNotes; null clears a field; any other key is refused (400). Audited with the changed field names. Returns { agentId, changed, profile } (PTR-084). An empty patch is 400.
/partners/:id/contacts POST { contact: { id?, name, role, phone?, whatsapp?, email?, isPrimary? } } partners.edit partner_contact_save(p_agent_id, p_contact) — adds (no id) or changes a contact; role is owner | accounts | operations | reservations | other; a name and one of phone / WhatsApp / email are required; at most 20; one primary. Returns { id, agentId, contacts } (PTR-085).
/partners/:id/contacts/:contactId DELETE { reason } partners.edit partner_contact_remove(p_id, p_reason) — soft removal with a reason; audited. Returns { id, removed, contacts }.
/partners/:id/notes POST { kind, body, followUpOn? } partners.edit partner_note_add(p_agent_id, p_kind, p_body, p_follow_up_on) — kind is call | meeting | whatsapp | email | visit | other; 3–4,000 characters; a follow-up is today or later. Append-only: there is no route to change or delete a note (PTR-088). Returns the note.

The app changes a partner's status through partner_set_status(p_agent_id, p_status, p_reason) (partners.edit, a reason of at least three characters, unchanged: true when the status is already set) instead of PATCH /agents/:id; it records the reason as a confirm_reason audit row and tells the partner's login through their inbox.


Profiles (database functions)

The native app calls these directly; the desktop /me page reaches the first two through GET/PATCH /me/profile above. Every one takes the caller from the session (ACC-071, migration 20260929140000):

Function Who Purpose
profile_me() any signed-in login The merged profile: user (with accessMode), roles, kind, employee (with employeeCode), documents, partner (with partnerCode, PTY-006)
profile_update_me(p_patch) staff, not paused Own contact fields only: phone, personalEmail, address, district, state, pinCode, emergencyContact*, dateOfBirth, bloodGroup, gender; anything else is refused
employee_add_document(p_user_id, p_doc) self (staff) or admin.users.edit Records a file the upload-employee-doc function put on the drive (driveFileId required, never a typed link); at most 20
admin_users_list(p_query, p_limit) admin.users.view Logins with status, accessMode, roles, employeeCode, designation, department; p_query also matches the employee code (PTY-006)
admin_user_profile(p_user_id) admin.users.view Same shape as profile_me() for another login
admin_pause_user(p_user_id, p_reason) / admin_resume_user(p_user_id) see /users/:id above Pause (read-only) / resume
employee_record(p_user_id) admin.users.view The employee record (see /users/:id/record above) — migration 20260929230000
employee_document_review(p_id, p_decision, p_note) admin.users.edit (and can_manage_user) OK / rejected with a note; audited; the person told
partner_record(p_agent_id) agents.view The partner record (see Agents above); agency.partnerCode is the partner's code
partner_document_review(p_id, p_decision, p_note) partners.edit OK / rejected with a note; audited; the partner told
partner_set_status(p_agent_id, p_status, p_reason) partners.approve to decide a new partner (PTR-095); partners.edit for the rest active / inactive / suspended / pending with a reason; the partner told what changed
partner_staff_create(p_partner) partners.create, staff not paused Add a business partner from the staff app (PTR-097, migration 20261008230000). p_partner: { company, panCard, name?, email?, phone?, gstNumber?, commissionRate?, creditLimit?, address?, allowDuplicate? }. Company required; PAN ABCDE1234F; GSTIN optional, 15 characters; commission 0–100; credit limit 0 to 10,000,000,000 (₹1,000 crore); e-mail shaped like one — else 22023 with the reason. A non-zero commission needs agents.commission.set and a non-zero credit limit agents.credit_limit.set — else 42501 "Only finance or leadership can set a partner's …" (PTR-030). A PAN or GSTIN already on another partner is 23505 unless allowDuplicate: true: the message names that partner for an agents.view holder, and otherwise says only "This PAN is already registered with Alhuda. Ask the office.". The same PAN and company sent again by the same login within two minutes returns that partner with existing: true. The partner is pending; its code, both ledgers and the sales@ e-mail follow (PTR-001, PTR-096). Returns { id, partnerCode, status, company, existing }. No portal login

Audit log

Handler: handleAudit at src/lib/api.ts:12457.

Route Method Permission Purpose
/audit GET (read-only) Audit log with optional ?entityType=&entityId=; resolves user names and booking numbers
/audit POST finance.create Append an external audit entry
/admin/audit-log GET, POST same as /audit Alias

Permission is finance.create — historical

Writing to the audit log requires finance.create by convention; a future refactor may introduce a dedicated audit.write permission.


Communications

Handlers: handleOperationsCommunications at src/lib/api.ts:13180, handleCommunicationQueue at src/lib/api.ts:3181.

Route Method Permission Purpose
/operations/communications GET (read-only) Filter by ?entityType=&entityId=&limit=
/operations/communications POST admin.users.edit Log a communication; optionally dispatch via WhatsApp/email now or schedule for later
/communications/queue GET (read-only) Inspect the scheduled communications queue

Dispatch semantics

Cite: src/lib/api.ts:13200-13360. When channel is whatsapp or email:

  • If sendNow === false, requires a future scheduledFor datetime. Inserts into CommunicationQueue with status='pending'.
  • If sendNow !== false, dispatches via sendMailer({ type, channel, phone|to, templateData }) and logs into CommunicationsLog with the provider name tagged in notes.
  • Phone / email are resolved via resolveCommunicationTarget(entityType, entityId) from the related booking / agent / customer, or taken from the body directly.

Daily stats

Inline handler at src/lib/api.ts:28065. GET /admin/daily-stats with optional ?date=YYYY-MM-DD (defaults to today).

Returns — bookings created/approved/rejected/totalAmount, payments count/totalReceived/ verified/pending, customers created/total, groups active/departing7d, finance journal count + totals (debit/credit), visa applied/issued/pending. Read-only; no permission check ().


Currencies

Inline handler at src/lib/api.ts:28124. GET /admin/currencies returns active rows from the currencies table (code, name, symbol).


Finance config

Inline handler at src/lib/api.ts:28166.

Route Method Permission Purpose
/admin/finance-config GET (read-only) Read FinanceConfig singleton (sanitized for client)
/admin/finance-config PUT finance.edit Update allowed fields (prefixes, default currency, GST rate, GL account IDs); invalidates cache. bookingPrefix must be 1–12 letters or digits with a hyphen only between them, else 400 (rule: "FIN-048"); blank means BK

The allowlist of mutable fields is fixed (see src/lib/api.ts:28173-28190). Fields not in the list are ignored. After update, assertFinanceConfigAccountTypes validates that every GL account ID maps to the right account type (ASSET/LIABILITY/etc).


Communication settings

Inline handler at src/lib/api.ts:28205.

Route Method Permission Purpose
/admin/communication-settings GET (read-only) List all settings keys
/admin/communication-settings PUT admin.edit Upsert { settings: [{key, value}] }

Stores sender-side credentials and template defaults (SMTP, WhatsApp API keys, etc) in the CommunicationSetting table keyed by key.

WhatsApp delivery problems

Route Method Permission Purpose
/admin/whatsapp-delivery-failures GET admin.integrations.view The password reset codes, sign-up codes (TRV-016) and sign-up replies in the chat (TRV-017) WhatsApp refused, and the automatic booking notices that failed or found no approved template, in the last 30 days, newest first, at most 50 (ACC-069, COMM-024)

Calls whatsapp_delivery_failures(p_limit), which re-checks admin.integrations.view. Returns [{ id, at, action, userId, name, sentTo, errorCode, error }]: sentTo is the number masked to its last two digits (••••••12); errorCode is Meta's code (for example 131030) or null when Meta never answered; error is Meta's words with every run of five or more digits masked, at most 300 characters. The reset rows come from AuthLoginAttempt (reason = 'reset_code_send_failed'), written by auth-login only. A sign-up code row has action signup_code, userId null and name Sign-up code (new customer); it comes from AuthLoginAttempt (reason = 'signup_code_send_failed') with sentTo from SignupCode. A sign-up reply in the chat has action signup_whatsapp_flow and name WhatsApp sign-up form (new customer), WhatsApp sign-up — set-password link or WhatsApp sign-up reply; it comes from AuthLoginAttempt (reason signup_flow_send_failed, signup_flow_link_send_failed or signup_flow_reply_failed) with sentTo from SignupFlowToken. A booking notice row has id "notice:<uuid>", action booking_confirmed_notice or payment_receipt_notice, userId null, name such as Payment received — BK-00123 (customer), and bookingNo; it comes from BookingWhatsAppNotice (status failed, or skipped with reason template_not_approved / no_document).

WhatsApp sign-up form (Flows)

No src/lib/api.ts route: the card Sign-up in the WhatsApp chat (Admin → Integrations → WhatsApp, its own lazy chunk) calls the edge function whatsapp-flow-setup through src/services/whatsappFlowAdmin.ts. Rule: TRV-017.

Call Permission Purpose
signupFlowStatus() — whatsapp-flow-setup { "action": "status" } admin.integrations.view, checked in the function The saved form's id and Meta's status
setUpSignupFlow() — whatsapp-flow-setup { "action": "setup" } admin.integrations.edit, checked in the function; staff only, not paused (ACC-070) Create the form on the WhatsApp account, upload its JSON, publish it, save its id

Both answer { ok, flowId, status, chatLink }; status is Meta's (DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED) or NOT_SET_UP, NOT_FOUND (gone from Meta), NOT_PUBLISHED, IN_PROGRESS. Setup does only what is missing: published → nothing; a saved draft → upload and publish; nothing, gone or deprecated → POST /{waba-id}/flows (name alhuda_customer_sign_up_<date>, categories ["SIGN_UP"]), the id saved at once (CommunicationSetting whatsapp_signup_flow_id), POST /{flow-id}/assets (supabase/functions/_shared/whatsappSignupFlow.json, asset_type FLOW_JSON), POST /{flow-id}/publish. A second setup within two minutes of one still running is 409 IN_PROGRESS. Meta's refusal is HTTP 502 with { ok: false, error: "Meta refused: <words, digits masked>", code, hint } — hint says what to do for a token that may not manage Flows (codes 10, 200, 294), an expired token (190) or a wrong account id (100); validation errors in the form are named. No token: 409 (setup) or NOT_SET_UP (status); no account id: 409. The token is read server-side and sent only as a header. Each setup, done or refused, writes one AuditLog row (whatsapp_flow.setup or whatsapp_flow.setup_failed, entity CommunicationSetting, entity id whatsapp_signup_flow_id). See WhatsApp (Meta Cloud API) → Sign-up in the chat.

WhatsApp message templates

There is no src/lib/api.ts route for these: like the rest of the WhatsApp screens (src/services/whatsappService.ts), the Message templates card calls the table and the edge function directly, through src/services/whatsappTemplateAdmin.ts, which only that card imports — so it adds nothing to the first load (PRF-005). Rule: AUD-024.

Call Permission Purpose
listWhatsAppTemplates() — WhatsAppTemplate select, ordered by name and language admin.integrations.view (screen); the table's RLS lets staff read Every template row, usable or not
syncWhatsAppTemplates() — edge function whatsapp-templates-sync, empty body admin.integrations.edit, checked again in the function Copy the templates from Meta now

The list returns [{ id, name, displayName, category, languageCode, status, isActive, unusableReason, lastSyncedAt, source }]. status is Meta's (APPROVED, PENDING, REJECTED, PAUSED, DISABLED …, or DELETED when the template is gone from Meta); null on a row typed by hand. isActive is true only when staff can send it from chat; otherwise unusableReason says why.

whatsapp-templates-sync takes nothing from the browser: it reads the access token and the Business Account ID server-side (AUD-021), re-checks admin.integrations.edit, refuses a paused account (ACC-070) and a portal login, and returns { ok, summary: { added, updated, unchanged, deactivated, skipped, usable }, syncedAt, managerUrl }. skipped counts templates listed but not usable in chat; deactivated counts rows whose template is no longer in Meta (kept, never deleted). When Meta refuses, the answer is HTTP 502 with { ok: false, error: "Meta refused: <Meta's words, digits masked>", code }; with no token or account ID it is 409 and says which. Each run, done or refused, writes one AuditLog row (whatsapp_templates.sync or whatsapp_templates.sync_failed, entity WhatsAppTemplate, entity id meta-sync) with who, when and the counts. It also accepts the dispatch key or the service-role key (x-dispatch-key or bearer) for the nightly pg_cron job alhuda-whatsapp-templates-sync (21:30 UTC, 03:00 IST); see WhatsApp (Meta Cloud API) → Message templates.


Files

There is no upload route in src/lib/api.ts. POST /storage/upload is removed (it answered any signed-in user and wrote to a bucket that does not exist). A file goes to the company Shared Drive through an edge function — drive-upload, or the customer, partner and employee document functions — and opens through drive-file (ACC-074; Customers API).


Requests (service requests)

See Bookings — /requests, /requests/:id, /requests/:id/notes are documented there.


Portals

Partner (/portals/agent/*) and customer (/portals/customer/*) portal routes are documented in Bookings. They are JWT-authenticated and row-scoped by resolving the caller's Agent or Customer row — no permission checks.


Internal / maintenance endpoints

Route Method Permission Purpose
/finance/ledger POST admin.edit Rebuild derived ledger rows (mode: 'rebuild_quota_blocks')
/finance/stock-adjustment POST finance.create Manual inventory write-up / write-down
/finance/supplier-transactions GET finance.view Dump of all SupplierTransaction rows

Deprecated endpoints

These routes throw immediately — do not use.

Route Method Error
/accounts POST 400 — "Create GL accounts from Finance → Accounts..."
/accounts/:id PATCH 400 — same

Use /finance/accounts instead (see Finance).


Unmigrated endpoints

When the dispatch cascade falls through without a match, the router throws ApiError(501, { message: 'Unmigrated endpoint: <METHOD> <path>. This UI route still expects the old /api backend. Migrate it to supabase.from(...).' }). Cite: src/lib/api.ts:28235.

Typical cause — a frontend service calling an old REST path before someone adds the dispatch entry. The fix is to add the handler in _apiFetchInternal per the checklist in the Overview.