feat(crm): Vertragsdokumente — Datei-Upload Frontend + Backend-Briefing

Frontend (gegen API-Contract, bereit fuer Backend-Integration):
- ContractFile Interface in types.ts
- contractFilesApi (upload, list, download-as-blob, delete) in api.ts
- useContractFiles, useUploadContractFile, useDeleteContractFile in hooks.ts
- ContractsCard: Dokumente-Sektion im Edit-Modal
  - Dateiliste mit Icon (PDF/Word/Excel), Name, Groesse, Download, Loeschen
  - Upload-Button (PDF/DOC/DOCX/XLSX/XLS, max 25 MB)
  - Client-seitige Groessenvalidierung
  - Blob-Download via Axios (Auth-Header werden mitgesendet)

Backend-Briefing in INSIGHT-CRM.md:
- contract_files Tabelle + Prisma-Modell
- Datei-Speicherung (/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{name})
- 4 Endpoints (POST/GET/GET-download/DELETE)
- Sicherheitshinweise (Path Traversal, Tenant-Isolation, DSGVO)
- Docker-Volume Konfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 21:46:32 +01:00
parent 0e6565e210
commit bfe672ec96
5 changed files with 527 additions and 7 deletions

View file

@ -3257,3 +3257,183 @@ Die `ContractsCard.tsx` ist vollstaendig implementiert. Das "Modul in Entwicklun
### TypeScript-Check ### TypeScript-Check
`npx tsc --noEmit` — 0 Fehler `npx tsc --noEmit` — 0 Fehler
---
## 2026-03-12 | Plattform-Admin: Briefing — Vertragsdokumente (Datei-Upload)
### Anforderung
An jedem Vertrag soll eine oder mehrere Dateien (primär PDFs) hochgeladen werden koennen — z.B. das unterschriebene Original-Dokument, Anlagen oder Nachtraege.
### Neue DB-Tabelle: `contract_files`
```sql
CREATE TABLE app_crm.contract_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
contract_id UUID NOT NULL REFERENCES app_crm.contracts(id) ON DELETE CASCADE,
original_name VARCHAR(500) NOT NULL,
storage_path VARCHAR(1000) NOT NULL, -- relativer Pfad auf Disk
mime_type VARCHAR(200) NOT NULL,
size INTEGER NOT NULL, -- Bytes
uploaded_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON app_crm.contract_files(contract_id);
```
**Prisma-Modell (in `crm.schema.prisma` erganzen):**
```prisma
model ContractFile {
id String @id @default(uuid())
tenantId String @map("tenant_id") @db.Uuid
contractId String @map("contract_id") @db.Uuid
originalName String @map("original_name") @db.VarChar(500)
storagePath String @map("storage_path") @db.VarChar(1000)
mimeType String @map("mime_type") @db.VarChar(200)
size Int
uploadedBy String @map("uploaded_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
@@map("contract_files")
@@schema("app_crm")
}
```
**Rueckwaertsrelation im Contract-Modell erganzen:**
```prisma
model Contract {
// ... bestehende Felder ...
files ContractFile[]
}
```
### Datei-Speicherung
- Ablageort: `/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{originalName}`
- Docker-Volume: `./uploads:/app/uploads` (im docker-compose.crm.yml erganzen)
- Verzeichnis wird automatisch beim ersten Upload erstellt (`fs.mkdirSync(..., { recursive: true })`)
- Dateiname auf Disk: `{uuid}-{originalName}` (UUID-Prefix verhindert Kollisionen und path-traversal)
### Erlaubte Dateitypen und Groessen
| Typ | MIME | Max |
|-----|------|-----|
| PDF | application/pdf | 25 MB |
| Word | application/msword, .../wordprocessingml.document | 25 MB |
| Excel | application/vnd.ms-excel, .../spreadsheetml.sheet | 25 MB |
- Multer-Validierung: `fileFilter` + `limits: { fileSize: 26_214_400 }` (25 MB)
- Max. Dateien pro Vertrag: 10
### Neue Endpoints (4)
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| `POST` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files` | Datei hochladen (multipart/form-data, field: `file`) |
| `GET` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files` | Datei-Liste abrufen |
| `GET` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files/:fileId/download` | Datei herunterladen / anzeigen |
| `DELETE` | `/api/v1/crm/companies/:companyId/contracts/:contractId/files/:fileId` | Datei loeschen |
#### POST /files — Upload
- Multer `FileInterceptor('file')`
- Validierung: Dateityp + Groesse
- Speichern auf Disk
- ContractFile-Record in DB anlegen
- **Sicherheit:** tenantId aus JWT, nicht aus Request-Body!
- Tenant-Pruefung: Contract muss zum Tenant gehoeren
**Response:**
```json
{
"success": true,
"data": {
"id": "uuid",
"contractId": "uuid",
"originalName": "Wartungsvertrag_2026.pdf",
"mimeType": "application/pdf",
"size": 245678,
"uploadedBy": "uuid",
"createdAt": "2026-03-12T..."
}
}
```
#### GET /files — Liste
**Response:**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"contractId": "uuid",
"originalName": "Wartungsvertrag_2026.pdf",
"mimeType": "application/pdf",
"size": 245678,
"uploadedBy": "uuid",
"createdAt": "2026-03-12T..."
}
]
}
```
#### GET /files/:fileId/download — Herunterladen / Inline anzeigen
- Query-Parameter: `?inline=true` (optional) — setzt `Content-Disposition: inline` statt `attachment`
- Default: `Content-Disposition: attachment; filename="Wartungsvertrag_2026.pdf"`
- Content-Type: aus DB (`mimeType`)
- Implementierung: `@Res() res: Response`, `res.sendFile(absolutePath)` oder NestJS `StreamableFile`
- **Sicherheit:** Pruefung dass fileId zum tenantId gehoert, KEIN direktes Auslesen von `storagePath` aus Request
#### DELETE /files/:fileId — Loeschen
- DB-Record loeschen
- Datei auf Disk loeschen (`fs.unlink`)
- Falls Datei auf Disk nicht gefunden: trotzdem DB-Record loeschen (kein Fehler)
### Sicherheits-Hinweise
1. **Path Traversal**: `originalName` NIEMALS direkt als Pfad verwenden. Immer UUID-Prefix auf Disk.
2. **Tenant-Isolation**: Jeder File-Zugriff muss `tenantId` aus JWT gegen `contract.tenantId` pruefen.
3. **Upload-Limit**: Max 10 Dateien pro Vertrag — pruefe Anzahl vor Upload.
4. **DSGVO**: Dateien werden beim Loeschen des Vertrags automatisch entfernt (Cascade in DB + Cron-Job oder onDelete-Hook fuer Disk-Cleanup).
### Modulstruktur
- Kein eigenes Modul noetig — in `ContractsModule` (`src/contracts/`) integrieren:
- `contracts.controller.ts` — neue Routen erganzen
- `contracts.service.ts``uploadFile`, `listFiles`, `downloadFile`, `deleteFile` Methoden
- `multer` bereits als `@nestjs/platform-express` Peer-Dependency vorhanden
### Migration
```
prisma/migrations/20260312_contract_files/migration.sql
```
Nach Migration: `docker compose ... up -d crm --force-recreate -V`
Und im docker-compose.crm.yml:
```yaml
volumes:
- ./uploads:/app/uploads
```
### TODO fuer Frontend
Nach Implementierung der 4 Endpoints:
- [ ] `ContractFile`-Interface in `types.ts`
- [ ] `contractFilesApi` in `api.ts` (upload, list, download als Blob, delete)
- [ ] `useContractFiles`, `useUploadContractFile`, `useDeleteContractFile` in `hooks.ts`
- [ ] `ContractsCard.tsx` — Dateien-Sektion im Edit-Modal: Upload-Button, Dateiliste mit Download + Loeschen
**Hinweis:** Das Frontend kann vorab gegen den definierten API-Contract gebaut werden. Sobald die Backend-Endpoints live sind, ist die Integration sofort funktional.

