Add multi-file contract document uploads (Verträge & Abrechnung)

Replaces the unused, never-wired-up single contractPdfUrl field with
contractDocumentUrls (String[]) so landlords can attach several files
per contract (signed lease, addenda, ...) instead of just one. New
endpoints: GET /contracts/documents (tenant sees own contract only,
landlord sees all — same visibility rule as the rest of the app),
POST/DELETE /contracts/:id/documents (landlord/admin only). Frontend
reuses the existing FileReader-to-data-URL upload pattern already used
for inventory photos, so files are stored inline like everywhere else
in this app rather than introducing a new storage mechanism.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 09:52:56 +00:00
parent aa59ab452a
commit a8dc999180
3 changed files with 225 additions and 2 deletions

View File

@ -1,6 +1,6 @@
import { Router, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { AuthedRequest, requireAuth } from '../middleware/auth';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient();
@ -75,3 +75,84 @@ contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: Auth
res.status(200).json({ deadlines: items });
});
// ----------------------------------------------------------------------------
// VERTRAGS-DOKUMENTE (unterschriebener Vertrag, Nachträge etc.)
// ----------------------------------------------------------------------------
const MAX_CONTRACT_DOCUMENTS = 10;
const CONTRACT_DOCUMENTS_SELECT = {
id: true,
startDate: true,
endDate: true,
isActive: true,
contractDocumentUrls: true,
user: { select: { id: true, fullName: true } },
room: { select: { id: true, roomNumber: true } },
};
function sanitizeDocumentUrls(input: unknown): string[] {
if (!Array.isArray(input)) return [];
return input.filter((u) => typeof u === 'string' && u.trim()).map((u) => (u as string).trim());
}
// Mieter sehen nur den eigenen Vertrag, Vermieter/Admin alle — dieselbe
// Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten.
contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => {
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
const where: Record<string, unknown> = {};
if (!isLandlord) where.userId = req.user!.id;
const contracts = await prisma.contract.findMany({
where,
select: CONTRACT_DOCUMENTS_SELECT,
orderBy: { startDate: 'desc' },
});
res.status(200).json({ contracts });
});
// Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal,
// werden an bereits vorhandene angehängt statt sie zu ersetzen).
contractsRouter.post(
'/contracts/:id/documents',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const contract = await prisma.contract.findUnique({ where: { id: req.params.id } });
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
const newUrls = sanitizeDocumentUrls(req.body?.urls);
if (!newUrls.length) return res.status(400).json({ error: 'urls (Array) ist erforderlich' });
const merged = [...contract.contractDocumentUrls, ...newUrls].slice(0, MAX_CONTRACT_DOCUMENTS);
const updated = await prisma.contract.update({
where: { id: contract.id },
data: { contractDocumentUrls: merged },
select: CONTRACT_DOCUMENTS_SELECT,
});
res.status(200).json({ contract: updated });
},
);
// Vermieter/Admin: einzelne Datei aus einem Vertrag entfernen.
contractsRouter.delete(
'/contracts/:id/documents',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const contract = await prisma.contract.findUnique({ where: { id: req.params.id } });
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
const { url } = req.body || {};
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url ist erforderlich' });
const remaining = contract.contractDocumentUrls.filter((u) => u !== url);
const updated = await prisma.contract.update({
where: { id: contract.id },
data: { contractDocumentUrls: remaining },
select: CONTRACT_DOCUMENTS_SELECT,
});
res.status(200).json({ contract: updated });
},
);

View File

