Leave and agreements API
Handler: handleLeave() in src/lib/leave.ts, loaded on demand by apiFetch (like the
operations screens), so the API chunk every screen downloads does not carry it. The file's
header lists every route with its function and permission.
Every route is one SECURITY DEFINER database function. The function takes the caller from the
session, checks the permission and the routing (who may approve whose leave), refuses a paused
login and writes the audit row. The requirePermission() calls in the handler only shape the
screen (ACC-001). Rules:
22 · Leave and the employee agreement. Permissions:
PERMISSIONS.md §6.20, §6.21.
Dates go to the database as YYYY-MM-DD and come back the same; the screens show DD/MM/YYYY.
A database refusal comes back as its sentence, for the screen to show as it is.
Leave
| Method | Path | Body / query | Function | Permission |
|---|---|---|---|---|
| GET | /leave/dashboard |
— | leave_my_dashboard |
leave.apply |
| POST | /leave/preview |
{ application } |
leave_preview |
leave.apply |
| POST | /leave/applications |
{ application } |
leave_apply |
leave.apply |
| GET | /leave/applications/:id |
— | leave_application_detail |
own, routed to the caller, or HR (checked in the function); 404 otherwise. Answers canDecide, canDecideCancellation, canCancel (own) and canHrCancel (hr.leave.admin, someone else's, pending / approved / cancellation asked) |
| POST | /leave/applications/:id/cancel |
{ reason? } |
leave_cancel |
leave.apply (own) or hr.leave.admin (anyone's, reason required; approved days go back — LV-033) |
| POST | /leave/applications/:id/decide |
{ decision: approve \| reject, remarks? } |
leave_decide |
the routed approver (in the function) |
| POST | /leave/applications/:id/cancellation |
{ approve: boolean, remarks? } |
leave_decide_cancellation |
the routed approver (in the function) |
| POST | /leave/applications/:id/certificate |
{ documentId } |
leave_attach_certificate |
leave.apply (own application, own medical_certificate) |
| GET | /leave/ledger?year= |
— | leave_my_ledger |
leave.apply |
| GET | /leave/inbox |
— | leave_inbox |
any staff login; lists only what is routed to the caller |
| GET | /leave/team?from=&to= |
— | leave_team |
any staff login; everyone for HR and management, direct reports otherwise; at most three months |
| GET | /leave/register/:userId?year= |
— | leave_register |
hr.leave.admin or hr.leave.approve |
| GET | /leave/admin |
— | leave_admin_screen |
hr.leave.admin |
| PATCH | /leave/admin/settings |
{ patch } |
leave_settings_save |
hr.leave.admin |
| PATCH | /leave/admin/types/:code |
{ patch } |
leave_type_save |
hr.leave.admin |
| POST | /leave/admin/peak-seasons |
{ season } |
peak_season_save |
hr.leave.admin |
| DELETE | /leave/admin/peak-seasons/:id |
— | peak_season_delete |
hr.leave.admin |
| POST | /leave/admin/compoff |
{ userId, workedOn, instructedBy, reason, days } |
leave_compoff_credit |
hr.leave.admin, not for one's own login |
| POST | /leave/admin/adjustments |
{ userId, leaveType, days, reason, year?, kind? } |
leave_adjust |
hr.leave.admin, not for one's own login |
| PATCH | /leave/admin/employees/:id |
{ patch } |
hr_set_employee_leave_profile |
hr.leave.admin, not for one's own login |
| POST | /leave/admin/absences |
{ absence } |
leave_hr_record_absence |
hr.leave.admin, not for one's own login (LV-042) |
| POST | /leave/admin/accruals |
{ asOf? } |
leave_run_accruals |
hr.leave.admin; never a future date |
| GET | /leave/admin/year-end?year= |
— | leave_year_end_preview |
hr.leave.admin |
| POST | /leave/admin/year-end |
{ year } |
leave_year_end_run |
hr.leave.admin; after the year's 31 March; once |
| GET | /holidays?year= |
— | holiday_list |
any staff login |
| POST | /holidays |
{ holiday } |
holiday_save |
hr.holidays.manage |
| PATCH | /holidays/:id |
{ holiday } |
holiday_save |
hr.holidays.manage |
| DELETE | /holidays/:id |
{ reason? } |
holiday_delete |
hr.holidays.manage |
An application
POST /leave/preview and POST /leave/applications take the same object:
{
"leaveType": "CL_SL",
"fromDate": "2026-10-12",
"toDate": "2026-10-14",
"startHalf": "first",
"endHalf": "second",
"reason": "Family function out of town",
"handoverUserId": "…",
"handoverNote": "Two visa files due on 13/10",
"contactWhileAway": "…",
"isEmergency": false,
"isUnplanned": false,
"allowLwp": false,
"clientKey": "a random string made once per form"
}
Leave types: CL_SL, EL, COMP, MAT, PAT, BRV, MAR, HAJ, LWP. A half day is
startHalf: "second" or endHalf: "first" (LV-005).
isUnplanned: true reports an absence (LV-042).
allowLwp: true takes a shortfall as leave without pay (LV-041).
The preview answers every check as one object, and leave_apply refuses when errors is not
empty, with the errors joined into one sentence:
{
"errors": [], "warnings": ["Earned leave is normally applied for 7 to 15 days in advance …"],
"leaveType": "CL_SL", "leaveYear": 2026, "fromDate": "2026-10-12", "toDate": "2026-10-14",
"days": 3, "paidDays": 3, "lwpDays": 0, "usable": 9.5,
"counted": ["2026-10-12", "2026-10-13", "2026-10-14"], "skipped": [],
"peakOverlap": false, "peakSeasons": null, "inNoticePeriod": false,
"needsManagement": false, "certificateNeeded": true,
"routeHrUserId": null, "routeHrName": null, "approvers": "HR",
"isEmergency": false, "isUnplanned": false
}
leave_apply answers { application, warnings, repeat }. A second call with the same
clientKey returns the first application with repeat: true. leave_decide, leave_cancel
and leave_decide_cancellation answer { application, repeat }; a second click on the same
decision answers repeat: true and changes nothing.
After POST /leave/applications, …/decide, …/cancel, …/cancellation and
POST /leave/admin/absences, the handler asks the communications-dispatcher to send the leave
e-mails that step queued (LV-036 … LV-038) — the caller's
own rows only. A failure there is reported and never fails the step; the five-minute run sends
them anyway.
PATCH /leave/admin/settings takes { patch } with any of the settings' fields, including
emailNotifications (boolean, the leave e-mails on or off —
LV-039).
An absence recorded by HR
POST /leave/admin/absences records an absence older than the reporting window
(LV-042):
{
"absence": {
"userId": "…",
"leaveType": "CL_SL",
"fromDate": "2026-09-07",
"toDate": "2026-09-08",
"startHalf": "first",
"endHalf": "second",
"reason": "Absent; found on the attendance register",
"clientKey": "a random string made once per form"
}
}
leaveType is CL_SL, COMP or LWP; the dates are today or earlier. The function runs the
same checks as leave_apply for a reported absence, except the reporting window and an HR
employee's routing to their manager, and always takes a shortfall as leave without pay. It
refuses one's own login, a login without leave.apply, and a date in a closed leave year. It
answers { application, warnings, repeat }; the application is approved at once, its days are
in the ledger, the timeline has recorded_by_hr, and the audit row is
leave_absence_recorded_by_hr. The same clientKey returns the first record with
repeat: true.
The medical certificate
Two calls: the file goes to the edge function upload-employee-doc with document type
medical_certificate (the company Shared Drive, Employees / "<name> (<id>)"; recorded by
employee_add_document), then POST /leave/applications/:id/certificate links the returned
document id to the application (LV-014).
The employee agreement
| Method | Path | Body | Function | Permission |
|---|---|---|---|---|
| GET | /me/agreement |
— | agreement_my |
any staff login; own agreements, withdrawn ones left out |
| GET | /agreements/:id |
— | agreement_get |
own; hr.agreements.view or hr.agreements.issue; hr.agreements.countersign once the employee has signed. 404 otherwise |
| POST | /agreements/:id/sign |
{ typedName, signature, password, hash, readConfirmed } |
agreement_sign |
the employee named in it (in the function) |
| POST | /agreements/:id/countersign |
{ typedName, signature, password, hash, designation? } |
agreement_countersign |
hr.agreements.countersign, never one's own |
| POST | /agreements/:id/withdraw |
{ reason } |
agreement_withdraw |
hr.agreements.issue; unsigned only |
| POST | /agreements/:id/approve |
{ note? } |
agreement_approve |
hr.agreements.approve; a version waiting for approval, not one's own or one the caller prepared; issues it to the employee (EA-017); a repeat returns repeat: true |
| POST | /agreements/:id/reject |
{ reason } |
agreement_reject |
hr.agreements.approve; reason of 3+ characters; the version is withdrawn as "Not approved by the CEO: …" and HR is told |
| GET | /hr/agreements |
— | agreement_hr_list |
hr.agreements.view, hr.agreements.issue or hr.agreements.countersign |
| POST | /hr/agreements/prepare |
{ userId, values } |
agreement_prepare |
hr.agreements.issue |
| POST | /hr/agreements |
{ userId, values, saveToProfile? } |
agreement_issue |
hr.agreements.issue; the write-back to the profile also needs the right to edit that profile — hr.profiles.edit for a staff login that is not an IT Admin or Super Admin, or admin.users.edit over that person (in the function, ACC-084) |
| GET | /users/:id/reporting-options |
— | staff_reporting_options |
admin.users.view or hr.agreements.issue (Admin API) |
agreement_get answers the agreement with its renderedText, values, contentHash,
hashVerified, the signature fields, and canSign, canCountersign, canWithdraw, myName.
agreement_prepare answers the template's fields (key, label, group, default, required),
the filled values, the rendered text, its hash, the missing required labels and the
employee's current agreement, and for the appointment (EA-012):
origins—{ designation, department, reportsTo, joiningDate, parentName }, eachprofile,role(designation and department only),missingortyped;reportsToUserId— the reporting officer's login: the one picked, else the profile's;profile—{ designation, department, reportsToUserId, joinedOn, parentName }as the profile holds them now;profileSave—{ allowed, reason }: whether this caller's issue would write the appointment back, and if not, why (EA-013).
values may carry reportsToUserId (a login from /users/:id/reporting-options) in place of
the reportsTo text; the database writes the text as "Designation (Name)" and refuses a login
that is not active staff, the employee, or someone below them
(ACC-081).
agreement_issue takes saveToProfile (default true) and answers the agreement with repeat
and profileSave: { requested, saved: [field…], skipped: reason | null, notes: [text…] }.
saved names the profile fields written (designation, department, reportsToUserId,
joinedOn, parentName) — only those empty on the profile or changed by HR. A second click with the same
values returns the agreement already issued and writes nothing.
signature is SVG path data in a 600 × 200 box, made of move, line and curve commands and
numbers only (EA-004). hash is the SHA-256 of the text
the signer read; it must equal the stored hash.
A wrong password is not an error from the database: agreement_sign and
agreement_countersign answer { ok: false, error }, so the failed try stays in the audit
trail, and the handler turns it into a 401 with that sentence ("That password is not right.",
or the fifteen-minute lock). A success answers { ok: true, repeat, agreement }.
Not an API
- The nightly job has no route:
leave_nightly()runs underpg_cronasalhuda-leave-nightly(LV-062).POST /leave/admin/accrualsis the same catch-up by hand. - There is no route that deletes an application or an agreement, or edits a signed one (LV-033, EA-007).
- There is no PDF route. The agreement page prints from the browser (EA-009).