Base URL: http://localhost:4000/api/v1
Auth: All routes except /auth/* require an Authorization: Bearer <jwt> header.
Content-Type: application/json unless noted (e.g. CSV exports).
Every successful response wraps the payload in a data key:
{ "data": { ... } }Every error follows the same shape:
{
"error": {
"code": "MACHINE_READABLE_CODE",
"message": "Human readable summary",
"details": { ... }
}
}Standard status codes:
| Status | Meaning |
|---|---|
| 200 | Success (read or non-creating write) |
| 201 | Resource created |
| 204 | Success, no body |
| 400 | Validation error (VALIDATION_ERROR) |
| 401 | Missing or invalid token (AUTHENTICATION_REQUIRED, INVALID_TOKEN) |
| 403 | Authenticated but forbidden (FORBIDDEN, ACCOUNT_INACTIVE) |
| 404 | Not found |
| 409 | Business-rule conflict (ALLOCATION_CONFLICT, BOOKING_OVERLAP, TRANSFER_LOCKED, etc.) |
| 500 | Unexpected server error |
Create an Employee account. Role is always EMPLOYEE regardless of any role field sent.
Body
{
"name": "Priya Sharma",
"email": "priya@example.com",
"password": "at-least-8-chars"
}201
{
"data": {
"token": "eyJhbGc...",
"user": { "id": "...", "email": "priya@example.com", "name": "Priya Sharma", "role": "EMPLOYEE", ... }
}
}Body: { "email": "...", "password": "..." }
200: same { token, user } shape as signup.
401: INVALID_CREDENTIALS
Current authenticated user. 200: { data: { id, email, name, role, department, ... } }
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /departments |
— | list of departments with parent, head, counts |
| POST | /departments |
{ name, code, parentDepartmentId?, status? } |
created department |
| PATCH | /departments/:id |
any subset of the above (+ headEmployeeId) |
updated department |
| DELETE | /departments/:id |
— | 204 · 409 if has employees, children, or cycles |
Business rules
codeis trimmed and uppercased server-side.- A department cannot become its own ancestor (cycle detection).
headEmployeeIdmust be an active employee already in the department.
CRUD identical shape to Departments. Body fields:
{
"name": "Electronics",
"description": "Computers, displays, phones...",
"customFields": { "warrantyMonths": "number" },
"status": "ACTIVE"
}Deleting a category referenced by any asset returns 409 RECORD_IN_USE.
| Method | Path | Access | Purpose |
|---|---|---|---|
| GET | /employees |
Any authenticated | Directory with optional ?departmentId=&role=&status= filters |
| PATCH | /employees/:id/promote |
Admin | Change role. Body: { "role": "ASSET_MANAGER" }. Admins cannot promote themselves. |
| PATCH | /employees/:id/status |
Admin | Toggle status. Body: { "status": "INACTIVE" } |
Query: search, status, categoryId, departmentId, location, page (default 1), limit (default 20).
Returns:
{
"data": {
"items": [ { "id":"...","assetTag":"AF-0001","name":"MacBook Pro","status":"ALLOCATED","currentHolder":{...},"category":{...} } ],
"pagination": { "page":1,"limit":20,"total":42,"totalPages":3 }
}
}Access: ASSET_MANAGER, ADMIN.
{
"name": "MacBook Pro 14\"",
"categoryId": "uuid",
"serialNumber": "MBP-14-0001",
"acquisitionDate": "2024-11-01",
"acquisitionCost": 180000,
"condition": "GOOD",
"location": "IT Storage · 3F",
"isBookable": false
}Server auto-generates assetTag (format AF-XXXX) via the asset_tag_sequence Postgres sequence.
Returns the asset plus allocation history, maintenance history, and transfer history.
Standard update / delete. Deleting an ALLOCATED asset returns 409 ASSET_ALLOCATED.
Access: DEPARTMENT_HEAD, ASSET_MANAGER, ADMIN.
{
"assetId": "uuid",
"employeeId": "uuid",
"expectedReturnDate": "2026-08-01T00:00:00Z",
"notes": "Optional"
}201 on success. 409 if asset is already allocated:
{
"error": {
"code": "ALLOCATION_CONFLICT",
"message": "Asset is currently allocated to another employee",
"details": {
"currentHolder": { "id":"...","name":"Priya Sharma","email":"..." },
"allocationId": "uuid"
}
}
}The frontend uses details.currentHolder to render the "Request Transfer" modal.
Query: employeeId?, status? (ACTIVE|RETURNED|OVERDUE), scope? (mine|all), page, limit.
Employees see their own by default; managers see all.
{ "conditionOnReturn": "GOOD", "notes": "..." }Atomically closes the allocation and flips asset to AVAILABLE.
Convenience list for the dashboard.
| Method | Path | Access | Body |
|---|---|---|---|
| POST | /transfers |
Holder or manager | { assetId, fromEmployeeId, toEmployeeId, reason } |
| GET | /transfers |
Any | filters: status?, assetId? |
| POST | /transfers/:id/approve |
Manager | { notes? } — atomic custody swap |
| POST | /transfers/:id/reject |
Manager | { reason } — stored in decisionNotes |
Approving a transfer performs 4 writes in one transaction:
- Old allocation →
RETURNED - New allocation created for
toEmployeeId - TransferRequest →
COMPLETED - Asset status stays
ALLOCATED(custody moved, not returned)
Bookable assets (where isBookable = true and status not in LOST/RETIRED/DISPOSED/UNDER_MAINTENANCE).
{
"assetId": "uuid",
"startTime": "2026-07-13T10:00:00.000Z",
"endTime": "2026-07-13T11:00:00.000Z",
"purpose": "Sprint planning · Team Kestrel",
"employeeId": "uuid"
}409 on overlap:
{
"error": {
"code": "BOOKING_OVERLAP",
"message": "The requested time overlaps with an existing booking",
"details": {
"conflictingBooking": { "id":"...","startTime":"...","endTime":"...","asset":{...},"employee":{...} }
}
}
}Overlap uses the half-open interval [start, end) — abutting slots are legal.
Query: assetId?, employeeId?, status?, from?, to?, scope? (mine|all).
Reschedule body: { startTime, endTime, purpose? } — overlap check re-runs.
| Method | Path | Access | Purpose |
|---|---|---|---|
| POST | /maintenance |
Any auth | Raise request. Body: { assetId, description, priority?, photoUrl? } |
| GET | /maintenance |
Any auth | Query: status?, priority?, assetId?, scope? |
| POST | /maintenance/:id/approve |
Asset Mgr / Admin | Body: { assignedTechnicianId? } — flips asset to UNDER_MAINTENANCE |
| POST | /maintenance/:id/reject |
Asset Mgr / Admin | Body: { reason } |
| POST | /maintenance/:id/assign |
Asset Mgr / Admin | Body: { technicianId } |
| POST | /maintenance/:id/start |
Assigned tech / Mgr | Marks IN_PROGRESS |
| POST | /maintenance/:id/resolve |
Assigned tech / Mgr | Body: { resolutionNotes } — flips asset back to AVAILABLE |
{
"name": "Q1 2026 Engineering Audit",
"scopeDepartmentId": "uuid",
"startDate": "2026-01-15",
"endDate": "2026-01-31"
}Returns a DRAFT cycle.
{ "auditorIds": ["uuid","uuid"] }Idempotent: only assigns not-yet-assigned auditors.
Materializes an AuditRecord (status PENDING) for every in-scope asset and moves the cycle to ACTIVE.
{ "status": "VERIFIED" | "MISSING" | "DAMAGED", "notes": "..." }Atomic close:
- Cycle →
CLOSED - Every
MISSINGasset →LOST - Every
DAMAGEDasset gets a newMaintenanceRequest(priorityHIGH, statusPENDING) - Other assigned auditors are notified
Read endpoints; discrepancies filter to MISSING/DAMAGED records.
| Endpoint | Access | Returns |
|---|---|---|
GET /reports/dashboard-kpis |
Any auth | { assetsAvailable, assetsAllocated, maintenanceToday, activeBookings, pendingTransfers, upcomingReturns, overdueReturns } |
GET /reports/utilization |
Manager / Admin | Assets sorted by allocated-days |
GET /reports/maintenance-frequency |
Manager / Admin | Grouped by asset with category |
GET /reports/department-allocation |
Manager / Admin | Active allocation count per department |
GET /reports/booking-heatmap |
Manager / Admin | 7 × 24 grid of booking counts |
GET /reports/export?type=utilization|maintenance|department|heatmap |
Manager / Admin | text/csv file download |
| Method | Path | Purpose |
|---|---|---|
| GET | /notifications?unreadOnly=true |
Returns { notifications, unreadCount }, own only, latest 30 |
| PATCH | /notifications/:id/read |
Marks one as read |
| PATCH | /notifications/read-all |
Marks all own as read; returns { updated: n } |
Notification types emitted by services:
BOOKING_CONFIRMEDMAINTENANCE_APPROVED,MAINTENANCE_REJECTED,MAINTENANCE_RESOLVEDTRANSFER_REQUESTED,TRANSFER_APPROVED,TRANSFER_REJECTEDAUDIT_ASSIGNED,AUDIT_CLOSED
GET /activity-logs — query: entityType?, entityId?, employeeId?, from?, to?.
Returns up to 250 most-recent entries with employee joined.
| Code | Origin |
|---|---|
VALIDATION_ERROR |
Zod parse failure at controller |
AUTHENTICATION_REQUIRED |
Missing or malformed Bearer header |
INVALID_TOKEN |
Signature invalid / expired / user gone |
ACCOUNT_INACTIVE |
Valid token but employee is INACTIVE |
FORBIDDEN |
Role check failed |
NOT_FOUND |
Resource does not exist |
ALLOCATION_CONFLICT |
Asset already allocated |
BOOKING_OVERLAP |
Requested slot conflicts |
ASSET_UNAVAILABLE / ASSET_UNDER_MAINTENANCE |
Asset cannot be booked/allocated |
TRANSFER_MISMATCH |
fromEmployeeId does not currently hold the asset |
TRANSFER_LOCKED |
Transfer already decided |
MAINTENANCE_LOCKED |
Request in a non-transitionable state |
AUDIT_NOT_ACTIVE |
Cycle isn't in ACTIVE state for the requested action |
AUDIT_ALREADY_CLOSED |
Close attempted twice |
INTERNAL_SERVER_ERROR |
Unexpected — check server logs |