// Read-only access to data/clinics.db (built by scripts/import-clinics.mjs). // Fail-soft by design: if the database is missing the site still renders, the // autocomplete simply returns nothing and the form falls back to manual approval. import fs from 'node:fs' import path from 'node:path' import Database from 'better-sqlite3' import { registrableDomain, searchVariants } from '../../lib/domains.mjs' export type ClinicRow = { id: number stoid: string name: string street: string plz: string city: string land: string url: string email: string phone: string traeger: string beds: number | null lat: number | null lon: number | null domain: string domains: string } export type Clinic = Omit & { domains: string[] } let handle: any = null let opened = false let openError = '' const resolveDbPath = (): string => { const configured = (useRuntimeConfig().clinicDbPath || process.env.NUXT_CLINIC_DB_PATH || '').trim() const rel = configured || 'data/clinics.db' return path.isAbsolute(rel) ? rel : path.resolve(process.cwd(), rel) } /** * Lazily opened, read-only handle. Returns null when the file is not (yet) built — * and retries on the next call once the file appears, so an import that runs after the * server started does not require a restart. */ export const getClinicDb = (): any => { if (handle) return handle const file = resolveDbPath() if (opened && !fs.existsSync(file)) return null opened = true try { if (!fs.existsSync(file)) { openError = `clinics.db not found at ${file} — run "npm run import:clinics"` console.warn('[blut24] ' + openError) return null } handle = new Database(file, { readonly: true, fileMustExist: true }) handle.pragma('query_only = true') } catch (err: any) { openError = err?.message || String(err) console.error('[blut24] clinics.db konnte nicht geöffnet werden:', openError) handle = null } return handle } export const clinicDbStatus = () => ({ available: !!getClinicDb(), error: openError, path: resolveDbPath() }) const toClinic = (row: ClinicRow | undefined): Clinic | null => { if (!row) return null return { ...row, domains: String(row.domains || '').split(' ').filter(Boolean) } } /** * Build an FTS5 MATCH expression from free user input. * Every token becomes a prefix term, umlaut variants are OR-ed, tokens are AND-ed: * "helios münch" -> ("helios"* ) AND ("münch"* OR "muench"* OR "munch"*) */ export const buildMatchQuery = (input: string): string => { const raw = String(input ?? '').toLowerCase() const tokens = raw.split(/[^\p{L}\p{N}]+/u).filter((t) => t.length >= 2 || /^\d+$/.test(t)) const groups: string[] = [] for (const token of tokens.slice(0, 6)) { const variants = searchVariants(token).map((v) => v.replace(/"/g, '')).filter(Boolean) if (!variants.length) continue groups.push('(' + variants.map((v) => `"${v}"*`).join(' OR ') + ')') } return groups.join(' AND ') } /** Full-text autocomplete over name / city / street / PLZ. */ export const searchClinics = (input: string, limit = 8): Clinic[] => { const db = getClinicDb() if (!db) return [] const match = buildMatchQuery(input) if (!match) return [] try { const rows = db.prepare(` SELECT c.* FROM clinics_fts f JOIN clinics c ON c.id = f.rowid WHERE clinics_fts MATCH ? ORDER BY rank, c.name LIMIT ? `).all(match, Math.max(1, Math.min(25, limit))) as ClinicRow[] return rows.map((r) => toClinic(r)!).filter(Boolean) } catch (err: any) { console.error('[blut24] Klinik-Suche fehlgeschlagen:', err?.message || err) return [] } } /** One Standort by its Bundes-Klinik-Atlas id. */ export const clinicByStoid = (stoid: string): Clinic | null => { const db = getClinicDb() if (!db || !stoid) return null const row = db.prepare('SELECT * FROM clinics WHERE stoid = ?').get(String(stoid)) as ClinicRow | undefined return toClinic(row) } /** * All Standorte behind one registrable domain. Hospital groups (helios-gesundheit.de, * sana.de, asklepios.com, …) share a single domain across dozens of hospitals, so a * domain match must always offer the user the choice of their own Standort. */ export const clinicsByDomain = (domain: string, limit = 200): Clinic[] => { const db = getClinicDb() const key = registrableDomain(domain) if (!db || !key) return [] const rows = db.prepare(` SELECT c.* FROM clinic_domains d JOIN clinics c ON c.stoid = d.stoid WHERE d.domain = ? ORDER BY c.name LIMIT ? `).all(key, limit) as ClinicRow[] return rows.map((r) => toClinic(r)!).filter(Boolean) } /** Public projection — never leaks the clinic's own contact e-mail address. */ export const publicClinic = (c: Clinic | null) => { if (!c) return null return { stoid: c.stoid, name: c.name, street: c.street, plz: c.plz, city: c.city, land: c.land, beds: c.beds, traeger: c.traeger, domain: c.domain, domains: c.domains, hasDomain: c.domains.length > 0, } }