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:
@@ -190,6 +190,24 @@ const uploadBasisAnhang = multer({
|
|||||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||||
}).single('datei');
|
}).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)
|
// Directory + uploader for the applicant's signature image (used in the letter)
|
||||||
const signaturDir = path.join(dataDir, 'signatur');
|
const signaturDir = path.join(dataDir, 'signatur');
|
||||||
if (!fs.existsSync(signaturDir)) {
|
if (!fs.existsSync(signaturDir)) {
|
||||||
@@ -716,6 +734,22 @@ function initializeDatabase() {
|
|||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) return reject(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.
|
// E-Mail correspondence (sent + received), linked to an application.
|
||||||
// Serialized mode guarantees these run after the tables above exist.
|
// Serialized mode guarantees these run after the tables above exist.
|
||||||
db.run(`
|
db.run(`
|
||||||
@@ -947,6 +981,7 @@ function initializeDatabase() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1255,6 +1290,10 @@ initializeDatabase().then(() => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
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]);
|
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
||||||
|
|
||||||
res.json({ success: true });
|
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',
|
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||||
[id]
|
[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');
|
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
||||||
// Available static attachments (Zeugnisse etc.) to optionally enclose.
|
// Available static attachments (Zeugnisse etc.) to optionally enclose.
|
||||||
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC');
|
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC');
|
||||||
@@ -1363,6 +1407,7 @@ initializeDatabase().then(() => {
|
|||||||
application,
|
application,
|
||||||
verlauf,
|
verlauf,
|
||||||
anhaenge,
|
anhaenge,
|
||||||
|
interneAnhaenge,
|
||||||
emails,
|
emails,
|
||||||
mailConfigured: mailer.isConfigured(),
|
mailConfigured: mailer.isConfigured(),
|
||||||
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
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
|
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
||||||
// first, removes any previously generated attachments, then generates.
|
// first, removes any previously generated attachments, then generates.
|
||||||
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
||||||
|
|||||||
@@ -93,6 +93,53 @@
|
|||||||
<textarea name="interne_notizen" rows="4"
|
<textarea name="interne_notizen" rows="4"
|
||||||
class="w-full px-3 py-2 border border-amber-300 dark:border-amber-700 rounded-md bg-amber-50 dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"
|
class="w-full px-3 py-2 border border-amber-300 dark:border-amber-700 rounded-md bg-amber-50 dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"
|
||||||
placeholder="Nur für dich – erscheint nicht im Export..."><%= application.interne_notizen || '' %></textarea>
|
placeholder="Nur für dich – erscheint nicht im Export..."><%= application.interne_notizen || '' %></textarea>
|
||||||
|
|
||||||
|
<!-- Private attachments: for the user only, never exported/sent -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<span class="text-xs font-medium text-amber-700 dark:text-amber-400">Eigene Anhänge (nur für dich)</span>
|
||||||
|
</div>
|
||||||
|
<% if (interneAnhaenge && interneAnhaenge.length) { %>
|
||||||
|
<ul class="space-y-2 mb-3">
|
||||||
|
<% interneAnhaenge.forEach(a => { %>
|
||||||
|
<li class="flex items-center justify-between gap-3 rounded-lg border border-amber-200 dark:border-amber-800/60 bg-amber-50/60 dark:bg-gray-700/40 px-3 py-2">
|
||||||
|
<a href="/bewerbung/<%= application.id %>/interne-anhaenge/<%= a.id %>/download?inline=1" target="_blank" rel="noopener"
|
||||||
|
class="flex items-center gap-2 min-w-0 group" title="Öffnen">
|
||||||
|
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 group-hover:underline"><%= a.name || a.dateiname %></span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<a href="/bewerbung/<%= application.id %>/interne-anhaenge/<%= a.id %>/download"
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline">Download</a>
|
||||||
|
<form action="/bewerbung/<%= application.id %>/interne-anhaenge/<%= a.id %>/delete" method="POST"
|
||||||
|
onsubmit="return confirm('Diesen Anhang löschen?');">
|
||||||
|
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anhang löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
<form action="/bewerbung/<%= application.id %>/interne-anhaenge" method="POST" enctype="multipart/form-data" class="space-y-2">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<input type="file" name="datei" required
|
||||||
|
class="w-full text-xs text-gray-700 dark:text-gray-300 file:mr-2 file:py-1.5 file:px-2.5 file:rounded-md file:border-0 file:bg-amber-600 file:text-white hover:file:bg-amber-700">
|
||||||
|
<input type="text" name="name" placeholder="Bezeichnung (optional)"
|
||||||
|
class="w-full px-2.5 py-1.5 text-sm border border-amber-300 dark:border-amber-700 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="px-3 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-md transition-colors">
|
||||||
|
Anhang hochladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
|||||||
Reference in New Issue
Block a user