From 0b44f04456183c883000511215c4e0d153726460 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Thu, 9 Jul 2026 04:37:52 +0200 Subject: [PATCH] =?UTF-8?q?Interne=20Notizen:=20eigene=20Anh=C3=A4nge=20(P?= =?UTF-8?q?DFs/Dateien)=20nur=20f=C3=BCr=20dich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unter den internen Notizen lassen sich nun private Anhänge hochladen. PDFs öffnen per Klick direkt im Browser; Anhänge werden weder in den PDF-Export noch in den Bewerbungsversand einbezogen. Co-Authored-By: Claude --- server.js | 106 ++++++++++++++++++++++++++++++++++++++++++++ views/bewerbung.ejs | 47 ++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/server.js b/server.js index fdf0af3..5bbe9ec 100644 --- a/server.js +++ b/server.js @@ -190,6 +190,24 @@ const uploadBasisAnhang = multer({ limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB }).single('datei'); +// Directory for private attachments the user keeps alongside the internal +// notes of an application. These are never exported into the PDF and never +// sent with an application — they are for the user only. +const interneAnhaengeDir = path.join(dataDir, 'interne_anhaenge'); +if (!fs.existsSync(interneAnhaengeDir)) { + fs.mkdirSync(interneAnhaengeDir, { recursive: true }); +} +const uploadInterneAnhang = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, interneAnhaengeDir), + filename: (req, file, cb) => { + const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${Date.now()}_${safe}`); + }, + }), + limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB +}).single('datei'); + // Directory + uploader for the applicant's signature image (used in the letter) const signaturDir = path.join(dataDir, 'signatur'); if (!fs.existsSync(signaturDir)) { @@ -716,6 +734,22 @@ function initializeDatabase() { `, (err) => { if (err) return reject(err); + // Private attachments linked to an application's internal notes. + // Not exported, not sent — for the user only. + db.run(` + CREATE TABLE IF NOT EXISTS interne_anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bewerbung_id INTEGER NOT NULL, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `, (err) => { + if (err) return reject(err); + // E-Mail correspondence (sent + received), linked to an application. // Serialized mode guarantees these run after the tables above exist. db.run(` @@ -947,6 +981,7 @@ function initializeDatabase() { }); }); }); + }); }); }); }); @@ -1255,6 +1290,10 @@ initializeDatabase().then(() => { try { const { id } = req.params; await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]); + // Remove private attachments belonging to the internal notes. + const interne = await dbAll('SELECT pfad FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); + interne.forEach((a) => fs.promises.unlink(path.join(interneAnhaengeDir, a.pfad)).catch(() => {})); + await dbRun('DELETE FROM interne_anhaenge WHERE bewerbung_id = ?', [id]); await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); res.json({ success: true }); @@ -1328,6 +1367,11 @@ initializeDatabase().then(() => { 'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] ); + // Private attachments belonging to the internal notes — never exported/sent. + const interneAnhaenge = await dbAll( + 'SELECT id, name, dateiname, mime, created_at FROM interne_anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', + [id] + ); const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); // Available static attachments (Zeugnisse etc.) to optionally enclose. const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC'); @@ -1363,6 +1407,7 @@ initializeDatabase().then(() => { application, verlauf, anhaenge, + interneAnhaenge, emails, mailConfigured: mailer.isConfigured(), mailFrom: mailer.isConfigured() ? mailer.fromField() : '', @@ -2055,6 +2100,67 @@ initializeDatabase().then(() => { } }); + // ----- Private attachments (internal notes) ----- + + // Upload a private attachment for an application's internal notes + app.post('/bewerbung/:id/interne-anhaenge', (req, res) => { + uploadInterneAnhang(req, res, async (err) => { + try { + const { id } = req.params; + if (err) { + console.error('Interne-Anhang upload error:', err.message); + return res.redirect('/bewerbung/' + id); + } + if (req.file) { + const original = req.file.originalname || req.file.filename; + const name = (req.body.name && req.body.name.trim()) + ? sanitizeInput(req.body.name.trim()) + : sanitizeInput(original.replace(/\.[^.]+$/, '')); + await dbRun( + 'INSERT INTO interne_anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', + [id, name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename] + ); + } + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error saving internal attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + }); + + // Download a private attachment (inline=1 opens PDFs/images in the browser) + app.get('/bewerbung/:id/interne-anhaenge/:anhangId/download', async (req, res) => { + try { + const { id, anhangId } = req.params; + const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + if (!anhang) return res.status(404).send('Anhang nicht gefunden'); + const filePath = path.join(interneAnhaengeDir, anhang.pfad); + if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); + if (req.query.inline === '1') return serveInline(res, filePath, anhang.dateiname, anhang.mime); + res.download(filePath, anhang.dateiname); + } catch (error) { + console.error('Error downloading internal attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Delete a private attachment + app.post('/bewerbung/:id/interne-anhaenge/:anhangId/delete', async (req, res) => { + try { + const { id, anhangId } = req.params; + const anhang = await dbGet('SELECT * FROM interne_anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + if (anhang) { + fs.promises.unlink(path.join(interneAnhaengeDir, anhang.pfad)).catch(() => {}); + await dbRun('DELETE FROM interne_anhaenge WHERE id = ?', [anhangId]); + } + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error deleting internal attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + // Start (or re-run) the AI generation for an application. Saves the LLM notes // first, removes any previously generated attachments, then generates. app.post('/bewerbung/:id/generieren', async (req, res) => { diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 273ac1d..a5eb4cf 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -93,6 +93,53 @@ + + +
+
+ Eigene Anhänge (nur für dich) +
+ <% if (interneAnhaenge && interneAnhaenge.length) { %> + + <% } %> +
+
+ + +
+
+ +
+
+