Skip to content

API Contract

Claude product-architect (Opus 4.6) edited this page Feb 24, 2026 · 17 revisions

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.

Current Endpoints

Health Check

GET /api/health

Auth required: No

Response (200 OK):

{
  "status": "ok",
  "timestamp": "2026-02-07T12:00:00.000Z"
}

EPIC-01: Authentication & User Management

Authentication Flow Overview

Cornerstone supports two authentication methods:

  1. Local authentication -- Email/password login for the initial admin account (setup flow) and as a fallback
  2. 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.

Session Cookie

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.

Environment Variables (Auth)

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.


Auth Endpoints

GET /api/auth/me

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: true means zero users exist in the database; the client should show the setup form.
  • oidcEnabled tells the client whether to show the "Login with SSO" button.

POST /api/auth/setup

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

POST /api/auth/login

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_CREDENTIALS error 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_DEACTIVATED to distinguish from invalid credentials (the user should contact an admin).
  • OIDC users attempting local login receive INVALID_CREDENTIALS (they have no password_hash).

POST /api/auth/logout

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

GET /api/auth/oidc/login

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_id from config
  • redirect_uri from 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

GET /api/auth/oidc/callback

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:

  1. Validates the state parameter against the stored value
  2. Exchanges the code for tokens using the OIDC token endpoint
  3. Extracts sub, email, name/preferred_username from the ID token
  4. Looks up the user by (auth_provider='oidc', oidc_subject=sub)
  5. If no user exists, provisions a new user with role member (Story 1.5)
  6. If the user exists but is deactivated, redirects to /login?error=account_deactivated
  7. Creates a session and sets the cookie
  8. 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_conflict case occurs when an OIDC user's email matches an existing local user's email. This prevents account confusion.

User Endpoints

GET /api/users/me

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

PATCH /api/users/me

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

POST /api/users/me/password

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)

GET /api/users (Admin only)

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 deactivatedAt set)
  • No pagination needed for <5 users; will be revisited if the user count model changes
  • The password_hash and oidc_subject fields 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

PATCH /api/users/:id (Admin only)

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

DELETE /api/users/:id (Admin only)

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_at set 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)

Route Protection

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.


EPIC-03: Work Items Core CRUD & Properties

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 Response Shapes

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:

  • tags are always embedded (not paginated; a work item is unlikely to have more than ~20 tags).
  • subtasks are always embedded, sorted by sortOrder ascending.
  • dependencies.predecessors lists work items that this item depends on. dependencies.successors lists work items that depend on this item. Both include a summary of the related work item.
  • budgets are 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/notes to allow independent pagination if needed in the future.

Work Item Endpoints

GET /api/work-items

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

POST /api/work-items

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

GET /api/work-items/:id

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

PATCH /api/work-items/:id

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 tagIds is 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 null to 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

DELETE /api/work-items/:id

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

Tag Endpoints

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.

GET /api/tags

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

POST /api/tags

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)

PATCH /api/tags/:id

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)

DELETE /api/tags/:id

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

Note Endpoints

Notes are nested under work items. All note endpoints require the parent work item to exist.

GET /api/work-items/:workItemId/notes

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

POST /api/work-items/:workItemId/notes

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

PATCH /api/work-items/:workItemId/notes/:noteId

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

DELETE /api/work-items/:workItemId/notes/:noteId

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

Subtask Endpoints

Subtasks are nested under work items. All subtask endpoints require the parent work item to exist.

GET /api/work-items/:workItemId/subtasks

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

POST /api/work-items/:workItemId/subtasks

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

PATCH /api/work-items/:workItemId/subtasks/:subtaskId

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

DELETE /api/work-items/:workItemId/subtasks/:subtaskId

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

PATCH /api/work-items/:workItemId/subtasks/reorder

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

Dependency Endpoints

Dependencies define predecessor/successor relationships between work items for scheduling.

GET /api/work-items/:id/dependencies

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

POST /api/work-items/:id/dependencies

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

PATCH /api/work-items/:id/dependencies/:predecessorId

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

DELETE /api/work-items/:id/dependencies/:predecessorId

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 Line Endpoints

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).

GET /api/work-items/:workItemId/budgets

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

POST /api/work-items/:workItemId/budgets

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

PATCH /api/work-items/:workItemId/budgets/:budgetId

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

DELETE /api/work-items/:workItemId/budgets/:budgetId

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_USE error response includes a details field 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.

EPIC-05: Budget Management

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).

Budget Category Response Shape

interface BudgetCategoryResponse {
  id: string;
  name: string;
  description: string | null;
  color: string | null;
  sortOrder: number;
  createdAt: string; // ISO 8601
  updatedAt: string; // ISO 8601
}

Budget Category Endpoints

Budget categories are a small, finite collection (typically 10-20 items). They are NOT paginated.

GET /api/budget-categories

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

POST /api/budget-categories

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)

GET /api/budget-categories/:id

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

PATCH /api/budget-categories/:id

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)

DELETE /api/budget-categories/:id

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_USE error response includes a details field 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 Response Shapes

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:

  • invoiceCount is the total number of invoices for this vendor (all statuses).
  • outstandingBalance is the sum of amount for all invoices with status pending or claimed. Returns 0 if no outstanding invoices exist.

Vendor Endpoints

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).

