mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Add Projects module database schema
Introduce the Projects domain model as Drizzle table definitions (schema-only; no migration generated/run yet). New tables in src/app/db/schema/projects/projects.ts: project, project_type, project_property, contractor, workstream, project_workstream, project_workstream_stage, project_workstream_contractor, document_type, project_workstream_evidence_requirement, work_order. Also: - uploaded_files: add "projects" file_source value and three nullable bigint FK columns linking evidence to the Projects module. - relations.ts: wire the Projects relations. - db.ts: register the new schema module. Deviations from schema.md, decided during implementation: - client_id is uuid -> organisation (Client maps to organisation). - property_id FKs are bigint (Property has a bigint PK, not integer). - TBC status/priority columns modelled as text for now. - No new uploaded_by column; reuse the existing bigint FK -> user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
558633c128
commit
e452fc9ab2
5 changed files with 797 additions and 1 deletions
|
|
@ -17,6 +17,7 @@ import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
|
|||
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
|
||||
import * as BulkAddressUploadsSchema from "@/app/db/schema/bulk_address_uploads";
|
||||
import * as TagsSchema from "@/app/db/schema/tags";
|
||||
import * as ProjectsSchema from "@/app/db/schema/projects/projects";
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
|
|
@ -45,6 +46,7 @@ const schema = {
|
|||
...PortfolioOrgSchema,
|
||||
...BulkAddressUploadsSchema,
|
||||
...TagsSchema,
|
||||
...ProjectsSchema,
|
||||
};
|
||||
|
||||
export const db = drizzle(pool, {
|
||||
|
|
|
|||
219
src/app/db/schema/projects/projects.ts
Normal file
219
src/app/db/schema/projects/projects.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// Projects module schema.
|
||||
//
|
||||
// See ./schema.md for the domain-model proposal this implements. Design notes
|
||||
// that differ from that document, decided during implementation:
|
||||
// - "Client" maps to the existing `organisation` table (uuid PK), so
|
||||
// `project.client_id` is a uuid FK, not the bigint the doc assumed.
|
||||
// - Property has a bigint PK (see property.ts), so every `property_id` FK is
|
||||
// bigint, not the integer the doc assumed.
|
||||
// - status / priority columns whose enum values are still TBC are modelled as
|
||||
// `text` for now; convert to pgEnum once the value sets are agreed.
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
date,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferModel } from "drizzle-orm";
|
||||
import { property } from "../property";
|
||||
import { organisation } from "../organisation";
|
||||
|
||||
// Reference data: the available project types (e.g. Retrofit, New Build).
|
||||
export const projectType = pgTable("project_type", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
});
|
||||
|
||||
// The aggregate root.
|
||||
export const project = pgTable("project", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
// "Client" is the existing organisation table (uuid PK).
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => organisation.id),
|
||||
projectTypeId: bigint("project_type_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => projectType.id),
|
||||
description: text("description"),
|
||||
startDate: date("start_date"),
|
||||
endDate: date("end_date"),
|
||||
domnaAdminAccess: boolean("domna_admin_access").notNull().default(false),
|
||||
sharepointBaseUrl: varchar("sharepoint_base_url"),
|
||||
sharepointSubFolderTemplate: varchar("sharepoint_sub_folder_template"),
|
||||
});
|
||||
|
||||
// Bridging table for the many-to-many between Project and Property.
|
||||
export const projectProperty = pgTable(
|
||||
"project_property",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
projectId: bigint("project_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => project.id),
|
||||
// Property has a bigint PK (property.ts), not integer.
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
},
|
||||
(t) => ({
|
||||
projectPropertyUnique: unique().on(t.projectId, t.propertyId),
|
||||
})
|
||||
);
|
||||
|
||||
// An organisation responsible for completing work.
|
||||
export const contractor = pgTable("contractor", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
phoneNumber: varchar("phone_number"),
|
||||
emailAddress: varchar("email_address"),
|
||||
});
|
||||
|
||||
// Reference data: the available workstream types (Windows, Doors, Roofs, ...).
|
||||
export const workstream = pgTable("workstream", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
description: text("description").notNull(),
|
||||
});
|
||||
|
||||
// A workstream configured for a specific project.
|
||||
//
|
||||
// `currentStageId` is a forward reference to projectWorkstreamStage (defined
|
||||
// below); the `() => ...` thunk is evaluated lazily so declaration order is fine.
|
||||
export const projectWorkstream = pgTable("project_workstream", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
projectId: bigint("project_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => project.id),
|
||||
workstreamId: bigint("workstream_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => workstream.id),
|
||||
// Return type annotated with AnyPgColumn to break the TS inference cycle
|
||||
// created by the mutual FK with projectWorkstreamStage.projectWorkstreamId.
|
||||
currentStageId: bigint("current_stage_id", { mode: "bigint" }).references(
|
||||
(): AnyPgColumn => projectWorkstreamStage.id
|
||||
),
|
||||
});
|
||||
|
||||
// The workflow for an individual Project Workstream. Each Project Workstream
|
||||
// owns its own stage sequence.
|
||||
export const projectWorkstreamStage = pgTable("project_workstream_stage", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
projectWorkstreamId: bigint("project_workstream_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => projectWorkstream.id),
|
||||
name: varchar("name").notNull(),
|
||||
order: integer("order").notNull(),
|
||||
startDate: date("start_date"),
|
||||
dueDate: date("due_date"),
|
||||
lastUpdated: timestamp("last_updated", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
// Enum values TBC — modelled as text until the value set is agreed.
|
||||
status: text("status").notNull(),
|
||||
statusDescription: text("status_description"),
|
||||
});
|
||||
|
||||
// A contractor assigned to deliver a Project Workstream.
|
||||
export const projectWorkstreamContractor = pgTable(
|
||||
"project_workstream_contractor",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
projectWorkstreamId: bigint("project_workstream_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => projectWorkstream.id),
|
||||
contractorId: bigint("contractor_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => contractor.id),
|
||||
updateStagesPermission: boolean("update_stages_permission")
|
||||
.notNull()
|
||||
.default(false),
|
||||
uploadDocumentsPermission: boolean("upload_documents_permission")
|
||||
.notNull()
|
||||
.default(false),
|
||||
assignedAt: timestamp("assigned_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
// Reference data: the documents that may be requested.
|
||||
export const documentType = pgTable("document_type", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
});
|
||||
|
||||
// Configuration describing which evidence is required.
|
||||
export const projectWorkstreamEvidenceRequirement = pgTable(
|
||||
"project_workstream_evidence_requirement",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
projectWorkstreamId: bigint("project_workstream_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => projectWorkstream.id),
|
||||
documentTypeId: bigint("document_type_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => documentType.id),
|
||||
// Null when the requirement applies to the workstream as a whole.
|
||||
projectWorkstreamStageId: bigint("project_workstream_stage_id", {
|
||||
mode: "bigint",
|
||||
}).references(() => projectWorkstreamStage.id),
|
||||
required: boolean("required").notNull().default(true),
|
||||
namingRule: varchar("naming_rule"),
|
||||
}
|
||||
);
|
||||
|
||||
// Work issued to a contractor.
|
||||
export const workOrder = pgTable("work_order", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
reference: varchar("reference").notNull().unique(),
|
||||
projectWorkstreamContractorId: bigint("project_workstream_contractor_id", {
|
||||
mode: "bigint",
|
||||
})
|
||||
.notNull()
|
||||
.references(() => projectWorkstreamContractor.id),
|
||||
// Property has a bigint PK (property.ts), not integer.
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
projectWorkstreamStageId: bigint("project_workstream_stage_id", {
|
||||
mode: "bigint",
|
||||
})
|
||||
.notNull()
|
||||
.references(() => projectWorkstreamStage.id),
|
||||
// Enum values TBC — modelled as text until the value sets are agreed.
|
||||
status: text("status").notNull(),
|
||||
forecastEnd: date("forecast_end"),
|
||||
priority: text("priority"),
|
||||
});
|
||||
|
||||
export type Project = InferModel<typeof project, "select">;
|
||||
export type NewProject = InferModel<typeof project, "insert">;
|
||||
export type ProjectType = InferModel<typeof projectType, "select">;
|
||||
export type ProjectProperty = InferModel<typeof projectProperty, "select">;
|
||||
export type Contractor = InferModel<typeof contractor, "select">;
|
||||
export type Workstream = InferModel<typeof workstream, "select">;
|
||||
export type ProjectWorkstream = InferModel<typeof projectWorkstream, "select">;
|
||||
export type ProjectWorkstreamStage = InferModel<
|
||||
typeof projectWorkstreamStage,
|
||||
"select"
|
||||
>;
|
||||
export type ProjectWorkstreamContractor = InferModel<
|
||||
typeof projectWorkstreamContractor,
|
||||
"select"
|
||||
>;
|
||||
export type DocumentType = InferModel<typeof documentType, "select">;
|
||||
export type ProjectWorkstreamEvidenceRequirement = InferModel<
|
||||
typeof projectWorkstreamEvidenceRequirement,
|
||||
"select"
|
||||
>;
|
||||
export type WorkOrder = InferModel<typeof workOrder, "select">;
|
||||
379
src/app/db/schema/projects/schema.md
Normal file
379
src/app/db/schema/projects/schema.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# Project Domain Model Proposal
|
||||
|
||||
## Purpose
|
||||
|
||||
This document proposes the initial domain model and persistence design for the new **Projects** module.
|
||||
|
||||
The proposal is based on the approved UX flows and business requirements and aims to validate the proposed aggregate boundaries, entities, relationships and database schema before implementation.
|
||||
|
||||
The focus of this document is the domain model and its persistence, rather than application workflows or UI behaviour.
|
||||
|
||||
---
|
||||
|
||||
# Design Principles
|
||||
|
||||
The proposed model is based on the following principles:
|
||||
|
||||
- **Project** is the aggregate root.
|
||||
- A Project covers one or more Properties, and a Property may belong to many Projects (bridged by Project Property).
|
||||
- Workstreams are configured as part of project setup before contractors are assigned.
|
||||
- Contractors are assigned to individual Project Workstreams rather than directly to a Project.
|
||||
- Each Project Workstream owns its own stage progression, allowing different workstreams to follow different workflows.
|
||||
- Evidence requirements are configuration data.
|
||||
- Evidence submissions are operational data, stored as rows in the existing `uploaded_files` table (linked via nullable FK columns) rather than a new Projects-specific file store.
|
||||
|
||||
---
|
||||
|
||||
# Domain Model
|
||||
|
||||
```
|
||||
Project
|
||||
│
|
||||
├── Properties (via Project Property)
|
||||
│
|
||||
├── Project Workstream (Windows)
|
||||
│ ├── Stages
|
||||
│ ├── Evidence Requirements
|
||||
│ └── Contractor Assignment
|
||||
│ ├── Evidence (rows in uploaded_files)
|
||||
│ └── Work Orders
|
||||
│
|
||||
├── Project Workstream (Roofs)
|
||||
│ ├── Stages
|
||||
│ ├── Evidence Requirements
|
||||
│ └── Contractor Assignment
|
||||
│
|
||||
└── Project Workstream (Doors)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Proposed Schema
|
||||
|
||||
Conventions: bigint autoincrement primary keys for all new tables (and bigint FKs between them); FKs to pre-existing tables match the referenced PK's type (e.g. Property has an integer PK). `timestamp` columns default to `now()`, and status-like columns are Postgres enums (one enum type per column, values TBC).
|
||||
|
||||
## Project
|
||||
|
||||
The aggregate root.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ------------------------------ | ------- | -------- | -------------------------------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| name | varchar | No | |
|
||||
| client_id | bigint | No | FK → Client |
|
||||
| project_type_id | bigint | No | FK → Project Type |
|
||||
| description | text | Yes | |
|
||||
| start_date | date | Yes | May be unknown at creation |
|
||||
| end_date | date | Yes | |
|
||||
| domna_admin_access | boolean | No | Default `false` |
|
||||
| sharepoint_base_url | varchar | Yes | Only set when SharePoint is used |
|
||||
| sharepoint_sub_folder_template | varchar | Yes | |
|
||||
|
||||
---
|
||||
|
||||
## Project Property
|
||||
|
||||
Bridging table between Project and Property — the relationship is many-to-many.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ----------- | ------- | -------- | ------------------------------------------ |
|
||||
| id | bigint | No | Primary Key |
|
||||
| project_id | bigint | No | FK → Project |
|
||||
| property_id | integer | No | FK → Property (existing table, integer PK) |
|
||||
|
||||
Unique constraint on `(project_id, property_id)`.
|
||||
|
||||
---
|
||||
|
||||
## Contractor
|
||||
|
||||
Represents an organisation responsible for completing work.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ------------- | ------- | -------- | ----------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| name | varchar | No | |
|
||||
| phone_number | varchar | Yes | |
|
||||
| email_address | varchar | Yes | |
|
||||
|
||||
---
|
||||
|
||||
## Workstream
|
||||
|
||||
Reference data defining available workstream types.
|
||||
|
||||
Examples:
|
||||
|
||||
- Windows
|
||||
- Doors
|
||||
- Roofs
|
||||
- Electrical
|
||||
- Heating
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ----------- | ------- | -------- | ----------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| name | varchar | No | |
|
||||
| description | text | No | |
|
||||
|
||||
---
|
||||
|
||||
## Project Workstream
|
||||
|
||||
Represents a workstream configured for a specific project.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ---------------- | ------ | -------- | --------------------------------------------------------------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| project_id | bigint | No | FK → Project |
|
||||
| workstream_id | bigint | No | FK → Workstream |
|
||||
| current_stage_id | bigint | Yes | FK → Project Workstream Stage; null until stages are configured |
|
||||
|
||||
---
|
||||
|
||||
## Project Workstream Stage
|
||||
|
||||
Defines the workflow for an individual Project Workstream.
|
||||
|
||||
Unlike a global Stage lookup, each Project Workstream owns its own stage sequence.
|
||||
|
||||
Example:
|
||||
|
||||
### Roofs
|
||||
|
||||
1. Ordered
|
||||
2. In Progress
|
||||
3. Completed
|
||||
|
||||
### Windows
|
||||
|
||||
1. Survey
|
||||
2. Manufacturing
|
||||
3. Installation
|
||||
4. QA Complete
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --------------------- | --------- | -------- | --------------------------------------------------------------------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| project_workstream_id | bigint | No | FK → Project Workstream |
|
||||
| name | varchar | No | |
|
||||
| order | integer | No | Position within the workstream's sequence |
|
||||
| start_date | date | Yes | Set as work progresses |
|
||||
| due_date | date | Yes | |
|
||||
| last_updated | timestamp | No | Default `now()`, refreshed on change |
|
||||
| status | enum | No | Postgres enum; values TBC (e.g. not_started / in_progress / complete) |
|
||||
| status_description | text | Yes | Free-text commentary on the current status |
|
||||
|
||||
---
|
||||
|
||||
## Project Workstream Contractor
|
||||
|
||||
Represents a contractor assigned to deliver a Project Workstream.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --------------------------- | --------- | -------- | ----------------------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| project_workstream_id | bigint | No | FK → Project Workstream |
|
||||
| contractor_id | bigint | No | FK → Contractor |
|
||||
| update_stages_permission | boolean | No | Default `false` |
|
||||
| upload_documents_permission | boolean | No | Default `false` |
|
||||
| assigned_at | timestamp | No | Default `now()` |
|
||||
|
||||
---
|
||||
|
||||
## Document Type
|
||||
|
||||
Reference data describing the documents that may be requested.
|
||||
|
||||
Examples:
|
||||
|
||||
- Completion Photo
|
||||
- RAMS
|
||||
- Insurance Certificate
|
||||
- Warranty
|
||||
- Handover Pack
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ------ | ------- | -------- | ----------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| name | varchar | No | |
|
||||
|
||||
---
|
||||
|
||||
## Project Workstream Evidence Requirement
|
||||
|
||||
Configuration describing which evidence is required.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| --------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | bigint | No | Primary Key |
|
||||
| project_workstream_id | bigint | No | FK → Project Workstream |
|
||||
| document_type_id | bigint | No | FK → Document Type |
|
||||
| project_workstream_stage_id | bigint | Yes | FK → Project Workstream Stage; null when the requirement applies to the workstream as a whole rather than a specific stage |
|
||||
| required | boolean | No | Default `true` |
|
||||
| naming_rule | varchar | Yes | Optional regex/template |
|
||||
|
||||
---
|
||||
|
||||
## Uploaded File (existing table)
|
||||
|
||||
Evidence submitted by a contractor is stored in the existing `uploaded_files` table rather than a new Projects-specific table.
|
||||
|
||||
The table already provides the file storage columns (`s3_file_bucket`, `s3_file_key`, `s3_upload_timestamp`), `file_type` / `file_source` enums, and a set of nullable link columns (`uprn`, `hubspot_deal_id`, …). The Projects module adds the following columns, all nullable because pre-existing rows and non-Projects uploads will not populate them:
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| ------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------- |
|
||||
| project_workstream_id | bigint | Yes | FK → Project Workstream; nullable for now — aim to make it NOT NULL for Projects uploads in future |
|
||||
| project_workstream_evidence_requirement_id | bigint | Yes | FK → Project Workstream Evidence Requirement; null for ad-hoc uploads not tied to a requirement |
|
||||
| project_workstream_contractor_id | bigint | Yes | FK → Project Workstream Contractor |
|
||||
| uploaded_by | text | Yes | Free text for now — undecided whether contractors get individual or shared company accounts |
|
||||
|
||||
The `file_source` enum will likely need a new value for uploads originating from the Projects module.
|
||||
|
||||
---
|
||||
|
||||
## Work Order
|
||||
|
||||
Represents work issued to a contractor.
|
||||
|
||||
| Column | Type | Nullable | Notes |
|
||||
| -------------------------------- | ------- | -------- | ------------------------------------------ |
|
||||
| id | bigint | No | Primary Key |
|
||||
| reference | varchar | No | Unique |
|
||||
| project_workstream_contractor_id | bigint | No | FK → Project Workstream Contractor |
|
||||
| property_id | integer | No | FK → Property (existing table, integer PK) |
|
||||
| project_workstream_stage_id | bigint | No | FK → Project Workstream Stage |
|
||||
| status | enum | No | Postgres enum; values TBC |
|
||||
| forecast_end | date | Yes | |
|
||||
| priority | enum | Yes | Postgres enum; values TBC |
|
||||
|
||||
---
|
||||
|
||||
# Relationships
|
||||
|
||||
| Relationship | Cardinality |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Project ↔ Property | Many : Many (via ProjectProperty) |
|
||||
| Project → ProjectWorkstream | 1 : Many |
|
||||
| Workstream → ProjectWorkstream | 1 : Many |
|
||||
| ProjectWorkstream → ProjectWorkstreamStage | 1 : Many |
|
||||
| ProjectWorkstream → EvidenceRequirement | 1 : Many |
|
||||
| ProjectWorkstreamStage → EvidenceRequirement | 1 : Many (optional — a requirement may not be stage-specific) |
|
||||
| ProjectWorkstream → ProjectWorkstreamContractor | 1 : Many |
|
||||
| Contractor → ProjectWorkstreamContractor | 1 : Many |
|
||||
| ProjectWorkstream → UploadedFile | 1 : Many (optional — nullable FK for now) |
|
||||
| ProjectWorkstreamContractor → UploadedFile | 1 : Many (optional) |
|
||||
| EvidenceRequirement → UploadedFile | 1 : Many (optional — null for ad-hoc uploads) |
|
||||
| ProjectWorkstreamContractor → WorkOrder | 1 : Many |
|
||||
| ProjectWorkstreamStage → WorkOrder | 1 : Many |
|
||||
| Property → WorkOrder | 1 : Many |
|
||||
|
||||
---
|
||||
|
||||
# Design Rationale
|
||||
|
||||
## Aggregate
|
||||
|
||||
The aggregate root is **Project**.
|
||||
|
||||
Everything else exists within the context of a Project.
|
||||
|
||||
---
|
||||
|
||||
## Configuration vs Operational Data
|
||||
|
||||
The model intentionally separates project configuration from operational data.
|
||||
|
||||
### Configuration
|
||||
|
||||
- Project Workstream
|
||||
- Project Workstream Stage
|
||||
- Evidence Requirement
|
||||
|
||||
### Operational
|
||||
|
||||
- Contractor Assignment
|
||||
- Evidence (rows in the existing `uploaded_files` table)
|
||||
- Work Order
|
||||
|
||||
This allows a project to be fully configured before work begins.
|
||||
|
||||
---
|
||||
|
||||
## Project-Specific Workflow
|
||||
|
||||
Stages are owned by Project Workstreams rather than being globally defined.
|
||||
|
||||
This allows each workstream within a project to define its own workflow independently.
|
||||
|
||||
---
|
||||
|
||||
## Contractor Assignment
|
||||
|
||||
Contractors are assigned to Project Workstreams rather than Projects.
|
||||
|
||||
This more accurately reflects the business process and provides a natural location for assignment-specific permissions.
|
||||
|
||||
---
|
||||
|
||||
## Evidence Model
|
||||
|
||||
Evidence requirements define what documentation is expected.
|
||||
|
||||
Documents actually submitted are rows in the existing `uploaded_files` table, linked back to the requirement, workstream and contractor assignment via new nullable FK columns. This reuses the established upload pipeline instead of introducing a parallel file store.
|
||||
|
||||
Separating expectation from submission provides flexibility for future enhancements such as:
|
||||
|
||||
- approvals
|
||||
- versioning
|
||||
- expiry dates
|
||||
- rejected submissions
|
||||
- audit history
|
||||
|
||||
without requiring schema changes.
|
||||
|
||||
---
|
||||
|
||||
# Outstanding Design Questions
|
||||
|
||||
## Stage Templates
|
||||
|
||||
Should Projects be created from reusable templates that pre-populate Workstreams, Stages and Evidence Requirements?
|
||||
|
||||
Or should every Project define these independently?
|
||||
|
||||
---
|
||||
|
||||
## Evidence Versioning
|
||||
|
||||
Should multiple submissions be permitted against a single evidence requirement?
|
||||
|
||||
If so, should previous versions remain visible?
|
||||
|
||||
---
|
||||
|
||||
## Multiple Contractor Assignments
|
||||
|
||||
Can multiple contractors be assigned to the same Project Workstream simultaneously?
|
||||
|
||||
Or should assignments be exclusive?
|
||||
|
||||
---
|
||||
|
||||
## File Storage
|
||||
|
||||
Evidence reuses the existing `uploaded_files` table, which stores files in S3 (`s3_file_bucket` / `s3_file_key`) and records provenance via the `file_source` enum.
|
||||
|
||||
Open points: which `file_type` / `file_source` values Projects uploads should use (new enum values are likely needed), and whether the `project_workstream_id` column can eventually become NOT NULL for Projects-originated uploads.
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
The proposed model aims to:
|
||||
|
||||
- represent business concepts rather than relational joins
|
||||
- separate configuration from operational data
|
||||
- support project-specific workflows
|
||||
- minimise coupling to external systems
|
||||
- provide a flexible foundation for future enhancements while keeping the initial implementation straightforward
|
||||
|
|
@ -22,6 +22,21 @@ import { material } from "./materials";
|
|||
import { portfolio, portfolioUsers } from "./portfolio";
|
||||
import { user } from "./users";
|
||||
import { fundingPackage, fundingPackageMeasures } from "./funding";
|
||||
import { organisation } from "./organisation";
|
||||
import { uploadedFiles } from "./uploaded_files";
|
||||
import {
|
||||
project,
|
||||
projectType,
|
||||
projectProperty,
|
||||
contractor,
|
||||
workstream,
|
||||
projectWorkstream,
|
||||
projectWorkstreamStage,
|
||||
projectWorkstreamContractor,
|
||||
documentType,
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
workOrder,
|
||||
} from "./projects/projects";
|
||||
|
||||
// Define the other side of the one to many relation between a property and its recomendations
|
||||
export const recommendationsRelations = relations(
|
||||
|
|
@ -192,3 +207,164 @@ export const fundingPackageMeasuresRelations = relations(
|
|||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects module relations (see ./projects/projects.ts).
|
||||
//
|
||||
// Inverses on pre-existing tables (property, organisation, user) are omitted
|
||||
// so their existing relation blocks are left untouched; drizzle allows a
|
||||
// one-directional relation where the pairing is unambiguous.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const projectRelations = relations(project, ({ one, many }) => ({
|
||||
client: one(organisation, {
|
||||
fields: [project.clientId],
|
||||
references: [organisation.id],
|
||||
}),
|
||||
projectType: one(projectType, {
|
||||
fields: [project.projectTypeId],
|
||||
references: [projectType.id],
|
||||
}),
|
||||
properties: many(projectProperty),
|
||||
workstreams: many(projectWorkstream),
|
||||
}));
|
||||
|
||||
export const projectTypeRelations = relations(projectType, ({ many }) => ({
|
||||
projects: many(project),
|
||||
}));
|
||||
|
||||
export const projectPropertyRelations = relations(
|
||||
projectProperty,
|
||||
({ one }) => ({
|
||||
project: one(project, {
|
||||
fields: [projectProperty.projectId],
|
||||
references: [project.id],
|
||||
}),
|
||||
property: one(property, {
|
||||
fields: [projectProperty.propertyId],
|
||||
references: [property.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const contractorRelations = relations(contractor, ({ many }) => ({
|
||||
assignments: many(projectWorkstreamContractor),
|
||||
}));
|
||||
|
||||
export const workstreamRelations = relations(workstream, ({ many }) => ({
|
||||
projectWorkstreams: many(projectWorkstream),
|
||||
}));
|
||||
|
||||
export const projectWorkstreamRelations = relations(
|
||||
projectWorkstream,
|
||||
({ one, many }) => ({
|
||||
project: one(project, {
|
||||
fields: [projectWorkstream.projectId],
|
||||
references: [project.id],
|
||||
}),
|
||||
workstream: one(workstream, {
|
||||
fields: [projectWorkstream.workstreamId],
|
||||
references: [workstream.id],
|
||||
}),
|
||||
// The workstream's currently-active stage.
|
||||
currentStage: one(projectWorkstreamStage, {
|
||||
fields: [projectWorkstream.currentStageId],
|
||||
references: [projectWorkstreamStage.id],
|
||||
relationName: "currentStage",
|
||||
}),
|
||||
// All stages owned by this workstream.
|
||||
stages: many(projectWorkstreamStage, { relationName: "workstreamStages" }),
|
||||
evidenceRequirements: many(projectWorkstreamEvidenceRequirement),
|
||||
contractors: many(projectWorkstreamContractor),
|
||||
uploadedFiles: many(uploadedFiles),
|
||||
})
|
||||
);
|
||||
|
||||
export const projectWorkstreamStageRelations = relations(
|
||||
projectWorkstreamStage,
|
||||
({ one, many }) => ({
|
||||
projectWorkstream: one(projectWorkstream, {
|
||||
fields: [projectWorkstreamStage.projectWorkstreamId],
|
||||
references: [projectWorkstream.id],
|
||||
relationName: "workstreamStages",
|
||||
}),
|
||||
// Inverse of projectWorkstream.currentStage.
|
||||
currentOfWorkstreams: many(projectWorkstream, {
|
||||
relationName: "currentStage",
|
||||
}),
|
||||
evidenceRequirements: many(projectWorkstreamEvidenceRequirement),
|
||||
workOrders: many(workOrder),
|
||||
})
|
||||
);
|
||||
|
||||
export const projectWorkstreamContractorRelations = relations(
|
||||
projectWorkstreamContractor,
|
||||
({ one, many }) => ({
|
||||
projectWorkstream: one(projectWorkstream, {
|
||||
fields: [projectWorkstreamContractor.projectWorkstreamId],
|
||||
references: [projectWorkstream.id],
|
||||
}),
|
||||
contractor: one(contractor, {
|
||||
fields: [projectWorkstreamContractor.contractorId],
|
||||
references: [contractor.id],
|
||||
}),
|
||||
workOrders: many(workOrder),
|
||||
uploadedFiles: many(uploadedFiles),
|
||||
})
|
||||
);
|
||||
|
||||
export const documentTypeRelations = relations(documentType, ({ many }) => ({
|
||||
evidenceRequirements: many(projectWorkstreamEvidenceRequirement),
|
||||
}));
|
||||
|
||||
export const projectWorkstreamEvidenceRequirementRelations = relations(
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
({ one, many }) => ({
|
||||
projectWorkstream: one(projectWorkstream, {
|
||||
fields: [projectWorkstreamEvidenceRequirement.projectWorkstreamId],
|
||||
references: [projectWorkstream.id],
|
||||
}),
|
||||
documentType: one(documentType, {
|
||||
fields: [projectWorkstreamEvidenceRequirement.documentTypeId],
|
||||
references: [documentType.id],
|
||||
}),
|
||||
stage: one(projectWorkstreamStage, {
|
||||
fields: [projectWorkstreamEvidenceRequirement.projectWorkstreamStageId],
|
||||
references: [projectWorkstreamStage.id],
|
||||
}),
|
||||
uploadedFiles: many(uploadedFiles),
|
||||
})
|
||||
);
|
||||
|
||||
export const workOrderRelations = relations(workOrder, ({ one }) => ({
|
||||
projectWorkstreamContractor: one(projectWorkstreamContractor, {
|
||||
fields: [workOrder.projectWorkstreamContractorId],
|
||||
references: [projectWorkstreamContractor.id],
|
||||
}),
|
||||
property: one(property, {
|
||||
fields: [workOrder.propertyId],
|
||||
references: [property.id],
|
||||
}),
|
||||
stage: one(projectWorkstreamStage, {
|
||||
fields: [workOrder.projectWorkstreamStageId],
|
||||
references: [projectWorkstreamStage.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const uploadedFilesRelations = relations(uploadedFiles, ({ one }) => ({
|
||||
projectWorkstream: one(projectWorkstream, {
|
||||
fields: [uploadedFiles.projectWorkstreamId],
|
||||
references: [projectWorkstream.id],
|
||||
}),
|
||||
projectWorkstreamEvidenceRequirement: one(
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
{
|
||||
fields: [uploadedFiles.projectWorkstreamEvidenceRequirementId],
|
||||
references: [projectWorkstreamEvidenceRequirement.id],
|
||||
}
|
||||
),
|
||||
projectWorkstreamContractor: one(projectWorkstreamContractor, {
|
||||
fields: [uploadedFiles.projectWorkstreamContractorId],
|
||||
references: [projectWorkstreamContractor.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { bigint, bigserial, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { user } from "./users";
|
||||
import {
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
projectWorkstreamEvidenceRequirement,
|
||||
} from "./projects/projects";
|
||||
|
||||
export const fileType = pgEnum("file_type", [
|
||||
// Assessment
|
||||
|
|
@ -70,7 +75,9 @@ export const fileSource = pgEnum("file_source", [
|
|||
"contractor",
|
||||
"magic_plan",
|
||||
"coordination_hub",
|
||||
"audit_generator"
|
||||
"audit_generator",
|
||||
// Uploads originating from the Projects module.
|
||||
"projects"
|
||||
]);
|
||||
|
||||
export const uploadedFiles = pgTable(
|
||||
|
|
@ -90,5 +97,18 @@ export const uploadedFiles = pgTable(
|
|||
source: fileSource("file_source"),
|
||||
measureName: text("measure_name"),
|
||||
uploadedBy: bigint("uploaded_by", { mode: "bigint" }).references(() => user.id),
|
||||
// Projects module links. All nullable: pre-existing rows and non-Projects
|
||||
// uploads don't populate them. Evidence submitted by a contractor is stored
|
||||
// here rather than in a Projects-specific file store.
|
||||
projectWorkstreamId: bigint("project_workstream_id", {
|
||||
mode: "bigint"
|
||||
}).references(() => projectWorkstream.id),
|
||||
projectWorkstreamEvidenceRequirementId: bigint(
|
||||
"project_workstream_evidence_requirement_id",
|
||||
{ mode: "bigint" }
|
||||
).references(() => projectWorkstreamEvidenceRequirement.id),
|
||||
projectWorkstreamContractorId: bigint("project_workstream_contractor_id", {
|
||||
mode: "bigint"
|
||||
}).references(() => projectWorkstreamContractor.id),
|
||||
}
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue