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
@@ -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 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 availabilityWindows = existingBookings.map((booking) => ({
startDate: booking.startDate,
@@ -1,8 +1,10 @@
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
import { checkDuplicateAssignment } from "@planarchy/engine";
import { TRPCError } from "@trpc/server";
import { type AssignmentWithRelations } from "./create-assignment.js";
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
import { listAssignmentBookings } from "./list-assignment-bookings.js";
export interface FillDemandRequirementResult {
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);
}