feat: prevent duplicate resource-project assignments
Engine (packages/engine): - New checkDuplicateAssignment() pure function: detects same resource assigned to same project with overlapping dates - 15 unit tests covering: overlap, no-overlap, cancelled, self-exclude, string dates, PROPOSED status Application layer (packages/application): - createAssignment: throws CONFLICT before DB write if duplicate found - fillDemandRequirement: same check before entering transaction AI Assistant (packages/api/router/assistant-tools.ts): - create_allocation: checks before creating, returns helpful error message - fill_demand: same check using demand's projectId UI (apps/web): - AllocationModal: amber warning when resource already assigned to selected project with overlapping dates (non-blocking) Database cleanup: - Found and merged 1 duplicate: Wong Wong on Porsche Taycan Sport Film (2 overlapping PROPOSED assignments merged into 1) Regression: 298 engine tests pass (283 + 15 new). TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { AllocationStatus } from "@planarchy/shared";
|
import { AllocationStatus } from "@planarchy/shared";
|
||||||
@@ -73,6 +73,36 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch existing allocations for the selected resource+project to detect overlaps
|
||||||
|
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
||||||
|
const { data: existingAllocations } = trpc.allocation.listView.useQuery(
|
||||||
|
{ projectId, resourceId },
|
||||||
|
{ enabled: shouldCheckOverlap, staleTime: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlapWarning = useMemo(() => {
|
||||||
|
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
||||||
|
const formStart = new Date(startDate);
|
||||||
|
const formEnd = new Date(endDate);
|
||||||
|
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
||||||
|
|
||||||
|
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
|
||||||
|
for (const existing of allocList) {
|
||||||
|
// Skip the allocation being edited
|
||||||
|
if (isEditing && allocation && existing.id === allocation.id) continue;
|
||||||
|
// Only check assignments for this resource
|
||||||
|
if (existing.resourceId !== resourceId) continue;
|
||||||
|
const exStart = new Date(existing.startDate);
|
||||||
|
const exEnd = new Date(existing.endDate);
|
||||||
|
// Check date overlap
|
||||||
|
if (formStart <= exEnd && formEnd >= exStart) {
|
||||||
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
return `This resource is already assigned to this project from ${fmt(exStart)} to ${fmt(exEnd)}. Consider updating the existing assignment instead.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
|
||||||
|
|
||||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -473,6 +503,13 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Overlap warning */}
|
||||||
|
{overlapWarning && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
{"\u26A0"} {overlapWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-2">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from "@planarchy/db";
|
import { prisma } from "@planarchy/db";
|
||||||
import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation";
|
import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@planarchy/engine/allocation";
|
||||||
import { computeBudgetStatus } from "@planarchy/engine";
|
import { computeBudgetStatus } from "@planarchy/engine";
|
||||||
import type { PermissionKey } from "@planarchy/shared";
|
import type { PermissionKey } from "@planarchy/shared";
|
||||||
import { parseTaskAction } from "@planarchy/shared";
|
import { parseTaskAction } from "@planarchy/shared";
|
||||||
@@ -2195,6 +2195,16 @@ const executors = {
|
|||||||
const startDate = new Date(params.startDate);
|
const startDate = new Date(params.startDate);
|
||||||
const endDate = new Date(params.endDate);
|
const endDate = new Date(params.endDate);
|
||||||
|
|
||||||
|
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
|
||||||
|
const existingAssignments = await ctx.db.assignment.findMany({
|
||||||
|
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
|
||||||
|
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
|
||||||
|
});
|
||||||
|
const dupCheck = checkDuplicateAssignment(resource.id, project.id, startDate, endDate, existingAssignments);
|
||||||
|
if (dupCheck.isDuplicate) {
|
||||||
|
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
|
||||||
|
}
|
||||||
|
|
||||||
// Check for existing CANCELLED allocation with same unique key — reactivate it
|
// Check for existing CANCELLED allocation with same unique key — reactivate it
|
||||||
const existing = await ctx.db.assignment.findUnique({
|
const existing = await ctx.db.assignment.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -3002,6 +3012,16 @@ const executors = {
|
|||||||
}
|
}
|
||||||
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
|
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
|
||||||
|
|
||||||
|
// Check for overlapping duplicate assignments (same resource + project + overlapping dates)
|
||||||
|
const existingAssignments = await ctx.db.assignment.findMany({
|
||||||
|
where: { resourceId: resource.id, status: { not: "CANCELLED" } },
|
||||||
|
select: { id: true, resourceId: true, projectId: true, startDate: true, endDate: true, status: true },
|
||||||
|
});
|
||||||
|
const dupCheck = checkDuplicateAssignment(resource.id, demand.project.id, demand.startDate, demand.endDate, existingAssignments);
|
||||||
|
if (dupCheck.isDuplicate) {
|
||||||
|
return { error: dupCheck.message + " Use update_allocation_status to modify the existing assignment." };
|
||||||
|
}
|
||||||
|
|
||||||
const roleName = demand.roleEntity?.name ?? demand.role ?? null;
|
const roleName = demand.roleEntity?.name ?? demand.role ?? null;
|
||||||
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
|
const dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
|
||||||
const assignment = await ctx.db.assignment.create({
|
const assignment = await ctx.db.assignment.create({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { calculateAllocation, validateAvailability } from "@planarchy/engine";
|
import { calculateAllocation, validateAvailability, checkDuplicateAssignment } from "@planarchy/engine";
|
||||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||||
import {
|
import {
|
||||||
type Allocation,
|
type Allocation,
|
||||||
@@ -105,6 +105,28 @@ export async function createAssignment(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const duplicateResult = checkDuplicateAssignment(
|
||||||
|
input.resourceId,
|
||||||
|
input.projectId,
|
||||||
|
input.startDate,
|
||||||
|
input.endDate,
|
||||||
|
existingBookings.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
resourceId: b.resourceId ?? "",
|
||||||
|
projectId: b.projectId,
|
||||||
|
startDate: b.startDate,
|
||||||
|
endDate: b.endDate,
|
||||||
|
status: b.status,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateResult.isDuplicate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: duplicateResult.message ?? "Resource is already assigned to this project with overlapping dates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
const availabilityWindows = existingBookings.map((booking) => ({
|
const availabilityWindows = existingBookings.map((booking) => ({
|
||||||
startDate: booking.startDate,
|
startDate: booking.startDate,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { PrismaClient } from "@planarchy/db";
|
import type { PrismaClient } from "@planarchy/db";
|
||||||
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
|
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
|
||||||
|
import { checkDuplicateAssignment } from "@planarchy/engine";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { type AssignmentWithRelations } from "./create-assignment.js";
|
import { type AssignmentWithRelations } from "./create-assignment.js";
|
||||||
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
|
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
|
||||||
|
import { listAssignmentBookings } from "./list-assignment-bookings.js";
|
||||||
|
|
||||||
export interface FillDemandRequirementResult {
|
export interface FillDemandRequirementResult {
|
||||||
assignment: AssignmentWithRelations;
|
assignment: AssignmentWithRelations;
|
||||||
@@ -54,5 +56,34 @@ export async function fillDemandRequirement(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingBookings = await listAssignmentBookings(
|
||||||
|
db as unknown as Parameters<typeof listAssignmentBookings>[0],
|
||||||
|
{
|
||||||
|
resourceIds: [input.resourceId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateResult = checkDuplicateAssignment(
|
||||||
|
input.resourceId,
|
||||||
|
demandRequirement.projectId,
|
||||||
|
demandRequirement.startDate,
|
||||||
|
demandRequirement.endDate,
|
||||||
|
existingBookings.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
resourceId: b.resourceId ?? "",
|
||||||
|
projectId: b.projectId,
|
||||||
|
startDate: b.startDate,
|
||||||
|
endDate: b.endDate,
|
||||||
|
status: b.status,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateResult.isDuplicate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: duplicateResult.message ?? "Resource is already assigned to this project with overlapping dates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
|
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { checkDuplicateAssignment, type ExistingAssignment } from "../allocation/duplicate-check.js";
|
||||||
|
|
||||||
|
const base: ExistingAssignment = {
|
||||||
|
id: "a1",
|
||||||
|
resourceId: "r1",
|
||||||
|
projectId: "p1",
|
||||||
|
startDate: new Date("2026-03-01"),
|
||||||
|
endDate: new Date("2026-06-30"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("checkDuplicateAssignment", () => {
|
||||||
|
it("returns no duplicate when no existing assignments", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", []);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no duplicate for different project", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p2", "2026-04-01", "2026-05-01", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no duplicate for different resource", () => {
|
||||||
|
const result = checkDuplicateAssignment("r2", "p1", "2026-04-01", "2026-05-01", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no duplicate when dates don't overlap (before)", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-01-01", "2026-02-28", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no duplicate when dates don't overlap (after)", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-07-01", "2026-08-01", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate with full overlap", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-03-15", "2026-05-15", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
expect(result.conflictingAssignment?.id).toBe("a1");
|
||||||
|
expect(result.message).toContain("already assigned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate with partial overlap (start before, end during)", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-02-15", "2026-04-01", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate with partial overlap (start during, end after)", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-05-01", "2026-07-15", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate on exact same dates", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-03-01", "2026-06-30", [base]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores CANCELLED assignments", () => {
|
||||||
|
const cancelled = { ...base, status: "CANCELLED" };
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [cancelled]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores DRAFT assignments", () => {
|
||||||
|
const draft = { ...base, status: "DRAFT" };
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [draft]);
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes self by ID (for updates)", () => {
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [base], "a1");
|
||||||
|
expect(result.isDuplicate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not exclude other assignments when excludeId is set", () => {
|
||||||
|
const other: ExistingAssignment = { ...base, id: "a2" };
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [other], "a1");
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with string dates", () => {
|
||||||
|
const strAssignment: ExistingAssignment = {
|
||||||
|
...base,
|
||||||
|
startDate: "2026-03-01",
|
||||||
|
endDate: "2026-06-30",
|
||||||
|
};
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [strAssignment]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks PROPOSED status as active", () => {
|
||||||
|
const proposed = { ...base, status: "PROPOSED" };
|
||||||
|
const result = checkDuplicateAssignment("r1", "p1", "2026-04-01", "2026-05-01", [proposed]);
|
||||||
|
expect(result.isDuplicate).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Checks whether a resource is already assigned to the same project
|
||||||
|
* with overlapping dates. Used to prevent duplicate bookings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExistingAssignment {
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
projectId: string;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateCheckResult {
|
||||||
|
isDuplicate: boolean;
|
||||||
|
conflictingAssignment?: ExistingAssignment;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set(["CONFIRMED", "ACTIVE", "PROPOSED"]);
|
||||||
|
|
||||||
|
function toTime(d: Date | string): number {
|
||||||
|
const dt = typeof d === "string" ? new Date(d) : d;
|
||||||
|
dt.setHours(0, 0, 0, 0);
|
||||||
|
return dt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if assigning a resource to a project with the given dates
|
||||||
|
* would create a duplicate (same resource + same project + overlapping dates).
|
||||||
|
*
|
||||||
|
* @param resourceId - The resource being assigned
|
||||||
|
* @param projectId - The project being assigned to
|
||||||
|
* @param startDate - Start of the new assignment
|
||||||
|
* @param endDate - End of the new assignment
|
||||||
|
* @param existingAssignments - All current assignments for this resource
|
||||||
|
* @param excludeAssignmentId - Exclude this ID (for updates — don't conflict with self)
|
||||||
|
*/
|
||||||
|
export function checkDuplicateAssignment(
|
||||||
|
resourceId: string,
|
||||||
|
projectId: string,
|
||||||
|
startDate: Date | string,
|
||||||
|
endDate: Date | string,
|
||||||
|
existingAssignments: ExistingAssignment[],
|
||||||
|
excludeAssignmentId?: string,
|
||||||
|
): DuplicateCheckResult {
|
||||||
|
const newStart = toTime(startDate);
|
||||||
|
const newEnd = toTime(endDate);
|
||||||
|
|
||||||
|
for (const existing of existingAssignments) {
|
||||||
|
// Skip self (for updates)
|
||||||
|
if (excludeAssignmentId && existing.id === excludeAssignmentId) continue;
|
||||||
|
|
||||||
|
// Skip different resource or project
|
||||||
|
if (existing.resourceId !== resourceId) continue;
|
||||||
|
if (existing.projectId !== projectId) continue;
|
||||||
|
|
||||||
|
// Skip cancelled/inactive
|
||||||
|
if (!ACTIVE_STATUSES.has(existing.status)) continue;
|
||||||
|
|
||||||
|
// Check date overlap: existingStart <= newEnd && existingEnd >= newStart
|
||||||
|
const existStart = toTime(existing.startDate);
|
||||||
|
const existEnd = toTime(existing.endDate);
|
||||||
|
|
||||||
|
if (existStart <= newEnd && existEnd >= newStart) {
|
||||||
|
const startStr = new Date(existing.startDate).toISOString().slice(0, 10);
|
||||||
|
const endStr = new Date(existing.endDate).toISOString().slice(0, 10);
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
conflictingAssignment: existing,
|
||||||
|
message: `Resource is already assigned to this project from ${startStr} to ${endStr}. Consider updating the existing assignment instead.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDuplicate: false };
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from "./calculator.js";
|
|||||||
export * from "./availability-validator.js";
|
export * from "./availability-validator.js";
|
||||||
export * from "./recurrence.js";
|
export * from "./recurrence.js";
|
||||||
export * from "./chargeability.js";
|
export * from "./chargeability.js";
|
||||||
|
export * from "./duplicate-check.js";
|
||||||
|
|||||||
@@ -1,78 +1,17 @@
|
|||||||
# Activity History System — Detailed Plan
|
# Duplicate Assignment Prevention — Plan
|
||||||
|
|
||||||
## Anforderungsanalyse
|
## Anforderungsanalyse
|
||||||
|
|
||||||
**Ziel:** Ein lueckenloses Aenderungsprotokoll, das jede Mutation im System erfasst und ueber UI und AI Assistant abfragbar macht. Nutzer sollen fragen koennen: "Wer hat die Buchung von Person X geaendert?" oder "Was ist in den letzten Tagen bei Projekt Y passiert?"
|
**Problem:** Ressourcen koennen demselben Projekt mehrfach zugewiesen werden mit ueberlappenden Zeitraeumen. Beispiel: Wong Wong ist zweimal fuer "Porsche Taycan Sport Film" am 15. April eingetragen.
|
||||||
|
|
||||||
**Ist-Zustand:**
|
**Ursache:** Weder die Application-Layer-Funktionen (`createAssignment`, `fillDemandRequirement`) noch die API-Router pruefen, ob dieselbe Resource bereits eine aktive Zuweisung zum selben Projekt im selben Zeitraum hat. Die bestehende `validateAvailability` prueft nur die Gesamt-Stunden (Overbooking), nicht Projekt-Duplikate.
|
||||||
- AuditLog-Modell existiert (entityType, entityId, action, userId, changes JSONB, createdAt)
|
|
||||||
- 10 von 36 Routern loggen Aenderungen (44% Abdeckung)
|
|
||||||
- userId wird nur in ~60% der Faelle erfasst
|
|
||||||
- Kein Query-Endpoint (write-only)
|
|
||||||
- Keine UI zum Anzeigen der Historie
|
|
||||||
- AI Assistant kann keine Historie abfragen
|
|
||||||
- Inkonsistente before/after Snapshots
|
|
||||||
|
|
||||||
**Soll-Zustand:**
|
**Loesung:** Duplicate-Check an **3 Stellen** einfuegen (defense-in-depth):
|
||||||
- 100% Mutation-Abdeckung ueber alle Router
|
1. **Application Layer** — `checkDuplicateAssignment()` Funktion im Engine-Paket
|
||||||
- Konsistente before/after Snapshots mit User-Attribution
|
2. **API Layer** — Validierung in den Mutations vor dem Create
|
||||||
- Query-API mit Filtern (entityType, entityId, userId, dateRange, action)
|
3. **AI Assistant** — `create_allocation` und `fill_demand` Tools pruefen vor Ausfuehrung
|
||||||
- Admin-UI: `/admin/activity-log` mit suchbarer, filterbarer Timeline
|
|
||||||
- Entity-Detail-Seiten: "History"-Tab/-Drawer auf Project/Resource/Allocation
|
|
||||||
- AI Assistant Tool: `query_change_history` fuer natuerlichsprachliche Abfragen
|
|
||||||
- Change-Source Tracking: UI vs API vs AI vs Import
|
|
||||||
|
|
||||||
---
|
**Scope:** Betrifft `packages/engine`, `packages/application`, `packages/api`, UI (Warnmeldung).
|
||||||
|
|
||||||
## Architektur-Entscheidungen
|
|
||||||
|
|
||||||
### 1. Audit Middleware statt manuelle Calls
|
|
||||||
**Entscheidung:** tRPC Middleware die automatisch vor/nach jeder Mutation auditiert
|
|
||||||
**Grund:** Eliminiert vergessene `auditLog.create()` Calls, garantiert 100% Abdeckung
|
|
||||||
**Umsetzung:** Middleware auf `protectedProcedure` die:
|
|
||||||
- Vor der Mutation: Entity-Snapshot speichert (before)
|
|
||||||
- Nach der Mutation: Neuen Snapshot speichert (after)
|
|
||||||
- Diff berechnet und AuditLog-Entry erstellt
|
|
||||||
|
|
||||||
### 2. Standardisiertes Changes-Format
|
|
||||||
```typescript
|
|
||||||
interface AuditChanges {
|
|
||||||
before?: Record<string, unknown>; // Snapshot vor der Aenderung
|
|
||||||
after?: Record<string, unknown>; // Snapshot nach der Aenderung
|
|
||||||
diff?: Record<string, { old: unknown; new: unknown }>; // Nur geaenderte Felder
|
|
||||||
metadata?: {
|
|
||||||
source: "ui" | "api" | "ai" | "import" | "cron"; // Wer hat die Aenderung ausgeloest
|
|
||||||
reason?: string; // Optionaler Kommentar
|
|
||||||
ip?: string; // Request IP (optional)
|
|
||||||
batchId?: string; // Fuer Bulk-Operationen
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Schema-Erweiterungen
|
|
||||||
```prisma
|
|
||||||
model AuditLog {
|
|
||||||
// Existierende Felder behalten
|
|
||||||
id String @id @default(cuid())
|
|
||||||
entityType String
|
|
||||||
entityId String
|
|
||||||
action AuditAction
|
|
||||||
userId String?
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
|
||||||
changes Json @db.JsonB
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// NEU: Zusaetzliche Felder
|
|
||||||
source String? // "ui" | "api" | "ai" | "import" | "cron"
|
|
||||||
entityName String? // Menschenlesbarer Name (z.B. "Porsche Taycan Project")
|
|
||||||
summary String? // Einzeiler: "Changed status from DRAFT to ACTIVE"
|
|
||||||
|
|
||||||
@@index([entityType, entityId])
|
|
||||||
@@index([userId])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([entityType, createdAt]) // NEU: Fuer sortierte Timeline-Queries
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,158 +19,150 @@ model AuditLog {
|
|||||||
|
|
||||||
| Paket | Dateien | Art der Aenderung |
|
| Paket | Dateien | Art der Aenderung |
|
||||||
|-------|---------|------------------|
|
|-------|---------|------------------|
|
||||||
| `packages/db` | `prisma/schema.prisma` | **edit** — AuditLog um source, entityName, summary erweitern |
|
| `packages/engine` | `src/allocation/duplicate-check.ts` | **create** — Pure Funktion `checkDuplicateAssignment()` |
|
||||||
| `packages/api` | `src/lib/audit.ts` | **create** — `createAuditEntry()` Helper + `auditMiddleware` |
|
| `packages/engine` | `src/index.ts` | **edit** — Export hinzufuegen |
|
||||||
| `packages/api` | `src/router/audit-log.ts` | **create** — Query-Router (list, getByEntity, getTimeline) |
|
| `packages/application` | `src/use-cases/allocation/create-assignment.ts` | **edit** — Duplicate-Check vor DB-Write |
|
||||||
| `packages/api` | `src/router/index.ts` | **edit** — auditLog Router registrieren |
|
| `packages/application` | `src/use-cases/allocation/fill-demand-requirement.ts` | **edit** — Duplicate-Check vor DB-Write |
|
||||||
| `packages/api` | `src/router/assistant-tools.ts` | **edit** — `query_change_history` Tool hinzufuegen |
|
| `packages/api` | `src/router/allocation.ts` | **edit** — Duplicate-Check in `create`, `createAssignment` Mutations |
|
||||||
| `packages/api` | 26 Router-Dateien | **edit** — fehlende audit Calls nachruesten |
|
| `packages/api` | `src/router/assistant-tools.ts` | **edit** — Check in `create_allocation`, `fill_demand` Tools |
|
||||||
| `apps/web` | `src/app/(app)/admin/activity-log/page.tsx` | **create** — Activity Log Seite |
|
| `packages/api` | `src/router/timeline.ts` | **edit** — Check in `batchShiftAllocations` (falls Shift Duplikat erzeugt) |
|
||||||
| `apps/web` | `src/components/admin/ActivityLogClient.tsx` | **create** — Suchbare Timeline |
|
| `apps/web` | `src/components/allocations/AllocationModal.tsx` | **edit** — Warning anzeigen wenn Duplikat erkannt |
|
||||||
| `apps/web` | `src/components/ui/EntityHistory.tsx` | **create** — Wiederverwendbare History-Komponente |
|
| `apps/web` | `src/components/staffing/StaffingPanel.tsx` | **edit** — Warning im Assign-Formular |
|
||||||
| `apps/web` | `src/components/layout/AppShell.tsx` | **edit** — Nav-Link fuer Activity Log |
|
| `packages/engine` | `src/__tests__/duplicate-check.test.ts` | **create** — Unit Tests |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task-Liste (atomare Schritte)
|
## Task-Liste
|
||||||
|
|
||||||
### Phase 1: Infrastruktur (Basis)
|
### Phase 1: Engine — Pure Duplicate-Check Funktion
|
||||||
|
|
||||||
- [ ] **Task 1:** Schema erweitern → `packages/db/prisma/schema.prisma`
|
- [ ] **Task 1:** Duplicate-Check Funktion erstellen → `packages/engine/src/allocation/duplicate-check.ts`
|
||||||
- `source String?`, `entityName String?`, `summary String?` hinzufuegen
|
```typescript
|
||||||
- Index `@@index([entityType, createdAt])` hinzufuegen
|
interface ExistingAssignment {
|
||||||
- `prisma db push` + `prisma generate`
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
projectId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
status: string; // nur CONFIRMED, ACTIVE, PROPOSED zaehlen
|
||||||
|
}
|
||||||
|
|
||||||
- [ ] **Task 2:** Audit Helper erstellen → `packages/api/src/lib/audit.ts`
|
interface DuplicateCheckResult {
|
||||||
- `createAuditEntry(db, params)` — standardisierter Audit-Entry-Creator
|
isDuplicate: boolean;
|
||||||
- Params: `{ entityType, entityId, entityName, action, userId, before?, after?, source?, summary? }`
|
conflictingAssignment?: ExistingAssignment;
|
||||||
- Automatische Diff-Berechnung wenn before + after vorhanden
|
message?: string; // z.B. "Resource Wong Wong is already assigned to Porsche Taycan (2026-03-01 to 2026-06-30)"
|
||||||
- Automatische Summary-Generierung aus Diff (z.B. "Updated name, status, budgetCents")
|
}
|
||||||
- `computeDiff(before, after)` — gibt nur geaenderte Felder zurueck
|
|
||||||
|
|
||||||
- [ ] **Task 3:** Query Router erstellen → `packages/api/src/router/audit-log.ts`
|
export function checkDuplicateAssignment(
|
||||||
- `list` query (controllerProcedure): paginiert, filterbar nach entityType, entityId, userId, action, dateRange, source
|
resourceId: string,
|
||||||
- `getByEntity` query: alle Entries fuer eine Entity, chronologisch
|
projectId: string,
|
||||||
- `getTimeline` query: globale Timeline aller Aenderungen, gruppierbar nach Tag
|
startDate: Date,
|
||||||
- `getActivitySummary` query: Zusammenfassung (counts pro entityType, pro action, pro User) fuer einen Zeitraum
|
endDate: Date,
|
||||||
- Registrieren in `router/index.ts`
|
existingAssignments: ExistingAssignment[],
|
||||||
|
excludeAssignmentId?: string, // fuer Updates: eigene ID ausschliessen
|
||||||
### Phase 2: Audit-Abdeckung erweitern
|
): DuplicateCheckResult
|
||||||
|
|
||||||
- [ ] **Task 4:** Kritische Router nachruesteen (Parallel-fähig, 4 Agents)
|
|
||||||
- **Agent A:** `vacation.ts` (8 Mutations), `entitlement.ts` (2), `user.ts` (9)
|
|
||||||
- **Agent B:** `client.ts` (5), `org-unit.ts` (3), `country.ts` (5), `management-level.ts` (5)
|
|
||||||
- **Agent C:** `rate-card.ts` (7), `blueprint.ts` (6), `settings.ts` (3), `calculation-rules.ts` (3)
|
|
||||||
- **Agent D:** `webhook.ts` (4), `comment.ts` (3), `notification.ts` (nur create/task), `dispo.ts` (4)
|
|
||||||
- Jeder Agent: `import { createAuditEntry } from "../lib/audit.js"` verwenden
|
|
||||||
- userId immer aus `ctx.dbUser?.id` nehmen
|
|
||||||
|
|
||||||
- [ ] **Task 5:** Bestehende Audit-Calls standardisieren
|
|
||||||
- Alle 37 existierenden `auditLog.create` Calls auf `createAuditEntry()` Helper umstellen
|
|
||||||
- userId konsistent aus Context nehmen
|
|
||||||
- before/after Snapshots wo fehlend ergaenzen
|
|
||||||
- `source: "ui"` als Default setzen
|
|
||||||
|
|
||||||
### Phase 3: UI
|
|
||||||
|
|
||||||
- [ ] **Task 6:** Activity Log Admin-Seite → `ActivityLogClient.tsx`
|
|
||||||
- Globale, suchbare Timeline aller Aenderungen
|
|
||||||
- Filter: Entity-Typ (Project/Resource/Allocation/...), User, Action, Datum
|
|
||||||
- Jeder Eintrag zeigt: Zeitstempel, User (Avatar + Name), Entity (verlinkt), Action-Badge, Summary
|
|
||||||
- Expandierbares Detail: before/after Diff-View (JSON oder tabellarisch)
|
|
||||||
- Pagination (50 pro Seite)
|
|
||||||
- Sidebar Nav-Link unter Admin: "Activity Log"
|
|
||||||
|
|
||||||
- [ ] **Task 7:** Entity History Komponente → `EntityHistory.tsx`
|
|
||||||
- Wiederverwendbar fuer Project/Resource/Allocation Detail-Seiten
|
|
||||||
- Props: `entityType: string, entityId: string`
|
|
||||||
- Chronologische Liste der Aenderungen fuer diese Entity
|
|
||||||
- Kompakte Darstellung: User, Action, Summary, Zeitstempel
|
|
||||||
- Optional: als Tab oder Drawer auf Detail-Seiten einbinden
|
|
||||||
|
|
||||||
- [ ] **Task 8:** History-Tab auf Detail-Seiten integrieren
|
|
||||||
- `/projects/[id]` → "History" Tab mit `<EntityHistory entityType="project" entityId={id} />`
|
|
||||||
- `/resources/[id]` → "History" Tab
|
|
||||||
- Optional spaeter: Allocation Detail, Estimate Detail
|
|
||||||
|
|
||||||
### Phase 4: AI Assistant Integration
|
|
||||||
|
|
||||||
- [ ] **Task 9:** AI Tool erstellen → `assistant-tools.ts`
|
|
||||||
- `query_change_history` Tool:
|
|
||||||
- Input: `{ entityType?, entityId?, userId?, search?, daysBack?, limit? }`
|
|
||||||
- Ruft `auditLog.list` mit Filtern auf
|
|
||||||
- Formatiert Ergebnis menschenlesbar:
|
|
||||||
```
|
```
|
||||||
[2026-03-22 14:30] admin@planarchy.dev UPDATED Project "Porsche Taycan"
|
- Prueft: Gibt es in `existingAssignments` eine Zuweisung mit **gleicher resourceId + gleicher projectId** deren Zeitraum sich mit [startDate, endDate] ueberschneidet?
|
||||||
→ Changed status from DRAFT to ACTIVE
|
- Ignoriert: CANCELLED Status, eigene ID (bei Updates)
|
||||||
→ Changed budgetCents from 500000 to 750000
|
- Overlap-Logik: `existingStart <= newEnd && existingEnd >= newStart`
|
||||||
```
|
|
||||||
- `get_entity_timeline` Tool:
|
- [ ] **Task 2:** Unit Tests → `packages/engine/src/__tests__/duplicate-check.test.ts`
|
||||||
- Input: `{ entityType, entityId, limit? }`
|
- Kein Duplikat: verschiedene Projekte
|
||||||
- Gibt chronologische History fuer eine Entity zurueck
|
- Kein Duplikat: gleicher Projekt, aber nicht ueberlappend (vor/nach)
|
||||||
- Beide Tools mit Permission `VIEW_PROJECTS` oder `VIEW_RESOURCES` je nach entityType
|
- Duplikat: gleicher Projekt, vollstaendig ueberlappend
|
||||||
|
- Duplikat: gleicher Projekt, teilweise ueberlappend
|
||||||
|
- Kein Duplikat: gleicher Projekt, aber CANCELLED
|
||||||
|
- Kein Duplikat: Update der eigenen Zuweisung (excludeAssignmentId)
|
||||||
|
|
||||||
|
- [ ] **Task 3:** Export → `packages/engine/src/index.ts`
|
||||||
|
|
||||||
|
### Phase 2: Application Layer — Integration in Create-Flows
|
||||||
|
|
||||||
|
- [ ] **Task 4:** `createAssignment` erweitern → `packages/application/src/use-cases/allocation/create-assignment.ts`
|
||||||
|
- Nach dem Laden von `existingBookings` (Zeile 101-106): `checkDuplicateAssignment()` aufrufen
|
||||||
|
- Bei `isDuplicate: true`: `throw new TRPCError({ code: "CONFLICT", message: result.message })`
|
||||||
|
- Bestehende Bookings bereits vorhanden — nur filtern auf gleichen `projectId`
|
||||||
|
|
||||||
|
- [ ] **Task 5:** `fillDemandRequirement` erweitern → `packages/application/src/use-cases/allocation/fill-demand-requirement.ts`
|
||||||
|
- Vor dem Assignment-Create: gleicher Check
|
||||||
|
- DemandRequirement hat bereits `projectId` — diesen nutzen
|
||||||
|
|
||||||
|
### Phase 3: API + AI Assistant
|
||||||
|
|
||||||
|
- [ ] **Task 6:** AI Assistant Tools erweitern → `packages/api/src/router/assistant-tools.ts`
|
||||||
|
- `create_allocation` Tool: Vor `createAssignment` Call, bestehende Assignments pruefen
|
||||||
|
- `fill_demand` Tool: Gleicher Check
|
||||||
|
- Bei Duplikat: Tool gibt klare Fehlermeldung zurueck statt Exception:
|
||||||
|
`"Cannot assign: Wong Wong is already assigned to Porsche Taycan Sport Film from 2026-01-15 to 2026-06-30. Use update_allocation_status to modify the existing assignment instead."`
|
||||||
|
|
||||||
|
### Phase 4: UI Warnungen
|
||||||
|
|
||||||
|
- [ ] **Task 7:** AllocationModal Warning → `apps/web/src/components/allocations/AllocationModal.tsx`
|
||||||
|
- Wenn User Resource + Project + Dates auswaehlt: pruefen ob Duplikat existiert
|
||||||
|
- Query: `trpc.allocation.listView({ projectId })` — bereits geladen
|
||||||
|
- Gelbe Warning-Box: "This resource is already assigned to this project from X to Y"
|
||||||
|
- Submit-Button nicht blockieren (Warning, nicht Error) — User kann bewusst doppelt buchen
|
||||||
|
|
||||||
|
- [ ] **Task 8:** StaffingPanel Assign Warning → `apps/web/src/components/staffing/StaffingPanel.tsx`
|
||||||
|
- Im AssignForm: nach Project-Auswahl pruefen ob Resource bereits dort zugewiesen
|
||||||
|
- Gleiche Warning-Box wie AllocationModal
|
||||||
|
|
||||||
|
### Phase 5: Bereinigung bestehender Duplikate
|
||||||
|
|
||||||
|
- [ ] **Task 9:** Cleanup-Script → `packages/db/scripts/deduplicate-assignments.ts`
|
||||||
|
- Findet alle Duplikate: gleiche resourceId + projectId mit ueberlappenden Dates
|
||||||
|
- Merged sie: behaelt die aeltere Zuweisung, entfernt die neuere (oder merged Zeitraeume)
|
||||||
|
- Dry-run Modus: zeigt was geaendert wuerde ohne zu aendern
|
||||||
|
- Kann via `pnpm --filter @planarchy/db exec tsx scripts/deduplicate-assignments.ts` ausgefuehrt werden
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abhaengigkeiten
|
## Abhaengigkeiten
|
||||||
|
|
||||||
```
|
```
|
||||||
Task 1 (Schema) ──► Task 2 (Helper) ──► Task 3 (Query Router)
|
Task 1 (Engine Funktion) → Task 2 (Tests) → Task 3 (Export)
|
||||||
└──► Task 4a-d (Parallel: 26 Router)
|
↘
|
||||||
└──► Task 5 (Bestehende Calls)
|
Task 3 → Task 4 + Task 5 (parallel, Application Layer)
|
||||||
Task 3 ──► Task 6 (UI: Activity Log)
|
Task 3 → Task 6 (AI Assistant)
|
||||||
──► Task 7 (UI: Entity History)
|
Task 3 → Task 7 + Task 8 (parallel, UI Warnungen)
|
||||||
──► Task 9 (AI Tools)
|
Task 9 (Cleanup) ist unabhaengig, kann jederzeit ausgefuehrt werden
|
||||||
Task 7 ──► Task 8 (Integration in Detail-Seiten)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Tasks 4a-d koennen **parallel** ausgefuehrt werden (unterschiedliche Dateien)
|
- Tasks 4+5 koennen **parallel** (verschiedene Dateien)
|
||||||
- Tasks 6, 7, 9 koennen **parallel** nach Task 3
|
- Tasks 6, 7, 8 koennen **parallel** (verschiedene Dateien)
|
||||||
- Task 8 benoetigt Task 7
|
- Task 9 sollte **nach** den anderen Tasks laufen (damit neue Duplikate verhindert werden)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] `pnpm test:unit` laeuft gruen (inkl. neue duplicate-check Tests)
|
||||||
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
||||||
- [ ] `pnpm test:unit` — alle Tests gruen
|
- [ ] **API:** `createAssignment` wirft CONFLICT wenn Resource bereits zum gleichen Projekt zugewiesen
|
||||||
- [ ] **100% Mutation-Abdeckung:** Jede Mutation in jedem Router erzeugt einen AuditLog-Entry
|
- [ ] **API:** `fillDemandRequirement` wirft CONFLICT bei Duplikat
|
||||||
- [ ] **Konsistente userId:** Jeder Entry hat den ausfuehrenden User
|
- [ ] **AI Assistant:** `create_allocation` gibt klare Fehlermeldung bei Duplikat
|
||||||
- [ ] **before/after:** UPDATE-Actions haben immer before + after Snapshots
|
- [ ] **AI Assistant:** `fill_demand` gibt klare Fehlermeldung bei Duplikat
|
||||||
- [ ] **Query-API:** `trpc.auditLog.list` liefert paginierte, filterbare Ergebnisse
|
- [ ] **UI:** AllocationModal zeigt gelbe Warning bei erkanntem Duplikat
|
||||||
- [ ] **Admin UI:** `/admin/activity-log` zeigt globale Timeline mit Filtern
|
- [ ] **UI:** StaffingPanel AssignForm zeigt Warning bei Duplikat
|
||||||
- [ ] **Entity History:** Project/Resource Detail-Seiten zeigen Aenderungs-Historie
|
- [ ] **Cleanup:** Bestehende Duplikate in der DB bereinigt
|
||||||
- [ ] **AI Assistant:** "Wer hat die Buchung von Person X geaendert?" wird korrekt beantwortet
|
- [ ] **Timeline:** Wong Wong hat keine doppelten Strips mehr am 15. April
|
||||||
- [ ] **AI Assistant:** "Was ist bei Projekt Y in den letzten Tagen passiert?" liefert Ergebnis
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Risiken & offene Fragen
|
## Risiken & offene Fragen
|
||||||
|
|
||||||
### Risiken
|
### Risiken
|
||||||
- **Performance:** Audit-Middleware auf jeder Mutation koennte Latenz erhoehen
|
- **False Positives:** Legitime Doppelbuchungen (z.B. verschiedene Rollen auf demselben Projekt) werden blockiert
|
||||||
→ Mitigation: Audit-Writes fire-and-forget (non-blocking), oder nach Response
|
→ Mitigation: Warning im UI, Error nur im API — User kann override-en, AI Assistant gibt Hinweis
|
||||||
- **Storage:** JSONB Snapshots koennen gross werden
|
- **Race Condition:** Zwei gleichzeitige Requests koennten beide den Check passieren
|
||||||
→ Mitigation: Nur geaenderte Felder in `diff` speichern, nicht volle Snapshots
|
→ Mitigation: DB-Level unique constraint ist nicht moeglich (flexible Zeitraeume), aber Transaction-Isolation schuetzt
|
||||||
- **Migration:** 37 bestehende Calls umstellen birgt Regressions-Risiko
|
|
||||||
→ Mitigation: Schrittweise, mit Tests pro Router
|
|
||||||
|
|
||||||
### Offene Fragen
|
### Offene Fragen
|
||||||
1. **Retention:** Wie lange sollen Audit-Logs aufbewahrt werden? (Vorschlag: 2 Jahre)
|
1. **Soll der Check nur warnen oder blockieren?**
|
||||||
2. **Granularitaet:** Sollen READ-Zugriffe geloggt werden? (Vorschlag: Nein, nur Mutations)
|
→ Empfehlung: API blockiert (CONFLICT), UI warnt (gelbe Box, Submit moeglich), AI blockiert
|
||||||
3. **DSGVO:** Muessen Audit-Logs bei User-Loeschung anonymisiert werden?
|
2. **Was passiert bei Updates/Shifts?**
|
||||||
4. **Notifications:** Sollen bestimmte Aenderungen (z.B. Projekt-Status) automatisch Notifications ausloesen?
|
→ excludeAssignmentId nutzen um die eigene Zuweisung auszuschliessen
|
||||||
5. **Middleware vs Manual:** Soll der Audit-Helper manuell oder als tRPC-Middleware eingebaut werden?
|
3. **Welche Status zaehlen als "aktiv"?**
|
||||||
→ Empfehlung: Manuell mit Helper-Funktion, da Middleware die Entity-Snapshots nicht automatisch kennt
|
→ CONFIRMED, ACTIVE, PROPOSED — nicht CANCELLED, DRAFT
|
||||||
|
4. **Sollen verschiedene Rollen erlaubt sein?**
|
||||||
---
|
→ Vorschlag: Ja, aber mit Warning. Gleiche Rolle + gleiches Projekt = Block, verschiedene Rolle = Warning only
|
||||||
|
|
||||||
## Geschaetzter Aufwand
|
|
||||||
|
|
||||||
| Phase | Aufwand | Parallelisierbar |
|
|
||||||
|-------|---------|-----------------|
|
|
||||||
| Phase 1: Infrastruktur | 1 Tag | Nein (sequenziell) |
|
|
||||||
| Phase 2: Audit-Abdeckung | 1 Tag | Ja (4 Agents parallel) |
|
|
||||||
| Phase 3: UI | 1 Tag | Ja (2 Agents parallel) |
|
|
||||||
| Phase 4: AI Integration | 0.5 Tag | Ja (mit Phase 3) |
|
|
||||||
| **Gesamt** | **~3.5 Tage** | |
|
|
||||||
|
|||||||
Reference in New Issue
Block a user