diff --git a/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts new file mode 100644 index 0000000..3c0ffb2 --- /dev/null +++ b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts @@ -0,0 +1,136 @@ +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') + ) + `); + + 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(`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`); + } +}