Die Jobsuche lag in zwei Host-Skripten (jobsuche-cron.sh, jobsuche-remote-cron.sh) aus der Ein-Benutzer-Zeit: Die Suchkriterien (sieben Staedte, Rollen, Buzzwords) standen fest im Prompt, und importiert wurde mit EINEM globalen API-Token aus der .env - der zufaellig dem Admin gehoerte. Auf der Multi-User-Plattform ist beides hinfaellig. Jetzt legt jeder Benutzer sein Suchprofil selbst fest (/jobsuche): - Modus: regional / 100 % Remote / beides - Staedte (die erste gilt als Wohnort und wird hoechstpriorisiert, max. 12) - Feinschliff: zusaetzliche Begriffe, Ausschluesse - Zeitplan: Wochentage + Uhrzeit, plus Button "Jetzt suchen" Rollen und Technologien bleiben abgeleitet - aus dem Lebenslauf des Benutzers (basis_dokumente), nicht aus einer gepflegten Liste. Admins koennen Profil und Zeitplan eines Benutzers ueber /jobsuche?user=<id> mitpflegen (Link im Admin-Panel). Aufteilung App/Host: Der Container hat weder claude noch ollama. Die App reiht Laeufe daher nur in die Warteschlange ein (Tabelle suchlaeufe); der neue Runner auf dem Host (scripts/jobsuche-runner.js, Cron alle 5 Min) arbeitet sie ab, baut den Prompt je Benutzer aus dessen Profil + Lebenslauf und laeuft mit DESSEN Zugangsdaten: - BEWERBUNG_API_KEY = eigener API-Token des Benutzers (wird beim ersten Lauf automatisch erzeugt), damit Treffer im richtigen Konto landen und durch dieselbe Dedup-/Blacklist-Logik gehen, - ANTHROPIC_BASE_URL/AUTH_TOKEN = eigener Ollama-Key des Benutzers ueber die Anthropic-kompatible Schnittstelle von Ollama Cloud (https://ollama.com/v1/messages, verifiziert: gueltiger Key -> 200, ungueltiger -> 401). Damit zahlt jeder seine eigene Suche, statt alles ueber die Host-Subscription zu buchen (JOBSUCHE_KI_AUTH=host stellt das alte Verhalten wieder her). Ohne eigenen Ollama-Key oder ohne Lebenslauf bricht der Lauf mit klarer Meldung ab statt still nichts zu tun; die Oberflaeche warnt vorab. Kein Stapeln: solange ein Lauf offen ist, erzeugt ein weiterer Klick keinen zweiten. Verwaiste Laeufe (Prozess weg) werden nach Zeitlimit als Fehler freigegeben. Verifiziert mit zwei Benutzern: Zugriffsschutz (fremdes Profil -> 403), Speichern, Warteschlange, automatische Key-Erzeugung, Prompt-Aufbau aus Profil + CV, Ergebnis-Ruecklauf in die Oberflaeche, Fehlerpfade und die Faelligkeitslogik des Zeitplans (Tag/Uhrzeit/bereits gelaufen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
207 lines
15 KiB
Plaintext
207 lines
15 KiB
Plaintext
<header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg">
|
|
<div class="container mx-auto px-4 py-4">
|
|
<div class="flex items-center justify-between">
|
|
<a href="/" class="flex items-center space-x-3">
|
|
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
<h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1>
|
|
</a>
|
|
|
|
<div class="flex items-center space-x-4">
|
|
<!-- Postfach (unlinked incoming e-mails) link -->
|
|
<a href="/postfach"
|
|
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="E-Mails im Postfach, die noch keiner Bewerbung zugeordnet sind">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Postfach</span>
|
|
<span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
|
</a>
|
|
|
|
<!-- Jobangebote (ingested via third-party REST API) link -->
|
|
<a href="/jobangebote"
|
|
id="jobangeboteLink"
|
|
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Über die REST-API eingespielte Jobangebote">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Jobangebote</span>
|
|
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
|
</a>
|
|
|
|
<!-- Jobsuche (per-user search profile + runs) link -->
|
|
<a href="/jobsuche"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Eigenes Suchprofil und Suchläufe verwalten">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Jobsuche</span>
|
|
</a>
|
|
|
|
<!-- Blacklist (blocked job offers) link -->
|
|
<a href="/blacklist"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Geblockte Jobangebote (Blacklist) verwalten">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Blacklist</span>
|
|
</a>
|
|
|
|
<!-- Vorlagen (base documents) link -->
|
|
<a href="/vorlagen"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Basis-Unterlagen für die KI-Generierung verwalten">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Vorlagen</span>
|
|
</a>
|
|
|
|
<!-- Settings (formerly .env — now in the database) link -->
|
|
<a href="/einstellungen"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Einstellungen (Ollama, E-Mail, Kalender, REST-API)">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Einstellungen</span>
|
|
</a>
|
|
|
|
<% if (typeof user !== 'undefined' && user && user.is_admin) { %>
|
|
<!-- Admin: user management -->
|
|
<a href="/admin"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Benutzer verwalten">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Benutzer</span>
|
|
</a>
|
|
<% } %>
|
|
|
|
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
|
<div class="relative" id="notifWrap">
|
|
<button id="notifBtn" type="button"
|
|
class="relative flex items-center p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
|
aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
|
</svg>
|
|
<span id="notifBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
|
</button>
|
|
<div id="notifPanel" class="hidden absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/10 dark:ring-white/10 z-50 text-left">
|
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
|
|
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
|
|
<button id="notifMarkAll" type="button" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline">Alle gelesen</button>
|
|
</div>
|
|
<ul id="notifList" class="divide-y divide-gray-100 dark:divide-gray-700"></ul>
|
|
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div>
|
|
</div>
|
|
</div>
|
|
|
|
<% if (typeof user !== 'undefined' && user) { %>
|
|
<!-- Signed-in user + logout -->
|
|
<div class="flex items-center gap-2 pl-1">
|
|
<span class="text-white/90 text-sm font-medium hidden sm:inline"><%= user.username %><% if (user.is_admin) { %> <span class="text-white/60">(Admin)</span><% } %></span>
|
|
<a href="/logout"
|
|
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
title="Abmelden">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
|
</svg>
|
|
<span class="hidden sm:inline">Abmelden</span>
|
|
</a>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
// Notification bell: unread received e-mails (new replies + orphan mail).
|
|
(function () {
|
|
var btn = document.getElementById('notifBtn');
|
|
var panel = document.getElementById('notifPanel');
|
|
var badge = document.getElementById('notifBadge');
|
|
var list = document.getElementById('notifList');
|
|
var empty = document.getElementById('notifEmpty');
|
|
var markAll = document.getElementById('notifMarkAll');
|
|
if (!btn) return;
|
|
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
|
function fmtDate(d) {
|
|
if (!d) return '';
|
|
try {
|
|
var dt = new Date(d), diff = (Date.now() - dt.getTime()) / 1000;
|
|
if (diff < 60) return 'gerade eben';
|
|
if (diff < 3600) return Math.floor(diff / 60) + ' Min.';
|
|
if (diff < 86400) return Math.floor(diff / 3600) + ' Std.';
|
|
return dt.toLocaleDateString('de-DE');
|
|
} catch (e) { return ''; }
|
|
}
|
|
function render(data) {
|
|
var items = (data && data.items) || [], count = (data && data.count) || 0;
|
|
if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('hidden'); }
|
|
else { badge.classList.add('hidden'); }
|
|
list.innerHTML = '';
|
|
if (!items.length) { empty.classList.remove('hidden'); markAll.classList.add('hidden'); return; }
|
|
empty.classList.add('hidden'); markAll.classList.remove('hidden');
|
|
items.forEach(function (it) {
|
|
var li = document.createElement('li');
|
|
var a = document.createElement('a');
|
|
a.href = it.url;
|
|
a.className = 'block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors';
|
|
a.innerHTML =
|
|
'<div class="flex items-center justify-between gap-2">'
|
|
+ '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(it.from) + '</span>'
|
|
+ '<span class="text-[11px] text-gray-400 shrink-0">' + esc(fmtDate(it.date)) + '</span>'
|
|
+ '</div>'
|
|
+ '<div class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(it.subject) + '</div>'
|
|
+ (it.snippet ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' + esc(it.snippet) + '</div>' : '')
|
|
+ (it.kontext ? '<div class="text-[11px] text-blue-600 dark:text-blue-400 truncate mt-0.5">' + esc(it.kontext) + '</div>' : '');
|
|
li.appendChild(a); list.appendChild(li);
|
|
});
|
|
}
|
|
function load() {
|
|
fetch('/api/notifications', { cache: 'no-store' })
|
|
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
|
|
}
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
panel.classList.toggle('hidden');
|
|
if (!panel.classList.contains('hidden')) load();
|
|
});
|
|
document.addEventListener('click', function (e) {
|
|
if (!panel.classList.contains('hidden') && !panel.contains(e.target) && !btn.contains(e.target)) {
|
|
panel.classList.add('hidden');
|
|
}
|
|
});
|
|
markAll.addEventListener('click', function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
fetch('/api/emails/mark-all-read', { method: 'POST' })
|
|
.then(function () { render({ count: 0, items: [] }); }).catch(function () { /* ignore */ });
|
|
});
|
|
load();
|
|
setInterval(load, 60000);
|
|
})();
|
|
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
|
(function () {
|
|
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
|
var b = document.getElementById('unassignedBadge');
|
|
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
|
|
}).catch(function () { /* ignore — badge stays hidden */ });
|
|
})();
|
|
// Jobangebote badge: count of open offers ingested via the REST API.
|
|
(function () {
|
|
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); }).then(function (d) {
|
|
var b = document.getElementById('jobangeboteBadge');
|
|
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
|
|
}).catch(function () { /* ignore */ });
|
|
})();
|
|
</script>
|
|
</header>
|