From 39eb2474560074e04c6089db5053aa6436203a30 Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 12:08:18 +0200 Subject: [PATCH] v1.13: PDF-Upload und -Download fuer Dokumente (multer, Upload-Volume, Rechtepruefung) --- docker-compose.yml | 5 ++ docker/templates/default.conf.template | 7 +- kimi.md | 23 ++++++ server/package-lock.json | 44 +++++++++++ server/package.json | 1 + server/src/db.js | 2 + server/src/index.js | 83 +++++++++++++++++++- src/lib/api.ts | 20 +++++ src/pages/Documents.tsx | 102 ++++++++++++++++++++++--- src/store/store.tsx | 16 +++- src/types/index.ts | 2 + 11 files changed, 287 insertions(+), 18 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 39446f9..2f71ac9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,11 @@ services: N8N_TICKET_WEBHOOK: ${N8N_TICKET_WEBHOOK:-} # Token, mit dem n8n die fertige KI-Antwort zurück an die API postet KI_API_TOKEN: ${KI_API_TOKEN:-} + # PDF-Ablage im Dokumentenmodul (persistiertes Volume) + UPLOAD_DIR: /app/uploads PORT: 3000 + volumes: + - mp_uploads:/app/uploads depends_on: - mp-db networks: @@ -69,3 +73,4 @@ networks: volumes: mp_pgdata: + mp_uploads: diff --git a/docker/templates/default.conf.template b/docker/templates/default.conf.template index a59e635..801ce83 100644 --- a/docker/templates/default.conf.template +++ b/docker/templates/default.conf.template @@ -33,14 +33,15 @@ server { 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/ { proxy_pass http://mp-api:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; - client_max_body_size 256k; - proxy_read_timeout 30s; + client_max_body_size 25m; + proxy_read_timeout 60s; } # SPA-Fallback für React Router diff --git a/kimi.md b/kimi.md index aa09acb..4345425 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,29 @@ tar -czf mp-dist.tar.gz -C dist . ## 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 `.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 Mietverträge). API (adminOnly): POST/PATCH/DELETE /api/documents(/:id) mit Validierung (Titel nicht leer, Kategorie aus den 5 erlaubten Werten, unitId diff --git a/server/package-lock.json b/server/package-lock.json index b9ae3bc..ddfc2ab 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.3.0", "dependencies": { "express": "^4.21.2", + "multer": "^2.0.2", "pg": "^8.13.1" }, "engines": { @@ -28,6 +29,12 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -58,6 +65,17 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -520,6 +538,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -920,6 +956,14 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/server/package.json b/server/package.json index fe3ec81..1e05b5e 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "express": "^4.21.2", + "multer": "^2.0.2", "pg": "^8.13.1" } } diff --git a/server/src/db.js b/server/src/db.js index 2b634dc..ad4a897 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS documents ( size 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 ( id TEXT PRIMARY KEY, diff --git a/server/src/index.js b/server/src/index.js index 67ce6fd..b0a23eb 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -8,6 +8,9 @@ const express = require('express') const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const multer = require('multer') const { pool, init, sessionsAufraeumen } = require('./db') const { seed, seedeZaehler, seedeHaeuser } = require('./seed') const mail = require('./mail') @@ -18,11 +21,40 @@ const COOKIE_NAME = 'mp_session' // KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert) const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || '' 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() app.use(express.json({ limit: '256kb' })) 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 (.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 // JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter. process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e)) @@ -127,6 +159,7 @@ const mapTicket = (t) => ({ }) const mapDocument = (d) => ({ 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 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) => { try { - const { rowCount } = await pool.query('DELETE FROM documents WHERE id = $1', [req.params.id]) - if (!rowCount) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) + const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id]) + 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 }) } 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) ──────────────── function pruefeHaus(b) { const fehler = [] diff --git a/src/lib/api.ts b/src/lib/api.ts index bb26dc5..f92958c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -53,3 +53,23 @@ export const apiGet = (pfad: string) => api(pfad) export const apiPost = (pfad: string, body?: unknown) => api(pfad, { method: 'POST' }, body ?? {}) export const apiPatch = (pfad: string, body?: unknown) => api(pfad, { method: 'PATCH' }, body ?? {}) export const apiDelete = (pfad: string) => api(pfad, { method: 'DELETE' }) + +/** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */ +export async function apiUpload(pfad: string, datei: File): Promise { + 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 +} diff --git a/src/pages/Documents.tsx b/src/pages/Documents.tsx index e36539d..8916228 100644 --- a/src/pages/Documents.tsx +++ b/src/pages/Documents.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react' -import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2 } from 'lucide-react' +import { useMemo, useRef, useState } from 'react' +import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2, Upload } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -28,6 +28,7 @@ import { TableRow, } from '@/components/ui/table' import { useStore } from '../store/store' +import { apiUpload, ApiFehler } from '../lib/api' import type { DocumentItem, Unit } from '../types' const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges'] @@ -50,7 +51,7 @@ const catIcon = (cat: string) => { const heute = () => new Date().toLocaleDateString('de-DE') function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) { - const { addDokument, updateDokument } = useStore() + const { addDokument, updateDokument, syncDokument } = useStore() const bearbeiten = Boolean(dokument) const [open, setOpen] = useState(false) 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 [datum, setDatum] = useState(dokument?.date ?? heute()) const [groesse, setGroesse] = useState(dokument?.size === '–' ? '' : (dokument?.size ?? '')) + const [datei, setDatei] = useState(null) + const dateiInput = useRef(null) const [sende, setSende] = useState(false) const [fehler, setFehler] = useState(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 () => { if (sende) return setSende(true) @@ -72,9 +90,28 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh date: datum.trim() || heute(), ...(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) - if (err) return setFehler(err) setOpen(false) } @@ -145,9 +182,41 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
- setGroesse(e.target.value)} /> + setGroesse(e.target.value)} />
+
+ +
+ dateiWaehlen(e.target.files?.[0] ?? null)} + /> + {datei && ( + + )} +
+ {bearbeiten && dokument?.hasFile && !datei && ( +

+ Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei. +

+ )} + {datei && ( +

+ {datei.name} wird beim Speichern hochgeladen. +

+ )} +
{fehler &&

{fehler}

} @@ -277,9 +346,22 @@ export default function Documents() { {d.size}
- + {d.hasFile ? ( + + PDF + + ) : ( + + PDF + + )} {role === 'admin' && ( <> @@ -299,7 +381,7 @@ export default function Documents() {

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.

) diff --git a/src/store/store.tsx b/src/store/store.tsx index fffe521..ea9f0e6 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -131,12 +131,14 @@ interface Store extends Bootstrap { unitId: string | null date: string size?: string - }) => Promise + }) => Promise<{ fehler: string | null; id?: string }> updateDokument: ( id: string, d: Partial<{ title: string; category: DocumentItem['category']; unitId: string | null; date: string; size: string }>, ) => Promise deleteDokument: (id: string) => Promise + /** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */ + syncDokument: (dokument: DocumentItem) => void } const StoreContext = createContext(null) @@ -366,9 +368,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { try { const { dokument } = await apiPost<{ dokument: DocumentItem }>('/api/documents', d) setDaten((x) => ({ ...x, documents: [dokument, ...x.documents] })) - return null + return { fehler: null, id: dokument.id } } catch (e) { - return fehlerNachricht(e) + return { fehler: fehlerNachricht(e) } } }, async updateDokument(id, d) { @@ -389,6 +391,14 @@ export function StoreProvider({ children }: { children: ReactNode }) { 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]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index e007fa9..7ba06de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,6 +68,8 @@ export interface DocumentItem { category: 'Mietvertrag' | 'Nebenkostenabrechnung' | 'Hausordnung' | 'Protokoll' | 'Sonstiges' size: string date: string + fileName?: string | null // Originalname der hinterlegten PDF + hasFile?: boolean } export interface Message {