Upload via GUI (16 Dateien)

This commit is contained in:
2026-09-14 18:57:05 +00:00
parent 6733901d99
commit 98d23de73f
16 changed files with 1930 additions and 0 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

+103
View File
@@ -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 <dataFolder>/backups/<YYYY-MM-DD>/,
// 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 };
+612
View File
@@ -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/<animalId> 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/<animalId> 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;
},
};
+58
View File
@@ -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 };
+320
View File
@@ -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;
});
};
+70
View File
@@ -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 };
+35
View File
@@ -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 };
+145
View File
@@ -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();
});
+69
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
async function renderPdf({ title, ownerBlockHtml, bodyHtml, signatureDataUri, signedInfoHtml, logoDataUri }) {
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
<style>
@page { margin: 22mm 18mm; }
/* .footer is pinned to the bottom of every printed page (see below); this padding
reserves the same space in the normal content flow so text never runs under it. */
body{ font-family: Georgia, 'Times New Roman', serif; color:#3a332c; margin:0; font-size:12px; line-height:1.55; padding-bottom:34px; }
.header{ display:flex; align-items:center; gap:16px; border-bottom:2px solid #b8975a; padding-bottom:14px; margin-bottom:22px; }
.header img{ height:56px; width:auto; }
.practice{ font-family: Georgia, serif; font-size:10.5px; color:#7a6c60; line-height:1.5; }
.practice b{ color:#3a332c; font-size:12px; letter-spacing:.03em; }
h1{ font-size:19px; margin:0 0 20px; color:#3a332c; font-weight:normal; letter-spacing:.02em; }
.owner-block{ font-size:12px; margin-bottom:20px; }
.owner-block .row{ display:flex; gap:8px; margin-bottom:3px; }
.owner-block .lbl{ width:120px; color:#7a6c60; flex-shrink:0; }
.body h2{ font-size:12.5px; margin:16px 0 5px; color:#3a332c; }
.body p{ margin:0 0 8px; }
.body ul{ padding-left:18px; margin:6px 0 12px; }
.body li{ margin-bottom:5px; }
.body li.checked::before{ content:"\\2713 "; color:#6f8f5c; font-weight:bold; }
.body li.unchecked::before{ content:"\\2717 "; color:#a89c8f; }
.sig-block{ margin-top:30px; border-top:1px solid #e2d0bd; padding-top:14px; font-size:11.5px; }
.sig-block img{ max-height:65px; display:block; margin:8px 0 4px; }
/* position:fixed repeats an element on every page when Chromium paginates for print,
which is exactly what we want for a letterhead footer instead of it just trailing
after the content wherever that happens to end. */
.footer{ position:fixed; left:0; right:0; bottom:0; padding-top:10px; border-top:1px solid #e2d0bd; font-size:8.5px; color:#a89c8f; text-align:center; background:#fff; }
</style></head>
<body>
<div class="header">
${logoDataUri ? `<img src="${logoDataUri}">` : ""}
<div class="practice"><b>TIERHEILPRAXIS IRIS HACK</b><br>
Iris Hack, Elisabethstr. 17, 84489 Burghausen, Deutschland<br>
Tel: +49 160 3408101 &middot; info@tierheilpraxis-hack.de</div>
</div>
<h1>${escapeHtml(title)}</h1>
<div class="owner-block">${ownerBlockHtml}</div>
<div class="body">${bodyHtml}</div>
${signedInfoHtml || signatureDataUri ? `<div class="sig-block">${signedInfoHtml}${signatureDataUri ? `<img src="${signatureDataUri}">` : ""}</div>` : ""}
<div class="footer">Tierheilpraxis Iris Hack &middot; Elisabethstr. 17, 84489 Burghausen &middot; Steuernummer: 106/224/10465 &middot; IBAN: DE59711600000007312210</div>
</body></html>`;
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 };
+80
View File
@@ -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"),
});
+78
View File
@@ -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 };
+360
View File
@@ -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 <img>/<a> 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 };