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:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 type { PermissionKey } from "@planarchy/shared";
|
||||
import { parseTaskAction } from "@planarchy/shared";
|
||||
@@ -2195,6 +2195,16 @@ const executors = {
|
||||
const startDate = new Date(params.startDate);
|
||||
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
|
||||
const existing = await ctx.db.assignment.findUnique({
|
||||
where: {
|
||||
@@ -3002,6 +3012,16 @@ const executors = {
|
||||
}
|
||||
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 dailyCostCents = Math.round(resource.lcrCents * demand.hoursPerDay);
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -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 "./recurrence.js";
|
||||
export * from "./chargeability.js";
|
||||
export * from "./duplicate-check.js";
|
||||
|
||||
Reference in New Issue
Block a user