import { MigrationInterface, QueryRunner } from 'typeorm'; const newPermissions = ['assets.read_media', 'assets.manage_media'] as const; const rolePermissionValues = ` ('admin', 'assets.read_media'), ('admin', 'assets.manage_media'), ('director', 'assets.read_media'), ('supervisor', 'assets.read_media'), ('supervisor', 'assets.manage_media'), ('inspector', 'assets.read_media'), ('inspector', 'assets.manage_media'), ('auditor', 'assets.read_media') `; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } export class PhaseB4AssetMedia1786723202000 implements MigrationInterface { name = 'PhaseB4AssetMedia1786723202000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE asset_media ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, kind varchar(20) 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), source varchar(20) NOT NULL, uploaded_by uuid, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at timestamptz, deleted_by uuid, CONSTRAINT uq_asset_media_stored_name UNIQUE (stored_name), CONSTRAINT chk_asset_media_kind CHECK (kind IN ('PHOTO', 'DOCUMENT')), CONSTRAINT chk_asset_media_source CHECK (source IN ('WEB', 'ANDROID', 'IMPORT')), CONSTRAINT chk_asset_media_mime CHECK (mime_type IN ( 'image/jpeg', 'image/png', 'image/webp', 'application/pdf' )), CONSTRAINT chk_asset_media_size CHECK ( size_bytes > 0 AND size_bytes <= 15728640 ), CONSTRAINT chk_asset_media_sha256 CHECK (sha256 ~ '^[0-9a-f]{64}$'), CONSTRAINT chk_asset_media_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_asset_media_accuracy CHECK ( accuracy_m IS NULL OR accuracy_m >= 0 ), CONSTRAINT fk_asset_media_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT, CONSTRAINT fk_asset_media_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_asset_media_deleted_by FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL ) `); await queryRunner.query(` CREATE INDEX idx_asset_media_asset_created ON asset_media (asset_id, created_at DESC) `); await queryRunner.query(` CREATE INDEX idx_asset_media_sha256 ON asset_media (sha256) `); await queryRunner.query(` CREATE INDEX idx_asset_media_active ON asset_media (asset_id, deleted_at) `); await queryRunner.query(` ALTER TABLE asset_versions DROP CONSTRAINT chk_asset_versions_change_type `); await queryRunner.query(` ALTER TABLE asset_versions ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED' )) `); await queryRunner.query(` INSERT INTO permissions (code, description) VALUES ('assets.read_media', 'Consultar fotografías y documentos de activos'), ('assets.manage_media', 'Cargar, editar o retirar archivos de activos') 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, UPDATE ON TABLE asset_media TO ${applicationRole} `); await queryRunner.query(` REVOKE DELETE ON TABLE asset_media 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(` UPDATE asset_versions SET change_type = 'UPDATED' WHERE change_type IN ('MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED') `); await queryRunner.query(` ALTER TABLE asset_versions DROP CONSTRAINT chk_asset_versions_change_type `); await queryRunner.query(` ALTER TABLE asset_versions ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( 'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED', 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED' )) `); await queryRunner.query('DROP TABLE asset_media'); } }