Skip to content

Projects & Sites

Status: Specified — ready to build Build order: 1 of 10 — foundational module, no upstream dependencies


Purpose

Projects and Sites are the root entities of the platform. Every other module attaches to a Site. A Project is the contract or programme of work; a Site is the physical location where work takes place within that project.

A project can have many sites. A highway scheme might have three sites running simultaneously — each with its own site diary, headcount, and plant allocation — all under one project.


Entities

Project

Represents a contract or body of work. Top-level organisational unit.

Field Type Required Notes
id UUID System PK, generated
name VARCHAR(255) Display name
reference VARCHAR(100) User-defined, unique within tenant
description TEXT
status VARCHAR(20) Default: active. See status lifecycle.
client_name VARCHAR(255) Label-configurable: field.project.client_name
contract_value NUMERIC(15,2) Tenant home currency (GBP for UK)
contract_type VARCHAR(50) NEC3, NEC4, JCT, bespoke, etc.
start_date DATE
end_date DATE Planned completion
actual_end_date DATE Set on transition to completed
region VARCHAR(50) Free text
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System Trigger-maintained
created_by UUID System FK → users.id
updated_by UUID System FK → users.id
deleted_at TIMESTAMPTZ NULL = not deleted
CREATE UNIQUE INDEX idx_projects_reference
    ON projects(reference) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_status
    ON projects(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_created_by ON projects(created_by);

Site

Physical location where work takes place. Belongs to a Project.

Field Type Required Notes
id UUID System PK, generated
project_id UUID FK → projects.id
name VARCHAR(255)
reference VARCHAR(100) Optional site-level reference
status VARCHAR(20) Default: active
address_line_1 VARCHAR(255)
address_line_2 VARCHAR(255)
city VARCHAR(100)
postcode VARCHAR(20)
latitude NUMERIC(10,7) Required if longitude provided
longitude NUMERIC(10,7) Required if latitude provided
what3words VARCHAR(100) Alternative for remote sites
site_manager_id UUID FK → users.id
start_date DATE
end_date DATE
notes TEXT
timezone VARCHAR(50) IANA timezone identifier. Default: Europe/London.
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System
created_by UUID System
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_sites_project_id
    ON sites(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_sites_status
    ON sites(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_sites_site_manager
    ON sites(site_manager_id) WHERE deleted_at IS NULL;

Project Assignments

Controls which users can access which projects. Rows here are mirrored into public.user_project_access (see ADR-003); that table is the runtime source of truth for access checks, not the JWT.

Field Type Required Notes
id UUID System PK
project_id UUID FK → projects.id
user_id UUID FK → users.id
role VARCHAR(50) project_manager | site_manager
created_at TIMESTAMPTZ System
created_by UUID System
CREATE UNIQUE INDEX idx_project_assignments_unique
    ON project_assignments(project_id, user_id);
CREATE INDEX idx_project_assignments_user
    ON project_assignments(user_id);
CREATE INDEX idx_project_assignments_project
    ON project_assignments(project_id);

Tenant Admins have implicit access to all projects — no row required.


Status Lifecycles

Project

From Permitted transitions
tender active, archived
active on_hold, completed, archived
on_hold active, archived
completed archived
archived — terminal

Site

From Permitted transitions
active on_hold, completed, archived
on_hold active, archived
completed archived
archived — terminal

Sites have no tender status — created when work is confirmed.


Business Rules

  1. reference is unique within the tenant. Enforced at DB level (partial unique index) and validated in the service layer before the constraint fires.

  2. Soft deleting a project soft-deletes all its sites in the same transaction. Service layer, not DB cascade.

  3. A site cannot be set to active if its parent project is archived or completed.

  4. A site can be on_hold while its project is active. One work front can pause without pausing the whole project.

  5. If latitude is provided, longitude is required and vice versa. Pydantic validator.

  6. Contract value stored in tenant home currency. No multi-currency in V1.

  7. Status transition reason is written to the audit log, not stored on the record.

  8. actual_end_date is set automatically when a project transitions to completed, if not already provided in the transition request.

  9. All DATE fields in modules scoped to this site (e.g. entry_date in Site Diary, expiry comparisons in Plant & Equipment, Documents) are evaluated in the site's timezone. The API accepts and returns datetimes in UTC; application-layer code converts for date-boundary logic using the site's IANA timezone identifier. Sites in the UK default to Europe/London, which observes BST, ensuring that midnight-boundary comparisons produce the correct calendar date year-round.


API Endpoints

Projects

Method Path Description
GET /projects List (paginated, filterable)
POST /projects Create
GET /projects/{id} Full detail
PATCH /projects/{id} Partial update
DELETE /projects/{id} Soft delete project and all sites
POST /projects/{id}/transition Status transition
GET /projects/{id}/sites List sites for a project
GET /projects/{id}/assignments List assignments
POST /projects/{id}/assignments Add assignment
DELETE /projects/{id}/assignments/{user_id} Remove assignment

Sites

Method Path Description
POST /sites Create
GET /sites/{id} Full detail
PATCH /sites/{id} Partial update
DELETE /sites/{id} Soft delete
POST /sites/{id}/transition Status transition

Request / Response Shapes

POST /projects

// Request
{
  "name": "A417 Missing Link — Phase 3",
  "reference": "PRJ-2025-001",
  "client_name": "National Highways",
  "contract_type": "NEC4",
  "contract_value": 4250000.00,
  "start_date": "2025-06-01",
  "end_date": "2027-03-31",
  "region": "South West",
  "custom_fields": {}
}

// Response 201
{
  "id": "a1b2c3d4-...",
  "name": "A417 Missing Link — Phase 3",
  "reference": "PRJ-2025-001",
  "status": "active",
  "client_name": "National Highways",
  "contract_type": "NEC4",
  "contract_value": 4250000.00,
  "start_date": "2025-06-01",
  "end_date": "2027-03-31",
  "actual_end_date": null,
  "region": "South West",
  "description": null,
  "site_count": 0,
  "custom_fields": {},
  "created_at": "2025-05-27T09:00:00Z",
  "updated_at": "2025-05-27T09:00:00Z",
  "created_by": "user-uuid"
}

GET /projects

Query params: status, q, sort (default -created_at), limit (default 20, max 100), cursor

// Response 200
{
  "data": [
    {
      "id": "a1b2c3d4-...",
      "name": "A417 Missing Link — Phase 3",
      "reference": "PRJ-2025-001",
      "status": "active",
      "client_name": "National Highways",
      "start_date": "2025-06-01",
      "end_date": "2027-03-31",
      "site_count": 3,
      "created_at": "2025-05-27T09:00:00Z"
    }
  ],
  "pagination": {
    "total": 12,
    "cursor": "eyJpZCI6Ii4uLiJ9",
    "has_more": false
  }
}

List returns summary shape. contract_value and description on detail only.

POST /projects/{id}/transition

// Request
{ "status": "on_hold", "reason": "Client instructed pause" }

// Response 200 — full project object
// Response 409 — invalid transition
{ "error": "conflict", "message": "Cannot transition from completed to on_hold" }

POST /sites

// Request
{
  "project_id": "a1b2c3d4-...",
  "name": "Zone A — Earthworks",
  "reference": "SITE-A",
  "address_line_1": "A417, Cowley",
  "postcode": "GL53 9NX",
  "latitude": 51.8279,
  "longitude": -2.0123,
  "what3words": "filled.count.soap",
  "site_manager_id": "user-uuid",
  "start_date": "2025-06-01",
  "end_date": "2026-09-30",
  "custom_fields": {}
}
// Response 201 — full site object

POST /projects/{id}/assignments

// Request
{ "user_id": "user-uuid", "role": "project_manager" }

// Response 201
{
  "project_id": "a1b2c3d4-...",
  "user_id": "user-uuid",
  "role": "project_manager",
  "created_at": "2025-05-27T09:00:00Z"
}

Permission Matrix

Action Platform Admin Tenant Admin Project Manager Site Manager Viewer
List all projects assigned only assigned only assigned only
Create project
View project ✓ assigned ✓ assigned ✓ assigned
View contract value
Edit project ✓ assigned
Transition project status ✓ assigned
Delete project
Manage assignments
Create site ✓ assigned project
View site ✓ assigned ✓ own site ✓ assigned
Edit site ✓ assigned ✓ own site
Transition site status ✓ assigned
Delete site

"Own site" = sites where site_manager_id = current_user.id.


Offline Sync Scope

Data Syncs to Notes
Project summaries Project Manager, Site Manager Excludes contract_value, description
Project full detail Project Manager only contract_value never syncs to Site Manager
Sites Project Manager, Site Manager All fields
Assignments Not synced Tenant Admin use only

Site Manager syncs only sites where site_manager_id = their user ID or where they have an explicit project assignment row.


Cross-Module Dependencies

No upstream dependencies. This module must be built first.

project_id and site_id are foreign keys in every other module:

Module References
Site Diary site_id
Attendance site_id
Plant & Equipment site_id
Incidents site_id, project_id
Documents project_id, site_id
Inductions site_id
Deliveries site_id
Subcontractors project_id

project_access in the JWT is populated from project_assignments. All downstream permission checks derive from project access.


Tests Required

Standard

  • Create project — valid payload → 201
  • Create project — duplicate reference → 409
  • Create project — missing required field → 422
  • List projects — pagination works correctly
  • List projects — filter by status
  • List projects — search by name and reference
  • Get project — full detail fields present
  • Get project — contract_value present for Project Manager, absent for Site Manager
  • Update project — PATCH semantics (partial update, unspecified fields unchanged)
  • Delete project — soft deletes project and all child sites in one transaction
  • Status transitions — all valid transitions → 200
  • Status transitions — all invalid transitions → 409
  • actual_end_date auto-set on transition to completed
  • Assignment create, list, delete

Cross-tenant isolation (mandatory)

  • GET /projects/{id} where id belongs to another tenant → 404
  • POST /sites with project_id from another tenant → 404
  • POST /projects/{id}/assignments with user from another tenant → 422

Permission boundaries

  • Site Manager cannot create a project → 403
  • Site Manager cannot see contract_value — field absent from response
  • Site Manager can edit their own site
  • Site Manager cannot edit a site they are not assigned to → 403
  • Viewer cannot edit anything → 403

Default Tenant Labels

Key Default
field.project.client_name Client
field.project.contract_type Contract Type
field.project.contract_value Contract Value
field.project.region Region
field.project.reference Reference
field.site.reference Site Reference
field.site.site_manager_id Site Manager
role.site_manager Site Manager
role.project_manager Project Manager
module.projects Projects
module.sites Sites
status.project.tender Tender
status.project.active Active
status.project.on_hold On Hold
status.project.completed Completed
status.project.archived Archived
status.site.active Active
status.site.on_hold On Hold
status.site.completed Completed
status.site.archived Archived

Foundation Dependencies

This module cannot be built until the following foundation components exist and are verified:

Component Location Used for
TenantMiddleware core/middleware.py Sets request.state.tenant on every request
get_db core/db.py Provides search_path-scoped AsyncSession
get_current_user core/security.py Returns CurrentUser with roles and project_access
require_permission core/security.py RBAC dependency factory
EntityMixin core/models.py Universal columns on all models
PaginatedResponse core/pagination.py List endpoint wrapper
ConstruoError hierarchy core/exceptions.py Typed exceptions
audit_entity_change trigger DB foundation Applied to projects, sites, project_assignments
tenant_labels table Public schema migration Label lookups
conftest.py fixtures tests/conftest.py client, auth_headers, test_tenants
factories.py tests/factories.py create_test_project, create_test_site