import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = [ 'inspection_evidence.read', 'inspection_evidence.create', 'inspection_communications.read', 'inspection_communications.create', ] as const; const rolePermissionValues = ` ('admin', 'inspection_evidence.read'), ('admin', 'inspection_evidence.create'), ('admin', 'inspection_communications.read'), ('admin', 'inspection_communications.create'), ('director', 'inspection_evidence.read'), ('director', 'inspection_evidence.create'), ('director', 'inspection_communications.read'), ('director', 'inspection_communications.create'), ('supervisor', 'inspection_evidence.read'), ('supervisor', 'inspection_evidence.create'), ('supervisor', 'inspection_communications.read'), ('supervisor', 'inspection_communications.create'), ('inspector', 'inspection_evidence.read'), ('inspector', 'inspection_evidence.create'), ('inspector', 'inspection_communications.read'), ('inspector', 'inspection_communications.create'), ('auditor', 'inspection_evidence.read'), ('auditor', 'inspection_communications.read') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseD4FindingEvidenceCommunications1787241600000 implements MigrationInterface { name = 'PhaseD4FindingEvidenceCommunications1787241600000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE inspection_finding_communications ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), finding_id uuid NOT NULL, direction varchar(20) NOT NULL, channel varchar(20) NOT NULL, type varchar(32) NOT NULL, occurred_at timestamptz NOT NULL, subject varchar(250) NOT NULL, details text, contact_name varchar(200), contact_email varchar(320), created_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_inspection_finding_communications_id_finding UNIQUE (id, finding_id), CONSTRAINT chk_inspection_finding_communications_direction CHECK ( direction IN ('INBOUND', 'OUTBOUND', 'INTERNAL') ), CONSTRAINT chk_inspection_finding_communications_channel CHECK ( channel IN ('EMAIL', 'IN_PERSON', 'PHONE', 'LETTER', 'SYSTEM', 'OTHER') ), CONSTRAINT chk_inspection_finding_communications_type CHECK ( type IN ('COMPANY_RESPONSE', 'AUTHORITY_NOTICE', 'FOLLOW_UP', 'OTHER') ), CONSTRAINT chk_inspection_finding_communications_subject CHECK ( LENGTH(TRIM(subject)) > 0 ), CONSTRAINT chk_inspection_finding_company_response_direction CHECK ( type <> 'COMPANY_RESPONSE' OR direction = 'INBOUND' ), CONSTRAINT fk_inspection_finding_communications_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_communications_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_communications_timeline ON inspection_finding_communications (finding_id, occurred_at DESC) `); await queryRunner.query(` CREATE TABLE inspection_finding_evidence ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), finding_id uuid NOT NULL, communication_id uuid, kind varchar(20) NOT NULL, purpose varchar(40) NOT NULL, original_name varchar(255) NOT NULL, stored_name varchar(80) NOT NULL, mime_type varchar(100) NOT NULL, size_bytes bigint NOT NULL, sha256 char(64) NOT NULL, title varchar(200), description text, captured_at timestamptz, latitude numeric(9, 6), longitude numeric(9, 6), accuracy_m numeric(12, 3), device_label varchar(200), source varchar(20) NOT NULL, uploaded_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_inspection_finding_evidence_stored_name UNIQUE (stored_name), CONSTRAINT chk_inspection_finding_evidence_kind CHECK ( kind IN ('PHOTO', 'DOCUMENT') ), CONSTRAINT chk_inspection_finding_evidence_purpose CHECK ( purpose IN ( 'OBSERVATION', 'COMPANY_RESPONSE', 'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT' ) ), CONSTRAINT chk_inspection_finding_evidence_source CHECK ( source IN ('WEB', 'ANDROID', 'IMPORT') ), CONSTRAINT chk_inspection_finding_evidence_mime CHECK ( mime_type IN ('image/jpeg', 'image/png', 'image/webp', 'application/pdf') ), CONSTRAINT chk_inspection_finding_evidence_size CHECK ( size_bytes > 0 AND size_bytes <= 15728640 ), CONSTRAINT chk_inspection_finding_evidence_sha256 CHECK ( sha256 ~ '^[0-9a-f]{64}$' ), CONSTRAINT chk_inspection_finding_evidence_coordinates CHECK ( (latitude IS NULL AND longitude IS NULL) OR ( latitude IS NOT NULL AND longitude IS NOT NULL AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180 ) ), CONSTRAINT chk_inspection_finding_evidence_accuracy CHECK ( accuracy_m IS NULL OR accuracy_m >= 0 ), CONSTRAINT chk_inspection_finding_evidence_kind_mime CHECK ( (kind = 'PHOTO' AND mime_type IN ('image/jpeg', 'image/png', 'image/webp')) OR (kind = 'DOCUMENT' AND mime_type = 'application/pdf') ), CONSTRAINT chk_inspection_finding_company_response_pdf CHECK ( purpose <> 'COMPANY_RESPONSE' OR ( kind = 'DOCUMENT' AND mime_type = 'application/pdf' AND communication_id IS NOT NULL ) ), CONSTRAINT chk_inspection_finding_attachment_link CHECK ( purpose <> 'COMMUNICATION_ATTACHMENT' OR communication_id IS NOT NULL ), CONSTRAINT fk_inspection_finding_evidence_finding FOREIGN KEY (finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_evidence_communication FOREIGN KEY (communication_id, finding_id) REFERENCES inspection_finding_communications(id, finding_id) ON DELETE RESTRICT, CONSTRAINT fk_inspection_finding_evidence_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_evidence_finding_created ON inspection_finding_evidence (finding_id, created_at DESC) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_evidence_sha256 ON inspection_finding_evidence (sha256) `); await queryRunner.query(` CREATE INDEX idx_inspection_finding_evidence_communication ON inspection_finding_evidence (communication_id) `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('inspection_evidence.read', 'Consultar evidencias y documentos de hallazgos'), ('inspection_evidence.create', 'Incorporar evidencias y documentos de hallazgos'), ('inspection_communications.read', 'Consultar comunicaciones de hallazgos'), ('inspection_communications.create', 'Registrar comunicaciones de hallazgos') ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description `); await queryRunner.query(` WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) INSERT INTO role_permissions (role_id, permission_id) SELECT role.id, permission.id FROM mapping INNER JOIN roles role ON role.code = mapping.role_code INNER JOIN permissions permission ON permission.code = mapping.permission_code ON CONFLICT (role_id, permission_id) DO NOTHING `); const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const roleRows = (await queryRunner.query( 'SELECT 1 FROM pg_roles WHERE rolname = $1', [appRole], )) as unknown[]; if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(` GRANT SELECT, INSERT ON TABLE inspection_finding_communications, inspection_finding_evidence TO ${applicationRole} `); await queryRunner.query(` REVOKE UPDATE, DELETE ON TABLE inspection_finding_communications, inspection_finding_evidence FROM ${applicationRole} `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` WITH mapping(role_code, permission_code) AS (VALUES ${rolePermissionValues}) DELETE FROM role_permissions role_permission USING roles role, permissions permission, mapping WHERE role_permission.role_id = role.id AND role_permission.permission_id = permission.id AND role.code = mapping.role_code AND permission.code = mapping.permission_code `); await queryRunner.query( `DELETE FROM permissions WHERE code = ANY($1::varchar[]) AND NOT EXISTS ( SELECT 1 FROM role_permissions WHERE permission_id = permissions.id )`, [newPermissions], ); await queryRunner.query('DROP TABLE inspection_finding_evidence'); await queryRunner.query('DROP TABLE inspection_finding_communications'); } }