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¶
-
referenceis unique within the tenant. Enforced at DB level (partial unique index) and validated in the service layer before the constraint fires. -
Soft deleting a project soft-deletes all its sites in the same transaction. Service layer, not DB cascade.
-
A site cannot be set to
activeif its parent project isarchivedorcompleted. -
A site can be
on_holdwhile its project isactive. One work front can pause without pausing the whole project. -
If latitude is provided, longitude is required and vice versa. Pydantic validator.
-
Contract value stored in tenant home currency. No multi-currency in V1.
-
Status transition
reasonis written to the audit log, not stored on the record. -
actual_end_dateis set automatically when a project transitions tocompleted, if not already provided in the transition request. -
All DATE fields in modules scoped to this site (e.g.
entry_datein Site Diary, expiry comparisons in Plant & Equipment, Documents) are evaluated in the site'stimezone. 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 toEurope/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_valuepresent 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_dateauto-set on transition tocompleted- Assignment create, list, delete
Cross-tenant isolation (mandatory)¶
- GET
/projects/{id}where id belongs to another tenant → 404 - POST
/siteswithproject_idfrom another tenant → 404 - POST
/projects/{id}/assignmentswith 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 |