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:
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -471,7 +471,7 @@ export interface ContractTaskContract {
|
||||
|
||||
export interface ContractTask {
|
||||
id: number;
|
||||
contractId: number;
|
||||
contractId: number | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: ContractTaskStatus;
|
||||
|
||||
Reference in New Issue
Block a user