1720 lines
63 KiB
TypeScript
1720 lines
63 KiB
TypeScript
import { createHash } from 'crypto'
|
|
import { Readable } from 'stream'
|
|
import type { ContentService, Logger } from '@pikku/core/services'
|
|
import { BadRequestError, NotFoundError } from '@pikku/core/errors'
|
|
import type {
|
|
DB,
|
|
RecognitionDossierBlockingIssue,
|
|
RecognitionDossierChecklistItem,
|
|
RecognitionDossierDetail,
|
|
RecognitionDossierDocumentSummary,
|
|
RecognitionDossierSubmissionGuide,
|
|
RecognitionDossierSummary,
|
|
} from '@heygermany/sdk'
|
|
import {
|
|
CandidateDocumentTypes,
|
|
DocumentStatuses,
|
|
GermanStates,
|
|
LanguageLevels,
|
|
RecognitionDossierDocumentCategories,
|
|
RecognitionDossierStatuses,
|
|
} from '@heygermany/sdk'
|
|
import dayjs from 'dayjs'
|
|
import type { Kysely, Selectable } from 'kysely'
|
|
import type { KyselyDB } from '../database-types.js'
|
|
import { toBucketKey } from '../content-path.js'
|
|
import { normalizeStringArray } from '../normalizers.js'
|
|
|
|
type Rule = {
|
|
state: DB.GermanState
|
|
profession: DB.NurseRecognitionGermany
|
|
targetTitle: string
|
|
authorityName: string | null
|
|
authorityWebsiteUrl: string | null
|
|
submissionMode: string | null
|
|
submissionUrl: string | null
|
|
postalAddress: string[]
|
|
contactEmail: string | null
|
|
contactPhone: string | null
|
|
notes: string[]
|
|
lastVerifiedAt: string
|
|
requiresEmployerIntent: boolean
|
|
requiresAuthorityForm: boolean
|
|
supportsWaiver: boolean
|
|
minimumLanguageLevel: DB.LanguageLevel
|
|
languageMaxAgeYears: number | null
|
|
languageIssuerPatterns: RegExp[]
|
|
unsupportedReason: string | null
|
|
}
|
|
|
|
type CandidateSnapshot = Awaited<
|
|
ReturnType<RecognitionDossierService['getCandidateSnapshot']>
|
|
>
|
|
|
|
type DossierRow = Selectable<KyselyDB['recognitionDossier']>
|
|
type DossierDocumentRow = Selectable<KyselyDB['recognitionDossierDocument']>
|
|
type CandidateDocumentRow = Selectable<KyselyDB['candidateDocument']>
|
|
|
|
type UploadRequest = {
|
|
candidateId: string
|
|
dossierId: string
|
|
documents: Array<{
|
|
contentType: string
|
|
size: number
|
|
category: DB.RecognitionDossierDocumentCategory
|
|
fileName: string
|
|
}>
|
|
}
|
|
|
|
const RULES_VERSION = 'pilot-v1-2026-04-21'
|
|
const LAST_VERIFIED_AT = '2026-04-21'
|
|
|
|
const LANGUAGE_LEVEL_ORDER: Record<DB.LanguageLevel, number> = {
|
|
NONE: 0,
|
|
A_ONE: 1,
|
|
A_TWO: 2,
|
|
B_ONE: 3,
|
|
B_TWO: 4,
|
|
C_ONE: 5,
|
|
C_TWO: 6,
|
|
}
|
|
|
|
const createRule = (rule: Rule): Rule => rule
|
|
|
|
const RULES: Partial<Record<DB.GermanState, Partial<Record<DB.NurseRecognitionGermany, Rule>>>> = {
|
|
[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: {},
|
|
} as const
|
|
|
|
const defaultRule = (
|
|
state: DB.GermanState,
|
|
profession: DB.NurseRecognitionGermany
|
|
): Rule => ({
|
|
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: Buffer) => {
|
|
let value = 0xffffffff
|
|
for (const byte of buffer) {
|
|
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8)
|
|
}
|
|
return (value ^ 0xffffffff) >>> 0
|
|
}
|
|
|
|
const toZipDosTime = (date: 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 = <T>(value: unknown, fallback: T): T => {
|
|
if (value == null) {
|
|
return fallback
|
|
}
|
|
if (typeof value === 'string') {
|
|
try {
|
|
return safeJsonValue(JSON.parse(value), fallback)
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
return value as T
|
|
}
|
|
|
|
const sanitizeFilename = (fileName: string) =>
|
|
fileName
|
|
.normalize('NFKD')
|
|
.replace(/[^\x20-\x7E]+/g, '')
|
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
.replace(/-{2,}/g, '-')
|
|
.replace(/^-+|-+$/g, '') || 'file'
|
|
|
|
const csvEscape = (value: unknown) => {
|
|
const text = String(value ?? '')
|
|
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
}
|
|
|
|
const extFromContentType = (contentType: string) => {
|
|
const suffix = contentType.split('/').pop()?.trim().toLowerCase()
|
|
return suffix || 'bin'
|
|
}
|
|
|
|
const resolveStoredFileName = (fileName: string, contentType: string) => {
|
|
return /\.[A-Za-z0-9]+$/.test(fileName)
|
|
? fileName
|
|
: `${fileName}.${extFromContentType(contentType)}`
|
|
}
|
|
|
|
const levelAtLeast = (
|
|
actualLevel: DB.LanguageLevel | null | undefined,
|
|
minimumLevel: DB.LanguageLevel | null | undefined
|
|
) => {
|
|
if (!actualLevel || !minimumLevel) {
|
|
return false
|
|
}
|
|
|
|
return LANGUAGE_LEVEL_ORDER[actualLevel] >= LANGUAGE_LEVEL_ORDER[minimumLevel]
|
|
}
|
|
|
|
export class RecognitionDossierService {
|
|
constructor(
|
|
private readonly kysely: Kysely<KyselyDB>,
|
|
private readonly content: ContentService,
|
|
private readonly logger: Logger
|
|
) {}
|
|
|
|
public async listCandidateDossiers(
|
|
candidateId: string
|
|
): Promise<RecognitionDossierSummary[]> {
|
|
await this.ensureCandidateExists(candidateId)
|
|
|
|
const dossiers = await this.kysely
|
|
.selectFrom('recognitionDossier')
|
|
.selectAll()
|
|
.where('candidateId', '=', candidateId)
|
|
.orderBy('createdAt', 'desc')
|
|
.execute()
|
|
|
|
const summaries: RecognitionDossierSummary[] = []
|
|
for (const dossier of dossiers) {
|
|
const refreshed = await this.refreshDossier(dossier)
|
|
summaries.push(this.toSummary(refreshed))
|
|
}
|
|
|
|
return summaries
|
|
}
|
|
|
|
public async getCandidateDossier(
|
|
candidateId: string,
|
|
dossierId: string
|
|
): Promise<RecognitionDossierDetail> {
|
|
const dossier = await this.getDossierRow(candidateId, dossierId)
|
|
const refreshed = await this.refreshDossier(dossier)
|
|
return await this.toDetail(refreshed)
|
|
}
|
|
|
|
public async createOrRefreshDossier(input: {
|
|
candidateId: string
|
|
targetState: DB.GermanState
|
|
targetProfession: DB.NurseRecognitionGermany
|
|
}): Promise<RecognitionDossierDetail> {
|
|
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)
|
|
}
|
|
|
|
public async requestDocumentUploads(input: UploadRequest) {
|
|
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,
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
public async completeDocumentUpload(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
documentId: string
|
|
uploaded: boolean
|
|
}): Promise<RecognitionDossierDetail> {
|
|
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)
|
|
}
|
|
|
|
public async deleteDocument(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
documentId: string
|
|
}): Promise<RecognitionDossierDetail> {
|
|
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)
|
|
}
|
|
|
|
public async generatePackage(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
}): Promise<RecognitionDossierDetail> {
|
|
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)
|
|
}
|
|
|
|
public async getDownloadUrl(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
}): Promise<string> {
|
|
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(),
|
|
})
|
|
}
|
|
|
|
private async ensureCandidateExists(candidateId: string) {
|
|
await this.kysely
|
|
.selectFrom('candidate')
|
|
.select('candidateId')
|
|
.where('candidateId', '=', candidateId)
|
|
.executeTakeFirstOrThrow(() => new NotFoundError('Candidate not found'))
|
|
}
|
|
|
|
private async getCandidateSnapshot(candidateId: string) {
|
|
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) as DB.GermanState[] | null,
|
|
},
|
|
candidateDocuments,
|
|
dossierDocuments,
|
|
structured: {
|
|
education,
|
|
license,
|
|
language,
|
|
experiences,
|
|
},
|
|
}
|
|
}
|
|
|
|
private async getDossierRow(candidateId: string, dossierId: string) {
|
|
return await this.kysely
|
|
.selectFrom('recognitionDossier')
|
|
.selectAll()
|
|
.where('candidateId', '=', candidateId)
|
|
.where('dossierId', '=', dossierId)
|
|
.executeTakeFirstOrThrow(() => new NotFoundError('Recognition dossier not found'))
|
|
}
|
|
|
|
private async getDossierDocumentRow(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
documentId: string
|
|
}) {
|
|
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')
|
|
)
|
|
}
|
|
|
|
private resolveRule(
|
|
state: DB.GermanState,
|
|
profession: DB.NurseRecognitionGermany
|
|
): Rule {
|
|
return RULES_BY_STATE_AND_PROFESSION[state]?.[profession] ?? defaultRule(state, profession)
|
|
}
|
|
|
|
private readChecklist(value: unknown): RecognitionDossierChecklistItem[] {
|
|
return safeJsonValue<RecognitionDossierChecklistItem[]>(value, [])
|
|
}
|
|
|
|
private readBlockingIssues(value: unknown): RecognitionDossierBlockingIssue[] {
|
|
return safeJsonValue<RecognitionDossierBlockingIssue[]>(value, [])
|
|
}
|
|
|
|
private readSubmissionGuide(
|
|
value: unknown
|
|
): RecognitionDossierSubmissionGuide | null {
|
|
const parsed = safeJsonValue<RecognitionDossierSubmissionGuide | null>(value, null)
|
|
return parsed && typeof parsed === 'object' ? parsed : null
|
|
}
|
|
|
|
private async refreshDossier(dossier: DossierRow): Promise<DossierRow> {
|
|
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()
|
|
}
|
|
|
|
private buildChecklist(input: {
|
|
candidate: CandidateSnapshot['candidate']
|
|
structured: CandidateSnapshot['structured']
|
|
candidateDocuments: CandidateDocumentRow[]
|
|
dossierDocuments: DossierDocumentRow[]
|
|
rule: Rule
|
|
}): RecognitionDossierChecklistItem[] {
|
|
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.'],
|
|
}),
|
|
]
|
|
}
|
|
|
|
private buildBlockingIssues(input: {
|
|
candidate: CandidateSnapshot['candidate']
|
|
language: CandidateSnapshot['structured']['language']
|
|
checklist: RecognitionDossierChecklistItem[]
|
|
rule: Rule
|
|
}) {
|
|
if (input.rule.unsupportedReason) {
|
|
return [
|
|
{
|
|
code: 'unsupported',
|
|
message: input.rule.unsupportedReason,
|
|
},
|
|
] satisfies RecognitionDossierBlockingIssue[]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private candidateDocumentItem(input: {
|
|
id: string
|
|
label: string
|
|
documents?: CandidateDocumentRow[]
|
|
structuredPresent: boolean
|
|
required: boolean
|
|
notes: string[]
|
|
}): RecognitionDossierChecklistItem {
|
|
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,
|
|
})
|
|
}
|
|
|
|
private dossierDocumentItem(input: {
|
|
id: string
|
|
label: string
|
|
documents?: DossierDocumentRow[]
|
|
required: boolean
|
|
notes: string[]
|
|
}): RecognitionDossierChecklistItem {
|
|
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,
|
|
})
|
|
}
|
|
|
|
private buildChecklistItem(input: {
|
|
id: string
|
|
label: string
|
|
status: string
|
|
source: string | null
|
|
sourceLabel: string | null
|
|
required: boolean
|
|
notes: string[]
|
|
}): RecognitionDossierChecklistItem {
|
|
return {
|
|
id: input.id,
|
|
label: input.label,
|
|
status: input.status,
|
|
message: null,
|
|
source: input.source,
|
|
sourceLabel: input.sourceLabel,
|
|
required: input.required,
|
|
notes: input.notes,
|
|
}
|
|
}
|
|
|
|
private getDocumentsStatus(
|
|
documents: Array<{ documentStatus: DB.DocumentStatus }>
|
|
) {
|
|
if (documents.length === 0) {
|
|
return 'missing'
|
|
}
|
|
|
|
if (
|
|
documents.some(
|
|
(document) => document.documentStatus === DocumentStatuses.VERIFIED
|
|
)
|
|
) {
|
|
return 'present_verified'
|
|
}
|
|
|
|
return 'present_unverified'
|
|
}
|
|
|
|
private optionalizeStatus(status: string) {
|
|
return status === 'missing' ? 'optional' : status
|
|
}
|
|
|
|
private getSourceForDocuments(
|
|
documents: Array<CandidateDocumentRow | DossierDocumentRow>,
|
|
fallback: string | null
|
|
) {
|
|
if (documents.length === 0) {
|
|
return fallback
|
|
}
|
|
|
|
return 'dossierId' in documents[0] ? 'dossier_document' : 'candidate_document'
|
|
}
|
|
|
|
private getSourceLabelForDocuments(
|
|
documents: Array<CandidateDocumentRow | DossierDocumentRow>,
|
|
fallback: string | null
|
|
) {
|
|
if (documents.length === 0) {
|
|
return fallback
|
|
}
|
|
|
|
return 'dossierId' in documents[0] ? 'Dossier attachment' : 'Candidate evidence'
|
|
}
|
|
|
|
private getLanguageNotes(rule: 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
|
|
}
|
|
|
|
private computeStatus(input: {
|
|
supported: boolean
|
|
blockingIssues: RecognitionDossierBlockingIssue[]
|
|
packageFilePath: string | null
|
|
}): DB.RecognitionDossierStatus {
|
|
if (!input.supported) {
|
|
return RecognitionDossierStatuses.UNSUPPORTED
|
|
}
|
|
if (input.blockingIssues.length > 0) {
|
|
return RecognitionDossierStatuses.BLOCKED
|
|
}
|
|
if (input.packageFilePath) {
|
|
return RecognitionDossierStatuses.GENERATED
|
|
}
|
|
return RecognitionDossierStatuses.READY
|
|
}
|
|
|
|
private groupDocumentsByCategory<
|
|
T extends { category: string }
|
|
>(documents: T[]) {
|
|
return documents.reduce<Record<string, T[]>>((groups, document) => {
|
|
groups[document.category] ??= []
|
|
groups[document.category].push(document)
|
|
return groups
|
|
}, {})
|
|
}
|
|
|
|
private buildInputFingerprint(
|
|
dossier: DossierRow,
|
|
snapshot: CandidateSnapshot
|
|
) {
|
|
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,
|
|
})
|
|
}
|
|
|
|
private hashJson(value: unknown) {
|
|
return createHash('sha256')
|
|
.update(JSON.stringify(value))
|
|
.digest('hex')
|
|
}
|
|
|
|
private getSubmissionGuide(rule: Rule): RecognitionDossierSubmissionGuide {
|
|
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,
|
|
}
|
|
}
|
|
|
|
private async toDetail(dossier: DossierRow): Promise<RecognitionDossierDetail> {
|
|
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)),
|
|
}
|
|
}
|
|
|
|
private toSummary(dossier: DossierRow): RecognitionDossierSummary {
|
|
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),
|
|
}
|
|
}
|
|
|
|
private toDocumentSummary(
|
|
document: DossierDocumentRow
|
|
): RecognitionDossierDocumentSummary {
|
|
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,
|
|
}
|
|
}
|
|
|
|
private async getSignedPackageUrl(filePath: string) {
|
|
const { bucket, key } = toBucketKey(filePath)
|
|
return await this.content.signContentKey({
|
|
bucket,
|
|
contentKey: key,
|
|
dateLessThan: dayjs().add(1, 'hour').toDate(),
|
|
})
|
|
}
|
|
|
|
private getDossierDocumentAssetKey(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
documentId: string
|
|
fileName: string
|
|
}) {
|
|
return `recognition-dossier/${input.candidateId}/${input.dossierId}/documents/${input.documentId}-${sanitizeFilename(input.fileName)}`
|
|
}
|
|
|
|
private getPackageAssetKey(input: {
|
|
candidateId: string
|
|
dossierId: string
|
|
generatedAt: Date
|
|
}) {
|
|
const timestamp = input.generatedAt
|
|
.toISOString()
|
|
.replace(/[-:]/g, '')
|
|
.replace(/\.\d{3}Z$/, 'Z')
|
|
return `recognition-dossier/${input.candidateId}/${input.dossierId}/packages/${timestamp}.zip`
|
|
}
|
|
|
|
private async buildPackageArchive(
|
|
dossier: RecognitionDossierDetail,
|
|
snapshot: CandidateSnapshot
|
|
) {
|
|
const checklist = dossier.checklist
|
|
const submissionGuide = dossier.submissionGuide
|
|
const entries: Array<{ name: string; data: Buffer }> = [
|
|
{
|
|
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)
|
|
}
|
|
|
|
private getUniqueZipEntryName(usedNames: Set<string>, initialName: string) {
|
|
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
|
|
}
|
|
}
|
|
|
|
private createZipArchive(entries: Array<{ name: string; data: Buffer }>) {
|
|
const localParts: Buffer[] = []
|
|
const centralParts: Buffer[] = []
|
|
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])
|
|
}
|
|
|
|
private buildChecklistCsv(checklist: RecognitionDossierChecklistItem[]) {
|
|
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`
|
|
}
|
|
|
|
private buildReadme(dossier: RecognitionDossierDetail) {
|
|
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')
|
|
}
|
|
}
|