chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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;
|
||||
});
|
||||
@@ -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 Área–Operadora. 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;
|
||||
});
|
||||
Reference in New Issue
Block a user