Documents¶
Status: Specified — ready to build Build order: 8 of 10 — depends on Projects & Sites
Purpose¶
Documents is the version-controlled register for all project and site documents — drawings, method statements, RAMS, permits, certificates, and any other file the project needs to track. Documents with expiry dates trigger alerts before they lapse. Each document has a controlled version history: superseded revisions are retained but clearly marked.
Entities¶
Document¶
A single controlled document in the register. The document itself is a logical
record; files are attached as DocumentRevision records.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| project_id | UUID | ✓ | FK → projects.id |
| site_id | UUID | FK → sites.id — NULL = project-level document | |
| title | VARCHAR(255) | ✓ | Document title |
| document_number | VARCHAR(100) | User-defined reference (e.g. drawing number) | |
| category | VARCHAR(100) | ✓ | See categories below |
| description | TEXT | ||
| expiry_date | DATE | NULL = does not expire | |
| current_revision_id | UUID | FK → document_revisions.id — latest revision | |
| status | VARCHAR(20) | ✓ | Default: current |
| 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 | |
| deleted_at | TIMESTAMPTZ |
CREATE INDEX idx_documents_project_id
ON documents(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_site_id
ON documents(site_id) WHERE deleted_at IS NULL AND site_id IS NOT NULL;
CREATE INDEX idx_documents_category
ON documents(project_id, category) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_status
ON documents(project_id, status) WHERE deleted_at IS NULL;
-- Expiry scanner
CREATE INDEX idx_documents_expiry
ON documents(expiry_date) WHERE deleted_at IS NULL AND expiry_date IS NOT NULL;
Document categories:
| Category | Typical use |
|---|---|
| RAMS | Risk Assessment and Method Statement |
| Drawing | Engineering drawings — as-built, scheme, construction |
| Certificate | Inspection, test, or approval certificate |
| Permit | Permit to work, permit to dig, hot works permit |
| Site Instruction | Formal written instruction from client/engineer |
| Contract Document | Contract, NEC schedule, specification |
| Insurance | Project-level insurance documents |
| Health & Safety | H&S plan, fire risk assessment, COSHH |
| Environmental | Environmental management plan, waste consignment |
| Commissioning | Test records, O&M manuals |
| Other |
Document status values:
| Status | Meaning |
|---|---|
current |
Latest approved revision — in active use |
superseded |
Older revision — retained for record |
archived |
No longer in use |
DocumentRevision¶
A specific version/revision of a document. Each upload creates a new revision.
The previous revision is retained in the register as superseded.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| document_id | UUID | ✓ | FK → documents.id |
| project_id | UUID | ✓ | Denormalised |
| revision | VARCHAR(20) | ✓ | e.g. Rev A, Rev B, v1.0, P01, C01 |
| file_key | VARCHAR(500) | ✓ | S3 object key |
| file_name | VARCHAR(255) | ✓ | Original file name |
| file_size_bytes | INTEGER | ✓ | |
| mime_type | VARCHAR(100) | ✓ | |
| change_description | TEXT | What changed in this revision | |
| uploaded_at | TIMESTAMPTZ | ✓ | |
| uploaded_by | UUID | ✓ | FK → users.id |
| created_at | TIMESTAMPTZ | System | |
| deleted_at | TIMESTAMPTZ |
CREATE INDEX idx_doc_revision_document
ON document_revisions(document_id, uploaded_at DESC) WHERE deleted_at IS NULL;
Business Rules¶
-
Every upload creates a new
DocumentRevision. The document record'scurrent_revision_idis updated to the new revision. The previous revision is retained (not deleted). -
expiry_dateis on theDocumentrecord, not the revision. It represents when the document itself expires (e.g. a permit expires, a certificate is reviewed annually). It does not change when a new revision is uploaded. -
site_idis optional. Project-level documents (drawings, contracts, RAMS) havesite_id = NULL. Site-level documents (site-specific permits) havesite_idset. -
The list endpoint returns the document record with the current revision's metadata inlined. The revision history is fetched separately.
-
document_numberis not unique — the same drawing can have multiple documents (different sites, superseded originals). It is a display identifier, not a primary key. -
Soft deleting a document does not delete its revisions. Files remain in S3 for the tenant's retention period (from
tenant.retention_days). -
Uploading a new revision automatically transitions the document to
currentif it wasarchived. Restoring a document by uploading a new revision is the intended workflow.
API Endpoints¶
Documents¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/documents |
List all project documents (paginated, filterable) |
| POST | /projects/{projectId}/documents |
Create document record |
| GET | /projects/{projectId}/documents/{id} |
Full detail with current revision |
| PATCH | /projects/{projectId}/documents/{id} |
Update metadata (not file) |
| DELETE | /projects/{projectId}/documents/{id} |
Soft delete |
| POST | /projects/{projectId}/documents/{id}/transition |
Status transition |
Revisions¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/documents/{id}/revisions |
Full revision history |
| POST | /projects/{projectId}/documents/{id}/revisions |
Upload new revision (returns presigned PUT URL) |
| GET | /projects/{projectId}/documents/{id}/revisions/{revId}/download |
Presigned GET URL for file |
Site-level view¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/documents |
Documents scoped to this site |
Request / Response Shapes¶
POST /projects/{projectId}/documents¶
// Request — create the document record first
{
"title": "Structural Steelwork RAMS — Grid A–D",
"document_number": "CWPH2-RAMS-001",
"category": "RAMS",
"site_id": null,
"description": "Method statement for erection of structural steelwork to Grids A through D",
"expiry_date": "2027-05-28",
"custom_fields": {}
}
// Response 201
{
"id": "uuid-doc",
"project_id": "uuid-project",
"site_id": null,
"title": "Structural Steelwork RAMS — Grid A–D",
"document_number": "CWPH2-RAMS-001",
"category": "RAMS",
"description": "...",
"expiry_date": "2027-05-28",
"days_until_expiry": 365,
"expiry_status": "valid",
"current_revision": null,
"status": "current",
"custom_fields": {},
"created_at": "2026-05-28T09:00:00Z"
}
POST /projects/{projectId}/documents/{id}/revisions¶
// Request
{
"revision": "Rev A",
"file_name": "CWPH2-RAMS-001-RevA.pdf",
"file_size_bytes": 1243648,
"mime_type": "application/pdf",
"change_description": "Initial issue for approval"
}
// Response 201
{
"revision_id": "uuid-revision",
"document_id": "uuid-doc",
"revision": "Rev A",
"file_name": "CWPH2-RAMS-001-RevA.pdf",
"file_size_bytes": 1243648,
"mime_type": "application/pdf",
"upload_url": "https://s3.eu-west-2.amazonaws.com/construo-files-prod/...?X-Amz-...",
"upload_expires_at": "2026-05-28T09:15:00Z"
}
The client uploads directly to S3 using the presigned URL, then calls a
confirm endpoint to mark the upload as complete.
GET /projects/{projectId}/documents¶
Query params: category, site_id (NULL for project-level, a UUID for site-level),
status, expiry_status (expired|expiring_soon|valid|no_expiry),
q (title/document_number search), sort (default title), limit, cursor
// Response 200
{
"data": [
{
"id": "uuid-doc",
"title": "Structural Steelwork RAMS — Grid A–D",
"document_number": "CWPH2-RAMS-001",
"category": "RAMS",
"expiry_date": "2027-05-28",
"expiry_status": "valid",
"days_until_expiry": 365,
"current_revision": {
"revision": "Rev A",
"file_name": "CWPH2-RAMS-001-RevA.pdf",
"uploaded_at": "2026-05-28T09:05:00Z",
"uploaded_by_name": "James Fletcher"
},
"status": "current"
}
],
"pagination": { "total": 24, "cursor": null, "has_more": false }
}
Permission Matrix¶
| Action | Platform Admin | Tenant Admin | Project Manager | Site Manager | Viewer |
|---|---|---|---|---|---|
| List / view documents | ✓ | ✓ | ✓ assigned | ✓ assigned | ✓ assigned |
| Create document | ✓ | ✓ | ✓ assigned | ✓ own site (site-level only) | ✗ |
| Upload revision | ✓ | ✓ | ✓ assigned | ✓ own site (site-level only) | ✗ |
| Edit metadata | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ |
| Transition status | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Delete document | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Download file | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ assigned |
| View revision history | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ assigned |
Site Managers can upload and manage documents scoped to their own site. They cannot manage project-level documents (drawings, contracts, RAMS at project level).
Notifications Triggered¶
| Event | Trigger point | Notification type |
|---|---|---|
| Document approaching expiry | Daily scanner (EventBridge 06:00 UTC) | document.expiry |
Scanner query:
SELECT *
FROM documents
WHERE deleted_at IS NULL
AND expiry_date IS NOT NULL
AND expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
AND status != 'archived'
Offline Sync Scope¶
| Data | Syncs to | Notes |
|---|---|---|
| Document list | Project Manager, Site Manager | Metadata only — no file content |
| Current revision metadata | Project Manager, Site Manager | File name, date, revision |
| File content | Not synced | On-demand presigned URL download |
Document files are large and variable in size — they are fetched on demand via presigned GET URLs rather than synced to local storage in V1.
Cross-Module Dependencies¶
Upstream¶
| Module | Dependency |
|---|---|
| Projects & Sites | project_id, optional site_id |
Downstream¶
None. Documents is a terminal module.
Tests Required¶
Documents¶
- Create document — valid → 201, no current revision
- List documents — filter by
category,status,expiry_status - Expiry status computed correctly for all four states
qsearch matches title and document_number- Transition to
archived→ status updated - Soft delete — document excluded from list
Revisions¶
- Upload revision — returns presigned PUT URL
- Confirm upload —
current_revision_idupdated on document - Second revision uploaded —
current_revision_idupdated to new revision - Revision history lists both revisions in reverse chronological order
- Download endpoint — returns presigned GET URL with short expiry
Expiry¶
days_until_expirycomputed correctly (positive, zero, negative)expiry_statuscorrect for all states
Cross-tenant isolation (mandatory)¶
- GET document from another tenant's project → 404
- POST revision with
document_idfrom another tenant → 404
Permission boundaries¶
- Site Manager can create site-level document → 201
- Site Manager cannot create project-level document (site_id = null) → 403
- Site Manager cannot delete document → 403
- Viewer can view and download → 200; cannot upload → 403
Default Tenant Labels¶
| Key | Default |
|---|---|
module.documents |
Documents |
field.document.document_number |
Document Number |
field.document.category |
Category |
field.document.expiry_date |
Expiry Date |
field.revision.revision |
Revision |
field.revision.change_description |
Change Description |
status.document.current |
Current |
status.document.superseded |
Superseded |
status.document.archived |
Archived |
Foundation Dependencies¶
| Component | Location | Used for |
|---|---|---|
TenantMiddleware |
core/middleware.py |
Sets request.state.tenant |
get_db |
core/db.py |
Schema-scoped AsyncSession |
get_current_user |
core/security.py |
Auth |
require_permission |
core/security.py |
RBAC |
EntityMixin |
core/models.py |
Universal columns |
PaginatedResponse |
core/pagination.py |
List endpoint wrapper |
ConstruoError hierarchy |
core/exceptions.py |
Typed exceptions |
presigned_put_url / presigned_get_url |
core/storage.py |
File upload and download |
audit_entity_change trigger |
DB foundation | Applied to documents, document_revisions |
factories.py |
tests/factories.py |
create_document, create_document_revision |