chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
+234
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
+89
@@ -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');
|
||||
}
|
||||
}
|
||||
+354
@@ -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');
|
||||
}
|
||||
}
|
||||
+137
@@ -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`);
|
||||
}
|
||||
}
|
||||
+125
@@ -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`);
|
||||
}
|
||||
}
|
||||
+92
@@ -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')
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
+89
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
+49
@@ -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]);
|
||||
}
|
||||
}
|
||||
+42
@@ -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`);
|
||||
}
|
||||
}
|
||||
+190
@@ -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');
|
||||
}
|
||||
}
|
||||
+209
@@ -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
Reference in New Issue
Block a user