import { MigrationInterface, QueryRunner } from 'typeorm'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseF21FieldInventory1789668000000 implements MigrationInterface { name = 'PhaseF21FieldInventory1789668000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE field_inventory_code_sequences ( year integer PRIMARY KEY, last_value integer NOT NULL DEFAULT 0, CONSTRAINT chk_field_inventory_code_sequences_year CHECK (year BETWEEN 2000 AND 9999), CONSTRAINT chk_field_inventory_code_sequences_value CHECK (last_value >= 0) ) `); await queryRunner.query(` CREATE TABLE asset_field_capture_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visit_id uuid NOT NULL, asset_id uuid NOT NULL, media_id uuid, event_type varchar(24) NOT NULL, device_latitude numeric(9, 6) NOT NULL, device_longitude numeric(9, 6) NOT NULL, device_accuracy_m numeric(12, 3), device_captured_at timestamptz NOT NULL, device_label varchar(255), exif_latitude numeric(9, 6), exif_longitude numeric(9, 6), exif_captured_at timestamptz, created_by uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_asset_field_capture_visit FOREIGN KEY (visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_field_capture_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_field_capture_media FOREIGN KEY (media_id) REFERENCES asset_media(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_field_capture_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT, CONSTRAINT chk_asset_field_capture_event_type CHECK (event_type IN ('CREATED','PHOTO')), CONSTRAINT chk_asset_field_capture_device_coordinates CHECK ( device_latitude BETWEEN -90 AND 90 AND device_longitude BETWEEN -180 AND 180 ), CONSTRAINT chk_asset_field_capture_accuracy CHECK ( device_accuracy_m IS NULL OR device_accuracy_m BETWEEN 0 AND 100000 ), CONSTRAINT chk_asset_field_capture_exif_coordinates CHECK ( (exif_latitude IS NULL AND exif_longitude IS NULL) OR ( exif_latitude IS NOT NULL AND exif_longitude IS NOT NULL AND exif_latitude BETWEEN -90 AND 90 AND exif_longitude BETWEEN -180 AND 180 ) ), CONSTRAINT chk_asset_field_capture_media_event CHECK ( (event_type = 'CREATED' AND media_id IS NULL) OR (event_type = 'PHOTO' AND media_id IS NOT NULL) ) ) `); await queryRunner.query(`CREATE INDEX idx_asset_field_capture_visit ON asset_field_capture_events (visit_id, created_at, id)`); await queryRunner.query(`CREATE INDEX idx_asset_field_capture_asset ON asset_field_capture_events (asset_id, created_at, id)`); await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_field_capture_media ON asset_field_capture_events (media_id) WHERE media_id IS NOT NULL`); await queryRunner.query(` ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source `); await queryRunner.query(` ALTER TABLE inspection_visit_assets ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION','FIELD') ) `); await queryRunner.query(` ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type `); await queryRunner.query(` ALTER TABLE inspection_visit_asset_events ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','FIELD_INCLUDED','EXCLUDED','REINCLUDED') ) `); await queryRunner.query(` CREATE OR REPLACE FUNCTION enforce_field_inventory_capture_before_finding() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE finding_visit_id uuid; BEGIN SELECT act.visit_id INTO finding_visit_id FROM inspection_acts act WHERE act.id = NEW.act_id; IF finding_visit_id IS NULL THEN RETURN NEW; END IF; IF EXISTS ( SELECT 1 FROM asset_field_discoveries discovery WHERE discovery.visit_id = finding_visit_id AND discovery.asset_id = NEW.asset_id ) THEN IF NOT EXISTS ( SELECT 1 FROM asset_field_capture_events capture WHERE capture.visit_id = finding_visit_id AND capture.asset_id = NEW.asset_id AND capture.event_type = 'CREATED' ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'FIELD_INVENTORY_GPS_REQUIRED'; END IF; IF NOT EXISTS ( SELECT 1 FROM asset_field_capture_events capture WHERE capture.visit_id = finding_visit_id AND capture.asset_id = NEW.asset_id AND capture.event_type = 'PHOTO' ) THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'FIELD_INVENTORY_PHOTO_REQUIRED'; END IF; END IF; RETURN NEW; END $$ `); await queryRunner.query(` CREATE TRIGGER trg_inspection_findings_field_capture BEFORE INSERT OR UPDATE OF act_id, asset_id ON inspection_findings FOR EACH ROW EXECUTE FUNCTION enforce_field_inventory_capture_before_finding() `); const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE field_inventory_code_sequences TO ${applicationRole}`); await queryRunner.query(`GRANT SELECT, INSERT ON TABLE asset_field_capture_events TO ${applicationRole}`); await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events FROM ${applicationRole}`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` DO $$ BEGIN IF EXISTS (SELECT 1 FROM asset_field_capture_events LIMIT 1) THEN RAISE EXCEPTION 'F2.1 no puede revertirse: existen capturas GPS/EXIF de campo'; END IF; IF EXISTS (SELECT 1 FROM inspection_visit_assets WHERE planning_source = 'FIELD' LIMIT 1) THEN RAISE EXCEPTION 'F2.1 no puede revertirse: existen registros seleccionados o creados en campo'; END IF; IF EXISTS (SELECT 1 FROM inspection_visit_asset_events WHERE event_type = 'FIELD_INCLUDED' LIMIT 1) THEN RAISE EXCEPTION 'F2.1 no puede revertirse: existen eventos de Inventario de campo'; END IF; END $$ `); await queryRunner.query(`DROP TRIGGER IF EXISTS trg_inspection_findings_field_capture ON inspection_findings`); await queryRunner.query(`DROP FUNCTION IF EXISTS enforce_field_inventory_capture_before_finding()`); await queryRunner.query(`ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type`); await queryRunner.query(` ALTER TABLE inspection_visit_asset_events ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED') ) `); await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`); await queryRunner.query(` ALTER TABLE inspection_visit_assets ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION') ) `); await queryRunner.query(`DROP TABLE asset_field_capture_events`); await queryRunner.query(`DROP TABLE field_inventory_code_sequences`); } }