chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { join } from 'node:path';
import { DataSource } from 'typeorm';
import { PHASE_A_ENTITIES } from './entities';
function requiredEnvironmentVariable(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`Missing required environment variable: ${key}`);
return value;
}
function databasePort(): number {
const port = Number(process.env.DB_PORT ?? 5432);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('DB_PORT must be a valid TCP port');
}
return port;
}
export const migrationDataSource = new DataSource({
type: 'postgres',
host: requiredEnvironmentVariable('DB_HOST'),
port: databasePort(),
database: requiredEnvironmentVariable('DB_NAME'),
username: requiredEnvironmentVariable('DB_MIGRATION_USER'),
password: requiredEnvironmentVariable('DB_MIGRATION_PASSWORD'),
entities: PHASE_A_ENTITIES,
migrations: [join(__dirname, 'migrations', '*.{js,ts}')],
migrationsTableName: 'typeorm_migrations',
synchronize: false,
migrationsRun: false,
logging: false,
applicationName: 'dhv2-migrations',
connectTimeoutMS: 5000,
});
@@ -0,0 +1,51 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum AreaOrganizationRole {
OPERATOR='OPERATOR', TECHNICAL_OPERATOR='TECHNICAL_OPERATOR', CONCESSIONAIRE='CONCESSIONAIRE', PERMIT_HOLDER='PERMIT_HOLDER', PARTICIPANT='PARTICIPANT', OTHER='OTHER'
}
@Entity({ name: 'area_company_relations' })
@Index('idx_area_company_relations_area_id', ['areaId'])
@Index('idx_area_company_relations_company_id', ['companyId'])
@Index('idx_area_company_relations_valid_until', ['validUntil'])
export class AreaCompanyRelation extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'area_id', type: 'uuid' })
areaId!: string;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@Column({ name:'relation_role', type:'enum', enum:AreaOrganizationRole, enumName:'area_organization_role', default:AreaOrganizationRole.OPERATOR })
relationRole!: AreaOrganizationRole;
@Column({ name:'participation_percent', type:'numeric', precision:7, scale:4, nullable:true })
participationPercent!: string|null;
@Column({ name:'legal_instrument', type:'varchar', length:240, nullable:true })
legalInstrument!: string|null;
@Column({ name:'source_document_id', type:'uuid', nullable:true })
sourceDocumentId!: string|null;
@Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
validFrom!: Date;
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
validUntil!: Date | null;
@Column({ name: 'start_reason', type: 'text' })
startReason!: string;
@Column({ name: 'end_reason', type: 'text', nullable: true })
endReason!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'ended_by', type: 'uuid', nullable: true })
endedBy!: string | null;
}
@@ -0,0 +1,4 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity';
export enum AreaLegalRightOrganizationRole { HOLDER='HOLDER', PARTICIPANT='PARTICIPANT', OPERATOR='OPERATOR', OTHER='OTHER' }
@Entity({name:'area_legal_right_organizations'}) @Index('idx_area_legal_right_org_right_id',['rightId']) @Index('idx_area_legal_right_org_organization_id',['organizationId']) @Index('idx_area_legal_right_org_valid_until',['validUntil'])
export class AreaLegalRightOrganization extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'right_id',type:'uuid'}) rightId!:string; @Column({name:'organization_id',type:'uuid'}) organizationId!:string; @Column({type:'enum',enum:AreaLegalRightOrganizationRole,enumName:'area_legal_right_organization_role'}) role!:AreaLegalRightOrganizationRole; @Column({name:'participation_percent',type:'numeric',precision:7,scale:4,nullable:true}) participationPercent!:string|null; @Column({name:'valid_from',type:'date',default:()=> 'CURRENT_DATE'}) validFrom!:string; @Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null; }
@@ -0,0 +1,5 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity';
export enum AreaLegalRightType { EXPLOITATION_CONCESSION='EXPLOITATION_CONCESSION', EXPLORATION_PERMIT='EXPLORATION_PERMIT', TRANSPORT_CONCESSION='TRANSPORT_CONCESSION', OTHER='OTHER' }
export enum AreaLegalRightStatus { ACTIVE='ACTIVE', EXPIRED='EXPIRED', REVOKED='REVOKED', PENDING='PENDING' }
@Entity({name:'area_legal_rights'}) @Index('idx_area_legal_rights_area_id',['areaId']) @Index('idx_area_legal_rights_valid_until',['validUntil'])
export class AreaLegalRight extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'area_id',type:'uuid'}) areaId!:string; @Column({name:'right_type',type:'enum',enum:AreaLegalRightType,enumName:'area_legal_right_type'}) rightType!:AreaLegalRightType; @Column({type:'varchar',length:260}) name!:string; @Column({name:'instrument_number',type:'varchar',length:180,nullable:true}) instrumentNumber!:string|null; @Column({name:'valid_from',type:'date',nullable:true}) validFrom!:string|null; @Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null; @Column({type:'enum',enum:AreaLegalRightStatus,enumName:'area_legal_right_status',default:AreaLegalRightStatus.ACTIVE}) status!:AreaLegalRightStatus; @Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'updated_by',type:'uuid',nullable:true}) updatedBy!:string|null; }
@@ -0,0 +1,51 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum AssetAttributeDataType {
TEXT = 'TEXT',
NUMBER = 'NUMBER',
BOOLEAN = 'BOOLEAN',
DATE = 'DATE',
DATETIME = 'DATETIME',
SELECT = 'SELECT',
}
@Entity({ name: 'asset_attribute_definitions' })
@Index('idx_asset_attribute_definitions_type', ['assetTypeId', 'sortOrder'])
@Index('idx_asset_attribute_definitions_active', ['isActive'])
export class AssetAttributeDefinition extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'asset_type_id', type: 'uuid' })
assetTypeId!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({
name: 'data_type',
type: 'enum',
enum: AssetAttributeDataType,
enumName: 'asset_attribute_data_type',
})
dataType!: AssetAttributeDataType;
@Column({ name: 'is_required', type: 'boolean', default: false })
isRequired!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ type: 'varchar', length: 40, nullable: true })
unit!: string | null;
@Column({ type: 'jsonb', nullable: true })
options!: string[] | null;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
}
@@ -0,0 +1,19 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'asset_attribute_values' })
export class AssetAttributeValue {
@PrimaryColumn({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@PrimaryColumn({ name: 'definition_id', type: 'uuid' })
definitionId!: string;
@Column({ type: 'jsonb' })
value!: unknown;
@Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,3 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity';
@Entity({name:'asset_external_identifiers'}) @Index('idx_asset_external_identifiers_asset_id',['assetId']) @Index('idx_asset_external_identifiers_namespace',['namespace'])
export class AssetExternalIdentifier extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'asset_id',type:'uuid'}) assetId!:string; @Column({type:'varchar',length:80}) namespace!:string; @Column({type:'varchar',length:180}) value!:string; @Column({name:'valid_from',type:'timestamptz',default:()=> 'CURRENT_TIMESTAMP'}) validFrom!:Date; @Column({name:'valid_until',type:'timestamptz',nullable:true}) validUntil!:Date|null; @Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null; }
@@ -0,0 +1,58 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
export enum AssetGeometryType {
POINT = 'POINT',
LINESTRING = 'LINESTRING',
POLYGON = 'POLYGON',
}
export enum AssetGeometrySource {
WEB = 'WEB',
ANDROID = 'ANDROID',
IMPORT = 'IMPORT',
SURVEY = 'SURVEY',
}
@Entity({ name: 'asset_geometries' })
@Index('idx_asset_geometries_geometry', ['geometry'], { spatial: true })
@Index('idx_asset_geometries_type', ['geometryType'])
export class AssetGeometry {
@PrimaryColumn({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({
type: 'geometry',
spatialFeatureType: 'Geometry',
srid: 4326,
})
geometry!: { type: AssetGeometryType; coordinates: unknown };
@Column({ name: 'geometry_type', type: 'varchar', length: 20 })
geometryType!: AssetGeometryType;
@Column({
type: 'enum',
enum: AssetGeometrySource,
enumName: 'asset_geometry_source',
default: AssetGeometrySource.WEB,
})
source!: AssetGeometrySource;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ name: 'captured_at', type: 'timestamptz', nullable: true })
capturedAt!: Date | null;
@Column({ name: 'device_label', type: 'varchar', length: 255, nullable: true })
deviceLabel!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,78 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum AssetMediaKind {
PHOTO = 'PHOTO',
DOCUMENT = 'DOCUMENT',
}
export enum AssetMediaSource {
WEB = 'WEB',
ANDROID = 'ANDROID',
IMPORT = 'IMPORT',
}
@Entity({ name: 'asset_media' })
@Index('idx_asset_media_asset_created', ['assetId', 'createdAt'])
@Index('idx_asset_media_sha256', ['sha256'])
@Index('idx_asset_media_active', ['assetId', 'deletedAt'])
export class AssetMedia {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ type: 'varchar', length: 20 })
kind!: AssetMediaKind;
@Column({ name: 'original_name', type: 'varchar', length: 255 })
originalName!: string;
@Column({ name: 'stored_name', type: 'varchar', length: 80 })
storedName!: string;
@Column({ name: 'mime_type', type: 'varchar', length: 100 })
mimeType!: string;
@Column({ name: 'size_bytes', type: 'bigint' })
sizeBytes!: string;
@Column({ type: 'char', length: 64 })
sha256!: string;
@Column({ type: 'varchar', length: 200, nullable: true })
title!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'captured_at', type: 'timestamptz', nullable: true })
capturedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'varchar', length: 20 })
source!: AssetMediaSource;
@Column({ name: 'uploaded_by', type: 'uuid', nullable: true })
uploadedBy!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt!: Date | null;
@Column({ name: 'deleted_by', type: 'uuid', nullable: true })
deletedBy!: string | null;
}
@@ -0,0 +1,4 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity';
export enum AssetSourceDocumentRelationType { SOURCE='SOURCE', MENTIONS='MENTIONS', VALIDATES='VALIDATES', SUPERSEDES='SUPERSEDES', OTHER='OTHER' }
@Entity({name:'asset_source_documents'}) @Index('idx_asset_source_documents_asset_id',['assetId']) @Index('idx_asset_source_documents_document_id',['documentId'])
export class AssetSourceDocument extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'asset_id',type:'uuid'}) assetId!:string; @Column({name:'document_id',type:'uuid'}) documentId!:string; @Column({name:'relation_type',type:'enum',enum:AssetSourceDocumentRelationType,enumName:'asset_source_document_relation_type',default:AssetSourceDocumentRelationType.SOURCE}) relationType!:AssetSourceDocumentRelationType; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; }
@@ -0,0 +1,13 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'asset_type_parent_rules' })
export class AssetTypeParentRule {
@PrimaryColumn({ name: 'child_type_id', type: 'uuid' })
childTypeId!: string;
@PrimaryColumn({ name: 'parent_type_id', type: 'uuid' })
parentTypeId!: string;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,45 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum AssetTypeOperationalRole {
GENERIC = 'GENERIC',
AREA = 'AREA',
COMPANY = 'COMPANY',
}
@Entity({ name: 'asset_types' })
@Index('idx_asset_types_is_active', ['isActive'])
export class AssetType extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({ type: 'text', default: '' })
description!: string;
@Column({ name: 'can_be_root', type: 'boolean', default: false })
canBeRoot!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({
name: 'operational_role',
type: 'enum',
enum: AssetTypeOperationalRole,
enumName: 'asset_type_operational_role',
default: AssetTypeOperationalRole.GENERIC,
})
operationalRole!: AssetTypeOperationalRole;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,60 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum AssetVersionChangeType {
BASELINE = 'BASELINE',
CREATED = 'CREATED',
UPDATED = 'UPDATED',
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
STATUS_CHANGED = 'STATUS_CHANGED',
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
GEOMETRY_UPDATED = 'GEOMETRY_UPDATED',
GEOMETRY_REMOVED = 'GEOMETRY_REMOVED',
MEDIA_UPLOADED = 'MEDIA_UPLOADED',
MEDIA_UPDATED = 'MEDIA_UPDATED',
MEDIA_REMOVED = 'MEDIA_REMOVED',
PROVENANCE_BASELINE = 'PROVENANCE_BASELINE',
PROVENANCE_UPDATED = 'PROVENANCE_UPDATED',
PROVENANCE_VERIFIED = 'PROVENANCE_VERIFIED',
}
@Entity({ name: 'asset_versions' })
@Index('idx_asset_versions_asset_number', ['assetId', 'versionNumber'], {
unique: true,
})
@Index('idx_asset_versions_occurred_at', ['occurredAt'])
@Index('idx_asset_versions_change_type', ['changeType'])
export class AssetVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ name: 'change_type', type: 'varchar', length: 32 })
changeType!: AssetVersionChangeType;
@Column({ name: 'changed_fields', type: 'text', array: true, default: () => "'{}'" })
changedFields!: string[];
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
occurredAt!: Date;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ type: 'varchar', length: 32 })
source!: string;
@Column({ name: 'request_id', type: 'varchar', length: 128, nullable: true })
requestId!: string | null;
}
@@ -0,0 +1,115 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum AssetInformationStatus {
DRAFT = 'DRAFT',
PENDING_SURVEY = 'PENDING_SURVEY',
SURVEYED = 'SURVEYED',
VALIDATED = 'VALIDATED',
OBSERVED = 'OBSERVED',
OUTDATED = 'OUTDATED',
INACTIVE = 'INACTIVE',
}
export enum AssetOperationalStatus {
UNKNOWN = 'UNKNOWN',
IN_SERVICE = 'IN_SERVICE',
TEMPORARILY_OUT_OF_SERVICE = 'TEMPORARILY_OUT_OF_SERVICE',
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
DECOMMISSIONED = 'DECOMMISSIONED',
ABANDONED = 'ABANDONED',
}
export enum AssetDataOrigin {
MANUAL = 'MANUAL',
FIELD_SURVEY = 'FIELD_SURVEY',
PROVIDED_DOCUMENT = 'PROVIDED_DOCUMENT',
IMPORT = 'IMPORT',
SYSTEM = 'SYSTEM',
}
@Entity({ name: 'assets' })
@Index('idx_assets_type_id', ['assetTypeId'])
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@Index('idx_assets_data_origin', ['dataOrigin'])
@Index('idx_assets_provenance_verified_at', ['provenanceVerifiedAt'])
export class Asset extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'asset_type_id', type: 'uuid' })
assetTypeId!: string;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId!: string | null;
@Column({ name: 'operational_area_id', type: 'uuid', nullable: true })
operationalAreaId!: string | null;
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ type: 'varchar', length: 120 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ name: 'common_name', type: 'varchar', length: 200, nullable: true })
commonName!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({
name: 'information_status',
type: 'enum',
enum: AssetInformationStatus,
enumName: 'asset_information_status',
default: AssetInformationStatus.DRAFT,
})
informationStatus!: AssetInformationStatus;
@Column({ name: 'operational_status', type: 'enum', enum: AssetOperationalStatus, enumName: 'asset_operational_status', default: AssetOperationalStatus.UNKNOWN })
operationalStatus!: AssetOperationalStatus;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number;
@Column({ name: 'data_origin', type: 'varchar', length: 32, default: AssetDataOrigin.MANUAL })
dataOrigin!: AssetDataOrigin;
@Column({ name: 'source_name', type: 'varchar', length: 160, nullable: true })
sourceName!: string | null;
@Column({ name: 'source_reference', type: 'varchar', length: 255, nullable: true })
sourceReference!: string | null;
@Column({ name: 'source_observed_at', type: 'timestamptz', nullable: true })
sourceObservedAt!: Date | null;
@Column({ name: 'source_notes', type: 'text', nullable: true })
sourceNotes!: string | null;
@Column({ name: 'provenance_verified_at', type: 'timestamptz', nullable: true })
provenanceVerifiedAt!: Date | null;
@Column({ name: 'provenance_verified_by', type: 'uuid', nullable: true })
provenanceVerifiedBy!: string | null;
@Column({ name: 'provenance_updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
provenanceUpdatedAt!: Date;
@Column({ name: 'provenance_updated_by', type: 'uuid', nullable: true })
provenanceUpdatedBy!: string | null;
}
@@ -0,0 +1,163 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum AuditSource {
WEB = 'WEB',
ANDROID = 'ANDROID',
SYSTEM = 'SYSTEM',
IMPORT = 'IMPORT',
}
export enum AuditAction {
AUTH_LOGIN_SUCCESS = 'AUTH_LOGIN_SUCCESS',
AUTH_LOGIN_FAILED = 'AUTH_LOGIN_FAILED',
AUTH_LOGOUT = 'AUTH_LOGOUT',
AUTH_REFRESH = 'AUTH_REFRESH',
AUTH_REFRESH_REUSE_DETECTED = 'AUTH_REFRESH_REUSE_DETECTED',
AUTH_PASSWORD_CHANGED = 'AUTH_PASSWORD_CHANGED',
SYSTEM_BOOTSTRAP_ADMIN_CREATED = 'SYSTEM_BOOTSTRAP_ADMIN_CREATED',
USER_CREATED = 'USER_CREATED',
USER_UPDATED = 'USER_UPDATED',
USER_PASSWORD_RESET = 'USER_PASSWORD_RESET',
USER_STATUS_CHANGED = 'USER_STATUS_CHANGED',
USER_ROLES_CHANGED = 'USER_ROLES_CHANGED',
ROLE_CREATED = 'ROLE_CREATED',
ROLE_UPDATED = 'ROLE_UPDATED',
ROLE_PERMISSIONS_CHANGED = 'ROLE_PERMISSIONS_CHANGED',
ASSET_TYPE_CREATED = 'ASSET_TYPE_CREATED',
ASSET_TYPE_UPDATED = 'ASSET_TYPE_UPDATED',
ASSET_MASTER_BOOTSTRAPPED = 'ASSET_MASTER_BOOTSTRAPPED',
ASSET_ATTRIBUTE_CREATED = 'ASSET_ATTRIBUTE_CREATED',
ASSET_ATTRIBUTE_UPDATED = 'ASSET_ATTRIBUTE_UPDATED',
ASSET_CREATED = 'ASSET_CREATED',
ASSET_FIELD_DISCOVERY_CREATED = 'ASSET_FIELD_DISCOVERY_CREATED',
ASSET_FIELD_DISCOVERY_APPROVED = 'ASSET_FIELD_DISCOVERY_APPROVED',
ASSET_FIELD_DISCOVERY_MATCHED = 'ASSET_FIELD_DISCOVERY_MATCHED',
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
ASSET_UPDATED = 'ASSET_UPDATED',
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
ASSET_IMPORT_BATCH_ANALYZED = 'ASSET_IMPORT_BATCH_ANALYZED',
ASSET_IMPORT_BATCH_RECONCILED = 'ASSET_IMPORT_BATCH_RECONCILED',
ASSET_IMPORT_PLAN_GENERATED = 'ASSET_IMPORT_PLAN_GENERATED',
ASSET_IMPORT_PLAN_ITEM_RESOLVED = 'ASSET_IMPORT_PLAN_ITEM_RESOLVED',
ASSET_IMPORT_PLAN_APPLIED = 'ASSET_IMPORT_PLAN_APPLIED',
ASSET_IMPORT_PLAN_ROLLED_BACK = 'ASSET_IMPORT_PLAN_ROLLED_BACK',
ASSET_IMPORT_BATCH_CANCELLED = 'ASSET_IMPORT_BATCH_CANCELLED',
SOURCE_DOCUMENT_CREATED = 'SOURCE_DOCUMENT_CREATED',
AREA_LEGAL_RIGHT_CREATED = 'AREA_LEGAL_RIGHT_CREATED',
AREA_LEGAL_RIGHT_UPDATED = 'AREA_LEGAL_RIGHT_UPDATED',
AREA_LEGAL_RIGHT_ORGANIZATION_ENDED = 'AREA_LEGAL_RIGHT_ORGANIZATION_ENDED',
ASSET_GEOMETRY_UPDATED = 'ASSET_GEOMETRY_UPDATED',
ASSET_GEOMETRY_REMOVED = 'ASSET_GEOMETRY_REMOVED',
ASSET_MEDIA_UPLOADED = 'ASSET_MEDIA_UPLOADED',
ASSET_MEDIA_UPDATED = 'ASSET_MEDIA_UPDATED',
ASSET_MEDIA_REMOVED = 'ASSET_MEDIA_REMOVED',
ASSET_PROVENANCE_UPDATED = 'ASSET_PROVENANCE_UPDATED',
ASSET_PROVENANCE_VERIFIED = 'ASSET_PROVENANCE_VERIFIED',
ASSET_AREA_COMPANY_RELATION_CREATED = 'ASSET_AREA_COMPANY_RELATION_CREATED',
ASSET_AREA_COMPANY_RELATION_ENDED = 'ASSET_AREA_COMPANY_RELATION_ENDED',
SURVEY_CAMPAIGN_CREATED = 'SURVEY_CAMPAIGN_CREATED',
SURVEY_CAMPAIGN_UPDATED = 'SURVEY_CAMPAIGN_UPDATED',
SURVEY_CAMPAIGN_STATUS_CHANGED = 'SURVEY_CAMPAIGN_STATUS_CHANGED',
SURVEY_TARGET_ADDED = 'SURVEY_TARGET_ADDED',
SURVEY_TARGET_UPDATED = 'SURVEY_TARGET_UPDATED',
SURVEY_TARGET_ASSIGNED = 'SURVEY_TARGET_ASSIGNED',
SURVEY_TARGET_STATUS_CHANGED = 'SURVEY_TARGET_STATUS_CHANGED',
SURVEY_REPORT_SAVED = 'SURVEY_REPORT_SAVED',
SURVEY_REPORT_SUBMITTED = 'SURVEY_REPORT_SUBMITTED',
SURVEY_REPORT_APPROVED = 'SURVEY_REPORT_APPROVED',
SURVEY_REPORT_REJECTED = 'SURVEY_REPORT_REJECTED',
INSPECTION_VISIT_CREATED = 'INSPECTION_VISIT_CREATED',
INSPECTION_VISIT_UPDATED = 'INSPECTION_VISIT_UPDATED',
INSPECTION_VISIT_STATUS_CHANGED = 'INSPECTION_VISIT_STATUS_CHANGED',
INSPECTION_VISIT_ASSETS_UPDATED = 'INSPECTION_VISIT_ASSETS_UPDATED',
INSPECTION_VISIT_TEAM_UPDATED = 'INSPECTION_VISIT_TEAM_UPDATED',
INSPECTION_VISIT_CHECKLIST_GENERATED = 'INSPECTION_VISIT_CHECKLIST_GENERATED',
INSPECTION_VISIT_ASSET_EXCLUDED = 'INSPECTION_VISIT_ASSET_EXCLUDED',
INSPECTION_VISIT_ASSET_REINCLUDED = 'INSPECTION_VISIT_ASSET_REINCLUDED',
INSPECTION_VISIT_STARTED = 'INSPECTION_VISIT_STARTED',
INSPECTION_ACT_CREATED = 'INSPECTION_ACT_CREATED',
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
INSPECTION_VERIFICATION_PLANNED = 'INSPECTION_VERIFICATION_PLANNED',
INSPECTION_VERIFICATION_RESULT_RECORDED = 'INSPECTION_VERIFICATION_RESULT_RECORDED',
INSPECTION_FINDING_CREATED = 'INSPECTION_FINDING_CREATED',
INSPECTION_FINDING_UPDATED = 'INSPECTION_FINDING_UPDATED',
INSPECTION_FINDING_FOLLOW_UP_UPDATED = 'INSPECTION_FINDING_FOLLOW_UP_UPDATED',
INSPECTION_FINDING_CLOSED = 'INSPECTION_FINDING_CLOSED',
INSPECTION_FINDING_EVIDENCE_UPLOADED = 'INSPECTION_FINDING_EVIDENCE_UPLOADED',
INSPECTION_FINDING_COMMUNICATION_CREATED = 'INSPECTION_FINDING_COMMUNICATION_CREATED',
FINDING_CATEGORY_CREATED = 'FINDING_CATEGORY_CREATED',
FINDING_CATEGORY_UPDATED = 'FINDING_CATEGORY_UPDATED',
FINDING_CATALOG_ITEM_CREATED = 'FINDING_CATALOG_ITEM_CREATED',
FINDING_CATALOG_ITEM_UPDATED = 'FINDING_CATALOG_ITEM_UPDATED',
FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED = 'FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED',
FINDING_CATALOG_ASSET_SELECTION_UPDATED = 'FINDING_CATALOG_ASSET_SELECTION_UPDATED',
FINDING_CATALOG_PROPOSAL_MATCHED = 'FINDING_CATALOG_PROPOSAL_MATCHED',
FINDING_CATALOG_PROPOSAL_REJECTED = 'FINDING_CATALOG_PROPOSAL_REJECTED',
}
@Entity({ name: 'audit_events' })
@Index('idx_audit_events_occurred_at', ['occurredAt'])
@Index('idx_audit_events_actor_user_id', ['actorUserId'])
@Index('idx_audit_events_action', ['action'])
@Index('idx_audit_events_entity', ['entityType', 'entityId'])
@Index('idx_audit_events_request_id', ['requestId'])
export class AuditEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
occurredAt!: Date;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ type: 'varchar', length: 100 })
action!: string;
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
entityType!: string | null;
@Column({ name: 'entity_id', type: 'varchar', length: 255, nullable: true })
entityId!: string | null;
@Column({ name: 'request_id', type: 'varchar', length: 128, nullable: true })
requestId!: string | null;
@Column({ type: 'varchar', length: 32 })
source!: AuditSource;
@Column({ type: 'inet', nullable: true })
ip!: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent!: string | null;
@Column({ name: 'before_data', type: 'jsonb', nullable: true })
beforeData!: Record<string, unknown> | null;
@Column({ name: 'after_data', type: 'jsonb', nullable: true })
afterData!: Record<string, unknown> | null;
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, unknown> | null;
}
@@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity({ name: 'auth_sessions' })
@Index('idx_auth_sessions_user_id', ['userId'])
@Index('idx_auth_sessions_expires_at', ['expiresAt'])
@Index('idx_auth_sessions_revoked_at', ['revokedAt'])
@Index('idx_auth_sessions_replaced_by_session_id', ['replacedBySessionId'])
export class AuthSession {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ name: 'refresh_token_hash', type: 'text', select: false })
refreshTokenHash!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@Column({ name: 'last_used_at', type: 'timestamptz' })
lastUsedAt!: Date;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@Column({ name: 'replaced_by_session_id', type: 'uuid', nullable: true })
replacedBySessionId!: string | null;
@Column({ type: 'inet', nullable: true })
ip!: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent!: string | null;
@Column({ name: 'device_label', type: 'text', nullable: true })
deviceLabel!: string | null;
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'user_id' })
user!: User;
}
@@ -0,0 +1,21 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
export enum DocumentSequenceType {
ACT = 'ACT',
REPORT = 'REPORT',
}
@Entity({ name: 'document_annual_sequences' })
export class DocumentAnnualSequence {
@PrimaryColumn({ name: 'document_type', type: 'varchar', length: 20 })
documentType!: DocumentSequenceType;
@PrimaryColumn({ type: 'integer' })
year!: number;
@Column({ name: 'last_number', type: 'integer', default: 0 })
lastNumber!: number;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'finding_catalog_asset_overrides' })
@Index('uq_finding_catalog_asset_overrides_pair', ['assetId', 'catalogItemId'], { unique: true })
@Index('idx_finding_catalog_asset_overrides_asset', ['assetId'])
export class FindingCatalogAssetOverride extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'catalog_item_id', type: 'uuid' })
catalogItemId!: string;
@Column({ name: 'is_enabled', type: 'boolean' })
isEnabled!: boolean;
@Column({ type: 'text' })
reason!: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,15 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'finding_catalog_asset_type_profiles' })
@Index('idx_finding_catalog_asset_type_profiles_updated', ['updatedAt'])
export class FindingCatalogAssetTypeProfile extends TimestampedEntity {
@PrimaryColumn({ name: 'asset_type_id', type: 'uuid' })
assetTypeId!: string;
@Column({ type: 'text' })
reason!: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,19 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'finding_catalog_item_asset_types' })
@Index('uq_finding_catalog_item_asset_types_pair', ['catalogItemId', 'assetTypeId'], { unique: true })
@Index('idx_finding_catalog_item_asset_types_type', ['assetTypeId', 'catalogItemId'])
export class FindingCatalogItemAssetType extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'catalog_item_id', type: 'uuid' })
catalogItemId!: string;
@Column({ name: 'asset_type_id', type: 'uuid' })
assetTypeId!: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -0,0 +1,41 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'finding_catalog_items' })
@Index('uq_finding_catalog_items_code', ['code'], { unique: true })
@Index('uq_finding_catalog_items_source', ['categoryId', 'sourceNumber'], { unique: true })
@Index('idx_finding_catalog_items_active', ['categoryId', 'isActive'])
export class FindingCatalogItem extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'category_id', type: 'uuid' })
categoryId!: string;
@Column({ type: 'varchar', length: 120 })
code!: string;
@Column({ name: 'source_number', type: 'integer' })
sourceNumber!: number;
@Column({ type: 'varchar', length: 500 })
title!: string;
@Column({ name: 'legal_basis', type: 'text', nullable: true })
legalBasis!: string | null;
@Column({ type: 'text', nullable: true })
glossary!: string | null;
@Column({ name: 'import_note', type: 'text', nullable: true })
importNote!: string | null;
@Column({ name: 'suggested_severity', type: 'smallint', nullable: true })
suggestedSeverity!: number | null;
@Column({ type: 'integer', default: 1 })
revision!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}
@@ -0,0 +1,56 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum FindingCatalogProposalStatus {
PENDING = 'PENDING',
MATCHED = 'MATCHED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'finding_catalog_proposals' })
@Index('uq_finding_catalog_proposals_finding', ['findingId'], { unique: true })
@Index('idx_finding_catalog_proposals_status', ['status', 'createdAt'])
@Index('idx_finding_catalog_proposals_asset_type', ['assetTypeId', 'status'])
export class FindingCatalogProposal extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'asset_type_id', type: 'uuid' })
assetTypeId!: string;
@Column({ name: 'proposed_title', type: 'varchar', length: 500 })
proposedTitle!: string;
@Column({ name: 'proposed_legal_basis', type: 'text', nullable: true })
proposedLegalBasis!: string | null;
@Column({ name: 'proposed_severity', type: 'smallint', nullable: true })
proposedSeverity!: number | null;
@Column({ type: 'text' })
description!: string;
@Column({ type: 'varchar', length: 20, default: FindingCatalogProposalStatus.PENDING })
status!: FindingCatalogProposalStatus;
@Column({ name: 'resolved_catalog_item_id', type: 'uuid', nullable: true })
resolvedCatalogItemId!: string | null;
@Column({ name: 'office_notes', type: 'text', nullable: true })
officeNotes!: string | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -0,0 +1,22 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'finding_categories' })
@Index('uq_finding_categories_code', ['code'], { unique: true })
@Index('idx_finding_categories_active_order', ['isActive', 'sortOrder'])
export class FindingCategory extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}
+237
View File
@@ -0,0 +1,237 @@
export { AuditAction, AuditEvent, AuditSource } from './audit-event.entity';
export { AuthSession } from './auth-session.entity';
export { Permission } from './permission.entity';
export { RolePermission } from './role-permission.entity';
export { Role } from './role.entity';
export { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export {
AssetVersion,
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
export { OrganizationMembership, OrganizationMembershipRole } from './organization-membership.entity';
export { SourceDocument, SourceDocumentType } from './source-document.entity';
export { AssetSourceDocument, AssetSourceDocumentRelationType } from './asset-source-document.entity';
export { AssetExternalIdentifier } from './asset-external-identifier.entity';
export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area-legal-right.entity';
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export {
AssetGeometry,
AssetGeometrySource,
AssetGeometryType,
} from './asset-geometry.entity';
export {
AssetMedia,
AssetMediaKind,
AssetMediaSource,
} from './asset-media.entity';
export {
SurveyCampaign,
SurveyCampaignStatus,
} from './survey-campaign.entity';
export {
SurveyCampaignTarget,
SurveyTargetStatus,
} from './survey-campaign-target.entity';
export {
SurveyReportOutcome,
SurveyReportStatus,
SurveyTargetReport,
} from './survey-target-report.entity';
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
export {
SurveyReportVersionEvent,
SurveyTargetReportVersion,
} from './survey-target-report-version.entity';
export {
InspectionVisit,
InspectionVisitStatus,
} from './inspection-visit.entity';
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity';
export {
DocumentAnnualSequence,
DocumentSequenceType,
} from './document-annual-sequence.entity';
export {
InspectionAct,
InspectionActStatus,
} from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity';
export {
InspectionReport,
InspectionReportPdfStatus,
InspectionReportReviewStatus,
InspectionReportStatus,
InspectionReportWordStatus,
} from './inspection-report.entity';
export {
InspectionActVersion,
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export {
InspectionActResponsible,
InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity';
export {
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export {
InspectionActSignature,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
} from './inspection-act-signature.entity';
export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export {
InspectionFinding,
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
} from './inspection-finding.entity';
export {
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionFindingCommunication,
} from './inspection-finding-communication.entity';
export {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionEvidenceSource,
InspectionFindingEvidence,
} from './inspection-finding-evidence.entity';
export {
InspectionFindingVerificationVisit,
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.entity';
import { Permission } from './permission.entity';
import { RolePermission } from './role-permission.entity';
import { Role } from './role.entity';
import { UserRole } from './user-role.entity';
import { User } from './user.entity';
import { Asset } from './asset.entity';
import { AssetAttributeDefinition } from './asset-attribute-definition.entity';
import { AssetAttributeValue } from './asset-attribute-value.entity';
import { AssetTypeParentRule } from './asset-type-parent-rule.entity';
import { AssetType } from './asset-type.entity';
import { AreaCompanyRelation } from './area-company-relation.entity';
import { OrganizationProfile } from './organization-profile.entity';
import { OrganizationMembership } from './organization-membership.entity';
import { SourceDocument } from './source-document.entity';
import { AssetSourceDocument } from './asset-source-document.entity';
import { AssetExternalIdentifier } from './asset-external-identifier.entity';
import { AreaLegalRight } from './area-legal-right.entity';
import { AreaLegalRightOrganization } from './area-legal-right-organization.entity';
import { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.entity';
import { SurveyCampaign } from './survey-campaign.entity';
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
import { SurveyTargetReport } from './survey-target-report.entity';
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
import { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.entity';
import { DocumentAnnualSequence } from './document-annual-sequence.entity';
import { InspectionAct } from './inspection-act.entity';
import { InspectionActAsset } from './inspection-act-asset.entity';
import { InspectionReport } from './inspection-report.entity';
import { InspectionActVersion } from './inspection-act-version.entity';
import { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity';
import { InspectionActSignature } from './inspection-act-signature.entity';
import { FindingCategory } from './finding-category.entity';
import { FindingCatalogItem } from './finding-catalog-item.entity';
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
import { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
import { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
import { FindingCatalogProposal } from './finding-catalog-proposal.entity';
import { InspectionFinding } from './inspection-finding.entity';
import { InspectionFindingVersion } from './inspection-finding-version.entity';
import { InspectionFindingCommunication } from './inspection-finding-communication.entity';
import { InspectionFindingEvidence } from './inspection-finding-evidence.entity';
import { InspectionFindingVerificationVisit } from './inspection-finding-verification-visit.entity';
import { InspectionFindingVerificationEvent } from './inspection-finding-verification-event.entity';
export const PHASE_A_ENTITIES = [
User,
Role,
Permission,
UserRole,
RolePermission,
AuthSession,
AuditEvent,
AssetType,
AssetTypeParentRule,
AssetAttributeDefinition,
Asset,
AssetAttributeValue,
AreaCompanyRelation,
OrganizationProfile,
OrganizationMembership,
SourceDocument,
AssetSourceDocument,
AssetExternalIdentifier,
AreaLegalRight,
AreaLegalRightOrganization,
AssetGeometry,
AssetVersion,
AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit,
InspectionVisitAsset,
InspectionVisitMember,
DocumentAnnualSequence,
InspectionAct,
InspectionActAsset,
InspectionReport,
InspectionActVersion,
InspectionActResponsible,
InspectionActClosure,
InspectionActSignature,
FindingCategory,
FindingCatalogItem,
FindingCatalogItemAssetType,
FindingCatalogAssetTypeProfile,
FindingCatalogAssetOverride,
FindingCatalogProposal,
InspectionFinding,
InspectionFindingVersion,
InspectionFindingCommunication,
InspectionFindingEvidence,
InspectionFindingVerificationVisit,
InspectionFindingVerificationEvent,
];
@@ -0,0 +1,19 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_act_assets' })
@Index('idx_inspection_act_assets_asset_id', ['assetId'])
@Index('idx_inspection_act_assets_included', ['actId', 'included'])
export class InspectionActAsset extends TimestampedEntity {
@PrimaryColumn({ name: 'act_id', type: 'uuid' })
actId!: string;
@PrimaryColumn({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -0,0 +1,51 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
export enum InspectionActUploadMode {
IMMEDIATE = 'IMMEDIATE',
DEFERRED = 'DEFERRED',
}
@Entity({ name: 'inspection_act_closures' })
export class InspectionActClosure {
@PrimaryColumn({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'schema_version', type: 'varchar', length: 40 })
schemaVersion!: string;
@Column({ name: 'prepared_snapshot', type: 'jsonb' })
preparedSnapshot!: Record<string, unknown>;
@Column({ name: 'prepared_sha256', type: 'char', length: 64 })
preparedSha256!: string;
@Column({ name: 'prepared_at', type: 'timestamptz' })
preparedAt!: Date;
@Column({ name: 'prepared_by', type: 'uuid' })
preparedBy!: string;
@Column({ name: 'final_snapshot', type: 'jsonb', nullable: true })
finalSnapshot!: Record<string, unknown> | null;
@Column({ name: 'final_sha256', type: 'char', length: 64, nullable: true })
finalSha256!: string | null;
@Column({ name: 'device_closed_at', type: 'timestamptz', nullable: true })
deviceClosedAt!: Date | null;
@Column({ name: 'server_closed_at', type: 'timestamptz', nullable: true })
serverClosedAt!: Date | null;
@Column({ name: 'upload_mode', type: 'varchar', length: 20, nullable: true })
uploadMode!: InspectionActUploadMode | null;
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
closedBy!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}
@@ -0,0 +1,52 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
export enum InspectionResponsibleAttendanceStatus {
PRESENT = 'PRESENT',
ABSENT = 'ABSENT',
}
export enum InspectionResponsibleDocumentType {
DNI = 'DNI',
CUIL = 'CUIL',
PASSPORT = 'PASSPORT',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_act_responsibles' })
export class InspectionActResponsible {
@PrimaryColumn({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'attendance_status', type: 'varchar', length: 20 })
attendanceStatus!: InspectionResponsibleAttendanceStatus;
@Column({ name: 'full_name', type: 'varchar', length: 200, nullable: true })
fullName!: string | null;
@Column({ name: 'document_type', type: 'varchar', length: 20, nullable: true })
documentType!: InspectionResponsibleDocumentType | null;
@Column({ name: 'document_number', type: 'varchar', length: 40, nullable: true })
documentNumber!: string | null;
@Column({ type: 'varchar', length: 200, nullable: true })
position!: string | null;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
phone!: string | null;
@Column({ name: 'absence_reason', type: 'text', nullable: true })
absenceReason!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}
@@ -0,0 +1,110 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { InspectionResponsibleDocumentType } from './inspection-act-responsible.entity';
export enum InspectionActSignerType {
INSPECTOR = 'INSPECTOR',
COMPANY_RESPONSIBLE = 'COMPANY_RESPONSIBLE',
}
export enum InspectionActSignatureStatus {
SIGNED = 'SIGNED',
REFUSED = 'REFUSED',
ABSENT = 'ABSENT',
}
export enum InspectionActSignatureSource {
WEB = 'WEB',
ANDROID = 'ANDROID',
}
@Entity({ name: 'inspection_act_signatures' })
@Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt'])
@Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256'])
export class InspectionActSignature {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'signer_type', type: 'varchar', length: 32 })
signerType!: InspectionActSignerType;
@Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
signerUserId!: string | null;
@Column({ name: 'signer_name', type: 'varchar', length: 200 })
signerName!: string;
@Column({ name: 'document_type', type: 'varchar', length: 20, nullable: true })
documentType!: InspectionResponsibleDocumentType | null;
@Column({ name: 'document_number', type: 'varchar', length: 40, nullable: true })
documentNumber!: string | null;
@Column({ type: 'varchar', length: 200, nullable: true })
position!: string | null;
@Column({ type: 'varchar', length: 20 })
status!: InspectionActSignatureStatus;
@Column({ type: 'text', nullable: true })
reason!: string | null;
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
originalName!: string | null;
@Column({ name: 'stored_name', type: 'varchar', length: 80, nullable: true })
storedName!: string | null;
@Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true })
mimeType!: string | null;
@Column({ name: 'size_bytes', type: 'bigint', nullable: true })
sizeBytes!: number | null;
@Column({ name: 'image_sha256', type: 'char', length: 64, nullable: true })
imageSha256!: string | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText!: string | null;
@Column({ name: 'consent_version', type: 'varchar', length: 20, nullable: true })
consentVersion!: string | null;
@Column({ name: 'consent_accepted_at', type: 'timestamptz', nullable: true })
consentAcceptedAt!: Date | null;
@Column({ name: 'client_signed_at', type: 'timestamptz', nullable: true })
clientSignedAt!: Date | null;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: number | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: number | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: number | null;
@Column({ name: 'device_label', type: 'varchar', length: 200, nullable: true })
deviceLabel!: string | null;
@Column({ type: 'varchar', length: 20 })
source!: InspectionActSignatureSource;
@Column({ name: 'prepared_sha256', type: 'char', length: 64 })
preparedSha256!: string;
@Column({ name: 'signature_payload_sha256', type: 'char', length: 64 })
signaturePayloadSha256!: string;
@Column({ name: 'uploaded_by', type: 'uuid' })
uploadedBy!: string;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,39 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionActVersionEvent {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
READY = 'READY',
REOPENED = 'REOPENED',
CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'inspection_act_versions' })
@Index('uq_inspection_act_versions_number', ['actId', 'versionNumber'], { unique: true })
@Index('idx_inspection_act_versions_created_at', ['createdAt'])
export class InspectionActVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: InspectionActVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,70 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionActStatus {
DRAFT = 'DRAFT',
READY = 'READY',
CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED',
RECTIFIED = 'RECTIFIED',
}
@Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_visit', ['visitId'], { unique: true })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@Index('uq_inspection_acts_code', ['code'], { unique: true })
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
@Index('idx_inspection_acts_occurred_at', ['occurredAt'])
@Index('idx_inspection_acts_created_by', ['createdBy'])
export class InspectionAct extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'visit_id', type: 'uuid' })
visitId!: string;
@Column({ name: 'act_year', type: 'integer' })
actYear!: number;
@Column({ name: 'act_number', type: 'integer' })
actNumber!: number;
@Column({ type: 'varchar', length: 24 })
code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
status!: InspectionActStatus;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text' })
summary!: string;
@Column({ type: 'text', nullable: true })
observations!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number;
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
cancellationReason!: string | null;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt!: Date | null;
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
closedBy!: string | null;
@Column({ name: 'closure_sha256', type: 'char', length: 64, nullable: true })
closureSha256!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,64 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionCommunicationDirection {
INBOUND = 'INBOUND',
OUTBOUND = 'OUTBOUND',
INTERNAL = 'INTERNAL',
}
export enum InspectionCommunicationChannel {
EMAIL = 'EMAIL',
IN_PERSON = 'IN_PERSON',
PHONE = 'PHONE',
LETTER = 'LETTER',
SYSTEM = 'SYSTEM',
OTHER = 'OTHER',
}
export enum InspectionCommunicationType {
COMPANY_RESPONSE = 'COMPANY_RESPONSE',
AUTHORITY_NOTICE = 'AUTHORITY_NOTICE',
FOLLOW_UP = 'FOLLOW_UP',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_finding_communications' })
@Index('uq_inspection_finding_communications_id_finding', ['id', 'findingId'], { unique: true })
@Index('idx_inspection_finding_communications_timeline', ['findingId', 'occurredAt'])
export class InspectionFindingCommunication {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ type: 'varchar', length: 20 })
direction!: InspectionCommunicationDirection;
@Column({ type: 'varchar', length: 20 })
channel!: InspectionCommunicationChannel;
@Column({ type: 'varchar', length: 32 })
type!: InspectionCommunicationType;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'varchar', length: 250 })
subject!: string;
@Column({ type: 'text', nullable: true })
details!: string | null;
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
contactName!: string | null;
@Column({ name: 'contact_email', type: 'varchar', length: 320, nullable: true })
contactEmail!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,89 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionEvidenceKind {
PHOTO = 'PHOTO',
DOCUMENT = 'DOCUMENT',
}
export enum InspectionEvidencePurpose {
OBSERVATION = 'OBSERVATION',
VERIFICATION = 'VERIFICATION',
COMPANY_RESPONSE = 'COMPANY_RESPONSE',
COMMUNICATION_ATTACHMENT = 'COMMUNICATION_ATTACHMENT',
OTHER_DOCUMENT = 'OTHER_DOCUMENT',
}
export enum InspectionEvidenceSource {
WEB = 'WEB',
ANDROID = 'ANDROID',
IMPORT = 'IMPORT',
}
@Entity({ name: 'inspection_finding_evidence' })
@Index('idx_inspection_finding_evidence_finding_created', ['findingId', 'createdAt'])
@Index('idx_inspection_finding_evidence_sha256', ['sha256'])
@Index('idx_inspection_finding_evidence_communication', ['communicationId'])
export class InspectionFindingEvidence {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ name: 'communication_id', type: 'uuid', nullable: true })
communicationId!: string | null;
@Column({ name: 'verification_visit_id', type: 'uuid', nullable: true })
verificationVisitId!: string | null;
@Column({ type: 'varchar', length: 20 })
kind!: InspectionEvidenceKind;
@Column({ type: 'varchar', length: 40 })
purpose!: InspectionEvidencePurpose;
@Column({ name: 'original_name', type: 'varchar', length: 255 })
originalName!: string;
@Column({ name: 'stored_name', type: 'varchar', length: 80 })
storedName!: string;
@Column({ name: 'mime_type', type: 'varchar', length: 100 })
mimeType!: string;
@Column({ name: 'size_bytes', type: 'bigint' })
sizeBytes!: string;
@Column({ type: 'char', length: 64 })
sha256!: string;
@Column({ type: 'varchar', length: 200, nullable: true })
title!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'captured_at', type: 'timestamptz', nullable: true })
capturedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ name: 'device_label', type: 'varchar', length: 200, nullable: true })
deviceLabel!: string | null;
@Column({ type: 'varchar', length: 20 })
source!: InspectionEvidenceSource;
@Column({ name: 'uploaded_by', type: 'uuid', nullable: true })
uploadedBy!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,54 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
export enum InspectionFindingVerificationEventType {
CONTROL_DATE_DEFINED = 'CONTROL_DATE_DEFINED',
CONTROL_DATE_CHANGED = 'CONTROL_DATE_CHANGED',
CONTROL_DATE_CLEARED = 'CONTROL_DATE_CLEARED',
VISIT_PLANNED = 'VISIT_PLANNED',
RESULT_RECORDED = 'RESULT_RECORDED',
}
@Entity({ name: 'inspection_finding_verification_events' })
@Index('idx_inspection_finding_verification_events_finding', ['findingId', 'occurredAt'])
@Index('idx_inspection_finding_verification_events_visit', ['verificationVisitId'])
export class InspectionFindingVerificationEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ name: 'verification_visit_id', type: 'uuid', nullable: true })
verificationVisitId!: string | null;
@Column({ name: 'event_type', type: 'varchar', length: 40 })
eventType!: InspectionFindingVerificationEventType;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'target_control_on', type: 'date', nullable: true })
targetControlOn!: string | null;
@Column({ name: 'previous_control_on', type: 'date', nullable: true })
previousControlOn!: string | null;
@Column({ name: 'next_control_on', type: 'date', nullable: true })
nextControlOn!: string | null;
@Column({ type: 'varchar', length: 24, nullable: true })
outcome!: InspectionVerificationOutcome | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,47 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionVerificationOutcome {
RESOLVED = 'RESOLVED',
NOT_RESOLVED = 'NOT_RESOLVED',
REQUIRES_NEW_DATE = 'REQUIRES_NEW_DATE',
}
@Entity({ name: 'inspection_finding_verification_visits' })
@Index('uq_inspection_finding_verification_visit', ['findingId', 'visitId'], { unique: true })
@Index('idx_inspection_finding_verification_finding', ['findingId'])
@Index('idx_inspection_finding_verification_visit', ['visitId'])
export class InspectionFindingVerificationVisit extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ name: 'visit_id', type: 'uuid' })
visitId!: string;
@Column({ name: 'linked_by', type: 'uuid', nullable: true })
linkedBy!: string | null;
@Column({ name: 'target_control_on', type: 'date', nullable: true })
targetControlOn!: string | null;
@Column({ type: 'varchar', length: 24, nullable: true })
outcome!: InspectionVerificationOutcome | null;
@Column({ name: 'result_notes', type: 'text', nullable: true })
resultNotes!: string | null;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt!: Date | null;
@Column({ name: 'result_recorded_at', type: 'timestamptz', nullable: true })
resultRecordedAt!: Date | null;
@Column({ name: 'result_recorded_by', type: 'uuid', nullable: true })
resultRecordedBy!: string | null;
@Column({ name: 'rescheduled_control_on', type: 'date', nullable: true })
rescheduledControlOn!: string | null;
}
@@ -0,0 +1,37 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionFindingVersionEvent {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
FOLLOW_UP_UPDATED = 'FOLLOW_UP_UPDATED',
VERIFICATION_RECORDED = 'VERIFICATION_RECORDED',
}
@Entity({ name: 'inspection_finding_versions' })
@Index('uq_inspection_finding_versions_number', ['findingId', 'versionNumber'], { unique: true })
@Index('idx_inspection_finding_versions_created_at', ['createdAt'])
export class InspectionFindingVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'finding_id', type: 'uuid' })
findingId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 32 })
event!: InspectionFindingVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,108 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionFindingResponseDueBasis {
FINDING_DATE = 'FINDING_DATE',
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
}
export enum InspectionFindingStatus {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
VOIDED = 'VOIDED',
}
@Entity({ name: 'inspection_findings' })
@Index('uq_inspection_findings_number', ['actId', 'findingNumber'], { unique: true })
@Index('uq_inspection_findings_code', ['code'], { unique: true })
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'catalog_item_id', type: 'uuid', nullable: true })
catalogItemId!: string | null;
@Column({ name: 'finding_number', type: 'integer' })
findingNumber!: number;
@Column({ type: 'varchar', length: 40 })
code!: string;
@Column({ type: 'varchar', length: 20, default: InspectionFindingStatus.OPEN })
status!: InspectionFindingStatus;
@Column({ type: 'varchar', length: 500 })
title!: string;
@Column({ type: 'text' })
description!: string;
@Column({ name: 'legal_basis', type: 'text', nullable: true })
legalBasis!: string | null;
@Column({ type: 'text', nullable: true })
glossary!: string | null;
@Column({ name: 'catalog_revision', type: 'integer', nullable: true })
catalogRevision!: number | null;
@Column({ name: 'suggested_severity', type: 'smallint', nullable: true })
suggestedSeverity!: number | null;
@Column({ type: 'smallint', nullable: true })
severity!: number | null;
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null;
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
responseDueBasis!: InspectionFindingResponseDueBasis | null;
@Column({ name: 'response_due_days', type: 'integer', nullable: true })
responseDueDays!: number | null;
@Column({ name: 'response_due_base_on', type: 'date', nullable: true })
responseDueBaseOn!: string | null;
@Column({ name: 'report_notified_on', type: 'date', nullable: true })
reportNotifiedOn!: string | null;
@Column({ name: 'company_response', type: 'text', nullable: true })
companyResponse!: string | null;
@Column({ name: 'company_response_received_on', type: 'date', nullable: true })
companyResponseReceivedOn!: string | null;
@Column({ name: 'company_committed_correction_on', type: 'date', nullable: true })
companyCommittedCorrectionOn!: string | null;
@Column({ name: 'next_control_on', type: 'date', nullable: true })
nextControlOn!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt!: Date | null;
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
closedBy!: string | null;
@Column({ name: 'closure_notes', type: 'text', nullable: true })
closureNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,124 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportStatus {
FROZEN = 'FROZEN',
CANCELLED = 'CANCELLED',
}
export enum InspectionReportPdfStatus {
PENDING = 'PENDING',
READY = 'READY',
FAILED = 'FAILED',
}
export enum InspectionReportWordStatus {
PENDING = 'PENDING',
READY = 'READY',
FAILED = 'FAILED',
}
export enum InspectionReportReviewStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
APPROVED = 'APPROVED',
SIGNED = 'SIGNED',
}
@Entity({ name: 'inspection_reports' })
@Index('uq_inspection_reports_visit', ['visitId'], { unique: true })
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
@Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true })
@Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'visit_id', type: 'uuid' })
visitId!: string;
@Column({ name: 'act_id', type: 'uuid' })
actId!: string;
@Column({ name: 'report_year', type: 'integer' })
reportYear!: number;
@Column({ name: 'report_number', type: 'integer' })
reportNumber!: number;
@Column({ type: 'varchar', length: 24 })
code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN })
status!: InspectionReportStatus;
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
pdfStatus!: InspectionReportPdfStatus;
@Column({ name: 'word_status', type: 'varchar', length: 24, default: InspectionReportWordStatus.PENDING })
wordStatus!: InspectionReportWordStatus;
@Column({ name: 'word_original_name', type: 'varchar', length: 255, nullable: true })
wordOriginalName!: string | null;
@Column({ name: 'word_stored_name', type: 'varchar', length: 255, nullable: true })
wordStoredName!: string | null;
@Column({ name: 'word_mime_type', type: 'varchar', length: 120, nullable: true })
wordMimeType!: string | null;
@Column({ name: 'word_size_bytes', type: 'integer', nullable: true })
wordSizeBytes!: number | null;
@Column({ name: 'word_sha256', type: 'char', length: 64, nullable: true })
wordSha256!: string | null;
@Column({ name: 'word_generated_at', type: 'timestamptz', nullable: true })
wordGeneratedAt!: Date | null;
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null;
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
reviewStatus!: InspectionReportReviewStatus;
@Column({ name: 'current_revision_number', type: 'integer', default: 0 })
currentRevisionNumber!: number;
@Column({ name: 'approved_revision_id', type: 'uuid', nullable: true })
approvedRevisionId!: string | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy!: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt!: Date | null;
@Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true })
reviewNote!: string | null;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt!: Date | null;
@Column({ type: 'varchar', length: 220 })
title!: string;
@Column({ name: 'act_version', type: 'integer' })
actVersion!: number;
@Column({ name: 'act_closure_sha256', type: 'char', length: 64 })
actClosureSha256!: string;
@Column({ name: 'frozen_sha256', type: 'char', length: 64 })
frozenSha256!: string;
@Column({ name: 'frozen_snapshot', type: 'jsonb' })
frozenSnapshot!: Record<string, unknown>;
@Column({ name: 'generated_at', type: 'timestamptz' })
generatedAt!: Date;
@Column({ name: 'generated_by', type: 'uuid' })
generatedBy!: string;
}
@@ -0,0 +1,38 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionVisitAssetPlanningSource {
LEGACY = 'LEGACY',
AUTOMATIC = 'AUTOMATIC',
PREVENTIVE = 'PREVENTIVE',
VERIFICATION = 'VERIFICATION',
}
@Entity({ name: 'inspection_visit_assets' })
@Index('idx_inspection_visit_assets_asset_id', ['assetId'])
@Index('idx_inspection_visit_assets_included', ['visitId', 'included'])
export class InspectionVisitAsset extends TimestampedEntity {
@PrimaryColumn({ name: 'visit_id', type: 'uuid' })
visitId!: string;
@PrimaryColumn({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'planning_source', type: 'varchar', length: 24, default: InspectionVisitAssetPlanningSource.LEGACY })
planningSource!: InspectionVisitAssetPlanningSource;
@Column({ name: 'exclusion_reason', type: 'text', nullable: true })
exclusionReason!: string | null;
@Column({ name: 'excluded_by', type: 'uuid', nullable: true })
excludedBy!: string | null;
@Column({ name: 'excluded_at', type: 'timestamptz', nullable: true })
excludedAt!: Date | null;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -0,0 +1,19 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_visit_members' })
@Index('idx_inspection_visit_members_user_id', ['userId'])
@Index('idx_inspection_visit_members_included', ['visitId', 'included'])
export class InspectionVisitMember extends TimestampedEntity {
@PrimaryColumn({ name: 'visit_id', type: 'uuid' })
visitId!: string;
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'assigned_by', type: 'uuid', nullable: true })
assignedBy!: string | null;
}
@@ -0,0 +1,76 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionVisitStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'inspection_visits' })
@Index('uq_inspection_visits_code', ['code'], { unique: true })
@Index('idx_inspection_visits_status', ['status'])
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt'])
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
export class InspectionVisit extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text', nullable: true })
objective!: string | null;
@Column({ type: 'varchar', length: 24, default: InspectionVisitStatus.DRAFT })
status!: InspectionVisitStatus;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'operational_area_id', type: 'uuid', nullable: true })
operationalAreaId!: string | null;
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'lead_inspector_user_id', type: 'uuid', nullable: true })
leadInspectorUserId!: string | null;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
actualStartedAt!: Date | null;
@Column({ name: 'actual_closed_at', type: 'timestamptz', nullable: true })
actualClosedAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
cancellationReason!: string | null;
@Column({ name: 'checklist_generation', type: 'integer', default: 0 })
checklistGeneration!: number;
@Column({ name: 'checklist_generated_at', type: 'timestamptz', nullable: true })
checklistGeneratedAt!: Date | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,21 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum OrganizationMembershipRole { MEMBER='MEMBER', LEAD_MEMBER='LEAD_MEMBER', OTHER='OTHER' }
@Entity({name:'organization_memberships'})
@Index('idx_organization_memberships_parent_id',['parentOrganizationId'])
@Index('idx_organization_memberships_member_id',['memberOrganizationId'])
@Index('idx_organization_memberships_valid_until',['validUntil'])
export class OrganizationMembership extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') id!:string;
@Column({name:'parent_organization_id',type:'uuid'}) parentOrganizationId!:string;
@Column({name:'member_organization_id',type:'uuid'}) memberOrganizationId!:string;
@Column({type:'enum',enum:OrganizationMembershipRole,enumName:'organization_membership_role'}) role!:OrganizationMembershipRole;
@Column({name:'participation_percent',type:'numeric',precision:7,scale:4,nullable:true}) participationPercent!:string|null;
@Column({name:'valid_from',type:'date',default:()=> 'CURRENT_DATE'}) validFrom!:string;
@Column({name:'valid_until',type:'date',nullable:true}) validUntil!:string|null;
@Column({name:'source_document_id',type:'uuid',nullable:true}) sourceDocumentId!:string|null;
@Column({type:'text',nullable:true}) notes!:string|null;
@Column({name:'end_reason',type:'text',nullable:true}) endReason!:string|null;
@Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null;
@Column({name:'ended_by',type:'uuid',nullable:true}) endedBy!:string|null;
}
@@ -0,0 +1,13 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum OrganizationKind { COMPANY='COMPANY', UTE='UTE', PUBLIC_ENTITY='PUBLIC_ENTITY', OTHER='OTHER' }
@Entity({ name: 'organization_profiles' })
export class OrganizationProfile extends TimestampedEntity {
@PrimaryColumn({ name:'asset_id', type:'uuid' }) assetId!: string;
@Column({ name:'organization_kind', type:'enum', enum:OrganizationKind, enumName:'organization_kind', default:OrganizationKind.COMPANY }) organizationKind!: OrganizationKind;
@Column({ name:'legal_name', type:'varchar', length:240, nullable:true }) legalName!: string|null;
@Column({ name:'tax_id', type:'varchar', length:32, nullable:true }) taxId!: string|null;
@Column({ name:'notification_email', type:'varchar', length:320, nullable:true }) notificationEmail!: string|null;
@Column({ type:'text', nullable:true }) notes!: string|null;
@Column({ name:'updated_by', type:'uuid', nullable:true }) updatedBy!: string|null;
}
@@ -0,0 +1,16 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'permissions' })
export class Permission {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 120 })
code!: string;
@Column({ type: 'text' })
description!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,21 @@
import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { Permission } from './permission.entity';
import { Role } from './role.entity';
@Entity({ name: 'role_permissions' })
@Index('idx_role_permissions_permission_id', ['permissionId'])
export class RolePermission {
@PrimaryColumn({ name: 'role_id', type: 'uuid' })
roleId!: string;
@PrimaryColumn({ name: 'permission_id', type: 'uuid' })
permissionId!: string;
@ManyToOne(() => Role, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'role_id' })
role!: Role;
@ManyToOne(() => Permission, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'permission_id' })
permission!: Permission;
}
@@ -0,0 +1,20 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'roles' })
export class Role extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 120 })
name!: string;
@Column({ type: 'text' })
description!: string;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem!: boolean;
}
@@ -0,0 +1,6 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SourceDocumentType { NOTE='NOTE', TECHNICAL_REPORT='TECHNICAL_REPORT', INSPECTION_ACT='INSPECTION_ACT', INVENTORY='INVENTORY', RESOLUTION='RESOLUTION', DECREE='DECREE', CONTRACT='CONTRACT', SPREADSHEET='SPREADSHEET', OTHER='OTHER' }
@Entity({name:'source_documents'})
@Index('idx_source_documents_number',['documentNumber']) @Index('idx_source_documents_document_date',['documentDate'])
export class SourceDocument extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!:string; @Column({name:'document_type',type:'enum',enum:SourceDocumentType,enumName:'source_document_type'}) documentType!:SourceDocumentType; @Column({name:'document_number',type:'varchar',length:160,nullable:true}) documentNumber!:string|null; @Column({type:'varchar',length:300}) title!:string; @Column({type:'varchar',length:240,nullable:true}) issuer!:string|null; @Column({name:'document_date',type:'date',nullable:true}) documentDate!:string|null; @Column({name:'external_reference',type:'varchar',length:500,nullable:true}) externalReference!:string|null; @Column({type:'text',nullable:true}) notes!:string|null; @Column({name:'created_by',type:'uuid',nullable:true}) createdBy!:string|null; @Column({name:'updated_by',type:'uuid',nullable:true}) updatedBy!:string|null; }
@@ -0,0 +1,44 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyTargetStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
SUBMITTED = 'SUBMITTED',
COMPLETED = 'COMPLETED',
SKIPPED = 'SKIPPED',
}
@Entity({ name: 'survey_campaign_targets' })
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
export class SurveyCampaignTarget extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'campaign_id', type: 'uuid' })
campaignId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
assignedUserId!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
status!: SurveyTargetStatus;
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,51 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyCampaignStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'survey_campaigns' })
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
@Index('idx_survey_campaigns_status', ['status'])
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
export class SurveyCampaign extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
status!: SurveyCampaignStatus;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
coordinatorUserId!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,19 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'survey_target_report_media' })
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
export class SurveyTargetReportMedia extends TimestampedEntity {
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
reportId!: string;
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
mediaId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SurveyReportVersionEvent {
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_report_versions' })
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
export class SurveyTargetReportVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: SurveyReportVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -0,0 +1,73 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyReportOutcome {
CONFIRMED = 'CONFIRMED',
CHANGES_RECORDED = 'CHANGES_RECORDED',
NOT_LOCATED = 'NOT_LOCATED',
}
export enum SurveyReportStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_reports' })
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
@Index('idx_survey_target_reports_status', ['status'])
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
export class SurveyTargetReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'target_id', type: 'uuid' })
targetId!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
outcome!: SurveyReportOutcome | null;
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
status!: SurveyReportStatus;
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
observedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
assetVersionAtSubmission!: number | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt!: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,9 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class TimestampedEntity {
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,35 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { Role } from './role.entity';
import { User } from './user.entity';
@Entity({ name: 'user_roles' })
@Index('idx_user_roles_user_id', ['userId'])
@Index('idx_user_roles_role_id', ['roleId'])
export class UserRole {
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
userId!: string;
@PrimaryColumn({ name: 'role_id', type: 'uuid' })
roleId!: string;
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
assignedAt!: Date;
@Column({ name: 'assigned_by', type: 'uuid', nullable: true })
assignedBy!: string | null;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@ManyToOne(() => Role, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'role_id' })
role!: Role;
}
@@ -0,0 +1,59 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum UserStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}
@Entity({ name: 'users' })
@Index('idx_users_status', ['status'])
@Index('idx_users_locked_until', ['lockedUntil'])
export class User extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
username!: string;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ name: 'password_hash', type: 'text', select: false })
passwordHash!: string;
@Column({ name: 'first_name', type: 'varchar', length: 120 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 120 })
lastName!: string;
@Column({
type: 'enum',
enum: UserStatus,
enumName: 'user_status',
default: UserStatus.ACTIVE,
})
status!: UserStatus;
@Column({ name: 'must_change_password', type: 'boolean', default: true })
mustChangePassword!: boolean;
@Column({ name: 'failed_login_attempts', type: 'integer', default: 0 })
failedLoginAttempts!: number;
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
lockedUntil!: Date | null;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt!: Date | null;
@Column({ name: 'password_changed_at', type: 'timestamptz', nullable: true })
passwordChangedAt!: Date | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
+49
View File
@@ -0,0 +1,49 @@
import 'reflect-metadata';
import { migrationDataSource } from './data-source';
type MigrationCommand = 'run' | 'show' | 'revert';
function readCommand(): MigrationCommand {
const command = process.argv[2];
if (command === 'run' || command === 'show' || command === 'revert') {
return command;
}
throw new Error('Expected migration command: run, show or revert');
}
async function main(): Promise<void> {
const command = readCommand();
await migrationDataSource.initialize();
try {
if (command === 'run') {
const migrations = await migrationDataSource.runMigrations({
transaction: 'each',
});
console.log(`Applied migrations: ${migrations.length}`);
for (const migration of migrations) console.log(`- ${migration.name}`);
return;
}
if (command === 'revert') {
await migrationDataSource.undoLastMigration({ transaction: 'each' });
console.log('Last migration reverted');
return;
}
const hasPendingMigrations = await migrationDataSource.showMigrations();
console.log(
hasPendingMigrations
? 'Pending migrations: yes'
: 'Pending migrations: no',
);
} finally {
await migrationDataSource.destroy();
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`Migration command failed: ${message}`);
process.exit(1);
});
@@ -0,0 +1,124 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseAUsersRolesPermissions1786548000000
implements MigrationInterface
{
name = 'PhaseAUsersRolesPermissions1786548000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE user_status AS ENUM ('ACTIVE', 'INACTIVE')
`);
await queryRunner.query(`
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username varchar(80) NOT NULL,
email varchar(320),
password_hash text NOT NULL,
first_name varchar(120) NOT NULL,
last_name varchar(120) NOT NULL,
status user_status NOT NULL DEFAULT 'ACTIVE',
must_change_password boolean NOT NULL DEFAULT true,
failed_login_attempts integer NOT NULL DEFAULT 0,
locked_until timestamptz,
last_login_at timestamptz,
password_changed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid,
updated_by uuid,
CONSTRAINT chk_users_failed_login_attempts
CHECK (failed_login_attempts >= 0),
CONSTRAINT fk_users_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_users_updated_by
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(
'CREATE UNIQUE INDEX uq_users_username_ci ON users (LOWER(username))',
);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_users_email_ci
ON users (LOWER(email))
WHERE email IS NOT NULL
`);
await queryRunner.query('CREATE INDEX idx_users_status ON users (status)');
await queryRunner.query(`
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL,
name varchar(120) NOT NULL,
description text NOT NULL,
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(
'CREATE UNIQUE INDEX uq_roles_code ON roles (code)',
);
await queryRunner.query(`
CREATE TABLE permissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(120) NOT NULL,
description text NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(
'CREATE UNIQUE INDEX uq_permissions_code ON permissions (code)',
);
await queryRunner.query(`
CREATE TABLE user_roles (
user_id uuid NOT NULL,
role_id uuid NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by uuid,
CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_roles_assigned_by
FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(
'CREATE INDEX idx_user_roles_user_id ON user_roles (user_id)',
);
await queryRunner.query(
'CREATE INDEX idx_user_roles_role_id ON user_roles (role_id)',
);
await queryRunner.query(`
CREATE TABLE role_permissions (
role_id uuid NOT NULL,
permission_id uuid NOT NULL,
CONSTRAINT pk_role_permissions PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX idx_role_permissions_permission_id
ON role_permissions (permission_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE role_permissions');
await queryRunner.query('DROP TABLE user_roles');
await queryRunner.query('DROP TABLE permissions');
await queryRunner.query('DROP TABLE roles');
await queryRunner.query('DROP TABLE users');
await queryRunner.query('DROP TYPE user_status');
}
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseAAuthSessions1786548001000 implements MigrationInterface {
name = 'PhaseAAuthSessions1786548001000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE auth_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
refresh_token_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at timestamptz NOT NULL,
last_used_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at timestamptz,
replaced_by_session_id uuid,
ip inet,
user_agent text,
device_label text,
CONSTRAINT fk_auth_sessions_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_auth_sessions_replaced_by
FOREIGN KEY (replaced_by_session_id)
REFERENCES auth_sessions(id) ON DELETE SET NULL,
CONSTRAINT chk_auth_sessions_expiration
CHECK (expires_at > created_at)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_auth_sessions_refresh_token_hash
ON auth_sessions (refresh_token_hash)
`);
await queryRunner.query(`
CREATE INDEX idx_auth_sessions_user_id ON auth_sessions (user_id)
`);
await queryRunner.query(`
CREATE INDEX idx_auth_sessions_expires_at ON auth_sessions (expires_at)
`);
await queryRunner.query(`
CREATE INDEX idx_auth_sessions_revoked_at ON auth_sessions (revoked_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE auth_sessions');
}
}
@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseAAuditEvents1786548002000 implements MigrationInterface {
name = 'PhaseAAuditEvents1786548002000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE audit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
actor_user_id uuid,
actor_username varchar(80),
action varchar(100) NOT NULL,
entity_type varchar(100),
entity_id varchar(255),
request_id varchar(128),
source varchar(32) NOT NULL,
ip inet,
user_agent text,
before_data jsonb,
after_data jsonb,
metadata jsonb,
CONSTRAINT fk_audit_events_actor_user
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_audit_events_json_objects
CHECK (
(before_data IS NULL OR jsonb_typeof(before_data) = 'object') AND
(after_data IS NULL OR jsonb_typeof(after_data) = 'object') AND
(metadata IS NULL OR jsonb_typeof(metadata) = 'object')
)
)
`);
await queryRunner.query(`
CREATE INDEX idx_audit_events_occurred_at
ON audit_events (occurred_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_audit_events_actor_user_id
ON audit_events (actor_user_id)
`);
await queryRunner.query(`
CREATE INDEX idx_audit_events_action ON audit_events (action)
`);
await queryRunner.query(`
CREATE INDEX idx_audit_events_entity
ON audit_events (entity_type, entity_id)
`);
await queryRunner.query(`
CREATE INDEX idx_audit_events_request_id
ON audit_events (request_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE audit_events');
}
}
@@ -0,0 +1,137 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCodes = [
'dashboard.read',
'users.read',
'users.create',
'users.update',
'users.change_status',
'users.assign_roles',
'roles.read',
'roles.manage',
'audit.read',
] as const;
const roleCodes = [
'admin',
'director',
'supervisor',
'inspector',
'auditor',
] as const;
const rolePermissionValues = `
('admin', 'dashboard.read'),
('admin', 'users.read'),
('admin', 'users.create'),
('admin', 'users.update'),
('admin', 'users.change_status'),
('admin', 'users.assign_roles'),
('admin', 'roles.read'),
('admin', 'roles.manage'),
('admin', 'audit.read'),
('director', 'dashboard.read'),
('director', 'users.read'),
('director', 'roles.read'),
('director', 'audit.read'),
('supervisor', 'dashboard.read'),
('supervisor', 'users.read'),
('supervisor', 'roles.read'),
('inspector', 'dashboard.read'),
('inspector', 'roles.read'),
('auditor', 'dashboard.read'),
('auditor', 'roles.read'),
('auditor', 'audit.read')
`;
export class PhaseASeedRolesPermissions1786548003000
implements MigrationInterface
{
name = 'PhaseASeedRolesPermissions1786548003000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('dashboard.read', 'Ver el panel general'),
('users.read', 'Listar y consultar usuarios'),
('users.create', 'Crear usuarios'),
('users.update', 'Editar usuarios'),
('users.change_status', 'Activar o desactivar usuarios'),
('users.assign_roles', 'Asignar roles a usuarios'),
('roles.read', 'Listar y consultar roles y permisos'),
('roles.manage', 'Administrar roles y su matriz de permisos'),
('audit.read', 'Consultar eventos de auditoría')
ON CONFLICT (code) DO UPDATE
SET description = EXCLUDED.description
`);
await queryRunner.query(`
INSERT INTO roles (code, name, description, is_system)
VALUES
('admin', 'Administrador', 'Administración total del sistema', true),
('director', 'Director', 'Consulta directiva y auditoría', true),
('supervisor', 'Supervisor', 'Supervisión operativa', true),
('inspector', 'Inspector', 'Operación de inspecciones', true),
('auditor', 'Auditor', 'Consulta de información y auditoría', true)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
description = EXCLUDED.description,
is_system = true,
updated_at = CURRENT_TIMESTAMP
`);
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
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 roles role
WHERE role.code = ANY($1::varchar[])
AND role.is_system = true
AND NOT EXISTS (
SELECT 1 FROM user_roles user_role WHERE user_role.role_id = role.id
)
`,
[roleCodes],
);
await queryRunner.query(
`
DELETE FROM permissions permission
WHERE permission.code = ANY($1::varchar[])
AND NOT EXISTS (
SELECT 1
FROM role_permissions role_permission
WHERE role_permission.permission_id = permission.id
)
`,
[permissionCodes],
);
}
}
@@ -0,0 +1,86 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const phaseATables = [
'users',
'roles',
'permissions',
'user_roles',
'role_permissions',
'auth_sessions',
'audit_events',
] as const;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
async function databaseContext(queryRunner: QueryRunner): Promise<{
appRole: string;
ownerRole: string;
databaseName: string;
}> {
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 [context] = (await queryRunner.query(`
SELECT current_user AS "ownerRole", current_database() AS "databaseName"
`)) as Array<{ ownerRole: string; databaseName: string }>;
if (!context) throw new Error('Could not resolve database migration context');
return { appRole, ...context };
}
export class PhaseARuntimeGrants1786548004000 implements MigrationInterface {
name = 'PhaseARuntimeGrants1786548004000';
public async up(queryRunner: QueryRunner): Promise<void> {
const { appRole, ownerRole, databaseName } = await databaseContext(queryRunner);
const applicationRole = quoteIdentifier(appRole);
const migrationOwner = quoteIdentifier(ownerRole);
const database = quoteIdentifier(databaseName);
const tables = phaseATables.map(quoteIdentifier).join(', ');
await queryRunner.query(`GRANT CONNECT ON DATABASE ${database} TO ${applicationRole}`);
await queryRunner.query(`GRANT USAGE ON SCHEMA public TO ${applicationRole}`);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables} TO ${applicationRole}
`);
await queryRunner.query(`
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public
TO ${applicationRole}
`);
await queryRunner.query(`
ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${applicationRole}
`);
await queryRunner.query(`
ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const { appRole, ownerRole } = await databaseContext(queryRunner);
const applicationRole = quoteIdentifier(appRole);
const migrationOwner = quoteIdentifier(ownerRole);
const tables = phaseATables.map(quoteIdentifier).join(', ');
await queryRunner.query(`
ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM ${applicationRole}
`);
await queryRunner.query(`
ALTER DEFAULT PRIVILEGES FOR ROLE ${migrationOwner} IN SCHEMA public
REVOKE USAGE, SELECT, UPDATE ON SEQUENCES FROM ${applicationRole}
`);
await queryRunner.query(`
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables} FROM ${applicationRole}
`);
}
}
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseA2SessionSecurityIndexes1786548005000
implements MigrationInterface
{
name = 'PhaseA2SessionSecurityIndexes1786548005000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX idx_users_locked_until ON users (locked_until)
`);
await queryRunner.query(`
CREATE INDEX idx_auth_sessions_replaced_by_session_id
ON auth_sessions (replaced_by_session_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX idx_auth_sessions_replaced_by_session_id',
);
await queryRunner.query('DROP INDEX idx_users_locked_until');
}
}
@@ -0,0 +1,340 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'asset_types.read',
'asset_types.manage',
'assets.read',
'assets.create',
'assets.update',
'assets.change_status',
] as const;
const rolePermissionValues = `
('admin', 'asset_types.read'),
('admin', 'asset_types.manage'),
('admin', 'assets.read'),
('admin', 'assets.create'),
('admin', 'assets.update'),
('admin', 'assets.change_status'),
('director', 'asset_types.read'),
('director', 'assets.read'),
('director', 'assets.change_status'),
('supervisor', 'asset_types.read'),
('supervisor', 'assets.read'),
('supervisor', 'assets.create'),
('supervisor', 'assets.update'),
('supervisor', 'assets.change_status'),
('inspector', 'asset_types.read'),
('inspector', 'assets.read'),
('inspector', 'assets.create'),
('inspector', 'assets.update'),
('auditor', 'asset_types.read'),
('auditor', 'assets.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseB1AssetMaster1786636800000 implements MigrationInterface {
name = 'PhaseB1AssetMaster1786636800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE asset_information_status AS ENUM (
'DRAFT', 'PENDING_SURVEY', 'SURVEYED', 'VALIDATED',
'OBSERVED', 'OUTDATED', 'INACTIVE'
)
`);
await queryRunner.query(`
CREATE TYPE asset_attribute_data_type AS ENUM (
'TEXT', 'NUMBER', 'BOOLEAN', 'DATE', 'DATETIME', 'SELECT'
)
`);
await queryRunner.query(`
CREATE TABLE asset_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
description text NOT NULL DEFAULT '',
can_be_root boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid,
updated_by uuid,
CONSTRAINT fk_asset_types_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_types_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(
'CREATE UNIQUE INDEX uq_asset_types_code_ci ON asset_types (LOWER(code))',
);
await queryRunner.query(
'CREATE INDEX idx_asset_types_is_active ON asset_types (is_active)',
);
await queryRunner.query(`
CREATE TABLE asset_type_parent_rules (
child_type_id uuid NOT NULL,
parent_type_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_asset_type_parent_rules
PRIMARY KEY (child_type_id, parent_type_id),
CONSTRAINT chk_asset_type_parent_rules_not_self
CHECK (child_type_id <> parent_type_id),
CONSTRAINT fk_asset_type_parent_rules_child FOREIGN KEY (child_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_asset_type_parent_rules_parent FOREIGN KEY (parent_type_id)
REFERENCES asset_types(id) ON DELETE RESTRICT
)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_type_parent_rules_parent
ON asset_type_parent_rules (parent_type_id)
`);
await queryRunner.query(`
CREATE TABLE asset_attribute_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_type_id uuid NOT NULL,
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
data_type asset_attribute_data_type NOT NULL,
is_required boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
unit varchar(40),
options jsonb,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_asset_attribute_sort_order CHECK (sort_order >= 0),
CONSTRAINT chk_asset_attribute_select_options CHECK (
(data_type = 'SELECT' AND options IS NOT NULL AND jsonb_typeof(options) = 'array')
OR (data_type <> 'SELECT' AND options IS NULL)
),
CONSTRAINT fk_asset_attribute_definitions_type FOREIGN KEY (asset_type_id)
REFERENCES asset_types(id) ON DELETE RESTRICT
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_asset_attribute_definitions_code_ci
ON asset_attribute_definitions (asset_type_id, LOWER(code))
`);
await queryRunner.query(`
CREATE INDEX idx_asset_attribute_definitions_type
ON asset_attribute_definitions (asset_type_id, sort_order)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_attribute_definitions_active
ON asset_attribute_definitions (is_active)
`);
await queryRunner.query(`
CREATE TABLE assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_type_id uuid NOT NULL,
parent_id uuid,
code varchar(120) NOT NULL,
name varchar(200) NOT NULL,
description text,
information_status asset_information_status NOT NULL DEFAULT 'DRAFT',
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid,
updated_by uuid,
CONSTRAINT chk_assets_not_self_parent CHECK (id <> parent_id),
CONSTRAINT fk_assets_type FOREIGN KEY (asset_type_id)
REFERENCES asset_types(id) ON DELETE RESTRICT,
CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_assets_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_assets_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(
'CREATE UNIQUE INDEX uq_assets_code_ci ON assets (LOWER(code))',
);
await queryRunner.query('CREATE INDEX idx_assets_type_id ON assets (asset_type_id)');
await queryRunner.query('CREATE INDEX idx_assets_parent_id ON assets (parent_id)');
await queryRunner.query(
'CREATE INDEX idx_assets_information_status ON assets (information_status)',
);
await queryRunner.query(`
CREATE FUNCTION enforce_asset_hierarchy_rules()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.parent_id IS NULL THEN
IF NOT EXISTS (
SELECT 1 FROM asset_types
WHERE id = NEW.asset_type_id AND can_be_root = true
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'asset type does not allow root assets';
END IF;
ELSE
IF NEW.parent_id = NEW.id THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'asset cannot be its own parent';
END IF;
IF NOT EXISTS (
SELECT 1
FROM assets parent
INNER JOIN asset_type_parent_rules rule
ON rule.parent_type_id = parent.asset_type_id
AND rule.child_type_id = NEW.asset_type_id
WHERE parent.id = NEW.parent_id
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'asset parent type is not allowed';
END IF;
IF EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = NEW.parent_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors current ON parent.id = current.parent_id
)
SELECT 1 FROM ancestors WHERE id = NEW.id
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'asset hierarchy cycle detected';
END IF;
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_hierarchy_rules
BEFORE INSERT OR UPDATE OF asset_type_id, parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_asset_hierarchy_rules()
`);
await queryRunner.query(`
CREATE TABLE asset_attribute_values (
asset_id uuid NOT NULL,
definition_id uuid NOT NULL,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by uuid,
CONSTRAINT pk_asset_attribute_values PRIMARY KEY (asset_id, definition_id),
CONSTRAINT fk_asset_attribute_values_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_asset_attribute_values_definition FOREIGN KEY (definition_id)
REFERENCES asset_attribute_definitions(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_attribute_values_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_attribute_values_definition
ON asset_attribute_values (definition_id)
`);
await queryRunner.query(`
CREATE FUNCTION enforce_asset_attribute_type_match()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF (
SELECT asset.asset_type_id IS DISTINCT FROM definition.asset_type_id
FROM assets asset, asset_attribute_definitions definition
WHERE asset.id = NEW.asset_id AND definition.id = NEW.definition_id
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'attribute definition does not belong to asset type';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_asset_attribute_type_match
BEFORE INSERT OR UPDATE OF asset_id, definition_id ON asset_attribute_values
FOR EACH ROW EXECUTE FUNCTION enforce_asset_attribute_type_match()
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('asset_types.read', 'Consultar tipos y atributos del Maestro de Activos'),
('asset_types.manage', 'Administrar tipos, jerarquías permitidas y atributos'),
('assets.read', 'Listar y consultar activos'),
('assets.create', 'Crear activos'),
('assets.update', 'Editar activos y sus atributos'),
('assets.change_status', 'Cambiar el estado de información 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, DELETE ON TABLE
asset_types,
asset_type_parent_rules,
asset_attribute_definitions,
assets,
asset_attribute_values
TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 TRIGGER trg_asset_attribute_type_match ON asset_attribute_values');
await queryRunner.query('DROP FUNCTION enforce_asset_attribute_type_match');
await queryRunner.query('DROP TABLE asset_attribute_values');
await queryRunner.query('DROP TRIGGER trg_assets_hierarchy_rules ON assets');
await queryRunner.query('DROP FUNCTION enforce_asset_hierarchy_rules');
await queryRunner.query('DROP TABLE assets');
await queryRunner.query('DROP TABLE asset_attribute_definitions');
await queryRunner.query('DROP TABLE asset_type_parent_rules');
await queryRunner.query('DROP TABLE asset_types');
await queryRunner.query('DROP TYPE asset_attribute_data_type');
await queryRunner.query('DROP TYPE asset_information_status');
}
}
@@ -0,0 +1,114 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCode = 'assets.update_geometry';
const rolePermissionValues = `
('admin', 'assets.update_geometry'),
('supervisor', 'assets.update_geometry'),
('inspector', 'assets.update_geometry')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseB2AssetGeometries1786723200000
implements MigrationInterface
{
name = 'PhaseB2AssetGeometries1786723200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE asset_geometry_source AS ENUM (
'WEB', 'ANDROID', 'IMPORT', 'SURVEY'
)
`);
await queryRunner.query(`
CREATE TABLE asset_geometries (
asset_id uuid PRIMARY KEY,
geometry geometry(Geometry, 4326) NOT NULL,
geometry_type varchar(20) NOT NULL,
source asset_geometry_source NOT NULL DEFAULT 'WEB',
accuracy_m numeric(12, 3),
captured_at timestamptz,
device_label varchar(255),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by uuid,
CONSTRAINT chk_asset_geometries_type
CHECK (geometry_type IN ('POINT', 'LINESTRING', 'POLYGON')),
CONSTRAINT chk_asset_geometries_type_matches
CHECK (geometry_type = UPPER(GeometryType(geometry))),
CONSTRAINT chk_asset_geometries_valid
CHECK (ST_IsValid(geometry)),
CONSTRAINT chk_asset_geometries_accuracy
CHECK (accuracy_m IS NULL OR accuracy_m >= 0),
CONSTRAINT fk_asset_geometries_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_asset_geometries_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_geometries_geometry
ON asset_geometries USING GIST (geometry)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_geometries_type
ON asset_geometries (geometry_type)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_geometries_updated_at
ON asset_geometries (updated_at DESC)
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('assets.update_geometry', 'Crear, actualizar o quitar la ubicación geográfica 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');
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE asset_geometries
TO ${quoteIdentifier(appRole)}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 = $1
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)`,
[permissionCode],
);
await queryRunner.query('DROP TABLE asset_geometries');
await queryRunner.query('DROP TYPE asset_geometry_source');
}
}
@@ -0,0 +1,203 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCode = 'assets.read_history';
const rolePermissionValues = `
('admin', 'assets.read_history'),
('director', 'assets.read_history'),
('supervisor', 'assets.read_history'),
('inspector', 'assets.read_history'),
('auditor', 'assets.read_history')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseB3AssetVersions1786723201000 implements MigrationInterface {
name = 'PhaseB3AssetVersions1786723201000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN current_version integer NOT NULL DEFAULT 0,
ADD CONSTRAINT chk_assets_current_version CHECK (current_version >= 0)
`);
await queryRunner.query(`
CREATE TABLE asset_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
version_number integer NOT NULL,
change_type varchar(32) NOT NULL,
changed_fields text[] NOT NULL DEFAULT '{}',
snapshot jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
actor_user_id uuid,
actor_username varchar(80),
source varchar(32) NOT NULL,
request_id varchar(128),
CONSTRAINT uq_asset_versions_asset_number
UNIQUE (asset_id, version_number),
CONSTRAINT chk_asset_versions_number CHECK (version_number >= 1),
CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'STATUS_CHANGED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED'
)),
CONSTRAINT chk_asset_versions_source CHECK (source IN (
'WEB', 'ANDROID', 'SYSTEM', 'IMPORT'
)),
CONSTRAINT chk_asset_versions_snapshot_object
CHECK (jsonb_typeof(snapshot) = 'object'),
CONSTRAINT fk_asset_versions_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_asset_number
ON asset_versions (asset_id, version_number DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_occurred_at
ON asset_versions (occurred_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_change_type
ON asset_versions (change_type)
`);
await queryRunner.query(`
CREATE INDEX idx_asset_versions_snapshot_gin
ON asset_versions USING GIN (snapshot jsonb_path_ops)
`);
await queryRunner.query('UPDATE assets SET current_version = 1');
await queryRunner.query(`
INSERT INTO asset_versions (
asset_id, version_number, change_type, changed_fields, snapshot,
occurred_at, actor_user_id, actor_username, source
)
SELECT
asset.id,
1,
'BASELINE',
ARRAY['baseline']::text[],
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'description', asset.description,
'type', JSONB_BUILD_OBJECT(
'id', asset_type.id,
'code', asset_type.code,
'name', asset_type.name
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'informationStatus', asset.information_status,
'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order,
'value', value.value
) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value
ON value.definition_id = definition.id
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
'geometryType', geometry.geometry_type,
'source', geometry.source,
'accuracyM', geometry.accuracy_m::double precision,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,
'createdAt', geometry.created_at,
'updatedAt', geometry.updated_at,
'updatedBy', geometry.updated_by
) END,
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', 1
),
asset.updated_at,
asset.updated_by,
actor.username,
'SYSTEM'
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
LEFT JOIN users actor ON actor.id = asset.updated_by
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('assets.read_history', 'Consultar versiones históricas completas 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');
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE asset_versions
TO ${quoteIdentifier(appRole)}
`);
await queryRunner.query(`
REVOKE UPDATE, DELETE ON TABLE asset_versions
FROM ${quoteIdentifier(appRole)}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 = $1
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)`,
[permissionCode],
);
await queryRunner.query('DROP TABLE asset_versions');
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_current_version');
await queryRunner.query('ALTER TABLE assets DROP COLUMN current_version');
}
}
@@ -0,0 +1,171 @@
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<void> {
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<void> {
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');
}
}
@@ -0,0 +1,191 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'assets.read_provenance',
'assets.manage_provenance',
'assets.verify_provenance',
'assets.read_temporal',
] as const;
const rolePermissionValues = `
('admin', 'assets.read_provenance'),
('admin', 'assets.manage_provenance'),
('admin', 'assets.verify_provenance'),
('admin', 'assets.read_temporal'),
('director', 'assets.read_provenance'),
('director', 'assets.verify_provenance'),
('director', 'assets.read_temporal'),
('supervisor', 'assets.read_provenance'),
('supervisor', 'assets.manage_provenance'),
('supervisor', 'assets.verify_provenance'),
('supervisor', 'assets.read_temporal'),
('inspector', 'assets.read_provenance'),
('inspector', 'assets.manage_provenance'),
('inspector', 'assets.read_temporal'),
('auditor', 'assets.read_provenance'),
('auditor', 'assets.read_temporal')
`;
export class PhaseB5ProvenanceTemporal1786723203000 implements MigrationInterface {
name = 'PhaseB5ProvenanceTemporal1786723203000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN data_origin varchar(32) NOT NULL DEFAULT 'MANUAL',
ADD COLUMN source_name varchar(160),
ADD COLUMN source_reference varchar(255),
ADD COLUMN source_observed_at timestamptz,
ADD COLUMN source_notes text,
ADD COLUMN provenance_verified_at timestamptz,
ADD COLUMN provenance_verified_by uuid,
ADD COLUMN provenance_updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN provenance_updated_by uuid,
ADD CONSTRAINT chk_assets_data_origin CHECK (data_origin IN (
'MANUAL', 'FIELD_SURVEY', 'PROVIDED_DOCUMENT', 'IMPORT', 'SYSTEM'
)),
ADD CONSTRAINT fk_assets_provenance_verified_by FOREIGN KEY (provenance_verified_by)
REFERENCES users(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_assets_provenance_updated_by FOREIGN KEY (provenance_updated_by)
REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
UPDATE assets
SET provenance_updated_by = updated_by
`);
await queryRunner.query(`CREATE INDEX idx_assets_data_origin ON assets (data_origin)`);
await queryRunner.query(`
CREATE INDEX idx_assets_provenance_verified_at
ON assets (provenance_verified_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',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
await queryRunner.query(`
WITH bumped AS (
UPDATE assets
SET current_version = current_version + 1
RETURNING *
)
INSERT INTO asset_versions (
asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id
)
SELECT
asset.id,
asset.current_version,
'PROVENANCE_BASELINE',
ARRAY['provenance']::text[],
COALESCE(previous.snapshot, '{}'::jsonb) || JSONB_BUILD_OBJECT(
'provenance', JSONB_BUILD_OBJECT(
'origin', asset.data_origin,
'sourceName', asset.source_name,
'sourceReference', asset.source_reference,
'observedAt', asset.source_observed_at,
'notes', asset.source_notes,
'verifiedAt', asset.provenance_verified_at,
'verifiedBy', asset.provenance_verified_by,
'updatedAt', asset.provenance_updated_at,
'updatedBy', asset.provenance_updated_by
),
'currentVersion', asset.current_version
),
NULL,
NULL,
'SYSTEM',
'migration:phase-b5-provenance-baseline'
FROM bumped asset
LEFT JOIN LATERAL (
SELECT version.snapshot
FROM asset_versions version
WHERE version.asset_id = asset.id
ORDER BY version.version_number DESC
LIMIT 1
) previous ON true
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('assets.read_provenance', 'Consultar procedencia de datos de activos'),
('assets.manage_provenance', 'Actualizar procedencia de datos de activos'),
('assets.verify_provenance', 'Verificar procedencia de datos de activos'),
('assets.read_temporal', 'Reconstruir el maestro de activos a una fecha')
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
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 (
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
)
`);
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(`
ALTER TABLE assets
DROP CONSTRAINT fk_assets_provenance_verified_by,
DROP CONSTRAINT fk_assets_provenance_updated_by,
DROP CONSTRAINT chk_assets_data_origin,
DROP COLUMN data_origin,
DROP COLUMN source_name,
DROP COLUMN source_reference,
DROP COLUMN source_observed_at,
DROP COLUMN source_notes,
DROP COLUMN provenance_verified_at,
DROP COLUMN provenance_verified_by,
DROP COLUMN provenance_updated_at,
DROP COLUMN provenance_updated_by
`);
}
}
@@ -0,0 +1,155 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'surveys.read',
'surveys.manage',
'surveys.assign',
'surveys.execute',
] as const;
const rolePermissionValues = `
('admin', 'surveys.read'),
('admin', 'surveys.manage'),
('admin', 'surveys.assign'),
('admin', 'surveys.execute'),
('director', 'surveys.read'),
('supervisor', 'surveys.read'),
('supervisor', 'surveys.manage'),
('supervisor', 'surveys.assign'),
('supervisor', 'surveys.execute'),
('inspector', 'surveys.read'),
('inspector', 'surveys.execute'),
('auditor', 'surveys.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseC1SurveyPlanning1786809600000 implements MigrationInterface {
name = 'PhaseC1SurveyPlanning1786809600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE survey_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL,
name varchar(200) NOT NULL,
description text,
status varchar(24) NOT NULL DEFAULT 'DRAFT',
planned_start_at timestamptz,
planned_end_at timestamptz,
scope_asset_id uuid,
coordinator_user_id uuid,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_campaigns_code UNIQUE (code),
CONSTRAINT chk_survey_campaigns_status CHECK (status IN (
'DRAFT', 'PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'
)),
CONSTRAINT chk_survey_campaigns_dates CHECK (
planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at
),
CONSTRAINT fk_survey_campaigns_scope_asset FOREIGN KEY (scope_asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_campaigns_coordinator FOREIGN KEY (coordinator_user_id)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_campaigns_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_campaigns_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_survey_campaigns_status ON survey_campaigns (status)`);
await queryRunner.query(`CREATE INDEX idx_survey_campaigns_scope_asset_id ON survey_campaigns (scope_asset_id)`);
await queryRunner.query(`CREATE INDEX idx_survey_campaigns_coordinator_user_id ON survey_campaigns (coordinator_user_id)`);
await queryRunner.query(`CREATE INDEX idx_survey_campaigns_planned_dates ON survey_campaigns (planned_start_at, planned_end_at)`);
await queryRunner.query(`
CREATE TABLE survey_campaign_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL,
asset_id uuid NOT NULL,
assigned_user_id uuid,
status varchar(24) NOT NULL DEFAULT 'PENDING',
due_at timestamptz,
instructions text,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE (campaign_id, asset_id),
CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN (
'PENDING', 'IN_PROGRESS', 'COMPLETED', 'SKIPPED'
)),
CONSTRAINT fk_survey_campaign_targets_campaign FOREIGN KEY (campaign_id)
REFERENCES survey_campaigns(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_campaign_targets_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_campaign_targets_assignee FOREIGN KEY (assigned_user_id)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_campaign_targets_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_campaign_targets_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_campaign_status ON survey_campaign_targets (campaign_id, status)`);
await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_asset_id ON survey_campaign_targets (asset_id)`);
await queryRunner.query(`CREATE INDEX idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets (assigned_user_id)`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('surveys.read', 'Consultar planificación de relevamientos'),
('surveys.manage', 'Crear y actualizar campañas de relevamiento'),
('surveys.assign', 'Asignar objetivos de relevamiento'),
('surveys.execute', 'Actualizar el avance de objetivos asignados')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE survey_campaigns, survey_campaign_targets
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE survey_campaigns, survey_campaign_targets
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 survey_campaign_targets`);
await queryRunner.query(`DROP TABLE survey_campaigns`);
}
}
@@ -0,0 +1,216 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'surveys.read_reports',
'surveys.capture',
'surveys.review',
] as const;
const rolePermissionValues = `
('admin', 'surveys.read_reports'),
('admin', 'surveys.capture'),
('admin', 'surveys.review'),
('director', 'surveys.read_reports'),
('director', 'surveys.review'),
('supervisor', 'surveys.read_reports'),
('supervisor', 'surveys.capture'),
('supervisor', 'surveys.review'),
('inspector', 'surveys.read_reports'),
('inspector', 'surveys.capture'),
('auditor', 'surveys.read_reports')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseC2SurveyExecution1786896000000 implements MigrationInterface {
name = 'PhaseC2SurveyExecution1786896000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE survey_campaign_targets
DROP CONSTRAINT chk_survey_campaign_targets_status
`);
await queryRunner.query(`
ALTER TABLE survey_campaign_targets
ADD CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN (
'PENDING', 'IN_PROGRESS', 'SUBMITTED', 'COMPLETED', 'SKIPPED'
))
`);
await queryRunner.query(`
CREATE TABLE survey_target_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
target_id uuid NOT NULL,
outcome varchar(32),
status varchar(24) NOT NULL DEFAULT 'DRAFT',
observed_at timestamptz,
latitude numeric(9, 6),
longitude numeric(9, 6),
accuracy_m numeric(12, 3),
notes text,
asset_version_at_submission integer,
submitted_at timestamptz,
submitted_by uuid,
reviewed_at timestamptz,
reviewed_by uuid,
review_notes text,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_target_reports_target_id UNIQUE (target_id),
CONSTRAINT chk_survey_target_reports_outcome CHECK (
outcome IS NULL OR outcome IN ('CONFIRMED', 'CHANGES_RECORDED', 'NOT_LOCATED')
),
CONSTRAINT chk_survey_target_reports_status CHECK (
status IN ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED')
),
CONSTRAINT chk_survey_target_reports_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_survey_target_reports_accuracy CHECK (
accuracy_m IS NULL OR (
accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL
)
),
CONSTRAINT fk_survey_target_reports_target FOREIGN KEY (target_id)
REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_target_reports_submitted_by FOREIGN KEY (submitted_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_target_reports_reviewed_by FOREIGN KEY (reviewed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_target_reports_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_survey_target_reports_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_survey_target_reports_status ON survey_target_reports (status)`);
await queryRunner.query(`CREATE INDEX idx_survey_target_reports_submitted_by ON survey_target_reports (submitted_by)`);
await queryRunner.query(`CREATE INDEX idx_survey_target_reports_reviewed_by ON survey_target_reports (reviewed_by)`);
await queryRunner.query(`
CREATE TABLE survey_target_report_media (
report_id uuid NOT NULL,
media_id uuid NOT NULL,
included boolean NOT NULL DEFAULT true,
added_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (report_id, media_id),
CONSTRAINT fk_survey_target_report_media_report FOREIGN KEY (report_id)
REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_target_report_media_media FOREIGN KEY (media_id)
REFERENCES asset_media(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_target_report_media_added_by FOREIGN KEY (added_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_survey_target_report_media_media_id ON survey_target_report_media (media_id)`);
await queryRunner.query(`CREATE INDEX idx_survey_target_report_media_included ON survey_target_report_media (report_id, included)`);
await queryRunner.query(`
CREATE TABLE survey_target_report_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
version_number integer NOT NULL,
event varchar(24) NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_target_report_versions_number UNIQUE (report_id, version_number),
CONSTRAINT chk_survey_target_report_versions_event CHECK (
event IN ('SUBMITTED', 'APPROVED', 'REJECTED')
),
CONSTRAINT fk_survey_target_report_versions_report FOREIGN KEY (report_id)
REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
CONSTRAINT fk_survey_target_report_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_survey_target_report_versions_created_at ON survey_target_report_versions (created_at)`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('surveys.read_reports', 'Consultar capturas y versiones de relevamientos'),
('surveys.capture', 'Capturar y enviar relevamientos de campo'),
('surveys.review', 'Aprobar o rechazar relevamientos enviados')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE
survey_target_reports, survey_target_report_media
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE survey_target_report_versions
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE
survey_target_reports, survey_target_report_media, survey_target_report_versions
FROM ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE ON TABLE survey_target_report_versions
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 survey_target_report_versions`);
await queryRunner.query(`DROP TABLE survey_target_report_media`);
await queryRunner.query(`DROP TABLE survey_target_reports`);
await queryRunner.query(`
UPDATE survey_campaign_targets SET status = 'IN_PROGRESS' WHERE status = 'SUBMITTED'
`);
await queryRunner.query(`
ALTER TABLE survey_campaign_targets
DROP CONSTRAINT chk_survey_campaign_targets_status
`);
await queryRunner.query(`
ALTER TABLE survey_campaign_targets
ADD CONSTRAINT chk_survey_campaign_targets_status CHECK (status IN (
'PENDING', 'IN_PROGRESS', 'COMPLETED', 'SKIPPED'
))
`);
}
}
@@ -0,0 +1,178 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'inspections.read',
'inspections.manage',
'inspections.assign',
'inspections.execute',
] as const;
const rolePermissionValues = `
('admin', 'inspections.read'),
('admin', 'inspections.manage'),
('admin', 'inspections.assign'),
('admin', 'inspections.execute'),
('director', 'inspections.read'),
('supervisor', 'inspections.read'),
('supervisor', 'inspections.manage'),
('supervisor', 'inspections.assign'),
('supervisor', 'inspections.execute'),
('inspector', 'inspections.read'),
('inspector', 'inspections.execute'),
('auditor', 'inspections.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD1InspectionVisits1786982400000 implements MigrationInterface {
name = 'PhaseD1InspectionVisits1786982400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_visits (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL,
title varchar(200) NOT NULL,
objective text,
status varchar(24) NOT NULL DEFAULT 'DRAFT',
scope_asset_id uuid,
lead_inspector_user_id uuid,
planned_start_at timestamptz,
planned_end_at timestamptz,
actual_started_at timestamptz,
actual_closed_at timestamptz,
instructions text,
cancellation_reason text,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_visits_code UNIQUE (code),
CONSTRAINT chk_inspection_visits_code CHECK (
code = UPPER(code) AND code ~ '^[A-Z0-9][A-Z0-9._/-]*$'
),
CONSTRAINT chk_inspection_visits_status CHECK (
status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS', 'CLOSED', 'CANCELLED')
),
CONSTRAINT chk_inspection_visits_planned_dates CHECK (
planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at
),
CONSTRAINT chk_inspection_visits_actual_dates CHECK (
actual_started_at IS NULL OR actual_closed_at IS NULL OR actual_closed_at >= actual_started_at
),
CONSTRAINT chk_inspection_visits_cancellation CHECK (
status <> 'CANCELLED' OR LENGTH(TRIM(COALESCE(cancellation_reason, ''))) >= 10
),
CONSTRAINT fk_inspection_visits_scope_asset FOREIGN KEY (scope_asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visits_lead_inspector FOREIGN KEY (lead_inspector_user_id)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_visits_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_visits_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visits_status ON inspection_visits (status)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visits_scope_asset_id ON inspection_visits (scope_asset_id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visits_lead_inspector_user_id ON inspection_visits (lead_inspector_user_id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visits_planned_dates ON inspection_visits (planned_start_at, planned_end_at)`);
await queryRunner.query(`
CREATE TABLE inspection_visit_assets (
visit_id uuid NOT NULL,
asset_id uuid NOT NULL,
included boolean NOT NULL DEFAULT true,
added_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (visit_id, asset_id),
CONSTRAINT fk_inspection_visit_assets_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_assets_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_assets_added_by FOREIGN KEY (added_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_assets_asset_id ON inspection_visit_assets (asset_id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_assets_included ON inspection_visit_assets (visit_id, included)`);
await queryRunner.query(`
CREATE TABLE inspection_visit_members (
visit_id uuid NOT NULL,
user_id uuid NOT NULL,
included boolean NOT NULL DEFAULT true,
assigned_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (visit_id, user_id),
CONSTRAINT fk_inspection_visit_members_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_members_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_members_assigned_by FOREIGN KEY (assigned_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_members_user_id ON inspection_visit_members (user_id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_members_included ON inspection_visit_members (visit_id, included)`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspections.read', 'Consultar visitas de inspección'),
('inspections.manage', 'Crear y planificar visitas de inspección'),
('inspections.assign', 'Asignar equipos de inspección'),
('inspections.execute', 'Iniciar y ejecutar visitas asignadas')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE
inspection_visits, inspection_visit_assets, inspection_visit_members
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE
inspection_visits, inspection_visit_assets, inspection_visit_members
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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_visit_members`);
await queryRunner.query(`DROP TABLE inspection_visit_assets`);
await queryRunner.query(`DROP TABLE inspection_visits`);
}
}
@@ -0,0 +1,205 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'inspection_acts.read',
'inspection_acts.create',
'inspection_acts.update',
'inspection_acts.cancel',
] as const;
const rolePermissionValues = `
('admin', 'inspection_acts.read'),
('admin', 'inspection_acts.create'),
('admin', 'inspection_acts.update'),
('admin', 'inspection_acts.cancel'),
('director', 'inspection_acts.read'),
('supervisor', 'inspection_acts.read'),
('supervisor', 'inspection_acts.create'),
('supervisor', 'inspection_acts.update'),
('supervisor', 'inspection_acts.cancel'),
('inspector', 'inspection_acts.read'),
('inspector', 'inspection_acts.create'),
('inspector', 'inspection_acts.update'),
('auditor', 'inspection_acts.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD2InspectionActs1787068800000 implements MigrationInterface {
name = 'PhaseD2InspectionActs1787068800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE document_annual_sequences (
document_type varchar(20) NOT NULL,
year integer NOT NULL,
last_number integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (document_type, year),
CONSTRAINT chk_document_annual_sequences_type CHECK (
document_type IN ('ACT', 'REPORT')
),
CONSTRAINT chk_document_annual_sequences_year CHECK (
year BETWEEN 2000 AND 9999
),
CONSTRAINT chk_document_annual_sequences_number CHECK (
last_number >= 0
)
)
`);
await queryRunner.query(`
CREATE TABLE inspection_acts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
visit_id uuid NOT NULL,
act_year integer NOT NULL,
act_number integer NOT NULL,
code varchar(24) NOT NULL,
status varchar(24) NOT NULL DEFAULT 'DRAFT',
occurred_at timestamptz NOT NULL,
title varchar(200) NOT NULL,
summary text NOT NULL,
observations text,
current_version integer NOT NULL DEFAULT 0,
cancellation_reason text,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_acts_year_number UNIQUE (act_year, act_number),
CONSTRAINT uq_inspection_acts_code UNIQUE (code),
CONSTRAINT chk_inspection_acts_year CHECK (act_year BETWEEN 2000 AND 9999),
CONSTRAINT chk_inspection_acts_number CHECK (act_number > 0),
CONSTRAINT chk_inspection_acts_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}$'
),
CONSTRAINT chk_inspection_acts_status CHECK (
status IN ('DRAFT', 'READY', 'CLOSED', 'CANCELLED', 'RECTIFIED')
),
CONSTRAINT chk_inspection_acts_current_version CHECK (current_version >= 0),
CONSTRAINT chk_inspection_acts_cancellation CHECK (
status <> 'CANCELLED' OR LENGTH(TRIM(COALESCE(cancellation_reason, ''))) >= 10
),
CONSTRAINT fk_inspection_acts_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_acts_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_acts_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_acts_visit_status ON inspection_acts (visit_id, status)`);
await queryRunner.query(`CREATE INDEX idx_inspection_acts_occurred_at ON inspection_acts (occurred_at)`);
await queryRunner.query(`CREATE INDEX idx_inspection_acts_created_by ON inspection_acts (created_by)`);
await queryRunner.query(`
CREATE TABLE inspection_act_assets (
act_id uuid NOT NULL,
asset_id uuid NOT NULL,
included boolean NOT NULL DEFAULT true,
added_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (act_id, asset_id),
CONSTRAINT fk_inspection_act_assets_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_assets_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_assets_added_by FOREIGN KEY (added_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_act_assets_asset_id ON inspection_act_assets (asset_id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_act_assets_included ON inspection_act_assets (act_id, included)`);
await queryRunner.query(`
CREATE TABLE inspection_act_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
version_number integer NOT NULL,
event varchar(24) NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_act_versions_number UNIQUE (act_id, version_number),
CONSTRAINT chk_inspection_act_versions_number CHECK (version_number > 0),
CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED', 'UPDATED', 'CANCELLED')
),
CONSTRAINT fk_inspection_act_versions_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_act_versions_created_at ON inspection_act_versions (created_at)`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_acts.read', 'Consultar actas y sus versiones'),
('inspection_acts.create', 'Crear actas dentro de visitas asignadas'),
('inspection_acts.update', 'Actualizar actas en borrador'),
('inspection_acts.cancel', 'Cancelar actas en borrador')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE
document_annual_sequences, inspection_acts, inspection_act_assets
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE inspection_act_versions
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE
document_annual_sequences, inspection_acts,
inspection_act_assets, inspection_act_versions
FROM ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE ON TABLE inspection_act_versions
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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_act_versions`);
await queryRunner.query(`DROP TABLE inspection_act_assets`);
await queryRunner.query(`DROP TABLE inspection_acts`);
await queryRunner.query(`DROP TABLE document_annual_sequences`);
}
}
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseD21SingleActPerVisit1787072400000 implements MigrationInterface {
name = 'PhaseD21SingleActPerVisit1787072400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT visit_id
FROM inspection_acts
GROUP BY visit_id
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION
'D2.1 no puede aplicar un acta por visita: existen visitas con actas duplicadas';
END IF;
END
$$
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT uq_inspection_acts_visit UNIQUE (visit_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT uq_inspection_acts_visit
`);
}
}
@@ -0,0 +1,303 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
PHASE_D3_FINDING_CATALOG_ITEMS,
PHASE_D3_FINDING_CATEGORIES,
} from '../seeds/phase-d3-finding-catalog';
const newPermissions = [
'finding_catalog.read',
'finding_catalog.manage',
'inspection_findings.read',
'inspection_findings.create',
'inspection_findings.update',
'inspection_findings.follow_up',
] as const;
const rolePermissionValues = `
('admin', 'finding_catalog.read'),
('admin', 'finding_catalog.manage'),
('admin', 'inspection_findings.read'),
('admin', 'inspection_findings.create'),
('admin', 'inspection_findings.update'),
('admin', 'inspection_findings.follow_up'),
('director', 'finding_catalog.read'),
('director', 'inspection_findings.read'),
('director', 'inspection_findings.follow_up'),
('supervisor', 'finding_catalog.read'),
('supervisor', 'finding_catalog.manage'),
('supervisor', 'inspection_findings.read'),
('supervisor', 'inspection_findings.create'),
('supervisor', 'inspection_findings.update'),
('supervisor', 'inspection_findings.follow_up'),
('inspector', 'finding_catalog.read'),
('inspector', 'inspection_findings.read'),
('inspector', 'inspection_findings.create'),
('inspector', 'inspection_findings.update'),
('inspector', 'inspection_findings.follow_up'),
('auditor', 'finding_catalog.read'),
('auditor', 'inspection_findings.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD3InspectionFindings1787155200000 implements MigrationInterface {
name = 'PhaseD3InspectionFindings1787155200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE finding_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL,
name varchar(200) NOT NULL,
sort_order integer NOT NULL DEFAULT 0,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_categories_code UNIQUE (code),
CONSTRAINT chk_finding_categories_sort_order CHECK (sort_order >= 0),
CONSTRAINT chk_finding_categories_name CHECK (LENGTH(TRIM(name)) > 0)
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_categories_active_order
ON finding_categories (is_active, sort_order)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
category_id uuid NOT NULL,
code varchar(120) NOT NULL,
source_number integer NOT NULL,
title varchar(500) NOT NULL,
legal_basis text,
glossary text,
import_note text,
revision integer NOT NULL DEFAULT 1,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_items_code UNIQUE (code),
CONSTRAINT uq_finding_catalog_items_source UNIQUE (category_id, source_number),
CONSTRAINT chk_finding_catalog_items_source CHECK (source_number > 0),
CONSTRAINT chk_finding_catalog_items_revision CHECK (revision > 0),
CONSTRAINT chk_finding_catalog_items_title CHECK (LENGTH(TRIM(title)) > 0),
CONSTRAINT fk_finding_catalog_items_category FOREIGN KEY (category_id)
REFERENCES finding_categories(id) ON DELETE RESTRICT
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_items_active
ON finding_catalog_items (category_id, is_active)
`);
await queryRunner.query(`
CREATE TABLE inspection_findings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
asset_id uuid NOT NULL,
catalog_item_id uuid,
finding_number integer NOT NULL,
code varchar(40) NOT NULL,
status varchar(20) NOT NULL DEFAULT 'OPEN',
title varchar(500) NOT NULL,
description text NOT NULL,
legal_basis text,
glossary text,
catalog_revision integer,
correction_due_on date,
company_response text,
company_response_received_on date,
company_committed_correction_on date,
next_control_on date,
current_version integer NOT NULL DEFAULT 0,
closed_at timestamptz,
closed_by uuid,
closure_notes text,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_findings_number UNIQUE (act_id, finding_number),
CONSTRAINT uq_inspection_findings_code UNIQUE (code),
CONSTRAINT chk_inspection_findings_number CHECK (
finding_number BETWEEN 1 AND 999
),
CONSTRAINT chk_inspection_findings_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$'
),
CONSTRAINT chk_inspection_findings_status CHECK (
status IN ('OPEN', 'CLOSED', 'VOIDED')
),
CONSTRAINT chk_inspection_findings_content CHECK (
LENGTH(TRIM(title)) > 0 AND LENGTH(TRIM(description)) > 0
),
CONSTRAINT chk_inspection_findings_version CHECK (current_version >= 0),
CONSTRAINT chk_inspection_findings_catalog_revision CHECK (
catalog_revision IS NULL OR catalog_revision > 0
),
CONSTRAINT chk_inspection_findings_response CHECK (
(company_response IS NULL AND company_response_received_on IS NULL)
OR (
LENGTH(TRIM(COALESCE(company_response, ''))) > 0
AND company_response_received_on IS NOT NULL
)
),
CONSTRAINT chk_inspection_findings_commitment CHECK (
company_committed_correction_on IS NULL
OR company_response_received_on IS NOT NULL
),
CONSTRAINT chk_inspection_findings_closure CHECK (
status <> 'CLOSED'
OR (closed_at IS NOT NULL AND closed_by IS NOT NULL
AND LENGTH(TRIM(COALESCE(closure_notes, ''))) >= 10)
),
CONSTRAINT fk_inspection_findings_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_findings_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_findings_catalog_item FOREIGN KEY (catalog_item_id)
REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_findings_closed_by FOREIGN KEY (closed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_findings_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inspection_findings_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_findings_status_control
ON inspection_findings (status, next_control_on)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_findings_asset_status
ON inspection_findings (asset_id, status)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_findings_catalog_item
ON inspection_findings (catalog_item_id)
`);
await queryRunner.query(`
CREATE TABLE inspection_finding_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL,
version_number integer NOT NULL,
event varchar(32) NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_finding_versions_number
UNIQUE (finding_id, version_number),
CONSTRAINT chk_inspection_finding_versions_number CHECK (version_number > 0),
CONSTRAINT chk_inspection_finding_versions_event CHECK (
event IN ('CREATED', 'UPDATED', 'FOLLOW_UP_UPDATED')
),
CONSTRAINT fk_inspection_finding_versions_finding FOREIGN KEY (finding_id)
REFERENCES inspection_findings(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_finding_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_finding_versions_created_at
ON inspection_finding_versions (created_at)
`);
for (const category of PHASE_D3_FINDING_CATEGORIES) {
await queryRunner.query(`
INSERT INTO finding_categories (code, name, sort_order)
VALUES ($1, $2, $3)
`, [category.code, category.name, category.sortOrder]);
}
for (const item of PHASE_D3_FINDING_CATALOG_ITEMS) {
await queryRunner.query(`
INSERT INTO finding_catalog_items (
category_id, code, source_number, title, legal_basis, glossary, import_note
)
SELECT id, $2, $3, $4, $5, $6, $7
FROM finding_categories
WHERE code = $1
`, [
item.categoryCode,
item.code,
item.sourceNumber,
item.title,
item.legalBasis,
item.glossary,
item.importNote,
]);
}
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('finding_catalog.read', 'Consultar el catálogo de hallazgos'),
('finding_catalog.manage', 'Administrar el catálogo de hallazgos'),
('inspection_findings.read', 'Consultar hallazgos y seguimiento'),
('inspection_findings.create', 'Crear hallazgos dentro de actas'),
('inspection_findings.update', 'Actualizar hallazgos en borrador'),
('inspection_findings.follow_up', 'Registrar respuestas y próximos controles')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE
finding_categories, finding_catalog_items, inspection_findings
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE inspection_finding_versions
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE
finding_categories, finding_catalog_items,
inspection_findings, inspection_finding_versions
FROM ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE ON TABLE inspection_finding_versions
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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_versions`);
await queryRunner.query(`DROP TABLE inspection_findings`);
await queryRunner.query(`DROP TABLE finding_catalog_items`);
await queryRunner.query(`DROP TABLE finding_categories`);
}
}
@@ -0,0 +1,234 @@
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<void> {
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<void> {
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');
}
}
@@ -0,0 +1,554 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'inspection_closure.read',
'inspection_closure.prepare',
'inspection_closure.sign',
'inspection_closure.close',
] as const;
const rolePermissionValues = `
('admin', 'inspection_closure.read'),
('admin', 'inspection_closure.prepare'),
('admin', 'inspection_closure.sign'),
('admin', 'inspection_closure.close'),
('director', 'inspection_closure.read'),
('director', 'inspection_closure.close'),
('supervisor', 'inspection_closure.read'),
('supervisor', 'inspection_closure.prepare'),
('supervisor', 'inspection_closure.sign'),
('supervisor', 'inspection_closure.close'),
('inspector', 'inspection_closure.read'),
('inspector', 'inspection_closure.prepare'),
('inspector', 'inspection_closure.sign'),
('inspector', 'inspection_closure.close'),
('auditor', 'inspection_closure.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5ActClosingSignatures1787328000000 implements MigrationInterface {
name = 'PhaseD5ActClosingSignatures1787328000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD COLUMN closed_at timestamptz,
ADD COLUMN closed_by uuid,
ADD COLUMN closure_sha256 char(64),
ADD CONSTRAINT fk_inspection_acts_closed_by FOREIGN KEY (closed_by)
REFERENCES users(id) ON DELETE SET NULL,
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
status <> 'CLOSED'
OR (
closed_at IS NOT NULL
AND closed_by IS NOT NULL
AND closure_sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_acts_closure_sha256
ON inspection_acts (closure_sha256)
WHERE closure_sha256 IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED', 'UPDATED', 'READY', 'REOPENED', 'CLOSED', 'CANCELLED')
)
`);
await queryRunner.query(`
CREATE TABLE inspection_act_responsibles (
act_id uuid PRIMARY KEY,
attendance_status varchar(20) NOT NULL,
full_name varchar(200),
document_type varchar(20),
document_number varchar(40),
position varchar(200),
email varchar(320),
phone varchar(50),
absence_reason text,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_act_responsibles_attendance CHECK (
attendance_status IN ('PRESENT', 'ABSENT')
),
CONSTRAINT chk_inspection_act_responsibles_document_type CHECK (
document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER')
),
CONSTRAINT chk_inspection_act_responsibles_details CHECK (
(
attendance_status = 'PRESENT'
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
AND document_type IS NOT NULL
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
AND absence_reason IS NULL
) OR (
attendance_status = 'ABSENT'
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
)
),
CONSTRAINT fk_inspection_act_responsibles_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_responsibles_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE TABLE inspection_act_closures (
act_id uuid PRIMARY KEY,
schema_version varchar(40) NOT NULL,
prepared_snapshot jsonb NOT NULL,
prepared_sha256 char(64) NOT NULL,
prepared_at timestamptz NOT NULL,
prepared_by uuid NOT NULL,
final_snapshot jsonb,
final_sha256 char(64),
device_closed_at timestamptz,
server_closed_at timestamptz,
upload_mode varchar(20),
closed_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_act_closures_schema CHECK (
LENGTH(TRIM(schema_version)) > 0
),
CONSTRAINT chk_inspection_act_closures_prepared_sha CHECK (
prepared_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT chk_inspection_act_closures_final_sha CHECK (
final_sha256 IS NULL OR final_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT chk_inspection_act_closures_upload_mode CHECK (
upload_mode IS NULL OR upload_mode IN ('IMMEDIATE', 'DEFERRED')
),
CONSTRAINT chk_inspection_act_closures_final CHECK (
(
final_snapshot IS NULL
AND final_sha256 IS NULL
AND device_closed_at IS NULL
AND server_closed_at IS NULL
AND upload_mode IS NULL
AND closed_by IS NULL
) OR (
final_snapshot IS NOT NULL
AND final_sha256 IS NOT NULL
AND device_closed_at IS NOT NULL
AND server_closed_at IS NOT NULL
AND upload_mode IS NOT NULL
AND closed_by IS NOT NULL
)
),
CONSTRAINT fk_inspection_act_closures_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_closures_prepared_by FOREIGN KEY (prepared_by)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_closures_closed_by FOREIGN KEY (closed_by)
REFERENCES users(id) ON DELETE RESTRICT
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_act_closures_prepared_sha
ON inspection_act_closures (prepared_sha256)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_act_closures_final_sha
ON inspection_act_closures (final_sha256)
WHERE final_sha256 IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_act_signatures (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
signer_type varchar(32) NOT NULL,
signer_user_id uuid,
signer_name varchar(200) NOT NULL,
document_type varchar(20),
document_number varchar(40),
position varchar(200),
status varchar(20) NOT NULL,
reason text,
original_name varchar(255),
stored_name varchar(80),
mime_type varchar(100),
size_bytes bigint,
image_sha256 char(64),
consent_text text,
consent_version varchar(20),
consent_accepted_at timestamptz,
client_signed_at timestamptz,
signed_at timestamptz,
latitude numeric(9, 6),
longitude numeric(9, 6),
accuracy_m numeric(12, 3),
device_label varchar(200),
source varchar(20) NOT NULL,
prepared_sha256 char(64) NOT NULL,
signature_payload_sha256 char(64) NOT NULL,
uploaded_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_act_signatures_stored_name UNIQUE (stored_name),
CONSTRAINT chk_inspection_act_signatures_signer_type CHECK (
signer_type IN ('INSPECTOR', 'COMPANY_RESPONSIBLE')
),
CONSTRAINT chk_inspection_act_signatures_status CHECK (
status IN ('SIGNED', 'REFUSED', 'ABSENT')
),
CONSTRAINT chk_inspection_act_signatures_document_type CHECK (
document_type IS NULL OR document_type IN ('DNI', 'CUIL', 'PASSPORT', 'OTHER')
),
CONSTRAINT chk_inspection_act_signatures_source CHECK (
source IN ('WEB', 'ANDROID')
),
CONSTRAINT chk_inspection_act_signatures_sha CHECK (
prepared_sha256 ~ '^[0-9a-f]{64}$'
AND signature_payload_sha256 ~ '^[0-9a-f]{64}$'
AND (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$')
),
CONSTRAINT chk_inspection_act_signatures_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_act_signatures_accuracy CHECK (
accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL)
),
CONSTRAINT chk_inspection_act_signatures_inspector CHECK (
signer_type <> 'INSPECTOR'
OR (signer_user_id IS NOT NULL AND status = 'SIGNED')
),
CONSTRAINT chk_inspection_act_signatures_company CHECK (
signer_type <> 'COMPANY_RESPONSIBLE' OR signer_user_id IS NULL
),
CONSTRAINT chk_inspection_act_signatures_signed CHECK (
(
status = 'SIGNED'
AND reason IS NULL
AND stored_name IS NOT NULL
AND mime_type = 'image/png'
AND size_bytes BETWEEN 1 AND 1048576
AND image_sha256 IS NOT NULL
AND LENGTH(TRIM(COALESCE(consent_text, ''))) > 0
AND consent_version IS NOT NULL
AND consent_accepted_at IS NOT NULL
AND signed_at IS NOT NULL
) OR (
status IN ('REFUSED', 'ABSENT')
AND signer_type = 'COMPANY_RESPONSIBLE'
AND LENGTH(TRIM(COALESCE(reason, ''))) >= 10
AND stored_name IS NULL
AND mime_type IS NULL
AND size_bytes IS NULL
AND image_sha256 IS NULL
AND consent_text IS NULL
AND consent_version IS NULL
AND consent_accepted_at IS NULL
AND signed_at IS NULL
)
),
CONSTRAINT fk_inspection_act_signatures_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_signatures_signer_user FOREIGN KEY (signer_user_id)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_act_signatures_uploaded_by FOREIGN KEY (uploaded_by)
REFERENCES users(id) ON DELETE RESTRICT
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_act_signatures_act_created
ON inspection_act_signatures (act_id, created_at)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_act_signatures_sha256
ON inspection_act_signatures (signature_payload_sha256)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_act_inspector_signature
ON inspection_act_signatures (act_id, signer_user_id)
WHERE signer_type = 'INSPECTOR'
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_act_company_outcome
ON inspection_act_signatures (act_id)
WHERE signer_type = 'COMPANY_RESPONSIBLE'
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_responsible_draft()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE act_status varchar(24);
BEGIN
SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id;
IF act_status <> 'DRAFT' THEN
RAISE EXCEPTION 'El responsable sólo puede modificarse con el acta en borrador';
END IF;
NEW.updated_at := CURRENT_TIMESTAMP;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_act_responsibles_draft
BEFORE INSERT OR UPDATE ON inspection_act_responsibles
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_responsible_draft()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_status varchar(24);
DECLARE current_sha char(64);
BEGIN
SELECT act.status, closure.prepared_sha256
INTO current_status, current_sha
FROM inspection_acts act
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
WHERE act.id = NEW.act_id;
IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_act_signatures_ready
BEFORE INSERT ON inspection_act_signatures
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_signature_ready()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status IN ('CLOSED', 'RECTIFIED') THEN
RAISE EXCEPTION 'El acta cerrada es inmutable';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_acts_closed_immutable
BEFORE UPDATE ON inspection_acts
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_act_immutable()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_closure_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.server_closed_at IS NOT NULL THEN
RAISE EXCEPTION 'El cierre sellado es inmutable';
END IF;
NEW.updated_at := CURRENT_TIMESTAMP;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_act_closures_immutable
BEFORE UPDATE ON inspection_act_closures
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_closed_closure_immutable()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_asset_draft()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE act_status varchar(24);
BEGIN
SELECT status INTO act_status FROM inspection_acts
WHERE id = COALESCE(NEW.act_id, OLD.act_id);
IF act_status <> 'DRAFT' THEN
RAISE EXCEPTION 'Los activos del acta están congelados';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_act_assets_draft
BEFORE INSERT OR UPDATE OR DELETE ON inspection_act_assets
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_act_asset_draft()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_finding_inspection_fields()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE act_status varchar(24);
BEGIN
IF TG_OP = 'INSERT' THEN
SELECT status INTO act_status FROM inspection_acts WHERE id = NEW.act_id;
IF act_status <> 'DRAFT' THEN
RAISE EXCEPTION 'No pueden agregarse hallazgos al acta congelada';
END IF;
RETURN NEW;
END IF;
SELECT status INTO act_status FROM inspection_acts WHERE id = OLD.act_id;
IF act_status <> 'DRAFT' AND (
NEW.act_id IS DISTINCT FROM OLD.act_id
OR NEW.asset_id IS DISTINCT FROM OLD.asset_id
OR NEW.catalog_item_id IS DISTINCT FROM OLD.catalog_item_id
OR NEW.finding_number IS DISTINCT FROM OLD.finding_number
OR NEW.code IS DISTINCT FROM OLD.code
OR NEW.title IS DISTINCT FROM OLD.title
OR NEW.description IS DISTINCT FROM OLD.description
OR NEW.legal_basis IS DISTINCT FROM OLD.legal_basis
OR NEW.glossary IS DISTINCT FROM OLD.glossary
OR NEW.catalog_revision IS DISTINCT FROM OLD.catalog_revision
) THEN
RAISE EXCEPTION 'El contenido constatado del hallazgo está congelado';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_findings_frozen_fields
BEFORE INSERT OR UPDATE ON inspection_findings
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_finding_inspection_fields()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_observation_evidence_draft()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE act_status varchar(24);
BEGIN
IF NEW.purpose = 'OBSERVATION' THEN
SELECT act.status INTO act_status
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
WHERE finding.id = NEW.finding_id;
IF act_status <> 'DRAFT' THEN
RAISE EXCEPTION 'La evidencia de constatación pertenece a un acta congelada';
END IF;
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_evidence_observation_draft
BEFORE INSERT ON inspection_finding_evidence
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_observation_evidence_draft()
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_closure.read', 'Consultar responsable, firmas y cierre del acta'),
('inspection_closure.prepare', 'Identificar responsable y preparar el acta para firmas'),
('inspection_closure.sign', 'Registrar firmas o resultado de recepción del acta'),
('inspection_closure.close', 'Cerrar y sellar el acta y la visita')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE
inspection_act_responsibles,
inspection_act_closures
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT ON TABLE inspection_act_signatures
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE
inspection_act_responsibles,
inspection_act_closures,
inspection_act_signatures
FROM ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE ON TABLE inspection_act_signatures
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER trg_inspection_evidence_observation_draft ON inspection_finding_evidence');
await queryRunner.query('DROP FUNCTION dhv2_guard_observation_evidence_draft()');
await queryRunner.query('DROP TRIGGER trg_inspection_findings_frozen_fields ON inspection_findings');
await queryRunner.query('DROP FUNCTION dhv2_guard_finding_inspection_fields()');
await queryRunner.query('DROP TRIGGER trg_inspection_act_assets_draft ON inspection_act_assets');
await queryRunner.query('DROP FUNCTION dhv2_guard_act_asset_draft()');
await queryRunner.query('DROP TRIGGER trg_inspection_act_closures_immutable ON inspection_act_closures');
await queryRunner.query('DROP FUNCTION dhv2_guard_closed_closure_immutable()');
await queryRunner.query('DROP TRIGGER trg_inspection_acts_closed_immutable ON inspection_acts');
await queryRunner.query('DROP FUNCTION dhv2_guard_closed_act_immutable()');
await queryRunner.query('DROP TRIGGER trg_inspection_act_signatures_ready ON inspection_act_signatures');
await queryRunner.query('DROP FUNCTION dhv2_guard_act_signature_ready()');
await queryRunner.query('DROP TRIGGER trg_inspection_act_responsibles_draft ON inspection_act_responsibles');
await queryRunner.query('DROP FUNCTION dhv2_guard_act_responsible_draft()');
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_act_signatures');
await queryRunner.query('DROP TABLE inspection_act_closures');
await queryRunner.query('DROP TABLE inspection_act_responsibles');
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED', 'UPDATED', 'CANCELLED')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT chk_inspection_acts_closure,
DROP CONSTRAINT fk_inspection_acts_closed_by,
DROP COLUMN closure_sha256,
DROP COLUMN closed_by,
DROP COLUMN closed_at
`);
}
}
@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const operationalPermissions = [
'inspections.execute',
'inspection_acts.create',
'inspection_acts.update',
'inspection_acts.cancel',
'inspection_findings.create',
'inspection_findings.update',
'inspection_closure.prepare',
'inspection_closure.sign',
'inspection_closure.close',
] as const;
const previousNonInspectorMappings = `
('admin', 'inspections.execute'),
('supervisor', 'inspections.execute'),
('admin', 'inspection_acts.create'),
('admin', 'inspection_acts.update'),
('admin', 'inspection_acts.cancel'),
('supervisor', 'inspection_acts.create'),
('supervisor', 'inspection_acts.update'),
('supervisor', 'inspection_acts.cancel'),
('admin', 'inspection_findings.create'),
('admin', 'inspection_findings.update'),
('supervisor', 'inspection_findings.create'),
('supervisor', 'inspection_findings.update'),
('admin', 'inspection_closure.prepare'),
('admin', 'inspection_closure.sign'),
('admin', 'inspection_closure.close'),
('director', 'inspection_closure.close'),
('supervisor', 'inspection_closure.prepare'),
('supervisor', 'inspection_closure.sign'),
('supervisor', 'inspection_closure.close')
`;
export class PhaseD51MobileInspectionPolicy1787331600000 implements MigrationInterface {
name = 'PhaseD51MobileInspectionPolicy1787331600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions role_permission
USING roles role, permissions permission
WHERE role_permission.role_id = role.id
AND role_permission.permission_id = permission.id
AND role.code <> 'inspector'
AND permission.code = ANY($1::varchar[])
`, [operationalPermissions]);
await queryRunner.query(`
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
CROSS JOIN permissions permission
WHERE role.code = 'inspector'
AND permission.code = ANY($1::varchar[])
ON CONFLICT (role_id, permission_id) DO NOTHING
`, [operationalPermissions]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions role_permission
USING roles role, permissions permission
WHERE role_permission.role_id = role.id
AND role_permission.permission_id = permission.id
AND role.code = 'inspector'
AND permission.code = 'inspection_acts.cancel'
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES ${previousNonInspectorMappings})
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
`);
}
}
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD52FindingCatalogAdministration1787335200000 implements MigrationInterface {
name = 'PhaseD52FindingCatalogAdministration1787335200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE finding_catalog_item_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
item_id uuid NOT NULL,
revision integer NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_item_versions_revision
UNIQUE (item_id, revision),
CONSTRAINT chk_finding_catalog_item_versions_revision
CHECK (revision > 0),
CONSTRAINT chk_finding_catalog_item_versions_snapshot
CHECK (jsonb_typeof(snapshot) = 'object'),
CONSTRAINT fk_finding_catalog_item_versions_item FOREIGN KEY (item_id)
REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
CONSTRAINT fk_finding_catalog_item_versions_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_item_versions_item_revision
ON finding_catalog_item_versions (item_id, revision DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_item_versions_created_at
ON finding_catalog_item_versions (created_at DESC)
`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_versions (
item_id, revision, snapshot, actor_username, created_at
)
SELECT
item.id,
item.revision,
JSONB_BUILD_OBJECT(
'id', item.id,
'categoryId', category.id,
'categoryCode', category.code,
'categoryName', category.name,
'code', item.code,
'sourceNumber', item.source_number,
'title', item.title,
'legalBasis', item.legal_basis,
'glossary', item.glossary,
'importNote', item.import_note,
'revision', item.revision,
'isActive', item.is_active
),
'migration:D5.2',
item.updated_at
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
`);
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 finding_catalog_item_versions
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE, DELETE ON TABLE finding_catalog_item_versions
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE finding_catalog_item_versions');
}
}
@@ -0,0 +1,474 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'asset_relations.read',
'asset_relations.manage',
] as const;
const rolePermissionValues = `
('admin', 'asset_relations.read'),
('admin', 'asset_relations.manage'),
('director', 'asset_relations.read'),
('supervisor', 'asset_relations.read'),
('supervisor', 'asset_relations.manage'),
('inspector', 'asset_relations.read'),
('auditor', 'asset_relations.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD53OperationalContext1787421600000 implements MigrationInterface {
name = 'PhaseD53OperationalContext1787421600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE asset_type_operational_role AS ENUM ('GENERIC', 'AREA', 'COMPANY')
`);
await queryRunner.query(`
ALTER TABLE asset_types
ADD COLUMN operational_role asset_type_operational_role NOT NULL DEFAULT 'GENERIC'
`);
await queryRunner.query(`
CREATE INDEX idx_asset_types_operational_role
ON asset_types (operational_role)
`);
await queryRunner.query(`
CREATE TABLE area_company_relations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
area_id uuid NOT NULL,
company_id uuid NOT NULL,
valid_from timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
valid_until timestamptz,
start_reason text NOT NULL,
end_reason text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid,
ended_by uuid,
CONSTRAINT chk_area_company_relation_dates
CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT chk_area_company_relation_start_reason
CHECK (length(btrim(start_reason)) >= 3),
CONSTRAINT chk_area_company_relation_end_reason
CHECK (valid_until IS NULL OR (end_reason IS NOT NULL AND length(btrim(end_reason)) >= 3)),
CONSTRAINT fk_area_company_relation_area FOREIGN KEY (area_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_company_relation_company FOREIGN KEY (company_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_company_relation_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_area_company_relation_ended_by FOREIGN KEY (ended_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_area_company_relations_area_id
ON area_company_relations (area_id, valid_from DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_area_company_relations_company_id
ON area_company_relations (company_id, valid_from DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_area_company_relations_valid_until
ON area_company_relations (valid_until)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_area_company_relations_active_pair
ON area_company_relations (area_id, company_id)
WHERE valid_until IS NULL
`);
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN operational_area_id uuid,
ADD COLUMN operator_company_id uuid,
ADD CONSTRAINT chk_assets_operational_assignment_pair CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
),
ADD CONSTRAINT fk_assets_operational_area FOREIGN KEY (operational_area_id)
REFERENCES assets(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_assets_operator_company FOREIGN KEY (operator_company_id)
REFERENCES assets(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
CREATE INDEX idx_assets_operational_area_id ON assets (operational_area_id)
`);
await queryRunner.query(`
CREATE INDEX idx_assets_operator_company_id ON assets (operator_company_id)
`);
await queryRunner.query(`
CREATE INDEX idx_assets_operational_context
ON assets (operational_area_id, operator_company_id)
`);
await queryRunner.query(`
UPDATE asset_versions
SET snapshot = JSONB_SET(
JSONB_SET(
JSONB_SET(
snapshot,
'{type,operationalRole}',
'"GENERIC"'::jsonb,
true
),
'{operationalArea}',
'null'::jsonb,
true
),
'{operatorCompany}',
'null'::jsonb,
true
)
`);
await queryRunner.query(`
CREATE FUNCTION protect_operational_type_changes()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.operational_role IN ('AREA'::asset_type_operational_role, 'COMPANY'::asset_type_operational_role)
AND EXISTS (
SELECT 1 FROM assets
WHERE asset_type_id = NEW.id
AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA or COMPANY types cannot contain assets with their own operational assignment';
END IF;
IF OLD.operational_role = 'AREA'::asset_type_operational_role
AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false)
AND (
EXISTS (
SELECT 1 FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
WHERE area.asset_type_id = OLD.id AND relation.valid_until IS NULL
)
OR EXISTS (
SELECT 1 FROM assets assigned
INNER JOIN assets area ON area.id = assigned.operational_area_id
WHERE area.asset_type_id = OLD.id
)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'AREA type cannot change role or be inactivated while it is operationally in use';
END IF;
IF OLD.operational_role = 'COMPANY'::asset_type_operational_role
AND (NEW.operational_role <> OLD.operational_role OR NEW.is_active = false)
AND (
EXISTS (
SELECT 1 FROM area_company_relations relation
INNER JOIN assets company ON company.id = relation.company_id
WHERE company.asset_type_id = OLD.id AND relation.valid_until IS NULL
)
OR EXISTS (
SELECT 1 FROM assets assigned
INNER JOIN assets company ON company.id = assigned.operator_company_id
WHERE company.asset_type_id = OLD.id
)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'COMPANY type cannot change role or be inactivated while it is operationally in use';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_asset_types_protect_operational_changes
BEFORE UPDATE OF operational_role, is_active ON asset_types
FOR EACH ROW EXECUTE FUNCTION protect_operational_type_changes()
`);
await queryRunner.query(`
CREATE FUNCTION enforce_area_company_relation_roles()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
area_role asset_type_operational_role;
company_role asset_type_operational_role;
BEGIN
SELECT asset_type.operational_role INTO area_role
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id = NEW.area_id
AND asset.information_status <> 'INACTIVE'
AND asset_type.is_active = true;
SELECT asset_type.operational_role INTO company_role
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id = NEW.company_id
AND asset.information_status <> 'INACTIVE'
AND asset_type.is_active = true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area relation target must be an active AREA asset';
END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company relation target must be an active COMPANY asset';
END IF;
IF NEW.area_id = NEW.company_id THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company must be different assets';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_area_company_relation_roles
BEFORE INSERT OR UPDATE OF area_id, company_id ON area_company_relations
FOR EACH ROW EXECUTE FUNCTION enforce_area_company_relation_roles()
`);
await queryRunner.query(`
CREATE FUNCTION enforce_asset_operational_context()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN
RETURN NEW;
END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company must be assigned together';
END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id = NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area and company assets cannot receive an operational assignment';
END IF;
SELECT asset_type.operational_role INTO area_role
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id = NEW.operational_area_id
AND asset.information_status <> 'INACTIVE'
AND asset_type.is_active = true;
SELECT asset_type.operational_role INTO company_role
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id = NEW.operator_company_id
AND asset.information_status <> 'INACTIVE'
AND asset_type.is_active = true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an active AREA asset';
END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operator company must be an active COMPANY asset';
END IF;
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id = NEW.operational_area_id
AND relation.company_id = NEW.operator_company_id
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area and company do not have an active relation';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = NEW.parent_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors current ON parent.id = current.parent_id
)
SELECT 1 FROM ancestors WHERE id = NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'operational area must be an ancestor in the physical hierarchy';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_operational_context
BEFORE INSERT OR UPDATE OF asset_type_id, parent_id, operational_area_id, operator_company_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_context()
`);
await queryRunner.query(`
CREATE FUNCTION enforce_asset_operational_descendants()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.parent_id IS NOT DISTINCT FROM OLD.parent_id THEN
RETURN NEW;
END IF;
IF EXISTS (
WITH RECURSIVE descendants AS (
SELECT child.id, child.parent_id, child.operational_area_id
FROM assets child
WHERE child.parent_id = NEW.id
UNION ALL
SELECT child.id, child.parent_id, child.operational_area_id
FROM assets child
INNER JOIN descendants parent ON child.parent_id = parent.id
)
SELECT 1
FROM descendants descendant
WHERE descendant.operational_area_id IS NOT NULL
AND NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM assets WHERE id = descendant.parent_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors current ON parent.id = current.parent_id
)
SELECT 1 FROM ancestors
WHERE id = descendant.operational_area_id
LIMIT 1
)
LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'physical hierarchy change would invalidate descendant operational assignments';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_operational_descendants
AFTER UPDATE OF parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_asset_operational_descendants()
`);
await queryRunner.query(`
CREATE FUNCTION protect_operational_anchor_inactivation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
role asset_type_operational_role;
BEGIN
IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status
OR NEW.information_status <> 'INACTIVE' THEN
RETURN NEW;
END IF;
SELECT operational_role INTO role FROM asset_types WHERE id = NEW.asset_type_id;
IF role = 'AREA'::asset_type_operational_role AND (
EXISTS (SELECT 1 FROM area_company_relations WHERE area_id = NEW.id AND valid_until IS NULL)
OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id = NEW.id)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'area cannot be inactivated while operational relations or assignments are active';
END IF;
IF role = 'COMPANY'::asset_type_operational_role AND (
EXISTS (SELECT 1 FROM area_company_relations WHERE company_id = NEW.id AND valid_until IS NULL)
OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id = NEW.id)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'company cannot be inactivated while operational relations or assignments are active';
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_protect_operational_anchor_inactivation
BEFORE UPDATE OF information_status ON assets
FOR EACH ROW EXECUTE FUNCTION protect_operational_anchor_inactivation()
`);
for (const permission of newPermissions) {
await queryRunner.query(
`INSERT INTO permissions (code, description)
VALUES ($1, $2)
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`,
[
permission,
permission === 'asset_relations.read'
? 'Consultar relaciones operativas entre áreas y empresas'
: 'Administrar relaciones operativas entre áreas y empresas',
],
);
}
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 area_company_relations
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE area_company_relations
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE asset_versions
SET snapshot = (snapshot #- '{operationalArea}' #- '{operatorCompany}' #- '{type,operationalRole}')
`);
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::text[])
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)
`, [newPermissions]);
await queryRunner.query('DROP TRIGGER trg_asset_types_protect_operational_changes ON asset_types');
await queryRunner.query('DROP FUNCTION protect_operational_type_changes()');
await queryRunner.query('DROP TRIGGER trg_assets_protect_operational_anchor_inactivation ON assets');
await queryRunner.query('DROP FUNCTION protect_operational_anchor_inactivation()');
await queryRunner.query('DROP TRIGGER trg_assets_operational_descendants ON assets');
await queryRunner.query('DROP FUNCTION enforce_asset_operational_descendants()');
await queryRunner.query('DROP TRIGGER trg_assets_operational_context ON assets');
await queryRunner.query('DROP FUNCTION enforce_asset_operational_context()');
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operator_company');
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT fk_assets_operational_area');
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT chk_assets_operational_assignment_pair');
await queryRunner.query('DROP INDEX idx_assets_operational_context');
await queryRunner.query('DROP INDEX idx_assets_operator_company_id');
await queryRunner.query('DROP INDEX idx_assets_operational_area_id');
await queryRunner.query('ALTER TABLE assets DROP COLUMN operator_company_id');
await queryRunner.query('ALTER TABLE assets DROP COLUMN operational_area_id');
await queryRunner.query('DROP TRIGGER trg_area_company_relation_roles ON area_company_relations');
await queryRunner.query('DROP FUNCTION enforce_area_company_relation_roles()');
await queryRunner.query('DROP TABLE area_company_relations');
await queryRunner.query('DROP INDEX idx_asset_types_operational_role');
await queryRunner.query('ALTER TABLE asset_types DROP COLUMN operational_role');
await queryRunner.query('DROP TYPE asset_type_operational_role');
}
}
@@ -0,0 +1,354 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'asset_registry.read',
'asset_registry.manage',
'assets.change_operational_status',
] as const;
const rolePermissionValues = `
('admin', 'asset_registry.read'), ('admin', 'asset_registry.manage'), ('admin', 'assets.change_operational_status'),
('director', 'asset_registry.read'), ('director', 'asset_registry.manage'), ('director', 'assets.change_operational_status'),
('supervisor', 'asset_registry.read'), ('supervisor', 'asset_registry.manage'), ('supervisor', 'assets.change_operational_status'),
('inspector', 'asset_registry.read'), ('auditor', 'asset_registry.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD533DefinitiveOperationalModel1787508000000 implements MigrationInterface {
name = 'PhaseD533DefinitiveOperationalModel1787508000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE asset_operational_status AS ENUM ('UNKNOWN','IN_SERVICE','TEMPORARILY_OUT_OF_SERVICE','OUT_OF_SERVICE','DECOMMISSIONED','ABANDONED')`);
await queryRunner.query(`ALTER TABLE assets ADD COLUMN operational_status asset_operational_status NOT NULL DEFAULT 'UNKNOWN'`);
await queryRunner.query(`CREATE INDEX idx_assets_operational_status ON assets (operational_status)`);
await queryRunner.query(`
UPDATE asset_types SET name='Área',
description='Área hidrocarburífera administrada como ancla territorial. Los permisos, concesiones y titulares se registran por separado en la capa legal.',
updated_at=CURRENT_TIMESTAMP WHERE code='area'
`);
await queryRunner.query(`
UPDATE asset_types SET name='Organización',
description='Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.',
updated_at=CURRENT_TIMESTAMP WHERE code='empresa'
`);
await queryRunner.query(`
UPDATE asset_attribute_definitions d SET is_active=false,updated_at=CURRENT_TIMESTAMP
FROM asset_types t WHERE d.asset_type_id=t.id AND t.code='area'
AND d.code IN ('situacion_concesion','vencimiento_concesion')
`);
await queryRunner.query(`
INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order)
SELECT t.id,p.code,p.name,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,p.sort_order
FROM asset_types t CROSS JOIN (VALUES ('cuenca','Cuenca',20),('departamento','Departamento',30)) p(code,name,sort_order)
WHERE t.code='area' ON CONFLICT DO NOTHING
`);
await queryRunner.query(`CREATE TYPE organization_kind AS ENUM ('COMPANY','UTE','PUBLIC_ENTITY','OTHER')`);
await queryRunner.query(`
CREATE TABLE organization_profiles (
asset_id uuid PRIMARY KEY,
organization_kind organization_kind NOT NULL DEFAULT 'COMPANY',
legal_name varchar(240), tax_id varchar(32), notes text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by uuid,
CONSTRAINT fk_organization_profiles_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_profiles_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_organization_profiles_tax_id ON organization_profiles (tax_id) WHERE tax_id IS NOT NULL`);
await queryRunner.query(`
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
SELECT a.id,'COMPANY'::organization_kind,a.name FROM assets a JOIN asset_types t ON t.id=a.asset_type_id
WHERE t.operational_role='COMPANY' ON CONFLICT (asset_id) DO NOTHING
`);
await queryRunner.query(`CREATE TYPE source_document_type AS ENUM ('NOTE','TECHNICAL_REPORT','INSPECTION_ACT','INVENTORY','RESOLUTION','DECREE','CONTRACT','SPREADSHEET','OTHER')`);
await queryRunner.query(`
CREATE TABLE source_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_type source_document_type NOT NULL,
document_number varchar(160), title varchar(300) NOT NULL, issuer varchar(240), document_date date,
external_reference varchar(500), notes text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid, updated_by uuid,
CONSTRAINT chk_source_documents_title CHECK (length(btrim(title)) >= 3),
CONSTRAINT fk_source_documents_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_source_documents_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_source_documents_number ON source_documents (document_number)`);
await queryRunner.query(`CREATE INDEX idx_source_documents_document_date ON source_documents (document_date DESC)`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_source_documents_number_issuer ON source_documents (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL`);
await queryRunner.query(`CREATE TYPE organization_membership_role AS ENUM ('MEMBER','LEAD_MEMBER','OTHER')`);
await queryRunner.query(`
CREATE TABLE organization_memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), parent_organization_id uuid NOT NULL, member_organization_id uuid NOT NULL,
role organization_membership_role NOT NULL DEFAULT 'MEMBER', participation_percent numeric(7,4),
valid_from date NOT NULL DEFAULT CURRENT_DATE, valid_until date, source_document_id uuid, notes text, end_reason text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid, ended_by uuid,
CONSTRAINT chk_organization_membership_different CHECK (parent_organization_id <> member_organization_id),
CONSTRAINT chk_organization_membership_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)),
CONSTRAINT chk_organization_membership_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT fk_organization_membership_parent FOREIGN KEY (parent_organization_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_membership_member FOREIGN KEY (member_organization_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_organization_membership_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_organization_membership_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_organization_membership_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_organization_memberships_parent_id ON organization_memberships (parent_organization_id)`);
await queryRunner.query(`CREATE INDEX idx_organization_memberships_member_id ON organization_memberships (member_organization_id)`);
await queryRunner.query(`CREATE INDEX idx_organization_memberships_valid_until ON organization_memberships (valid_until)`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_organization_memberships_active_role ON organization_memberships (parent_organization_id,member_organization_id,role) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE TYPE asset_source_document_relation_type AS ENUM ('SOURCE','MENTIONS','VALIDATES','SUPERSEDES','OTHER')`);
await queryRunner.query(`
CREATE TABLE asset_source_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, document_id uuid NOT NULL,
relation_type asset_source_document_relation_type NOT NULL DEFAULT 'SOURCE', notes text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid,
CONSTRAINT fk_asset_source_documents_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_source_documents_document FOREIGN KEY (document_id) REFERENCES source_documents(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_source_documents_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT uq_asset_source_documents UNIQUE (asset_id,document_id,relation_type)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_source_documents_asset_id ON asset_source_documents (asset_id)`);
await queryRunner.query(`CREATE INDEX idx_asset_source_documents_document_id ON asset_source_documents (document_id)`);
await queryRunner.query(`
CREATE TABLE asset_external_identifiers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL, namespace varchar(80) NOT NULL, value varchar(180) NOT NULL,
valid_from timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, valid_until timestamptz, source_document_id uuid, notes text, end_reason text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, ended_by uuid,
CONSTRAINT chk_asset_external_identifier_namespace CHECK (namespace ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'),
CONSTRAINT chk_asset_external_identifier_value CHECK (length(btrim(value)) >= 1),
CONSTRAINT chk_asset_external_identifier_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT fk_asset_external_identifier_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_external_identifier_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_external_identifier_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_external_identifier_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_external_identifiers_asset_id ON asset_external_identifiers (asset_id)`);
await queryRunner.query(`CREATE INDEX idx_asset_external_identifiers_namespace ON asset_external_identifiers (namespace)`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_external_identifiers_active_namespace_value ON asset_external_identifiers (namespace,value) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE TYPE area_organization_role AS ENUM ('OPERATOR','TECHNICAL_OPERATOR','CONCESSIONAIRE','PERMIT_HOLDER','PARTICIPANT','OTHER')`);
await queryRunner.query(`DROP INDEX uq_area_company_relations_active_pair`);
await queryRunner.query(`
ALTER TABLE area_company_relations
ADD COLUMN relation_role area_organization_role NOT NULL DEFAULT 'OPERATOR',
ADD COLUMN participation_percent numeric(7,4), ADD COLUMN legal_instrument varchar(240), ADD COLUMN source_document_id uuid,
ADD CONSTRAINT chk_area_company_relations_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)),
ADD CONSTRAINT fk_area_company_relations_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_area_company_relations_active_role ON area_company_relations (area_id,company_id,relation_role) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE INDEX idx_area_company_relations_role ON area_company_relations (relation_role)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization must be assigned together'; END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment'; END IF;
SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset'; END IF;
SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.relation_role='OPERATOR'::area_organization_role AND r.valid_until IS NULL FOR KEY SHARE;
IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation'; END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`CREATE TYPE area_legal_right_type AS ENUM ('EXPLOITATION_CONCESSION','EXPLORATION_PERMIT','TRANSPORT_CONCESSION','OTHER')`);
await queryRunner.query(`CREATE TYPE area_legal_right_status AS ENUM ('ACTIVE','EXPIRED','REVOKED','PENDING')`);
await queryRunner.query(`
CREATE TABLE area_legal_rights (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), area_id uuid NOT NULL, right_type area_legal_right_type NOT NULL,
name varchar(260) NOT NULL, instrument_number varchar(180), valid_from date, valid_until date,
status area_legal_right_status NOT NULL DEFAULT 'ACTIVE', source_document_id uuid, notes text,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, updated_by uuid,
CONSTRAINT chk_area_legal_right_dates CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until >= valid_from),
CONSTRAINT chk_area_legal_right_name CHECK (length(btrim(name)) >= 3),
CONSTRAINT fk_area_legal_right_area FOREIGN KEY (area_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_legal_right_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_area_legal_right_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_area_legal_right_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_area_legal_rights_area_id ON area_legal_rights (area_id)`);
await queryRunner.query(`CREATE INDEX idx_area_legal_rights_valid_until ON area_legal_rights (valid_until)`);
await queryRunner.query(`CREATE TYPE area_legal_right_organization_role AS ENUM ('HOLDER','PARTICIPANT','OPERATOR','OTHER')`);
await queryRunner.query(`
CREATE TABLE area_legal_right_organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), right_id uuid NOT NULL, organization_id uuid NOT NULL,
role area_legal_right_organization_role NOT NULL, participation_percent numeric(7,4), valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_until date, notes text, end_reason text, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by uuid, ended_by uuid,
CONSTRAINT chk_area_legal_right_org_participation CHECK (participation_percent IS NULL OR (participation_percent > 0 AND participation_percent <= 100)),
CONSTRAINT chk_area_legal_right_org_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT fk_area_legal_right_org_right FOREIGN KEY (right_id) REFERENCES area_legal_rights(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_legal_right_org_organization FOREIGN KEY (organization_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_legal_right_org_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_area_legal_right_org_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_right_id ON area_legal_right_organizations (right_id)`);
await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_organization_id ON area_legal_right_organizations (organization_id)`);
await queryRunner.query(`CREATE INDEX idx_area_legal_right_org_valid_until ON area_legal_right_organizations (valid_until)`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_area_legal_right_org_active_role ON area_legal_right_organizations (right_id,organization_id,role) WHERE valid_until IS NULL`);
await queryRunner.query(`
CREATE FUNCTION enforce_d533_registry_roles() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE resolved_role asset_type_operational_role; parent_kind organization_kind; member_kind organization_kind;
BEGIN
IF TG_TABLE_NAME='organization_profiles' THEN
SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.asset_id;
IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization profile requires a COMPANY-role asset'; END IF;
IF NEW.organization_kind <> 'UTE'::organization_kind AND EXISTS (SELECT 1 FROM organization_memberships m WHERE m.parent_organization_id=NEW.asset_id AND m.valid_until IS NULL) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization with active members must remain profiled as UTE'; END IF;
IF NEW.organization_kind = 'UTE'::organization_kind AND EXISTS (SELECT 1 FROM organization_memberships m WHERE m.member_organization_id=NEW.asset_id AND m.valid_until IS NULL) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization that actively belongs to a UTE cannot itself become UTE'; END IF;
ELSIF TG_TABLE_NAME='organization_memberships' THEN
IF NEW.parent_organization_id=NEW.member_organization_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization cannot be a member of itself'; END IF;
SELECT t.operational_role,p.organization_kind INTO resolved_role,parent_kind FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN organization_profiles p ON p.asset_id=a.id WHERE a.id=NEW.parent_organization_id;
IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role OR parent_kind IS DISTINCT FROM 'UTE'::organization_kind THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='parent organization membership requires an organization profiled as UTE'; END IF;
SELECT t.operational_role,p.organization_kind INTO resolved_role,member_kind FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN organization_profiles p ON p.asset_id=a.id WHERE a.id=NEW.member_organization_id;
IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role OR member_kind IS NULL OR member_kind='UTE'::organization_kind THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='UTE member must be a non-UTE organization'; END IF;
ELSIF TG_TABLE_NAME='area_legal_rights' THEN
SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.area_id;
IF resolved_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='legal right requires an AREA asset'; END IF;
ELSIF TG_TABLE_NAME='area_legal_right_organizations' THEN
SELECT t.operational_role INTO resolved_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.organization_id;
IF resolved_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='legal right participant requires a COMPANY-role asset'; END IF;
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`CREATE TRIGGER trg_organization_profiles_role BEFORE INSERT OR UPDATE OF asset_id,organization_kind ON organization_profiles FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`);
await queryRunner.query(`CREATE TRIGGER trg_organization_memberships_role BEFORE INSERT OR UPDATE OF parent_organization_id,member_organization_id ON organization_memberships FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`);
await queryRunner.query(`CREATE TRIGGER trg_area_legal_rights_role BEFORE INSERT OR UPDATE OF area_id ON area_legal_rights FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`);
await queryRunner.query(`CREATE TRIGGER trg_area_legal_right_org_role BEFORE INSERT OR UPDATE OF organization_id ON area_legal_right_organizations FOR EACH ROW EXECUTE FUNCTION enforce_d533_registry_roles()`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION protect_operational_anchor_inactivation() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE role asset_type_operational_role;
BEGIN
IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status OR NEW.information_status <> 'INACTIVE' THEN RETURN NEW; END IF;
SELECT operational_role INTO role FROM asset_types WHERE id=NEW.asset_type_id;
IF role='AREA'::asset_type_operational_role AND (
EXISTS (SELECT 1 FROM area_company_relations WHERE area_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id=NEW.id) OR
EXISTS (SELECT 1 FROM area_legal_rights r WHERE r.area_id=NEW.id AND r.status IN ('ACTIVE','PENDING') AND (r.valid_until IS NULL OR r.valid_until>=CURRENT_DATE))
) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area cannot be inactivated while operational relations, assignments or legal rights are active'; END IF;
IF role='COMPANY'::asset_type_operational_role AND (
EXISTS (SELECT 1 FROM area_company_relations WHERE company_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id=NEW.id) OR
EXISTS (SELECT 1 FROM organization_memberships m WHERE (m.parent_organization_id=NEW.id OR m.member_organization_id=NEW.id) AND m.valid_until IS NULL) OR
EXISTS (SELECT 1 FROM area_legal_right_organizations p JOIN area_legal_rights r ON r.id=p.right_id WHERE p.organization_id=NEW.id AND p.valid_until IS NULL AND r.status IN ('ACTIVE','PENDING') AND (r.valid_until IS NULL OR r.valid_until>=CURRENT_DATE))
) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='organization cannot be inactivated while operational relations, assignments, memberships or legal participation are active'; END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`UPDATE asset_versions SET snapshot=jsonb_set(snapshot,'{operationalStatus}','"UNKNOWN"'::jsonb,true) WHERE NOT (snapshot ? 'operationalStatus')`);
const descriptions: Record<string,string> = {
'asset_registry.read':'Consultar identificadores, documentos, organizaciones y derechos del Maestro',
'asset_registry.manage':'Administrar identificadores, documentos, organizaciones y derechos del Maestro',
'assets.change_operational_status':'Cambiar el estado operativo de activos',
};
for (const permission of newPermissions) {
await queryRunner.query(`INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`,[permission,descriptions[permission]]);
}
await queryRunner.query(`WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.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);
const tables=['organization_profiles','organization_memberships','source_documents','asset_source_documents','asset_external_identifiers','area_legal_rights','area_legal_right_organizations'].map(quoteIdentifier).join(', ');
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE ${tables} TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE ${tables} FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) DELETE FROM role_permissions rp USING roles r,permissions p,mapping m WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code`);
await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`,[newPermissions]);
await queryRunner.query(`UPDATE asset_versions SET snapshot=snapshot-'operationalStatus'`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION protect_operational_anchor_inactivation() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE role asset_type_operational_role; BEGIN
IF NEW.information_status IS NOT DISTINCT FROM OLD.information_status OR NEW.information_status<>'INACTIVE' THEN RETURN NEW; END IF;
SELECT operational_role INTO role FROM asset_types WHERE id=NEW.asset_type_id;
IF role='AREA'::asset_type_operational_role AND (EXISTS (SELECT 1 FROM area_company_relations WHERE area_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operational_area_id=NEW.id)) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area cannot be inactivated while operational relations or assignments are active'; END IF;
IF role='COMPANY'::asset_type_operational_role AND (EXISTS (SELECT 1 FROM area_company_relations WHERE company_id=NEW.id AND valid_until IS NULL) OR EXISTS (SELECT 1 FROM assets WHERE operator_company_id=NEW.id)) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='company cannot be inactivated while operational relations or assignments are active'; END IF;
RETURN NEW; END $$
`);
await queryRunner.query('DROP TRIGGER trg_area_legal_right_org_role ON area_legal_right_organizations');
await queryRunner.query('DROP TRIGGER trg_area_legal_rights_role ON area_legal_rights');
await queryRunner.query('DROP TRIGGER trg_organization_memberships_role ON organization_memberships');
await queryRunner.query('DROP TRIGGER trg_organization_profiles_role ON organization_profiles');
await queryRunner.query('DROP FUNCTION enforce_d533_registry_roles()');
await queryRunner.query('DROP TABLE area_legal_right_organizations');
await queryRunner.query('DROP TYPE area_legal_right_organization_role');
await queryRunner.query('DROP TABLE area_legal_rights');
await queryRunner.query('DROP TYPE area_legal_right_status');
await queryRunner.query('DROP TYPE area_legal_right_type');
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and company must be assigned together'; END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and company assets cannot receive an operational assignment'; END IF;
SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator company must be an active COMPANY asset'; END IF;
SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.valid_until IS NULL FOR KEY SHARE;
IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and company do not have an active relation'; END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF;
RETURN NEW; END $$
`);
await queryRunner.query('DROP INDEX idx_area_company_relations_role');
await queryRunner.query('DROP INDEX uq_area_company_relations_active_role');
await queryRunner.query('ALTER TABLE area_company_relations DROP CONSTRAINT fk_area_company_relations_source_document');
await queryRunner.query('ALTER TABLE area_company_relations DROP CONSTRAINT chk_area_company_relations_participation');
await queryRunner.query('ALTER TABLE area_company_relations DROP COLUMN source_document_id,DROP COLUMN legal_instrument,DROP COLUMN participation_percent,DROP COLUMN relation_role');
await queryRunner.query(`CREATE UNIQUE INDEX uq_area_company_relations_active_pair ON area_company_relations (area_id,company_id) WHERE valid_until IS NULL`);
await queryRunner.query('DROP TYPE area_organization_role');
await queryRunner.query('DROP TABLE asset_external_identifiers');
await queryRunner.query('DROP TABLE asset_source_documents');
await queryRunner.query('DROP TYPE asset_source_document_relation_type');
await queryRunner.query('DROP TABLE organization_memberships');
await queryRunner.query('DROP TYPE organization_membership_role');
await queryRunner.query('DROP TABLE source_documents');
await queryRunner.query('DROP TYPE source_document_type');
await queryRunner.query('DROP TABLE organization_profiles');
await queryRunner.query('DROP TYPE organization_kind');
await queryRunner.query(`UPDATE asset_types SET name='Área / Concesión',description='Área hidrocarburífera o concesión administrada. Funciona como ancla territorial y operativa.',updated_at=CURRENT_TIMESTAMP WHERE code='area'`);
await queryRunner.query(`UPDATE asset_types SET name='Empresa / Operadora',description='Empresa operadora, concesionaria o integrante de una explotación. Se vincula a una o más áreas mediante relaciones operativas históricas.',updated_at=CURRENT_TIMESTAMP WHERE code='empresa'`);
await queryRunner.query(`UPDATE asset_attribute_definitions d SET is_active=true,updated_at=CURRENT_TIMESTAMP FROM asset_types t WHERE d.asset_type_id=t.id AND t.code='area' AND d.code IN ('situacion_concesion','vencimiento_concesion')`);
await queryRunner.query(`DELETE FROM asset_attribute_definitions d USING asset_types t WHERE d.asset_type_id=t.id AND t.code='area' AND d.code IN ('cuenca','departamento') AND NOT EXISTS (SELECT 1 FROM asset_attribute_values v WHERE v.definition_id=d.id)`);
await queryRunner.query('DROP INDEX idx_assets_operational_status');
await queryRunner.query('ALTER TABLE assets DROP COLUMN operational_status');
await queryRunner.query('DROP TYPE asset_operational_status');
}
}
@@ -0,0 +1,137 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = ['asset_imports.read', 'asset_imports.manage'] as const;
const rolePermissionValues = `
('admin', 'asset_imports.read'), ('admin', 'asset_imports.manage'),
('director', 'asset_imports.read'), ('director', 'asset_imports.manage'),
('supervisor', 'asset_imports.read'), ('supervisor', 'asset_imports.manage')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD535ImportCenterScalability1787594400000 implements MigrationInterface {
name = 'PhaseD535ImportCenterScalability1787594400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_code_trgm ON assets USING gin (code gin_trgm_ops)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_assets_name_trgm ON assets USING gin (name gin_trgm_ops)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_external_identifiers_value_trgm ON asset_external_identifiers USING gin (value gin_trgm_ops)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_asset_attribute_values_text_trgm ON asset_attribute_values USING gin ((value #>> '{}') gin_trgm_ops)`);
await queryRunner.query(`CREATE TYPE asset_import_batch_status AS ENUM ('ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED')`);
await queryRunner.query(`CREATE TYPE asset_import_row_status AS ENUM ('READY','WARNING','CONFLICT','IGNORED')`);
await queryRunner.query(`CREATE TYPE asset_import_suggested_action AS ENUM ('CREATE','MATCH','REVIEW','IGNORE')`);
await queryRunner.query(`
CREATE TABLE asset_import_batches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(120) NOT NULL,
size_bytes integer NOT NULL,
sha256 char(64) NOT NULL,
profile_code varchar(80) NOT NULL,
profile_confidence integer NOT NULL DEFAULT 0,
worksheet_name varchar(160),
header_row integer,
total_rows integer NOT NULL DEFAULT 0,
ready_rows integer NOT NULL DEFAULT 0,
warning_rows integer NOT NULL DEFAULT 0,
conflict_rows integer NOT NULL DEFAULT 0,
ignored_rows integer NOT NULL DEFAULT 0,
status asset_import_batch_status NOT NULL,
source_document_id uuid,
source_label varchar(240),
notes text,
analysis jsonb NOT NULL DEFAULT '{}'::jsonb,
uploaded_by uuid,
analyzed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_asset_import_batch_size CHECK (size_bytes > 0 AND size_bytes <= 26214400),
CONSTRAINT chk_asset_import_profile_confidence CHECK (profile_confidence >= 0 AND profile_confidence <= 100),
CONSTRAINT chk_asset_import_batch_counts CHECK (total_rows >= 0 AND ready_rows >= 0 AND warning_rows >= 0 AND conflict_rows >= 0 AND ignored_rows >= 0),
CONSTRAINT fk_asset_import_batch_source_document FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_batch_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_import_batches_created_at ON asset_import_batches (created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_batches_status ON asset_import_batches (status)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_batches_sha256 ON asset_import_batches (sha256)`);
await queryRunner.query(`
CREATE TABLE asset_import_rows (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id uuid NOT NULL,
worksheet_name varchar(160) NOT NULL,
row_number integer NOT NULL,
status asset_import_row_status NOT NULL,
suggested_action asset_import_suggested_action NOT NULL,
raw_data jsonb NOT NULL,
normalized_data jsonb NOT NULL,
issue_codes jsonb NOT NULL DEFAULT '[]'::jsonb,
fingerprint char(64) NOT NULL,
matched_asset_id uuid,
imported_asset_id uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_asset_import_row_number CHECK (row_number > 0),
CONSTRAINT chk_asset_import_row_issues_array CHECK (jsonb_typeof(issue_codes) = 'array'),
CONSTRAINT fk_asset_import_rows_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_import_rows_matched_asset FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_rows_imported_asset FOREIGN KEY (imported_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CONSTRAINT uq_asset_import_row_source UNIQUE (batch_id, worksheet_name, row_number)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_status ON asset_import_rows (batch_id,status,row_number)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_rows_batch_action ON asset_import_rows (batch_id,suggested_action)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_rows_fingerprint ON asset_import_rows (fingerprint)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_rows_inventory_id ON asset_import_rows ((normalized_data->>'inventoryId')) WHERE normalized_data ? 'inventoryId'`);
const descriptions: Record<string, string> = {
'asset_imports.read': 'Consultar lotes, análisis y conflictos de importación del Maestro',
'asset_imports.manage': 'Analizar archivos XLSX/CSV y administrar lotes de importación del Maestro',
};
for (const permission of newPermissions) {
await queryRunner.query(
`INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`,
[permission, descriptions[permission]],
);
}
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
INSERT INTO role_permissions (role_id,permission_id)
SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.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_import_batches, asset_import_rows TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_batches, asset_import_rows FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
DELETE FROM role_permissions rp USING roles r,permissions p,mapping m
WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code
`);
await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`, [newPermissions]);
await queryRunner.query(`DROP TABLE asset_import_rows`);
await queryRunner.query(`DROP TABLE asset_import_batches`);
await queryRunner.query(`DROP TYPE asset_import_suggested_action`);
await queryRunner.query(`DROP TYPE asset_import_row_status`);
await queryRunner.query(`DROP TYPE asset_import_batch_status`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_attribute_values_text_trgm`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_asset_external_identifiers_value_trgm`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_name_trgm`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_assets_code_trgm`);
}
}
@@ -0,0 +1,125 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = ['asset_imports.apply'] as const;
const rolePermissionValues = `
('admin', 'asset_imports.apply'),
('director', 'asset_imports.apply')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD537ImportPlanningApplication1787680800000 implements MigrationInterface {
name = 'PhaseD537ImportPlanningApplication1787680800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE asset_import_plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id uuid NOT NULL,
revision integer NOT NULL,
status varchar(24) NOT NULL,
external_id_namespace varchar(80),
operator_asset_id uuid,
summary jsonb NOT NULL DEFAULT '{}'::jsonb,
plan_hash char(64) NOT NULL,
generated_by uuid,
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
applied_by uuid,
applied_at timestamptz,
application_summary jsonb,
rolled_back_by uuid,
rolled_back_at timestamptz,
rollback_summary jsonb,
superseded_at timestamptz,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_asset_import_plan_revision CHECK (revision > 0),
CONSTRAINT chk_asset_import_plan_status CHECK (status IN ('REVIEW_REQUIRED','READY','APPLIED','ROLLED_BACK','SUPERSEDED','FAILED')),
CONSTRAINT chk_asset_import_plan_namespace CHECK (external_id_namespace IS NULL OR external_id_namespace ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'),
CONSTRAINT chk_asset_import_plan_hash CHECK (plan_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT fk_asset_import_plan_batch FOREIGN KEY (batch_id) REFERENCES asset_import_batches(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_import_plan_operator FOREIGN KEY (operator_asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_import_plan_generated_by FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_plan_applied_by FOREIGN KEY (applied_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_plan_rolled_back_by FOREIGN KEY (rolled_back_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT uq_asset_import_plan_revision UNIQUE (batch_id, revision)
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_import_plan_active_batch ON asset_import_plans (batch_id) WHERE superseded_at IS NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_import_plans_status ON asset_import_plans (status, generated_at DESC)`);
await queryRunner.query(`
CREATE TABLE asset_import_plan_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id uuid NOT NULL,
item_order integer NOT NULL,
entity_key varchar(320) NOT NULL,
entity_kind varchar(40) NOT NULL,
action varchar(16) NOT NULL,
status varchar(24) NOT NULL,
asset_type_code varchar(80),
display_name varchar(260) NOT NULL,
generated_code varchar(120),
parent_entity_key varchar(320),
matched_asset_id uuid,
applied_asset_id uuid,
applied_object_id varchar(255),
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
source_row_numbers jsonb NOT NULL DEFAULT '[]'::jsonb,
review_codes jsonb NOT NULL DEFAULT '[]'::jsonb,
resolution_note text,
resolved_by uuid,
resolved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_asset_import_plan_item_order CHECK (item_order > 0),
CONSTRAINT chk_asset_import_plan_item_kind CHECK (entity_kind IN ('ORGANIZATION','AREA','FIELD','OPERATOR_RELATION','INSTALLATION','TECHNICAL_ASSET')),
CONSTRAINT chk_asset_import_plan_item_action CHECK (action IN ('CREATE','MATCH','REVIEW','IGNORE')),
CONSTRAINT chk_asset_import_plan_item_status CHECK (status IN ('PLANNED','MATCHED','REVIEW','IGNORED','APPLIED','ROLLED_BACK','FAILED')),
CONSTRAINT chk_asset_import_plan_item_rows CHECK (jsonb_typeof(source_row_numbers)='array'),
CONSTRAINT chk_asset_import_plan_item_reviews CHECK (jsonb_typeof(review_codes)='array'),
CONSTRAINT fk_asset_import_plan_item_plan FOREIGN KEY (plan_id) REFERENCES asset_import_plans(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_import_plan_item_match FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_plan_item_applied_asset FOREIGN KEY (applied_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_import_plan_item_resolved_by FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT uq_asset_import_plan_item_key UNIQUE (plan_id, entity_key)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_order ON asset_import_plan_items (plan_id, item_order)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_plan_action ON asset_import_plan_items (plan_id, action, entity_kind)`);
await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_match ON asset_import_plan_items (matched_asset_id) WHERE matched_asset_id IS NOT NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_import_plan_items_applied ON asset_import_plan_items (applied_asset_id) WHERE applied_asset_id IS NOT NULL`);
await queryRunner.query(
`INSERT INTO permissions (code,description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`,
['asset_imports.apply', 'Aplicar o revertir planes de importación transaccional sobre el Maestro'],
);
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
INSERT INTO role_permissions (role_id,permission_id)
SELECT r.id,p.id FROM mapping m JOIN roles r ON r.code=m.role_code JOIN permissions p ON p.code=m.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_import_plans, asset_import_plan_items TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE asset_import_plans, asset_import_plan_items FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
DELETE FROM role_permissions rp USING roles r,permissions p,mapping m
WHERE rp.role_id=r.id AND rp.permission_id=p.id AND r.code=m.role_code AND p.code=m.permission_code
`);
await queryRunner.query(`DELETE FROM permissions WHERE code=ANY($1::text[]) AND NOT EXISTS (SELECT 1 FROM role_permissions WHERE permission_id=permissions.id)`, [newPermissions]);
await queryRunner.query(`DROP TABLE asset_import_plan_items`);
await queryRunner.query(`DROP TABLE asset_import_plans`);
}
}
@@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5372RelationalTerritoryPlan1787767200000 implements MigrationInterface {
name = 'PhaseD5372RelationalTerritoryPlan1787767200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind
`);
await queryRunner.query(`
ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK (
entity_kind IN (
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','TECHNICAL_ASSET'
)
)
`);
await queryRunner.query(`
CREATE TABLE administrative_departments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
province_code varchar(40) NOT NULL DEFAULT 'MENDOZA',
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
normalized_name varchar(180) NOT NULL,
is_active boolean NOT NULL DEFAULT true,
source_document_id uuid,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_administrative_department_code CHECK (code ~ '^[A-Z0-9][A-Z0-9._/-]{1,79}$'),
CONSTRAINT chk_administrative_department_name CHECK (length(btrim(name)) >= 2),
CONSTRAINT chk_administrative_department_normalized CHECK (length(btrim(normalized_name)) >= 2),
CONSTRAINT fk_administrative_department_source FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_administrative_department_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_administrative_department_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT uq_administrative_department_code UNIQUE (province_code, code),
CONSTRAINT uq_administrative_department_name UNIQUE (province_code, normalized_name)
)
`);
await queryRunner.query(`CREATE INDEX idx_administrative_departments_active_name ON administrative_departments (is_active, normalized_name)`);
await queryRunner.query(`
CREATE TABLE area_department_relations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
area_id uuid NOT NULL,
department_id uuid NOT NULL,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_until date,
source_document_id uuid,
notes text,
created_by uuid,
ended_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_area_department_relation_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT fk_area_department_relation_area FOREIGN KEY (area_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_department_relation_department FOREIGN KEY (department_id) REFERENCES administrative_departments(id) ON DELETE RESTRICT,
CONSTRAINT fk_area_department_relation_source FOREIGN KEY (source_document_id) REFERENCES source_documents(id) ON DELETE SET NULL,
CONSTRAINT fk_area_department_relation_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_area_department_relation_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_area_department_relation_active_pair ON area_department_relations (area_id, department_id) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE INDEX idx_area_department_relations_area ON area_department_relations (area_id) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE INDEX idx_area_department_relations_department ON area_department_relations (department_id) WHERE valid_until IS NULL`);
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 administrative_departments, area_department_relations TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE administrative_departments, area_department_relations FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE area_department_relations`);
await queryRunner.query(`DROP TABLE administrative_departments`);
await queryRunner.query(`ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind`);
await queryRunner.query(`
ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK (
entity_kind IN ('ORGANIZATION','AREA','FIELD','OPERATOR_RELATION','INSTALLATION','TECHNICAL_ASSET')
)
`);
}
}
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseD538LocalInventoryStructure1787853600000 implements MigrationInterface {
name = 'PhaseD538LocalInventoryStructure1787853600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind
`);
await queryRunner.query(`
ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK (
entity_kind IN (
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET'
)
)
`);
await queryRunner.query(`
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
SELECT
'estructura_local',
'Estructura local (fuente)',
'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.',
false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='estructura_local')
`);
await queryRunner.query(`
INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order)
SELECT type.id,definition.code,definition.name,'TEXT',false,true,definition.sort_order
FROM asset_types type
CROSS JOIN (VALUES
('nivel_fuente','Nivel informado por la fuente',10),
('clasificacion_fuente','Clasificación local informada',20),
('ruta_fuente','Ruta / nomenclatura de origen',30)
) AS definition(code,name,sort_order)
WHERE lower(type.code)='estructura_local'
AND NOT EXISTS (
SELECT 1 FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=type.id AND lower(existing.code)=lower(definition.code)
)
`);
await queryRunner.query(`
INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT local.id,parent.id
FROM asset_types local
JOIN asset_types parent ON lower(parent.code) IN ('area','yacimiento','locacion')
WHERE lower(local.code)='estructura_local'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT child.id,local.id
FROM asset_types child
CROSS JOIN asset_types local
WHERE lower(local.code)='estructura_local'
AND child.operational_role='GENERIC'
AND lower(child.code) NOT IN ('yacimiento','locacion','estructura_local','pozo','ducto','colector')
ON CONFLICT DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE asset_import_plan_items DROP CONSTRAINT chk_asset_import_plan_item_kind`);
await queryRunner.query(`
ALTER TABLE asset_import_plan_items ADD CONSTRAINT chk_asset_import_plan_item_kind CHECK (
entity_kind IN (
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','TECHNICAL_ASSET'
)
)
`);
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING asset_types local
WHERE lower(local.code)='estructura_local'
AND (rule.child_type_id=local.id OR rule.parent_type_id=local.id)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types local
WHERE definition.asset_type_id=local.id AND lower(local.code)='estructura_local'
`);
await queryRunner.query(`DELETE FROM asset_types WHERE lower(code)='estructura_local'`);
}
}
@@ -0,0 +1,146 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const newPermissions = [
'inspection_reports.read',
'inspection_reports.generate',
] as const;
const rolePermissionValues = `
('admin', 'inspection_reports.read'),
('director', 'inspection_reports.read'),
('supervisor', 'inspection_reports.read'),
('inspector', 'inspection_reports.read'),
('inspector', 'inspection_reports.generate'),
('auditor', 'inspection_reports.read')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5315DocumentCenter1788026400000 implements MigrationInterface {
name = 'PhaseD5315DocumentCenter1788026400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
visit_id uuid NOT NULL,
act_id uuid NOT NULL,
report_year integer NOT NULL,
report_number integer NOT NULL,
code varchar(24) NOT NULL,
status varchar(24) NOT NULL DEFAULT 'FROZEN',
pdf_status varchar(24) NOT NULL DEFAULT 'PENDING',
title varchar(220) NOT NULL,
act_version integer NOT NULL,
act_closure_sha256 char(64) NOT NULL,
frozen_sha256 char(64) NOT NULL,
frozen_snapshot jsonb NOT NULL,
generated_at timestamptz NOT NULL,
generated_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_reports_visit UNIQUE (visit_id),
CONSTRAINT uq_inspection_reports_act UNIQUE (act_id),
CONSTRAINT uq_inspection_reports_year_number UNIQUE (report_year, report_number),
CONSTRAINT uq_inspection_reports_code UNIQUE (code),
CONSTRAINT chk_inspection_reports_number CHECK (report_year >= 2000 AND report_number > 0),
CONSTRAINT chk_inspection_reports_status CHECK (status IN ('FROZEN', 'CANCELLED')),
CONSTRAINT chk_inspection_reports_pdf_status CHECK (pdf_status IN ('PENDING', 'READY', 'FAILED')),
CONSTRAINT chk_inspection_reports_hashes CHECK (
act_closure_sha256 ~ '^[0-9a-f]{64}$'
AND frozen_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT chk_inspection_reports_snapshot CHECK (jsonb_typeof(frozen_snapshot) = 'object'),
CONSTRAINT fk_inspection_reports_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_reports_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_reports_generated_by FOREIGN KEY (generated_by)
REFERENCES users(id) ON DELETE RESTRICT
)
`);
await queryRunner.query('CREATE INDEX idx_inspection_reports_generated_at ON inspection_reports (generated_at DESC)');
await queryRunner.query('CREATE INDEX idx_inspection_reports_status ON inspection_reports (status, pdf_status)');
await queryRunner.query(`
CREATE FUNCTION dhv2_guard_inspection_report_frozen()
RETURNS trigger AS $$
BEGIN
IF NEW.visit_id IS DISTINCT FROM OLD.visit_id
OR NEW.act_id IS DISTINCT FROM OLD.act_id
OR NEW.report_year IS DISTINCT FROM OLD.report_year
OR NEW.report_number IS DISTINCT FROM OLD.report_number
OR NEW.code IS DISTINCT FROM OLD.code
OR NEW.title IS DISTINCT FROM OLD.title
OR NEW.act_version IS DISTINCT FROM OLD.act_version
OR NEW.act_closure_sha256 IS DISTINCT FROM OLD.act_closure_sha256
OR NEW.frozen_sha256 IS DISTINCT FROM OLD.frozen_sha256
OR NEW.frozen_snapshot IS DISTINCT FROM OLD.frozen_snapshot
OR NEW.generated_at IS DISTINCT FROM OLD.generated_at
OR NEW.generated_by IS DISTINCT FROM OLD.generated_by
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'El contenido congelado del informe es inmutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_reports_frozen
BEFORE UPDATE ON inspection_reports
FOR EACH ROW EXECUTE FUNCTION dhv2_guard_inspection_report_frozen()
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_reports.read', 'Consultar informes de inspección y documentos pendientes de emisión'),
('inspection_reports.generate', 'Solicitar la emisión y congelado de un informe desde una inspección cerrada')
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE inspection_reports TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE DELETE ON TABLE inspection_reports FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER trg_inspection_reports_frozen ON inspection_reports');
await queryRunner.query('DROP FUNCTION dhv2_guard_inspection_report_frozen()');
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_reports');
}
}
@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCode = 'inspection_verifications.plan';
const rolePermissionValues = `
('admin', 'inspection_verifications.plan'),
('director', 'inspection_verifications.plan'),
('supervisor', 'inspection_verifications.plan')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5316VerificationPlanning1788112800000 implements MigrationInterface {
name = 'PhaseD5316VerificationPlanning1788112800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_finding_verification_visits (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL,
visit_id uuid NOT NULL,
linked_by uuid NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inspection_finding_verification_visit UNIQUE (finding_id, visit_id),
CONSTRAINT fk_inspection_finding_verification_finding FOREIGN KEY (finding_id)
REFERENCES inspection_findings(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_finding_verification_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_finding_verification_linked_by FOREIGN KEY (linked_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query('CREATE INDEX idx_inspection_finding_verification_finding ON inspection_finding_verification_visits (finding_id, created_at DESC)');
await queryRunner.query('CREATE INDEX idx_inspection_finding_verification_visit ON inspection_finding_verification_visits (visit_id)');
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ($1, 'Planificar visitas de verificación a partir de hallazgos con fecha de control')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`, [permissionCode]);
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT, INSERT ON TABLE inspection_finding_verification_visits TO ${applicationRole}`);
await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_visits FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 = $1
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)
`, [permissionCode]);
await queryRunner.query('DROP TABLE inspection_finding_verification_visits');
}
}
@@ -0,0 +1,113 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5317VerificationExecution1788199200000 implements MigrationInterface {
name = 'PhaseD5317VerificationExecution1788199200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_finding_verification_visits
ADD COLUMN target_control_on date NULL,
ADD COLUMN outcome varchar(24) NULL,
ADD COLUMN result_notes text NULL,
ADD COLUMN verified_at timestamptz NULL,
ADD COLUMN result_recorded_at timestamptz NULL,
ADD COLUMN result_recorded_by uuid NULL,
ADD COLUMN rescheduled_control_on date NULL,
ADD CONSTRAINT chk_inspection_verification_outcome
CHECK (outcome IS NULL OR outcome IN ('RESOLVED', 'NOT_RESOLVED', 'REQUIRES_NEW_DATE')),
ADD CONSTRAINT fk_inspection_verification_result_user
FOREIGN KEY (result_recorded_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
UPDATE inspection_finding_verification_visits verification_link
SET target_control_on = finding.next_control_on
FROM inspection_findings finding
WHERE finding.id = verification_link.finding_id
AND verification_link.target_control_on IS NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_finding_verification_result
ON inspection_finding_verification_visits (visit_id, outcome, result_recorded_at DESC)
`);
await queryRunner.query(`
ALTER TABLE inspection_finding_evidence
DROP CONSTRAINT chk_inspection_finding_evidence_purpose,
ADD CONSTRAINT chk_inspection_finding_evidence_purpose CHECK (
purpose IN (
'OBSERVATION', 'VERIFICATION', 'COMPANY_RESPONSE',
'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT'
)
),
ADD COLUMN verification_visit_id uuid NULL,
ADD CONSTRAINT fk_inspection_finding_evidence_verification_visit
FOREIGN KEY (verification_visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_finding_evidence_verification_visit
ON inspection_finding_evidence (verification_visit_id, created_at DESC)
`);
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 UPDATE (
outcome,
result_notes,
verified_at,
result_recorded_at,
result_recorded_by,
rescheduled_control_on,
updated_at
) ON inspection_finding_verification_visits TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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(`
REVOKE UPDATE (
outcome,
result_notes,
verified_at,
result_recorded_at,
result_recorded_by,
rescheduled_control_on,
updated_at
) ON inspection_finding_verification_visits FROM ${applicationRole}
`);
await queryRunner.query('DROP INDEX idx_inspection_finding_evidence_verification_visit');
await queryRunner.query(`
ALTER TABLE inspection_finding_evidence
DROP CONSTRAINT fk_inspection_finding_evidence_verification_visit,
DROP CONSTRAINT chk_inspection_finding_evidence_purpose,
DROP COLUMN verification_visit_id,
ADD CONSTRAINT chk_inspection_finding_evidence_purpose CHECK (
purpose IN (
'OBSERVATION', 'COMPANY_RESPONSE',
'COMMUNICATION_ATTACHMENT', 'OTHER_DOCUMENT'
)
)
`);
await queryRunner.query('DROP INDEX idx_inspection_finding_verification_result');
await queryRunner.query(`
ALTER TABLE inspection_finding_verification_visits
DROP CONSTRAINT fk_inspection_verification_result_user,
DROP CONSTRAINT chk_inspection_verification_outcome,
DROP COLUMN rescheduled_control_on,
DROP COLUMN result_recorded_by,
DROP COLUMN result_recorded_at,
DROP COLUMN verified_at,
DROP COLUMN result_notes,
DROP COLUMN outcome,
DROP COLUMN target_control_on
`);
}
}
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissionCode = 'inspection_findings.close';
const rolePermissionValues = `
('inspector', 'inspection_findings.close'),
('supervisor', 'inspection_findings.close'),
('director', 'inspection_findings.close')
`;
export class PhaseD53183OfficeFollowUpHardening1788285600000 implements MigrationInterface {
name = 'PhaseD53183OfficeFollowUpHardening1788285600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ($1, 'Cerrar administrativamente hallazgos después de revisar su seguimiento')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`, [permissionCode]);
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
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 = $1
AND NOT EXISTS (
SELECT 1 FROM role_permissions WHERE permission_id = permissions.id
)
`, [permissionCode]);
}
}
@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseD5319InventoryAliasesOperationalFilters1788372000000 implements MigrationInterface {
name = 'PhaseD5319InventoryAliasesOperationalFilters1788372000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN common_name varchar(200)
`);
await queryRunner.query(`
CREATE INDEX idx_assets_common_name_lower
ON assets (LOWER(common_name))
WHERE common_name IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD COLUMN response_due_basis varchar(32),
ADD COLUMN response_due_days integer,
ADD COLUMN response_due_base_on date,
ADD COLUMN report_notified_on date,
ADD CONSTRAINT ck_inspection_findings_response_due_basis
CHECK (response_due_basis IS NULL OR response_due_basis IN ('FINDING_DATE','REPORT_NOTIFICATION')),
ADD CONSTRAINT ck_inspection_findings_response_due_days
CHECK (response_due_days IS NULL OR response_due_days BETWEEN 0 AND 3650)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS ck_inspection_findings_response_due_days,
DROP CONSTRAINT IF EXISTS ck_inspection_findings_response_due_basis,
DROP COLUMN IF EXISTS report_notified_on,
DROP COLUMN IF EXISTS response_due_base_on,
DROP COLUMN IF EXISTS response_due_days,
DROP COLUMN IF EXISTS response_due_basis
`);
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_common_name_lower');
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS common_name');
}
}
@@ -0,0 +1,61 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseD5320AutomaticWordReport1788458400000 implements MigrationInterface {
name = 'PhaseD5320AutomaticWordReport1788458400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN word_status varchar(24) NOT NULL DEFAULT 'PENDING',
ADD COLUMN word_original_name varchar(255),
ADD COLUMN word_stored_name varchar(255),
ADD COLUMN word_mime_type varchar(120),
ADD COLUMN word_size_bytes integer,
ADD COLUMN word_sha256 char(64),
ADD COLUMN word_generated_at timestamptz,
ADD COLUMN word_error varchar(500)
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_word_status
CHECK (word_status IN ('PENDING', 'READY', 'FAILED')),
ADD CONSTRAINT chk_inspection_reports_word_metadata
CHECK (
(word_status = 'READY'
AND word_original_name IS NOT NULL
AND word_stored_name IS NOT NULL
AND word_mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
AND word_size_bytes > 0
AND word_sha256 ~ '^[0-9a-f]{64}$'
AND word_generated_at IS NOT NULL
AND word_error IS NULL)
OR (word_status = 'PENDING'
AND word_original_name IS NULL
AND word_stored_name IS NULL
AND word_mime_type IS NULL
AND word_size_bytes IS NULL
AND word_sha256 IS NULL
AND word_generated_at IS NULL)
OR word_status = 'FAILED'
)
`);
await queryRunner.query('CREATE INDEX idx_inspection_reports_word_status ON inspection_reports (word_status, generated_at DESC)');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX idx_inspection_reports_word_status');
await queryRunner.query('ALTER TABLE inspection_reports DROP CONSTRAINT chk_inspection_reports_word_metadata');
await queryRunner.query('ALTER TABLE inspection_reports DROP CONSTRAINT chk_inspection_reports_word_status');
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP COLUMN word_error,
DROP COLUMN word_generated_at,
DROP COLUMN word_sha256,
DROP COLUMN word_size_bytes,
DROP COLUMN word_mime_type,
DROP COLUMN word_stored_name,
DROP COLUMN word_original_name,
DROP COLUMN word_status
`);
}
}
@@ -0,0 +1,104 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissions = [
['document_delivery.read', 'Consultar configuración y entregas documentales'],
['document_delivery.manage', 'Configurar destinatarios y reintentar entregas documentales'],
] as const;
export class PhaseD5321DocumentDelivery1788544800000 implements MigrationInterface {
name = 'PhaseD5321DocumentDelivery1788544800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE organization_profiles ADD COLUMN notification_email varchar(320)`);
await queryRunner.query(`
CREATE TABLE institutional_delivery_settings (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
office_email varchar(320),
director_email varchar(320),
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_institutional_delivery_settings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`INSERT INTO institutional_delivery_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
await queryRunner.query(`
CREATE TABLE inspection_act_pdf_artifacts (
act_id uuid PRIMARY KEY,
status varchar(24) NOT NULL DEFAULT 'PENDING',
original_name varchar(255),
stored_name varchar(255),
mime_type varchar(120),
size_bytes integer,
sha256 char(64),
generated_at timestamptz,
error varchar(500),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inspection_act_pdf_artifacts_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE CASCADE,
CONSTRAINT chk_inspection_act_pdf_artifacts_status CHECK (status IN ('PENDING','READY','FAILED')),
CONSTRAINT chk_inspection_act_pdf_artifacts_ready CHECK (
status <> 'READY' OR (
original_name IS NOT NULL AND stored_name IS NOT NULL AND mime_type = 'application/pdf'
AND size_bytes > 0 AND sha256 ~ '^[0-9a-f]{64}$' AND generated_at IS NOT NULL AND error IS NULL
)
)
)
`);
await queryRunner.query(`
CREATE TABLE inspection_document_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
report_id uuid,
document_kind varchar(24) NOT NULL,
recipient_kind varchar(24) NOT NULL,
recipient_asset_id uuid,
recipient_key uuid NOT NULL,
recipient_email varchar(320),
status varchar(32) NOT NULL DEFAULT 'PENDING',
attempts integer NOT NULL DEFAULT 0,
last_attempt_at timestamptz,
sent_at timestamptz,
provider_message_id varchar(255),
last_error varchar(500),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inspection_document_deliveries_act FOREIGN KEY (act_id) REFERENCES inspection_acts(id) ON DELETE CASCADE,
CONSTRAINT fk_inspection_document_deliveries_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT fk_inspection_document_deliveries_recipient_asset FOREIGN KEY (recipient_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CONSTRAINT chk_inspection_document_deliveries_document_kind CHECK (document_kind IN ('ACT_PDF','REPORT_WORD')),
CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR')),
CONSTRAINT chk_inspection_document_deliveries_status CHECK (status IN ('PENDING','WAITING_RECIPIENT','WAITING_TRANSPORT','WAITING_ARTIFACT','SENT','FAILED')),
CONSTRAINT chk_inspection_document_deliveries_report CHECK ((document_kind='REPORT_WORD' AND report_id IS NOT NULL) OR document_kind='ACT_PDF')
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_inspection_document_delivery_role ON inspection_document_deliveries (act_id, document_kind, recipient_kind, recipient_key)`);
await queryRunner.query(`CREATE INDEX idx_inspection_document_deliveries_status ON inspection_document_deliveries (status, created_at DESC)`);
for (const [code, description] of permissions) {
await queryRunner.query(`INSERT INTO permissions (code, description) VALUES ($1,$2) ON CONFLICT (code) DO UPDATE SET description=EXCLUDED.description`, [code, description]);
}
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('admin','document_delivery.read'),('admin','document_delivery.manage'),
('director','document_delivery.read'),('director','document_delivery.manage'),
('supervisor','document_delivery.read')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id FROM mapping
JOIN roles role ON role.code=mapping.role_code
JOIN permissions permission ON permission.code=mapping.permission_code
ON CONFLICT (role_id, permission_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id=p.id AND p.code IN ('document_delivery.read','document_delivery.manage')`);
await queryRunner.query(`DELETE FROM permissions WHERE code IN ('document_delivery.read','document_delivery.manage')`);
await queryRunner.query(`DROP INDEX idx_inspection_document_deliveries_status`);
await queryRunner.query(`DROP INDEX uq_inspection_document_delivery_role`);
await queryRunner.query(`DROP TABLE inspection_document_deliveries`);
await queryRunner.query(`DROP TABLE inspection_act_pdf_artifacts`);
await queryRunner.query(`DROP TABLE institutional_delivery_settings`);
await queryRunner.query(`ALTER TABLE organization_profiles DROP COLUMN notification_email`);
}
}
@@ -0,0 +1,143 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const permissions = [
['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'],
['inspection_reports.review', 'Aprobar la versión vigente del informe'],
['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'],
] as const;
export class PhaseD5322DirectorReportReview1788631200000 implements MigrationInterface {
name = 'PhaseD5322DirectorReportReview1788631200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW',
ADD COLUMN current_revision_number integer NOT NULL DEFAULT 0,
ADD COLUMN approved_revision_id uuid,
ADD COLUMN approved_by uuid,
ADD COLUMN approved_at timestamptz,
ADD COLUMN review_note varchar(1000),
ADD COLUMN signed_at timestamptz,
ADD CONSTRAINT chk_inspection_reports_review_status CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED')),
ADD CONSTRAINT chk_inspection_reports_revision_number CHECK (current_revision_number >= 0),
ADD CONSTRAINT fk_inspection_reports_approved_by FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
CREATE TABLE inspection_report_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
revision_number integer NOT NULL,
source varchar(24) NOT NULL,
original_name varchar(255) NOT NULL,
stored_name varchar(255) NOT NULL,
mime_type varchar(120) NOT NULL,
size_bytes integer NOT NULL,
sha256 char(64) NOT NULL,
change_summary varchar(1000),
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inspection_report_revisions_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT fk_inspection_report_revisions_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT uq_inspection_report_revision_number UNIQUE (report_id, revision_number),
CONSTRAINT chk_inspection_report_revision_number CHECK (revision_number > 0),
CONSTRAINT chk_inspection_report_revision_source CHECK (source IN ('AUTO','DIRECTOR_UPLOAD')),
CONSTRAINT chk_inspection_report_revision_file CHECK (
mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
AND size_bytes > 0
AND sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_report_revisions_report_created ON inspection_report_revisions (report_id, revision_number DESC)`);
await queryRunner.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
)
SELECT
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
COALESCE(word_generated_at, generated_at)
FROM inspection_reports
WHERE word_status = 'READY'
AND word_original_name IS NOT NULL
AND word_stored_name IS NOT NULL
AND word_mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
AND word_size_bytes > 0
AND word_sha256 ~ '^[0-9a-f]{64}$'
ON CONFLICT (report_id, revision_number) DO NOTHING
`);
await queryRunner.query(`
UPDATE inspection_reports report
SET current_revision_number = 1
WHERE EXISTS (
SELECT 1 FROM inspection_report_revisions revision
WHERE revision.report_id = report.id AND revision.revision_number = 1
)
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT fk_inspection_reports_approved_revision
FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
CREATE TABLE inspection_report_signatures (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL UNIQUE,
revision_id uuid NOT NULL UNIQUE,
signed_by uuid NOT NULL,
signed_at timestamptz NOT NULL,
confirmation_text varchar(500) NOT NULL,
signature_payload jsonb NOT NULL,
signature_sha256 char(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inspection_report_signatures_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT fk_inspection_report_signatures_revision FOREIGN KEY (revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_report_signatures_user FOREIGN KEY (signed_by) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT chk_inspection_report_signature_sha CHECK (signature_sha256 ~ '^[0-9a-f]{64}$')
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_report_signatures_signed_at ON inspection_report_signatures (signed_at DESC)`);
for (const [code, description] of permissions) {
await queryRunner.query(
`INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`,
[code, description],
);
}
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('director', 'inspection_reports.revise'),
('director', 'inspection_reports.review'),
('director', 'inspection_reports.sign_final')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
JOIN roles role ON role.code = mapping.role_code
JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT (role_id, permission_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code IN ('inspection_reports.revise','inspection_reports.review','inspection_reports.sign_final')`);
await queryRunner.query(`DELETE FROM permissions WHERE code IN ('inspection_reports.revise','inspection_reports.review','inspection_reports.sign_final')`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_signatures`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP CONSTRAINT IF EXISTS fk_inspection_reports_approved_revision`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_revisions`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS fk_inspection_reports_approved_by,
DROP CONSTRAINT IF EXISTS chk_inspection_reports_revision_number,
DROP CONSTRAINT IF EXISTS chk_inspection_reports_review_status,
DROP COLUMN IF EXISTS signed_at,
DROP COLUMN IF EXISTS review_note,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS approved_revision_id,
DROP COLUMN IF EXISTS current_revision_number,
DROP COLUMN IF EXISTS review_status
`);
}
}
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('\"', '\"\"')}"`;
}
export class PhaseD5323FieldDiscoveries1788717600000 implements MigrationInterface {
name = 'PhaseD5323FieldDiscoveries1788717600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE asset_field_discoveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL UNIQUE,
visit_id uuid NOT NULL,
status varchar(24) NOT NULL DEFAULT 'PENDING',
discovery_notes text,
observed_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by uuid NOT NULL,
reviewed_at timestamptz,
reviewed_by uuid,
review_notes text,
matched_asset_id uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_asset_field_discoveries_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_discoveries_visit FOREIGN KEY (visit_id) REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_discoveries_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_discoveries_reviewed_by FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_field_discoveries_matched_asset FOREIGN KEY (matched_asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT chk_asset_field_discoveries_status CHECK (status IN ('PENDING','APPROVED','MATCHED','REJECTED')),
CONSTRAINT chk_asset_field_discoveries_match CHECK (
(status = 'MATCHED' AND matched_asset_id IS NOT NULL)
OR (status <> 'MATCHED' AND matched_asset_id IS NULL)
),
CONSTRAINT chk_asset_field_discoveries_not_self_match CHECK (matched_asset_id IS NULL OR matched_asset_id <> asset_id)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_status_created ON asset_field_discoveries (status, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_visit ON asset_field_discoveries (visit_id, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_asset_field_discoveries_matched ON asset_field_discoveries (matched_asset_id) WHERE matched_asset_id IS NOT NULL`);
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 asset_field_discoveries TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE asset_field_discoveries FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS asset_field_discoveries`);
}
}
@@ -0,0 +1,187 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5324AssetContextHistory1788804000000 implements MigrationInterface {
name = 'PhaseD5324AssetContextHistory1788804000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE asset_context_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
parent_id uuid,
operational_area_id uuid,
operator_company_id uuid,
valid_from timestamptz NOT NULL,
valid_until timestamptz,
change_reason text NOT NULL,
end_reason text,
asset_version_number integer NOT NULL,
source varchar(32) NOT NULL DEFAULT 'WEB',
request_id varchar(128),
created_by uuid,
ended_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at timestamptz,
CONSTRAINT fk_asset_context_history_asset FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_area FOREIGN KEY (operational_area_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_company FOREIGN KEY (operator_company_id) REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_context_history_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_asset_context_history_ended_by FOREIGN KEY (ended_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_asset_context_history_dates CHECK (valid_until IS NULL OR valid_until >= valid_from),
CONSTRAINT chk_asset_context_history_context_pair CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
),
CONSTRAINT chk_asset_context_history_reason CHECK (length(btrim(change_reason)) >= 5),
CONSTRAINT chk_asset_context_history_version CHECK (asset_version_number >= 1)
)
`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_context_history_active ON asset_context_history (asset_id) WHERE valid_until IS NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_asset_period ON asset_context_history (asset_id, valid_from DESC, valid_until)`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_area_period ON asset_context_history (operational_area_id, valid_from DESC) WHERE operational_area_id IS NOT NULL`);
await queryRunner.query(`CREATE INDEX idx_asset_context_history_company_period ON asset_context_history (operator_company_id, valid_from DESC) WHERE operator_company_id IS NOT NULL`);
await queryRunner.query(`
WITH ordered AS (
SELECT
version.asset_id,
version.version_number,
version.occurred_at,
NULLIF(version.snapshot #>> '{parent,id}', '')::uuid AS parent_id,
NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid AS operational_area_id,
NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid AS operator_company_id,
version.actor_user_id,
version.source,
version.request_id,
ROW_NUMBER() OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS row_number,
LAG(NULLIF(version.snapshot #>> '{parent,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_parent_id,
LAG(NULLIF(version.snapshot #>> '{operationalArea,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operational_area_id,
LAG(NULLIF(version.snapshot #>> '{operatorCompany,id}', '')::uuid) OVER (PARTITION BY version.asset_id ORDER BY version.occurred_at, version.version_number) AS previous_operator_company_id
FROM asset_versions version
), changes AS (
SELECT *
FROM ordered
WHERE row_number = 1
OR parent_id IS DISTINCT FROM previous_parent_id
OR operational_area_id IS DISTINCT FROM previous_operational_area_id
OR operator_company_id IS DISTINCT FROM previous_operator_company_id
), bounded AS (
SELECT
changes.*,
LEAD(changes.occurred_at) OVER (PARTITION BY changes.asset_id ORDER BY changes.occurred_at, changes.version_number) AS valid_until
FROM changes
)
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, valid_until, change_reason, asset_version_number,
source, request_id, created_by
)
SELECT
bounded.asset_id,
bounded.parent_id,
bounded.operational_area_id,
bounded.operator_company_id,
bounded.occurred_at,
bounded.valid_until,
CASE WHEN bounded.row_number = 1
THEN 'Contexto inicial reconstruido desde el historial versionado'
ELSE 'Cambio de contexto reconstruido desde el historial versionado'
END,
bounded.version_number,
bounded.source,
bounded.request_id,
bounded.actor_user_id
FROM bounded
ORDER BY bounded.asset_id, bounded.occurred_at, bounded.version_number
`);
await queryRunner.query(`
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, change_reason, asset_version_number, source, created_by
)
SELECT
asset.id, asset.parent_id, asset.operational_area_id, asset.operator_company_id,
asset.created_at, 'Contexto inicial incorporado al historial', GREATEST(asset.current_version, 1),
'SYSTEM', asset.created_by
FROM assets asset
WHERE NOT EXISTS (
SELECT 1 FROM asset_context_history history WHERE history.asset_id = asset.id
)
`);
await queryRunner.query(`
WITH mismatched AS (
SELECT
history.id AS history_id,
asset.id AS asset_id,
asset.parent_id,
asset.operational_area_id,
asset.operator_company_id,
GREATEST(asset.updated_at, history.valid_from) AS effective_from,
GREATEST(asset.current_version, 1) AS asset_version_number,
asset.updated_by
FROM asset_context_history history
JOIN assets asset ON asset.id=history.asset_id
WHERE history.valid_until IS NULL
AND (
history.parent_id IS DISTINCT FROM asset.parent_id
OR history.operational_area_id IS DISTINCT FROM asset.operational_area_id
OR history.operator_company_id IS DISTINCT FROM asset.operator_company_id
)
), closed AS (
UPDATE asset_context_history history
SET valid_until=mismatched.effective_from,
end_reason='Sincronización con contexto vigente al instalar D5.3.24',
ended_at=CURRENT_TIMESTAMP
FROM mismatched
WHERE history.id=mismatched.history_id
RETURNING mismatched.*
)
INSERT INTO asset_context_history (
asset_id, parent_id, operational_area_id, operator_company_id,
valid_from, change_reason, asset_version_number, source, created_by
)
SELECT
closed.asset_id, closed.parent_id, closed.operational_area_id, closed.operator_company_id,
closed.effective_from, 'Contexto vigente sincronizado al instalar D5.3.24',
closed.asset_version_number, 'SYSTEM', closed.updated_by
FROM closed
`);
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES ('assets.manage_context', 'Cambiar jerarquía, Área u Operadora preservando el historial temporal')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('admin', 'assets.manage_context'),
('supervisor', 'assets.manage_context')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
JOIN roles role ON role.code = mapping.role_code
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 applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE asset_context_history TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE asset_context_history FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM role_permissions rp USING permissions p WHERE rp.permission_id = p.id AND p.code = 'assets.manage_context'`);
await queryRunner.query(`DELETE FROM permissions WHERE code = 'assets.manage_context'`);
await queryRunner.query(`DROP TABLE IF EXISTS asset_context_history`);
}
}
@@ -0,0 +1,190 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD5325VerificationEventHistory1788890400000 implements MigrationInterface {
name = 'PhaseD5325VerificationEventHistory1788890400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_finding_verification_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL,
verification_visit_id uuid NULL,
event_type varchar(40) NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
target_control_on date NULL,
previous_control_on date NULL,
next_control_on date NULL,
outcome varchar(24) NULL,
notes text NULL,
actor_user_id uuid NULL,
actor_username varchar(80) NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inspection_finding_verification_event_type CHECK (
event_type IN (
'CONTROL_DATE_DEFINED', 'CONTROL_DATE_CHANGED', 'CONTROL_DATE_CLEARED',
'VISIT_PLANNED', 'RESULT_RECORDED'
)
),
CONSTRAINT chk_inspection_finding_verification_event_outcome CHECK (
outcome IS NULL OR outcome IN ('RESOLVED', 'NOT_RESOLVED', 'REQUIRES_NEW_DATE')
),
CONSTRAINT fk_inspection_finding_verification_event_finding FOREIGN KEY (finding_id)
REFERENCES inspection_findings(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_finding_verification_event_visit FOREIGN KEY (verification_visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_finding_verification_event_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_finding_verification_events_finding
ON inspection_finding_verification_events (finding_id, occurred_at DESC, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_finding_verification_events_visit
ON inspection_finding_verification_events (verification_visit_id, occurred_at DESC)
`);
await queryRunner.query(`
WITH ordered AS (
SELECT
version.finding_id,
version.event,
version.created_at,
version.actor_user_id,
version.actor_username,
NULLIF(version.snapshot ->> 'nextControlOn', '')::date AS next_control_on,
NULLIF(LAG(version.snapshot ->> 'nextControlOn') OVER (
PARTITION BY version.finding_id ORDER BY version.version_number
), '')::date AS previous_control_on
FROM inspection_finding_versions version
)
INSERT INTO inspection_finding_verification_events (
finding_id, event_type, occurred_at, target_control_on,
previous_control_on, next_control_on, notes, actor_user_id, actor_username
)
SELECT
ordered.finding_id,
CASE
WHEN ordered.next_control_on IS NULL THEN 'CONTROL_DATE_CLEARED'
WHEN ordered.previous_control_on IS NULL THEN 'CONTROL_DATE_DEFINED'
ELSE 'CONTROL_DATE_CHANGED'
END,
ordered.created_at,
ordered.next_control_on,
ordered.previous_control_on,
ordered.next_control_on,
'Reconstruido desde historial versionado de seguimiento',
ordered.actor_user_id,
ordered.actor_username
FROM ordered
WHERE ordered.event = 'FOLLOW_UP_UPDATED'
AND ordered.next_control_on IS DISTINCT FROM ordered.previous_control_on
`);
await queryRunner.query(`
INSERT INTO inspection_finding_verification_events (
finding_id, verification_visit_id, event_type, occurred_at,
target_control_on, next_control_on, notes, actor_user_id
)
SELECT
verification_link.finding_id,
verification_link.visit_id,
'VISIT_PLANNED',
verification_link.created_at,
verification_link.target_control_on,
verification_link.target_control_on,
'Visita de verificación reconstruida desde vínculo histórico',
verification_link.linked_by
FROM inspection_finding_verification_visits verification_link
`);
await queryRunner.query(`
INSERT INTO inspection_finding_verification_events (
finding_id, verification_visit_id, event_type, occurred_at,
target_control_on, previous_control_on, next_control_on,
outcome, notes, actor_user_id
)
SELECT
verification_link.finding_id,
verification_link.visit_id,
'RESULT_RECORDED',
COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at),
verification_link.target_control_on,
verification_link.target_control_on,
verification_link.rescheduled_control_on,
verification_link.outcome,
verification_link.result_notes,
verification_link.result_recorded_by
FROM inspection_finding_verification_visits verification_link
WHERE verification_link.outcome IS NOT NULL
`);
await queryRunner.query(`
INSERT INTO inspection_finding_verification_events (
finding_id, verification_visit_id, event_type, occurred_at,
target_control_on, previous_control_on, next_control_on,
notes, actor_user_id
)
SELECT
verification_link.finding_id,
verification_link.visit_id,
CASE WHEN verification_link.rescheduled_control_on IS NULL
THEN 'CONTROL_DATE_CLEARED'
ELSE 'CONTROL_DATE_CHANGED'
END,
COALESCE(verification_link.result_recorded_at, verification_link.verified_at, verification_link.updated_at),
verification_link.rescheduled_control_on,
verification_link.target_control_on,
verification_link.rescheduled_control_on,
CASE WHEN verification_link.rescheduled_control_on IS NULL
THEN 'La verificación consumió la fecha prevista sin fijar un nuevo control'
ELSE 'Nueva fecha definida como resultado de la verificación'
END,
verification_link.result_recorded_by
FROM inspection_finding_verification_visits verification_link
WHERE verification_link.outcome IS NOT NULL
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION protect_inspection_verification_result_immutability()
RETURNS trigger AS $$
BEGIN
IF OLD.result_recorded_at IS NOT NULL AND (
NEW.outcome IS DISTINCT FROM OLD.outcome
OR NEW.result_notes IS DISTINCT FROM OLD.result_notes
OR NEW.verified_at IS DISTINCT FROM OLD.verified_at
OR NEW.result_recorded_at IS DISTINCT FROM OLD.result_recorded_at
OR NEW.result_recorded_by IS DISTINCT FROM OLD.result_recorded_by
OR NEW.rescheduled_control_on IS DISTINCT FROM OLD.rescheduled_control_on
) THEN
RAISE EXCEPTION 'El resultado de verificación ya registrado es inmutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_verification_result_immutable
BEFORE UPDATE ON inspection_finding_verification_visits
FOR EACH ROW
EXECUTE FUNCTION protect_inspection_verification_result_immutability()
`);
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 ON TABLE inspection_finding_verification_events TO ${applicationRole}`);
await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE inspection_finding_verification_events FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inspection_verification_result_immutable ON inspection_finding_verification_visits');
await queryRunner.query('DROP FUNCTION IF EXISTS protect_inspection_verification_result_immutability()');
await queryRunner.query('DROP TABLE inspection_finding_verification_events');
}
}
@@ -0,0 +1,209 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD54ContextualFindingCatalog1788976800000 implements MigrationInterface {
name = 'PhaseD54ContextualFindingCatalog1788976800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE finding_catalog_items
ADD COLUMN suggested_severity smallint,
ADD CONSTRAINT chk_finding_catalog_items_suggested_severity
CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10)
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD COLUMN suggested_severity smallint,
ADD COLUMN severity smallint,
ADD CONSTRAINT chk_inspection_findings_suggested_severity
CHECK (suggested_severity IS NULL OR suggested_severity BETWEEN 1 AND 10),
ADD CONSTRAINT chk_inspection_findings_severity
CHECK (severity IS NULL OR severity BETWEEN 1 AND 10)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_asset_type_profiles (
asset_type_id uuid PRIMARY KEY,
reason text NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_finding_catalog_asset_type_profiles_reason CHECK (char_length(btrim(reason)) >= 5),
CONSTRAINT fk_finding_catalog_asset_type_profiles_type FOREIGN KEY (asset_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_asset_type_profiles_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_asset_type_profiles_updated
ON finding_catalog_asset_type_profiles (updated_at DESC)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_item_asset_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
catalog_item_id uuid NOT NULL,
asset_type_id uuid NOT NULL,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_item_asset_types_pair UNIQUE (catalog_item_id, asset_type_id),
CONSTRAINT fk_finding_catalog_item_asset_types_item FOREIGN KEY (catalog_item_id)
REFERENCES finding_catalog_items(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_item_asset_types_type FOREIGN KEY (asset_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_item_asset_types_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_item_asset_types_type
ON finding_catalog_item_asset_types (asset_type_id, catalog_item_id)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_asset_overrides (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
catalog_item_id uuid NOT NULL,
is_enabled boolean NOT NULL,
reason text NOT NULL,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_asset_overrides_pair UNIQUE (asset_id, catalog_item_id),
CONSTRAINT chk_finding_catalog_asset_overrides_reason CHECK (char_length(btrim(reason)) >= 5),
CONSTRAINT fk_finding_catalog_asset_overrides_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_asset_overrides_item FOREIGN KEY (catalog_item_id)
REFERENCES finding_catalog_items(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_asset_overrides_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_finding_catalog_asset_overrides_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_asset_overrides_asset
ON finding_catalog_asset_overrides (asset_id)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_proposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL,
asset_id uuid NOT NULL,
asset_type_id uuid NOT NULL,
proposed_title varchar(500) NOT NULL,
proposed_legal_basis text,
proposed_severity smallint,
description text NOT NULL,
status varchar(20) NOT NULL DEFAULT 'PENDING',
resolved_catalog_item_id uuid,
office_notes text,
reviewed_by uuid,
reviewed_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_proposals_finding UNIQUE (finding_id),
CONSTRAINT chk_finding_catalog_proposals_status CHECK (status IN ('PENDING', 'MATCHED', 'REJECTED')),
CONSTRAINT chk_finding_catalog_proposals_severity
CHECK (proposed_severity IS NULL OR proposed_severity BETWEEN 1 AND 10),
CONSTRAINT fk_finding_catalog_proposals_finding FOREIGN KEY (finding_id)
REFERENCES inspection_findings(id) ON DELETE RESTRICT,
CONSTRAINT fk_finding_catalog_proposals_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_finding_catalog_proposals_asset_type FOREIGN KEY (asset_type_id)
REFERENCES asset_types(id) ON DELETE RESTRICT,
CONSTRAINT fk_finding_catalog_proposals_resolved_item FOREIGN KEY (resolved_catalog_item_id)
REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
CONSTRAINT fk_finding_catalog_proposals_reviewed_by FOREIGN KEY (reviewed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_finding_catalog_proposals_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_proposals_status
ON finding_catalog_proposals (status, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_proposals_asset_type
ON finding_catalog_proposals (asset_type_id, status, created_at DESC)
`);
await queryRunner.query(`
INSERT INTO finding_catalog_proposals (
finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis,
proposed_severity, description, status, created_by, created_at, updated_at
)
SELECT
finding.id,
finding.asset_id,
asset.asset_type_id,
finding.title,
finding.legal_basis,
finding.severity,
finding.description,
'PENDING',
finding.created_by,
finding.created_at,
finding.updated_at
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE finding.catalog_item_id IS NULL
ON CONFLICT (finding_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 finding_catalog_asset_type_profiles
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT, DELETE ON TABLE finding_catalog_item_asset_types
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE finding_catalog_asset_overrides
TO ${applicationRole}
`);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE ON TABLE finding_catalog_proposals
TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE finding_catalog_proposals');
await queryRunner.query('DROP TABLE finding_catalog_asset_overrides');
await queryRunner.query('DROP TABLE finding_catalog_item_asset_types');
await queryRunner.query('DROP TABLE finding_catalog_asset_type_profiles');
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT chk_inspection_findings_severity,
DROP CONSTRAINT chk_inspection_findings_suggested_severity,
DROP COLUMN severity,
DROP COLUMN suggested_severity
`);
await queryRunner.query(`
ALTER TABLE finding_catalog_items
DROP CONSTRAINT chk_finding_catalog_items_suggested_severity,
DROP COLUMN suggested_severity
`);
}
}
@@ -0,0 +1,219 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseD55WebPlanningChecklist1789063200000 implements MigrationInterface {
name = 'PhaseD55WebPlanningChecklist1789063200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_visits
ADD COLUMN operational_area_id uuid,
ADD COLUMN operator_company_id uuid,
ADD COLUMN checklist_generation integer NOT NULL DEFAULT 0,
ADD COLUMN checklist_generated_at timestamptz,
ADD CONSTRAINT fk_inspection_visits_operational_area FOREIGN KEY (operational_area_id)
REFERENCES assets(id) ON DELETE RESTRICT,
ADD CONSTRAINT fk_inspection_visits_operator_company FOREIGN KEY (operator_company_id)
REFERENCES assets(id) ON DELETE RESTRICT,
ADD CONSTRAINT chk_inspection_visits_checklist_generation CHECK (checklist_generation >= 0),
ADD CONSTRAINT chk_inspection_visits_operational_context_pair CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visits_operational_context ON inspection_visits (operational_area_id, operator_company_id)`);
await queryRunner.query(`
WITH context AS (
SELECT
visit.id,
COALESCE(
(MAX(scope.id::text) FILTER (WHERE scope_type.operational_role = 'AREA'))::uuid,
MIN(asset.operational_area_id::text)::uuid
) AS area_id,
CASE
WHEN COUNT(DISTINCT asset.operator_company_id) FILTER (WHERE asset.operator_company_id IS NOT NULL) = 1
THEN MIN(asset.operator_company_id::text)::uuid
ELSE NULL
END AS company_id
FROM inspection_visits visit
LEFT JOIN assets scope ON scope.id = visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id
LEFT JOIN inspection_visit_assets link ON link.visit_id = visit.id AND link.included = true
LEFT JOIN assets asset ON asset.id = link.asset_id
GROUP BY visit.id
)
UPDATE inspection_visits visit
SET operational_area_id = context.area_id,
operator_company_id = context.company_id
FROM context
WHERE context.id = visit.id
AND context.area_id IS NOT NULL
AND context.company_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_visit_assets
ADD COLUMN planning_source varchar(24) NOT NULL DEFAULT 'LEGACY',
ADD COLUMN exclusion_reason text,
ADD COLUMN excluded_by uuid,
ADD COLUMN excluded_at timestamptz,
ADD CONSTRAINT fk_inspection_visit_assets_excluded_by FOREIGN KEY (excluded_by)
REFERENCES users(id) ON DELETE SET NULL,
ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK (
planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION')
)
`);
await queryRunner.query(`
UPDATE inspection_visit_assets link
SET planning_source = 'VERIFICATION'
WHERE EXISTS (
SELECT 1
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
WHERE verification_link.visit_id = link.visit_id
AND finding.asset_id = link.asset_id
)
`);
await queryRunner.query(`
UPDATE inspection_visit_assets
SET exclusion_reason = 'Exclusión histórica previa a D5.5',
excluded_by = added_by,
excluded_at = updated_at
WHERE included = false
AND exclusion_reason IS NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_visit_assets
ADD CONSTRAINT chk_inspection_visit_assets_exclusion_state CHECK (
(included = true AND exclusion_reason IS NULL AND excluded_at IS NULL)
OR (included = false AND LENGTH(TRIM(COALESCE(exclusion_reason, ''))) >= 10 AND excluded_at IS NOT NULL)
)
`);
await queryRunner.query(`
CREATE TABLE inspection_visit_checklist_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
visit_id uuid NOT NULL,
generation_number integer NOT NULL,
finding_id uuid NOT NULL,
asset_id uuid NOT NULL,
item_kind varchar(32) NOT NULL,
reference_on date,
finding_status varchar(20) NOT NULL,
finding_code varchar(40) NOT NULL,
finding_title varchar(500) NOT NULL,
severity smallint,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inspection_visit_checklist_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_checklist_finding FOREIGN KEY (finding_id)
REFERENCES inspection_findings(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_checklist_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT uq_inspection_visit_checklist_generation_finding UNIQUE (visit_id, generation_number, finding_id),
CONSTRAINT chk_inspection_visit_checklist_generation CHECK (generation_number > 0),
CONSTRAINT chk_inspection_visit_checklist_kind CHECK (
item_kind IN ('ANTECEDENT','COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
),
CONSTRAINT chk_inspection_visit_checklist_severity CHECK (severity IS NULL OR severity BETWEEN 1 AND 10)
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_current ON inspection_visit_checklist_items (visit_id, generation_number, item_kind)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_checklist_asset ON inspection_visit_checklist_items (visit_id, generation_number, asset_id)`);
await queryRunner.query(`
CREATE TABLE inspection_visit_asset_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
visit_id uuid NOT NULL,
asset_id uuid NOT NULL,
event_type varchar(32) NOT NULL,
reason text,
actor_user_id uuid,
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata jsonb,
CONSTRAINT fk_inspection_visit_asset_events_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_asset_events_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_inspection_visit_asset_events_actor FOREIGN KEY (actor_user_id)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_inspection_visit_asset_events_type CHECK (
event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED')
),
CONSTRAINT chk_inspection_visit_asset_events_exclusion_reason CHECK (
event_type <> 'EXCLUDED' OR LENGTH(TRIM(COALESCE(reason, ''))) >= 10
)
)
`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_visit ON inspection_visit_asset_events (visit_id, occurred_at, id)`);
await queryRunner.query(`CREATE INDEX idx_inspection_visit_asset_events_asset ON inspection_visit_asset_events (asset_id, occurred_at, id)`);
await queryRunner.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, actor_user_id, occurred_at, metadata
)
SELECT
link.visit_id,
link.asset_id,
CASE WHEN link.planning_source = 'VERIFICATION' THEN 'VERIFICATION_INCLUDED' ELSE 'LEGACY_INCLUDED' END,
link.added_by,
link.created_at,
JSONB_BUILD_OBJECT('backfill', true, 'planningSource', link.planning_source)
FROM inspection_visit_assets link
`);
await queryRunner.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, occurred_at, metadata
)
SELECT
link.visit_id,
link.asset_id,
'EXCLUDED',
link.exclusion_reason,
link.excluded_by,
COALESCE(link.excluded_at, link.updated_at),
JSONB_BUILD_OBJECT('backfill', true)
FROM inspection_visit_assets link
WHERE link.included = false
`);
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 ON TABLE
inspection_visit_checklist_items, inspection_visit_asset_events
TO ${applicationRole}
`);
await queryRunner.query(`
REVOKE UPDATE, DELETE ON TABLE
inspection_visit_checklist_items, inspection_visit_asset_events
FROM ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE inspection_visit_asset_events`);
await queryRunner.query(`DROP TABLE inspection_visit_checklist_items`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_exclusion_state`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT fk_inspection_visit_assets_excluded_by`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_at`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN excluded_by`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN exclusion_reason`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP COLUMN planning_source`);
await queryRunner.query(`DROP INDEX idx_inspection_visits_operational_context`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_operational_context_pair`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT chk_inspection_visits_checklist_generation`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operator_company`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP CONSTRAINT fk_inspection_visits_operational_area`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generated_at`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN checklist_generation`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operator_company_id`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN operational_area_id`);
}
}
File diff suppressed because it is too large Load Diff