Multi-User-Plattform: jeder Benutzer hat eigene, isolierte Daten
- Auth via Session-Cookie + Login-Seite (scrypt, lib/password.js, sessions-Tabelle) - AsyncLocalStorage (lib/context.js) propagiert aktuellen Benutzer durch alle Libs - user_id auf allen Datentabellen (FK->users ON DELETE CASCADE), per-user PK/UNIQUE (app_state, settings, prompts, design, jobangebote) und per-user Dateispeicher (data/<dir>/<userId>/) - Alle Queries in server.js + lib/api.js nach user_id scope-iert - Pro-Benutzer-Konfiguration (Ollama/Mail/CalDAV/API-Token) in app_state, Live gelesen via config.get(); Hintergrund-Loops (IMAP/CalDAV) iterieren alle Benutzer - REST-API /api/v1: X-API-Key loest den Token zu einem Benutzer auf, Anfragen operieren nur auf dessen Daten - Admin-Panel /admin: Benutzer anlegen, Passwort zuruecksetzen, loeschen (mit Daten) - Idempotente Migration (lib/migrate-multiuser.js + scripts/migrate-to-multiuser.js): bestehende Daten werden dem Benutzer admin:admin zugeordnet Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+142
-111
@@ -1,10 +1,11 @@
|
||||
// Third-party REST API (v1) for the Bewerbungs-Tracker.
|
||||
//
|
||||
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
||||
// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key
|
||||
// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard,
|
||||
// generation runner and attachment directories so behaviour stays consistent
|
||||
// with the web UI.
|
||||
// API key sent in the X-API-Key header. The key is per-user: it is the
|
||||
// API_TOKEN the user saved on /einstellungen. The auth step resolves the key to
|
||||
// the owning user, then runs every request inside that user's context
|
||||
// (lib/context.js) so all queries + file paths are scoped to that user — the
|
||||
// third-party software only ever sees and creates the user's own data.
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
@@ -12,6 +13,9 @@ const fs = require('fs');
|
||||
const blacklist = require('./blacklist');
|
||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||
const { normalizeDokumente } = require('./documents');
|
||||
const { userContext, currentUserId } = require('./context');
|
||||
|
||||
const CONFIG_PREFIX = 'cfg:';
|
||||
|
||||
// Replace the stored labels JSON string with a real array on outgoing rows.
|
||||
function withLabels(row) {
|
||||
@@ -37,35 +41,53 @@ function createExternalApi(deps) {
|
||||
runGeneration,
|
||||
anhaengeDir,
|
||||
emailAnhaengeDir,
|
||||
apiToken,
|
||||
userStorageDir,
|
||||
} = deps;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// `apiToken` may be a string (static) or a function () => string (dynamic,
|
||||
// read from the DB on each request so an edit on /einstellungen takes effect
|
||||
// without a restart).
|
||||
const resolveToken = () => (typeof apiToken === 'function' ? apiToken() : apiToken);
|
||||
// The current user's id, resolved from the per-request context set below.
|
||||
const uid = () => currentUserId();
|
||||
|
||||
// --- API key auth --------------------------------------------------
|
||||
// /health is public so monitoring tools can probe availability; everything
|
||||
// else returns 401 when the header is missing/wrong or the token isn't set.
|
||||
router.use((req, res, next) => {
|
||||
// /health is public so monitoring tools can probe availability. Every other
|
||||
// endpoint resolves the X-API-Key to the user who owns that token (stored in
|
||||
// app_state as cfg:API_TOKEN), then wraps the rest of the request in that
|
||||
// user's context so all queries/files are scoped to them. A missing or
|
||||
// unknown key yields 401.
|
||||
router.use(async (req, res, next) => {
|
||||
if (req.path === '/health') return next();
|
||||
const token = resolveToken();
|
||||
if (!token) {
|
||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' });
|
||||
}
|
||||
const provided = req.get('X-API-Key');
|
||||
if (!provided || provided !== token) {
|
||||
if (!provided) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
next();
|
||||
try {
|
||||
// Resolve the token to its owning user (the user whose cfg:API_TOKEN
|
||||
// matches). Two users could in principle share a value — we take the
|
||||
// first match, which is fine since the data the caller then sees is that
|
||||
// one user's only.
|
||||
const user = await dbGet(
|
||||
`SELECT u.id, u.username, u.is_admin
|
||||
FROM app_state a JOIN users u ON u.id = a.user_id
|
||||
WHERE a.key = ? AND a.value = ? LIMIT 1`,
|
||||
[CONFIG_PREFIX + 'API_TOKEN', provided]
|
||||
);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
req.user = user;
|
||||
// Run the remainder of the request inside this user's context so
|
||||
// currentUserId() / config.get() / the scoped helpers all resolve here.
|
||||
userContext.run(user, next);
|
||||
} catch (err) {
|
||||
console.error('API auth error:', err);
|
||||
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- helpers -------------------------------------------------------
|
||||
async function getApplication(id) {
|
||||
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
return dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
}
|
||||
|
||||
// --- System --------------------------------------------------------
|
||||
@@ -80,8 +102,8 @@ function createExternalApi(deps) {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
const where = ['user_id = ?'];
|
||||
const params = [uid()];
|
||||
if (label && LABEL_OPTIONS.includes(label)) {
|
||||
// labels is a JSON array string; match the quoted label token.
|
||||
where.push('labels LIKE ?');
|
||||
@@ -109,7 +131,7 @@ function createExternalApi(deps) {
|
||||
params.push(term, term);
|
||||
}
|
||||
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const clause = `WHERE ${where.join(' AND ')}`;
|
||||
const applications = await dbAll(
|
||||
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
@@ -146,10 +168,11 @@ function createExternalApi(deps) {
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||
(user_id, datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[
|
||||
uid(),
|
||||
datum,
|
||||
sanitizeInput(firma),
|
||||
sanitizeInput(stelle),
|
||||
@@ -167,12 +190,12 @@ function createExternalApi(deps) {
|
||||
|
||||
if (b.status && String(b.status).trim()) {
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
||||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||
[uid(), result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
||||
);
|
||||
}
|
||||
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.json({ success: true, application: withLabels(application) });
|
||||
} catch (error) {
|
||||
console.error('API create application error:', error);
|
||||
@@ -208,7 +231,7 @@ function createExternalApi(deps) {
|
||||
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
||||
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
||||
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
b.datum,
|
||||
sanitizeInput(b.firma),
|
||||
@@ -225,10 +248,11 @@ function createExternalApi(deps) {
|
||||
? serializeLabels(b.labels)
|
||||
: (existing.labels || '[]'),
|
||||
id,
|
||||
uid(),
|
||||
]
|
||||
);
|
||||
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
res.json({ success: true, application: withLabels(application) });
|
||||
} catch (error) {
|
||||
console.error('API update application error:', error);
|
||||
@@ -242,8 +266,8 @@ function createExternalApi(deps) {
|
||||
const existing = await getApplication(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete application error:', error);
|
||||
@@ -257,8 +281,8 @@ function createExternalApi(deps) {
|
||||
const { id } = req.params;
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const verlauf = await dbAll(
|
||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||
[id]
|
||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json(verlauf);
|
||||
} catch (error) {
|
||||
@@ -279,12 +303,12 @@ function createExternalApi(deps) {
|
||||
const day = datum || new Date().toISOString().split('T')[0];
|
||||
|
||||
const result = await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[id, day, status, sanitizeInput(kommentar || '')]
|
||||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||
[uid(), id, day, status, sanitizeInput(kommentar || '')]
|
||||
);
|
||||
await syncCurrentStatus(id);
|
||||
|
||||
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
|
||||
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
console.error('API add timeline error:', error);
|
||||
@@ -296,12 +320,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id, eintragId } = req.params;
|
||||
const entry = await dbGet(
|
||||
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
|
||||
[eintragId, id]
|
||||
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||
[eintragId, id, uid()]
|
||||
);
|
||||
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
|
||||
|
||||
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
|
||||
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND user_id = ?', [eintragId, uid()]);
|
||||
await syncCurrentStatus(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -316,8 +340,8 @@ function createExternalApi(deps) {
|
||||
const { id } = req.params;
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||
[id]
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json(anhaenge);
|
||||
} catch (error) {
|
||||
@@ -330,12 +354,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id, attachmentId } = req.params;
|
||||
const anhang = await dbGet(
|
||||
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
|
||||
[attachmentId, id]
|
||||
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||
[attachmentId, id, uid()]
|
||||
);
|
||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
|
||||
const file = path.join(anhaengeDir, anhang.pfad);
|
||||
const file = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||
|
||||
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
|
||||
@@ -352,14 +376,14 @@ function createExternalApi(deps) {
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
const emails = await dbAll(
|
||||
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||
[id]
|
||||
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
if (emails.length) {
|
||||
const eIds = emails.map((e) => e.id);
|
||||
const atts = await dbAll(
|
||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||
eIds
|
||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||
[uid(), ...eIds]
|
||||
);
|
||||
const byEmail = {};
|
||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||
@@ -376,12 +400,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { emailId, attachmentId } = req.params;
|
||||
const anhang = await dbGet(
|
||||
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
|
||||
[attachmentId, emailId]
|
||||
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ? AND user_id = ?',
|
||||
[attachmentId, emailId, uid()]
|
||||
);
|
||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
|
||||
const file = path.join(emailAnhaengeDir, anhang.pfad);
|
||||
const file = path.join(userStorageDir(emailAnhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||
|
||||
res.download(file, anhang.name || path.basename(anhang.pfad));
|
||||
@@ -399,18 +423,19 @@ function createExternalApi(deps) {
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, uid()]);
|
||||
}
|
||||
|
||||
// Drop existing generated attachments + their files before re-generating.
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
const dir = userStorageDir(anhaengeDir);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
fs.promises.unlink(path.join(dir, a.pfad)).catch(() => {});
|
||||
}
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
await dbRun(
|
||||
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
|
||||
[id]
|
||||
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?",
|
||||
[id, uid()]
|
||||
);
|
||||
|
||||
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
|
||||
@@ -421,7 +446,7 @@ function createExternalApi(deps) {
|
||||
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
||||
// both). Omitted / empty means both.
|
||||
const dokumente = normalizeDokumente((req.body || {}).dokumente);
|
||||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]);
|
||||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
|
||||
runGeneration(id, { anlagenIds, dokumente });
|
||||
res.status(202).json({ success: true, dokumente });
|
||||
} catch (error) {
|
||||
@@ -434,14 +459,14 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet(
|
||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
|
||||
[id]
|
||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?',
|
||||
[id, uid()]
|
||||
);
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||
[id]
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
|
||||
} catch (error) {
|
||||
@@ -453,7 +478,7 @@ function createExternalApi(deps) {
|
||||
// --- Settings ------------------------------------------------------
|
||||
router.get('/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('API get settings error:', error);
|
||||
@@ -465,8 +490,8 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { name, adresse, kundennummer } = req.body || {};
|
||||
await dbRun(
|
||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
|
||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE user_id = ?',
|
||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer), uid()]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -478,17 +503,19 @@ function createExternalApi(deps) {
|
||||
// --- Statistics & export -------------------------------------------
|
||||
router.get('/statistics', async (req, res) => {
|
||||
try {
|
||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
||||
const byArt = await dbAll(`
|
||||
SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC
|
||||
`);
|
||||
const byStatus = await dbAll(`
|
||||
SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE status IS NOT NULL AND status != ''
|
||||
GROUP BY status ORDER BY count DESC
|
||||
`);
|
||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [uid()]);
|
||||
const byArt = await dbAll(
|
||||
`SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC`,
|
||||
[uid()]
|
||||
);
|
||||
const byStatus = await dbAll(
|
||||
`SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND status IS NOT NULL AND status != ''
|
||||
GROUP BY status ORDER BY count DESC`,
|
||||
[uid()]
|
||||
);
|
||||
res.json({
|
||||
total: totalCount ? totalCount.count : 0,
|
||||
byArt,
|
||||
@@ -503,16 +530,16 @@ function createExternalApi(deps) {
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { month, year } = req.query;
|
||||
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
|
||||
const params = [];
|
||||
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC';
|
||||
const params = [uid()];
|
||||
if (month && year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(month).padStart(2, '0'), String(year));
|
||||
} else if (month) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(month).padStart(2, '0'));
|
||||
} else if (year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(year));
|
||||
}
|
||||
|
||||
@@ -530,7 +557,7 @@ function createExternalApi(deps) {
|
||||
// --- Templates -----------------------------------------------------
|
||||
router.get('/templates', async (req, res) => {
|
||||
try {
|
||||
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||||
res.json(docs);
|
||||
} catch (error) {
|
||||
console.error('API list templates error:', error);
|
||||
@@ -539,12 +566,14 @@ function createExternalApi(deps) {
|
||||
});
|
||||
|
||||
// --- Job offers (ingested by third-party software) -----------------
|
||||
// POST upserts by (quelle, external_id): re-sending the same offer updates
|
||||
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
|
||||
// POST upserts by (user_id, quelle, external_id): re-sending the same offer
|
||||
// updates it instead of creating a duplicate. `quelle` defaults to
|
||||
// "drittanbieter".
|
||||
router.get('/joboffers', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(
|
||||
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
|
||||
'SELECT * FROM jobangebote WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||
[uid()]
|
||||
);
|
||||
rows.forEach(withLabels);
|
||||
res.json(rows);
|
||||
@@ -560,7 +589,8 @@ function createExternalApi(deps) {
|
||||
router.get('/joboffers/blacklist', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(
|
||||
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
|
||||
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||
[uid()]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
@@ -587,10 +617,10 @@ function createExternalApi(deps) {
|
||||
}
|
||||
const cols = blacklist.COLUMNS;
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.status(201).json({ success: true, entry: row });
|
||||
} catch (error) {
|
||||
console.error('API create blacklist entry error:', error);
|
||||
@@ -600,9 +630,9 @@ function createExternalApi(deps) {
|
||||
|
||||
router.delete('/joboffers/blacklist/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
|
||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete blacklist entry error:', error);
|
||||
@@ -612,7 +642,7 @@ function createExternalApi(deps) {
|
||||
|
||||
router.get('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
res.json(withLabels(row));
|
||||
} catch (error) {
|
||||
@@ -629,7 +659,7 @@ function createExternalApi(deps) {
|
||||
}
|
||||
|
||||
// Reject blacklisted offers outright — they must never reappear.
|
||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||
const blocked = blacklist.matchBlacklist(blacklistRows, b);
|
||||
if (blocked) {
|
||||
return res.status(409).json({
|
||||
@@ -669,19 +699,20 @@ function createExternalApi(deps) {
|
||||
firmaSlug,
|
||||
];
|
||||
|
||||
// De-dup: prefer a (quelle, external_id) match, else the same normalized
|
||||
// URL — so the same posting never lands twice, even with a new id.
|
||||
// De-dup: prefer a (user_id, quelle, external_id) match, else the same
|
||||
// normalized URL — so the same posting never lands twice, even with a
|
||||
// new id.
|
||||
let existing = null;
|
||||
if (externalId) {
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
|
||||
[quelle, externalId]
|
||||
'SELECT id FROM jobangebote WHERE user_id = ? AND quelle = ? AND external_id = ?',
|
||||
[uid(), quelle, externalId]
|
||||
);
|
||||
}
|
||||
if (!existing && urlNorm) {
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||
[urlNorm]
|
||||
'SELECT id FROM jobangebote WHERE user_id = ? AND url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||
[uid(), urlNorm]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -689,20 +720,20 @@ function createExternalApi(deps) {
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[...fields, existing.id]
|
||||
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
|
||||
[...fields, existing.id, uid()]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [existing.id, uid()]);
|
||||
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
|
||||
}
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote
|
||||
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[externalId, quelle, ...fields]
|
||||
(user_id, external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uid(), externalId, quelle, ...fields]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
|
||||
} catch (error) {
|
||||
console.error('API create job offer error:', error);
|
||||
@@ -714,24 +745,24 @@ function createExternalApi(deps) {
|
||||
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
|
||||
router.delete('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
|
||||
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
|
||||
let blacklisted = false;
|
||||
if (!skipBlacklist) {
|
||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||
if (!blacklist.matchBlacklist(rows, row)) {
|
||||
const cols = blacklist.COLUMNS;
|
||||
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
|
||||
await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||
);
|
||||
}
|
||||
blacklisted = true;
|
||||
}
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
res.json({ success: true, blacklisted });
|
||||
} catch (error) {
|
||||
console.error('API delete job offer error:', error);
|
||||
|
||||
Reference in New Issue
Block a user