diff --git a/backend/prisma/migrations/20260818110000_task_optional_contract/migration.sql b/backend/prisma/migrations/20260818110000_task_optional_contract/migration.sql new file mode 100644 index 00000000..9e6f64ac --- /dev/null +++ b/backend/prisma/migrations/20260818110000_task_optional_contract/migration.sql @@ -0,0 +1,4 @@ +-- Aufgaben ohne Vertrag/Kunde erlauben: contractId nullable. +-- Der bestehende FK (onDelete Cascade) bleibt: gesetzte contractId cascaden +-- weiterhin, NULL bleibt unberührt. +ALTER TABLE `ContractTask` MODIFY COLUMN `contractId` INT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fe20d8ba..d4425ebc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -870,8 +870,10 @@ enum ContractTaskStatus { model ContractTask { id Int @id @default(autoincrement()) - contractId Int - contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) + // Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden + // (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal. + contractId Int? + contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade) title String description String? @db.Text status ContractTaskStatus @default(OPEN) diff --git a/backend/src/controllers/contractTask.controller.ts b/backend/src/controllers/contractTask.controller.ts index 4a442cd2..4cdef826 100644 --- a/backend/src/controllers/contractTask.controller.ts +++ b/backend/src/controllers/contractTask.controller.ts @@ -118,6 +118,41 @@ export async function getTasks(req: AuthRequest, res: Response): Promise { } } +// Allgemeine Aufgabe OHNE Vertrag/Kunde (nur Mitarbeiter/Admin). Ohne Vertrag +// gibt es keinen Kunden → visibleInPortal ist hier bedeutungslos und immer +// false (der Service erzwingt das ohnehin). +export async function createGeneralTask(req: AuthRequest, res: Response): Promise { + try { + if (req.user?.isCustomerPortal) { + res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse); + return; + } + const { title, description } = req.body ?? {}; + if (!title || !String(title).trim()) { + res.status(400).json({ success: false, error: 'Titel ist erforderlich' } as ApiResponse); + return; + } + const task = await contractTaskService.createTask({ + contractId: null, + title: String(title).trim(), + description, + visibleInPortal: false, + createdBy: req.user?.email, + }); + await logChange({ + req, action: 'CREATE', resourceType: 'ContractTask', + resourceId: task.id.toString(), + label: `Allgemeine Aufgabe "${String(title).trim()}" erstellt (ohne Vertrag)`, + }); + res.status(201).json({ success: true, data: task } as ApiResponse); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe', + } as ApiResponse); + } +} + export async function createTask(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.contractId); @@ -361,6 +396,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom return; } + // Allgemeine Aufgaben (ohne Vertrag/Kunde) sind reine Mitarbeiter-Aufgaben + // und für Portal-User nie zugänglich. + if (task.contractId == null) { + res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse); + return; + } // Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung // via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein // Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen. diff --git a/backend/src/routes/contractTask.routes.ts b/backend/src/routes/contractTask.routes.ts index 637ddf9a..ed78a624 100644 --- a/backend/src/routes/contractTask.routes.ts +++ b/backend/src/routes/contractTask.routes.ts @@ -22,6 +22,14 @@ router.get( contractTaskController.getTaskStats ); +// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter) +router.post( + '/tasks', + authenticate, + requirePermission('contracts:update'), + contractTaskController.createGeneralTask +); + // ==================== TASKS BY CONTRACT ==================== // Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare) diff --git a/backend/src/services/contractTask.service.ts b/backend/src/services/contractTask.service.ts index 11efc73e..f29984c5 100644 --- a/backend/src/services/contractTask.service.ts +++ b/backend/src/services/contractTask.service.ts @@ -50,18 +50,21 @@ export async function getTaskById(id: number) { } export async function createTask(data: { - contractId: number; + contractId?: number | null; title: string; description?: string; visibleInPortal?: boolean; createdBy?: string; }) { + // Ohne Vertrag (allgemeine Aufgabe) gibt es keinen Kunden → nie im Portal + // sichtbar, egal was der Client schickt. + const hasContract = data.contractId != null; return prisma.contractTask.create({ data: { - contractId: data.contractId, + contractId: data.contractId ?? null, title: data.title, description: data.description, - visibleInPortal: data.visibleInPortal ?? false, + visibleInPortal: hasContract ? (data.visibleInPortal ?? false) : false, createdBy: data.createdBy, }, }); diff --git a/docs/todo.md b/docs/todo.md index 86ccebeb..fd034a6e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,27 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) + - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint + `POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne + Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar** + (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei + contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus). + - Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet + Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt + solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link). + +- [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18) + - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail- + Providern konfigurierten Domain (oder Subdomain) liegen → man trägt so keine + verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist + jetzt Pflichtfeld (Frontend `required` + Backend create/update). + - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service. + +- [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwünscht"** (2026-08-18) + - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`). + Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail. + - [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal diff --git a/frontend/src/pages/tasks/TaskList.tsx b/frontend/src/pages/tasks/TaskList.tsx index 7108fe82..a8547245 100644 --- a/frontend/src/pages/tasks/TaskList.tsx +++ b/frontend/src/pages/tasks/TaskList.tsx @@ -196,7 +196,7 @@ export default function TaskList() { const contractDisplay = task.contract ? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}` - : `Vertrag #${task.contractId}`; + : (task.contractId != null ? `Vertrag #${task.contractId}` : 'Allgemeine Aufgabe (ohne Vertrag)'); const customerDisplay = task.contract?.customer ? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`) @@ -242,13 +242,17 @@ export default function TaskList() {
- e.stopPropagation()} - > - {contractDisplay} - + {task.contractId != null ? ( + e.stopPropagation()} + > + {contractDisplay} + + ) : ( + {contractDisplay} + )} {showCustomer && customerDisplay && ( <> | @@ -296,17 +300,19 @@ export default function TaskList() { )} - + {task.contractId != null && ( + + )}
@@ -763,6 +769,7 @@ function CreateTaskModal({ const queryClient = useQueryClient(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); const [selectedContractId, setSelectedContractId] = useState(null); + const [noCustomer, setNoCustomer] = useState(false); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [visibleInPortal, setVisibleInPortal] = useState(false); @@ -810,15 +817,25 @@ function CreateTaskModal({ }, [contractsData?.data, contractSearch]); const handleSubmit = async () => { - if (!selectedContractId || !title.trim()) return; + if (!title.trim()) return; + if (!noCustomer && !selectedContractId) return; + const contractId = selectedContractId; setIsSubmitting(true); try { - await contractTaskApi.create(selectedContractId, { - title: title.trim(), - description: description.trim() || undefined, - visibleInPortal, - }); + if (noCustomer) { + // Allgemeine Aufgabe ohne Vertrag/Kunde – nie im Portal sichtbar. + await contractTaskApi.createGeneral({ + title: title.trim(), + description: description.trim() || undefined, + }); + } else { + await contractTaskApi.create(contractId!, { + title: title.trim(), + description: description.trim() || undefined, + visibleInPortal, + }); + } queryClient.invalidateQueries({ queryKey: ['all-tasks'] }); queryClient.invalidateQueries({ queryKey: ['task-stats'] }); onClose(); @@ -828,8 +845,9 @@ function CreateTaskModal({ setVisibleInPortal(false); setSelectedContractId(null); setSelectedCustomerId(null); - // Navigate to the contract - navigate(`/contracts/${selectedContractId}`); + setNoCustomer(false); + // Bei vertragsgebundener Aufgabe zum Vertrag springen. + if (!noCustomer && contractId) navigate(`/contracts/${contractId}`); } catch (error) { console.error('Fehler beim Erstellen der Aufgabe:', error); alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.'); @@ -844,6 +862,7 @@ function CreateTaskModal({ setVisibleInPortal(false); setSelectedContractId(null); setSelectedCustomerId(null); + setNoCustomer(false); setCustomerSearch(''); setContractSearch(''); onClose(); @@ -861,7 +880,27 @@ function CreateTaskModal({ title="Neue Aufgabe" >
+ {/* Ohne Kunde (allgemeine Aufgabe) */} + + {/* Kundenauswahl */} + {!noCustomer && (
+ )} {/* Vertragsauswahl (nur wenn Kunde ausgewählt) */} {selectedCustomerId && ( @@ -969,7 +1009,8 @@ function CreateTaskModal({ /> - {/* Im Kundenportal sichtbar */} + {/* Im Kundenportal sichtbar – nur wenn ein Kunde/Vertrag zugeordnet ist */} + {!noCustomer && (
+ )} {/* Buttons */}
@@ -989,7 +1031,7 @@ function CreateTaskModal({ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 28a9924f..5ac8e7cb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1072,6 +1072,11 @@ export const contractTaskApi = { const res = await api.post>(`/contracts/${contractId}/tasks`, data); return res.data; }, + // Allgemeine Aufgabe ohne Vertrag/Kunde (nur Mitarbeiter). + createGeneral: async (data: { title: string; description?: string }) => { + const res = await api.post>(`/tasks`, data); + return res.data; + }, update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => { const res = await api.put>(`/tasks/${taskId}`, data); return res.data; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6fa449de..2367477a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -471,7 +471,7 @@ export interface ContractTaskContract { export interface ContractTask { id: number; - contractId: number; + contractId: number | null; title: string; description?: string; status: ContractTaskStatus;