Role requests API
Handler: handleRoleRequests() in src/lib/roleRequests.ts, loaded on demand by apiFetch
(like the leave routes), so the API chunk every screen downloads does not carry it. The file's
header lists every route with its function and permission.
A staff role changes only when it is requested, a CEO approves it and an admin applies it
(ACC-077 … ACC-080, ACC-082).
An administrative role — one whose permissions control users, access, settings or role changes —
is asked for, added or removed, only by a holder of roles.apply (IT Admin, Super Admin;
ACC-083),
enforced by the trigger RoleChangeRequest_admin_roles_by_admins.
Every route is one SECURITY DEFINER database function. The function takes the caller from the
session, checks the permission and the separation of duties (ACC-078), refuses a paused login and
writes the audit row. The requirePermission() calls in the handler only shape the screen
(ACC-001). Permissions: PERMISSIONS.md §4.7, §6.22.
A database refusal comes back as its sentence, for the screen to show as it is (403 for a
permission or separation-of-duties refusal, 409 for a duplicate, 400 for a missing reason).
Routes
| Method | Path | Body / query | Function | Permission |
|---|---|---|---|---|
| GET | /role-requests |
?scope=ceo\|admin\|mine\|history\|open\|all&userId= |
role_change_requests |
any staff login; the people in a request read it, and roles.request / roles.approve / roles.apply read every request (checked in the function and by the row policy) |
| GET | /role-requests/roles |
— | role_change_roles |
any signed-in login; the staff roles a request can name (never CUSTOMER, AGENT, TOUR_LEADER), as [{ name, administrative, requestable }] for the caller — requestable is false for an administrative role unless the caller holds roles.apply (ACC-083) |
| POST | /role-requests |
{ userId, action: 'add'\|'remove', role, reason } |
role_change_request_create |
roles.request; to add or remove an administrative role, roles.apply as well — otherwise 403 "Only IT Admin or Super Admin can ask for the CEO role." (ACC-083) |
| POST | /role-requests/:id/ceo-decision |
{ decision: 'approve'\|'reject', remarks } |
role_change_ceo_decide |
roles.approve; never the requester or the person; a rejection needs remarks |
| POST | /role-requests/:id/admin-decision |
{ decision: 'accept'\|'reject', remarks } |
role_change_admin_decide |
roles.apply; never the requester, the person or the approver; a rejection needs remarks |
| POST | /role-requests/:id/withdraw |
{ reason } |
role_change_withdraw |
roles.request, and only the requester |
GET /role-requests returns { rows, canRequest, canApprove, canApply, counts: { ceo, admin } }.
Each row carries the person, the change (action, roleName), the reason, the status
(pending_ceo, approved_by_ceo, applied, rejected_by_ceo, rejected_by_admin,
withdrawn), who decided each step and when, and three flags the database works out for the
caller — canApprove, canApply, canWithdraw. The screens show a button only when its flag is
true. The write routes return the same row; a second click returns the first answer with
existing: true (filing) or alreadyDecided: true (a decision).
On approve every other active holder of roles.apply gets a work-inbox job (type
role_change, subject RoleChangeRequest) that opens /admin/role-requests?tab=admin&request=<id>;
the jobs close when an admin decides or the requester withdraws. accept writes the UserRole
row in the same transaction.
admin-users (edge function)
| Action | What it does now |
|---|---|
create_user { …, roles?, reason? } |
Makes the login with no staff role, then files one role request per role in roles, as the caller, with reason (role_change_request_create_as, service role only). Needs roles.request and a reason when roles is given; CUSTOMER, AGENT and TOUR_LEADER are refused in roles. An administrative role in roles (role_is_administrative()) needs roles.apply; without it the call answers 403 "Only IT Admin or Super Admin can ask for the … role." and no login is made (ACC-083). Returns { user, tempPassword?, roleRequests, roleRequestErrors? } (ACC-082). |
assign_role / remove_role with a staff role |
Files a role request (reason required, roles.request) and answers 202 { ok, requested: true, request }. It never writes the role (ACC-079). |
assign_role / remove_role with CUSTOMER or AGENT |
Unchanged: the portal login's role is set (ACC-080). TOUR_LEADER is refused — a tour leader is appointed on the departure. |
delete_user |
Soft-deletes the login first, then clears its role rows (the lock lets a deleted login's rows go). |
Retired
| Route | Now |
|---|---|
POST /users/:id/roles |
Retired: answers 410 — request the change instead (ACC-079). |
PUT /permissions/users/:id with roleIds |
Answers 410; the route changes per-user overrides only. |