View file

@ -63,6 +63,7 @@ import type {
EnrichmentResponse, EnrichmentResponse,
EnrichmentConfig, EnrichmentConfig,
Contract, Contract,
ContractFile,
CreateContractPayload, CreateContractPayload,
UpdateContractPayload, UpdateContractPayload,
ContractsQueryParams, ContractsQueryParams,
@ -707,3 +708,44 @@ export const contractsApi = {
) )
.then((r) => r.data), .then((r) => r.data),
}; };
// --- Contract Files ---
export const contractFilesApi = {
list: (companyId: string, contractId: string) =>
api
.get<{ success: boolean; data: ContractFile[]; meta: { timestamp: string } }>(
`/crm/companies/${companyId}/contracts/${contractId}/files`,
)
.then((r) => r.data),
upload: (companyId: string, contractId: string, file: File) => {
const form = new FormData();
form.append('file', file);
return api
.post<SingleResponse<ContractFile>>(
`/crm/companies/${companyId}/contracts/${contractId}/files`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
.then((r) => r.data);
},
download: (companyId: string, contractId: string, fileId: string, inline = false) =>
api
.get(
`/crm/companies/${companyId}/contracts/${contractId}/files/${fileId}/download`,
{
responseType: 'blob',
params: inline ? { inline: 'true' } : undefined,
},
)
.then((r) => r.data as Blob),
delete: (companyId: string, contractId: string, fileId: string) =>
api
.delete<SingleResponse<ContractFile>>(
`/crm/companies/${companyId}/contracts/${contractId}/files/${fileId}`,
)
.then((r) => r.data),
};

View file

@ -1,12 +1,21 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { import {
useContracts, useContracts,
useCreateContract, useCreateContract,
useUpdateContract, useUpdateContract,
useDeleteContract, useDeleteContract,
useContractFiles,
useUploadContractFile,
useDeleteContractFile,
} from '../hooks'; } from '../hooks';
import type { Contract, ContractStatus, CreateContractPayload } from '../types'; import { contractFilesApi } from '../api';
import type {
Contract,
ContractFile,
ContractStatus,
CreateContractPayload,
} from '../types';
import styles from './CompanyDetailPage.module.css'; import styles from './CompanyDetailPage.module.css';
// ============================================================ // ============================================================
@ -27,6 +36,9 @@ const STATUS_COLORS: Record<ContractStatus, { bg: string; color: string }> = {
CANCELLED: { bg: '#fee2e2', color: '#dc2626' }, CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
}; };
const ALLOWED_TYPES = '.pdf,.doc,.docx,.xlsx,.xls';
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
// ============================================================ // ============================================================
// Helpers // Helpers
// ============================================================ // ============================================================
@ -52,6 +64,19 @@ function formatValue(val: string | null, currency = 'EUR'): string {
}).format(num); }).format(num);
} }
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileIcon(mimeType: string): string {
if (mimeType === 'application/pdf') return '📄';
if (mimeType.includes('word')) return '📝';
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊';
return '📎';
}
// ============================================================ // ============================================================
// Props // Props
// ============================================================ // ============================================================
@ -76,7 +101,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
const handleDelete = (contract: Contract) => { const handleDelete = (contract: Contract) => {
if ( if (
window.confirm( window.confirm(
`Vertrag "${contract.title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`, `Vertrag "${contract.title}" wirklich löschen? Alle angehängten Dateien werden ebenfalls gelöscht.`,
) )
) { ) {
deleteMut.mutate(contract.id); deleteMut.mutate(contract.id);
@ -160,7 +185,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {
<button <button
className={styles.relationDeleteBtn} className={styles.relationDeleteBtn}
onClick={() => setEditContract(c)} onClick={() => setEditContract(c)}
title="Bearbeiten" title="Bearbeiten / Dokumente"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
<svg <svg
@ -328,7 +353,7 @@ function ContractFormModal({
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'} title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
maxWidth="520px" maxWidth="560px"
> >
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{error && ( {error && (
@ -442,13 +467,13 @@ function ContractFormModal({
</div> </div>
{/* Notizen */} {/* Notizen */}
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Notizen</label> <label style={labelStyle}>Notizen</label>
<textarea <textarea
style={{ style={{
...inputStyle, ...inputStyle,
cursor: 'text', cursor: 'text',
minHeight: 72, minHeight: 64,
resize: 'vertical', resize: 'vertical',
}} }}
value={notes} value={notes}
@ -457,6 +482,11 @@ function ContractFormModal({
/> />
</div> </div>
{/* Dokumente — nur im Edit-Modus */}
{mode === 'edit' && contract && (
<FilesSection companyId={companyId} contractId={contract.id} />
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button <button
type="button" type="button"
@ -502,3 +532,218 @@ function ContractFormModal({
</Modal> </Modal>
); );
} }
// ============================================================
// Files Section (inside Edit Modal)
// ============================================================
interface FilesSectionProps {
companyId: string;
contractId: string;
}
function FilesSection({ companyId, contractId }: FilesSectionProps) {
const { data, isLoading } = useContractFiles(companyId, contractId);
const uploadMut = useUploadContractFile(companyId, contractId);
const deleteMut = useDeleteContractFile(companyId, contractId);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadError, setUploadError] = useState('');
const files: ContractFile[] = data?.data ?? [];
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadError('');
if (file.size > MAX_FILE_SIZE) {
setUploadError(`Datei zu groß (max. 25 MB). Gewählt: ${formatFileSize(file.size)}`);
e.target.value = '';
return;
}
uploadMut.mutate(file, {
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Upload fehlgeschlagen';
setUploadError(msg);
},
});
e.target.value = '';
};
const handleDownload = async (file: ContractFile) => {
try {
const blob = await contractFilesApi.download(companyId, contractId, file.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.originalName;
a.click();
URL.revokeObjectURL(url);
} catch {
// silently ignore
}
};
const handleDelete = (file: ContractFile) => {
if (window.confirm(`Datei "${file.originalName}" wirklich löschen?`)) {
deleteMut.mutate(file.id);
}
};
return (
<div
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '1rem',
marginBottom: '1.25rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.625rem',
}}
>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text)' }}>
Dokumente
</span>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadMut.isPending}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.625rem',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
cursor: uploadMut.isPending ? 'wait' : 'pointer',
color: 'var(--color-text)',
opacity: uploadMut.isPending ? 0.6 : 1,
}}
>
{uploadMut.isPending ? (
'Hochladen...'
) : (
<>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 1v7M3 4l3-3 3 3M1 10h10" />
</svg>
Hochladen
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_TYPES}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{uploadError && (
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
marginBottom: '0.5rem',
}}
>
{uploadError}
</p>
)}
{isLoading ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>Laden...</p>
) : files.length === 0 ? (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
Noch keine Dokumente PDF, Word oder Excel hochladen (max. 25 MB)
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{files.map((f) => (
<div
key={f.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.625rem',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
}}
>
<span style={{ fontSize: '1rem', flexShrink: 0 }}>{fileIcon(f.mimeType)}</span>
<span
style={{
flex: 1,
fontSize: '0.8125rem',
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={f.originalName}
>
{f.originalName}
</span>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
{formatFileSize(f.size)}
</span>
<button
type="button"
onClick={() => handleDownload(f)}
title="Herunterladen"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
color: 'var(--color-primary)',
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 1v7M3 8l3 3 3-3M1 11h10" />
</svg>
</button>
<button
type="button"
onClick={() => handleDelete(f)}
title="Löschen"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.125rem',
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -23,6 +23,7 @@ import {
importApi, importApi,
enrichmentApi, enrichmentApi,
contractsApi, contractsApi,
contractFilesApi,
} from './api'; } from './api';
import type { import type {
ContactsQueryParams, ContactsQueryParams,
@ -156,6 +157,11 @@ export const crmKeys = {
list: (companyId: string, params?: ContractsQueryParams) => list: (companyId: string, params?: ContractsQueryParams) =>
['crm', 'contracts', 'list', companyId, params] as const, ['crm', 'contracts', 'list', companyId, params] as const,
}, },
contractFiles: {
all: ['crm', 'contractFiles'] as const,
list: (companyId: string, contractId: string) =>
['crm', 'contractFiles', 'list', companyId, contractId] as const,
},
}; };
// ============================================================ // ============================================================
@ -1226,3 +1232,40 @@ export function useDeleteContract(companyId: string) {
}, },
}); });
} }
// ============================================================
// Contract Files
// ============================================================
export function useContractFiles(companyId: string, contractId: string) {
return useQuery({
queryKey: crmKeys.contractFiles.list(companyId, contractId),
queryFn: () => contractFilesApi.list(companyId, contractId),
enabled: !!companyId && !!contractId,
});
}
export function useUploadContractFile(companyId: string, contractId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => contractFilesApi.upload(companyId, contractId, file),
onSuccess: () => {
qc.invalidateQueries({
queryKey: crmKeys.contractFiles.list(companyId, contractId),
});
},
});
}
export function useDeleteContractFile(companyId: string, contractId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (fileId: string) =>
contractFilesApi.delete(companyId, contractId, fileId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: crmKeys.contractFiles.list(companyId, contractId),
});
},
});
}

View file

@ -185,6 +185,16 @@ export interface Contract {
updatedAt: string; updatedAt: string;
} }
export interface ContractFile {
id: string;
contractId: string;
originalName: string;
mimeType: string;
size: number;
uploadedBy: string;
createdAt: string;
}
export interface CreateContractPayload { export interface CreateContractPayload {
title: string; title: string;
status?: ContractStatus; status?: ContractStatus;