Base URL: http://localhost:3001/api
All responses are JSON unless otherwise noted. Dates are serialised as ISO 8601 strings. Decimal fields are returned as strings to preserve precision.
Log in with username and password. Returns an access token and sets a refresh cookie.
Auth: None
Request body:
{ "username": "admin", "password": "admin123" }Response 200:
{
"accessToken": "eyJhbG...",
"user": {
"id": "uuid",
"username": "admin",
"role": "admin",
"status": "active"
}
}Also sets cookie: refreshToken (httpOnly, 7 days, path /).
Errors: 400 missing fields, 401 invalid credentials or inactive account.
Issue a new access token from the refresh cookie.
Auth: None (cookie-based)
Response 200:
{ "accessToken": "eyJhbG..." }Also rotates the refreshToken cookie.
Errors: 401 no or invalid refresh token.
Clear the refresh cookie.
Auth: None
Response 200:
{ "message": "Logged out" }Return the current user's profile.
Auth: Bearer token
Response 200:
{ "id": "uuid", "username": "admin", "role": "admin", "status": "active" }All routes require Bearer token + admin role.
List all clients, ordered by name ascending.
Response 200: Client[]
[{ "id": "uuid", "name": "Acme Corp", "status": "active" }]Get a single client.
Create a client.
Body: { "name": string, "status": "active" | "inactive" }
Response 201: Created Client
Errors: 400 validation, 409 duplicate name.
Update a client.
Body: { "name": string, "status": "active" | "inactive" }
Delete a client.
List all engagements with nested client.name, ordered by code.
Response 200: Engagement[] (includes client: { name })
Body:
{
"clientId": "uuid",
"code": "ACME-WEB-2026",
"description": "Website Redesign",
"type": "fixed_price",
"budget": 50000,
"startDate": "2026-01-01",
"endDate": "2026-06-30"
}type values: fixed_price or t_and_m.
Errors: 400 validation (missing fields, start >= end), 409 duplicate code.
Password is never included in any response.
List all users (id, username, role, status), ordered by username.
Body:
{
"username": "john.doe",
"password": "secret",
"role": "employee",
"status": "active"
}Username is normalised to lowercase. Password is hashed with bcrypt cost 12.
Errors: 409 duplicate username.
Update username, role, and/or status. Does not accept password.
Body: { "username"?: string, "role"?: string, "status"?: string }
Change a user's password.
Body: { "password": "newpassword" }
List all cost rates with nested user: { id, username }, ordered by user then start date descending.
Body:
{
"userId": "uuid",
"hourlyRate": 45.00,
"startDate": "2026-01-01",
"endDate": null
}endDate can be null (valid indefinitely) or a date string.
List all assignments with nested user and engagement (including client.name).
Body:
{
"userId": "uuid",
"engagementId": "uuid",
"billingRate": 95.00,
"startDate": "2026-01-01",
"endDate": "2026-12-31"
}Validation rules:
startDateandendDatemust fall within the engagement's date range.billingRateis required if engagement type =t_and_m, ignored forfixed_price.- Date range must not overlap with another assignment for the same user + engagement.
Errors: 400 validation, 404 engagement not found, 409 date overlap.
Query params:
periodStart(optional) — e.g.2026-04-01. If omitted, returns all timesheets for the user.userId(optional, admin only) — view another user's timesheet.
Employees always see only their own data.
Response 200: Timesheet (single, if periodStart given) or Timesheet[]
Change status from draft to submitted. Employees can submit their own; admins can submit any.
Errors: 400 not in draft status, 403 not the owner (employee).
Change status from submitted to approved.
Change status from submitted back to draft.
Query params (required):
periodStart— e.g.2026-04-01userId(optional, admin only)
Returns all time entries for the user in the given month, with nested assignment.engagement.client and timesheet.status.
List active assignments for a user on a given date (used to populate the assignment dropdown in the entry form).
Query params:
date(required) — e.g.2026-04-15userId(optional, admin only)
Create a time entry. Auto-creates a draft timesheet if none exists for that month.
Body:
{
"assignmentId": "uuid",
"date": "2026-04-15",
"actualHours": 4.0,
"billedHours": 3.5,
"description": "Worked on feature X",
"userId": "uuid"
}billedHours is required for T&M engagements. userId is only used by admins.
Permission rules:
- Employee: can only create in
drafttimesheets they own. - Admin: can create in
draftorsubmittedtimesheets. Cannot create inapproved.
Validation (blocking errors — return 400):
- Total
actual_hourson the same date for the user must be < 24. - Total
billed_hourson the same date for the same client (T&M engagements only) must be < 24.
Validation (non-blocking warnings — included in response):
- Total
actual_hourson the same date > 8: warning returned inwarnings[]. - Total
billed_hourson the same date for the same client > 8: warning returned.
Response 201:
{
"id": "uuid",
"timesheetId": "uuid",
"assignmentId": "uuid",
"date": "2026-04-15T00:00:00.000Z",
"actualHours": "4",
"billedHours": "3.5",
"description": "Worked on feature X",
"assignment": { ... },
"timesheet": { "id": "uuid", "status": "draft" },
"warnings": ["Total actual hours on this date exceed 8 hours"]
}Update a time entry. Same validation and permission rules as create.
Delete a time entry. Same permission rules.
Query params: userId, periodStart
Response 200:
[
{
"date": "2026-03-02T00:00:00.000Z",
"actualHours": 4,
"clientName": "Acme Corp",
"engagementCode": "ACME-WEB-2026",
"description": "Website redesign work"
}
]Same params. Returns an .xlsx file download.
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Query params: userId, periodStart, engagementId (optional)
- Without
engagementId: returns{ engagements: [...] }— list of T&M engagements with logged time. - With
engagementId: returns{ entries: [{ date, billedHours, description }] }.
Requires all three params. Returns .xlsx.
Query params: userId, periodStart, clientId (optional)
- Without
clientId: returns{ clients: [...] }— clients with T&M time entries. - With
clientId: returns{ entries: [{ date, billedHours, engagementCode, description }] }.
Requires all three params. Returns .xlsx.
Returns margin data for all engagements.
Response 200:
[
{
"clientName": "Acme Corp",
"engagementCode": "ACME-WEB-2026",
"engagementType": "fixed_price",
"revenue": 50000.00,
"cost": 2970.00,
"margin": 47030.00,
"marginPerHour": 712.58
}
]Calculation rules:
| Engagement type | Revenue formula | Cost formula |
|---|---|---|
fixed-price |
engagement.budget |
SUM(actual_hours * cost_rate) |
t&m |
SUM(billed_hours * assignment.billing_rate) |
SUM(actual_hours * cost_rate) |
margin = revenue - cost
margin_per_hour = margin / SUM(actual_hours) (0 if no hours)
All error responses use this shape:
{ "error": "Human-readable error message" }| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Validation error |
401 |
Unauthenticated |
403 |
Forbidden (wrong role) |
404 |
Resource not found |
409 |
Conflict (duplicate, overlap) |
500 |
Internal server error |