import { createHash } from 'crypto'; import { Readable } from 'stream'; import { BadRequestError, NotFoundError } from '@pikku/core/errors'; import { CandidateDocumentTypes, DocumentStatuses, GermanStates, LanguageLevels, RecognitionDossierDocumentCategories, RecognitionDossierStatuses, } from '@heygermany/sdk'; import dayjs from 'dayjs'; import { toBucketKey } from '../content-path.js'; import { normalizeStringArray } from '../normalizers.js'; const RULES_VERSION = 'pilot-v1-2026-04-21'; const LAST_VERIFIED_AT = '2026-04-21'; const LANGUAGE_LEVEL_ORDER = { NONE: 0, A_ONE: 1, A_TWO: 2, B_ONE: 3, B_TWO: 4, C_ONE: 5, C_TWO: 6, }; const createRule = (rule) => rule; const RULES = { [GermanStates.Bayern]: { nurse: createRule({ state: GermanStates.Bayern, profession: 'nurse', targetTitle: 'Pflegefachfrau / Pflegefachmann / Pflegefachperson', authorityName: 'Bayerisches Landesamt fur Pflege', authorityWebsiteUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachkraft/', submissionMode: 'online_or_postal', submissionUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachkraft/', postalAddress: ['Bayerisches Landesamt fur Pflege', 'Anerkennungsverfahren', 'Mildred-Scheel-Str. 4', '92224 Amberg'], contactEmail: 'anerkennung-pflege@lfp.bayern.de', contactPhone: '+49 9621 9669-4545', notes: [ 'The LfP allows online or postal filing for the nursing recognition pilot.', 'Bavaria supports a waiver path for third-country nursing qualifications.', 'Use the dossier package as the authority-facing set, but keep original files available for follow-up.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: false, supportsWaiver: true, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates.Bayern, profession: 'assistant_nurse', targetTitle: 'Pflegefachhelferin / Pflegefachhelfer', authorityName: 'Bayerisches Landesamt fur Pflege', authorityWebsiteUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachhelfer/', submissionMode: 'online_or_postal', submissionUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachhelfer/', postalAddress: ['Bayerisches Landesamt fur Pflege', 'Anerkennungsverfahren', 'Mildred-Scheel-Str. 4', '92224 Amberg'], contactEmail: 'anerkennung-pflege@lfp.bayern.de', contactPhone: '+49 9621 9669-4545', notes: [ 'The Bavarian helper route is handled by the LfP and can be submitted online or by post.', 'The helper title is protected even though helper work itself is not fully regulated in Bavaria.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), nursing_helper: createRule({ state: GermanStates.Bayern, profession: 'nursing_helper', targetTitle: 'Pflegefachhelferin / Pflegefachhelfer', authorityName: 'Bayerisches Landesamt fur Pflege', authorityWebsiteUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachhelfer/', submissionMode: 'online_or_postal', submissionUrl: 'https://www.lfp.bayern.de/anerkennung/pflegefachhelfer/', postalAddress: ['Bayerisches Landesamt fur Pflege', 'Anerkennungsverfahren', 'Mildred-Scheel-Str. 4', '92224 Amberg'], contactEmail: 'anerkennung-pflege@lfp.bayern.de', contactPhone: '+49 9621 9669-4545', notes: [ 'Use the Bavarian Pflegefachhelfer route for the nursing-helper pilot.', 'The online route is preferred when the supporting documents are already digitized.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), }, [GermanStates.Hessen]: { nurse: createRule({ state: GermanStates.Hessen, profession: 'nurse', targetTitle: 'Pflegefachfrau / Pflegefachmann / Pflegefachperson', authorityName: 'Hessisches Landesamt fur Gesundheit und Pflege', authorityWebsiteUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', submissionMode: 'online_or_postal', submissionUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', postalAddress: ['Hessisches Landesamt fur Gesundheit und Pflege', 'Dezernat IV3 Pflegeberufe', 'Postfach 11 03 52', '64218 Darmstadt'], contactEmail: 'pflegeberufe.ausland@hlfgp.hessen.de', contactPhone: '+49 611 3259-1000', notes: [ 'Hessen provides a digital application path and accepts postal dossiers as an alternative.', 'The dossier should include a clear Hessian jurisdiction proof such as an employer intent letter or residence evidence.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates.Hessen, profession: 'assistant_nurse', targetTitle: 'Krankenpflegehilfe', authorityName: 'Hessisches Landesamt fur Gesundheit und Pflege', authorityWebsiteUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', submissionMode: 'online_or_postal', submissionUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', postalAddress: ['Hessisches Landesamt fur Gesundheit und Pflege', 'Dezernat IV3 Pflegeberufe', 'Postfach 11 03 52', '64218 Darmstadt'], contactEmail: 'pflegeberufe.ausland@hlfgp.hessen.de', contactPhone: '+49 611 3259-1000', notes: [ 'Hessen curates an assistenzberuf route for nursing-related assistant qualifications.', 'A Hessian intention-to-work proof is still expected.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), nursing_helper: createRule({ state: GermanStates.Hessen, profession: 'nursing_helper', targetTitle: 'Altenpflegehilfe', authorityName: 'Hessisches Landesamt fur Gesundheit und Pflege', authorityWebsiteUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', submissionMode: 'online_or_postal', submissionUrl: 'https://hlfgp.hessen.de/pflegefachberufe/auslaendische-abschluesse-pflege', postalAddress: ['Hessisches Landesamt fur Gesundheit und Pflege', 'Dezernat IV3 Pflegeberufe', 'Postfach 11 03 52', '64218 Darmstadt'], contactEmail: 'pflegeberufe.ausland@hlfgp.hessen.de', contactPhone: '+49 611 3259-1000', notes: [ 'Hessen also accepts helper-level care recognition requests in the pilot scope.', 'Attach clear evidence showing the intended place of work in Hessen.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), }, [GermanStates.Niedersachsen]: { nurse: createRule({ state: GermanStates.Niedersachsen, profession: 'nurse', targetTitle: 'Pflegefachfrau / Pflegefachmann / Pflegefachperson', authorityName: 'Niedersachsisches Landesamt fur Soziales, Jugend und Familie', authorityWebsiteUrl: 'https://soziales.niedersachsen.de/startseite/soziales_amp_gesundheit/gesundheit_und_pflege/nichtarztliche_heilberufe/anerkennungsverfahren_von_im_ausland_abgeschlossenen_ausbildungen/anerkennungsverfahren-von-auslandischen-gesundheitsfachberufen-101995.html', submissionMode: 'postal_package', submissionUrl: 'https://soziales.niedersachsen.de/startseite/soziales_amp_gesundheit/gesundheit_und_pflege/nichtarztliche_heilberufe/anerkennungsverfahren_von_im_ausland_abgeschlossenen_ausbildungen/anerkennungsverfahren-von-auslandischen-gesundheitsfachberufen-101995.html', postalAddress: ['Niedersachsisches Landesamt fur Soziales, Jugend und Familie', 'Team 4SL3', 'Auf der Hude 2', '21339 Luneburg'], contactEmail: 'Team4SL3@ls.niedersachsen.de', contactPhone: '+49 4131 15-0', notes: [ 'Lower Saxony expects the authority forms and supporting documents as a package route.', 'Do not send originals, folders, or stapled sets according to the current authority guidance.', 'A waiver declaration is part of the nursing route in Lower Saxony.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: true, supportsWaiver: true, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates.Niedersachsen, profession: 'assistant_nurse', targetTitle: 'Pflegeassistenz / Pflegehilfe', authorityName: 'Niedersachsisches Landesamt fur Soziales, Jugend und Familie', authorityWebsiteUrl: 'https://soziales.niedersachsen.de/startseite/soziales_amp_gesundheit/gesundheit_und_pflege/nichtarztliche_heilberufe/anerkennungsverfahren_von_im_ausland_abgeschlossenen_ausbildungen/anerkennungsverfahren-von-auslandischen-gesundheitsfachberufen-101995.html', submissionMode: 'postal_package', submissionUrl: 'https://soziales.niedersachsen.de/startseite/soziales_amp_gesundheit/gesundheit_und_pflege/nichtarztliche_heilberufe/anerkennungsverfahren_von_im_ausland_abgeschlossenen_ausbildungen/anerkennungsverfahren-von-auslandischen-gesundheitsfachberufen-101995.html', postalAddress: ['Niedersachsisches Landesamt fur Soziales, Jugend und Familie', 'Team 4SL3', 'Auf der Hude 2', '21339 Luneburg'], contactEmail: 'Team4SL3@ls.niedersachsen.de', contactPhone: '+49 4131 15-0', notes: [ 'The assistant-level pilot uses the same authority package route as other health professions in Lower Saxony.', 'Keep the dossier manual and form-driven; the state guidance centers on downloadable forms and a paper package.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), }, [GermanStates['Baden-Württemberg']]: { nurse: createRule({ state: GermanStates['Baden-Württemberg'], profession: 'nurse', targetTitle: 'Pflegefachkraft', authorityName: 'Regierungsprasidium Stuttgart', authorityWebsiteUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/pflegeberufe/', submissionMode: 'postal_package', submissionUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/antraege/', postalAddress: ['Regierungsprasidium Stuttgart', 'Referat 98', 'Ruppmannstr. 21', '70565 Stuttgart'], contactEmail: null, contactPhone: null, notes: [ 'Baden-Wurttemberg publishes a detailed two-checklist process for recognition and final title issuance.', 'A credible intent-to-work proof in Baden-Wurttemberg is required.', 'The final issuance bundle expects police clearance, medical certificate, and B2 proof in original form and not older than three months where stated by the authority.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates['Baden-Württemberg'], profession: 'assistant_nurse', targetTitle: 'Gesundheits- und Krankenpflegehilfe', authorityName: 'Regierungsprasidium Stuttgart', authorityWebsiteUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/pflegeberufe/', submissionMode: 'postal_package', submissionUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/antraege/', postalAddress: ['Regierungsprasidium Stuttgart', 'Referat 98', 'Ruppmannstr. 21', '70565 Stuttgart'], contactEmail: null, contactPhone: null, notes: [ 'Baden-Wurttemberg includes health-care assistant recognition in the same forms-and-checklists workflow.', 'Attach employer-intent evidence to establish Baden-Wurttemberg jurisdiction.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), nursing_helper: createRule({ state: GermanStates['Baden-Württemberg'], profession: 'nursing_helper', targetTitle: 'Altenpflegehilfe', authorityName: 'Regierungsprasidium Stuttgart', authorityWebsiteUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/pflegeberufe/', submissionMode: 'postal_package', submissionUrl: 'https://rp.baden-wuerttemberg.de/themen/bildung/ausbildung/seiten/gesundheitsberufe-ausland/de/antraege/', postalAddress: ['Regierungsprasidium Stuttgart', 'Referat 98', 'Ruppmannstr. 21', '70565 Stuttgart'], contactEmail: null, contactPhone: null, notes: [ 'The pilot treats Baden-Wurttemberg helper recognition as a checklist-led paper submission.', 'Employer intent remains part of the jurisdiction package.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), }, [GermanStates['Nordrhein-Westfalen']]: { nurse: createRule({ state: GermanStates['Nordrhein-Westfalen'], profession: 'nurse', targetTitle: 'Pflegefachfrau / Pflegefachmann / Pflegefachperson', authorityName: 'Zentrale Anerkennungsstelle fur Gesundheitsberufe - ZAG-PuG', authorityWebsiteUrl: 'https://www.bezreg-muenster.de/themen/gesundheit-und-soziales/zentrale-anerkennungsstelle-fuer-gesundheitsberufe/zag-pug', submissionMode: 'postal_package', submissionUrl: 'https://www.bezreg-muenster.de/themen/gesundheit-und-soziales/zentrale-anerkennungsstelle-fuer-gesundheitsberufe/zag-pug', postalAddress: ['Bezirksregierung Munster', 'Dezernat 241 ZAG-PuG', '48128 Munster'], contactEmail: 'pug-anerkennung@brms.nrw.de', contactPhone: '+49 251 411-2444', notes: [ 'NRW centralizes nursing equivalency review at the ZAG in Munster.', 'The current pilot keeps the route postal and package-based rather than portal-first.', 'The state distinguishes between the equivalency review and the later title-permit step.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates['Nordrhein-Westfalen'], profession: 'assistant_nurse', targetTitle: 'Pflegefachassistent:in', authorityName: 'Zentrale Anerkennungsstelle fur Gesundheitsberufe - ZAG-PuG', authorityWebsiteUrl: 'https://www.mags.nrw/berufsqualifikationen-anerkennung-gesundheitsberufe', submissionMode: 'postal_package', submissionUrl: 'https://www.bezreg-muenster.de/themen/gesundheit-und-soziales/zentrale-anerkennungsstelle-fuer-gesundheitsberufe/zag-pug', postalAddress: ['Bezirksregierung Munster', 'Dezernat 241 ZAG-PuG', '48128 Munster'], contactEmail: 'pug-anerkennung@brms.nrw.de', contactPhone: '+49 251 411-2444', notes: [ 'The pilot maps assistant-nurse recognition in NRW to the Pflegefachassistent route.', 'Expect a package-first workflow and follow-up with the authority if a local title-permit step is triggered.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: null, }), }, [GermanStates.Berlin]: { nurse: createRule({ state: GermanStates.Berlin, profession: 'nurse', targetTitle: 'Pflegefachfrau / Pflegefachmann', authorityName: 'Landesamt fur Gesundheit und Soziales Berlin - Landesprufungsamt fur Gesundheitsberufe', authorityWebsiteUrl: 'https://service.berlin.de/dienstleistung/331593/', submissionMode: 'postal_or_dropoff', submissionUrl: 'https://service.berlin.de/dienstleistung/331593/', postalAddress: ['Landesamt fur Gesundheit und Soziales Berlin', 'Landesprufungsamt fur Gesundheitsberufe', 'Postfach 31 09 29 - IV A 4', '10639 Berlin'], contactEmail: 'bqfg_nah@lageso.berlin.de', contactPhone: '+49 30 90229-2144', notes: [ 'Berlin currently publishes the route as a form-and-checklist submission rather than an online filing path.', 'Jurisdiction proof for Berlin should be included, for example a Berlin employer intent or Berlin residence evidence.', 'For this pilot, Berlin language proof is only treated as dossier-ready when the B2 certificate is traceable to an accepted issuer and can be validated against the three-year freshness rule.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: 3, languageIssuerPatterns: [/goethe/i, /telc/i, /testdaf/i, /ecl/i], unsupportedReason: null, }), assistant_nurse: createRule({ state: GermanStates.Berlin, profession: 'assistant_nurse', targetTitle: 'Gesundheits- und Krankenpflegehelfer/in', authorityName: 'Landesamt fur Gesundheit und Soziales Berlin - Landesprufungsamt fur Gesundheitsberufe', authorityWebsiteUrl: 'https://service.berlin.de/dienstleistung/331545/', submissionMode: 'postal_or_dropoff', submissionUrl: 'https://service.berlin.de/dienstleistung/331545/', postalAddress: ['Landesamt fur Gesundheit und Soziales Berlin', 'Landesprufungsamt fur Gesundheitsberufe', 'Postfach 31 09 29 - IV A 4', '10639 Berlin'], contactEmail: 'bqfg_nah@lageso.berlin.de', contactPhone: '+49 30 90229-2144', notes: [ 'Assistant-level Berlin dossiers follow the same non-portal, service-page-led package route.', 'Include Berlin jurisdiction proof and authority forms in the package.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: true, requiresAuthorityForm: true, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_ONE, languageMaxAgeYears: 3, languageIssuerPatterns: [/goethe/i, /telc/i, /testdaf/i, /ecl/i], unsupportedReason: null, }), }, Brandenburg: {}, Bremen: {}, Hamburg: {}, 'Mecklenburg-Vorpommern': {}, 'Rheinland-Pfalz': {}, Saarland: {}, Sachsen: {}, 'Sachsen-Anhalt': {}, 'Schleswig-Holstein': {}, Thüringen: {}, }; const defaultRule = (state, profession) => ({ state, profession, targetTitle: profession.replace(/_/g, ' '), authorityName: null, authorityWebsiteUrl: null, submissionMode: null, submissionUrl: null, postalAddress: [], contactEmail: null, contactPhone: null, notes: [ 'This state and target profession pair is outside the pilot support matrix.', 'Keep the dossier for traceability, but do not submit without manual review.', ], lastVerifiedAt: LAST_VERIFIED_AT, requiresEmployerIntent: false, requiresAuthorityForm: false, supportsWaiver: false, minimumLanguageLevel: LanguageLevels.B_TWO, languageMaxAgeYears: null, languageIssuerPatterns: [], unsupportedReason: 'Unsupported state/profession pair', }); const RULES_BY_STATE_AND_PROFESSION = RULES; const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[index] = value >>> 0; } return table; })(); const crc32 = (buffer) => { let value = 0xffffffff; for (const byte of buffer) { value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8); } return (value ^ 0xffffffff) >>> 0; }; const toZipDosTime = (date) => { const safeYear = Math.max(date.getUTCFullYear(), 1980); const dosTime = (date.getUTCSeconds() >> 1) | (date.getUTCMinutes() << 5) | (date.getUTCHours() << 11); const dosDate = date.getUTCDate() | ((date.getUTCMonth() + 1) << 5) | ((safeYear - 1980) << 9); return { dosTime, dosDate }; }; const safeJsonValue = (value, fallback) => { if (value == null) { return fallback; } if (typeof value === 'string') { try { return safeJsonValue(JSON.parse(value), fallback); } catch { return fallback; } } return value; }; const sanitizeFilename = (fileName) => fileName .normalize('NFKD') .replace(/[^\x20-\x7E]+/g, '') .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-+|-+$/g, '') || 'file'; const csvEscape = (value) => { const text = String(value ?? ''); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; }; const extFromContentType = (contentType) => { const suffix = contentType.split('/').pop()?.trim().toLowerCase(); return suffix || 'bin'; }; const resolveStoredFileName = (fileName, contentType) => { return /\.[A-Za-z0-9]+$/.test(fileName) ? fileName : `${fileName}.${extFromContentType(contentType)}`; }; const levelAtLeast = (actualLevel, minimumLevel) => { if (!actualLevel || !minimumLevel) { return false; } return LANGUAGE_LEVEL_ORDER[actualLevel] >= LANGUAGE_LEVEL_ORDER[minimumLevel]; }; export class RecognitionDossierService { constructor(kysely, content, logger) { this.kysely = kysely; this.content = content; this.logger = logger; } async listCandidateDossiers(candidateId) { await this.ensureCandidateExists(candidateId); const dossiers = await this.kysely .selectFrom('recognitionDossier') .selectAll() .where('candidateId', '=', candidateId) .orderBy('createdAt', 'desc') .execute(); const summaries = []; for (const dossier of dossiers) { const refreshed = await this.refreshDossier(dossier); summaries.push(this.toSummary(refreshed)); } return summaries; } async getCandidateDossier(candidateId, dossierId) { const dossier = await this.getDossierRow(candidateId, dossierId); const refreshed = await this.refreshDossier(dossier); return await this.toDetail(refreshed); } async createOrRefreshDossier(input) { await this.ensureCandidateExists(input.candidateId); let dossier = await this.kysely .selectFrom('recognitionDossier') .selectAll() .where('candidateId', '=', input.candidateId) .where('targetState', '=', input.targetState) .where('targetProfession', '=', input.targetProfession) .executeTakeFirst(); if (!dossier) { const now = new Date(); dossier = await this.kysely .insertInto('recognitionDossier') .values({ dossierId: crypto.randomUUID(), candidateId: input.candidateId, targetState: input.targetState, targetProfession: input.targetProfession, status: RecognitionDossierStatuses.DRAFT, submissionGuideJson: {}, checklistJson: [], blockingIssuesJson: [], createdAt: now, lastUpdatedAt: now, }) .returningAll() .executeTakeFirstOrThrow(); } const refreshed = await this.refreshDossier(dossier); return await this.toDetail(refreshed); } async requestDocumentUploads(input) { if (input.documents.length === 0) { throw new BadRequestError('At least one document is required'); } const dossier = await this.getDossierRow(input.candidateId, input.dossierId); const now = new Date(); return await Promise.all(input.documents.map(async (document) => { const documentId = crypto.randomUUID(); const fileName = resolveStoredFileName(document.fileName, document.contentType); const filePath = this.getDossierDocumentAssetKey({ candidateId: dossier.candidateId, dossierId: dossier.dossierId, documentId, fileName, }); const { bucket, key } = toBucketKey(filePath); await this.kysely .insertInto('recognitionDossierDocument') .values({ documentId, dossierId: dossier.dossierId, candidateId: dossier.candidateId, category: document.category, title: fileName, filePath, fileName, fileSize: document.size, mimeType: document.contentType, uploaded: false, documentStatus: DocumentStatuses.UNVERIFIED, createdAt: now, lastUpdatedAt: now, }) .execute(); return { ...(await this.content.getUploadURL({ bucket, fileKey: key, contentType: document.contentType, size: document.size, })), documentId, }; })); } async completeDocumentUpload(input) { await this.getDossierDocumentRow(input); if (input.uploaded) { await this.kysely .updateTable('recognitionDossierDocument') .set({ uploaded: true, lastUpdatedAt: new Date(), }) .where('documentId', '=', input.documentId) .execute(); } const dossier = await this.getDossierRow(input.candidateId, input.dossierId); const refreshed = await this.refreshDossier(dossier); return await this.toDetail(refreshed); } async deleteDocument(input) { const document = await this.getDossierDocumentRow(input); await this.kysely .deleteFrom('recognitionDossierDocument') .where('documentId', '=', document.documentId) .execute(); const deleted = await this.content.deleteFile(toBucketKey(document.filePath)); if (!deleted) { this.logger.warn({ dossierId: input.dossierId, documentId: input.documentId, filePath: document.filePath }, 'Failed to delete recognition dossier document content'); } const dossier = await this.getDossierRow(input.candidateId, input.dossierId); const refreshed = await this.refreshDossier(dossier); return await this.toDetail(refreshed); } async generatePackage(input) { const dossier = await this.getDossierRow(input.candidateId, input.dossierId); const refreshed = await this.refreshDossier(dossier); if (refreshed.status === RecognitionDossierStatuses.UNSUPPORTED) { throw new BadRequestError('This dossier is outside the supported pilot matrix'); } const blockingIssues = this.readBlockingIssues(refreshed.blockingIssuesJson); if (blockingIssues.length > 0) { throw new BadRequestError('Resolve the blocking issues before generating the authority package'); } await this.kysely .updateTable('recognitionDossier') .set({ status: RecognitionDossierStatuses.GENERATING, lastUpdatedAt: new Date(), }) .where('dossierId', '=', refreshed.dossierId) .execute(); try { const detail = await this.toDetail(refreshed); const candidateSnapshot = await this.getCandidateSnapshot(refreshed.candidateId); const archive = await this.buildPackageArchive(detail, candidateSnapshot); const generatedAt = new Date(); const filePath = this.getPackageAssetKey({ candidateId: refreshed.candidateId, dossierId: refreshed.dossierId, generatedAt, }); const { bucket, key } = toBucketKey(filePath); const wrote = await this.content.writeFile({ bucket, key, stream: Readable.from(archive), }); if (!wrote) { throw new Error('content write failed'); } await this.kysely .updateTable('recognitionDossier') .set({ status: RecognitionDossierStatuses.GENERATED, packageFilePath: filePath, generatedAt, lastUpdatedAt: generatedAt, }) .where('dossierId', '=', refreshed.dossierId) .execute(); } catch (error) { await this.kysely .updateTable('recognitionDossier') .set({ status: RecognitionDossierStatuses.FAILED, lastUpdatedAt: new Date(), }) .where('dossierId', '=', refreshed.dossierId) .execute(); if (error instanceof BadRequestError || error instanceof NotFoundError) { throw error; } this.logger.error({ dossierId: refreshed.dossierId, candidateId: refreshed.candidateId, error }, 'Failed to generate recognition dossier package'); throw new BadRequestError('The dossier package could not be generated'); } return await this.getCandidateDossier(input.candidateId, input.dossierId); } async getDownloadUrl(input) { const dossier = await this.getDossierRow(input.candidateId, input.dossierId); const refreshed = await this.refreshDossier(dossier); if (!refreshed.packageFilePath) { throw new BadRequestError('The dossier package has not been generated yet'); } const { bucket, key } = toBucketKey(refreshed.packageFilePath); return await this.content.signContentKey({ bucket, contentKey: key, dateLessThan: dayjs().add(1, 'hour').toDate(), }); } async ensureCandidateExists(candidateId) { await this.kysely .selectFrom('candidate') .select('candidateId') .where('candidateId', '=', candidateId) .executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found')); } async getCandidateSnapshot(candidateId) { const candidate = await this.kysely .selectFrom('candidate') .selectAll() .where('candidateId', '=', candidateId) .executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found')); const [candidateDocuments, dossierDocuments, education, license, language, experiences] = await Promise.all([ this.kysely .selectFrom('candidateDocument') .selectAll() .where('candidateId', '=', candidateId) .orderBy('createdAt') .execute(), this.kysely .selectFrom('recognitionDossierDocument') .selectAll() .where('candidateId', '=', candidateId) .orderBy('createdAt') .execute(), this.kysely .selectFrom('candidateEducation') .selectAll() .where('candidateId', '=', candidateId) .executeTakeFirst(), this.kysely .selectFrom('candidateLicense') .selectAll() .where('candidateId', '=', candidateId) .executeTakeFirst(), this.kysely .selectFrom('candidateLanguage') .selectAll() .where('candidateId', '=', candidateId) .executeTakeFirst(), this.kysely .selectFrom('candidateExperience') .selectAll() .where('candidateId', '=', candidateId) .orderBy('createdAt') .execute(), ]); return { candidate: { ...candidate, stateWorkPreference: normalizeStringArray(candidate.stateWorkPreference), }, candidateDocuments, dossierDocuments, structured: { education, license, language, experiences, }, }; } async getDossierRow(candidateId, dossierId) { return await this.kysely .selectFrom('recognitionDossier') .selectAll() .where('candidateId', '=', candidateId) .where('dossierId', '=', dossierId) .executeTakeFirstOrThrow(() => new NotFoundError('Recognition dossier not found')); } async getDossierDocumentRow(input) { return await this.kysely .selectFrom('recognitionDossierDocument') .selectAll() .where('candidateId', '=', input.candidateId) .where('dossierId', '=', input.dossierId) .where('documentId', '=', input.documentId) .executeTakeFirstOrThrow(() => new NotFoundError('Recognition dossier document not found')); } resolveRule(state, profession) { return RULES_BY_STATE_AND_PROFESSION[state]?.[profession] ?? defaultRule(state, profession); } readChecklist(value) { return safeJsonValue(value, []); } readBlockingIssues(value) { return safeJsonValue(value, []); } readSubmissionGuide(value) { const parsed = safeJsonValue(value, null); return parsed && typeof parsed === 'object' ? parsed : null; } async refreshDossier(dossier) { const snapshot = await this.getCandidateSnapshot(dossier.candidateId); const rule = this.resolveRule(dossier.targetState, dossier.targetProfession); const fingerprint = this.buildInputFingerprint(dossier, snapshot); const candidateDocuments = snapshot.candidateDocuments.filter((document) => document.uploaded); const dossierDocuments = snapshot.dossierDocuments.filter((document) => document.dossierId === dossier.dossierId && document.uploaded); const checklist = this.buildChecklist({ candidate: snapshot.candidate, structured: snapshot.structured, candidateDocuments, dossierDocuments, rule, }); const blockingIssues = this.buildBlockingIssues({ candidate: snapshot.candidate, language: snapshot.structured.language, checklist, rule, }); const packageStale = (dossier.inputFingerprint && dossier.inputFingerprint !== fingerprint) || (dossier.rulesVersion && dossier.rulesVersion !== RULES_VERSION); const packageFilePath = packageStale ? null : dossier.packageFilePath; const generatedAt = packageStale ? null : dossier.generatedAt; const status = this.computeStatus({ supported: !rule.unsupportedReason, blockingIssues, packageFilePath, }); const now = new Date(); await this.kysely .updateTable('recognitionDossier') .set({ rulesVersion: RULES_VERSION, inputFingerprint: fingerprint, submissionGuideJson: this.getSubmissionGuide(rule), checklistJson: checklist, blockingIssuesJson: blockingIssues, packageFilePath, generatedAt, status, lastUpdatedAt: now, }) .where('dossierId', '=', dossier.dossierId) .execute(); return await this.kysely .selectFrom('recognitionDossier') .selectAll() .where('dossierId', '=', dossier.dossierId) .executeTakeFirstOrThrow(); } buildChecklist(input) { const candidateDocsByCategory = this.groupDocumentsByCategory(input.candidateDocuments); const dossierDocsByCategory = this.groupDocumentsByCategory(input.dossierDocuments); const { candidate, structured, rule } = input; return [ this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.PASSPORT, label: 'Passport or ID copy', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.PASSPORT], required: true, notes: ['Upload a clear passport or identity document copy.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.BIRTH_CERTIFICATE, label: 'Birth certificate', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.BIRTH_CERTIFICATE], required: true, notes: ['Required in most pilot-state checklists for identity matching.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.NAME_CHANGE_PROOF, label: 'Name change proof', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.NAME_CHANGE_PROOF], required: false, notes: ['Upload only when the applicant name differs across records.'], }), this.candidateDocumentItem({ id: CandidateDocumentTypes.EDUCATION, label: 'Qualification diploma and education evidence', documents: candidateDocsByCategory[CandidateDocumentTypes.EDUCATION], structuredPresent: Boolean(structured.education), required: true, notes: ['Existing education uploads are reused as source evidence.'], }), this.candidateDocumentItem({ id: CandidateDocumentTypes.LICENSE, label: 'Home-country license or registration', documents: candidateDocsByCategory[CandidateDocumentTypes.LICENSE], structuredPresent: Boolean(structured.license), required: candidate.licenseMandatory !== false, notes: ['License evidence is used when the profession or state route requires it.'], }), this.candidateDocumentItem({ id: CandidateDocumentTypes.LANGUAGE, label: 'German language proof', documents: candidateDocsByCategory[CandidateDocumentTypes.LANGUAGE], structuredPresent: Boolean(structured.language) || Boolean(candidate.languageCertificateProvided) || Boolean(candidate.languageSelfAssessmentLevel), required: true, notes: this.getLanguageNotes(rule), }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.TRANSLATION, label: 'Certified translations', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.TRANSLATION], required: true, notes: ['Attach translations for foreign-language statutory documents and qualification records.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.MEDICAL_CERTIFICATE, label: 'Medical certificate', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.MEDICAL_CERTIFICATE], required: true, notes: ['A current medical fitness statement is part of most pilot authority routes.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.POLICE_CLEARANCE, label: 'Police clearance', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.POLICE_CLEARANCE], required: true, notes: ['Provide the latest good-conduct certificate required by the target authority.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.EMPLOYER_INTENT, label: 'Employer intent or jurisdiction proof', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.EMPLOYER_INTENT], required: rule.requiresEmployerIntent, notes: ['Use an offer letter, hiring intent, or equivalent proof for state jurisdiction.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.AUTHORITY_FORM, label: 'Authority application form', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.AUTHORITY_FORM], required: rule.requiresAuthorityForm, notes: ['Attach the authority-specific form when the state route is package-led.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.WAIVER_FORM, label: 'Waiver form', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.WAIVER_FORM], required: false, notes: rule.supportsWaiver ? ['Optional but relevant when the state provides a waiver path.'] : ['Not part of the standard package for this state/profession pair.'], }), this.candidateDocumentItem({ id: CandidateDocumentTypes.WORK_EXPERIENCE, label: 'Work experience evidence', documents: candidateDocsByCategory[CandidateDocumentTypes.WORK_EXPERIENCE], structuredPresent: structured.experiences.length > 0, required: false, notes: ['Experience evidence strengthens the dossier and can help with difference assessments.'], }), this.dossierDocumentItem({ id: RecognitionDossierDocumentCategories.POWER_OF_ATTORNEY, label: 'Power of attorney', documents: dossierDocsByCategory[RecognitionDossierDocumentCategories.POWER_OF_ATTORNEY], required: false, notes: ['Upload when an agency or employer submits on the candidate\'s behalf.'], }), ]; } buildBlockingIssues(input) { if (input.rule.unsupportedReason) { return [ { code: 'unsupported', message: input.rule.unsupportedReason, }, ]; } const issues = input.checklist .filter((item) => item.required && ['missing', 'expired', 'blocked'].includes(item.status)) .map((item) => ({ code: item.id, message: `${item.label} is missing or not dossier-ready.`, })); const languageItem = input.checklist.find((item) => item.id === CandidateDocumentTypes.LANGUAGE); const actualLevel = input.language?.level || input.candidate.languageSelfAssessmentLevel; if (!levelAtLeast(actualLevel, input.rule.minimumLanguageLevel)) { issues.push({ code: 'language_level', message: `The dossier does not yet show the minimum German level ${input.rule.minimumLanguageLevel.replace('_', ' ')} for ${input.rule.state}.`, }); } if (languageItem && languageItem.status !== 'missing' && (input.rule.languageMaxAgeYears !== null || input.rule.languageIssuerPatterns.length > 0)) { if (!input.language?.issueDate) { issues.push({ code: 'language_issue_date_missing', message: `${input.rule.state} requires a traceable language certificate issue date for readiness checks.`, }); } else if (input.rule.languageMaxAgeYears !== null && dayjs(input.language.issueDate).isBefore(dayjs().subtract(input.rule.languageMaxAgeYears, 'year'))) { issues.push({ code: 'language_certificate_expired', message: `${input.rule.state} only accepts the current language certificate within ${input.rule.languageMaxAgeYears} years.`, }); } if (input.rule.languageIssuerPatterns.length > 0) { const issuingBody = input.language?.issuingBody?.trim() || ''; if (!issuingBody) { issues.push({ code: 'language_issuer_missing', message: `${input.rule.state} requires the B2 issuer to be identifiable before the dossier is considered ready.`, }); } else if (input.rule.languageIssuerPatterns.every((pattern) => !pattern.test(issuingBody))) { issues.push({ code: 'language_issuer_invalid', message: `${input.rule.state} only treats the current language proof as ready when the issuer matches the accepted pilot allowlist.`, }); } } } return issues; } candidateDocumentItem(input) { const documents = input.documents || []; let status = this.getDocumentsStatus(documents); if (status === 'missing' && input.structuredPresent) { status = 'present_unverified'; } return this.buildChecklistItem({ id: input.id, label: input.label, status: input.required ? status : this.optionalizeStatus(status), source: this.getSourceForDocuments(documents, input.structuredPresent ? 'structured_candidate_data' : null), sourceLabel: this.getSourceLabelForDocuments(documents, input.structuredPresent ? 'Structured candidate data' : null), required: input.required, notes: input.notes, }); } dossierDocumentItem(input) { const documents = input.documents || []; const status = this.getDocumentsStatus(documents); return this.buildChecklistItem({ id: input.id, label: input.label, status: input.required ? status : this.optionalizeStatus(status), source: this.getSourceForDocuments(documents, null), sourceLabel: this.getSourceLabelForDocuments(documents, null), required: input.required, notes: input.notes, }); } buildChecklistItem(input) { return { id: input.id, label: input.label, status: input.status, message: null, source: input.source, sourceLabel: input.sourceLabel, required: input.required, notes: input.notes, }; } getDocumentsStatus(documents) { if (documents.length === 0) { return 'missing'; } if (documents.some((document) => document.documentStatus === DocumentStatuses.VERIFIED)) { return 'present_verified'; } return 'present_unverified'; } optionalizeStatus(status) { return status === 'missing' ? 'optional' : status; } getSourceForDocuments(documents, fallback) { if (documents.length === 0) { return fallback; } return 'dossierId' in documents[0] ? 'dossier_document' : 'candidate_document'; } getSourceLabelForDocuments(documents, fallback) { if (documents.length === 0) { return fallback; } return 'dossierId' in documents[0] ? 'Dossier attachment' : 'Candidate evidence'; } getLanguageNotes(rule) { const notes = [ `Target language threshold: ${rule.minimumLanguageLevel.replace('_', ' ')}.`, ]; if (rule.languageIssuerPatterns.length > 0) { notes.push('Accepted pilot issuers include Goethe, telc, TestDaF, and ECL.'); } if (rule.languageMaxAgeYears !== null) { notes.push(`The certificate must still be within ${rule.languageMaxAgeYears} years at authority review.`); } return notes; } computeStatus(input) { if (!input.supported) { return RecognitionDossierStatuses.UNSUPPORTED; } if (input.blockingIssues.length > 0) { return RecognitionDossierStatuses.BLOCKED; } if (input.packageFilePath) { return RecognitionDossierStatuses.GENERATED; } return RecognitionDossierStatuses.READY; } groupDocumentsByCategory(documents) { return documents.reduce((groups, document) => { groups[document.category] ??= []; groups[document.category].push(document); return groups; }, {}); } buildInputFingerprint(dossier, snapshot) { return this.hashJson({ candidateId: snapshot.candidate.candidateId, dossierId: dossier.dossierId, targetState: dossier.targetState, targetProfession: dossier.targetProfession, candidate: { status: snapshot.candidate.status, stateWorkPreference: snapshot.candidate.stateWorkPreference, livingInGermany: snapshot.candidate.livingInGermany, euPassport: snapshot.candidate.euPassport, languageCertificateProvided: snapshot.candidate.languageCertificateProvided, languageSelfAssessmentLevel: snapshot.candidate.languageSelfAssessmentLevel, qualificationStatusGermany: snapshot.candidate.qualificationStatusGermany, licenseMandatory: snapshot.candidate.licenseMandatory, recognitionLikelihood: snapshot.candidate.recognitionLikelihood, lifecycleNotifications: snapshot.candidate.lifecycleNotifications, }, candidateDocuments: snapshot.candidateDocuments.map((document) => ({ id: document.documentId, category: document.category, uploaded: document.uploaded, status: document.documentStatus, updatedAt: document.lastUpdatedAt?.toISOString?.() || null, })), dossierDocuments: snapshot.dossierDocuments .filter((document) => document.dossierId === dossier.dossierId) .map((document) => ({ id: document.documentId, category: document.category, uploaded: document.uploaded, status: document.documentStatus, updatedAt: document.lastUpdatedAt?.toISOString?.() || null, })), structured: snapshot.structured, }); } hashJson(value) { return createHash('sha256') .update(JSON.stringify(value)) .digest('hex'); } getSubmissionGuide(rule) { return { authorityName: rule.authorityName, authorityWebsiteUrl: rule.authorityWebsiteUrl, submissionMode: rule.submissionMode, submissionUrl: rule.submissionUrl, postalAddress: rule.postalAddress, contactEmail: rule.contactEmail, contactPhone: rule.contactPhone, notes: rule.notes, lastVerifiedAt: rule.lastVerifiedAt, }; } async toDetail(dossier) { const summary = this.toSummary(dossier); const documents = await this.kysely .selectFrom('recognitionDossierDocument') .selectAll() .where('dossierId', '=', dossier.dossierId) .orderBy('createdAt', 'desc') .execute(); return { ...summary, supported: dossier.status !== RecognitionDossierStatuses.UNSUPPORTED, packageFilePath: dossier.packageFilePath, downloadUrl: dossier.packageFilePath ? await this.getSignedPackageUrl(dossier.packageFilePath) : null, submissionGuide: this.readSubmissionGuide(dossier.submissionGuideJson), checklist: this.readChecklist(dossier.checklistJson), documents: documents.map((document) => this.toDocumentSummary(document)), }; } toSummary(dossier) { return { dossierId: dossier.dossierId, candidateId: dossier.candidateId, targetState: dossier.targetState, targetProfession: dossier.targetProfession, status: dossier.status, rulesVersion: dossier.rulesVersion, generatedAt: dossier.generatedAt, lastUpdatedAt: dossier.lastUpdatedAt, hasPackage: Boolean(dossier.packageFilePath), blockingIssues: this.readBlockingIssues(dossier.blockingIssuesJson), }; } toDocumentSummary(document) { return { documentId: document.documentId, candidateId: document.candidateId, dossierId: document.dossierId, category: document.category, title: document.title, note: document.note, fileName: document.fileName, fileSize: document.fileSize, mimeType: document.mimeType, uploaded: Boolean(document.uploaded), documentStatus: document.documentStatus, createdAt: document.createdAt, lastUpdatedAt: document.lastUpdatedAt, }; } async getSignedPackageUrl(filePath) { const { bucket, key } = toBucketKey(filePath); return await this.content.signContentKey({ bucket, contentKey: key, dateLessThan: dayjs().add(1, 'hour').toDate(), }); } getDossierDocumentAssetKey(input) { return `recognition-dossier/${input.candidateId}/${input.dossierId}/documents/${input.documentId}-${sanitizeFilename(input.fileName)}`; } getPackageAssetKey(input) { const timestamp = input.generatedAt .toISOString() .replace(/[-:]/g, '') .replace(/\.\d{3}Z$/, 'Z'); return `recognition-dossier/${input.candidateId}/${input.dossierId}/packages/${timestamp}.zip`; } async buildPackageArchive(dossier, snapshot) { const checklist = dossier.checklist; const submissionGuide = dossier.submissionGuide; const entries = [ { name: 'README.txt', data: Buffer.from(this.buildReadme(dossier), 'utf8'), }, { name: 'submission-guide/checklist.json', data: Buffer.from(JSON.stringify(checklist, null, 2), 'utf8'), }, { name: 'submission-guide/checklist.csv', data: Buffer.from(this.buildChecklistCsv(checklist), 'utf8'), }, { name: 'submission-guide/submission-guide.json', data: Buffer.from(JSON.stringify(submissionGuide, null, 2), 'utf8'), }, ]; const usedNames = new Set(entries.map((entry) => entry.name)); const candidateDocuments = snapshot.candidateDocuments.filter((document) => document.uploaded && document.category !== CandidateDocumentTypes.PROFILE_IMAGE); const dossierDocuments = snapshot.dossierDocuments.filter((document) => document.dossierId === dossier.dossierId && document.uploaded); for (const document of candidateDocuments) { const entryName = this.getUniqueZipEntryName(usedNames, `candidate-documents/${document.category}/${document.documentId}-${sanitizeFilename(document.fileName)}`); const buffer = await this.content.readFileAsBuffer(toBucketKey(document.filePath)); entries.push({ name: entryName, data: buffer }); } for (const document of dossierDocuments) { const entryName = this.getUniqueZipEntryName(usedNames, `dossier-documents/${document.category}/${document.documentId}-${sanitizeFilename(document.fileName)}`); const buffer = await this.content.readFileAsBuffer(toBucketKey(document.filePath)); entries.push({ name: entryName, data: buffer }); } return this.createZipArchive(entries); } getUniqueZipEntryName(usedNames, initialName) { if (!usedNames.has(initialName)) { usedNames.add(initialName); return initialName; } const extensionMatch = /(\.[^.]+)$/.exec(initialName); const extension = extensionMatch?.[1] || ''; const baseName = extension ? initialName.slice(0, -extension.length) : initialName; let suffix = 2; while (true) { const candidate = `${baseName}-${suffix}${extension}`; if (!usedNames.has(candidate)) { usedNames.add(candidate); return candidate; } suffix += 1; } } createZipArchive(entries) { const localParts = []; const centralParts = []; let offset = 0; for (const entry of entries) { const name = Buffer.from(entry.name.replace(/\\/g, '/'), 'utf8'); const data = entry.data; const checksum = crc32(data); const { dosTime, dosDate } = toZipDosTime(new Date()); const localHeader = Buffer.alloc(30); localHeader.writeUInt32LE(0x04034b50, 0); localHeader.writeUInt16LE(20, 4); localHeader.writeUInt16LE(0, 6); localHeader.writeUInt16LE(0, 8); localHeader.writeUInt16LE(dosTime, 10); localHeader.writeUInt16LE(dosDate, 12); localHeader.writeUInt32LE(checksum, 14); localHeader.writeUInt32LE(data.length, 18); localHeader.writeUInt32LE(data.length, 22); localHeader.writeUInt16LE(name.length, 26); localHeader.writeUInt16LE(0, 28); localParts.push(localHeader, name, data); const centralHeader = Buffer.alloc(46); centralHeader.writeUInt32LE(0x02014b50, 0); centralHeader.writeUInt16LE(20, 4); centralHeader.writeUInt16LE(20, 6); centralHeader.writeUInt16LE(0, 8); centralHeader.writeUInt16LE(0, 10); centralHeader.writeUInt16LE(dosTime, 12); centralHeader.writeUInt16LE(dosDate, 14); centralHeader.writeUInt32LE(checksum, 16); centralHeader.writeUInt32LE(data.length, 20); centralHeader.writeUInt32LE(data.length, 24); centralHeader.writeUInt16LE(name.length, 28); centralHeader.writeUInt16LE(0, 30); centralHeader.writeUInt16LE(0, 32); centralHeader.writeUInt16LE(0, 34); centralHeader.writeUInt16LE(0, 36); centralHeader.writeUInt32LE(0, 38); centralHeader.writeUInt32LE(offset, 42); centralParts.push(centralHeader, name); offset += localHeader.length + name.length + data.length; } const centralDirectory = Buffer.concat(centralParts); const centralDirectoryOffset = offset; const endRecord = Buffer.alloc(22); endRecord.writeUInt32LE(0x06054b50, 0); endRecord.writeUInt16LE(0, 4); endRecord.writeUInt16LE(0, 6); endRecord.writeUInt16LE(entries.length, 8); endRecord.writeUInt16LE(entries.length, 10); endRecord.writeUInt32LE(centralDirectory.length, 12); endRecord.writeUInt32LE(centralDirectoryOffset, 16); endRecord.writeUInt16LE(0, 20); return Buffer.concat([...localParts, centralDirectory, endRecord]); } buildChecklistCsv(checklist) { const rows = [ ['id', 'label', 'status', 'required', 'source_label', 'notes'].join(','), ...checklist.map((item) => [ csvEscape(item.id), csvEscape(item.label), csvEscape(item.status), csvEscape(item.required), csvEscape(item.sourceLabel || ''), csvEscape(item.notes.join(' | ')), ].join(',')), ]; return `${rows.join('\n')}\n`; } buildReadme(dossier) { return [ 'Recognition dossier package', '', `Candidate ID: ${dossier.candidateId}`, `State: ${dossier.targetState}`, `Target profession: ${dossier.targetProfession}`, `Generated at: ${new Date().toISOString()}`, `Rules version: ${dossier.rulesVersion || RULES_VERSION}`, '', 'Contents:', '- submission-guide/submission-guide.json', '- submission-guide/checklist.json', '- submission-guide/checklist.csv', '- candidate-documents/*', '- dossier-documents/*', '', 'Notes:', '- Existing candidate uploads remain the source evidence reused in this package.', '- Dossier-only attachments cover state-specific statutory gaps.', '- Regenerate the package whenever source documents or checklist readiness change.', '', ].join('\n'); } } //# sourceMappingURL=recognition-dossier-service.js.map