Add database migration to merge findings

Merge nonconformities and continual_improvements tables into a
unified findings table with kind (NONCONFORMITY, OBSERVATION,
EXCEPTION), status, and priority enums. Create findings_audits
junction table for the many-to-many audit relationship.

The migration generates new FND-XXX reference IDs per organization,
migrates both live and snapshot records preserving source_id links,
and carries over audit associations to the junction table.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-17 00:16:21 +01:00
parent 72d2f85ed2
commit 736436e997
2 changed files with 233 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
-- Add FINDINGS value to snapshots_type enum
-- This must be in its own migration because PostgreSQL requires the new enum
-- value to be committed before it can be used in DML statements.
ALTER TYPE snapshots_type ADD VALUE IF NOT EXISTS 'FINDINGS';

View File

@@ -0,0 +1,229 @@
-- Merge nonconformities and continual_improvements into findings
CREATE TYPE findings_kind AS ENUM ('NONCONFORMITY', 'OBSERVATION', 'EXCEPTION');
CREATE TYPE findings_status AS ENUM ('OPEN', 'IN_PROGRESS', 'CLOSED', 'RISK_ACCEPTED', 'MITIGATED', 'FALSE_POSITIVE');
CREATE TYPE findings_priority AS ENUM ('LOW', 'MEDIUM', 'HIGH');
CREATE TABLE findings (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
snapshot_id TEXT,
source_id TEXT,
kind findings_kind NOT NULL,
reference_id TEXT NOT NULL,
description TEXT,
source TEXT,
risk_id TEXT,
identified_on DATE,
root_cause TEXT,
corrective_action TEXT,
owner_id TEXT,
due_date DATE,
status findings_status NOT NULL,
priority findings_priority NOT NULL,
effectiveness_check TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT fk_findings_organization
FOREIGN KEY (organization_id)
REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_findings_owner
FOREIGN KEY (owner_id)
REFERENCES iam_membership_profiles(id),
CONSTRAINT fk_findings_snapshot
FOREIGN KEY (snapshot_id)
REFERENCES snapshots(id) ON DELETE CASCADE,
CONSTRAINT fk_findings_risk
FOREIGN KEY (risk_id)
REFERENCES risks(id) ON DELETE RESTRICT,
UNIQUE (source_id, snapshot_id)
);
CREATE INDEX idx_findings_organization_snapshot ON findings (organization_id, snapshot_id);
CREATE UNIQUE INDEX idx_findings_org_reference_id ON findings (organization_id, reference_id) WHERE snapshot_id IS NULL;
-- Create findings_audits junction table
CREATE TABLE findings_audits (
finding_id TEXT NOT NULL REFERENCES findings(id) ON DELETE CASCADE ON UPDATE CASCADE,
audit_id TEXT NOT NULL REFERENCES audits(id) ON DELETE CASCADE ON UPDATE CASCADE,
reference_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (finding_id, audit_id)
);
CREATE INDEX idx_findings_audits_audit_id ON findings_audits (audit_id);
-- Pre-generate IDs and FND-XXX reference_ids for live nonconformities
CREATE TEMP TABLE nc_id_map AS
SELECT
nc.id AS old_id,
generate_gid(decode_base64_unpadded(nc.tenant_id), 67) AS new_id,
nc.organization_id,
nc.reference_id AS old_reference_id
FROM nonconformities nc WHERE nc.snapshot_id IS NULL;
-- Assign FND-XXX reference_ids per organization across all live records
-- First, combine all live records from both tables with a stable ordering
CREATE TEMP TABLE all_live_findings AS
SELECT
nc_id_map.old_id,
nc_id_map.new_id,
nc_id_map.organization_id,
nc_id_map.old_reference_id,
'NC' AS source_type,
nonconformities.created_at
FROM nc_id_map
JOIN nonconformities ON nonconformities.id = nc_id_map.old_id;
-- Pre-generate IDs for live continual improvements
CREATE TEMP TABLE ci_id_map AS
SELECT
ci.id AS old_id,
generate_gid(decode_base64_unpadded(ci.tenant_id), 67) AS new_id,
ci.organization_id,
ci.reference_id AS old_reference_id
FROM continual_improvements ci WHERE ci.snapshot_id IS NULL;
INSERT INTO all_live_findings (old_id, new_id, organization_id, old_reference_id, source_type, created_at)
SELECT
ci_id_map.old_id,
ci_id_map.new_id,
ci_id_map.organization_id,
ci_id_map.old_reference_id,
'CI',
ci.created_at
FROM ci_id_map
JOIN continual_improvements ci ON ci.id = ci_id_map.old_id;
-- Generate sequential FND-XXX reference_ids per organization
CREATE TEMP TABLE finding_ref_map AS
SELECT
old_id,
new_id,
organization_id,
old_reference_id,
source_type,
'FND-' || LPAD(ROW_NUMBER() OVER (PARTITION BY organization_id ORDER BY created_at, old_id)::TEXT, 3, '0') AS new_reference_id
FROM all_live_findings;
-- Insert live nonconformities with FND-XXX reference_ids
INSERT INTO findings (
id, tenant_id, organization_id, snapshot_id, source_id,
kind, reference_id, description, source,
identified_on, root_cause, corrective_action,
owner_id, due_date, status, priority,
effectiveness_check, created_at, updated_at
)
SELECT
m.new_id,
nc.tenant_id, nc.organization_id, NULL, NULL,
'NONCONFORMITY'::findings_kind, m.new_reference_id, nc.description, NULL,
nc.date_identified, nc.root_cause, nc.corrective_action,
nc.owner_profile_id, nc.due_date, nc.status::text::findings_status, 'MEDIUM'::findings_priority,
nc.effectiveness_check, nc.created_at, nc.updated_at
FROM nonconformities nc
JOIN finding_ref_map m ON nc.id = m.old_id AND m.source_type = 'NC';
-- Migrate nonconformity audit associations (old reference_id goes on junction table)
INSERT INTO findings_audits (finding_id, audit_id, reference_id, organization_id, tenant_id, created_at)
SELECT m.new_id, nc.audit_id, nc.reference_id, nc.organization_id, nc.tenant_id, nc.created_at
FROM nonconformities nc
JOIN finding_ref_map m ON nc.id = m.old_id AND m.source_type = 'NC'
WHERE nc.audit_id IS NOT NULL;
-- Insert snapshot nonconformities with source_id pointing to live finding
-- Snapshot findings keep the same reference_id as their live counterpart
INSERT INTO findings (
id, tenant_id, organization_id, snapshot_id, source_id,
kind, reference_id, description, source,
identified_on, root_cause, corrective_action,
owner_id, due_date, status, priority,
effectiveness_check, created_at, updated_at
)
SELECT
generate_gid(decode_base64_unpadded(nc.tenant_id), 67),
nc.tenant_id, nc.organization_id, nc.snapshot_id, m.new_id,
'NONCONFORMITY'::findings_kind, m.new_reference_id, nc.description, NULL,
nc.date_identified, nc.root_cause, nc.corrective_action,
nc.owner_profile_id, nc.due_date, nc.status::text::findings_status, 'MEDIUM'::findings_priority,
nc.effectiveness_check, nc.created_at, nc.updated_at
FROM nonconformities nc
LEFT JOIN finding_ref_map m ON nc.source_id = m.old_id AND m.source_type = 'NC'
WHERE nc.snapshot_id IS NOT NULL;
-- Migrate snapshot nonconformity audit associations
INSERT INTO findings_audits (finding_id, audit_id, reference_id, organization_id, tenant_id, created_at)
SELECT f.id, nc.audit_id, nc.reference_id, nc.organization_id, nc.tenant_id, nc.created_at
FROM nonconformities nc
JOIN findings f ON f.snapshot_id = nc.snapshot_id
AND f.source_id IS NOT NULL
AND f.kind = 'NONCONFORMITY'
AND EXISTS (
SELECT 1 FROM finding_ref_map m
WHERE m.old_id = nc.source_id
AND m.source_type = 'NC'
AND f.source_id = m.new_id
)
WHERE nc.snapshot_id IS NOT NULL AND nc.audit_id IS NOT NULL;
-- Insert live continual improvements with FND-XXX reference_ids
INSERT INTO findings (
id, tenant_id, organization_id, snapshot_id, source_id,
kind, reference_id, description, source,
identified_on, root_cause, corrective_action,
owner_id, due_date, status, priority,
effectiveness_check, created_at, updated_at
)
SELECT
m.new_id,
ci.tenant_id, ci.organization_id, NULL, NULL,
'OBSERVATION'::findings_kind, m.new_reference_id, ci.description, ci.source,
NULL, NULL, NULL,
ci.owner_profile_id, ci.target_date, ci.status::text::findings_status, ci.priority::text::findings_priority,
NULL, ci.created_at, ci.updated_at
FROM continual_improvements ci
JOIN finding_ref_map m ON ci.id = m.old_id AND m.source_type = 'CI';
-- Insert snapshot continual improvements with source_id pointing to live finding
INSERT INTO findings (
id, tenant_id, organization_id, snapshot_id, source_id,
kind, reference_id, description, source,
identified_on, root_cause, corrective_action,
owner_id, due_date, status, priority,
effectiveness_check, created_at, updated_at
)
SELECT
generate_gid(decode_base64_unpadded(ci.tenant_id), 67),
ci.tenant_id, ci.organization_id, ci.snapshot_id, m.new_id,
'OBSERVATION'::findings_kind, m.new_reference_id, ci.description, ci.source,
NULL, NULL, NULL,
ci.owner_profile_id, ci.target_date, ci.status::text::findings_status, ci.priority::text::findings_priority,
NULL, ci.created_at, ci.updated_at
FROM continual_improvements ci
LEFT JOIN finding_ref_map m ON ci.source_id = m.old_id AND m.source_type = 'CI'
WHERE ci.snapshot_id IS NOT NULL;
DROP TABLE finding_ref_map;
DROP TABLE all_live_findings;
DROP TABLE nc_id_map;
DROP TABLE ci_id_map;
-- Update snapshot types
UPDATE snapshots
SET type = 'FINDINGS'
WHERE type IN ('NONCONFORMITIES', 'CONTINUAL_IMPROVEMENTS');
-- Drop old tables
DROP TABLE nonconformities;
DROP TABLE continual_improvements;
DROP TYPE nonconformities_status;
DROP TYPE continual_improvements_status;
DROP TYPE continual_improvement_registries_priority;