-
Notifications
You must be signed in to change notification settings - Fork 0
API Contract
The API contract evolves incrementally as each epic is implemented. This page documents endpoint specifications, request/response shapes, and error patterns for all routes.
GET /api/health
Auth required: No
Response (200 OK):
{
"status": "ok",
"timestamp": "2026-02-07T12:00:00.000Z"
}Cornerstone supports two authentication methods:
- Local authentication -- Email/password login for the initial admin account (setup flow) and as a fallback
- OIDC authentication -- OpenID Connect for all other users, with automatic provisioning on first login
All authenticated API requests use a server-side session. The session token is delivered as an HttpOnly cookie (cornerstone_session). There are no bearer tokens or API keys.
| Property | Value |
|---|---|
| Name | cornerstone_session |
| HttpOnly | true |
| SameSite | Strict |
| Secure | Configurable via SECURE_COOKIES env var (default: true) |
| Path | / |
| Max-Age | Configurable via SESSION_DURATION env var (default: 604800 seconds = 7 days) |
The cookie is set on successful login (local or OIDC) and cleared on logout.
| Variable | Default | Description |
|---|---|---|
SESSION_DURATION |
604800 |
Session lifetime in seconds (default: 7 days) |
SECURE_COOKIES |
true |
Set Secure flag on cookies; set to false for local dev without TLS |
OIDC_ISSUER |
(none) | OIDC provider issuer URL (e.g., https://auth.example.com/realms/main) |
OIDC_CLIENT_ID |
(none) | OIDC client ID |
OIDC_CLIENT_SECRET |
(none) | OIDC client secret |
OIDC_REDIRECT_URI |
(none) | OIDC callback URL (e.g., https://cornerstone.example.com/api/auth/oidc/callback) |
OIDC is enabled when all four OIDC variables (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI) are set. If any are missing, OIDC endpoints return 404.
Returns the current authentication state. This is the first endpoint the client calls on app load to determine what UI to show.
Auth required: No (returns different shapes based on auth state)
Response (200 OK -- authenticated):
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "Admin User",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z"
},
"oidcEnabled": true
}Response (200 OK -- not authenticated, setup required):
{
"user": null,
"setupRequired": true,
"oidcEnabled": false
}Response (200 OK -- not authenticated, setup complete):
{
"user": null,
"setupRequired": false,
"oidcEnabled": true
}Notes:
- This endpoint never returns 401. It always returns 200 with the current state.
-
setupRequired: truemeans zero users exist in the database; the client should show the setup form. -
oidcEnabledtells the client whether to show the "Login with SSO" button.
Creates the first admin user. Only works when zero users exist in the database.
Auth required: No
Request body:
{
"email": "admin@example.com",
"displayName": "Admin User",
"password": "securepassword123"
}| Field | Type | Validation |
|---|---|---|
email |
string | Required. Valid email format. |
displayName |
string | Required. 1-100 characters. |
password |
string | Required. Minimum 12 characters. |
Response (201 Created):
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "Admin User",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z"
}
}The response also sets the cornerstone_session cookie (the admin is logged in immediately after setup).
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid email, password too short, or missing fields |
| 403 | SETUP_COMPLETE |
At least one user already exists |
Authenticates a local user with email and password.
Auth required: No
Request body:
{
"email": "admin@example.com",
"password": "securepassword123"
}| Field | Type | Validation |
|---|---|---|
email |
string | Required. |
password |
string | Required. |
Response (200 OK):
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "Admin User",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z"
}
}The response also sets the cornerstone_session cookie.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing email or password |
| 401 | INVALID_CREDENTIALS |
Wrong email/password, or user is OIDC-only (no information leakage about which) |
| 401 | ACCOUNT_DEACTIVATED |
User account has been deactivated |
Security notes:
- The
INVALID_CREDENTIALSerror uses a generic message ("Invalid email or password") that does not reveal whether the email exists or the password was wrong. - Deactivated users receive
ACCOUNT_DEACTIVATEDto distinguish from invalid credentials (the user should contact an admin). - OIDC users attempting local login receive
INVALID_CREDENTIALS(they have no password_hash).
Destroys the current session and clears the session cookie.
Auth required: Yes
Request body: None
Response (204 No Content): Empty body. The cornerstone_session cookie is cleared.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Initiates the OIDC Authorization Code flow by redirecting the user to the OIDC provider.
Auth required: No
Query parameters:
| Parameter | Type | Description |
|---|---|---|
redirect |
string | Optional. URL path to redirect to after successful login (default: /) |
Response (302 Found): Redirects to the OIDC provider's authorization endpoint with:
response_type=code-
client_idfrom config -
redirect_urifrom config scope=openid email profile-
state= cryptographically random value (stored server-side temporarily for CSRF protection)
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 404 | OIDC_NOT_CONFIGURED |
OIDC environment variables are not set |
Handles the OIDC provider's redirect after the user authenticates.
Auth required: No
Query parameters (set by the OIDC provider):
| Parameter | Type | Description |
|---|---|---|
code |
string | Authorization code from the provider |
state |
string | CSRF state parameter to validate |
error |
string | Error code from the provider (if auth failed) |
error_description |
string | Error description from the provider (if auth failed) |
Response (302 Found): On success, redirects to the app (the path from the original redirect parameter, or /). The cornerstone_session cookie is set.
Behavior:
- Validates the
stateparameter against the stored value - Exchanges the
codefor tokens using the OIDC token endpoint - Extracts
sub,email,name/preferred_usernamefrom the ID token - Looks up the user by
(auth_provider='oidc', oidc_subject=sub) - If no user exists, provisions a new user with role
member(Story 1.5) - If the user exists but is deactivated, redirects to
/login?error=account_deactivated - Creates a session and sets the cookie
- Redirects to the app
Error responses (all via redirect to /login?error=<code>):
| Redirect Error | When |
|---|---|
oidc_not_configured |
OIDC env vars not set |
oidc_error |
OIDC provider returned an error |
invalid_state |
State parameter mismatch (CSRF) |
missing_email |
ID token does not contain an email claim |
email_conflict |
Email already used by a different auth provider |
account_deactivated |
User account has been deactivated |
Notes:
- OIDC callback errors redirect to the login page with a query parameter rather than returning JSON, because this endpoint is called via browser redirect (not AJAX).
- The
email_conflictcase occurs when an OIDC user's email matches an existing local user's email. This prevents account confusion.
Returns the full profile of the currently authenticated user.
Auth required: Yes
Response (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "Admin User",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z",
"updatedAt": "2026-02-08T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Updates the current user's profile. Only displayName can be changed by the user.
Auth required: Yes
Request body:
{
"displayName": "New Display Name"
}| Field | Type | Validation |
|---|---|---|
displayName |
string | Required. 1-100 characters. |
Response (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "New Display Name",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z",
"updatedAt": "2026-02-08T12:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid or missing displayName |
| 401 | UNAUTHORIZED |
No valid session |
Changes the current user's password. Only available for local auth users.
Auth required: Yes
Request body:
{
"currentPassword": "oldpassword123",
"newPassword": "newpassword456"
}| Field | Type | Validation |
|---|---|---|
currentPassword |
string | Required. Must match stored hash. |
newPassword |
string | Required. Minimum 12 characters. |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing fields or newPassword too short |
| 401 | UNAUTHORIZED |
No valid session |
| 401 | INVALID_CREDENTIALS |
Current password is incorrect |
| 403 | FORBIDDEN |
User is OIDC-authenticated (cannot change password) |
Returns a list of all users. Supports search filtering.
Auth required: Yes (admin role)
Query parameters:
| Parameter | Type | Description |
|---|---|---|
q |
string | Optional. Search filter matching email or display_name (case-insensitive LIKE) |
Response (200 OK):
{
"users": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"displayName": "Admin User",
"role": "admin",
"authProvider": "local",
"createdAt": "2026-02-08T10:00:00.000Z",
"deactivatedAt": null
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"email": "member@example.com",
"displayName": "Member User",
"role": "member",
"authProvider": "oidc",
"createdAt": "2026-02-08T11:00:00.000Z",
"deactivatedAt": null
}
]
}Notes:
- Returns both active and deactivated users (deactivated users have
deactivatedAtset) - No pagination needed for <5 users; will be revisited if the user count model changes
- The
password_hashandoidc_subjectfields are never exposed in API responses
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 403 | FORBIDDEN |
User is not an admin |
Updates a user's role or display name. Admins can also update a user's email.
Auth required: Yes (admin role)
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | User UUID |
Request body (all fields optional, at least one required):
{
"displayName": "Updated Name",
"email": "new-email@example.com",
"role": "admin"
}| Field | Type | Validation |
|---|---|---|
displayName |
string | 1-100 characters |
email |
string | Valid email format |
role |
string |
"admin" or "member"
|
Response (200 OK):
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"email": "new-email@example.com",
"displayName": "Updated Name",
"role": "admin",
"authProvider": "oidc",
"createdAt": "2026-02-08T11:00:00.000Z",
"deactivatedAt": null
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 403 | FORBIDDEN |
User is not an admin |
| 404 | NOT_FOUND |
User ID does not exist |
| 409 | LAST_ADMIN |
Attempting to demote the last admin to member |
| 409 | CONFLICT |
Email already in use by another user |
Deactivates a user account (soft delete). Sets deactivated_at and invalidates all active sessions.
Auth required: Yes (admin role)
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | User UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 403 | FORBIDDEN |
User is not an admin |
| 404 | NOT_FOUND |
User ID does not exist |
| 409 | SELF_DEACTIVATION |
Admin attempting to deactivate themselves |
| 409 | LAST_ADMIN |
Attempting to deactivate the last admin |
Notes:
- This is a soft delete: the user record is retained with
deactivated_atset to the current timestamp - All active sessions for the deactivated user are deleted immediately
- Deactivated users cannot log in (local login returns
ACCOUNT_DEACTIVATED; OIDC callback redirects with error) - An already-deactivated user can be "re-deleted" without error (idempotent)
All /api/* routes are protected by a Fastify preHandler hook except:
| Unprotected Routes | Reason |
|---|---|
GET /api/health |
Health check for Docker/monitoring |
GET /api/auth/me |
Must work without auth to detect setup state |
POST /api/auth/setup |
Must work without auth (no users exist yet) |
POST /api/auth/login |
Must work without auth (logging in) |
GET /api/auth/oidc/login |
Must work without auth (initiating OIDC flow) |
GET /api/auth/oidc/callback |
Must work without auth (OIDC provider callback) |
Admin-only routes additionally check user.role === 'admin' via a requireRole('admin') decorator.
Work items are the central entity of the application. All endpoints in this section require authentication. Both admin and member roles can perform all work item operations (no role restriction).
Work item summary (used in list responses):
interface WorkItemSummary {
id: string;
title: string;
status: WorkItemStatus;
startDate: string | null;
endDate: string | null;
durationDays: number | null;
assignedUser: UserSummary | null;
tags: TagResponse[];
createdAt: string;
updatedAt: string;
}Work item detail (used in single-item responses):
interface WorkItemDetail {
id: string;
title: string;
description: string | null;
status: WorkItemStatus;
startDate: string | null;
endDate: string | null;
durationDays: number | null;
startAfter: string | null;
startBefore: string | null;
assignedUser: UserSummary | null;
createdBy: UserSummary | null;
tags: TagResponse[];
subtasks: SubtaskResponse[];
dependencies: {
predecessors: DependencyResponse[];
successors: DependencyResponse[];
};
budgets: WorkItemBudgetLine[];
createdAt: string;
updatedAt: string;
}Supporting types:
type WorkItemStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked';
type DependencyType = 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish';
type ConfidenceLevel = 'own_estimate' | 'professional_estimate' | 'quote' | 'invoice';
interface UserSummary {
id: string;
displayName: string;
email: string;
}
interface TagResponse {
id: string;
name: string;
color: string | null;
}
interface SubtaskResponse {
id: string;
title: string;
isCompleted: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
interface DependencyResponse {
workItem: WorkItemSummary;
dependencyType: DependencyType;
leadLagDays: number; // Lead (negative) or lag (positive) offset in days; default 0
}
interface WorkItemBudgetLine {
id: string;
workItemId: string;
description: string | null;
plannedAmount: number;
confidence: ConfidenceLevel;
confidenceMargin: number; // Computed: 0.20, 0.10, 0.05, or 0.00
budgetCategory: BudgetCategoryResponse | null;
budgetSource: BudgetSourceSummary | null;
vendor: VendorSummary | null;
actualCost: number; // Computed: sum of all linked invoices
actualCostPaid: number; // Computed: sum of linked invoices with status 'paid' or 'claimed'
invoiceCount: number; // Computed: count of linked invoices
createdBy: UserSummary | null;
createdAt: string;
updatedAt: string;
}
interface BudgetSourceSummary {
id: string;
name: string;
sourceType: string;
}
interface VendorSummary {
id: string;
name: string;
specialty: string | null;
}Notes on the detail response:
-
tagsare always embedded (not paginated; a work item is unlikely to have more than ~20 tags). -
subtasksare always embedded, sorted bysortOrderascending. -
dependencies.predecessorslists work items that this item depends on.dependencies.successorslists work items that depend on this item. Both include a summary of the related work item. -
budgetsare always embedded, listing all budget lines for the work item. Each budget line includes computed fields (confidenceMargin,actualCost,actualCostPaid,invoiceCount) derived from linked invoices and the confidence level enum. - Notes are NOT embedded in the detail response. They are fetched separately via
GET /api/work-items/:id/notesto allow independent pagination if needed in the future.
Returns a paginated, filterable, sortable list of work items.
Auth required: Yes
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer | 1 |
Page number (1-indexed) |
pageSize |
integer | 25 |
Items per page (max 100) |
status |
string | (none) | Filter by status. One of: not_started, in_progress, completed, blocked
|
assignedUserId |
string | (none) | Filter by assigned user UUID |
tagId |
string | (none) | Filter by tag UUID (returns work items that have this tag) |
q |
string | (none) | Search title and description (case-insensitive substring match) |
sortBy |
string | created_at |
Sort field. One of: title, status, start_date, end_date, created_at, updated_at
|
sortOrder |
string | desc |
Sort direction: asc or desc
|
Response (200 OK):
{
"items": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Foundation Work",
"status": "in_progress",
"startDate": "2026-03-01",
"endDate": "2026-03-15",
"durationDays": 14,
"assignedUser": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"tags": [{ "id": "t1", "name": "Structural", "color": "#FF5733" }],
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"pageSize": 25,
"totalItems": 1,
"totalPages": 1
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid query parameters (e.g., invalid status value, page < 1, pageSize > 100) |
| 401 | UNAUTHORIZED |
No valid session |
Creates a new work item.
Auth required: Yes
Request body:
{
"title": "Foundation Work",
"description": "Excavation and concrete pouring for the foundation",
"status": "not_started",
"startDate": "2026-03-01",
"endDate": "2026-03-15",
"durationDays": 14,
"startAfter": "2026-02-28",
"startBefore": "2026-03-10",
"assignedUserId": "550e8400-e29b-41d4-a716-446655440000",
"tagIds": ["t1", "t2"]
}| Field | Type | Required | Validation |
|---|---|---|---|
title |
string | Yes | 1-500 characters |
description |
string | No | Max 10000 characters |
status |
string | No | One of: not_started, in_progress, completed, blocked. Default: not_started
|
startDate |
string | No | ISO 8601 date (YYYY-MM-DD) |
endDate |
string | No | ISO 8601 date (YYYY-MM-DD). Must be >= startDate if both provided |
durationDays |
integer | No | >= 0 |
startAfter |
string | No | ISO 8601 date (YYYY-MM-DD) |
startBefore |
string | No | ISO 8601 date (YYYY-MM-DD). Must be >= startAfter if both provided |
assignedUserId |
string | No | Must be a valid, active user UUID |
tagIds |
string[] | No | Array of existing tag UUIDs |
The createdBy field is automatically set to the authenticated user's ID.
Response (201 Created):
Returns a WorkItemDetail object (same shape as GET /api/work-items/:id).
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty title, invalid status, invalid date format, startDate > endDate, startAfter > startBefore, non-existent assignedUserId or tagIds |
| 401 | UNAUTHORIZED |
No valid session |
Returns a single work item with full detail including tags, subtasks, and dependencies.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Work item UUID |
Response (200 OK):
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Foundation Work",
"description": "Excavation and concrete pouring for the foundation",
"status": "in_progress",
"startDate": "2026-03-01",
"endDate": "2026-03-15",
"durationDays": 14,
"startAfter": "2026-02-28",
"startBefore": "2026-03-10",
"assignedUser": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"tags": [{ "id": "t1", "name": "Structural", "color": "#FF5733" }],
"subtasks": [
{
"id": "s1",
"title": "Excavate site",
"isCompleted": true,
"sortOrder": 0,
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-16T08:00:00.000Z"
},
{
"id": "s2",
"title": "Pour concrete",
"isCompleted": false,
"sortOrder": 1,
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
],
"dependencies": {
"predecessors": [
{
"workItem": {
"id": "prev-item-id",
"title": "Site Survey",
"status": "completed",
"startDate": "2026-02-20",
"endDate": "2026-02-22",
"durationDays": 2,
"assignedUser": null,
"tags": [],
"createdAt": "2026-02-10T10:00:00.000Z",
"updatedAt": "2026-02-22T10:00:00.000Z"
},
"dependencyType": "finish_to_start",
"leadLagDays": 0
}
],
"successors": []
},
"budgets": [
{
"id": "b1",
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"description": "Foundation concrete materials",
"plannedAmount": 8500.0,
"confidence": "quote",
"confidenceMargin": 0.05,
"budgetCategory": {
"id": "bc-materials",
"name": "Materials",
"description": "Raw materials and building supplies",
"color": "#3B82F6",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
},
"budgetSource": {
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan"
},
"vendor": {
"id": "v1",
"name": "ABC Concrete",
"specialty": "Concrete Work"
},
"actualCost": 2500.0,
"actualCostPaid": 2500.0,
"invoiceCount": 1,
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
],
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-16T08:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Updates a work item. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Work item UUID |
Request body (all fields optional, at least one required):
{
"title": "Updated Foundation Work",
"description": "Updated description",
"status": "completed",
"startDate": "2026-03-01",
"endDate": "2026-03-20",
"durationDays": 19,
"startAfter": null,
"startBefore": null,
"assignedUserId": "550e8400-e29b-41d4-a716-446655440000",
"tagIds": ["t1", "t3"]
}| Field | Type | Validation |
|---|---|---|
title |
string | 1-500 characters |
description |
string | null | Max 10000 characters; null clears the description |
status |
string | One of: not_started, in_progress, completed, blocked
|
startDate |
string | null | ISO 8601 date or null to clear |
endDate |
string | null | ISO 8601 date or null to clear. Must be >= startDate if both are set |
durationDays |
integer | null | >= 0 or null to clear |
startAfter |
string | null | ISO 8601 date or null to clear |
startBefore |
string | null | ISO 8601 date or null to clear. Must be >= startAfter if both are set |
assignedUserId |
string | null | Valid active user UUID or null to unassign |
tagIds |
string[] | Array of tag UUIDs. Replaces all current tags (set-semantics). Pass [] to remove all tags. |
Notes:
- When
tagIdsis provided, it replaces the entire tag set for the work item. Existing tag associations not in the array are removed; new ones are added. - Nullable fields can be explicitly set to
nullto clear the value.
Response (200 OK):
Returns the updated WorkItemDetail object.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid field values, startDate > endDate, startAfter > startBefore, non-existent assignedUserId or tagIds, no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Deletes a work item. Cascades to notes, subtasks, tag associations, and dependencies.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Work item UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Tags are a shared resource used to organize work items (and later, household items). The tag list is expected to remain small (fewer than ~100 tags) so no pagination is used.
Returns all tags sorted alphabetically by name.
Auth required: Yes
Response (200 OK):
{
"tags": [
{ "id": "t1", "name": "Electrical", "color": "#FFD700" },
{ "id": "t2", "name": "Plumbing", "color": "#4682B4" },
{ "id": "t3", "name": "Structural", "color": "#FF5733" }
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Creates a new tag.
Auth required: Yes
Request body:
{
"name": "Electrical",
"color": "#FFD700"
}| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | Yes | 1-50 characters. Must be unique (case-insensitive). |
color |
string | No | Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null |
Response (201 Created):
{
"id": "t1",
"name": "Electrical",
"color": "#FFD700",
"createdAt": "2026-02-15T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty name, name too long, invalid color format |
| 401 | UNAUTHORIZED |
No valid session |
| 409 | CONFLICT |
A tag with the same name already exists (case-insensitive) |
Updates a tag's name and/or color.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Tag UUID |
Request body (at least one field required):
{
"name": "Electrical Work",
"color": "#FFAA00"
}| Field | Type | Validation |
|---|---|---|
name |
string | 1-50 characters. Must be unique (case-insensitive). |
color |
string | null | Hex color code or null to clear |
Response (200 OK):
{
"id": "t1",
"name": "Electrical Work",
"color": "#FFAA00",
"createdAt": "2026-02-15T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Tag ID does not exist |
| 409 | CONFLICT |
A tag with the new name already exists (case-insensitive) |
Deletes a tag. Cascading removes the tag from all work items that reference it.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Tag UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Tag ID does not exist |
Notes are nested under work items. All note endpoints require the parent work item to exist.
Returns all notes for a work item, sorted by created_at descending (newest first).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Response (200 OK):
{
"notes": [
{
"id": "n1",
"content": "Foundation concrete has cured successfully.",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User"
},
"createdAt": "2026-02-16T14:00:00.000Z",
"updatedAt": "2026-02-16T14:00:00.000Z"
}
]
}The createdBy object contains id and displayName only (not the full user response). If the creating user has been deleted (SET NULL), createdBy is null.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Adds a note to a work item. The createdBy is automatically set to the authenticated user.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Request body:
{
"content": "Foundation concrete has cured successfully."
}| Field | Type | Required | Validation |
|---|---|---|---|
content |
string | Yes | 1-10000 characters |
Response (201 Created):
{
"id": "n1",
"content": "Foundation concrete has cured successfully.",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User"
},
"createdAt": "2026-02-16T14:00:00.000Z",
"updatedAt": "2026-02-16T14:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty content |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Updates a note's content. Only the note's author or an admin can edit a note.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
noteId |
string | Note UUID |
Request body:
{
"content": "Updated note content."
}| Field | Type | Required | Validation |
|---|---|---|---|
content |
string | Yes | 1-10000 characters |
Response (200 OK): Returns the updated note object.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty content |
| 401 | UNAUTHORIZED |
No valid session |
| 403 | FORBIDDEN |
Non-admin user trying to edit another user's note |
| 404 | NOT_FOUND |
Work item or note does not exist |
Deletes a note. Only the note's author or an admin can delete a note.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
noteId |
string | Note UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 403 | FORBIDDEN |
Non-admin user trying to delete another user's note |
| 404 | NOT_FOUND |
Work item or note does not exist |
Subtasks are nested under work items. All subtask endpoints require the parent work item to exist.
Returns all subtasks for a work item, sorted by sort_order ascending.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Response (200 OK):
{
"subtasks": [
{
"id": "s1",
"title": "Excavate site",
"isCompleted": true,
"sortOrder": 0,
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-16T08:00:00.000Z"
},
{
"id": "s2",
"title": "Pour concrete",
"isCompleted": false,
"sortOrder": 1,
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Adds a subtask to a work item. If sortOrder is not provided, the subtask is appended to the end (max sort_order + 1).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Request body:
{
"title": "Excavate site",
"sortOrder": 0
}| Field | Type | Required | Validation |
|---|---|---|---|
title |
string | Yes | 1-500 characters |
sortOrder |
integer | No | >= 0 |
Response (201 Created):
{
"id": "s1",
"title": "Excavate site",
"isCompleted": false,
"sortOrder": 0,
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty title, non-integer sortOrder |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Updates a subtask's title, completion status, and/or sort order.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
subtaskId |
string | Subtask UUID |
Request body (at least one field required):
{
"title": "Updated subtask title",
"isCompleted": true,
"sortOrder": 2
}| Field | Type | Validation |
|---|---|---|
title |
string | 1-500 characters |
isCompleted |
boolean | true or false |
sortOrder |
integer | >= 0 |
Response (200 OK): Returns the updated subtask object.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item or subtask does not exist |
Deletes a subtask.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
subtaskId |
string | Subtask UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item or subtask does not exist |
Bulk reorders all subtasks for a work item. Accepts an ordered array of subtask IDs and updates the sort_order of each subtask to match the array index.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Request body:
{
"subtaskIds": ["s2", "s1", "s3"]
}| Field | Type | Required | Validation |
|---|---|---|---|
subtaskIds |
string[] | Yes | Array of subtask UUIDs. Must contain ALL subtask IDs belonging to this work item (no subset reordering). |
Each subtask's sort_order is set to its index in the array (0-indexed). All subtask updatedAt timestamps are updated.
Response (200 OK):
{
"subtasks": [
{
"id": "s2",
"title": "Pour concrete",
"isCompleted": false,
"sortOrder": 0,
"createdAt": "...",
"updatedAt": "..."
},
{
"id": "s1",
"title": "Excavate site",
"isCompleted": true,
"sortOrder": 1,
"createdAt": "...",
"updatedAt": "..."
},
{
"id": "s3",
"title": "Inspect foundation",
"isCompleted": false,
"sortOrder": 2,
"createdAt": "...",
"updatedAt": "..."
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty array, any subtask ID does not belong to this work item, array does not contain all subtask IDs for this work item, duplicate IDs |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Dependencies define predecessor/successor relationships between work items for scheduling.
Returns both predecessors and successors for a work item.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Work item UUID |
Response (200 OK):
{
"predecessors": [
{
"workItem": {
"id": "prev-item-id",
"title": "Site Survey",
"status": "completed",
"startDate": "2026-02-20",
"endDate": "2026-02-22",
"durationDays": 2,
"assignedUser": null,
"tags": [],
"createdAt": "2026-02-10T10:00:00.000Z",
"updatedAt": "2026-02-22T10:00:00.000Z"
},
"dependencyType": "finish_to_start",
"leadLagDays": 0
}
],
"successors": [
{
"workItem": {
"id": "next-item-id",
"title": "Framing",
"status": "not_started",
"startDate": null,
"endDate": null,
"durationDays": null,
"assignedUser": null,
"tags": [],
"createdAt": "2026-02-10T10:00:00.000Z",
"updatedAt": "2026-02-10T10:00:00.000Z"
},
"dependencyType": "finish_to_start",
"leadLagDays": 0
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Adds a dependency to a work item. The work item identified by :id becomes the successor (it depends on predecessorId).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Successor work item UUID (the item that depends on the predecessor) |
Request body:
{
"predecessorId": "prev-item-id",
"dependencyType": "finish_to_start",
"leadLagDays": 3
}| Field | Type | Required | Validation |
|---|---|---|---|
predecessorId |
string | Yes | Must be a valid work item UUID, different from :id
|
dependencyType |
string | No | One of: finish_to_start, start_to_start, finish_to_finish, start_to_finish. Default: finish_to_start
|
leadLagDays |
integer | No | Lead (negative) or lag (positive) offset in days. Default: 0
|
Circular dependency detection: Before creating the dependency, the server performs a depth-first traversal of the dependency graph starting from the predecessor, following the successor direction. If the traversal reaches the current work item (:id), a cycle would be created and the request is rejected.
Response (201 Created):
{
"predecessorId": "prev-item-id",
"successorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dependencyType": "finish_to_start",
"leadLagDays": 3
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing predecessorId, self-referencing dependency (predecessorId === :id), invalid dependencyType |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Either work item (successor or predecessor) does not exist |
| 409 | CIRCULAR_DEPENDENCY |
Adding this dependency would create a cycle. The details field includes cycle (array of work item IDs in the detected cycle) |
| 409 | DUPLICATE_DEPENDENCY |
A dependency between these two work items already exists |
Updates a dependency's properties (dependency type and/or lead/lag days).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Successor work item UUID |
predecessorId |
string | Predecessor work item UUID |
Request body (at least one field required):
{
"dependencyType": "start_to_start",
"leadLagDays": -2
}| Field | Type | Validation |
|---|---|---|
dependencyType |
string | One of: finish_to_start, start_to_start, finish_to_finish, start_to_finish
|
leadLagDays |
integer | Lead (negative) or lag (positive) offset in days |
Response (200 OK):
{
"predecessorId": "prev-item-id",
"successorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dependencyType": "start_to_start",
"leadLagDays": -2
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
No fields provided, invalid dependencyType |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
The dependency (this predecessor/successor pair) does not exist |
Removes a dependency from a work item.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Successor work item UUID |
predecessorId |
string | Predecessor work item UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
The dependency (this predecessor/successor pair) does not exist |
Budget lines are nested under work items. Each budget line represents a cost estimate or allocation with its own confidence level, optional vendor, budget category, and budget source. All budget line endpoints require the parent work item to exist. Budget lines are NOT paginated (a work item typically has fewer than ~20 budget lines).
Returns all budget lines for a work item.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Response (200 OK):
{
"budgets": [
{
"id": "b1",
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"description": "Foundation concrete materials",
"plannedAmount": 8500.0,
"confidence": "quote",
"confidenceMargin": 0.05,
"budgetCategory": {
"id": "bc-materials",
"name": "Materials",
"description": "Raw materials and building supplies",
"color": "#3B82F6",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
},
"budgetSource": {
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan"
},
"vendor": {
"id": "v1",
"name": "ABC Concrete",
"specialty": "Concrete Work"
},
"actualCost": 2500.0,
"actualCostPaid": 2500.0,
"invoiceCount": 1,
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Creates a new budget line for a work item. The createdBy is automatically set to the authenticated user.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
Request body:
{
"description": "Foundation concrete materials",
"plannedAmount": 8500.0,
"confidence": "quote",
"budgetCategoryId": "bc-materials",
"budgetSourceId": "bs-1",
"vendorId": "v1"
}| Field | Type | Required | Validation |
|---|---|---|---|
description |
string | null | No | Max 500 characters |
plannedAmount |
number | Yes | Must be >= 0 |
confidence |
string | No | One of: own_estimate, professional_estimate, quote, invoice. Default: own_estimate
|
budgetCategoryId |
string | null | No | Must be a valid budget category UUID if provided |
budgetSourceId |
string | null | No | Must be a valid budget source UUID if provided |
vendorId |
string | null | No | Must be a valid vendor UUID if provided |
Response (201 Created):
{
"budget": {
"id": "b1",
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"description": "Foundation concrete materials",
"plannedAmount": 8500.0,
"confidence": "quote",
"confidenceMargin": 0.05,
"budgetCategory": {
"id": "bc-materials",
"name": "Materials",
"description": "Raw materials and building supplies",
"color": "#3B82F6",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
},
"budgetSource": {
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan"
},
"vendor": {
"id": "v1",
"name": "ABC Concrete",
"specialty": "Concrete Work"
},
"actualCost": 0,
"actualCostPaid": 0,
"invoiceCount": 0,
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T14:00:00.000Z",
"updatedAt": "2026-02-20T14:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing plannedAmount, negative amount, invalid confidence value, non-existent budgetCategoryId/budgetSourceId/vendorId, description too long |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item ID does not exist |
Updates a budget line. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
budgetId |
string | Budget line UUID |
Request body (all fields optional, at least one required):
{
"description": "Updated description",
"plannedAmount": 9000.0,
"confidence": "invoice",
"budgetCategoryId": "bc-materials",
"budgetSourceId": null,
"vendorId": "v1"
}| Field | Type | Validation |
|---|---|---|
description |
string | null | Max 500 characters; null clears the description |
plannedAmount |
number | Must be >= 0 |
confidence |
string | One of: own_estimate, professional_estimate, quote, invoice
|
budgetCategoryId |
string | null | Valid budget category UUID or null to clear |
budgetSourceId |
string | null | Valid budget source UUID or null to clear |
vendorId |
string | null | Valid vendor UUID or null to clear |
Response (200 OK):
Returns the updated WorkItemBudgetLine object wrapped in { "budget": ... }.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
No fields provided, negative amount, invalid confidence value, non-existent budgetCategoryId/budgetSourceId/vendorId, description too long |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item or budget line does not exist, or budget line does not belong to this work item |
Deletes a budget line. Fails with 409 Conflict if the budget line has invoices linked to it.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
workItemId |
string | Work item UUID |
budgetId |
string | Budget line UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Work item or budget line does not exist, or budget line does not belong to this work item |
| 409 | BUDGET_LINE_IN_USE |
Budget line has linked invoices and cannot be deleted |
Notes:
- The
BUDGET_LINE_IN_USEerror response includes adetailsfield indicating the number of linked invoices:{ "error": { "code": "BUDGET_LINE_IN_USE", "message": "Budget line has linked invoices and cannot be deleted", "details": { "invoiceCount": 3 } } } - Budget lines with no linked invoices can be freely deleted.
- The budget line must belong to the specified work item. If the budget line exists but belongs to a different work item, a 404 is returned.
Budget management endpoints for tracking construction costs, vendors, invoices, financing sources, and subsidy programs. All endpoints in this section require authentication. Both admin and member roles can perform all budget operations (no role restriction).
interface BudgetCategoryResponse {
id: string;
name: string;
description: string | null;
color: string | null;
sortOrder: number;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Budget categories are a small, finite collection (typically 10-20 items). They are NOT paginated.
Returns all budget categories sorted by sort_order ascending.
Auth required: Yes
Response (200 OK):
{
"categories": [
{
"id": "bc-materials",
"name": "Materials",
"description": "Raw materials and building supplies",
"color": "#3B82F6",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
},
{
"id": "bc-labor",
"name": "Labor",
"description": "Contractor and worker labor costs",
"color": "#EF4444",
"sortOrder": 1,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Creates a new budget category.
Auth required: Yes
Request body:
{
"name": "Appliances",
"description": "Kitchen and household appliances",
"color": "#F472B6",
"sortOrder": 10
}| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | Yes | 1-100 characters. Must be unique (case-insensitive). |
description |
string | No | Max 500 characters. |
color |
string | No | Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null |
sortOrder |
integer | No | >= 0. Default: 0 |
Response (201 Created):
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Appliances",
"description": "Kitchen and household appliances",
"color": "#F472B6",
"sortOrder": 10,
"createdAt": "2026-02-20T14:00:00.000Z",
"updatedAt": "2026-02-20T14:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty name, name too long, description too long, invalid color format, negative sortOrder |
| 401 | UNAUTHORIZED |
No valid session |
| 409 | CONFLICT |
A category with the same name already exists (case-insensitive) |
Returns a single budget category.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget category UUID |
Response (200 OK):
{
"id": "bc-materials",
"name": "Materials",
"description": "Raw materials and building supplies",
"color": "#3B82F6",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget category ID does not exist |
Updates a budget category's name, description, color, and/or sort order.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget category UUID |
Request body (at least one field required):
{
"name": "Building Materials",
"description": "Updated description",
"color": "#2563EB",
"sortOrder": 0
}| Field | Type | Validation |
|---|---|---|
name |
string | 1-100 characters. Must be unique (case-insensitive). |
description |
string | null | Max 500 characters; null clears the description |
color |
string | null | Hex color code or null to clear |
sortOrder |
integer | >= 0 |
Response (200 OK):
{
"id": "bc-materials",
"name": "Building Materials",
"description": "Updated description",
"color": "#2563EB",
"sortOrder": 0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T15:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget category ID does not exist |
| 409 | CONFLICT |
A category with the new name already exists (case-insensitive) |
Deletes a budget category. Fails with 409 Conflict if the category is referenced by work item budget lines (via work_item_budgets.budget_category_id) or by subsidy programs (via subsidy_program_categories).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget category UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget category ID does not exist |
| 409 | CATEGORY_IN_USE |
Category is referenced by budget lines or subsidy programs and cannot be deleted |
Notes:
- The
CATEGORY_IN_USEerror response includes adetailsfield indicating what references the category:{ "error": { "code": "CATEGORY_IN_USE", "message": "Budget category is in use and cannot be deleted", "details": { "subsidyProgramCount": 2, "budgetLineCount": 3 } } }
Vendor summary (used in list responses):
interface VendorResponse {
id: string;
name: string;
specialty: string | null;
phone: string | null;
email: string | null;
address: string | null;
notes: string | null;
createdBy: UserSummary | null;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Vendor detail (used in single-item responses, adds computed fields):
interface VendorDetailResponse extends VendorResponse {
invoiceCount: number;
outstandingBalance: number;
}Notes on computed fields:
-
invoiceCountis the total number of invoices for this vendor (all statuses). -
outstandingBalanceis the sum ofamountfor all invoices with statuspendingorclaimed. Returns0if no outstanding invoices exist.
Vendors/contractors are a core budget management entity. The vendor list may grow to dozens of entries for a large construction project, so pagination is used. All vendor endpoints require authentication. Both admin and member roles can perform all vendor operations (no role restriction).
Returns a paginated, searchable list of vendors sorted by name ascending.
Auth required: Yes
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer | 1 |
Page number (1-indexed) |
pageSize |
integer | 25 |
Items per page (max 100) |
q |
string | (none) | Search filter matching name or specialty (case-insensitive substring match) |
sortBy |
string | name |
Sort field. One of: name, specialty, created_at, updated_at
|
sortOrder |
string | asc |
Sort direction: asc or desc
|
Response (200 OK):
{
"items": [
{
"id": "v1",
"name": "ABC Plumbing",
"specialty": "Plumbing",
"phone": "+1-555-0100",
"email": "info@abcplumbing.com",
"address": "123 Main St, Springfield",
"notes": "Licensed and insured",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"pageSize": 25,
"totalItems": 1,
"totalPages": 1
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid query parameters (e.g., page < 1, pageSize > 100, invalid sortBy) |
| 401 | UNAUTHORIZED |
No valid session |
Creates a new vendor.
Auth required: Yes
Request body:
{
"name": "ABC Plumbing",
"specialty": "Plumbing",
"phone": "+1-555-0100",
"email": "info@abcplumbing.com",
"address": "123 Main St, Springfield",
"notes": "Licensed and insured"
}| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | Yes | 1-200 characters |
specialty |
string | No | Max 200 characters |
phone |
string | No | Max 50 characters |
email |
string | No | Max 200 characters. Valid email format if provided. |
address |
string | No | Max 500 characters |
notes |
string | No | Max 2000 characters |
The createdBy field is automatically set to the authenticated user's ID.
Response (201 Created):
{
"vendor": {
"id": "v1",
"name": "ABC Plumbing",
"specialty": "Plumbing",
"phone": "+1-555-0100",
"email": "info@abcplumbing.com",
"address": "123 Main St, Springfield",
"notes": "Licensed and insured",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T14:00:00.000Z",
"updatedAt": "2026-02-20T14:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty name, name too long, invalid email format, other field length violations |
| 401 | UNAUTHORIZED |
No valid session |
Returns a single vendor with full details including computed invoice statistics.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Vendor UUID |
Response (200 OK):
{
"vendor": {
"id": "v1",
"name": "ABC Plumbing",
"specialty": "Plumbing",
"phone": "+1-555-0100",
"email": "info@abcplumbing.com",
"address": "123 Main St, Springfield",
"notes": "Licensed and insured",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"invoiceCount": 3,
"outstandingBalance": 4500.0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID does not exist |
Updates a vendor's information. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Vendor UUID |
Request body (all fields optional, at least one required):
{
"name": "ABC Plumbing Co.",
"specialty": "Plumbing & HVAC",
"phone": "+1-555-0101",
"email": "contact@abcplumbing.com",
"address": "456 Oak Ave, Springfield",
"notes": "Updated notes"
}| Field | Type | Validation |
|---|---|---|
name |
string | 1-200 characters |
specialty |
string | null | Max 200 characters; null clears the field |
phone |
string | null | Max 50 characters; null clears the field |
email |
string | null | Max 200 characters. Valid email format if provided. null clears the field. |
address |
string | null | Max 500 characters; null clears the field |
notes |
string | null | Max 2000 characters; null clears the field |
Response (200 OK):
{
"vendor": {
"id": "v1",
"name": "ABC Plumbing Co.",
"specialty": "Plumbing & HVAC",
"phone": "+1-555-0101",
"email": "contact@abcplumbing.com",
"address": "456 Oak Ave, Springfield",
"notes": "Updated notes",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"invoiceCount": 3,
"outstandingBalance": 4500.0,
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T15:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID does not exist |
Deletes a vendor. Fails with 409 Conflict if the vendor has invoices or is referenced by work item budget lines (via work_item_budgets.vendor_id).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Vendor UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID does not exist |
| 409 | VENDOR_IN_USE |
Vendor has invoices or is referenced by budget lines and cannot be deleted |
Notes:
- The
VENDOR_IN_USEerror response includes adetailsfield indicating what references the vendor:{ "error": { "code": "VENDOR_IN_USE", "message": "Vendor is in use and cannot be deleted", "details": { "invoiceCount": 3, "budgetLineCount": 2 } } } - Vendors with no invoices and no budget line references can be freely deleted.
type InvoiceStatus = 'pending' | 'paid' | 'claimed';
interface InvoiceResponse {
id: string;
vendorId: string;
vendorName: string; // Resolved from vendors table
workItemBudgetId: string | null;
invoiceNumber: string | null;
amount: number;
date: string; // ISO 8601 date (YYYY-MM-DD)
dueDate: string | null; // ISO 8601 date
status: InvoiceStatus;
notes: string | null;
createdBy: UserSummary | null;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Notes on invoice status:
-
pending: Invoice received, payment not yet made (default) -
paid: Payment has been completed -
claimed: Invoice has been submitted for reimbursement (e.g., to a subsidy program or financing source)
The previous overdue status has been removed. Overdue detection is better handled as a computed state by comparing dueDate to the current date, rather than a manually-set status.
Invoices are nested under vendors and track payments for construction work. All invoice endpoints require authentication. Both admin and member roles can perform all invoice operations (no role restriction). All invoice endpoints require the parent vendor to exist.
The invoice list is NOT paginated. A vendor typically has fewer than ~50 invoices; pagination adds complexity without benefit at this scale.
Returns all invoices for a vendor, sorted by date descending (newest first).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
vendorId |
string | Vendor UUID |
Response (200 OK):
{
"invoices": [
{
"id": "inv-1",
"vendorId": "v1",
"vendorName": "ABC Construction",
"workItemBudgetId": "b1",
"invoiceNumber": "INV-2026-001",
"amount": 2500.0,
"date": "2026-03-15",
"dueDate": "2026-04-15",
"status": "pending",
"notes": "First payment for foundation work",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-03-15T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID does not exist |
Creates a new invoice for a vendor. The createdBy is automatically set to the authenticated user.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
vendorId |
string | Vendor UUID |
Request body:
{
"invoiceNumber": "INV-2026-001",
"amount": 2500.0,
"date": "2026-03-15",
"dueDate": "2026-04-15",
"status": "pending",
"notes": "First payment for foundation work",
"workItemBudgetId": "b1"
}| Field | Type | Required | Validation |
|---|---|---|---|
invoiceNumber |
string | No | Max 100 characters |
amount |
number | Yes | Must be > 0 |
date |
string | Yes | ISO 8601 date (YYYY-MM-DD) |
dueDate |
string | No | ISO 8601 date (YYYY-MM-DD). Must be >= date if provided. |
status |
string | No | One of: pending, paid, claimed. Default: pending
|
notes |
string | No | Max 2000 characters |
workItemBudgetId |
string | null | No | Must be a valid work item budget line UUID if provided. null or omitted means unlinked. |
Response (201 Created):
{
"invoice": {
"id": "inv-1",
"vendorId": "v1",
"vendorName": "ABC Construction",
"workItemBudgetId": "b1",
"invoiceNumber": "INV-2026-001",
"amount": 2500.0,
"date": "2026-03-15",
"dueDate": "2026-04-15",
"status": "pending",
"notes": "First payment for foundation work",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-03-15T10:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing amount or date, amount <= 0, invalid date format, dueDate < date, invalid status, field length violations, non-existent workItemBudgetId |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID does not exist |
Updates an invoice. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
vendorId |
string | Vendor UUID |
invoiceId |
string | Invoice UUID |
Request body (all fields optional, at least one required):
{
"invoiceNumber": "INV-2026-001-REV",
"amount": 2750.0,
"date": "2026-03-16",
"dueDate": "2026-04-16",
"status": "paid",
"notes": "Updated after revision",
"workItemBudgetId": "b1"
}| Field | Type | Validation |
|---|---|---|
invoiceNumber |
string | null | Max 100 characters; null clears the field |
amount |
number | Must be > 0 |
date |
string | ISO 8601 date (YYYY-MM-DD) |
dueDate |
string | null | ISO 8601 date (YYYY-MM-DD); null clears the field. Must be >= date if both are set (uses existing date if not being updated). |
status |
string | One of: pending, paid, claimed
|
notes |
string | null | Max 2000 characters; null clears the field |
workItemBudgetId |
string | null | Valid work item budget line UUID or null to unlink |
Response (200 OK):
{
"invoice": {
"id": "inv-1",
"vendorId": "v1",
"vendorName": "ABC Construction",
"workItemBudgetId": "b1",
"invoiceNumber": "INV-2026-001-REV",
"amount": 2750.0,
"date": "2026-03-16",
"dueDate": "2026-04-16",
"status": "paid",
"notes": "Updated after revision",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-03-20T14:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
No fields provided, amount <= 0, invalid date format, dueDate < date, invalid status, field length violations, non-existent workItemBudgetId |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID or invoice ID does not exist, or invoice does not belong to this vendor |
Notes:
- The invoice must belong to the specified vendor. If the invoice exists but belongs to a different vendor, a 404 is returned (not 403) to avoid leaking information about other vendors' invoices.
Deletes an invoice.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
vendorId |
string | Vendor UUID |
invoiceId |
string | Invoice UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Vendor ID or invoice ID does not exist, or invoice does not belong to this vendor |
Notes:
- Deleting an invoice will update the vendor's computed
outstandingBalanceandinvoiceCountfields (visible in subsequentGET /api/vendors/:idresponses). - The invoice must belong to the specified vendor. If the invoice exists but belongs to a different vendor, a 404 is returned.
These endpoints provide cross-vendor access to invoices. They complement the vendor-scoped endpoints above and are useful for listing all invoices across all vendors or fetching a single invoice without knowing its vendor.
All standalone invoice endpoints require authentication. Both admin and member roles can perform all operations (no role restriction).
Returns a paginated list of all invoices across all vendors. Supports filtering by status, vendor, and text search on invoice number.
Auth required: Yes
Query parameters:
| Parameter | Type | Default | Constraints | Description |
|---|---|---|---|---|
page |
integer | 1 |
>= 1 | Page number (1-indexed) |
pageSize |
integer | 25 |
1-100 | Items per page |
q |
string | -- | Max 200 | Search in invoice number (case-insensitive) |
status |
pending | paid | claimed
|
-- | -- | Filter by invoice status |
vendorId |
string | -- | -- | Filter by vendor UUID |
sortBy |
date | amount | status | vendor_name | due_date
|
date |
-- | Sort field |
sortOrder |
asc | desc
|
desc |
-- | Sort direction |
Response (200 OK):
{
"invoices": [
{
"id": "inv-1",
"vendorId": "v1",
"vendorName": "ABC Construction",
"workItemBudgetId": "b1",
"invoiceNumber": "INV-2026-001",
"amount": 2500.00,
"date": "2026-03-15",
"dueDate": "2026-04-15",
"status": "pending",
"notes": "First payment for foundation work",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-03-15T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"pageSize": 25,
"totalItems": 42,
"totalPages": 2
},
"summary": {
"pending": { "count": 5, "totalAmount": 12500.00 },
"paid": { "count": 30, "totalAmount": 75000.00 },
"claimed": { "count": 7, "totalAmount": 17500.00 }
}
}Notes:
- The
summaryfield is always the global unfiltered summary across all invoices, regardless of the current filter or search parameters. This provides consistent dashboard-level totals. - Results are sorted by
datedescending by default (newest invoices first). - The
qparameter searches invoice numbers using case-insensitive substring matching.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid query parameters (page < 1, pageSize out of range, etc.) |
| 401 | UNAUTHORIZED |
No valid session |
Returns a single invoice by ID, regardless of which vendor it belongs to.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
invoiceId |
string | Invoice UUID |
Response (200 OK):
{
"invoice": {
"id": "inv-1",
"vendorId": "v1",
"vendorName": "ABC Construction",
"workItemBudgetId": "b1",
"invoiceNumber": "INV-2026-001",
"amount": 2500.00,
"date": "2026-03-15",
"dueDate": "2026-04-15",
"status": "pending",
"notes": "First payment for foundation work",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-03-15T10:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Invoice ID does not exist |
type BudgetSourceType = 'bank_loan' | 'credit_line' | 'savings' | 'other';
type BudgetSourceStatus = 'active' | 'exhausted' | 'closed';
interface BudgetSourceResponse {
id: string;
name: string;
sourceType: BudgetSourceType;
totalAmount: number;
usedAmount: number; // Computed: SUM(planned_amount) of linked budget lines (planned allocation perspective)
availableAmount: number; // Computed: totalAmount - usedAmount (planned perspective)
claimedAmount: number; // Computed: SUM(amount) of claimed invoices on linked budget lines (actual drawdown perspective)
actualAvailableAmount: number; // Computed: totalAmount - claimedAmount (actual perspective)
interestRate: number | null;
terms: string | null;
notes: string | null;
status: BudgetSourceStatus;
createdBy: UserSummary | null;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Notes on computed fields:
-
usedAmountrepresents the planned allocation perspective -- the sum ofplanned_amountfrom allwork_item_budgetsrows referencing this source. It reflects how much of the source has been earmarked for budget lines. -
availableAmountistotalAmount - usedAmount, showing how much planned capacity remains. -
claimedAmountrepresents the actual drawdown perspective -- the sum ofamountfrom invoices with statusclaimedthat are linked (viawork_item_budget_id) to budget lines referencing this source. It reflects how much has actually been drawn down from the financing source. -
actualAvailableAmountistotalAmount - claimedAmount, showing how much of the source's funds remain after actual drawdowns.
Budget sources represent financing sources for the construction project (e.g., bank loans, credit lines, savings). Budget sources are a small collection and are NOT paginated. All budget source endpoints require authentication. Both admin and member roles can perform all budget source operations (no role restriction).
Returns all budget sources, sorted by name ascending.
Auth required: Yes
Response (200 OK):
{
"budgetSources": [
{
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan",
"totalAmount": 350000.0,
"usedAmount": 125000.0,
"availableAmount": 225000.0,
"claimedAmount": 45000.0,
"actualAvailableAmount": 305000.0,
"interestRate": 3.5,
"terms": "30-year fixed, monthly payments",
"notes": "Primary financing source",
"status": "active",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Creates a new budget source.
Auth required: Yes
Request body:
{
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan",
"totalAmount": 350000.0,
"interestRate": 3.5,
"terms": "30-year fixed, monthly payments",
"notes": "Primary financing source",
"status": "active"
}| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | Yes | 1-200 characters |
sourceType |
string | Yes | One of: bank_loan, credit_line, savings, other
|
totalAmount |
number | Yes | Must be > 0 |
interestRate |
number | null | No | 0-100 if provided |
terms |
string | null | No | Free text |
notes |
string | null | No | Free text |
status |
string | No | One of: active, exhausted, closed. Default: active
|
The createdBy field is automatically set to the authenticated user's ID.
Response (201 Created):
{
"budgetSource": {
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan",
"totalAmount": 350000.0,
"usedAmount": 0,
"availableAmount": 350000.0,
"claimedAmount": 0,
"actualAvailableAmount": 350000.0,
"interestRate": 3.5,
"terms": "30-year fixed, monthly payments",
"notes": "Primary financing source",
"status": "active",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T14:00:00.000Z",
"updatedAt": "2026-02-20T14:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty name, name too long, missing/invalid sourceType, totalAmount <= 0, interestRate out of range, invalid status |
| 401 | UNAUTHORIZED |
No valid session |
Returns a single budget source with computed allocation and drawdown fields.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget source UUID |
Response (200 OK):
{
"budgetSource": {
"id": "bs-1",
"name": "ABC Bank Mortgage",
"sourceType": "bank_loan",
"totalAmount": 350000.0,
"usedAmount": 125000.0,
"availableAmount": 225000.0,
"claimedAmount": 45000.0,
"actualAvailableAmount": 305000.0,
"interestRate": 3.5,
"terms": "30-year fixed, monthly payments",
"notes": "Primary financing source",
"status": "active",
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget source ID does not exist |
Updates a budget source's fields. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget source UUID |
Request body (all fields optional, at least one required):
{
"name": "ABC Bank Mortgage (Updated)",
"sourceType": "bank_loan",
"totalAmount": 400000.0,
"interestRate": 3.25,
"terms": "Updated terms",
"notes": "Updated notes",
"status": "active"
}| Field | Type | Validation |
|---|---|---|
name |
string | 1-200 characters |
sourceType |
string | One of: bank_loan, credit_line, savings, other
|
totalAmount |
number | Must be > 0 |
interestRate |
number | null | 0-100 if provided; null clears the field |
terms |
string | null | null clears the field |
notes |
string | null | null clears the field |
status |
string | One of: active, exhausted, closed
|
Response (200 OK):
Returns the updated BudgetSourceResponse object wrapped in { "budgetSource": ... }.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
No fields provided, invalid field values |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget source ID does not exist |
Deletes a budget source. Fails with 409 Conflict if the source is referenced by work item budget lines (via work_item_budgets.budget_source_id).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Budget source UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Budget source ID does not exist |
| 409 | BUDGET_SOURCE_IN_USE |
Budget source is referenced by budget lines and cannot be deleted |
Notes:
- The
BUDGET_SOURCE_IN_USEerror response includes adetailsfield indicating the number of referencing budget lines:{ "error": { "code": "BUDGET_SOURCE_IN_USE", "message": "Budget source is in use and cannot be deleted", "details": { "budgetLineCount": 5 } } } - Budget sources with no budget line references can be freely deleted.
The budget overview provides aggregated project-level budget data for the dashboard. All fields are computed (not stored in the database).
type ConfidenceLevel = 'own_estimate' | 'professional_estimate' | 'quote' | 'invoice';
// Margin factors: own_estimate=0.20, professional_estimate=0.10, quote=0.05, invoice=0.00
const CONFIDENCE_MARGINS: Record<ConfidenceLevel, number>;
interface CategoryBudgetSummary {
categoryId: string | null; // null for "Uncategorized" virtual category
categoryName: string;
categoryColor: string | null;
minPlanned: number; // SUM of per-line: max(0, plannedAmount*(1-margin) - subsidyReduction)
maxPlanned: number; // SUM of per-line: max(0, plannedAmount*(1+margin) - subsidyReduction)
projectedMin: number; // Blended: invoiced lines use actualCost, non-invoiced use minPlanned
projectedMax: number; // Blended: invoiced lines use actualCost, non-invoiced use maxPlanned
actualCost: number; // SUM of all invoice amounts linked to budget lines in this category
actualCostPaid: number; // SUM of paid/claimed invoice amounts linked to budget lines in this category
actualCostClaimed: number; // SUM of claimed invoice amounts linked to budget lines in this category (subset of actualCostPaid)
budgetLineCount: number;
}
interface BudgetOverview {
availableFunds: number; // SUM(total_amount) of active budget sources
sourceCount: number; // Count of active budget sources
minPlanned: number; // Total min with confidence margins and subsidy reductions
maxPlanned: number; // Total max with confidence margins and subsidy reductions
projectedMin: number; // Blended: invoiced lines use actualCost, non-invoiced use minPlanned
projectedMax: number; // Blended: invoiced lines use actualCost, non-invoiced use maxPlanned
actualCost: number; // All invoices linked to budget lines
actualCostPaid: number; // Paid/claimed invoices only
actualCostClaimed: number; // Claimed invoices only (subset of actualCostPaid)
remainingVsMinPlanned: number; // availableFunds - minPlanned
remainingVsMaxPlanned: number; // availableFunds - maxPlanned
remainingVsProjectedMin: number; // availableFunds - projectedMin
remainingVsProjectedMax: number; // availableFunds - projectedMax
remainingVsActualCost: number; // availableFunds - actualCost
remainingVsActualPaid: number; // availableFunds - actualCostPaid
remainingVsActualClaimed: number; // availableFunds - actualCostClaimed
categorySummaries: CategoryBudgetSummary[];
subsidySummary: {
totalReductions: number; // Total subsidy reductions across all budget lines
activeSubsidyCount: number; // Count of non-rejected subsidy programs
};
}Notes on projected (blended) fields:
- The
projectedMinandprojectedMaxfields provide a blended cost view that combines actuals and estimates. For each budget line:- If the line has any linked invoices, the invoice total is used (actual cost is known).
- If the line has no invoices, the planned range (minPlanned/maxPlanned with confidence margins and subsidy reductions) is used.
- This gives a more accurate picture as the project progresses and estimates are replaced by actual invoices.
Notes on subsidy reductions:
- A subsidy applies to a budget line only if: (a) the subsidy is linked to the budget line's work item, (b) the subsidy's status is not
rejected, and (c) the subsidy's applicable categories include the budget line's category (or the subsidy has no category restrictions, making it universal). - Percentage subsidies reduce each matching line's planned amount by the percentage.
- Fixed subsidies are divided equally across all matching budget lines for that (work item, subsidy) pair.
Notes on the categorySummaries array:
- Each real budget category appears as one entry, sorted by
sortOrderthenname. - If any budget lines have no category (
budget_category_id IS NULL), a virtual"Uncategorized"entry is appended at the end withcategoryId: nullandcategoryColor: null.
Returns aggregated project-level budget data for the dashboard. This endpoint computes all values on-the-fly from the underlying tables (budget sources, budget lines, invoices, subsidy programs).
Auth required: Yes
Response (200 OK):
{
"overview": {
"availableFunds": 500000.0,
"sourceCount": 2,
"minPlanned": 280000.0,
"maxPlanned": 370000.0,
"projectedMin": 265000.0,
"projectedMax": 340000.0,
"actualCost": 85000.0,
"actualCostPaid": 70000.0,
"actualCostClaimed": 25000.0,
"remainingVsMinPlanned": 220000.0,
"remainingVsMaxPlanned": 130000.0,
"remainingVsProjectedMin": 235000.0,
"remainingVsProjectedMax": 160000.0,
"remainingVsActualCost": 415000.0,
"remainingVsActualPaid": 430000.0,
"remainingVsActualClaimed": 475000.0,
"categorySummaries": [
{
"categoryId": "bc-materials",
"categoryName": "Materials",
"categoryColor": "#3B82F6",
"minPlanned": 120000.0,
"maxPlanned": 160000.0,
"projectedMin": 110000.0,
"projectedMax": 145000.0,
"actualCost": 45000.0,
"actualCostPaid": 40000.0,
"actualCostClaimed": 15000.0,
"budgetLineCount": 8
},
{
"categoryId": null,
"categoryName": "Uncategorized",
"categoryColor": null,
"minPlanned": 5000.0,
"maxPlanned": 7000.0,
"projectedMin": 5000.0,
"projectedMax": 7000.0,
"actualCost": 0,
"actualCostPaid": 0,
"actualCostClaimed": 0,
"budgetLineCount": 1
}
],
"subsidySummary": {
"totalReductions": 15000.0,
"activeSubsidyCount": 2
}
}
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Notes:
- Returns zero-valued fields when there is no budget data (no sources, no budget lines, no invoices). The response shape is always the same regardless of whether data exists.
-
categorySummariesis an empty array when there are no budget lines. - The overview endpoint has no query parameters -- it always returns the full project-level summary.
Timeline and scheduling endpoints for milestones, auto-scheduling, and aggregated timeline data. All endpoints in this section require authentication. Both admin and member roles can perform all timeline operations (no role restriction).
Milestone summary (used in list responses):
interface MilestoneSummary {
id: number;
title: string;
description: string | null;
targetDate: string; // ISO 8601 date
isCompleted: boolean;
completedAt: string | null; // ISO 8601 timestamp
color: string | null;
workItemCount: number; // Computed: count of linked work items
createdBy: UserSummary | null;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}Milestone detail (used in single-item responses):
interface MilestoneDetail {
id: number;
title: string;
description: string | null;
targetDate: string; // ISO 8601 date
isCompleted: boolean;
completedAt: string | null; // ISO 8601 timestamp
color: string | null;
workItems: WorkItemSummary[]; // Linked work items (full summary)
createdBy: UserSummary | null;
createdAt: string; // ISO 8601 timestamp
updatedAt: string; // ISO 8601 timestamp
}Milestones represent major project progress points. The milestone list is expected to remain small (fewer than ~50 milestones) so no pagination is used.
Returns all milestones sorted by target_date ascending, with linked work item count.
Auth required: Yes
Response (200 OK):
{
"milestones": [
{
"id": 1,
"title": "Foundation Complete",
"description": "All foundation work finished and inspected",
"targetDate": "2026-04-15",
"isCompleted": false,
"completedAt": null,
"color": "#EF4444",
"workItemCount": 3,
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}
]
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
Creates a new milestone. The createdBy is automatically set to the authenticated user.
Auth required: Yes
Request body:
{
"title": "Foundation Complete",
"description": "All foundation work finished and inspected",
"targetDate": "2026-04-15",
"color": "#EF4444"
}| Field | Type | Required | Validation |
|---|---|---|---|
title |
string | Yes | 1-200 characters |
description |
string | null | No | Max 2000 characters |
targetDate |
string | Yes | ISO 8601 date (YYYY-MM-DD) |
color |
string | null | No | Hex color code matching /^#[0-9A-Fa-f]{6}$/, or null |
Response (201 Created):
{
"id": 1,
"title": "Foundation Complete",
"description": "All foundation work finished and inspected",
"targetDate": "2026-04-15",
"isCompleted": false,
"completedAt": null,
"color": "#EF4444",
"workItems": [],
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty title, title too long, missing targetDate, invalid date format, invalid color format |
| 401 | UNAUTHORIZED |
No valid session |
Returns a single milestone with its linked work items (full summary).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
integer | Milestone integer ID |
Response (200 OK):
{
"id": 1,
"title": "Foundation Complete",
"description": "All foundation work finished and inspected",
"targetDate": "2026-04-15",
"isCompleted": false,
"completedAt": null,
"color": "#EF4444",
"workItems": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Pour Foundation",
"status": "in_progress",
"startDate": "2026-03-15",
"endDate": "2026-03-25",
"durationDays": 10,
"assignedUser": null,
"tags": [],
"createdAt": "2026-02-15T10:00:00.000Z",
"updatedAt": "2026-02-15T10:00:00.000Z"
}
],
"createdBy": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"createdAt": "2026-02-20T10:00:00.000Z",
"updatedAt": "2026-02-20T10:00:00.000Z"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Milestone ID does not exist |
Updates a milestone. All fields are optional; only provided fields are updated. The updatedAt timestamp is set automatically.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
integer | Milestone integer ID |
Request body (all fields optional, at least one required):
{
"title": "Foundation Complete & Inspected",
"description": "Updated description",
"targetDate": "2026-04-20",
"isCompleted": true,
"color": "#22C55E"
}| Field | Type | Validation |
|---|---|---|
title |
string | 1-200 characters |
description |
string | null | Max 2000 characters; null clears the description |
targetDate |
string | ISO 8601 date (YYYY-MM-DD) |
isCompleted |
boolean | true or false; setting to true auto-sets completedAt
|
color |
string | null | Hex color code or null to clear |
Notes:
- When
isCompletedis set totrue, thecompletedAtfield is automatically set to the current timestamp. - When
isCompletedis set tofalse, thecompletedAtfield is automatically cleared tonull.
Response (200 OK):
Returns the updated MilestoneDetail object.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid fields or no fields provided |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Milestone ID does not exist |
Deletes a milestone. Cascades to milestone-work-item associations (the work items themselves are not deleted).
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
integer | Milestone integer ID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Milestone ID does not exist |
Links a work item to a milestone.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
integer | Milestone integer ID |
Request body:
{
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}| Field | Type | Required | Validation |
|---|---|---|---|
workItemId |
string | Yes | Must be a valid work item UUID |
Response (201 Created):
{
"milestoneId": 1,
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing workItemId |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Milestone or work item does not exist |
| 409 | CONFLICT |
Work item is already linked to this milestone |
Unlinks a work item from a milestone.
Auth required: Yes
Path parameters:
| Parameter | Type | Description |
|---|---|---|
id |
integer | Milestone integer ID |
workItemId |
string | Work item UUID |
Response (204 No Content): Empty body.
Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Milestone, work item, or the link between them does not exist |
Runs the auto-scheduling engine using the Critical Path Method (CPM). See ADR-014 for algorithm details.
The endpoint is read-only — it returns the computed schedule without persisting any changes to the database. The client displays results for user review, then applies accepted changes via individual PATCH /api/work-items/:id calls. This supports what-if analysis and gives users full control over which schedule changes to accept.
Auth required: Yes
Request body:
{
"mode": "full",
"anchorWorkItemId": null
}| Field | Type | Required | Validation |
|---|---|---|---|
mode |
string | Yes | One of: full, cascade
|
anchorWorkItemId |
string | null | No | Work item UUID. Required when mode is cascade; ignored when mode is full. |
Scheduling modes:
| Mode | Description |
|---|---|
full |
Schedules all work items from scratch based on dependencies, constraints, and the current date |
cascade |
Starting from the anchor work item, propagates date changes only to downstream successors |
Response (200 OK):
{
"scheduledItems": [
{
"workItemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"previousStartDate": "2026-03-01",
"previousEndDate": "2026-03-15",
"scheduledStartDate": "2026-03-05",
"scheduledEndDate": "2026-03-19",
"latestStartDate": "2026-03-05",
"latestFinishDate": "2026-03-19",
"totalFloat": 0,
"isCritical": true
},
{
"workItemId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"previousStartDate": null,
"previousEndDate": null,
"scheduledStartDate": "2026-03-20",
"scheduledEndDate": "2026-03-30",
"latestStartDate": "2026-03-25",
"latestFinishDate": "2026-04-04",
"totalFloat": 5,
"isCritical": false
}
],
"criticalPath": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"warnings": [
{
"workItemId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"type": "start_before_violated",
"message": "Scheduled start date (2026-04-10) exceeds start-before constraint (2026-04-01)"
},
{
"workItemId": "d4e5f6a7-b8c9-0123-defa-234567890123",
"type": "no_duration",
"message": "Work item has no duration set; scheduled as zero-duration"
}
]
}Response type definitions:
interface ScheduleResponse {
scheduledItems: ScheduledItem[];
criticalPath: string[]; // Array of work item IDs on the critical path (zero float)
warnings: ScheduleWarning[];
}
interface ScheduledItem {
workItemId: string;
previousStartDate: string | null; // Current start_date before scheduling
previousEndDate: string | null; // Current end_date before scheduling
scheduledStartDate: string; // Earliest start (ES) - ISO 8601 date
scheduledEndDate: string; // Earliest finish (EF) - ISO 8601 date
latestStartDate: string; // Latest start (LS) - ISO 8601 date
latestFinishDate: string; // Latest finish (LF) - ISO 8601 date
totalFloat: number; // Days of slack: LS - ES (0 = critical)
isCritical: boolean; // true if on the critical path
}
type ScheduleWarningType = 'start_before_violated' | 'no_duration' | 'already_completed';
interface ScheduleWarning {
workItemId: string;
type: ScheduleWarningType;
message: string;
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid mode, missing anchorWorkItemId for cascade mode |
| 401 | UNAUTHORIZED |
No valid session |
| 404 | NOT_FOUND |
Anchor work item does not exist (cascade mode) |
| 409 | CIRCULAR_DEPENDENCY |
The dependency graph contains a cycle. The details field includes cycle (array of work item IDs) |
Notes:
- The endpoint is read-only — no database changes are made. The client applies accepted changes via
PATCH /api/work-items/:id. - Tasks with no dependencies and no constraints retain their current dates (they are not moved by the scheduling engine).
- The scheduling engine only processes work items that have at least one dependency or a
duration_daysvalue. Orphan work items with no dates and no dependencies are excluded. - Items with status
completedare included in the schedule calculation (their dates are fixed), but a warning of typealready_completedis generated if their dates would change. - The
criticalPatharray contains work item IDs in topological order (from project start to project end).
Returns an aggregated timeline view combining work items (with dates, dependencies, and scheduling metadata), milestones, and critical path information. This endpoint is optimized for rendering the Gantt chart and calendar views.
Auth required: Yes
Query parameters:
| Parameter | Type | Required | Validation |
|---|---|---|---|
from |
string | No | ISO 8601 date (YYYY-MM-DD). Filters to work items overlapping this start date. |
to |
string | No | ISO 8601 date (YYYY-MM-DD). Filters to work items overlapping this end date. Must be >= from if both provided. |
milestoneId |
number | No | Milestone ID. Filters to work items linked to this milestone (plus their inter-dependencies). |
When from/to are provided, only work items whose date range overlaps [from, to] are returned (i.e., startDate <= to AND endDate >= from). Work items with only one date set are included if that date falls within the range. Dependencies are filtered to only those between returned work items. Milestones are filtered to those with at least one linked work item in the result set (or all milestones if no date filter is applied). The critical path is recomputed over the full dataset regardless of filters (since filtering could break the path).
When milestoneId is provided, only work items linked to that milestone are returned, along with dependencies between those work items. The specified milestone is always included in the milestones array. Cannot be combined with from/to.
Response (200 OK):
{
"workItems": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Foundation Work",
"status": "in_progress",
"startDate": "2026-03-01",
"endDate": "2026-03-15",
"durationDays": 14,
"startAfter": "2026-02-28",
"startBefore": "2026-03-10",
"assignedUser": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"displayName": "Admin User",
"email": "admin@example.com"
},
"tags": [{ "id": "t1", "name": "Structural", "color": "#FF5733" }]
}
],
"dependencies": [
{
"predecessorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"successorId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"dependencyType": "finish_to_start",
"leadLagDays": 0
}
],
"milestones": [
{
"id": 1,
"title": "Foundation Complete",
"targetDate": "2026-04-15",
"isCompleted": false,
"completedAt": null,
"color": "#EF4444",
"workItemIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
}
],
"criticalPath": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"dateRange": {
"earliest": "2026-03-01",
"latest": "2026-12-15"
}
}Response type definitions:
interface TimelineResponse {
workItems: TimelineWorkItem[];
dependencies: TimelineDependency[];
milestones: TimelineMilestone[];
criticalPath: string[]; // Work item IDs on the critical path
dateRange: TimelineDateRange | null; // null when no work items have dates
}
interface TimelineWorkItem {
id: string;
title: string;
status: WorkItemStatus;
startDate: string | null;
endDate: string | null;
durationDays: number | null;
startAfter: string | null; // Earliest start constraint (scheduling)
startBefore: string | null; // Latest start constraint (scheduling)
assignedUser: UserSummary | null;
tags: TagResponse[];
}
interface TimelineDependency {
predecessorId: string;
successorId: string;
dependencyType: DependencyType;
leadLagDays: number;
}
interface TimelineMilestone {
id: number;
title: string;
targetDate: string;
isCompleted: boolean;
completedAt: string | null; // ISO 8601 timestamp when completed
color: string | null;
workItemIds: string[]; // IDs of linked work items
}
interface TimelineDateRange {
earliest: string; // ISO 8601 date — minimum start date across all work items
latest: string; // ISO 8601 date — maximum end date across all work items
}Error responses:
| HTTP Status | Error Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid query parameters (bad date format, from > to, non-existent milestoneId, combining milestoneId with from/to) |
| 401 | UNAUTHORIZED |
No valid session |
Notes:
- Without query parameters, the timeline endpoint returns ALL work items that have at least one date set (
startDateorendDate), ALL dependencies, ALL milestones, and the current critical path. - The critical path is always computed over the full dataset using the CPM algorithm (same as
POST /api/schedulebut read-only). For the small dataset sizes expected (<200 items), this is performant. Filtering parameters affect which items are returned but not the critical path calculation. -
dateRangeis computed from the returned work items. If no work items have dates set,dateRangeisnull. - This endpoint does not include budget information -- it is purpose-built for the Gantt chart and calendar views. Use
GET /api/work-items/:idfor full work item details including budgets. - Household item delivery dates will be added to this endpoint when EPIC-04 is implemented. They will appear as a separate
householdItemsarray with a visually distinct type.
All API endpoints are prefixed with /api/.
All error responses conform to the ApiErrorResponse interface defined in @cornerstone/shared:
interface ApiErrorResponse {
error: {
code: ErrorCode; // Machine-readable error code (see table below)
message: string; // Human-readable description
details?: Record<string, unknown>; // Optional additional context
};
}Example response:
{
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"details": { "id": "550e8400-e29b-41d4-a716-446655440000" }
}
}The details field is omitted entirely (not null) when there is no additional context.
All error codes are defined as an ErrorCode string literal union type in @cornerstone/shared. The code field in every error response is constrained to one of these values.
| Error Code | HTTP Status | Description | When Used |
|---|---|---|---|
NOT_FOUND |
404 | Resource not found | A specific entity (by ID) does not exist |
ROUTE_NOT_FOUND |
404 | API route not found | The requested /api/* path does not match any registered route |
VALIDATION_ERROR |
400 | Request validation failed | Request body, query params, or path params fail JSON schema validation; or business logic validation fails |
UNAUTHORIZED |
401 | Not authenticated | No valid session/token provided |
FORBIDDEN |
403 | Not authorized | Authenticated but insufficient permissions |
CONFLICT |
409 | Resource conflict | Duplicate unique constraint, optimistic locking conflict |
INTERNAL_ERROR |
500 | Internal server error | Unhandled/unexpected server errors |
SETUP_REQUIRED |
-- | Setup needed | No users exist (returned in GET /api/auth/me response body, not as an error) |
SETUP_COMPLETE |
403 | Setup already done |
POST /api/auth/setup called when users already exist |
INVALID_CREDENTIALS |
401 | Authentication failed | Wrong email/password on login or password change |
ACCOUNT_DEACTIVATED |
401 | Account disabled | Deactivated user attempting to log in |
SELF_DEACTIVATION |
409 | Cannot deactivate self | Admin trying to deactivate their own account |
LAST_ADMIN |
409 | Cannot remove last admin | Attempting to demote or deactivate the only remaining admin |
OIDC_NOT_CONFIGURED |
404 | OIDC not available | OIDC env vars not set |
OIDC_ERROR |
502 | OIDC provider error | OIDC provider returned an error or is unreachable |
EMAIL_CONFLICT |
409 | Email already in use | OIDC user's email matches a different auth provider's user |
CIRCULAR_DEPENDENCY |
409 | Circular dependency detected | Adding a work item dependency would create a cycle in the dependency graph |
DUPLICATE_DEPENDENCY |
409 | Duplicate dependency | A dependency between the same predecessor and successor already exists |
CATEGORY_IN_USE |
409 | Budget category in use | Budget category is referenced by work items or subsidy programs and cannot be deleted |
VENDOR_IN_USE |
409 | Vendor in use | Vendor has invoices or is referenced by budget lines and cannot be deleted |
BUDGET_SOURCE_IN_USE |
409 | Budget source in use | Budget source is referenced by budget lines and cannot be deleted |
SUBSIDY_PROGRAM_IN_USE |
409 | Subsidy program in use | Subsidy program is referenced by work items and cannot be deleted |
BUDGET_LINE_IN_USE |
409 | Budget line in use | Budget line has linked invoices and cannot be deleted |
When code is VALIDATION_ERROR and the error originates from Fastify's JSON schema validation (AJV), the details field contains field-level information:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"fields": [
{
"path": "/name",
"message": "must be string",
"params": { "type": "string" }
},
{
"path": "/",
"message": "must have required property 'email'",
"params": { "missingProperty": "email" }
}
]
}
}
}Each entry in fields includes:
-
path-- JSON pointer to the field that failed validation (e.g.,/name,/address/zip), or/for root-level errors -
message-- Human-readable validation failure message from AJV -
params-- AJV-specific parameters for the validation rule (optional, present when AJV provides them)
In production mode (NODE_ENV=production), INTERNAL_ERROR responses always return the message "An internal error occurred" regardless of the actual error. This prevents leaking internal details (database errors, file paths, stack traces) to clients. In development mode, the original error message is preserved for debugging.
| Code | Usage |
|---|---|
| 200 | Successful retrieval or update |
| 201 | Successful creation |
| 204 | Successful deletion or action with no response body |
| 302 | OIDC redirects |
| 400 | Validation error (malformed request) |
| 401 | Not authenticated or invalid credentials |
| 403 | Not authorized (insufficient role) or action not permitted |
| 404 | Resource not found or route not found |
| 409 | Conflict (duplicate, self-deactivation, last admin) |
| 500 | Internal server error |
| 502 | Bad gateway (OIDC provider error) |
All requests and responses use application/json, except:
- OIDC redirect endpoints (
GET /api/auth/oidc/loginandGET /api/auth/oidc/callback) which return HTTP redirects (302) -
204 No Contentresponses which have no body
Offset-based pagination is used for list endpoints that may return many items. The standard query parameters and response shape are:
Query parameters:
| Parameter | Type | Default | Constraints | Description |
|---|---|---|---|---|
page |
integer | 1 |
>= 1 | Page number (1-indexed) |
pageSize |
integer | 25 |
1-100 | Items per page |
Response metadata:
All paginated list responses include a pagination object alongside the items array:
{
"items": [...],
"pagination": {
"page": 1,
"pageSize": 25,
"totalItems": 47,
"totalPages": 2
}
}| Field | Type | Description |
|---|---|---|
page |
integer | Current page number |
pageSize |
integer | Items per page (as requested, clamped to 1-100) |
totalItems |
integer | Total number of items matching the filter criteria |
totalPages |
integer | Computed: ceil(totalItems / pageSize)
|
Notes:
- Page numbers are 1-indexed. Requesting page 0 or negative numbers returns a validation error.
- Requesting a page beyond
totalPagesreturns an emptyitemsarray with valid pagination metadata. - The
GET /api/usersendpoint does not use pagination (fewer than 5 users). TheGET /api/tagsendpoint does not use pagination (tags are expected to be a manageable number). TheGET /api/budget-categoriesendpoint does not use pagination (budget categories are a small, finite collection). TheGET /api/budget-sourcesendpoint does not use pagination (budget sources are a small, finite collection). TheGET /api/work-items/:id/budgetsendpoint does not use pagination (budget lines per work item are a small collection). TheGET /api/vendors/:id/invoicesendpoint does not use pagination (invoices per vendor are typically fewer than ~50). TheGET /api/milestonesendpoint does not use pagination (milestones are expected to be fewer than ~50). - The
GET /api/invoicesendpoint (standalone, cross-vendor) uses standard pagination since it spans all vendors and may return many items.
List endpoints support filtering and sorting via query parameters:
Filtering:
- Filter parameters use the exact field name (e.g.,
status,assignedUserId,tagId) - Multiple values for the same filter are not supported in a single parameter; use one value per filter
- Multiple different filters are combined with AND logic
- Text search uses the
qparameter for case-insensitive substring matching
Sorting:
-
sortByspecifies the field to sort by (default varies per endpoint) -
sortOrderspecifies direction:ascordesc(default varies per endpoint) - Only one sort field is supported per request
All user objects returned by the API follow a consistent shape. The password_hash and oidc_subject fields are never included in API responses.
interface UserResponse {
id: string;
email: string;
displayName: string;
role: 'admin' | 'member';
authProvider: 'local' | 'oidc';
createdAt: string; // ISO 8601
updatedAt?: string; // ISO 8601 (included in profile endpoints)
deactivatedAt?: string | null; // ISO 8601 or null (included in admin list)
}| Date | Page Section | Deviation | Resolution |
|---|---|---|---|
| 2026-02-23 | Invoice Response Shape, Invoice Endpoints | PR #203 added vendorName field to the Invoice interface and introduced standalone GET /api/invoices and GET /api/invoices/:invoiceId endpoints. Wiki was not updated alongside the implementation. |
Updated Invoice Response Shape to include vendorName: string. Added "Standalone Invoice Endpoints" section documenting both new endpoints. Updated pagination notes. |