chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import ExcelJS = require('exceljs');
|
||||
import PizZip from 'pizzip';
|
||||
import {
|
||||
AnexaType,
|
||||
MedicalCheckupType,
|
||||
MedicalVerdict,
|
||||
PrismaClient,
|
||||
RiskExposureType,
|
||||
} from '@prisma/client';
|
||||
import { DocxTemplateService } from '../src/modules/medical/services/docx-template.service';
|
||||
|
||||
type HttpResult = { status: number; body: unknown; text: string };
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const warnings: string[] = [];
|
||||
|
||||
function ok(message: string) {
|
||||
console.log(`OK ${message}`);
|
||||
}
|
||||
|
||||
function warn(message: string) {
|
||||
warnings.push(message);
|
||||
console.warn(`WARN ${message}`);
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function dbNameFromUrl(url: string): string {
|
||||
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function requireTemporaryDatabase() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is required for verification');
|
||||
const dbName = dbNameFromUrl(url);
|
||||
if (!dbName.startsWith('hrm_medpark_test_') && process.env.ALLOW_NON_TEST_DB !== 'true') {
|
||||
throw new Error(`Refusing to verify non-test database "${dbName}". Expected hrm_medpark_test_*.`);
|
||||
}
|
||||
return dbName;
|
||||
}
|
||||
|
||||
function sourcePath(fileName: string) {
|
||||
return resolve(process.cwd(), '..', '..', '..', fileName);
|
||||
}
|
||||
|
||||
function docxText(filePath: string): string {
|
||||
const zip = new PizZip(readFileSync(filePath));
|
||||
const xml = Object.keys(zip.files)
|
||||
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
|
||||
.map((name) => zip.file(name)?.asText() ?? '')
|
||||
.join('\n');
|
||||
return xml
|
||||
.replace(/<\/w:p>/g, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function verifySourceFiles() {
|
||||
const controlDocx = process.env.CONTROL_MEDICAL_DOCX ?? sourcePath('Control medical (5).docx');
|
||||
const rubriciXlsx = process.env.RUBRICI_NECESARE_XLSX ?? sourcePath('Rubrici necesare (6).xlsx');
|
||||
|
||||
assert(existsSync(controlDocx), `Missing source file: ${controlDocx}`);
|
||||
assert(existsSync(rubriciXlsx), `Missing source file: ${rubriciXlsx}`);
|
||||
|
||||
const controlText = docxText(controlDocx);
|
||||
for (const phrase of [
|
||||
/Baza de date a angajatilor trebuie sa contina/i,
|
||||
/Persoana expusa profesional la radiatii ionizante/i,
|
||||
/Tipul controlului medical/i,
|
||||
/Fisa de solicitare/i,
|
||||
/aptitudine/i,
|
||||
/Apt condi/i,
|
||||
/Inapt temporar/i,
|
||||
]) {
|
||||
assert(phrase.test(controlText), `Control medical checklist phrase not found: ${phrase}`);
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(rubriciXlsx);
|
||||
const rubrici = workbook.getWorksheet('Rubrici');
|
||||
const performance = workbook.getWorksheet('Sheet1');
|
||||
assert(rubrici, 'Rubrici necesare workbook must contain sheet "Rubrici"');
|
||||
assert(performance, 'Rubrici necesare workbook must contain sheet "Sheet1"');
|
||||
|
||||
const fields = new Set<string>();
|
||||
for (let rowIndex = 1; rowIndex <= rubrici.actualRowCount; rowIndex += 1) {
|
||||
const value = excelCellText(rubrici.getRow(rowIndex).getCell(3)).trim();
|
||||
if (value) fields.add(value);
|
||||
}
|
||||
for (const field of ['IDNP', 'Nume', 'Prenume', 'Data nasterii', 'Domiciliu', 'Nr de telefon personal', 'tipul actului de identitate', 'Nr CIM']) {
|
||||
assert(fields.has(field), `Rubrici checklist field not found: ${field}`);
|
||||
}
|
||||
|
||||
const performanceText = docxLikeSheetText(performance);
|
||||
for (const phrase of ['Abilitati clinice nursing', 'Judecata clinica', 'Respectarea Dress Code', 'Membru unui comitet']) {
|
||||
assert(performanceText.includes(phrase), `Performance checklist phrase not found: ${phrase}`);
|
||||
}
|
||||
|
||||
ok('source DOCX/XLSX checklists are present and readable');
|
||||
}
|
||||
|
||||
function docxLikeSheetText(worksheet: ExcelJS.Worksheet) {
|
||||
const parts: string[] = [];
|
||||
worksheet.eachRow((row) => {
|
||||
row.eachCell((cell) => {
|
||||
const text = excelCellText(cell).trim();
|
||||
if (text) parts.push(text);
|
||||
});
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function excelCellText(cell: ExcelJS.Cell) {
|
||||
const value = cell.value;
|
||||
if (value == null) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value !== 'object') return String(value);
|
||||
if ('text' in value && typeof value.text === 'string') return value.text;
|
||||
if ('result' in value && value.result != null) return String(value.result);
|
||||
if ('richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('');
|
||||
}
|
||||
if ('hyperlink' in value && 'text' in value && typeof value.text === 'string') return value.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
async function verifyDatabaseCoverage() {
|
||||
const [
|
||||
employees,
|
||||
identityDocuments,
|
||||
familyMembers,
|
||||
educations,
|
||||
qualifications,
|
||||
trainings,
|
||||
sanctions,
|
||||
benefits,
|
||||
contracts,
|
||||
riskCards,
|
||||
profiles,
|
||||
checkups,
|
||||
evaluationForms,
|
||||
] = await Promise.all([
|
||||
prisma.employee.count(),
|
||||
prisma.identityDocument.count(),
|
||||
prisma.familyMember.count(),
|
||||
prisma.education.count(),
|
||||
prisma.qualification.count(),
|
||||
prisma.training.count(),
|
||||
prisma.disciplinarySanction.count(),
|
||||
prisma.benefit.count(),
|
||||
prisma.employmentContract.count(),
|
||||
prisma.workplaceRiskCard.count(),
|
||||
prisma.employeeMedicalProfile.count(),
|
||||
prisma.medicalCheckup.count(),
|
||||
prisma.evaluationForm.count(),
|
||||
]);
|
||||
|
||||
assert(employees >= 6, `Expected at least 6 employees, got ${employees}`);
|
||||
assert(identityDocuments >= employees, 'Every seeded employee should have an identity document');
|
||||
assert(familyMembers >= 3, 'Expected family/contact records');
|
||||
assert(educations >= employees, 'Expected education records for all employees');
|
||||
assert(qualifications >= 4, 'Expected qualification records');
|
||||
assert(trainings >= employees, 'Expected training records for all employees');
|
||||
assert(sanctions >= 2, 'Expected disciplinary sanction scenarios');
|
||||
assert(benefits >= 2, 'Expected benefit scenarios');
|
||||
assert(contracts >= employees, 'Expected active CIM for all employees');
|
||||
assert(riskCards >= 3, 'Expected STANDARD, radiation, and DISTANTA_DIGITAL risk cards');
|
||||
assert(profiles >= employees, 'Expected medical profile for all employees');
|
||||
assert(checkups >= 8, 'Expected pending and completed checkups');
|
||||
assert(evaluationForms >= 3, 'Expected performance evaluation forms');
|
||||
|
||||
const exposureTypes = await prisma.workplaceRiskExposure.findMany({ select: { tip: true }, distinct: ['tip'] });
|
||||
const seededExposureTypes = new Set(exposureTypes.map((row) => row.tip));
|
||||
for (const type of Object.values(RiskExposureType)) {
|
||||
assert(seededExposureTypes.has(type), `Missing risk exposure type in test seed: ${type}`);
|
||||
}
|
||||
|
||||
const verdicts = await prisma.medicalCheckup.findMany({
|
||||
where: { verdict: { not: null } },
|
||||
select: { verdict: true },
|
||||
distinct: ['verdict'],
|
||||
});
|
||||
const seededVerdicts = new Set(verdicts.map((row) => row.verdict));
|
||||
for (const verdict of Object.values(MedicalVerdict)) {
|
||||
assert(seededVerdicts.has(verdict), `Missing medical verdict scenario: ${verdict}`);
|
||||
}
|
||||
|
||||
const radiationProfiles = await prisma.employeeMedicalProfile.count({ where: { expusRadiatiiIonizante: true } });
|
||||
const overexposures = await prisma.radiationOverexposure.findMany({ select: { fel: true }, distinct: ['fel'] });
|
||||
const remoteCards = await prisma.workplaceRiskCard.count({ where: { tipFisa: 'DISTANTA_DIGITAL' } });
|
||||
const pending = await prisma.medicalCheckup.count({ where: { verdict: null } });
|
||||
|
||||
assert(radiationProfiles >= 2, 'Expected at least two radiation-exposed employees');
|
||||
assert(overexposures.length >= 2, 'Expected exceptional and accidental overexposure rows');
|
||||
assert(remoteCards >= 1, 'Expected Anexa 4A/DISTANTA_DIGITAL risk card');
|
||||
assert(pending >= 3, 'Expected pending checkups for medic inbox');
|
||||
|
||||
ok('database seed covers HR, performance, and Control medical scenarios');
|
||||
}
|
||||
|
||||
function extractTemplatePlaceholders(fileName: string) {
|
||||
const fullPath = resolve(process.cwd(), 'templates', 'docx', fileName);
|
||||
assert(existsSync(fullPath), `Missing DOCX template: ${fullPath}`);
|
||||
const zip = new PizZip(readFileSync(fullPath));
|
||||
const xml = Object.keys(zip.files)
|
||||
.filter((name) => /^word\/(document|header|footer).*\.xml$/.test(name))
|
||||
.map((name) => zip.file(name)?.asText() ?? '')
|
||||
.join('\n');
|
||||
const opens = (xml.match(/\{/g) ?? []).length;
|
||||
const closes = (xml.match(/\}/g) ?? []).length;
|
||||
assert(opens === closes, `${fileName} has unbalanced placeholders: opens=${opens}, closes=${closes}`);
|
||||
const placeholders = Array.from(xml.matchAll(/\{([^{}]+)\}/g)).map((match) => match[1]);
|
||||
return { placeholders, opens };
|
||||
}
|
||||
|
||||
function valueForPlaceholder(name: string, index = 1) {
|
||||
if (name.startsWith('cb')) return index % 2 === 0 ? '☐' : '☑';
|
||||
if (/data|PanaLa/i.test(name)) return '27.05.2026';
|
||||
if (/email/i.test(name)) return 'hr.test@medpark.md';
|
||||
if (/telefon|fax/i.test(name)) return '+373 22 000 000';
|
||||
if (/idnp/i.test(name)) return `19850615000${index}`;
|
||||
if (/nr|numar|anNastere|ani/i.test(name)) return String(index);
|
||||
if (/doza/i.test(name)) return (index + 0.25).toFixed(4);
|
||||
return `Test ${name} ${index}`;
|
||||
}
|
||||
|
||||
function sampleDocxData(placeholders: string[]) {
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const raw of placeholders) {
|
||||
const name = raw.replace(/^#|\//g, '');
|
||||
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
|
||||
data[name] = valueForPlaceholder(name);
|
||||
}
|
||||
const row = (index: number) => {
|
||||
const values: Record<string, string> = {};
|
||||
for (const raw of placeholders) {
|
||||
const name = raw.replace(/^#|\//g, '');
|
||||
if (!name || raw.startsWith('#') || raw.startsWith('/')) continue;
|
||||
values[name] = valueForPlaceholder(name, index);
|
||||
}
|
||||
values.nr = String(index);
|
||||
values.numePrenume = index === 1 ? 'Popescu Alexandru' : 'Ionescu Maria';
|
||||
values.denumire = index === 1 ? 'Glutaraldehidă' : 'HBV/HCV/HIV';
|
||||
values.tipExpunere = index === 1 ? 'X externă' : 'gamma externă';
|
||||
return values;
|
||||
};
|
||||
for (const loopName of ['angajati', 'chimici', 'pulberi', 'biologici', 'zgomot', 'vibratii', 'campEM', 'optice', 'supraexpExceptionale', 'supraexpAccidentale']) {
|
||||
data[loopName] = [row(1), row(2)];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function verifyDocxTemplates() {
|
||||
const service = new DocxTemplateService();
|
||||
const files: Record<AnexaType, string> = {
|
||||
ANEXA_3: 'anexa-3.docx',
|
||||
ANEXA_4: 'anexa-4.docx',
|
||||
ANEXA_4A: 'anexa-4a.docx',
|
||||
ANEXA_4B: 'anexa-4b.docx',
|
||||
ANEXA_6: 'anexa-6.docx',
|
||||
};
|
||||
|
||||
for (const [type, fileName] of Object.entries(files) as [AnexaType, string][]) {
|
||||
const { placeholders, opens } = extractTemplatePlaceholders(fileName);
|
||||
assert(opens > 0, `${fileName} should contain docxtemplater placeholders`);
|
||||
const rendered = service.render(type, sampleDocxData(placeholders));
|
||||
const zip = new PizZip(rendered);
|
||||
const badParts: string[] = [];
|
||||
for (const name of Object.keys(zip.files).filter((entry) => /^word\/(document|header|footer).*\.xml$/.test(entry))) {
|
||||
const xml = zip.file(name)?.asText() ?? '';
|
||||
if (/[{}]/.test(xml) || /\b(undefined|null)\b/i.test(xml)) badParts.push(name);
|
||||
}
|
||||
assert(badParts.length === 0, `${fileName} rendered XML still contains placeholders/nulls in ${badParts.join(', ')}`);
|
||||
}
|
||||
|
||||
ok('all Anexa DOCX templates render cleanly through DocxTemplateService');
|
||||
}
|
||||
|
||||
async function isMinioAvailable() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const response = await fetch('http://localhost:9000/minio/health/live', { signal: controller.signal });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest(baseUrl: string, method: string, path: string, body?: unknown, token?: string): Promise<HttpResult> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return { status: response.status, body: parsed, text };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < 3) await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000 * attempt));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
throw new Error(`${method} ${path} failed after retries: ${String(lastError)}`);
|
||||
}
|
||||
|
||||
function tokenFrom(body: unknown) {
|
||||
if (typeof body === 'object' && body && 'token' in body && typeof (body as { token: unknown }).token === 'string') {
|
||||
return (body as { token: string }).token;
|
||||
}
|
||||
throw new Error('dev-login response did not include token');
|
||||
}
|
||||
|
||||
async function verifyHttpSmoke(minioAvailable: boolean) {
|
||||
const baseUrl = process.env.API_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
warn('API_BASE_URL is not set; skipped HTTP smoke tests. testdb:run starts a temporary API automatically.');
|
||||
return;
|
||||
}
|
||||
|
||||
const adminLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-admin', role: 'hr_admin' });
|
||||
const specialistLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-specialist', role: 'hr_specialist' });
|
||||
const medicLogin = await apiRequest(baseUrl, 'POST', '/auth/dev-login', { username: 'test-medic', role: 'medic_familie' });
|
||||
assert(adminLogin.status === 201 || adminLogin.status === 200, `hr_admin dev-login failed: ${adminLogin.text}`);
|
||||
assert(specialistLogin.status === 201 || specialistLogin.status === 200, `hr_specialist dev-login failed: ${specialistLogin.text}`);
|
||||
assert(medicLogin.status === 201 || medicLogin.status === 200, `medic_familie dev-login failed: ${medicLogin.text}`);
|
||||
const adminToken = tokenFrom(adminLogin.body);
|
||||
const specialistToken = tokenFrom(specialistLogin.body);
|
||||
const medicToken = tokenFrom(medicLogin.body);
|
||||
|
||||
for (const path of ['/dashboard/stats', '/medical/risk-cards', '/medical/upcoming-expirations']) {
|
||||
const response = await apiRequest(baseUrl, 'GET', path, undefined, adminToken);
|
||||
assert(response.status >= 200 && response.status < 300, `GET ${path} failed: ${response.status} ${response.text}`);
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { status: 'activ', medicalProfile: { workplaceRiskCardId: { not: null } } },
|
||||
select: { id: true },
|
||||
take: 5,
|
||||
});
|
||||
assert(employees.length >= 5, 'HTTP bulk smoke needs at least five eligible employees');
|
||||
const bulkBody = {
|
||||
employeeIds: employees.map((employee) => employee.id),
|
||||
tip: MedicalCheckupType.periodic,
|
||||
dataPlanificata: '2026-06-15',
|
||||
documentContext: {
|
||||
telefon: '+373 22 000 000',
|
||||
fax: '+373 22 000 001',
|
||||
email: 'hr.test@medpark.md',
|
||||
solicitant: 'Test HR Admin',
|
||||
functia: 'Specialist resurse umane',
|
||||
},
|
||||
};
|
||||
|
||||
const forbidden = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, specialistToken);
|
||||
assert(forbidden.status === 403, `hr_specialist bulk initiate should be 403, got ${forbidden.status}: ${forbidden.text}`);
|
||||
|
||||
if (!minioAvailable) {
|
||||
warn('MinIO is not reachable on localhost:9000; skipped full upload-dependent bulk success and medic completion HTTP tests.');
|
||||
ok('HTTP smoke passed for auth, read endpoints, and role-based 403');
|
||||
return;
|
||||
}
|
||||
|
||||
const bulk = await apiRequest(baseUrl, 'POST', '/medical/bulk/initiate', bulkBody, adminToken);
|
||||
assert(bulk.status >= 200 && bulk.status < 300, `hr_admin bulk initiate failed: ${bulk.status} ${bulk.text}`);
|
||||
assert(typeof bulk.body === 'object' && bulk.body && 'groupsCount' in bulk.body, 'bulk response should include groupsCount');
|
||||
|
||||
const pending = await prisma.medicalCheckup.findFirst({
|
||||
where: { verdict: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
assert(pending, 'No pending checkup found for medic completion smoke');
|
||||
const complete = await apiRequest(
|
||||
baseUrl,
|
||||
'PATCH',
|
||||
`/medical/checkups/${pending.id}/complete`,
|
||||
{
|
||||
verdict: MedicalVerdict.apt_conditionat,
|
||||
dataEfectuata: '2026-06-16',
|
||||
recomandari: 'Test: lucru cu dozimetru și reevaluare anuală.',
|
||||
valabilPanaLa: '2027-06-16',
|
||||
semnatDe: 'Dr. Verificare Test',
|
||||
},
|
||||
medicToken,
|
||||
);
|
||||
assert(complete.status >= 200 && complete.status < 300, `medic completion failed: ${complete.status} ${complete.text}`);
|
||||
|
||||
const completed = await prisma.medicalCheckup.findUnique({ where: { id: pending.id } });
|
||||
assert(completed?.verdict === MedicalVerdict.apt_conditionat, 'Medic completion did not persist verdict');
|
||||
assert(completed.semnatDe === 'Dr. Verificare Test', 'Medic completion did not persist semnatDe');
|
||||
assert(Array.isArray(completed.documenteGenerate), 'Completed checkup should contain generated documents');
|
||||
assert(JSON.stringify(completed.documenteGenerate).includes('Anexa_6_Final'), 'Completed checkup should include final Anexa 6 document');
|
||||
|
||||
ok('HTTP smoke passed, including MinIO-backed document generation');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dbName = requireTemporaryDatabase();
|
||||
console.log(`Verifying Medpark test database ${dbName}...`);
|
||||
await verifySourceFiles();
|
||||
await verifyDatabaseCoverage();
|
||||
await verifyDocxTemplates();
|
||||
const minioAvailable = await isMinioAvailable();
|
||||
if (minioAvailable) ok('MinIO is reachable on localhost:9000');
|
||||
else warn('MinIO is not reachable on localhost:9000; upload-dependent checks will be skipped or marked failed.');
|
||||
await verifyHttpSmoke(minioAvailable);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log('\nVerification completed with warnings:');
|
||||
for (const message of warnings) console.log(`- ${message}`);
|
||||
} else {
|
||||
console.log('\nVerification completed without warnings.');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user