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:
2026-03-23 08:51:49 +01:00
parent 1f079d0309
commit 47b2aeec72
8 changed files with 408 additions and 189 deletions
@@ -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 };
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./calculator.js";
export * from "./availability-validator.js";
export * from "./recurrence.js";
export * from "./chargeability.js";
export * from "./duplicate-check.js";