// POST /api/registration/submit // // Stores a pending registration and sends the double-opt-in mail. Two types: // klinik → Standort from the Bundes-Klinik-Atlas, e-mail domain must match it // kurierdienst → free company form, always manual approval // // Manual approval is the EXCEPTION, not the rule: see the `needs_manual_approval` comment // at the insert below. A registration the validator can decide (domain matches the chosen // Standort, or an operator test domain) is activated automatically and the ERP mails the // credentials after the double opt-in. // // Whether a clinic supplies units, receives them or does both is NOT asked here any more: // it is chosen at the user's first sign-in to Blut24, where at least one of the two roles // is mandatory. The retired 'abgebende_klinik' / 'beziehende_klinik' are rejected as input // (validateRegistration → isSubmittableType); only the READ paths still accept them. // // Duplicate handling is SILENT: a Standort (or contact address) that is already // registered gets the exact same response as a fresh registration, so the endpoint // cannot be used to enumerate which hospitals are on the platform. import { registrationTypeLabelDe } from '../../utils/registrationTypes' import { serverMessages, typeLabelFor } from '../../utils/i18n' // Naive in-memory throttle (per process). Enough to stop casual form hammering; // real rate limiting belongs in the reverse proxy. const hits = new Map() const WINDOW_MS = 10 * 60 * 1000 const MAX_PER_WINDOW = 6 const throttled = (ip: string): boolean => { if (!ip) return false const now = Date.now() const list = (hits.get(ip) || []).filter((t) => now - t < WINDOW_MS) list.push(now) hits.set(ip, list) if (hits.size > 5000) hits.clear() return list.length > MAX_PER_WINDOW } /** * The one neutral answer. A duplicate, a honeypot hit and a real registration all get * exactly this, so the endpoint cannot be used to find out which hospitals are on Blut24 * — see the duplicate guard below. Language follows the form the visitor is looking at. */ const genericOk = (lang: unknown) => ({ status: 200, message: serverMessages(lang).api.genericOk, }) export default defineEventHandler(async (event) => { const body = await readBody(event) const config = useRuntimeConfig() const ip = getRequestIP(event, { xForwardedFor: true }) || '' // Language the visitor filled the form in. Carried through validation, stored on the // row and used for the confirmation mail. Anything unrecognised falls back to German. const reqLang = body?.lang // Honeypot: a hidden field only a bot fills in. Answer 200 so it learns nothing. if (String(body?.website ?? '').trim()) return genericOk(reqLang) if (throttled(ip)) { return { status: 429, message: serverMessages(reqLang).api.throttled, errors: {}, } } const result = validateRegistration(body || {}) if (!result.ok) { return { status: 422, message: serverMessages(result.lang).api.checkFields, errors: result.errors, domainClinics: result.domainClinics, } } const v = result.values const lang = result.lang // Visitor-facing label in their language; the operations mail keeps the German one. const typeLabel = typeLabelFor(v.type, lang) const typeLabelDe = registrationTypeLabelDe(v.type) const address = [v.street, [v.plz, v.city].filter(Boolean).join(' ')].filter(Boolean).join(', ') // ---- duplicate guard (silent) -------------------------------------------------------- const existing = (v.stoid ? findLiveByStoid(v.stoid) : null) || findLiveByEmail(v.email) if (existing) { insertRegistration({ ...({} as any), type: v.type, status: 'duplicate', needs_manual_approval: 1, stoid: null, // the unique index is reserved for the live registration org_name: v.orgName, street: v.street, plz: v.plz, city: v.city, land: v.land, contact_name: v.contactName, contact_email: v.email, contact_phone: v.phone, email_domain: result.emailDomain, domain_verified: result.domainVerified ? 1 : 0, lang, note: `[Doppelte Registrierung] Bereits erfasst als #${existing.id} (${existing.status}). ` + (v.stoid ? `STOID ${v.stoid}. ` : '') + v.note, ip, user_agent: getRequestHeader(event, 'user-agent') || '', }) const notify = String(config.notifyEmail || '') if (notify) { const mail = buildInternalMail('Blut24: erneute Registrierung für eine bereits erfasste Einrichtung', [ ['Einrichtung', v.orgName], ['Typ', typeLabelDe], ['STOID', v.stoid || '–'], ['Neue Kontaktadresse', v.email], ['Bestehende Registrierung', `#${existing.id} (${existing.status}, ${existing.contact_email})`], ], 'Der Absender hat die neutrale Standardantwort erhalten — es wurde keine Bestätigungsmail verschickt.') await sendMail({ to: notify, subject: mail.subject, html: mail.html, text: mail.text }) } // Tell the EXISTING registrant that someone tried again. They are the only party // entitled to know a registration exists — the person attempting it gets the same // neutral response as a first-time registrant, so the form cannot be used to probe // which clinics are already on Blut24. Fail-soft: a mail problem must never change // the response the attempting visitor sees. if (existing.contact_email) { try { const owner = buildDuplicateAttemptMail({ contactName: existing.contact_name || '', orgName: existing.org_name || v.orgName, attemptedEmail: v.email, attemptedAt: new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }), // The owner's OWN language, not the language of whoever tried to register. lang: existing.lang, }) await sendMail({ to: existing.contact_email, subject: owner.subject, html: owner.html, text: owner.text, }) } catch (err: any) { console.warn('[registration] Hinweismail an bestehende Registrierung fehlgeschlagen:', err?.message) } } return genericOk(lang) } // ---- store --------------------------------------------------------------------------- const registration = insertRegistration({ type: v.type, status: 'pending', stoid: v.stoid, org_name: v.orgName, street: v.street, plz: v.plz, city: v.city, land: v.land, contact_name: v.contactName, contact_email: v.email, contact_phone: v.phone, email_domain: result.emailDomain, domain_verified: result.domainVerified ? 1 : 0, lang, // AUTOMATIC ACTIVATION. A registration only waits for a human when nothing here can // decide it: a clinic whose Standort has no e-mail domain on record (there is simply // nothing to correlate the address against) and every courier (no Bundes-Klinik-Atlas // equivalent exists for them). Everything the validator COULD decide — a domain that // matches the chosen Standort, or an operator test domain — is activated straight away, // and the confirmation then hands the registration to the ERP, which creates the // account and mails the credentials. // // `result.needsManualApproval` already carries exactly that rule; it must NOT be // widened with `|| result.isTestRegistration` again. A test domain deliberately // bypasses the domain<->Standort correlation, so it stays OUT of `domain_verified` and // is tagged in `note` below — but the whole point of the bypass is to exercise the real // end-to-end flow, which a manual-approval flag would stop dead. needs_manual_approval: result.needsManualApproval ? 1 : 0, // Audit trail, independent of the approval decision above: a test signup must stay // recognisable as one for anyone reviewing the list, even though it is auto-activated. note: result.isTestRegistration ? `[TESTREGISTRIERUNG via ${result.emailDomain}] ${v.note}`.trim() : v.note, ip, user_agent: getRequestHeader(event, 'user-agent') || '', }) if (!registration) { // Most likely cause: two submissions for the same Standort raced and the UNIQUE index // won. Answer exactly like the silent duplicate path — never reveal the conflict. if (v.stoid && findLiveByStoid(v.stoid)) return genericOk(lang) return { status: 500, message: serverMessages(lang).api.saveFailed, errors: {}, } } // ---- double opt-in mail --------------------------------------------------------------- const siteUrl = String(config.public.siteUrl || '').replace(/\/+$/, '') const confirmUrl = `${siteUrl}/bestaetigung?token=${encodeURIComponent(registration.token)}` const mail = buildConfirmationMail({ type: v.type, to: v.email, contactName: v.contactName, typeLabel, orgName: v.orgName, address, confirmUrl, needsManualApproval: !!result.needsManualApproval, lang, }) const sent = await sendMail({ to: v.email, subject: mail.subject, html: mail.html, text: mail.text }) const notify = String(config.notifyEmail || '') if (notify) { const internal = buildInternalMail('Blut24: neue Registrierung (unbestätigt)', [ ['Einrichtung', v.orgName], ['Typ', typeLabelDe], ['STOID', v.stoid || '–'], ['Anschrift', address], ['Kontakt', v.contactName], ['E-Mail', v.email], ['Telefon', v.phone || '–'], ['Domain geprüft', result.domainVerified ? 'ja' : 'nein'], ['Manuelle Freigabe nötig', result.needsManualApproval ? 'ja' : 'nein'], // Explicit row: a test registration is auto-activated like a verified one, so // "Manuelle Freigabe nötig: nein" above no longer distinguishes the two. Only // rendered when it applies — infoRow() drops an empty value. ...(result.isTestRegistration ? [['Testregistrierung', `ja — Domain-Bypass über ${result.emailDomain}`] as [string, string]] : []), ['Sprache', lang], ['Bemerkung', v.note || '–'], ]) await sendMail({ to: notify, subject: internal.subject, html: internal.html, text: internal.text }) } return { ...genericOk(lang), needsManualApproval: !!result.needsManualApproval, mailSent: sent.ok, } })