GET /api/vendors

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

POST /api/vendors

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

GET /api/vendors/:id

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

PATCH /api/vendors/:id

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

DELETE /api/vendors/:id

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_USE error response includes a details field 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.

Invoice Response Shape

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.


Invoice Endpoints

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.

GET /api/vendors/:vendorId/invoices

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

POST /api/vendors/:vendorId/invoices

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

PATCH /api/vendors/:vendorId/invoices/:invoiceId

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.

DELETE /api/vendors/:vendorId/invoices/:invoiceId

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 outstandingBalance and invoiceCount fields (visible in subsequent GET /api/vendors/:id responses).
  • The invoice must belong to the specified vendor. If the invoice exists but belongs to a different vendor, a 404 is returned.

Standalone Invoice Endpoints

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).

GET /api/invoices

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 summary field 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 date descending by default (newest invoices first).
  • The q parameter 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

GET /api/invoices/:invoiceId

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

Budget Source Response Shape

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:

  • usedAmount represents the planned allocation perspective -- the sum of planned_amount from all work_item_budgets rows referencing this source. It reflects how much of the source has been earmarked for budget lines.
  • availableAmount is totalAmount - usedAmount, showing how much planned capacity remains.
  • claimedAmount represents the actual drawdown perspective -- the sum of amount from invoices with status claimed that are linked (via work_item_budget_id) to budget lines referencing this source. It reflects how much has actually been drawn down from the financing source.
  • actualAvailableAmount is totalAmount - claimedAmount, showing how much of the source's funds remain after actual drawdowns.

Budget Source Endpoints

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).

GET /api/budget-sources

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

POST /api/budget-sources

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

GET /api/budget-sources/:id

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

PATCH /api/budget-sources/:id

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

DELETE /api/budget-sources/:id

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_USE error response includes a details field 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.

Budget Overview Response Shape

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 projectedMin and projectedMax fields 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 sortOrder then name.
  • If any budget lines have no category (budget_category_id IS NULL), a virtual "Uncategorized" entry is appended at the end with categoryId: null and categoryColor: null.

Budget Overview Endpoint

GET /api/budget/overview

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.
  • categorySummaries is an empty array when there are no budget lines.
  • The overview endpoint has no query parameters -- it always returns the full project-level summary.

EPIC-06: Timeline, Gantt Chart & Dependency Management

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 Response Shapes

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
}

Milestone Endpoints

Milestones represent major project progress points. The milestone list is expected to remain small (fewer than ~50 milestones) so no pagination is used.

GET /api/milestones

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

POST /api/milestones

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

GET /api/milestones/:id

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

PATCH /api/milestones/:id

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 isCompleted is set to true, the completedAt field is automatically set to the current timestamp.
  • When isCompleted is set to false, the completedAt field is automatically cleared to null.

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

DELETE /api/milestones/:id

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

Milestone-Work Item Link Endpoints

POST /api/milestones/:id/work-items

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

DELETE /api/milestones/:id/work-items/:workItemId

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

Scheduling Endpoints

POST /api/schedule

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_days value. Orphan work items with no dates and no dependencies are excluded.
  • Items with status completed are included in the schedule calculation (their dates are fixed), but a warning of type already_completed is generated if their dates would change.
  • The criticalPath array contains work item IDs in topological order (from project start to project end).

Timeline Endpoints

GET /api/timeline

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 (startDate or endDate), 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/schedule but 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.
  • dateRange is computed from the returned work items. If no work items have dates set, dateRange is null.
  • This endpoint does not include budget information -- it is purpose-built for the Gantt chart and calendar views. Use GET /api/work-items/:id for 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 householdItems array with a visually distinct type.

Conventions

Base URL

All API endpoints are prefixed with /api/.

Error Response Shape

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.

Error Codes

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

Validation Error Details

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)

Production Error Sanitization

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.

HTTP Status Codes

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)

Content Type

All requests and responses use application/json, except:

  • OIDC redirect endpoints (GET /api/auth/oidc/login and GET /api/auth/oidc/callback) which return HTTP redirects (302)
  • 204 No Content responses which have no body

Pagination

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 totalPages returns an empty items array with valid pagination metadata.
  • The GET /api/users endpoint does not use pagination (fewer than 5 users). The GET /api/tags endpoint does not use pagination (tags are expected to be a manageable number). The GET /api/budget-categories endpoint does not use pagination (budget categories are a small, finite collection). The GET /api/budget-sources endpoint does not use pagination (budget sources are a small, finite collection). The GET /api/work-items/:id/budgets endpoint does not use pagination (budget lines per work item are a small collection). The GET /api/vendors/:id/invoices endpoint does not use pagination (invoices per vendor are typically fewer than ~50). The GET /api/milestones endpoint does not use pagination (milestones are expected to be fewer than ~50).
  • The GET /api/invoices endpoint (standalone, cross-vendor) uses standard pagination since it spans all vendors and may return many items.

Filtering & Sorting

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 q parameter for case-insensitive substring matching

Sorting:

  • sortBy specifies the field to sort by (default varies per endpoint)
  • sortOrder specifies direction: asc or desc (default varies per endpoint)
  • Only one sort field is supported per request

User Response Shape

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)
}

Deviation Log

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.

Clone this wiki locally