diff --git a/lib/openapi.js b/lib/openapi.js index 2aa0e97..444c5f2 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -944,6 +944,14 @@ function buildOpenApiSpec(baseUrl = '') { anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' }, kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' }, status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] }, + favorit: { + type: 'integer', + enum: [0, 1], + readOnly: true, + description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' + + 'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' + + 'Favoriten-Merkmal einer Bewerbung.', + }, labels: { ...labelsSchema, nullable: true }, verknuepfte_bewerbung_id: { type: 'integer', nullable: true }, url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' }, diff --git a/server.js b/server.js index fa8998b..39fb51a 100644 --- a/server.js +++ b/server.js @@ -1486,9 +1486,22 @@ async function initializeDatabase() { await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)'); await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); - // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten - // erscheinen oben auf der Startseite — unabhängig vom Status (offen / - // uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt. + // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Jobangebote + // und Bewerbungen haben je ein eigenes, voneinander unabhängiges Merkmal — + // ein gemerktes Angebot wird also nicht zur gemerkten Bewerbung (und umgekehrt). + // Beide Spalten werden nur bei Bedarf nachgerüstet. + + // Jobangebote: Favoriten erscheinen oben in der Liste und im eigenen Tab + // „Favoriten“ auf /jobangebote — unabhängig vom Status (offen / uebernommen / + // abgelehnt). Die Favoriten bleiben auf die Jobangebote-Seite beschränkt. + { + const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)'); + if (!jobSpalten.some((s) => s.name === 'favorit')) { + await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0'); + } + } + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_favorit ON jobangebote(user_id, favorit)'); + // Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner // Bewerbungen, unabhängig von ihrem Status. { @@ -3540,8 +3553,27 @@ initializeDatabase().then(async () => { } }); + // Zähler für die Tab-Leiste der Jobangebote-Seiten (offen / gemerkt / + // übernommen). Die Favoriten zählen über alle Status hinweg. + async function jobangeboteZaehler() { + const row = await dbGet( + `SELECT + COUNT(*) FILTER (WHERE status = 'offen') AS offen, + COUNT(*) FILTER (WHERE status = 'uebernommen') AS uebernommen, + COUNT(*) FILTER (WHERE favorit = 1) AS favoriten + FROM jobangebote WHERE user_id = ?`, + [uid()] + ); + return { + offenCount: row ? row.offen : 0, + uebernommenCount: row ? row.uebernommen : 0, + favoritenCount: row ? row.favoriten : 0, + }; + } + // Main list: only OPEN offers (still to be decided on). Taken-over offers - // live on their own page (/jobangebote/uebernommen). + // live on their own page (/jobangebote/uebernommen). Gemerkte Angebote stehen + // hier oben und haben zusätzlich einen eigenen Tab (/jobangebote/favoriten). app.get('/jobangebote', async (req, res) => { try { const jobangebote = await dbAll( @@ -3549,14 +3581,15 @@ initializeDatabase().then(async () => { FROM jobangebote j LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id WHERE j.user_id = ? AND j.status = 'offen' - ORDER BY j.created_at DESC, j.id DESC`, + ORDER BY j.favorit DESC, j.created_at DESC, j.id DESC`, [uid()] ); jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]); + const zaehler = await jobangeboteZaehler(); res.render('jobangebote', { jobangebote, - uebernommenCount: uebernommenRow ? uebernommenRow.c : 0, + ...zaehler, + ansicht: 'offen', artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, labelOptions: LABEL_OPTIONS, @@ -3568,6 +3601,35 @@ initializeDatabase().then(async () => { } }); + // Gemerkte Angebote — quer über alle Status (offen, übernommen, abgelehnt). + // Nutzt dieselbe Ansicht wie die offene Liste, nur mit anderer Auswahl. + app.get('/jobangebote/favoriten', async (req, res) => { + try { + const jobangebote = await dbAll( + `SELECT j.*, b.datum AS bewerbung_datum + FROM jobangebote j + LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id + WHERE j.user_id = ? AND j.favorit = 1 + ORDER BY j.updated_at DESC, j.id DESC`, + [uid()] + ); + jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); + const zaehler = await jobangeboteZaehler(); + res.render('jobangebote', { + jobangebote, + ...zaehler, + ansicht: 'favoriten', + artOptions: ART_OPTIONS, + statusOptions: STATUS_OPTIONS, + labelOptions: LABEL_OPTIONS, + hideSettings: false, + }); + } catch (error) { + console.error('Error listing favorite job offers:', error); + res.status(500).send('Serverfehler'); + } + }); + // Taken-over offers: which offers were turned into applications. app.get('/jobangebote/uebernommen', async (req, res) => { try { @@ -3576,14 +3638,14 @@ initializeDatabase().then(async () => { FROM jobangebote j LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id WHERE j.user_id = ? AND j.status = 'uebernommen' - ORDER BY j.updated_at DESC, j.id DESC`, + ORDER BY j.favorit DESC, j.updated_at DESC, j.id DESC`, [uid()] ); uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]); + const zaehler = await jobangeboteZaehler(); res.render('jobangebote_uebernommen', { uebernommen, - offenCount: offenRow ? offenRow.c : 0, + ...zaehler, hideSettings: false, }); } catch (error) { @@ -3666,6 +3728,12 @@ initializeDatabase().then(async () => { } }); + // Nach Bearbeiten/Löschen zurück auf den Tab, von dem die Aktion kam + // (Formularfeld `zurueck`) — sonst auf die offene Liste. + function jobangeboteZiel(req) { + return (req.body && req.body.zurueck === 'favoriten') ? '/jobangebote/favoriten' : '/jobangebote'; + } + // Edit an offer's fields — mainly to paste/adjust the full job description // (any length) before turning it into an application. app.post('/jobangebote/:id/bearbeiten', async (req, res) => { @@ -3697,7 +3765,7 @@ initializeDatabase().then(async () => { uid(), ] ); - res.redirect('/jobangebote'); + res.redirect(jobangeboteZiel(req)); } catch (error) { console.error('Error editing job offer:', error); res.status(500).send('Serverfehler'); @@ -3714,13 +3782,30 @@ initializeDatabase().then(async () => { await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); } - res.redirect('/jobangebote'); + res.redirect(jobangeboteZiel(req)); } catch (error) { console.error('Error deleting job offer:', error); res.status(500).send('Serverfehler'); } }); + // Jobangebot merken/entmerken (Stern). Toggle, antwortet mit dem neuen Zustand + // — der Stern-Button in partials/favorit-button spricht diesen Endpunkt an. + // Getrennt von den Bewerbungs-Favoriten: das Merken eines Angebots berührt + // eine daraus entstandene Bewerbung nicht. + app.post('/jobangebote/:id/favorit', async (req, res) => { + try { + const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); + if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' }); + const neu = angebot.favorit ? 0 : 1; + await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]); + res.json({ success: true, favorit: neu }); + } catch (error) { + console.error('Error toggling job offer favorite:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status. app.post('/api/bewerbungen/:id/favorit', async (req, res) => { try { diff --git a/views/jobangebote.ejs b/views/jobangebote.ejs index d4774f1..9668768 100644 --- a/views/jobangebote.ejs +++ b/views/jobangebote.ejs @@ -15,26 +15,35 @@ Zurück zur Übersicht - -
+ + <% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %> + <%- include('partials/jobangebote-tabs', { + aktiv: favAnsicht ? 'favoriten' : 'offen', + offenCount: offenCount, + favoritenCount: favoritenCount, + uebernommenCount: uebernommenCount, + }) %> -
- Hier landen Stellen, die von Drittanbietersoftware über die
- POST /api/v1/joboffers
- REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
- als Bewerbung-Entwurf übernehmen. Hier siehst du nur offene
- Angebote — bereits übernommene findest du im Tab „Übernommen“.
+ <% if (favAnsicht) { %>
+ Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
+ noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
+ entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
+ Jobangebote und ist von den gemerkten Bewerbungen getrennt.
+ <% } else { %>
+ Hier landen Stellen, die von Drittanbietersoftware über die
+ POST /api/v1/joboffers
+ REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
+ als Bewerbung-Entwurf übernehmen. Hier siehst du nur offene
+ Angebote — bereits übernommene findest du im Tab „Übernommen“.
+ Mit dem Stern gemerkte Angebote stehen oben.
+ <% } %>