@ -270,7 +270,7 @@ model Contract {
isActive Boolean @default(true) @map("is_active")
contractPdfUrl String? @map("contract_pdf_url")
contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -933,6 +933,12 @@
<div class="page" id="page-contracts">
<section id="contractDocumentsSection">
<h2>Vertragsdokumente</h2>
<p class="hint-text">Unterschriebener Mietvertrag, Nachträge etc. — je Vertrag können mehrere Dateien hinterlegt werden.</p>
<div class="ticket-list" id="contractDocumentsList"></div>
</section>
<section id="noticeDeadlinesSection">
<h2>Kündigungsfristen</h2>
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
@ -1231,6 +1237,7 @@
loadUtilityStatements();
loadCraftsmen();
loadRatings();
loadContractDocuments();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
@ -1245,6 +1252,7 @@
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
setInterval(loadRatings, REFRESH_INTERVAL_MS);
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@ -2700,6 +2708,140 @@
}
}
// ---------------------------------------------------------------------
// VERTRAGSDOKUMENTE
// ---------------------------------------------------------------------
const MAX_CONTRACT_DOCUMENTS = 10;
const contractUploadFiles = {}; // contractId -> ausgewählte, noch nicht hochgeladene Files
async function loadContractDocuments() {
try {
const data = await apiFetch('/contracts/documents');
renderContractDocuments(data.contracts);
} catch (err) {
console.error('[contracts] Vertragsdokumente laden fehlgeschlagen:', err.message);
}
}
function documentFileName(dataUrl, index) {
const match = /^data:([^;]+);/.exec(dataUrl);
const mime = match ? match[1] : '';
const ext = mime.includes('pdf') ? 'pdf' : (mime.split('/')[1] || 'datei');
return `Dokument ${index + 1}.${ext}`;
}
function renderContractDocuments(contracts) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('contractDocumentsList');
list.innerHTML = '';
if (!contracts.length) {
list.appendChild(el('div', 'empty-state', 'Kein aktiver Mietvertrag gefunden.'));
return;
}
contracts.forEach(c => {
const card = el('div', 'ticket-row');
card.style.flexDirection = 'column';
card.style.alignItems = 'stretch';
card.style.gap = '10px';
const top = el('div');
top.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
card.appendChild(top);
const docWrap = el('div');
docWrap.style.display = 'flex';
docWrap.style.flexWrap = 'wrap';
docWrap.style.gap = '8px';
if (!c.contractDocumentUrls.length) {
docWrap.appendChild(el('span', 'meta-text', 'Noch keine Dateien hochgeladen.'));
} else {
c.contractDocumentUrls.forEach((url, idx) => {
const chip = el('span', 'invite-status-chip');
chip.style.cssText = 'background:var(--bg); color:var(--text); display:inline-flex; align-items:center; gap:6px; text-transform:none;';
const link = document.createElement('a');
link.href = url;
link.download = documentFileName(url, idx);
link.textContent = documentFileName(url, idx);
link.style.color = 'var(--text)';
chip.appendChild(link);
if (isLandlord) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '×';
removeBtn.title = 'Entfernen';
removeBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:13px; color:var(--text-muted); padding:0;';
removeBtn.onclick = () => deleteContractDocument(c.id, url);
chip.appendChild(removeBtn);
}
docWrap.appendChild(chip);
});
}
card.appendChild(docWrap);
if (isLandlord) {
const uploadRow = el('div');
uploadRow.style.display = 'flex';
uploadRow.style.gap = '8px';
uploadRow.style.alignItems = 'center';
uploadRow.style.flexWrap = 'wrap';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = 'application/pdf,image/*';
fileInput.onchange = () => {
contractUploadFiles[c.id] = Array.from(fileInput.files || []);
};
uploadRow.appendChild(fileInput);
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn-secondary';
uploadBtn.textContent = 'Hochladen';
uploadBtn.onclick = () => uploadContractDocuments(c.id, fileInput, uploadBtn);
uploadRow.appendChild(uploadBtn);
card.appendChild(uploadRow);
}
list.appendChild(card);
});
}
async function uploadContractDocuments(contractId, fileInput, uploadBtn) {
const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS);
if (!files.length) return;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Lädt hoch…';
try {
const urls = [];
for (const file of files) {
urls.push(await readFileAsDataUrl(file));
}
await apiFetch(`/contracts/${contractId}/documents`, { method: 'POST', body: JSON.stringify({ urls }) });
delete contractUploadFiles[contractId];
fileInput.value = '';
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Hochladen';
}
}
async function deleteContractDocument(contractId, url) {
try {
await apiFetch(`/contracts/${contractId}/documents`, { method: 'DELETE', body: JSON.stringify({ url }) });
loadContractDocuments();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// KÜNDIGUNGSFRISTEN
// ---------------------------------------------------------------------