// Validation core for the two registration types. Pure (no I/O beyond the read-only // clinics.db lookups) so it can be exercised directly by scripts/test-registration.mjs. import type { Clinic } from './clinicDb' import type { RegistrationType } from './registrationTypes' import { isSubmittableType } from './registrationTypes' import { asLang, serverMessages, type ApiLang } from './i18n' export type RegistrationInput = { type?: string stoid?: string orgName?: string street?: string plz?: string city?: string land?: string contactName?: string email?: string phone?: string note?: string acceptTerms?: boolean /** Display language of the form ('de' | 'en'); decides the language of `errors`. */ lang?: string } export type ValidationResult = { ok: boolean /** field name → error message, in `lang` */ errors: Record /** Normalised language of this registration — stored on the row, drives the mails. */ lang: ApiLang /** Standorte that share the address' domain — offered when the pick was wrong */ domainClinics: Array<{ stoid: string, name: string, plz: string, city: string }> needsManualApproval: boolean domainVerified: boolean /** Accepted via a REGISTRATION_TEST_DOMAINS bypass, not by a real domain match. */ isTestRegistration: boolean emailDomain: string clinic: Clinic | null values: { type: RegistrationType stoid: string | null orgName: string street: string plz: string city: string land: string contactName: string email: string phone: string note: string } } const text = (v: any, max = 200) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, max) /** * Test domains that may register for ANY clinic, bypassing the * e-mail-domain ↔ Standort correlation. * * Normally an address must belong to the chosen clinic's own domain — that check is * the only thing stopping a stranger from registering someone else's hospital, so it * is never relaxed for real users. These domains exist purely so the operator can * exercise the flow end-to-end against arbitrary Standorte. * * Configured through REGISTRATION_TEST_DOMAINS (comma-separated). Empty/unset — the * default — means the bypass does not exist at all. Every registration that uses it * is flagged in the stored record so a test signup can never be mistaken for a * verified one, and the duplicate guard still applies (one registration per Standort). */ const TEST_DOMAINS: string[] = String(process.env.REGISTRATION_TEST_DOMAINS ?? '') .split(',') .map((d) => d.trim().toLowerCase().replace(/^www\./, '')) .filter(Boolean) /** True when this address may register for any clinic regardless of its domain. */ export const isTestDomain = (domain: string): boolean => TEST_DOMAINS.length > 0 && TEST_DOMAINS.includes(String(domain ?? '').toLowerCase()) export const validateRegistration = (input: RegistrationInput): ValidationResult => { const lang = asLang(input.lang) const m = serverMessages(lang).validation const errors: Record = {} // SUBMIT path: only the types that are actually offered are accepted. The retired // 'abgebende_klinik' / 'beziehende_klinik' are NOT normalised here on purpose — they are // no longer on the form, so receiving one means a stale client or a hand-crafted request, // and answering `errors.type` is the honest response. The READ path (labels, confirm, // mails, ERP onboarding) still accepts them via normaliseType(); see registrationTypes.ts. const type = isSubmittableType(input.type) ? (String(input.type) as RegistrationType) : ('' as RegistrationType) const email = text(input.email, 254).toLowerCase() const values = { type, stoid: text(input.stoid, 20) || null, orgName: text(input.orgName, 160), street: text(input.street, 160), plz: text(input.plz, 10), city: text(input.city, 120), land: text(input.land, 40), contactName: text(input.contactName, 120), email, phone: text(input.phone, 60), note: text(input.note, 1000), } if (!type) errors.type = m.type if (!values.contactName || values.contactName.length < 3) { errors.contactName = m.contactName } if (!input.acceptTerms) { errors.acceptTerms = m.acceptTerms } // ---- e-mail ------------------------------------------------------------------------ let emailDomain = '' if (!email) { errors.email = m.emailRequired } else if (!isValidEmail(email)) { errors.email = m.emailInvalid } else { emailDomain = domainFromEmail(email) if (!emailDomain) { errors.email = m.emailInvalid } else if (isFreemailDomain(emailDomain) && type !== 'kurierdienst') { // Couriers are not tied to a clinic domain and every courier registration is // reviewed manually, so a small firm on a freemail address is not turned away. // For clinics the block stays: a hospital has a domain, and it is what proves // the registrant belongs to the Standort they picked. errors.email = m.emailFreemail } } // ---- type-specific ----------------------------------------------------------------- let clinic: Clinic | null = null let needsManualApproval = false let domainVerified = false let isTestRegistration = false let domainClinics: ValidationResult['domainClinics'] = [] // One clinic branch, unchanged from the three-type flow: the supplying/receiving // distinction never affected a single rule here. Whether a clinic supplies, receives or // does both is now decided at first sign-in in the ERP (IsVendor / IsCustomer). const isClinic = type === 'klinik' if (isClinic) { clinic = values.stoid ? clinicByStoid(values.stoid) : null if (!clinic) { errors.stoid = m.stoid } else { values.orgName = clinic.name values.street = clinic.street values.plz = clinic.plz values.city = clinic.city values.land = clinic.land if (!errors.email && emailDomain) { if (isTestDomain(emailDomain)) { // Operator test domain — deliberately NOT correlated with this or any other // Standort. Accepted for whichever clinic was picked; flagged as a test // registration downstream rather than counted as domain-verified. isTestRegistration = true domainVerified = false } else if (clinic.domains.length === 0) { // No domain on record for this Standort → cannot be verified automatically. needsManualApproval = true } else if (clinic.domains.includes(emailDomain)) { domainVerified = true } else { // The address may still belong to a hospital group — offer its Standorte. domainClinics = clinicsByDomain(emailDomain).map((c) => ({ stoid: c.stoid, name: c.name, plz: c.plz, city: c.city, })) errors.email = domainClinics.length ? m.domainMismatchWithSites(emailDomain, clinic.name) : m.domainMismatchNoSites(String(clinic.domains[0] ?? '')) } } } } else if (type === 'kurierdienst') { // No Bundes-Klinik-Atlas equivalent for couriers → always manual approval. needsManualApproval = true if (!values.orgName || values.orgName.length < 3) { errors.orgName = m.orgName } if (!values.street) errors.street = m.street if (!/^\d{5}$/.test(values.plz)) errors.plz = m.plz if (!values.city) errors.city = m.city if (!values.land) values.land = '' } return { ok: Object.keys(errors).length === 0, errors, lang, domainClinics, needsManualApproval, domainVerified, isTestRegistration, emailDomain, clinic, values, } }