chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
import 'reflect-metadata';
import { Writable } from 'node:stream';
import { createInterface } from 'node:readline/promises';
import { DataSource } from 'typeorm';
import { PasswordService } from '../auth/services/password.service';
import {
AuditAction,
AuditEvent,
AuditSource,
PHASE_A_ENTITIES,
Role,
User,
UserRole,
UserStatus,
} from '../database/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;
}
function createRuntimeDataSource(): DataSource {
return new DataSource({
type: 'postgres',
host: requiredEnvironmentVariable('DB_HOST'),
port: databasePort(),
database: requiredEnvironmentVariable('DB_NAME'),
username: requiredEnvironmentVariable('DB_APP_USER'),
password: requiredEnvironmentVariable('DB_APP_PASSWORD'),
entities: PHASE_A_ENTITIES,
synchronize: false,
migrationsRun: false,
logging: false,
applicationName: 'dhv2-bootstrap-admin',
connectTimeoutMS: 5000,
});
}
class MutedOutput extends Writable {
muted = false;
override _write(
chunk: Buffer | string,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): void {
if (!this.muted) process.stdout.write(chunk);
callback();
}
}
function validatedText(
label: string,
value: string,
maximumLength: number,
): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${label} is required`);
if (trimmed.length > maximumLength) {
throw new Error(`${label} is too long`);
}
return trimmed;
}
async function main(): Promise<void> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('Bootstrap admin must run in an interactive terminal');
}
const dataSource = createRuntimeDataSource();
await dataSource.initialize();
try {
const existing = (await dataSource.query(`
SELECT COUNT(*)::integer AS count
FROM user_roles user_role
INNER JOIN roles role ON role.id = user_role.role_id
WHERE role.code = 'admin'
`)) as Array<{ count: number }>;
if (Number(existing[0]?.count ?? 0) > 0) {
throw new Error('An administrator already exists; bootstrap was cancelled');
}
const output = new MutedOutput();
const prompt = createInterface({
input: process.stdin,
output,
terminal: true,
});
let username = '';
let email = '';
let firstName = '';
let lastName = '';
let password = '';
let passwordConfirmation = '';
let mustChangeAnswer = '';
try {
username = validatedText(
'Username',
await prompt.question('Username: '),
80,
).toLowerCase();
if (!/^[a-z0-9._-]{3,80}$/.test(username)) {
throw new Error(
'Username must contain 3-80 letters, numbers, dots, underscores or hyphens',
);
}
email = (await prompt.question('Email (optional): ')).trim().toLowerCase();
if (
email &&
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
) {
throw new Error('Email is invalid');
}
firstName = validatedText(
'First name',
await prompt.question('Nombre: '),
120,
);
lastName = validatedText(
'Last name',
await prompt.question('Apellido: '),
120,
);
const askSecret = async (label: string): Promise<string> => {
output.muted = false;
const pending = prompt.question(label);
output.muted = true;
const answer = await pending;
output.muted = false;
process.stdout.write('\n');
return answer;
};
password = await askSecret('Contraseña (mínimo 12 caracteres): ');
passwordConfirmation = await askSecret('Repetir contraseña: ');
mustChangeAnswer = (
await prompt.question('¿Forzar cambio en el primer ingreso? [S/n]: ')
)
.trim()
.toLowerCase();
} finally {
prompt.close();
}
if (password.length < 12 || password.length > 128) {
throw new Error('Password must contain between 12 and 128 characters');
}
if (password !== passwordConfirmation) {
throw new Error('Password confirmation does not match');
}
const mustChangePassword = !['n', 'no'].includes(mustChangeAnswer);
const passwordHash = await new PasswordService().hash(password);
await dataSource.transaction(async (manager) => {
await manager.query(
"SELECT pg_advisory_xact_lock(hashtext('dhv2-bootstrap-admin'))",
);
const adminCount = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM user_roles user_role
INNER JOIN roles role ON role.id = user_role.role_id
WHERE role.code = 'admin'
`)) as Array<{ count: number }>;
if (Number(adminCount[0]?.count ?? 0) > 0) {
throw new Error('An administrator already exists; bootstrap was cancelled');
}
const duplicate = (await manager.query(
`
SELECT 1
FROM users
WHERE LOWER(username) = $1
OR ($2::text IS NOT NULL AND LOWER(email) = $2)
LIMIT 1
`,
[username, email || null],
)) as unknown[];
if (duplicate.length > 0) {
throw new Error('Username or email already exists');
}
const adminRole = await manager.getRepository(Role).findOne({
where: { code: 'admin' },
});
if (!adminRole) throw new Error('Admin role seed is missing');
const user = manager.getRepository(User).create({
username,
email: email || null,
passwordHash,
firstName,
lastName,
status: UserStatus.ACTIVE,
mustChangePassword,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
passwordChangedAt: new Date(),
createdBy: null,
updatedBy: null,
});
await manager.getRepository(User).save(user);
await manager.getRepository(UserRole).save(
manager.getRepository(UserRole).create({
userId: user.id,
roleId: adminRole.id,
assignedAt: new Date(),
assignedBy: user.id,
}),
);
await manager.getRepository(AuditEvent).save(
manager.getRepository(AuditEvent).create({
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.SYSTEM_BOOTSTRAP_ADMIN_CREATED,
entityType: 'user',
entityId: user.id,
requestId: null,
source: AuditSource.SYSTEM,
ip: null,
userAgent: null,
beforeData: null,
afterData: {
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
status: user.status,
mustChangePassword: user.mustChangePassword,
roles: ['admin'],
},
metadata: { bootstrap: true },
}),
);
process.stdout.write(`Administrador creado: ${user.username}\n`);
});
} finally {
await dataSource.destroy();
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown bootstrap error';
process.stderr.write(`Bootstrap cancelado: ${message}\n`);
process.exitCode = 1;
});
+216
View File
@@ -0,0 +1,216 @@
import 'reflect-metadata';
import { randomUUID } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { AssetsService } from '../asset-master/assets.service';
import { AssetOperationalRelationsService } from '../asset-master/asset-operational-relations.service';
import { AssetProvenanceService } from '../asset-master/asset-provenance.service';
import { AssetRegistryService } from '../asset-master/asset-registry.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AreaOrganizationRole,
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetSourceDocumentRelationType,
OrganizationKind,
SourceDocumentType,
} from '../database/entities';
const DEMO_PREFIX = 'DEV-DEMO-';
const SOURCE_REFERENCE = 'DH-DEV-DEMO:ITN124/2026';
function fakeRequest(): RequestWithContext {
return {
requestId: `dev-seed-${randomUUID()}`,
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
header: () => 'dhv2-dev-seed',
} as unknown as RequestWithContext;
}
async function buildPrincipal(dataSource: DataSource): Promise<AuthPrincipal> {
const [user] = (await dataSource.query(`
SELECT id, username, first_name AS "firstName", last_name AS "lastName", email,
must_change_password AS "mustChangePassword"
FROM users
WHERE status = 'ACTIVE'
ORDER BY CASE WHEN username = 'admin' THEN 0 ELSE 1 END, created_at
LIMIT 1
`)) as Array<{ id: string; username: string; firstName: string; lastName: string; email: string | null; mustChangePassword: boolean }>;
if (!user) throw new Error('No hay un usuario activo para registrar la auditoría del demo');
const roles = (await dataSource.query(`
SELECT role.code FROM user_roles ur JOIN roles role ON role.id = ur.role_id WHERE ur.user_id = $1
`, [user.id])) as Array<{ code: string }>;
const permissions = (await dataSource.query(`
SELECT DISTINCT permission.code
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions permission ON permission.id = rp.permission_id
WHERE ur.user_id = $1
`, [user.id])) as Array<{ code: string }>;
return {
userId: user.id,
username: user.username,
sessionId: 'development-seed',
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
mustChangePassword: user.mustChangePassword,
roles: roles.map((row) => row.code),
permissions: permissions.map((row) => row.code),
transport: 'cookie',
};
}
async function main(): Promise<void> {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
const dataSource = app.get(DataSource);
const assets = app.get(AssetsService);
const relations = app.get(AssetOperationalRelationsService);
const provenance = app.get(AssetProvenanceService);
const registry = app.get(AssetRegistryService);
const principal = await buildPrincipal(dataSource);
const request = fakeRequest();
const [existing] = (await dataSource.query(
`SELECT COUNT(*)::integer AS count FROM assets WHERE code LIKE $1 OR source_reference = $2`,
[`${DEMO_PREFIX}%`, SOURCE_REFERENCE],
)) as Array<{ count: number }>;
if (Number(existing?.count ?? 0) > 0) {
throw new Error('Ya existen datos DEV-DEMO. Ejecutá primero scripts/dev-clean-demo.sh si querés reconstruir el ejemplo.');
}
const typeRows = (await dataSource.query(`
SELECT id, code FROM asset_types WHERE code = ANY($1::varchar[]) AND is_active = true
`, [['area', 'empresa', 'planta', 'tanque']])) as Array<{ id: string; code: string }>;
const types = new Map(typeRows.map((row) => [row.code, row.id]));
const missing = ['area', 'empresa', 'planta', 'tanque'].filter((code) => !types.has(code));
if (missing.length) {
throw new Error(`Faltan tipos técnicos (${missing.join(', ')}). En Maestro > Tipos y atributos ejecutá “Completar catálogo técnico”.`);
}
const attributeRows = (await dataSource.query(`
SELECT d.id, d.code, t.code AS type_code
FROM asset_attribute_definitions d
JOIN asset_types t ON t.id = d.asset_type_id
WHERE t.code = 'tanque' AND d.is_active = true
`)) as Array<{ id: string; code: string; type_code: string }>;
const tankAttrs = new Map(attributeRows.map((row) => [row.code, row.id]));
const createAsset = async (input: {
code: string; name: string; typeCode: string; parentId?: string | null;
areaId?: string | null; companyId?: string | null; description: string;
attributes?: Record<string, unknown>;
}) => {
const created = await assets.create({
code: input.code,
name: input.name,
typeId: types.get(input.typeCode)!,
parentId: input.parentId ?? null,
operationalAreaId: input.areaId ?? null,
operatorCompanyId: input.companyId ?? null,
description: input.description,
informationStatus: AssetInformationStatus.DRAFT,
attributes: input.attributes ?? {},
}, principal, request);
await provenance.update(created.id, {
origin: AssetDataOrigin.PROVIDED_DOCUMENT,
sourceName: 'DH DEV DEMO · Informe Técnico Nº 124/2026',
sourceReference: SOURCE_REFERENCE,
observedAt: '2026-06-25T12:00:00-03:00',
notes: 'DATO DE PRUEBA. Caso construido a partir del informe técnico recibido para validar nomenclatura, jerarquía y flujo. No constituye carga maestra real ni validación de vigencia.',
}, principal, request);
return created;
};
const area = await createAsset({
code: 'DEV-DEMO-AREA-ATAMISQUI',
name: '[PRUEBA] Atamisqui',
typeCode: 'area',
description: 'Área usada exclusivamente como ejemplo de desarrollo a partir del Informe Técnico Nº 124/2026.',
});
const company = await createAsset({
code: 'DEV-DEMO-EMP-PS',
name: '[PRUEBA] Petróleos Sudamericanos Energy S.A.',
typeCode: 'empresa',
description: 'Organización de prueba para validar la relación ÁreaOperadora. No implica vigencia administrativa real.',
});
await registry.upsertOrganizationProfile(company.id, {
organizationKind: OrganizationKind.COMPANY,
legalName: 'Petróleos Sudamericanos Energy S.A.',
notes: 'Perfil de prueba DH DEV DEMO.',
}, principal, request);
const sourceDocument = await registry.createSourceDocument({
documentType: SourceDocumentType.TECHNICAL_REPORT,
documentNumber: 'IT 124/2026 · DH-DEV-DEMO',
title: 'DH DEV DEMO · Informe Técnico Nº 124/2026',
issuer: 'Dirección de Hidrocarburos · Gobierno de Mendoza',
documentDate: '2026-06-25',
externalReference: SOURCE_REFERENCE,
notes: 'Documento de referencia cargado exclusivamente para el caso de prueba del sistema.',
}, principal, request);
await relations.create({
areaId: area.id,
companyId: company.id,
relationRole: AreaOrganizationRole.OPERATOR,
sourceDocumentId: sourceDocument.id,
reason: 'Relación creada para el caso de prueba DH DEV DEMO basado en ITN 124/2026.',
}, principal, request);
const plant = await createAsset({
code: 'DEV-DEMO-PLANTA-3PB',
name: '[PRUEBA] Planta de Entrega 3 PB',
typeCode: 'planta',
parentId: area.id,
areaId: area.id,
companyId: company.id,
description: 'Instalación seleccionada como referencia en el Informe Técnico Nº 124/2026.',
});
const tankAttributes = (capacity: number): Record<string, unknown> => {
const values: Record<string, unknown> = {};
const capacityId = tankAttrs.get('capacidad_nominal');
const productId = tankAttrs.get('producto_servicio');
if (capacityId) values[capacityId] = capacity;
if (productId) values[productId] = 'Petróleo';
return values;
};
const tank57 = await createAsset({
code: 'DEV-DEMO-TK-57', name: '[PRUEBA] Tanque TK-57', typeCode: 'tanque',
parentId: plant.id, areaId: area.id, companyId: company.id,
description: 'Tanque de prueba citado en ITN 124/2026; capacidad informada 480 m³.',
attributes: tankAttributes(480),
});
const tank58 = await createAsset({
code: 'DEV-DEMO-TK-58', name: '[PRUEBA] Tanque TK-58', typeCode: 'tanque',
parentId: plant.id, areaId: area.id, companyId: company.id,
description: 'Tanque de prueba citado en ITN 124/2026; capacidad informada 320 m³.',
attributes: tankAttributes(320),
});
for (const assetId of [area.id, company.id, plant.id, tank57.id, tank58.id]) {
await registry.linkDocument(assetId, sourceDocument.id, { relationType: AssetSourceDocumentRelationType.SOURCE }, principal, request);
}
await assets.changeOperationalStatus(tank57.id, { status: AssetOperationalStatus.OUT_OF_SERVICE }, principal, request);
await assets.changeOperationalStatus(tank58.id, { status: AssetOperationalStatus.OUT_OF_SERVICE }, principal, request);
console.log('DEMO DH creado correctamente');
console.log('Área: [PRUEBA] Atamisqui');
console.log('Organización operadora: [PRUEBA] Petróleos Sudamericanos Energy S.A.');
console.log('Instalación: [PRUEBA] Planta de Entrega 3 PB');
console.log('Equipos: [PRUEBA] TK-57 (480 m³), TK-58 (320 m³)');
console.log(`Marca de limpieza: ${SOURCE_REFERENCE}`);
} finally {
await app.close();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});