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 futurescheduledFordatetime. Inserts intoCommunicationQueuewithstatus='pending'. - If
sendNow !== false, dispatches viasendMailer({ type, channel, phone|to, templateData })and logs intoCommunicationsLogwith 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.