Zwei neue, pro Benutzer gespeicherte Konfigurationswerte: GEN_DOKUMENTE_DEFAULT (Standard: Anschreiben + Lebenslauf) und GEN_ANLAGEN_DEFAULT (Standard: keine). Sie belegen die Häkchen auf der Bewerbungsseite vor; eine bereits getroffene Dokumentenauswahl der Bewerbung gewinnt weiterhin. Die REST-API nutzt die Vorauswahl, wenn dokumente/anlagen im Body fehlen. Die Einstellungsseite kennt dafür jetzt Checkbox-Gruppen, deren Optionen auch erst zur Renderzeit feststehen dürfen (die eigenen Anlagen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
// Centralized, per-user configuration store.
|
||
//
|
||
// Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a
|
||
// user. The values live in the SQLite app_state table (key prefixed "cfg:"),
|
||
// now keyed by (user_id, key). The /einstellungen page edits the *current* user's
|
||
// values; the libs (mailer, caldav, chat, documents) read through config.get() /
|
||
// config.ollama() at call time, picking up the current user from the per-request
|
||
// context (lib/context.js). An edit therefore takes effect immediately, scoped
|
||
// to the user who made it — no restart, no .env file.
|
||
//
|
||
// On first start of an install that previously used .env (or that still passes
|
||
// the values as container env vars), the still-present env values are imported
|
||
// into the *admin* user's rows once — see importEnvIntoAdmin(), called from
|
||
// server.js at boot. After that the database is the single source of truth.
|
||
//
|
||
// process.env is deliberately NOT a read fallback: env config belongs to the
|
||
// admin, so falling back to it would hand every freshly created user the admin's
|
||
// mailbox, calendar, Ollama key and API token. Unset keys resolve to the
|
||
// built-in defaults instead, which leaves a new user's settings empty.
|
||
|
||
const { currentUser } = require('./context');
|
||
|
||
const DEFAULTS = {
|
||
// Ollama Cloud (KI text generation + chat).
|
||
OLLAMA_API_KEY: '',
|
||
OLLAMA_MODEL: 'glm-5.2:cloud',
|
||
OLLAMA_HOST: 'https://ollama.com',
|
||
OLLAMA_TIMEOUT_MS: '300000',
|
||
// Which documents are preselected when a generation is started (comma separated,
|
||
// see DOKUMENT_TYPEN in lib/documents.js). Empty means both.
|
||
GEN_DOKUMENTE_DEFAULT: 'anschreiben,lebenslauf',
|
||
// Which static attachments (basis_anhaenge IDs, comma separated) are preselected.
|
||
// Empty = none, which is the default.
|
||
GEN_ANLAGEN_DEFAULT: '',
|
||
// E-Mail (SMTP submission + IMAP receive).
|
||
MAIL_HOST: '',
|
||
MAIL_SMTP_PORT: '587',
|
||
MAIL_IMAP_PORT: '993',
|
||
MAIL_USER: '',
|
||
MAIL_PASSWORD: '',
|
||
MAIL_FROM_NAME: '',
|
||
MAIL_FROM: '',
|
||
MAIL_IMAP_MAILBOX: 'INBOX',
|
||
MAIL_POLL_MS: '180000',
|
||
// Bewerbungskalender (CalDAV, z. B. SOGo).
|
||
CALDAV_URL: '',
|
||
CALDAV_ALARM_MIN: '60',
|
||
CALDAV_POLL_MS: '300000',
|
||
// REST-API für Drittanbietersoftware (/api/v1, Header X-API-Key).
|
||
API_TOKEN: '',
|
||
};
|
||
|
||
// UI metadata for the /einstellungen page: grouped sections with input types.
|
||
// `secret` fields render as password inputs with a reveal toggle.
|
||
const FIELDS = [
|
||
{
|
||
titel: 'Ollama Cloud (KI-Generierung + Chat)',
|
||
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
|
||
items: [
|
||
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
|
||
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: glm-5.2:cloud' },
|
||
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
|
||
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
|
||
],
|
||
},
|
||
{
|
||
titel: 'Bewerbungsunterlagen (Vorauswahl)',
|
||
beschreibung: 'Was ist auf der Bewerbungsseite vorab angehakt, wenn die KI-Generierung gestartet wird? Pro Bewerbung lässt sich die Auswahl weiterhin ändern; die zuletzt gewählten Dokumente bleiben an der jeweiligen Bewerbung erhalten.',
|
||
items: [
|
||
{
|
||
key: 'GEN_DOKUMENTE_DEFAULT',
|
||
label: 'Standardmäßig generieren',
|
||
type: 'checkboxes',
|
||
options: [
|
||
{ value: 'anschreiben', label: 'Anschreiben' },
|
||
{ value: 'lebenslauf', label: 'Lebenslauf' },
|
||
],
|
||
help: 'Standard: beides. Die E-Mail-Begleitnachricht wird immer erzeugt. Ohne Häkchen gilt wieder beides.',
|
||
},
|
||
{
|
||
key: 'GEN_ANLAGEN_DEFAULT',
|
||
label: 'Standardmäßig beigelegte Anlagen',
|
||
type: 'checkboxes',
|
||
// Auswahlmöglichkeiten sind die eigenen Anlagen aus „Vorlagen“ und stehen
|
||
// erst zur Renderzeit fest — die Route reicht sie unter diesem Namen hinein.
|
||
optionsFrom: 'anlagen',
|
||
leerHinweis: 'Noch keine Anlagen hinterlegt – unter „Vorlagen“ kannst du Zeugnisse & Co. hochladen.',
|
||
help: 'Standard: keine. Angehakte Anlagen werden bei jeder Generierung vorausgewählt und im Anschreiben unter „Anlagen“ aufgeführt.',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
||
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
|
||
items: [
|
||
{ key: 'MAIL_HOST', label: 'SMTP/IMAP Host' },
|
||
{ key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' },
|
||
{ key: 'MAIL_IMAP_PORT', label: 'IMAP-Port', help: 'Standard: 993 (implicit TLS)' },
|
||
{ key: 'MAIL_USER', label: 'Benutzername (Login)', help: 'Auch CalDAV-Login' },
|
||
{ key: 'MAIL_PASSWORD', label: 'Passwort', secret: true, help: 'Auch CalDAV-Passwort' },
|
||
{ key: 'MAIL_FROM_NAME', label: 'Absendername' },
|
||
{ key: 'MAIL_FROM', label: 'Absenderadresse', help: 'Leer = Benutzername' },
|
||
{ key: 'MAIL_IMAP_MAILBOX', label: 'IMAP-Postfach', help: 'Standard: INBOX' },
|
||
{ key: 'MAIL_POLL_MS', label: 'Abrufintervall (ms)', help: 'Standard: 180000 (3 Min.) — greift nach Neustart' },
|
||
],
|
||
},
|
||
{
|
||
titel: 'Bewerbungskalender (CalDAV)',
|
||
beschreibung: 'Voll-URL der Kalender-Sammlung (mit abschließendem /). Authentifizierung läuft über MAIL_USER/MAIL_PASSWORD. Ohne URL sind die Kalender-Funktionen deaktiviert.',
|
||
items: [
|
||
{ key: 'CALDAV_URL', label: 'Kalender-URL', help: 'z. B. https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/' },
|
||
{ key: 'CALDAV_ALARM_MIN', label: 'Erinnerung (Minuten vor Termin)', help: 'Standard: 60' },
|
||
{ key: 'CALDAV_POLL_MS', label: 'Sync-Intervall (ms)', help: 'Standard: 300000 (5 Min.) — greift nach Neustart' },
|
||
],
|
||
},
|
||
{
|
||
titel: 'REST-API für Drittanbietersoftware',
|
||
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Der Token identifiziert den Benutzer: Anfragen sehen und ändern ausschließlich dessen eigene Daten. Ohne Token antwortet die API (bis auf /health) mit 401. Swagger unter /swagger.',
|
||
items: [
|
||
{
|
||
key: 'API_TOKEN',
|
||
label: 'API-Token (X-API-Key)',
|
||
secret: true,
|
||
generate: true,
|
||
help: 'Leer = API deaktiviert. Über das Auge einblenden, „Neu generieren“ erzeugt einen zufälligen Token — danach speichern. Ein neuer Token macht den alten sofort ungültig.',
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const PREFIX = 'cfg:';
|
||
|
||
// Per-user cache: Map<userId, Object<string,string>>. A user is loaded lazily on
|
||
// first access (ensureLoaded) and stays cached for the process lifetime. Edits
|
||
// via saveAll() update the cache in place so subsequent reads are consistent.
|
||
const cache = new Map(); // userId -> { key: value }
|
||
const loaded = new Set(); // userIds whose cfg rows have been read from the DB
|
||
let dbAllFn = null;
|
||
let dbRunFn = null;
|
||
|
||
// The built-in default for a key. Deliberately NOT a process.env lookup: env
|
||
// values belong to the admin (they are imported into the admin's rows once at
|
||
// boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back
|
||
// to process.env, every user without their own row would silently inherit the
|
||
// admin's credentials — their own mailbox, calendar, Ollama key and API token.
|
||
// The defaults below are non-secret standards only (model, host, ports, poll
|
||
// intervals); every credential defaults to empty, so a new user starts with
|
||
// Ollama/E-Mail/CalDAV/API switched off until they configure their own.
|
||
function builtinDefault(key) {
|
||
return DEFAULTS[key];
|
||
}
|
||
|
||
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
|
||
async function ensureLoaded(userId) {
|
||
if (!userId || loaded.has(userId)) return;
|
||
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]);
|
||
const obj = Object.create(null);
|
||
for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value;
|
||
cache.set(userId, obj);
|
||
loaded.add(userId);
|
||
}
|
||
|
||
// Drop the cached rows for a user so the next read reloads from the DB. Used
|
||
// after a direct DB write outside saveAll() (e.g. the one-time env migration).
|
||
function invalidate(userId) {
|
||
loaded.delete(userId);
|
||
cache.delete(userId);
|
||
}
|
||
|
||
// Synchronous read for the *current user only*. A key the user has not stored
|
||
// falls back to the built-in default — never to another user's value and never
|
||
// to process.env. A user who has configured nothing therefore reads as "empty"
|
||
// (no mail host, no keys), which is exactly what isolates them: mailer/caldav
|
||
// isConfigured() turns false and their background jobs stay idle.
|
||
//
|
||
// Outside a request context (boot) there is no user, so only the defaults apply.
|
||
// Callers that need a specific user's values must run inside that user's context
|
||
// (see lib/context.js) and must have called ensureLoaded(userId) first.
|
||
function get(key) {
|
||
const u = currentUser();
|
||
const userObj = u ? cache.get(u.id) : null;
|
||
if (userObj && userObj[key] !== undefined) return userObj[key];
|
||
return builtinDefault(key);
|
||
}
|
||
|
||
// All keys with their effective values for the current user — used by the
|
||
// settings UI. Must be called within a request context.
|
||
function getAll() {
|
||
const out = {};
|
||
for (const key of Object.keys(DEFAULTS)) out[key] = get(key);
|
||
return out;
|
||
}
|
||
|
||
// The current user's preselected static attachments (GEN_ANLAGEN_DEFAULT) as
|
||
// basis_anhaenge IDs. Deleted attachments may linger in the list; callers match
|
||
// them against the existing rows, so stale IDs simply never hit.
|
||
function anlagenDefaultIds() {
|
||
return String(get('GEN_ANLAGEN_DEFAULT') || '')
|
||
.split(',')
|
||
.map((v) => parseInt(v, 10))
|
||
.filter((n) => !Number.isNaN(n));
|
||
}
|
||
|
||
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
|
||
function ollama() {
|
||
return {
|
||
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
||
model: get('OLLAMA_MODEL') || 'glm-5.2:cloud',
|
||
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
|
||
apiKey: get('OLLAMA_API_KEY') || '',
|
||
};
|
||
}
|
||
|
||
// Wire up DB helpers. The one-time .env migration now runs per-admin at boot
|
||
// from server.js (it needs the admin user id); this init only stores the fns.
|
||
async function init({ dbAll, dbRun }) {
|
||
dbAllFn = dbAll;
|
||
dbRunFn = dbRun;
|
||
}
|
||
|
||
// Persist every key for the current user (writes all rows, including empty
|
||
// strings, so a cleared field is stored as empty and no longer falls back to
|
||
// env/default). Must be called within a request context.
|
||
async function saveAll(values) {
|
||
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||
const u = currentUser();
|
||
if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.');
|
||
const obj = cache.get(u.id) || Object.create(null);
|
||
for (const key of Object.keys(DEFAULTS)) {
|
||
const v = values && values[key] != null ? String(values[key]) : '';
|
||
await dbRunFn(
|
||
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||
[u.id, PREFIX + key, v]
|
||
);
|
||
obj[key] = v;
|
||
}
|
||
cache.set(u.id, obj);
|
||
loaded.add(u.id);
|
||
}
|
||
|
||
// Write a single key for an explicit user (used by the one-time env migration,
|
||
// which runs outside a request context). Updates the cache if loaded.
|
||
async function setForUser(userId, key, value) {
|
||
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||
await dbRunFn(
|
||
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||
[userId, PREFIX + key, String(value)]
|
||
);
|
||
if (loaded.has(userId)) {
|
||
const obj = cache.get(userId) || Object.create(null);
|
||
obj[key] = String(value);
|
||
cache.set(userId, obj);
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
DEFAULTS, FIELDS, PREFIX,
|
||
get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate,
|
||
}; |