Interne Notizen: eigene Anhänge (PDFs/Dateien) nur für dich

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 04:37:52 +02:00
co-authored by Claude
parent 40b4de12e1
commit 0b44f04456
2 changed files with 153 additions and 0 deletions
+106
View File
@@ -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) => {