From e452fc9ab2a09765d262f3c9437bffce4b5299df Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 14:14:30 +0000 Subject: [PATCH] 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) --- src/app/db/db.ts | 2 + src/app/db/schema/projects/projects.ts | 219 ++++++++++++++ src/app/db/schema/projects/schema.md | 379 +++++++++++++++++++++++++ src/app/db/schema/relations.ts | 176 ++++++++++++ src/app/db/schema/uploaded_files.ts | 22 +- 5 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 src/app/db/schema/projects/projects.ts create mode 100644 src/app/db/schema/projects/schema.md diff --git a/src/app/db/db.ts b/src/app/db/db.ts index 9a336b18..6aace62e 100644 --- a/src/app/db/db.ts +++ b/src/app/db/db.ts @@ -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, { diff --git a/src/app/db/schema/projects/projects.ts b/src/app/db/schema/projects/projects.ts new file mode 100644 index 00000000..ad8354ed --- /dev/null +++ b/src/app/db/schema/projects/projects.ts @@ -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; +export type NewProject = InferModel; +export type ProjectType = InferModel; +export type ProjectProperty = InferModel; +export type Contractor = InferModel; +export type Workstream = InferModel; +export type ProjectWorkstream = InferModel; +export type ProjectWorkstreamStage = InferModel< + typeof projectWorkstreamStage, + "select" +>; +export type ProjectWorkstreamContractor = InferModel< + typeof projectWorkstreamContractor, + "select" +>; +export type DocumentType = InferModel; +export type ProjectWorkstreamEvidenceRequirement = InferModel< + typeof projectWorkstreamEvidenceRequirement, + "select" +>; +export type WorkOrder = InferModel; diff --git a/src/app/db/schema/projects/schema.md b/src/app/db/schema/projects/schema.md new file mode 100644 index 00000000..344907ec --- /dev/null +++ b/src/app/db/schema/projects/schema.md @@ -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 diff --git a/src/app/db/schema/relations.ts b/src/app/db/schema/relations.ts index 8614ec66..04f417c8 100644 --- a/src/app/db/schema/relations.ts +++ b/src/app/db/schema/relations.ts @@ -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], + }), +})); diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts index fcc944f0..bb08b7e9 100644 --- a/src/app/db/schema/uploaded_files.ts +++ b/src/app/db/schema/uploaded_files.ts @@ -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), } ); \ No newline at end of file