diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..01915a2 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..3562950 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/skeleton-cat.png b/assets/skeleton-cat.png new file mode 100644 index 0000000..b4d406d Binary files /dev/null and b/assets/skeleton-cat.png differ diff --git a/assets/skeleton-dog.png b/assets/skeleton-dog.png new file mode 100644 index 0000000..e279969 Binary files /dev/null and b/assets/skeleton-dog.png differ diff --git a/assets/skeleton-rabbit.png b/assets/skeleton-rabbit.png new file mode 100644 index 0000000..3536207 Binary files /dev/null and b/assets/skeleton-rabbit.png differ diff --git a/main/backup.js b/main/backup.js new file mode 100644 index 0000000..792c942 --- /dev/null +++ b/main/backup.js @@ -0,0 +1,103 @@ +// backup.js — simple daily rotating backup of the shared data folder (fellakte.db + files/). +// No external dependencies: plain folder copies under /backups//, +// pruned to the most recent KEEP_BACKUPS. Runs once automatically per day (on app start) +// and can also be triggered manually from Settings. +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const KEEP_BACKUPS = 30; + +function pad(n) { + return String(n).padStart(2, "0"); +} +function todayStamp() { + const d = new Date(); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function backupsDir(dataFolder) { + return path.join(dataFolder, "backups"); +} + +function listBackups(dataFolder) { + const dir = backupsDir(dataFolder); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); +} + +function pruneOldBackups(dataFolder) { + const names = listBackups(dataFolder); + if (names.length <= KEEP_BACKUPS) return; + for (const name of names.slice(0, names.length - KEEP_BACKUPS)) { + fs.rmSync(path.join(backupsDir(dataFolder), name), { recursive: true, force: true }); + } +} + +function copySnapshot(dataFolder, dest) { + fs.mkdirSync(dest, { recursive: true }); + const dbFile = path.join(dataFolder, "fellakte.db"); + if (fs.existsSync(dbFile)) fs.copyFileSync(dbFile, path.join(dest, "fellakte.db")); + const filesDir = path.join(dataFolder, "files"); + if (fs.existsSync(filesDir)) fs.cpSync(filesDir, path.join(dest, "files"), { recursive: true }); +} + +// Manual/"Jetzt sichern" backup. If today's daily backup already exists, this makes an +// additional time-stamped one instead of silently doing nothing. +function runBackupNow(dataFolder) { + const stamp = todayStamp(); + let dest = path.join(backupsDir(dataFolder), stamp); + if (fs.existsSync(dest)) { + const d = new Date(); + dest = `${dest}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + } + copySnapshot(dataFolder, dest); + pruneOldBackups(dataFolder); + return dest; +} + +// Safe to call on every app start — no-ops if today's backup already exists. +function runDailyBackupIfNeeded(dataFolder) { + try { + const dest = path.join(backupsDir(dataFolder), todayStamp()); + if (fs.existsSync(dest)) return { ran: false }; + copySnapshot(dataFolder, dest); + pruneOldBackups(dataFolder); + return { ran: true, dest }; + } catch (e) { + console.error("Automatisches Backup fehlgeschlagen:", e); + return { ran: false, error: String((e && e.message) || e) }; + } +} + +function backupInfo(dataFolder) { + const names = listBackups(dataFolder); + return { count: names.length, last: names.length ? names[names.length - 1] : null }; +} + +// Restores fellakte.db + files/ from a named backup, overwriting the live data folder. +// Takes a fresh safety-net backup of the current (about-to-be-overwritten) state first, +// so a restore is itself always undoable. Caller is responsible for restarting the app +// afterwards — the in-memory database must be reloaded from the restored file. +function restoreBackup(dataFolder, backupName) { + const src = path.join(backupsDir(dataFolder), backupName); + if (!fs.existsSync(src)) throw new Error("Sicherung nicht gefunden: " + backupName); + runBackupNow(dataFolder); + + const dbFile = path.join(dataFolder, "fellakte.db"); + const srcDb = path.join(src, "fellakte.db"); + if (fs.existsSync(srcDb)) fs.copyFileSync(srcDb, dbFile); + + const filesDir = path.join(dataFolder, "files"); + fs.rmSync(filesDir, { recursive: true, force: true }); + const srcFiles = path.join(src, "files"); + if (fs.existsSync(srcFiles)) fs.cpSync(srcFiles, filesDir, { recursive: true }); + else fs.mkdirSync(filesDir, { recursive: true }); +} + +module.exports = { runBackupNow, runDailyBackupIfNeeded, restoreBackup, listBackups, backupInfo, backupsDir }; diff --git a/main/db.js b/main/db.js new file mode 100644 index 0000000..9286f55 --- /dev/null +++ b/main/db.js @@ -0,0 +1,612 @@ +// db.js — sql.js (pure WASM SQLite) wrapper. +// +// Why sql.js and not better-sqlite3: better-sqlite3 is a native module that must be +// compiled against the exact Electron ABI in use. On a small-practice machine without +// build tools installed, that install step is a common source of breakage. sql.js has +// no native dependency, so `npm install` + `npm start` works the same on any Windows PC. +// The trade-off: the whole database is held in memory and written back to disk after +// every change (see save()). For a solo/two-PC veterinary practice this file will stay +// small (a few MB after years of records — attached photos/PDFs are NOT stored inside +// it, only referenced by path), so this is not a performance concern. +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const initSqlJs = require("sql.js"); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS owners ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + phone TEXT, + email TEXT, + address TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS animals ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES owners(id), + name TEXT NOT NULL, + species TEXT NOT NULL, + species_label TEXT NOT NULL, + breed TEXT, + sex TEXT, + birth_date TEXT, + weight TEXT, + chip_number TEXT, + anamnese TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + date TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS history_entries ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + date TEXT NOT NULL, + title TEXT NOT NULL, + text TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS lab_results ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + name TEXT NOT NULL, + date TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS lab_values ( + id TEXT PRIMARY KEY, + lab_result_id TEXT NOT NULL REFERENCES lab_results(id), + parameter TEXT NOT NULL, + value TEXT, + reference TEXT +); +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + kind TEXT NOT NULL, + filename TEXT NOT NULL, + relative_path TEXT NOT NULL, + size_bytes INTEGER, + uploaded_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS findings ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + x REAL NOT NULL, + y REAL NOT NULL, + severity TEXT NOT NULL, + text TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS exams ( + id TEXT PRIMARY KEY, + animal_id TEXT NOT NULL REFERENCES animals(id), + date TEXT NOT NULL, + puls TEXT, atmung TEXT, temperatur TEXT, herztoene TEXT, gewicht TEXT, + schleimhaeute TEXT, maul_zaehne TEXT, augen_ohren TEXT, hydration TEXT, + lymphknoten TEXT, haut_fell TEXT, gangbild TEXT, verhalten TEXT, + auffaelligkeiten TEXT, empfehlung TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS consent ( + animal_id TEXT PRIMARY KEY REFERENCES animals(id), + granted INTEGER NOT NULL DEFAULT 0, + signed_at TEXT, + method TEXT, + signature_path TEXT, + marketing_ok INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS treatment_contract ( + animal_id TEXT PRIMARY KEY REFERENCES animals(id), + granted INTEGER NOT NULL DEFAULT 0, + signed_at TEXT, + method TEXT, + signature_path TEXT, + briefing_confirmed INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); +CREATE TABLE IF NOT EXISTS remedies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT, + keywords TEXT, + notes TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS appointments ( + id TEXT PRIMARY KEY, + animal_id TEXT REFERENCES animals(id), + title TEXT NOT NULL, + date TEXT NOT NULL, + start_time TEXT, + duration_minutes INTEGER, + location TEXT, + notes TEXT, + created_at TEXT NOT NULL +); +`; + +let SQL = null; +let db = null; +let dbFilePath = null; + +function uid() { + return crypto.randomUUID(); +} + +async function init(dataFolder) { + SQL = await initSqlJs({ + locateFile: (file) => path.join(path.dirname(require.resolve("sql.js")), file), + }); + + dbFilePath = path.join(dataFolder, "fellakte.db"); + fs.mkdirSync(path.join(dataFolder, "files"), { recursive: true }); + + if (fs.existsSync(dbFilePath)) { + const buf = fs.readFileSync(dbFilePath); + db = new SQL.Database(buf); + } else { + db = new SQL.Database(); + } + db.run(SCHEMA); + migrate(); + save(); + return db; +} + +// Additive, idempotent column migrations — safe to run on every start. +// (sql.js has no "ADD COLUMN IF NOT EXISTS"; SQLite errors if the column +// already exists, so each attempt is wrapped and the error ignored.) +function migrate() { + const attempts = [ + "ALTER TABLE animals ADD COLUMN anamnese_data TEXT NOT NULL DEFAULT '{}'", + "ALTER TABLE consent ADD COLUMN items_json TEXT NOT NULL DEFAULT '{}'", + "ALTER TABLE consent ADD COLUMN email_sent_at TEXT", + "ALTER TABLE treatment_contract ADD COLUMN email_sent_at TEXT", + "ALTER TABLE animals ADD COLUMN deceased_at TEXT", + ]; + for (const sql of attempts) { + try { + db.run(sql); + } catch { + // column already present from an earlier run — fine + } + } + seedRemedyNames(); +} + +// One-time seed: just the 38 standard Bach flower remedy names, as empty entries +// ready to fill in. The actual descriptions (Anwendungsgebiete, Dosierung) are left +// blank on purpose — the practice's real reference for those is a licensed book +// ("Bach-Blüten für Tiere", Baumgart/Hand), whose content this app must not copy. +function seedRemedyNames() { + const already = get("SELECT COUNT(*) AS n FROM remedies"); + if (already && already.n > 0) return; + const names = [ + "Agrimony", "Aspen", "Beech", "Centaury", "Cerato", "Cherry Plum", "Chestnut Bud", "Chicory", + "Clematis", "Crab Apple", "Elm", "Gentian", "Gorse", "Heather", "Holly", "Honeysuckle", + "Hornbeam", "Impatiens", "Larch", "Mimulus", "Mustard", "Oak", "Olive", "Pine", + "Red Chestnut", "Rock Rose", "Rock Water", "Scleranthus", "Star of Bethlehem", "Sweet Chestnut", + "Vervain", "Vine", "Walnut", "Water Violet", "White Chestnut", "Wild Oat", "Wild Rose", "Willow", + ]; + for (const name of names) { + run("INSERT INTO remedies (id,name,category,keywords,notes,created_at) VALUES (?,?,?,?,?,?)", [ + uid(), name, "Bachblüte", "", "", nowIso(), + ]); + } +} + +function save() { + if (!db || !dbFilePath) return; + const data = db.export(); + fs.writeFileSync(dbFilePath, Buffer.from(data)); +} + +function all(sql, params = []) { + const stmt = db.prepare(sql); + stmt.bind(params); + const rows = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + return rows; +} + +function get(sql, params = []) { + const rows = all(sql, params); + return rows[0] || null; +} + +function run(sql, params = []) { + db.run(sql, params); + save(); +} + +const nowIso = () => new Date().toISOString(); + +// ---------- owners / animals ---------- + +function listOwnersWithAnimals() { + const owners = all("SELECT * FROM owners ORDER BY name COLLATE NOCASE"); + const animals = all("SELECT * FROM animals ORDER BY name COLLATE NOCASE"); + return owners.map((o) => ({ ...o, animals: animals.filter((a) => a.owner_id === o.id) })); +} + +function createOwner(data) { + const id = uid(); + run("INSERT INTO owners (id,name,phone,email,address,created_at) VALUES (?,?,?,?,?,?)", [ + id, + data.name, + data.phone || "", + data.email || "", + data.address || "", + nowIso(), + ]); + return get("SELECT * FROM owners WHERE id=?", [id]); +} + +function createAnimal(ownerId, data) { + const id = uid(); + run( + `INSERT INTO animals (id,owner_id,name,species,species_label,breed,sex,birth_date,weight,chip_number,anamnese,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + id, + ownerId, + data.name, + data.species || "other", + data.species_label || data.name, + data.breed || "", + data.sex || "", + data.birth_date || "", + data.weight || "", + data.chip_number || "", + "", + nowIso(), + ] + ); + run("INSERT INTO consent (animal_id,granted,marketing_ok) VALUES (?,0,0)", [id]); + return id; +} + +function updateAnimalField(animalId, field, value) { + const allowed = ["name", "species", "species_label", "breed", "sex", "birth_date", "weight", "chip_number", "anamnese", "anamnese_data", "deceased_at"]; + if (!allowed.includes(field)) throw new Error("Feld nicht erlaubt: " + field); + run(`UPDATE animals SET ${field}=? WHERE id=?`, [value, animalId]); +} + +function updateOwnerField(ownerId, field, value) { + const allowed = ["name", "phone", "email", "address"]; + if (!allowed.includes(field)) throw new Error("Feld nicht erlaubt: " + field); + run(`UPDATE owners SET ${field}=? WHERE id=?`, [value, ownerId]); +} + +// Deletes one animal and everything that hangs off it (rows only — the caller is +// responsible for removing its files/ folder from disk). +function deleteAnimal(animalId) { + run("DELETE FROM findings WHERE animal_id=?", [animalId]); + run("DELETE FROM consent WHERE animal_id=?", [animalId]); + run("DELETE FROM treatment_contract WHERE animal_id=?", [animalId]); + run("DELETE FROM exams WHERE animal_id=?", [animalId]); + run("DELETE FROM notes WHERE animal_id=?", [animalId]); + run("DELETE FROM history_entries WHERE animal_id=?", [animalId]); + run("DELETE FROM documents WHERE animal_id=?", [animalId]); + for (const lr of all("SELECT id FROM lab_results WHERE animal_id=?", [animalId])) { + run("DELETE FROM lab_values WHERE lab_result_id=?", [lr.id]); + } + run("DELETE FROM lab_results WHERE animal_id=?", [animalId]); + run("DELETE FROM animals WHERE id=?", [animalId]); +} + +// Deletes an owner and every animal they have. Returns the removed animal ids so +// the caller can also remove each one's files/ folder from disk. +function deleteOwner(ownerId) { + const animalIds = all("SELECT id FROM animals WHERE owner_id=?", [ownerId]).map((r) => r.id); + for (const id of animalIds) deleteAnimal(id); + run("DELETE FROM owners WHERE id=?", [ownerId]); + return animalIds; +} + +function getAnimalBundle(animalId) { + const animal = get("SELECT * FROM animals WHERE id=?", [animalId]); + if (!animal) return null; + const owner = get("SELECT * FROM owners WHERE id=?", [animal.owner_id]); + const notes = all("SELECT * FROM notes WHERE animal_id=? ORDER BY created_at DESC", [animalId]); + const history = all("SELECT * FROM history_entries WHERE animal_id=? ORDER BY date DESC, created_at DESC", [animalId]); + const labResults = all("SELECT * FROM lab_results WHERE animal_id=? ORDER BY date DESC", [animalId]).map((lr) => ({ + ...lr, + values: all("SELECT * FROM lab_values WHERE lab_result_id=?", [lr.id]), + })); + const documents = all("SELECT * FROM documents WHERE animal_id=? AND kind='document' ORDER BY uploaded_at DESC", [animalId]); + const photos = all("SELECT * FROM documents WHERE animal_id=? AND kind='photo' ORDER BY uploaded_at DESC", [animalId]); + const findings = all("SELECT * FROM findings WHERE animal_id=? ORDER BY created_at ASC", [animalId]); + const exams = all("SELECT * FROM exams WHERE animal_id=? ORDER BY date DESC, created_at DESC", [animalId]); + const consent = get("SELECT * FROM consent WHERE animal_id=?", [animalId]); + const contract = get("SELECT * FROM treatment_contract WHERE animal_id=?", [animalId]); + return { animal, owner, notes, history, labResults, documents, photos, findings, exams, consent, contract }; +} + +// ---------- exams (vitals & Untersuchungsbefund — mirrors the paper Patientenakte) ---------- +const EXAM_FIELDS = [ + "puls", "atmung", "temperatur", "herztoene", "gewicht", + "schleimhaeute", "maul_zaehne", "augen_ohren", "hydration", + "lymphknoten", "haut_fell", "gangbild", "verhalten", + "auffaelligkeiten", "empfehlung", +]; +function addExam(animalId, fields) { + const id = uid(); + const cols = ["id", "animal_id", "date", ...EXAM_FIELDS, "created_at"]; + const values = [ + id, + animalId, + new Date().toLocaleDateString("de-DE"), + ...EXAM_FIELDS.map((f) => fields[f] || ""), + nowIso(), + ]; + run(`INSERT INTO exams (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`, values); + return id; +} + +// ---------- notes ---------- +function addNote(animalId, text) { + run("INSERT INTO notes (id,animal_id,date,text,created_at) VALUES (?,?,?,?,?)", [ + uid(), + animalId, + new Date().toLocaleDateString("de-DE"), + text, + nowIso(), + ]); +} + +// Returns the distinct animal ids whose Notizen (free-text notes) contain the query — +// used to extend the directory search beyond owner/animal names. +function searchAnimalIdsByNoteText(query) { + if (!query || !query.trim()) return []; + const like = `%${query.trim()}%`; + return all("SELECT DISTINCT animal_id FROM notes WHERE text LIKE ?", [like]).map((r) => r.animal_id); +} + +// ---------- history ---------- +function addHistoryEntry(animalId, title, text) { + run("INSERT INTO history_entries (id,animal_id,date,title,text,created_at) VALUES (?,?,?,?,?,?)", [ + uid(), + animalId, + new Date().toLocaleDateString("de-DE"), + title, + text, + nowIso(), + ]); +} + +// ---------- lab ---------- +function addLabResult(animalId, name) { + const id = uid(); + run("INSERT INTO lab_results (id,animal_id,name,date,created_at) VALUES (?,?,?,?,?)", [ + id, + animalId, + name, + new Date().toLocaleDateString("de-DE"), + nowIso(), + ]); + return id; +} +function addLabValue(labResultId, parameter, value, reference) { + run("INSERT INTO lab_values (id,lab_result_id,parameter,value,reference) VALUES (?,?,?,?,?)", [ + uid(), + labResultId, + parameter, + value, + reference, + ]); +} +function deleteLabResult(labResultId) { + run("DELETE FROM lab_values WHERE lab_result_id=?", [labResultId]); + run("DELETE FROM lab_results WHERE id=?", [labResultId]); +} + +// ---------- documents ---------- +function addDocumentRow(animalId, kind, filename, relativePath, sizeBytes) { + const id = uid(); + run( + "INSERT INTO documents (id,animal_id,kind,filename,relative_path,size_bytes,uploaded_at) VALUES (?,?,?,?,?,?,?)", + [id, animalId, kind, filename, relativePath, sizeBytes, nowIso()] + ); + return id; +} +function getDocument(id) { + return get("SELECT * FROM documents WHERE id=?", [id]); +} + +// ---------- findings (body map pins) ---------- +function addFinding(animalId, { x, y, severity, text }) { + const id = uid(); + run("INSERT INTO findings (id,animal_id,x,y,severity,text,created_at) VALUES (?,?,?,?,?,?,?)", [ + id, + animalId, + x, + y, + severity, + text, + nowIso(), + ]); + return id; +} +function updateFinding(id, { severity, text }) { + run("UPDATE findings SET severity=?, text=? WHERE id=?", [severity, text, id]); +} +function deleteFinding(id) { + run("DELETE FROM findings WHERE id=?", [id]); +} + +// ---------- consent ---------- +function setConsent(animalId, { granted, method, signaturePath, marketingOk, itemsJson }) { + const exists = get("SELECT * FROM consent WHERE animal_id=?", [animalId]); + // Only stamp a fresh "signed_at" on an actual false→true transition (a real signing + // event). Otherwise a call that leaves `granted` unchanged — e.g. toggling the + // separate marketing checkbox, or ticking a checklist item — would silently bump + // the treatment consent's "Erteilt am" date to today every time. + const wasGranted = exists ? !!exists.granted : false; + const signedAt = !granted ? null : !wasGranted ? nowIso() : exists.signed_at; + const items = itemsJson !== undefined ? itemsJson : exists ? undefined : "{}"; + if (exists) { + if (items !== undefined) { + run( + "UPDATE consent SET granted=?, signed_at=?, method=?, signature_path=?, marketing_ok=?, items_json=? WHERE animal_id=?", + [granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, items, animalId] + ); + } else { + run( + "UPDATE consent SET granted=?, signed_at=?, method=?, signature_path=?, marketing_ok=? WHERE animal_id=?", + [granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, animalId] + ); + } + } else { + run( + "INSERT INTO consent (animal_id,granted,signed_at,method,signature_path,marketing_ok,items_json) VALUES (?,?,?,?,?,?,?)", + [animalId, granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, items || "{}"] + ); + } +} +function markConsentEmailed(animalId) { + run("UPDATE consent SET email_sent_at=? WHERE animal_id=?", [nowIso(), animalId]); +} + +// ---------- treatment contract (Behandlungsvertrag) ---------- +function setContract(animalId, { granted, method, signaturePath, briefingConfirmed }) { + const exists = get("SELECT * FROM treatment_contract WHERE animal_id=?", [animalId]); + // Same fix as setConsent: only stamp a fresh signed_at on an actual false→true + // transition, so toggling the unrelated "briefing confirmed" checkbox afterwards + // doesn't silently bump the contract's "Erteilt am" date. + const wasGranted = exists ? !!exists.granted : false; + const signedAt = !granted ? null : !wasGranted ? nowIso() : exists.signed_at; + if (exists) { + run( + "UPDATE treatment_contract SET granted=?, signed_at=?, method=?, signature_path=?, briefing_confirmed=? WHERE animal_id=?", + [granted ? 1 : 0, signedAt, method || null, signaturePath || null, briefingConfirmed ? 1 : 0, animalId] + ); + } else { + run( + "INSERT INTO treatment_contract (animal_id,granted,signed_at,method,signature_path,briefing_confirmed) VALUES (?,?,?,?,?,?)", + [animalId, granted ? 1 : 0, signedAt, method || null, signaturePath || null, briefingConfirmed ? 1 : 0] + ); + } +} +function markContractEmailed(animalId) { + run("UPDATE treatment_contract SET email_sent_at=? WHERE animal_id=?", [nowIso(), animalId]); +} + +// ---------- remedies (Medikamenten-/Präparate-Verzeichnis) ---------- +function listRemedies(query) { + if (!query) return all("SELECT * FROM remedies ORDER BY name COLLATE NOCASE"); + const like = `%${query}%`; + return all( + "SELECT * FROM remedies WHERE name LIKE ? OR category LIKE ? OR keywords LIKE ? OR notes LIKE ? ORDER BY name COLLATE NOCASE", + [like, like, like, like] + ); +} +function addRemedy(data) { + const id = uid(); + run("INSERT INTO remedies (id,name,category,keywords,notes,created_at) VALUES (?,?,?,?,?,?)", [ + id, data.name, data.category || "", data.keywords || "", data.notes || "", nowIso(), + ]); + return id; +} +function updateRemedy(id, data) { + run("UPDATE remedies SET name=?, category=?, keywords=?, notes=? WHERE id=?", [ + data.name, data.category || "", data.keywords || "", data.notes || "", id, + ]); +} +function deleteRemedy(id) { + run("DELETE FROM remedies WHERE id=?", [id]); +} + +// ---------- appointments (Kalender) ---------- +function listAppointments(fromDate, toDate) { + const rows = all("SELECT * FROM appointments WHERE date>=? AND date<=? ORDER BY date ASC, start_time ASC", [fromDate, toDate]); + return rows.map((r) => { + if (!r.animal_id) return { ...r, animalName: null, ownerName: null }; + const animal = get("SELECT * FROM animals WHERE id=?", [r.animal_id]); + const owner = animal ? get("SELECT name FROM owners WHERE id=?", [animal.owner_id]) : null; + return { ...r, animalName: animal ? animal.name : null, ownerName: owner ? owner.name : null }; + }); +} +function addAppointment(data) { + const id = uid(); + run( + "INSERT INTO appointments (id,animal_id,title,date,start_time,duration_minutes,location,notes,created_at) VALUES (?,?,?,?,?,?,?,?,?)", + [id, data.animalId || null, data.title, data.date, data.startTime || null, data.durationMinutes || null, data.location || null, data.notes || null, nowIso()] + ); + return id; +} +function updateAppointment(id, data) { + run( + "UPDATE appointments SET animal_id=?, title=?, date=?, start_time=?, duration_minutes=?, location=?, notes=? WHERE id=?", + [data.animalId || null, data.title, data.date, data.startTime || null, data.durationMinutes || null, data.location || null, data.notes || null, id] + ); +} +function deleteAppointment(id) { + run("DELETE FROM appointments WHERE id=?", [id]); +} + +// ---------- settings (key/value; SMTP config lives here, shared via the data folder) ---------- +function getSetting(key) { + const row = get("SELECT value FROM settings WHERE key=?", [key]); + return row ? row.value : null; +} +function setSetting(key, value) { + const exists = get("SELECT key FROM settings WHERE key=?", [key]); + if (exists) run("UPDATE settings SET value=? WHERE key=?", [value, key]); + else run("INSERT INTO settings (key,value) VALUES (?,?)", [key, value]); +} + +module.exports = { + init, + save, + uid, + listOwnersWithAnimals, + createOwner, + createAnimal, + updateAnimalField, + updateOwnerField, + deleteAnimal, + deleteOwner, + getAnimalBundle, + addNote, + searchAnimalIdsByNoteText, + addHistoryEntry, + addLabResult, + addLabValue, + deleteLabResult, + addDocumentRow, + getDocument, + addFinding, + updateFinding, + deleteFinding, + addExam, + setConsent, + markConsentEmailed, + setContract, + markContractEmailed, + getSetting, + setSetting, + listAppointments, + addAppointment, + updateAppointment, + deleteAppointment, + listRemedies, + addRemedy, + updateRemedy, + deleteRemedy, + get dbFilePath() { + return dbFilePath; + }, +}; diff --git a/main/documents.js b/main/documents.js new file mode 100644 index 0000000..a749b90 --- /dev/null +++ b/main/documents.js @@ -0,0 +1,58 @@ +// documents.js — file-store and PDF-saving helpers shared by ipc.js (desktop) and +// webserver.js (tablet/Mac web client), so both talk to the exact same document +// pipeline instead of two copies of it. +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const db = require("./db"); +const pdfRenderer = require("./pdf"); + +function filesDirFor(dataFolder, animalId) { + const dir = path.join(dataFolder, "files", animalId); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function copyIntoStore(dataFolder, animalId, sourcePath) { + const original = path.basename(sourcePath); + const stored = `${crypto.randomUUID()}-${original}`; + const destDir = filesDirFor(dataFolder, animalId); + const destPath = path.join(destDir, stored); + fs.copyFileSync(sourcePath, destPath); + const size = fs.statSync(destPath).size; + return { original, relativePath: path.join("files", animalId, stored), size, absPath: destPath }; +} + +function fileToDataUri(absPath) { + try { + const buf = fs.readFileSync(absPath); + const ext = (path.extname(absPath).slice(1) || "png").replace("jpg", "jpeg"); + return `data:image/${ext};base64,${buf.toString("base64")}`; + } catch { + return null; + } +} + +function logoDataUri() { + return fileToDataUri(path.join(__dirname, "..", "assets", "logo.png")); +} + +// Renders a PDF and saves it into the animal's document store, registering it as a +// normal document row so it shows up in the Dokumente tab like any uploaded file. +async function savePdfAsDocument(dataFolder, animalId, filenamePrefix, pdfOptions) { + const buffer = await pdfRenderer.renderPdf({ logoDataUri: logoDataUri(), ...pdfOptions }); + const bundle = db.getAnimalBundle(animalId); + const safeName = bundle.animal.name.replace(/[^a-z0-9äöüß]+/gi, "_"); + const filename = `${filenamePrefix}_${safeName}_${new Date().toISOString().slice(0, 10)}_${Date.now()}.pdf`; + const dir = filesDirFor(dataFolder, animalId); + const absPath = path.join(dir, filename); + fs.writeFileSync(absPath, buffer); + const relativePath = path.join("files", animalId, filename); + const docId = db.addDocumentRow(animalId, "document", filename, relativePath, buffer.length); + return { docId, absPath, relativePath, filename }; +} + +module.exports = { filesDirFor, copyIntoStore, fileToDataUri, logoDataUri, savePdfAsDocument }; diff --git a/main/ipc.js b/main/ipc.js new file mode 100644 index 0000000..1a568c2 --- /dev/null +++ b/main/ipc.js @@ -0,0 +1,320 @@ +"use strict"; + +const { app, ipcMain, dialog, shell, BrowserWindow } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +const db = require("./db"); +const mailer = require("./mailer"); +const backup = require("./backup"); +const webserver = require("./webserver"); +const { filesDirFor, copyIntoStore, fileToDataUri, savePdfAsDocument } = require("./documents"); + +module.exports = function registerIpc({ dataFolder }) { + ipcMain.handle("directory:list", () => db.listOwnersWithAnimals()); + + ipcMain.handle("owner:create", (_e, data) => db.createOwner(data)); + + ipcMain.handle("animal:create", (_e, { ownerId, data }) => db.createAnimal(ownerId, data)); + + ipcMain.handle("animal:bundle", (_e, animalId) => { + const bundle = db.getAnimalBundle(animalId); + if (!bundle) return null; + const toFileUrl = (relPath) => "file://" + path.join(dataFolder, relPath).replace(/\\/g, "/"); + bundle.photos = bundle.photos.map((p) => ({ ...p, fileUrl: toFileUrl(p.relative_path) })); + if (bundle.consent && bundle.consent.signature_path) { + bundle.consent.signature_url = toFileUrl(bundle.consent.signature_path); + } + if (bundle.contract && bundle.contract.signature_path) { + bundle.contract.signature_url = toFileUrl(bundle.contract.signature_path); + } + return bundle; + }); + + ipcMain.handle("animal:updateField", (_e, { animalId, field, value }) => { + db.updateAnimalField(animalId, field, value); + return true; + }); + + ipcMain.handle("owner:updateField", (_e, { ownerId, field, value }) => { + db.updateOwnerField(ownerId, field, value); + return true; + }); + + ipcMain.handle("animal:delete", (_e, animalId) => { + db.deleteAnimal(animalId); + fs.rmSync(path.join(dataFolder, "files", animalId), { recursive: true, force: true }); + return true; + }); + + ipcMain.handle("owner:delete", (_e, ownerId) => { + const animalIds = db.deleteOwner(ownerId); + for (const animalId of animalIds) { + fs.rmSync(path.join(dataFolder, "files", animalId), { recursive: true, force: true }); + } + return true; + }); + + ipcMain.handle("note:add", (_e, { animalId, text }) => { + db.addNote(animalId, text); + return true; + }); + + ipcMain.handle("history:add", (_e, { animalId, title, text }) => { + db.addHistoryEntry(animalId, title, text); + return true; + }); + + ipcMain.handle("lab:addResult", (_e, { animalId, name }) => db.addLabResult(animalId, name)); + ipcMain.handle("lab:addValue", (_e, { labResultId, parameter, value, reference }) => { + db.addLabValue(labResultId, parameter, value, reference); + return true; + }); + ipcMain.handle("lab:deleteResult", (_e, labResultId) => { + db.deleteLabResult(labResultId); + return true; + }); + + ipcMain.handle("finding:add", (_e, { animalId, x, y, severity, text }) => + db.addFinding(animalId, { x, y, severity, text }) + ); + ipcMain.handle("finding:update", (_e, { id, severity, text }) => { + db.updateFinding(id, { severity, text }); + return true; + }); + ipcMain.handle("finding:delete", (_e, id) => { + db.deleteFinding(id); + return true; + }); + + ipcMain.handle("exam:add", (_e, { animalId, fields }) => db.addExam(animalId, fields)); + + ipcMain.handle("consent:set", (_e, { animalId, granted, method, signaturePath, marketingOk, itemsJson }) => { + db.setConsent(animalId, { granted, method, signaturePath, marketingOk, itemsJson }); + return true; + }); + + ipcMain.handle("consent:saveSignature", (_e, { animalId, dataUrl }) => { + const base64 = dataUrl.replace(/^data:image\/png;base64,/, ""); + const dir = filesDirFor(dataFolder, animalId); + const filename = `unterschrift-${Date.now()}.png`; + const absPath = path.join(dir, filename); + fs.writeFileSync(absPath, Buffer.from(base64, "base64")); + const relativePath = path.join("files", animalId, filename); + + const existing = db.getAnimalBundle(animalId).consent; + db.setConsent(animalId, { + granted: true, + method: "Unterschrift auf dem Tablet erfasst", + signaturePath: relativePath, + marketingOk: existing ? !!existing.marketing_ok : false, + }); + return db.getAnimalBundle(animalId).consent; + }); + + ipcMain.handle("contract:set", (_e, { animalId, granted, method, signaturePath, briefingConfirmed }) => { + db.setContract(animalId, { granted, method, signaturePath, briefingConfirmed }); + return true; + }); + + ipcMain.handle("contract:saveSignature", (_e, { animalId, dataUrl }) => { + const base64 = dataUrl.replace(/^data:image\/png;base64,/, ""); + const dir = filesDirFor(dataFolder, animalId); + const filename = `vertrag-unterschrift-${Date.now()}.png`; + const absPath = path.join(dir, filename); + fs.writeFileSync(absPath, Buffer.from(base64, "base64")); + const relativePath = path.join("files", animalId, filename); + + const existing = db.getAnimalBundle(animalId).contract; + db.setContract(animalId, { + granted: true, + method: "Unterschrift auf dem Tablet erfasst", + signaturePath: relativePath, + briefingConfirmed: existing ? !!existing.briefing_confirmed : true, + }); + return db.getAnimalBundle(animalId).contract; + }); + + ipcMain.handle("document:generatePdf", async (_e, { animalId, kind, title, ownerBlockHtml, bodyHtml, signedInfoHtml }) => { + const bundle = db.getAnimalBundle(animalId); + const record = kind === "vertrag" ? bundle.contract : bundle.consent; + const signatureDataUri = record && record.signature_path ? fileToDataUri(path.join(dataFolder, record.signature_path)) : null; + const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "Einwilligung"; + return savePdfAsDocument(dataFolder, animalId, docLabel, { title, ownerBlockHtml, bodyHtml, signedInfoHtml, signatureDataUri }); + }); + + ipcMain.handle("document:generateTemplatePdf", async (_e, { animalId, filenamePrefix, title, ownerBlockHtml, bodyHtml }) => { + return savePdfAsDocument(dataFolder, animalId, filenamePrefix, { title, ownerBlockHtml, bodyHtml, signedInfoHtml: "", signatureDataUri: null }); + }); + + ipcMain.handle("email:send", async (_e, { animalId, kind, absPath, filename }) => { + const smtpJson = db.getSetting("smtp"); + if (!smtpJson) return { ok: false, error: "not-configured" }; + const smtp = JSON.parse(smtpJson); + const bundle = db.getAnimalBundle(animalId); + if (!bundle.owner.email) return { ok: false, error: "no-owner-email" }; + const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "DSGVO-Einwilligung"; + try { + await mailer.sendMail(smtp, { + to: bundle.owner.email, + subject: `${docLabel} – ${bundle.animal.name} – Tierheilpraxis Iris Hack`, + text: `Guten Tag ${bundle.owner.name},\n\nanbei erhalten Sie die unterschriebenen Unterlagen (${docLabel}) für ${bundle.animal.name}.\n\nMit freundlichen Grüßen\nIris Hack\nTierheilpraxis Iris Hack`, + attachments: [{ filename, path: absPath }], + }); + if (kind === "vertrag") db.markContractEmailed(animalId); + else db.markConsentEmailed(animalId); + return { ok: true }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } + }); + + ipcMain.handle("remedy:list", (_e, query) => db.listRemedies(query)); + ipcMain.handle("remedy:add", (_e, data) => db.addRemedy(data)); + ipcMain.handle("remedy:update", (_e, { id, data }) => { + db.updateRemedy(id, data); + return true; + }); + ipcMain.handle("remedy:delete", (_e, id) => { + db.deleteRemedy(id); + return true; + }); + + ipcMain.handle("appointment:list", (_e, { fromDate, toDate }) => db.listAppointments(fromDate, toDate)); + ipcMain.handle("appointment:add", (_e, data) => db.addAppointment(data)); + ipcMain.handle("appointment:update", (_e, { id, data }) => { + db.updateAppointment(id, data); + return true; + }); + ipcMain.handle("appointment:delete", (_e, id) => { + db.deleteAppointment(id); + return true; + }); + + ipcMain.handle("lexware:getPath", () => db.getSetting("lexwarePath")); + ipcMain.handle("lexware:setPath", (_e, value) => { + db.setSetting("lexwarePath", value); + return true; + }); + ipcMain.handle("lexware:open", async () => { + const target = db.getSetting("lexwarePath"); + if (!target) return { ok: false, error: "not-configured" }; + try { + if (/^https?:\/\//i.test(target)) { + await shell.openExternal(target); + } else { + const result = await shell.openPath(target); + if (result) return { ok: false, error: result }; // openPath resolves with an error string on failure + } + return { ok: true }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } + }); + + ipcMain.handle("settings:getSmtp", () => { + const json = db.getSetting("smtp"); + return json ? JSON.parse(json) : null; + }); + ipcMain.handle("settings:setSmtp", (_e, smtp) => { + db.setSetting("smtp", JSON.stringify(smtp)); + return true; + }); + ipcMain.handle("settings:testSmtp", async (_e, smtp) => { + try { + await mailer.verifySmtp(smtp); + return { ok: true }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } + }); + + ipcMain.handle("document:add", async (_e, { animalId, kind }) => { + const win = BrowserWindow.getFocusedWindow(); + const filters = + kind === "photo" + ? [{ name: "Bilder", extensions: ["png", "jpg", "jpeg", "heic", "webp"] }] + : [{ name: "Dokumente", extensions: ["pdf", "png", "jpg", "jpeg", "docx", "doc"] }]; + const result = await dialog.showOpenDialog(win, { + title: kind === "photo" ? "Foto auswählen" : "Dokument auswählen", + properties: ["openFile", "multiSelections"], + filters, + }); + if (result.canceled) return []; + return result.filePaths.map((sourcePath) => { + const { original, relativePath, size } = copyIntoStore(dataFolder, animalId, sourcePath); + const id = db.addDocumentRow(animalId, kind, original, relativePath, size); + return db.getDocument(id); + }); + }); + + ipcMain.handle("document:open", (_e, documentId) => { + const doc = db.getDocument(documentId); + if (!doc) return false; + shell.openPath(path.join(dataFolder, doc.relative_path)); + return true; + }); + + ipcMain.handle("document:absPath", (_e, documentId) => { + const doc = db.getDocument(documentId); + if (!doc) return null; + return path.join(dataFolder, doc.relative_path); + }); + + ipcMain.handle("app:dataFolder", () => dataFolder); + + ipcMain.handle("search:notes", (_e, query) => db.searchAnimalIdsByNoteText(query)); + + ipcMain.handle("backup:now", () => { + try { + const dest = backup.runBackupNow(dataFolder); + return { ok: true, dest }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } + }); + ipcMain.handle("backup:info", () => backup.backupInfo(dataFolder)); + ipcMain.handle("backup:list", () => backup.listBackups(dataFolder).slice().reverse()); + ipcMain.handle("backup:openFolder", () => { + const dir = backup.backupsDir(dataFolder); + fs.mkdirSync(dir, { recursive: true }); + shell.openPath(dir); + return true; + }); + ipcMain.handle("backup:restore", (_e, name) => { + try { + backup.restoreBackup(dataFolder, name); + // The in-memory database must be reloaded from the restored file, so the whole + // app restarts — a plain page reload would keep the stale data and overwrite + // the restore on its next save(). + setTimeout(() => { + app.relaunch(); + app.exit(0); + }, 300); + return { ok: true }; + } catch (e) { + return { ok: false, error: String((e && e.message) || e) }; + } + }); + + ipcMain.handle("webaccess:startPairing", async () => { + const info = webserver.startPairing(); + const qr = await webserver.pairingQrDataUri(); + return { ...info, qr }; + }); + ipcMain.handle("webaccess:pairingStatus", () => webserver.pairingInfo()); + ipcMain.handle("webaccess:listDevices", () => webserver.listDevices()); + ipcMain.handle("webaccess:revokeDevice", (_e, id) => { + webserver.revokeDevice(id); + return true; + }); + + ipcMain.handle("settings:getReminderLead", () => { + const v = db.getSetting("reminderLeadMinutes"); + return v ? Number(v) : 30; + }); + ipcMain.handle("settings:setReminderLead", (_e, minutes) => { + db.setSetting("reminderLeadMinutes", String(minutes)); + return true; + }); +}; diff --git a/main/lock.js b/main/lock.js new file mode 100644 index 0000000..dfd04c7 --- /dev/null +++ b/main/lock.js @@ -0,0 +1,70 @@ +// lock.js — soft lock so the second PC gets warned instead of silently +// overwriting the other PC's work. This is advisory, not a hard database lock: +// sql.js rewrites the whole .db file on every save, so if both PCs really did +// write at the same time, the last save wins. The heartbeat file makes that +// situation visible before it happens instead of after. +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const HEARTBEAT_MS = 10_000; +const STALE_AFTER_MS = 30_000; + +let lockPath = null; +let heartbeatTimer = null; +let myToken = null; + +function readLock(dataFolder) { + const p = path.join(dataFolder, ".fellakte.lock"); + if (!fs.existsSync(p)) return null; + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return null; + } +} + +/** + * Checks whether another PC currently looks active. + * Returns { active:boolean, info } — info is the other host's lock contents when active. + */ +function checkOther(dataFolder) { + const info = readLock(dataFolder); + if (!info) return { active: false, info: null }; + const age = Date.now() - info.updatedAt; + const isOther = info.host !== os.hostname() || info.pid !== process.pid; + return { active: isOther && age < STALE_AFTER_MS, info }; +} + +function acquire(dataFolder) { + lockPath = path.join(dataFolder, ".fellakte.lock"); + myToken = { host: os.hostname(), pid: process.pid, updatedAt: Date.now() }; + fs.writeFileSync(lockPath, JSON.stringify(myToken)); + heartbeatTimer = setInterval(() => { + myToken.updatedAt = Date.now(); + try { + fs.writeFileSync(lockPath, JSON.stringify(myToken)); + } catch { + // shared drive briefly unreachable — next tick will retry + } + }, HEARTBEAT_MS); +} + +function release() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + if (lockPath && fs.existsSync(lockPath)) { + try { + const current = readLock(path.dirname(lockPath)); + if (current && current.host === os.hostname() && current.pid === process.pid) { + fs.unlinkSync(lockPath); + } + } catch { + // best effort — a stale lock will simply age out after STALE_AFTER_MS + } + } +} + +module.exports = { checkOther, acquire, release }; diff --git a/main/mailer.js b/main/mailer.js new file mode 100644 index 0000000..89550f4 --- /dev/null +++ b/main/mailer.js @@ -0,0 +1,35 @@ +// mailer.js — sends mail through the practice's own SMTP account (their normal +// mailbox + an app password, entered once in Einstellungen). No third-party email +// service involved; credentials are stored in the shared data folder's database, +// same trust model as the folder path itself. +"use strict"; + +const nodemailer = require("nodemailer"); + +function buildTransport(smtp) { + return nodemailer.createTransport({ + host: smtp.host, + port: Number(smtp.port) || 587, + secure: !!smtp.secure, + auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined, + }); +} + +async function verifySmtp(smtp) { + const transporter = buildTransport(smtp); + await transporter.verify(); +} + +async function sendMail(smtp, { to, subject, text, attachments }) { + const transporter = buildTransport(smtp); + const fromAddress = smtp.fromEmail || smtp.user; + await transporter.sendMail({ + from: smtp.fromName ? `"${smtp.fromName}" <${fromAddress}>` : fromAddress, + to, + subject, + text, + attachments, + }); +} + +module.exports = { verifySmtp, sendMail }; diff --git a/main/main.js b/main/main.js new file mode 100644 index 0000000..c1dde27 --- /dev/null +++ b/main/main.js @@ -0,0 +1,145 @@ +"use strict"; + +const { app, BrowserWindow, dialog } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +const db = require("./db"); +const lock = require("./lock"); +const backup = require("./backup"); +const reminders = require("./reminders"); +const webserver = require("./webserver"); +const registerIpc = require("./ipc"); + +// Works around a blank/unpainted-window glitch seen on some Windows GPU drivers +// (matches the "GPU process exited unexpectedly" warnings this app triggered there) — +// newly inserted DOM content (e.g. the modal) wouldn't repaint until the window was +// resized or moved. Must be called before app is ready. +app.disableHardwareAcceleration(); + +const settingsPath = path.join(app.getPath("userData"), "settings.json"); +const iconPath = path.join(__dirname, "..", "assets", "icon.ico"); + +function readSettings() { + try { + return JSON.parse(fs.readFileSync(settingsPath, "utf8")); + } catch { + return {}; + } +} +function writeSettings(s) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2)); +} + +let mainWindow = null; + +async function chooseDataFolder(isFirstRun) { + const message = isFirstRun + ? "Wo soll die Fellakte ihre Daten speichern?\n\nFür zwei PCs in der Praxis: einen Ordner auf einem gemeinsamen Netzlaufwerk oder einem synchronisierten Ordner (z. B. OneDrive/Dropbox) wählen. Für einen einzelnen PC reicht ein normaler Ordner." + : "Neuen Datenordner wählen"; + await dialog.showMessageBox({ + type: "info", + title: "Fellakte – Datenordner", + message, + buttons: ["Weiter"], + }); + const result = await dialog.showOpenDialog({ + title: "Datenordner für die Fellakte wählen oder anlegen", + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || !result.filePaths[0]) return null; + return result.filePaths[0]; +} + +async function ensureDataFolder() { + const settings = readSettings(); + let folder = settings.dataFolder; + + if (!folder || !fs.existsSync(folder)) { + folder = await chooseDataFolder(true); + if (!folder) { + app.quit(); + return null; + } + writeSettings({ ...settings, dataFolder: folder }); + } + return folder; +} + +async function checkLockAndWarn(folder) { + const { active, info } = lock.checkOther(folder); + if (!active) return true; + const when = new Date(info.updatedAt).toLocaleTimeString("de-DE"); + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Fellakte wird eventuell gerade woanders benutzt", + message: `Auf "${info.host}" wurde dieser Datenordner zuletzt um ${when} Uhr verwendet.`, + detail: + "Wenn dort gerade jemand arbeitet und du hier ebenfalls speicherst, überschreibt die zuletzt gespeicherte Version die andere. Am sichersten: kurz nachfragen, ob der andere PC gerade offen ist.", + buttons: ["Trotzdem öffnen", "Abbrechen"], + defaultId: 1, + cancelId: 1, + }); + return response === 0; +} + +async function createWindow(dataFolder) { + mainWindow = new BrowserWindow({ + width: 1360, + height: 860, + minWidth: 980, + minHeight: 640, + backgroundColor: "#f7f0e8", + icon: iconPath, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + mainWindow.setMenuBarVisibility(false); + // Belt-and-suspenders alongside the webPreferences option above: without this, + // Chromium can throttle/skip repainting a window that isn't the active foreground + // one yet, which reads as "newly opened content stays blank until you resize/refocus". + mainWindow.webContents.setBackgroundThrottling(false); + await mainWindow.loadFile(path.join(__dirname, "..", "renderer", "index.html")); +} + +app.whenReady().then(async () => { + const dataFolder = await ensureDataFolder(); + if (!dataFolder) return; + + const proceed = await checkLockAndWarn(dataFolder); + if (!proceed) { + app.quit(); + return; + } + + lock.acquire(dataFolder); + await db.init(dataFolder); + backup.runDailyBackupIfNeeded(dataFolder); + registerIpc({ dataFolder }); + await createWindow(dataFolder); + reminders.start(); + webserver.start(dataFolder); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(dataFolder); + }); +}); + +app.on("window-all-closed", () => { + lock.release(); + reminders.stop(); + webserver.stop(); + if (process.platform !== "darwin") app.quit(); +}); + +app.on("before-quit", () => { + lock.release(); + reminders.stop(); + webserver.stop(); +}); diff --git a/main/pdf.js b/main/pdf.js new file mode 100644 index 0000000..bad2b85 --- /dev/null +++ b/main/pdf.js @@ -0,0 +1,69 @@ +// pdf.js — renders a branded, signed document (Einwilligung / Behandlungsvertrag) to +// PDF using Electron's own printToPDF, no extra PDF library needed. A hidden +// BrowserWindow loads a self-contained HTML string (logo and signature inlined as +// base64 data URIs — a data: page can't reliably load file:// resources) and prints it. +"use strict"; + +const { BrowserWindow } = require("electron"); + +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +async function renderPdf({ title, ownerBlockHtml, bodyHtml, signatureDataUri, signedInfoHtml, logoDataUri }) { + const html = `${escapeHtml(title)} + + +
+ ${logoDataUri ? `` : ""} +
TIERHEILPRAXIS IRIS HACK
+ Iris Hack, Elisabethstr. 17, 84489 Burghausen, Deutschland
+ Tel: +49 160 3408101 · info@tierheilpraxis-hack.de
+
+

