// Domain helpers shared by the import script (plain node), the Nitro server routes // and the browser form. Plain ESM on purpose so `node scripts/*.mjs` can import it // directly — do not convert this file to TypeScript. /** * Second-level suffixes that are NOT registrable on their own. Kept deliberately small: * a full Public Suffix List is overkill for German hospital domains, but these show up. */ const SECOND_LEVEL_SUFFIXES = new Set([ 'co', 'com', 'org', 'net', 'gov', 'edu', 'ac', 'or', 'ne', 'go', 'mil', 'nom', ]) /** * Freemail / consumer mailbox providers. A hospital account must never be registered * with one of these — the address would prove nothing about the institution. */ export const FREEMAIL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com', 'gmx.info', 'gmx.biz', 'web.de', 'freenet.de', 'arcor.de', 'online.de', 'email.de', 'mail.de', 't-online.de', 'telekom.de', 'magenta.de', 'outlook.com', 'outlook.de', 'outlook.at', 'hotmail.com', 'hotmail.de', 'live.com', 'live.de', 'live.at', 'msn.com', 'windowslive.com', 'yahoo.com', 'yahoo.de', 'ymail.com', 'rocketmail.com', 'aol.com', 'aol.de', 'icloud.com', 'me.com', 'mac.com', 'posteo.de', 'posteo.net', 'mailbox.org', 'protonmail.com', 'protonmail.ch', 'proton.me', 'tutanota.com', 'tutanota.de', 'tuta.io', 'tuta.com', 'zoho.com', 'yandex.com', 'yandex.ru', 'mail.ru', 'gmx.us', 'firemail.de', 'unitybox.de', 'unity-mail.de', 'kabelmail.de', 'vodafone.de', 'ewetel.net', 'nexgo.de', 'inbox.com', 'fastmail.com', 'hushmail.com', 'trashmail.com', 'trashmail.de', 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'wegwerfmail.de', 'temp-mail.org', 'yopmail.com', ]) /** Lowercase + strip whitespace/trailing dots. */ const clean = (value) => String(value ?? '').trim().toLowerCase().replace(/\.+$/, '') /** * Host part of a URL (or of a bare "www.example.de/pfad" string), without www. prefix. * Returns '' when nothing usable is found. */ export function hostFromUrl(url) { let raw = clean(url) if (!raw) return '' raw = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // strip scheme raw = raw.replace(/^[^@/]*@/, '') // strip user:pass@ raw = raw.split(/[/?#]/)[0] || '' // strip path/query/hash raw = raw.split(':')[0] || '' // strip port raw = raw.replace(/^www\d*\./, '') // strip www. / www2. if (!raw || !raw.includes('.')) return '' if (!/^[a-z0-9.-]+$/.test(raw)) return '' return raw.replace(/\.+$/, '') } /** Domain part of an e-mail address (everything after the last @), without www. */ export function hostFromEmail(email) { const raw = clean(email) const at = raw.lastIndexOf('@') if (at < 0) return '' return hostFromUrl(raw.slice(at + 1)) } /** * Registrable domain ("eTLD+1") of a host: klinik.helios-gesundheit.de → helios-gesundheit.de. * Collapsing subdomains is intentional — a hospital group shares ONE registrable domain * across many Standorte, and that is exactly the key we match registrations against. */ export function registrableDomain(host) { const h = hostFromUrl(host) if (!h) return '' const parts = h.split('.').filter(Boolean) if (parts.length < 2) return '' const last = parts[parts.length - 1] const secondLast = parts[parts.length - 2] // e.g. example.co.uk / example.com.de → keep three labels if (parts.length >= 3 && last.length === 2 && SECOND_LEVEL_SUFFIXES.has(secondLast)) { return parts.slice(-3).join('.') } return parts.slice(-2).join('.') } /** Registrable domain derived from a URL string. */ export const domainFromUrl = (url) => registrableDomain(hostFromUrl(url)) /** Registrable domain derived from an e-mail address. */ export const domainFromEmail = (email) => registrableDomain(hostFromEmail(email)) /** True when the address belongs to a consumer mailbox provider. */ export function isFreemailDomain(domain) { const d = registrableDomain(domain) if (!d) return false return FREEMAIL_DOMAINS.has(d) || FREEMAIL_DOMAINS.has(clean(domain)) } /** Syntactic e-mail check — deliberately permissive, the confirmation mail is the real proof. */ export function isValidEmail(email) { const raw = clean(email) if (!raw || raw.length > 254) return false return /^[^\s@,;:<>()[\]\\"]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(raw) } /** * German umlaut/ß transliteration plus a diacritic-free variant, so that both * "München" and "Muenchen"/"Munchen" find the same Standort in the FTS index. */ export function searchVariants(text) { const base = String(text ?? '').toLowerCase() if (!base) return [] const expanded = base .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') .replace(/ß/g, 'ss').replace(/é|è|ê/g, 'e').replace(/á|à|â/g, 'a') const stripped = base .replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/ü/g, 'u').replace(/ß/g, 's') .normalize('NFD').replace(/[̀-ͯ]/g, '') const out = new Set([base]) if (expanded !== base) out.add(expanded) if (stripped !== base) out.add(stripped) return [...out] } export default { FREEMAIL_DOMAINS, hostFromUrl, hostFromEmail, registrableDomain, domainFromUrl, domainFromEmail, isFreemailDomain, isValidEmail, searchVariants, }