Upload via GUI (16 Dateien)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
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
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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 = `<!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 · 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 · Elisabethstr. 17, 84489 Burghausen · Steuernummer: 106/224/10465 · 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 };
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user