Aufgaben ohne Kunde/Vertrag anlegbar

ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).

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)" ohne Vertrags-Link/
Zum-Vertrag-Button.

Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:15:33 +02:00
co-authored by Claude Opus 4.8
parent b8128616f2
commit 15142676ef
9 changed files with 161 additions and 35 deletions
@@ -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;
+4 -2
View File
@@ -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)
@@ -118,6 +118,41 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
}
}
// 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<void> {
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<void> {
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.
@@ -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)
+6 -3
View File
@@ -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,
},
});
+21
View File
@@ -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
+71 -29
View File
@@ -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() {
</div>
<div className="text-sm text-gray-500 mt-1 flex items-center gap-2">
<FileText className="w-4 h-4" />
<Link
to={`/contracts/${task.contractId}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contractDisplay}
</Link>
{task.contractId != null ? (
<Link
to={`/contracts/${task.contractId}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contractDisplay}
</Link>
) : (
<span className="text-gray-500 italic">{contractDisplay}</span>
)}
{showCustomer && customerDisplay && (
<>
<span className="text-gray-400">|</span>
@@ -296,17 +300,19 @@ export default function TaskList() {
<Trash2 className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${task.contractId}`);
}}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Button>
{task.contractId != null && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${task.contractId}`);
}}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Button>
)}
</div>
</div>
@@ -763,6 +769,7 @@ function CreateTaskModal({
const queryClient = useQueryClient();
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedContractId, setSelectedContractId] = useState<number | null>(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"
>
<div className="space-y-4">
{/* Ohne Kunde (allgemeine Aufgabe) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={noCustomer}
onChange={(e) => {
setNoCustomer(e.target.checked);
if (e.target.checked) {
// Ohne Kunde → Kunden-/Vertragsauswahl + Portal-Sichtbarkeit weg.
setSelectedCustomerId(null);
setSelectedContractId(null);
setVisibleInPortal(false);
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Ohne Kunde (allgemeine Aufgabe)</span>
</label>
{/* Kundenauswahl */}
{!noCustomer && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunde *
@@ -896,6 +935,7 @@ function CreateTaskModal({
)}
</div>
</div>
)}
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
{selectedCustomerId && (
@@ -969,7 +1009,8 @@ function CreateTaskModal({
/>
</div>
{/* Im Kundenportal sichtbar */}
{/* Im Kundenportal sichtbar nur wenn ein Kunde/Vertrag zugeordnet ist */}
{!noCustomer && (
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -981,6 +1022,7 @@ function CreateTaskModal({
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
</label>
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4">
@@ -989,7 +1031,7 @@ function CreateTaskModal({
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContractId || !title.trim() || isSubmitting}
disabled={(!noCustomer && !selectedContractId) || !title.trim() || isSubmitting}
>
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
</Button>
+5
View File
@@ -1072,6 +1072,11 @@ export const contractTaskApi = {
const res = await api.post<ApiResponse<ContractTask>>(`/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<ApiResponse<ContractTask>>(`/tasks`, data);
return res.data;
},
update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => {
const res = await api.put<ApiResponse<ContractTask>>(`/tasks/${taskId}`, data);
return res.data;
+1 -1
View File
@@ -471,7 +471,7 @@ export interface ContractTaskContract {
export interface ContractTask {
id: number;
contractId: number;
contractId: number | null;
title: string;
description?: string;
status: ContractTaskStatus;