diff --git a/docs/INSIGHT-CRM.md b/docs/INSIGHT-CRM.md index e57d278..4208f48 100644 --- a/docs/INSIGHT-CRM.md +++ b/docs/INSIGHT-CRM.md @@ -3257,3 +3257,183 @@ Die `ContractsCard.tsx` ist vollstaendig implementiert. Das "Modul in Entwicklun ### TypeScript-Check `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. + diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index b53c422..42f54e7 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -63,6 +63,7 @@ import type { EnrichmentResponse, EnrichmentConfig, Contract, + ContractFile, CreateContractPayload, UpdateContractPayload, ContractsQueryParams, @@ -707,3 +708,44 @@ export const contractsApi = { ) .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>( + `/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>( + `/crm/companies/${companyId}/contracts/${contractId}/files/${fileId}`, + ) + .then((r) => r.data), +}; diff --git a/packages/frontend/src/crm/companies/ContractsCard.tsx b/packages/frontend/src/crm/companies/ContractsCard.tsx index fa3ff9a..950ccd4 100644 --- a/packages/frontend/src/crm/companies/ContractsCard.tsx +++ b/packages/frontend/src/crm/companies/ContractsCard.tsx @@ -1,12 +1,21 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Modal } from '../../components/Modal'; import { useContracts, useCreateContract, useUpdateContract, useDeleteContract, + useContractFiles, + useUploadContractFile, + useDeleteContractFile, } 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'; // ============================================================ @@ -27,6 +36,9 @@ const STATUS_COLORS: Record = { CANCELLED: { bg: '#fee2e2', color: '#dc2626' }, }; +const ALLOWED_TYPES = '.pdf,.doc,.docx,.xlsx,.xls'; +const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB + // ============================================================ // Helpers // ============================================================ @@ -52,6 +64,19 @@ function formatValue(val: string | null, currency = 'EUR'): string { }).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 // ============================================================ @@ -76,7 +101,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) { const handleDelete = (contract: Contract) => { if ( 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); @@ -160,7 +185,7 @@ export function ContractsCard({ companyId }: ContractsCardProps) {