${escapeHtml(title)}

+
${ownerBlockHtml}
+
${bodyHtml}
+ ${signedInfoHtml || signatureDataUri ? `
${signedInfoHtml}${signatureDataUri ? `` : ""}
` : ""} + +`; + + const win = new BrowserWindow({ show: false, webPreferences: { offscreen: false } }); + try { + await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + const buffer = await win.webContents.printToPDF({ + printBackground: true, + pageSize: "A4", + margins: { marginType: "default" }, + }); + return buffer; + } finally { + win.destroy(); + } +} + +module.exports = { renderPdf }; diff --git a/main/preload.js b/main/preload.js new file mode 100644 index 0000000..31182e5 --- /dev/null +++ b/main/preload.js @@ -0,0 +1,80 @@ +"use strict"; + +const { contextBridge, ipcRenderer } = require("electron"); + +const invoke = (channel) => (...args) => ipcRenderer.invoke(channel, ...args); + +contextBridge.exposeInMainWorld("api", { + listDirectory: invoke("directory:list"), + createOwner: (data) => ipcRenderer.invoke("owner:create", data), + createAnimal: (ownerId, data) => ipcRenderer.invoke("animal:create", { ownerId, data }), + getAnimalBundle: (animalId) => ipcRenderer.invoke("animal:bundle", animalId), + updateAnimalField: (animalId, field, value) => ipcRenderer.invoke("animal:updateField", { animalId, field, value }), + updateOwnerField: (ownerId, field, value) => ipcRenderer.invoke("owner:updateField", { ownerId, field, value }), + deleteAnimal: (animalId) => ipcRenderer.invoke("animal:delete", animalId), + deleteOwner: (ownerId) => ipcRenderer.invoke("owner:delete", ownerId), + + addNote: (animalId, text) => ipcRenderer.invoke("note:add", { animalId, text }), + addHistoryEntry: (animalId, title, text) => ipcRenderer.invoke("history:add", { animalId, title, text }), + + addLabResult: (animalId, name) => ipcRenderer.invoke("lab:addResult", { animalId, name }), + addLabValue: (labResultId, parameter, value, reference) => + ipcRenderer.invoke("lab:addValue", { labResultId, parameter, value, reference }), + deleteLabResult: (labResultId) => ipcRenderer.invoke("lab:deleteResult", labResultId), + + addFinding: (animalId, finding) => ipcRenderer.invoke("finding:add", { animalId, ...finding }), + updateFinding: (id, patch) => ipcRenderer.invoke("finding:update", { id, ...patch }), + deleteFinding: (id) => ipcRenderer.invoke("finding:delete", id), + + addExam: (animalId, fields) => ipcRenderer.invoke("exam:add", { animalId, fields }), + + setConsent: (animalId, patch) => ipcRenderer.invoke("consent:set", { animalId, ...patch }), + saveSignature: (animalId, dataUrl) => ipcRenderer.invoke("consent:saveSignature", { animalId, dataUrl }), + + setContract: (animalId, patch) => ipcRenderer.invoke("contract:set", { animalId, ...patch }), + saveContractSignature: (animalId, dataUrl) => ipcRenderer.invoke("contract:saveSignature", { animalId, dataUrl }), + + generatePdf: (animalId, kind, content) => ipcRenderer.invoke("document:generatePdf", { animalId, kind, ...content }), + generateTemplatePdf: (animalId, filenamePrefix, content) => ipcRenderer.invoke("document:generateTemplatePdf", { animalId, filenamePrefix, ...content }), + sendDocumentEmail: (animalId, kind, file) => ipcRenderer.invoke("email:send", { animalId, kind, ...file }), + + listRemedies: (query) => ipcRenderer.invoke("remedy:list", query), + addRemedy: (data) => ipcRenderer.invoke("remedy:add", data), + updateRemedy: (id, data) => ipcRenderer.invoke("remedy:update", { id, data }), + deleteRemedy: (id) => ipcRenderer.invoke("remedy:delete", id), + + listAppointments: (fromDate, toDate) => ipcRenderer.invoke("appointment:list", { fromDate, toDate }), + addAppointment: (data) => ipcRenderer.invoke("appointment:add", data), + updateAppointment: (id, data) => ipcRenderer.invoke("appointment:update", { id, data }), + deleteAppointment: (id) => ipcRenderer.invoke("appointment:delete", id), + + getLexwarePath: invoke("lexware:getPath"), + setLexwarePath: (value) => ipcRenderer.invoke("lexware:setPath", value), + openLexware: invoke("lexware:open"), + + getSmtpSettings: invoke("settings:getSmtp"), + setSmtpSettings: (smtp) => ipcRenderer.invoke("settings:setSmtp", smtp), + testSmtpSettings: (smtp) => ipcRenderer.invoke("settings:testSmtp", smtp), + + getReminderLead: invoke("settings:getReminderLead"), + setReminderLead: (minutes) => ipcRenderer.invoke("settings:setReminderLead", minutes), + + backupNow: invoke("backup:now"), + getBackupInfo: invoke("backup:info"), + listBackups: invoke("backup:list"), + openBackupFolder: invoke("backup:openFolder"), + restoreBackup: (name) => ipcRenderer.invoke("backup:restore", name), + + searchNotes: (query) => ipcRenderer.invoke("search:notes", query), + + startWebPairing: invoke("webaccess:startPairing"), + getWebPairingStatus: invoke("webaccess:pairingStatus"), + listWebDevices: invoke("webaccess:listDevices"), + revokeWebDevice: (id) => ipcRenderer.invoke("webaccess:revokeDevice", id), + + addDocument: (animalId, kind) => ipcRenderer.invoke("document:add", { animalId, kind }), + openDocument: (documentId) => ipcRenderer.invoke("document:open", documentId), + documentAbsPath: (documentId) => ipcRenderer.invoke("document:absPath", documentId), + + dataFolder: invoke("app:dataFolder"), +}); diff --git a/main/reminders.js b/main/reminders.js new file mode 100644 index 0000000..41f2a70 --- /dev/null +++ b/main/reminders.js @@ -0,0 +1,78 @@ +// reminders.js — native OS notifications for upcoming appointments. +// Checks once a minute; fires once per appointment when it enters the lead window +// (default 30 min before start_time, configurable via the "reminderLeadMinutes" setting). +"use strict"; + +const { Notification, BrowserWindow } = require("electron"); +const db = require("./db"); + +const CHECK_INTERVAL_MS = 60 * 1000; +const DEFAULT_LEAD_MINUTES = 30; + +const notified = new Set(); // appointment ids already notified + +function pad(n) { + return String(n).padStart(2, "0"); +} +function isoToday() { + const d = new Date(); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function leadMinutes() { + const raw = db.getSetting("reminderLeadMinutes"); + const n = raw ? Number(raw) : NaN; + return Number.isFinite(n) && n > 0 ? n : DEFAULT_LEAD_MINUTES; +} + +function checkNow() { + if (!Notification.isSupported()) return; + let appointments; + try { + appointments = db.listAppointments(isoToday(), isoToday()); + } catch { + return; // db not ready yet + } + const lead = leadMinutes(); + const now = new Date(); + for (const appt of appointments) { + if (!appt.start_time || notified.has(appt.id)) continue; + const [h, m] = appt.start_time.split(":").map(Number); + if (Number.isNaN(h) || Number.isNaN(m)) continue; + const start = new Date(); + start.setHours(h, m, 0, 0); + const minutesUntil = (start - now) / 60000; + // Fire once the appointment enters the lead window, and up to 2 min after its + // start (covers the case where the app was closed/asleep right at the moment). + if (minutesUntil <= lead && minutesUntil >= -2) { + notified.add(appt.id); + const who = appt.animalName ? ` – ${appt.animalName}${appt.ownerName ? " (" + appt.ownerName + ")" : ""}` : ""; + const title = minutesUntil > 1 ? `Termin in ${Math.round(minutesUntil)} Min.` : "Termin jetzt"; + const n = new Notification({ + title, + body: `${appt.title}${who}${appt.location ? "\n" + appt.location : ""}`, + }); + n.on("click", () => { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + win.show(); + win.focus(); + } + }); + n.show(); + } + } +} + +let timer = null; +function start() { + if (timer) return; + checkNow(); + timer = setInterval(checkNow, CHECK_INTERVAL_MS); +} +function stop() { + if (timer) clearInterval(timer); + timer = null; +} + +module.exports = { start, stop }; diff --git a/main/webserver.js b/main/webserver.js new file mode 100644 index 0000000..267170c --- /dev/null +++ b/main/webserver.js @@ -0,0 +1,360 @@ +// webserver.js — small local HTTP server so the Fellakte web client (tablet/Mac +// browser) can reach this PC over the practice WiFi. No cloud, no internet, no +// external registration: only devices on the same local network can connect, and +// only after being paired here with a one-time code. +// +// Phase 4 note: PDF generation and email sending both need a real Electron/Node +// process (Electron's printToPDF for the former, SMTP for the latter) — neither is +// possible from inside a plain browser. Because this server *is* that same process +// (webserver.js runs inside the desktop app, not as a separate relay), the tablet +// gets both "for free" by calling the exact same documents.js/mailer.js code the +// desktop UI already uses, over HTTP, rather than needing new browser-side tech. +"use strict"; + +const path = require("path"); +const os = require("os"); +const fs = require("fs"); +const crypto = require("crypto"); +const express = require("express"); +const QRCode = require("qrcode"); + +const db = require("./db"); +const lock = require("./lock"); +const mailer = require("./mailer"); +const documents = require("./documents"); + +const PORT = 51823; +const PAIRING_TTL_MS = 10 * 60 * 1000; +const MAX_PAIRING_ATTEMPTS = 10; + +let httpServer = null; +let dataFolder = null; +let pairing = null; // { code, expiresAt, attempts } + +function lanAddress() { + const nets = os.networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name] || []) { + if (net.family === "IPv4" && !net.internal) return net.address; + } + } + return "127.0.0.1"; +} + +function getDevices() { + try { + return JSON.parse(db.getSetting("webDevices") || "[]"); + } catch { + return []; + } +} +function saveDevices(list) { + db.setSetting("webDevices", JSON.stringify(list)); +} + +function startPairing() { + const code = String(crypto.randomInt(0, 1000000)).padStart(6, "0"); + pairing = { code, expiresAt: Date.now() + PAIRING_TTL_MS, attempts: 0, pairedDeviceId: null }; + return pairingInfo(); +} + +function pairingInfo() { + if (!pairing) return null; + const url = `http://${lanAddress()}:${PORT}/?pair=${pairing.code}`; + return { + code: pairing.code, + url, + address: lanAddress(), + port: PORT, + expiresAt: pairing.expiresAt, + pairedDeviceId: pairing.pairedDeviceId, + }; +} + +async function pairingQrDataUri() { + if (!pairing) return null; + const url = `http://${lanAddress()}:${PORT}/?pair=${pairing.code}`; + return QRCode.toDataURL(url, { margin: 1, width: 240 }); +} + +function checkAuth(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : req.query.t; + if (!token) return res.status(401).json({ error: "not-authenticated" }); + const devices = getDevices(); + const device = devices.find((d) => d.token === token); + if (!device) return res.status(401).json({ error: "not-authenticated" }); + device.lastSeen = new Date().toISOString(); + saveDevices(devices); + req.deviceToken = token; + next(); +} + +// Same advisory check the desktop app runs at startup — surfaced here on every +// write instead, since the tablet has no "startup" moment of its own to catch it at. +function doubleUseWarning() { + const { active, info } = lock.checkOther(dataFolder); + if (!active) return null; + return { host: info.host, updatedAt: info.updatedAt }; +} + +function fileUrl(relativePath, token) { + const normalized = String(relativePath).replace(/\\/g, "/"); + return `/data/${normalized}?t=${encodeURIComponent(token)}`; +} + +function buildApp() { + const app = express(); + app.disable("x-powered-by"); + app.use(express.json()); + + // Static: the tablet web-client shell, then the same renderer files the desktop + // app itself uses (app.js, styles.css) — single source of truth, nothing duplicated. + app.use(express.static(path.join(__dirname, "..", "web-client"))); + app.use("/assets", express.static(path.join(__dirname, "..", "assets"))); + app.use(express.static(path.join(__dirname, "..", "renderer"))); + + app.post("/api/pair", (req, res) => { + if (!pairing || Date.now() > pairing.expiresAt) { + return res.status(410).json({ error: "expired" }); + } + pairing.attempts++; + if (pairing.attempts > MAX_PAIRING_ATTEMPTS) { + pairing = null; + return res.status(429).json({ error: "too-many-attempts" }); + } + const code = String((req.body && req.body.code) || "").trim(); + if (code !== pairing.code) { + return res.status(401).json({ error: "wrong-code" }); + } + const device = { + id: crypto.randomUUID(), + token: crypto.randomBytes(24).toString("hex"), + name: (req.body && req.body.deviceName) || "Tablet", + pairedAt: new Date().toISOString(), + lastSeen: new Date().toISOString(), + }; + const devices = getDevices(); + devices.push(device); + saveDevices(devices); + pairing.pairedDeviceId = device.id; + res.json({ token: device.token, deviceId: device.id }); + }); + + app.get("/api/directory", checkAuth, (_req, res) => { + res.json(db.listOwnersWithAnimals()); + }); + + app.get("/api/animal/:id", checkAuth, (req, res) => { + const bundle = db.getAnimalBundle(req.params.id); + if (!bundle) return res.status(404).json({ error: "not-found" }); + const token = req.deviceToken; + bundle.photos = bundle.photos.map((p) => ({ ...p, fileUrl: fileUrl(p.relative_path, token) })); + if (bundle.consent && bundle.consent.signature_path) { + bundle.consent.signature_url = fileUrl(bundle.consent.signature_path, token); + } + if (bundle.contract && bundle.contract.signature_path) { + bundle.contract.signature_url = fileUrl(bundle.contract.signature_path, token); + } + res.json(bundle); + }); + + app.get("/api/document-url/:id", checkAuth, (req, res) => { + const doc = db.getDocument(req.params.id); + if (!doc) return res.status(404).json({ error: "not-found" }); + res.json({ url: fileUrl(doc.relative_path, req.deviceToken) }); + }); + + app.get("/api/appointments", checkAuth, (req, res) => { + res.json(db.listAppointments(req.query.from || "", req.query.to || "")); + }); + + app.get("/api/remedies", checkAuth, (req, res) => { + res.json(db.listRemedies(req.query.q || "")); + }); + + app.get("/api/search-notes", checkAuth, (req, res) => { + res.json(db.searchAnimalIdsByNoteText(req.query.q || "")); + }); + + // ---- Phase 2 writes: Notizen, Befunde, Termine ---- + + app.post("/api/note", checkAuth, (req, res) => { + const { animalId, text } = req.body || {}; + if (!animalId || !text) return res.status(400).json({ error: "missing-fields" }); + db.addNote(animalId, text); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + + app.post("/api/finding", checkAuth, (req, res) => { + const { animalId, x, y, severity, text } = req.body || {}; + if (!animalId) return res.status(400).json({ error: "missing-fields" }); + const id = db.addFinding(animalId, { x, y, severity, text }); + res.json({ ok: true, id, warning: doubleUseWarning() }); + }); + app.patch("/api/finding/:id", checkAuth, (req, res) => { + const { severity, text } = req.body || {}; + db.updateFinding(req.params.id, { severity, text }); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + app.delete("/api/finding/:id", checkAuth, (req, res) => { + db.deleteFinding(req.params.id); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + + app.post("/api/appointment", checkAuth, (req, res) => { + const id = db.addAppointment(req.body || {}); + res.json({ ok: true, id, warning: doubleUseWarning() }); + }); + app.patch("/api/appointment/:id", checkAuth, (req, res) => { + db.updateAppointment(req.params.id, req.body || {}); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + app.delete("/api/appointment/:id", checkAuth, (req, res) => { + db.deleteAppointment(req.params.id); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + + // ---- Phase 4: Unterschrift, PDFs, E-Mail ---- + + app.post("/api/consent", checkAuth, (req, res) => { + const { animalId, granted, method, signaturePath, marketingOk, itemsJson } = req.body || {}; + db.setConsent(animalId, { granted, method, signaturePath, marketingOk, itemsJson }); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + + app.post("/api/consent/signature", checkAuth, (req, res) => { + const { animalId, dataUrl } = req.body || {}; + const base64 = String(dataUrl).replace(/^data:image\/png;base64,/, ""); + const dir = documents.filesDirFor(dataFolder, animalId); + const filename = `unterschrift-${Date.now()}.png`; + const absPath = path.join(dir, filename); + fs.writeFileSync(absPath, Buffer.from(base64, "base64")); + const relativePath = path.join("files", animalId, filename); + + const existing = db.getAnimalBundle(animalId).consent; + db.setConsent(animalId, { + granted: true, + method: "Unterschrift auf dem Tablet erfasst", + signaturePath: relativePath, + marketingOk: existing ? !!existing.marketing_ok : false, + }); + const consent = db.getAnimalBundle(animalId).consent; + if (consent && consent.signature_path) consent.signature_url = fileUrl(consent.signature_path, req.deviceToken); + res.json({ ...consent, warning: doubleUseWarning() }); + }); + + app.post("/api/contract", checkAuth, (req, res) => { + const { animalId, granted, method, signaturePath, briefingConfirmed } = req.body || {}; + db.setContract(animalId, { granted, method, signaturePath, briefingConfirmed }); + res.json({ ok: true, warning: doubleUseWarning() }); + }); + + app.post("/api/contract/signature", checkAuth, (req, res) => { + const { animalId, dataUrl } = req.body || {}; + const base64 = String(dataUrl).replace(/^data:image\/png;base64,/, ""); + const dir = documents.filesDirFor(dataFolder, animalId); + const filename = `vertrag-unterschrift-${Date.now()}.png`; + const absPath = path.join(dir, filename); + fs.writeFileSync(absPath, Buffer.from(base64, "base64")); + const relativePath = path.join("files", animalId, filename); + + const existing = db.getAnimalBundle(animalId).contract; + db.setContract(animalId, { + granted: true, + method: "Unterschrift auf dem Tablet erfasst", + signaturePath: relativePath, + briefingConfirmed: existing ? !!existing.briefing_confirmed : true, + }); + const contract = db.getAnimalBundle(animalId).contract; + if (contract && contract.signature_path) contract.signature_url = fileUrl(contract.signature_path, req.deviceToken); + res.json({ ...contract, warning: doubleUseWarning() }); + }); + + app.post("/api/document/pdf", checkAuth, async (req, res) => { + const { animalId, kind, title, ownerBlockHtml, bodyHtml, signedInfoHtml } = req.body || {}; + const bundle = db.getAnimalBundle(animalId); + const record = kind === "vertrag" ? bundle.contract : bundle.consent; + const signatureDataUri = record && record.signature_path ? documents.fileToDataUri(path.join(dataFolder, record.signature_path)) : null; + const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "Einwilligung"; + try { + const result = await documents.savePdfAsDocument(dataFolder, animalId, docLabel, { title, ownerBlockHtml, bodyHtml, signedInfoHtml, signatureDataUri }); + res.json({ ...result, warning: doubleUseWarning() }); + } catch (e) { + res.status(500).json({ error: String((e && e.message) || e) }); + } + }); + + app.post("/api/document/template-pdf", checkAuth, async (req, res) => { + const { animalId, filenamePrefix, title, ownerBlockHtml, bodyHtml } = req.body || {}; + try { + const result = await documents.savePdfAsDocument(dataFolder, animalId, filenamePrefix, { title, ownerBlockHtml, bodyHtml, signedInfoHtml: "", signatureDataUri: null }); + res.json({ ...result, warning: doubleUseWarning() }); + } catch (e) { + res.status(500).json({ error: String((e && e.message) || e) }); + } + }); + + app.post("/api/document/email", checkAuth, async (req, res) => { + const { animalId, kind, absPath, filename } = req.body || {}; + const smtpJson = db.getSetting("smtp"); + if (!smtpJson) return res.json({ ok: false, error: "not-configured" }); + const smtp = JSON.parse(smtpJson); + const bundle = db.getAnimalBundle(animalId); + if (!bundle.owner.email) return res.json({ ok: false, error: "no-owner-email" }); + const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "DSGVO-Einwilligung"; + try { + await mailer.sendMail(smtp, { + to: bundle.owner.email, + subject: `${docLabel} – ${bundle.animal.name} – Tierheilpraxis Iris Hack`, + text: `Guten Tag ${bundle.owner.name},\n\nanbei erhalten Sie die unterschriebenen Unterlagen (${docLabel}) für ${bundle.animal.name}.\n\nMit freundlichen Grüßen\nIris Hack\nTierheilpraxis Iris Hack`, + attachments: [{ filename, path: absPath }], + }); + if (kind === "vertrag") db.markContractEmailed(animalId); + else db.markConsentEmailed(animalId); + res.json({ ok: true }); + } catch (e) { + res.json({ ok: false, error: String((e && e.message) || e) }); + } + }); + + // Serves photos/signatures/documents. Auth via ?t= since / tags can't set + // an Authorization header. + app.get("/data/*splat", (req, res) => { + const token = req.query.t; + const devices = getDevices(); + if (!token || !devices.some((d) => d.token === token)) return res.status(401).end(); + if (!dataFolder) return res.status(500).end(); + const rel = Array.isArray(req.params.splat) ? req.params.splat.join("/") : req.params.splat; + const abs = path.join(dataFolder, rel); + // Guard against escaping the data folder via a crafted relative path. + if (!abs.startsWith(path.join(dataFolder))) return res.status(400).end(); + if (!fs.existsSync(abs)) return res.status(404).end(); + res.sendFile(abs); + }); + + return app; +} + +function start(folder) { + dataFolder = folder; + if (httpServer) return; + const app = buildApp(); + httpServer = app.listen(PORT, "0.0.0.0"); +} + +function stop() { + if (httpServer) { + httpServer.close(); + httpServer = null; + } +} + +function listDevices() { + return getDevices().map((d) => ({ id: d.id, name: d.name, pairedAt: d.pairedAt, lastSeen: d.lastSeen })); +} +function revokeDevice(id) { + saveDevices(getDevices().filter((d) => d.id !== id)); +} + +module.exports = { start, stop, startPairing, pairingInfo, pairingQrDataUri, listDevices, revokeDevice, lanAddress, PORT };