1395 lines
65 KiB
JavaScript
1395 lines
65 KiB
JavaScript
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
|