Skip to content

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 }, each profile, role (designation and department only), missing or typed;
  • 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 under pg_cron as alhuda-leave-nightly (LV-062). POST /leave/admin/accruals is 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).