v1.13: PDF-Upload und -Download fuer Dokumente (multer, Upload-Volume, Rechtepruefung)
This commit is contained in:
@@ -39,7 +39,11 @@ services:
|
|||||||
N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-}
|
N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-}
|
||||||
# Token, mit dem n8n die fertige KI-Antwort zurück an die API postet
|
# Token, mit dem n8n die fertige KI-Antwort zurück an die API postet
|
||||||
KI_API_TOKEN: ${KI_API_TOKEN:-}
|
KI_API_TOKEN: ${KI_API_TOKEN:-}
|
||||||
|
# PDF-Ablage im Dokumentenmodul (persistiertes Volume)
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
volumes:
|
||||||
|
- mp_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
- mp-db
|
- mp-db
|
||||||
networks:
|
networks:
|
||||||
@@ -69,3 +73,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mp_pgdata:
|
mp_pgdata:
|
||||||
|
mp_uploads:
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ server {
|
|||||||
proxy_read_timeout 15s;
|
proxy_read_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Portal-API (Node/Express + PostgreSQL): Auth, Daten, Benachrichtigungen.
|
# Portal-API (Node/Express + PostgreSQL): Auth, Daten, Benachrichtigungen,
|
||||||
|
# Dokumenten-Uploads/-Downloads (PDF bis 25 MB).
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://mp-api:3000;
|
proxy_pass http://mp-api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
client_max_body_size 256k;
|
client_max_body_size 25m;
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA-Fallback für React Router
|
# SPA-Fallback für React Router
|
||||||
|
|||||||
@@ -189,6 +189,29 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 8. Verlauf / offene Punkte
|
||||||
|
|
||||||
|
**Erledigt 04.10. (v1.13):** Echter PDF-Upload für das Dokumentenmodul. Neue
|
||||||
|
Dep multer (^2.0.2, server/package-lock.json liegt jetzt im Repo, Docker
|
||||||
|
baut mit `npm ci`). DB: `documents` um `file_name`/`file_path` erweitert
|
||||||
|
(idempotent per `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` in db.js SCHEMA –
|
||||||
|
ACHTUNG: SCHEMA läuft als eine pool.query mit mehreren Statements,
|
||||||
|
funktioniert nur über das Simple-Query-Protokoll ohne Parameter). API:
|
||||||
|
POST /api/documents/:id/upload (adminOnly, multipart Feld „file", nur PDF,
|
||||||
|
25 MB, Datei heißt `<id>.pdf` im UPLOAD_DIR und ERSETZT die alte Datei;
|
||||||
|
`size` wird aus den echten Bytes gesetzt, `formatBytes` deutsche Anzeige);
|
||||||
|
GET /api/documents/:id/download (auth: Verwaltung immer, Mieter nur eigene
|
||||||
|
Wohnung oder Hausdokument; `path.basename` gegen Pfad-Traversal); DELETE
|
||||||
|
löscht die Datei best-effort mit. Ablage: UPLOAD_DIR env (default
|
||||||
|
./uploads), im Compose als named Volume `mp_uploads` an /app/uploads –
|
||||||
|
überlebt Container-Rekreation. nginx: `client_max_body_size 25m` für
|
||||||
|
location /api/ (war 256k!), proxy_read_timeout 60s. Frontend: Datei-Input
|
||||||
|
im Dokument-Dialog (Client-Validierung PDF/25 MB), Upload läuft NACH
|
||||||
|
Anlegen/Patchen über apiUpload (FormData, kein Content-Type setzen),
|
||||||
|
`syncDokument` ersetzt den State mit der Server-Fassung; PDF-Button ist
|
||||||
|
jetzt echter Download-Link, ohne Datei deaktiviert mit Hinweis.
|
||||||
|
E2E verifiziert: Upload/Download Byte-identisch (Admin + Mieter eigene
|
||||||
|
Wohnung), fremde Wohnung 403, Nicht-PDF 400, Upload ohne Doc 404,
|
||||||
|
Löschen entfernt Datei, Testdaten aufgeräumt.
|
||||||
|
|
||||||
**Erledigt 04.10. (v1.12):** Dokumente-CRUD für die Verwaltung (Schwerpunkt
|
**Erledigt 04.10. (v1.12):** Dokumente-CRUD für die Verwaltung (Schwerpunkt
|
||||||
Mietverträge). API (adminOnly): POST/PATCH/DELETE /api/documents(/:id) mit
|
Mietverträge). API (adminOnly): POST/PATCH/DELETE /api/documents(/:id) mit
|
||||||
Validierung (Titel nicht leer, Kategorie aus den 5 erlaubten Werten, unitId
|
Validierung (Titel nicht leer, Kategorie aus den 5 erlaubten Werten, unitId
|
||||||
|
|||||||
Generated
+44
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -28,6 +29,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -58,6 +65,17 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -520,6 +538,24 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -920,6 +956,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS documents (
|
|||||||
size TEXT NOT NULL,
|
size TEXT NOT NULL,
|
||||||
date TEXT NOT NULL
|
date TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_name TEXT; -- Originalname beim Upload
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_path TEXT; -- Dateiname im Upload-Verzeichnis
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS threads (
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
+81
-2
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const multer = require('multer')
|
||||||
const { pool, init, sessionsAufraeumen } = require('./db')
|
const { pool, init, sessionsAufraeumen } = require('./db')
|
||||||
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
||||||
const mail = require('./mail')
|
const mail = require('./mail')
|
||||||
@@ -18,11 +21,40 @@ const COOKIE_NAME = 'mp_session'
|
|||||||
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
|
// KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert)
|
||||||
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
|
const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || ''
|
||||||
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
const KI_API_TOKEN = process.env.KI_API_TOKEN || ''
|
||||||
|
// PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert)
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||||||
|
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(express.json({ limit: '256kb' }))
|
app.use(express.json({ limit: '256kb' }))
|
||||||
app.set('trust proxy', true)
|
app.set('trust proxy', true)
|
||||||
|
|
||||||
|
// PDF-Uploads (nur application/pdf, 25 MB); der Dateiname wird erst nach dem
|
||||||
|
// Dokument-Lookup vergeben → die Datei heißt wie die Dokument-ID (<id>.pdf).
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: UPLOAD_DIR,
|
||||||
|
filename: (req, _file, cb) => cb(null, `${req.params.id}.pdf`),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const istPdf = file.mimetype === 'application/pdf' || /\.pdf$/i.test(file.originalname || '')
|
||||||
|
cb(istPdf ? null : new Error('Nur PDF-Dateien sind erlaubt.'), istPdf)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Deutsche Größenangabe, z. B. 1,8 MB. */
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return '–'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const einheiten = ['KB', 'MB', 'GB']
|
||||||
|
let wert = bytes / 1024
|
||||||
|
let i = 0
|
||||||
|
while (wert >= 1024 && i < einheiten.length - 1) { wert /= 1024; i += 1 }
|
||||||
|
return `${wert.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${einheiten[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
// Prozess nie durch einen einzelnen Fehler killen lassen (z. B. defekter
|
// Prozess nie durch einen einzelnen Fehler killen lassen (z. B. defekter
|
||||||
// JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter.
|
// JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter.
|
||||||
process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e))
|
process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e))
|
||||||
@@ -127,6 +159,7 @@ const mapTicket = (t) => ({
|
|||||||
})
|
})
|
||||||
const mapDocument = (d) => ({
|
const mapDocument = (d) => ({
|
||||||
id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date,
|
id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date,
|
||||||
|
fileName: d.file_name ?? null, hasFile: Boolean(d.file_path),
|
||||||
})
|
})
|
||||||
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
|
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
|
||||||
const mapThread = (t) => ({
|
const mapThread = (t) => ({
|
||||||
@@ -540,12 +573,58 @@ app.patch('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
|||||||
|
|
||||||
app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { rowCount } = await pool.query('DELETE FROM documents WHERE id = $1', [req.params.id])
|
const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id])
|
||||||
if (!rowCount) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||||
|
if (rows[0].file_path) {
|
||||||
|
// Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen)
|
||||||
|
await fs.promises.unlink(path.join(UPLOAD_DIR, rows[0].file_path)).catch(() => {})
|
||||||
|
}
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// PDF-Upload zum Dokument (ersetzt eine vorhandene Datei; nur Verwaltung)
|
||||||
|
app.post('/api/documents/:id/upload', auth, adminOnly, async (req, res, next) => {
|
||||||
|
const { rows } = await pool.query('SELECT id, file_path FROM documents WHERE id = $1', [req.params.id])
|
||||||
|
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||||
|
const alteDatei = rows[0].file_path
|
||||||
|
upload.single('file')(req, res, async (fehler) => {
|
||||||
|
try {
|
||||||
|
if (fehler) {
|
||||||
|
const meldung = fehler.code === 'LIMIT_FILE_SIZE'
|
||||||
|
? 'Die Datei ist größer als 25 MB.'
|
||||||
|
: (fehler.message || 'Der Upload ist fehlgeschlagen.')
|
||||||
|
return res.status(fehler.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ fehler: meldung })
|
||||||
|
}
|
||||||
|
if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' })
|
||||||
|
if (alteDatei && alteDatei !== req.file.filename) {
|
||||||
|
await fs.promises.unlink(path.join(UPLOAD_DIR, alteDatei)).catch(() => {})
|
||||||
|
}
|
||||||
|
const originalname = Buffer.from(req.file.originalname, 'latin1').toString('utf8') // MSIE-/Chrome-Kodierung geraderücken
|
||||||
|
const { rows: aktualisiert } = await pool.query(
|
||||||
|
'UPDATE documents SET file_name = $1, file_path = $2, size = $3 WHERE id = $4 RETURNING *',
|
||||||
|
[originalname, req.file.filename, formatBytes(req.file.size), req.params.id],
|
||||||
|
)
|
||||||
|
res.json({ dokument: mapDocument(aktualisiert[0]) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// PDF-Download: Verwaltung immer, Mieter nur bei eigener Wohnung oder Hausdokument
|
||||||
|
app.get('/api/documents/:id/download', auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id])
|
||||||
|
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
|
||||||
|
const d = rows[0]
|
||||||
|
const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id
|
||||||
|
if (!erlaubt) return res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' })
|
||||||
|
if (!d.file_path) return res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' })
|
||||||
|
const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden
|
||||||
|
if (!fs.existsSync(vollerPfad)) return res.status(404).json({ fehler: 'Datei nicht gefunden.' })
|
||||||
|
res.download(vollerPfad, d.file_name || `${d.title}.pdf`)
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||||
function pruefeHaus(b) {
|
function pruefeHaus(b) {
|
||||||
const fehler = []
|
const fehler = []
|
||||||
|
|||||||
@@ -53,3 +53,23 @@ export const apiGet = <T>(pfad: string) => api<T>(pfad)
|
|||||||
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
|
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
|
||||||
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
|
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
|
||||||
export const apiDelete = <T>(pfad: string) => api<T>(pfad, { method: 'DELETE' })
|
export const apiDelete = <T>(pfad: string) => api<T>(pfad, { method: 'DELETE' })
|
||||||
|
|
||||||
|
/** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */
|
||||||
|
export async function apiUpload<T>(pfad: string, datei: File): Promise<T> {
|
||||||
|
const formular = new FormData()
|
||||||
|
formular.append('file', datei)
|
||||||
|
const res = await fetch(pfad, { method: 'POST', credentials: 'same-origin', body: formular })
|
||||||
|
let daten: T | null = null
|
||||||
|
try {
|
||||||
|
daten = (await res.json()) as T
|
||||||
|
} catch {
|
||||||
|
/* leerer Body */
|
||||||
|
}
|
||||||
|
if (!res.ok || daten === null) {
|
||||||
|
const fehler =
|
||||||
|
(daten as { fehler?: string } | null)?.fehler ??
|
||||||
|
(res.status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${res.status}).`)
|
||||||
|
throw new ApiFehler(res.status, fehler)
|
||||||
|
}
|
||||||
|
return daten
|
||||||
|
}
|
||||||
|
|||||||
+92
-10
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2 } from 'lucide-react'
|
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2, Upload } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
|
import { apiUpload, ApiFehler } from '../lib/api'
|
||||||
import type { DocumentItem, Unit } from '../types'
|
import type { DocumentItem, Unit } from '../types'
|
||||||
|
|
||||||
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
|
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
|
||||||
@@ -50,7 +51,7 @@ const catIcon = (cat: string) => {
|
|||||||
const heute = () => new Date().toLocaleDateString('de-DE')
|
const heute = () => new Date().toLocaleDateString('de-DE')
|
||||||
|
|
||||||
function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) {
|
function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) {
|
||||||
const { addDokument, updateDokument } = useStore()
|
const { addDokument, updateDokument, syncDokument } = useStore()
|
||||||
const bearbeiten = Boolean(dokument)
|
const bearbeiten = Boolean(dokument)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [titel, setTitel] = useState(dokument?.title ?? '')
|
const [titel, setTitel] = useState(dokument?.title ?? '')
|
||||||
@@ -58,9 +59,26 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
|||||||
const [einheit, setEinheit] = useState(dokument?.unitId ?? '__alle') // '__alle' = Alle Häuser
|
const [einheit, setEinheit] = useState(dokument?.unitId ?? '__alle') // '__alle' = Alle Häuser
|
||||||
const [datum, setDatum] = useState(dokument?.date ?? heute())
|
const [datum, setDatum] = useState(dokument?.date ?? heute())
|
||||||
const [groesse, setGroesse] = useState(dokument?.size === '–' ? '' : (dokument?.size ?? ''))
|
const [groesse, setGroesse] = useState(dokument?.size === '–' ? '' : (dokument?.size ?? ''))
|
||||||
|
const [datei, setDatei] = useState<File | null>(null)
|
||||||
|
const dateiInput = useRef<HTMLInputElement>(null)
|
||||||
const [sende, setSende] = useState(false)
|
const [sende, setSende] = useState(false)
|
||||||
const [fehler, setFehler] = useState<string | null>(null)
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const dateiWaehlen = (f: File | null) => {
|
||||||
|
setFehler(null)
|
||||||
|
if (!f) return setDatei(null)
|
||||||
|
const istPdf = f.type === 'application/pdf' || /\.pdf$/i.test(f.name)
|
||||||
|
if (!istPdf) {
|
||||||
|
dateiInput.current && (dateiInput.current.value = '')
|
||||||
|
return setFehler('Nur PDF-Dateien sind erlaubt.')
|
||||||
|
}
|
||||||
|
if (f.size > 25 * 1024 * 1024) {
|
||||||
|
dateiInput.current && (dateiInput.current.value = '')
|
||||||
|
return setFehler('Die Datei ist größer als 25 MB.')
|
||||||
|
}
|
||||||
|
setDatei(f)
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (sende) return
|
if (sende) return
|
||||||
setSende(true)
|
setSende(true)
|
||||||
@@ -72,9 +90,28 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
|||||||
date: datum.trim() || heute(),
|
date: datum.trim() || heute(),
|
||||||
...(groesse.trim() ? { size: groesse.trim() } : {}),
|
...(groesse.trim() ? { size: groesse.trim() } : {}),
|
||||||
}
|
}
|
||||||
const err = bearbeiten ? await updateDokument(dokument!.id, payload) : await addDokument(payload)
|
let dokId: string
|
||||||
|
if (bearbeiten) {
|
||||||
|
const err = await updateDokument(dokument!.id, payload)
|
||||||
|
if (err) { setSende(false); return setFehler(err) }
|
||||||
|
dokId = dokument!.id
|
||||||
|
} else {
|
||||||
|
const r = await addDokument(payload)
|
||||||
|
if (r.fehler) { setSende(false); return setFehler(r.fehler) }
|
||||||
|
dokId = r.id!
|
||||||
|
}
|
||||||
|
if (datei) {
|
||||||
|
try {
|
||||||
|
const { dokument: hochgeladen } = await apiUpload<{ dokument: DocumentItem }>(
|
||||||
|
`/api/documents/${dokId}/upload`, datei,
|
||||||
|
)
|
||||||
|
syncDokument(hochgeladen) // Größe/Dateiname kommen aus der Upload-Antwort
|
||||||
|
} catch (e) {
|
||||||
|
setSende(false)
|
||||||
|
return setFehler(e instanceof ApiFehler ? e.message : 'Der PDF-Upload ist fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
setSende(false)
|
setSende(false)
|
||||||
if (err) return setFehler(err)
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +182,41 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="d-groesse">Dateigröße (optional)</Label>
|
<Label htmlFor="d-groesse">Dateigröße (optional)</Label>
|
||||||
<Input id="d-groesse" placeholder="z. B. 1,8 MB" value={groesse} onChange={(e) => setGroesse(e.target.value)} />
|
<Input id="d-groesse" placeholder="wird beim Upload gesetzt" value={groesse} onChange={(e) => setGroesse(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="d-datei">PDF-Datei (optional, max. 25 MB)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="d-datei"
|
||||||
|
ref={dateiInput}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
onChange={(e) => dateiWaehlen(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{datei && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Datei entfernen"
|
||||||
|
onClick={() => { setDatei(null); if (dateiInput.current) dateiInput.current.value = '' }}
|
||||||
|
className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bearbeiten && dokument?.hasFile && !datei && (
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{datei && (
|
||||||
|
<p className="flex items-center gap-1 text-xs text-teal-700">
|
||||||
|
<Upload className="h-3.5 w-3.5" /> {datei.name} wird beim Speichern hochgeladen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
|
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -277,9 +346,22 @@ export default function Documents() {
|
|||||||
<TableCell>{d.size}</TableCell>
|
<TableCell>{d.size}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button className="inline-flex items-center gap-1 rounded-lg bg-teal-50 px-3 py-1.5 text-xs font-semibold text-teal-700 transition hover:bg-teal-100">
|
{d.hasFile ? (
|
||||||
<Download className="h-3.5 w-3.5" /> PDF
|
<a
|
||||||
</button>
|
href={`/api/documents/${d.id}/download`}
|
||||||
|
title={d.fileName ? `${d.fileName} herunterladen` : 'PDF herunterladen'}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-teal-50 px-3 py-1.5 text-xs font-semibold text-teal-700 transition hover:bg-teal-100"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> PDF
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title={role === 'admin' ? 'Noch keine Datei – über „Bearbeiten" hochladen' : 'Noch keine Datei hinterlegt'}
|
||||||
|
className="inline-flex cursor-not-allowed items-center gap-1 rounded-lg bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-300"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> PDF
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<>
|
<>
|
||||||
<DokumentDialog dokument={d} einheiten={units} />
|
<DokumentDialog dokument={d} einheiten={units} />
|
||||||
@@ -299,7 +381,7 @@ export default function Documents() {
|
|||||||
|
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Alle Dokumente werden verschlüsselt gespeichert und sind nur für berechtigte Konten
|
Alle Dokumente werden verschlüsselt gespeichert und sind nur für berechtigte Konten
|
||||||
einsehbar. Die Download-Funktion ist in dieser Demo dargestellt.
|
einsehbar. Die Verwaltung lädt Vertrags-PDFs hoch; Mieter laden sie hier direkt herunter.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-3
@@ -131,12 +131,14 @@ interface Store extends Bootstrap {
|
|||||||
unitId: string | null
|
unitId: string | null
|
||||||
date: string
|
date: string
|
||||||
size?: string
|
size?: string
|
||||||
}) => Promise<string | null>
|
}) => Promise<{ fehler: string | null; id?: string }>
|
||||||
updateDokument: (
|
updateDokument: (
|
||||||
id: string,
|
id: string,
|
||||||
d: Partial<{ title: string; category: DocumentItem['category']; unitId: string | null; date: string; size: string }>,
|
d: Partial<{ title: string; category: DocumentItem['category']; unitId: string | null; date: string; size: string }>,
|
||||||
) => Promise<string | null>
|
) => Promise<string | null>
|
||||||
deleteDokument: (id: string) => Promise<string | null>
|
deleteDokument: (id: string) => Promise<string | null>
|
||||||
|
/** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */
|
||||||
|
syncDokument: (dokument: DocumentItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoreContext = createContext<Store | null>(null)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -366,9 +368,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const { dokument } = await apiPost<{ dokument: DocumentItem }>('/api/documents', d)
|
const { dokument } = await apiPost<{ dokument: DocumentItem }>('/api/documents', d)
|
||||||
setDaten((x) => ({ ...x, documents: [dokument, ...x.documents] }))
|
setDaten((x) => ({ ...x, documents: [dokument, ...x.documents] }))
|
||||||
return null
|
return { fehler: null, id: dokument.id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fehlerNachricht(e)
|
return { fehler: fehlerNachricht(e) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateDokument(id, d) {
|
async updateDokument(id, d) {
|
||||||
@@ -389,6 +391,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
return fehlerNachricht(e)
|
return fehlerNachricht(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
syncDokument(dokument) {
|
||||||
|
setDaten((x) => ({
|
||||||
|
...x,
|
||||||
|
documents: x.documents.some((m) => m.id === dokument.id)
|
||||||
|
? x.documents.map((m) => (m.id === dokument.id ? dokument : m))
|
||||||
|
: [dokument, ...x.documents],
|
||||||
|
}))
|
||||||
|
},
|
||||||
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface DocumentItem {
|
|||||||
category: 'Mietvertrag' | 'Nebenkostenabrechnung' | 'Hausordnung' | 'Protokoll' | 'Sonstiges'
|
category: 'Mietvertrag' | 'Nebenkostenabrechnung' | 'Hausordnung' | 'Protokoll' | 'Sonstiges'
|
||||||
size: string
|
size: string
|
||||||
date: string
|
date: string
|
||||||
|
fileName?: string | null // Originalname der hinterlegten PDF
|
||||||
|
hasFile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
Reference in New Issue
Block a user