feat(api): add timeline allocation fragment support
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadAllocationEntryMock,
|
||||||
|
updateAssignmentMock,
|
||||||
|
createAssignmentMock,
|
||||||
|
deleteAllocationEntryMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
loadAllocationEntryMock: vi.fn(),
|
||||||
|
updateAssignmentMock: vi.fn(),
|
||||||
|
createAssignmentMock: vi.fn(),
|
||||||
|
deleteAllocationEntryMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadAllocationEntry: loadAllocationEntryMock,
|
||||||
|
updateAssignment: updateAssignmentMock,
|
||||||
|
createAssignment: createAssignmentMock,
|
||||||
|
deleteAllocationEntry: deleteAllocationEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
|
||||||
|
|
||||||
|
function createResolvedAssignment() {
|
||||||
|
return {
|
||||||
|
kind: "assignment" as const,
|
||||||
|
entry: {
|
||||||
|
id: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
id: "assignment_1",
|
||||||
|
demandRequirementId: null,
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
percentage: 100,
|
||||||
|
role: "Artist",
|
||||||
|
roleId: "role_1",
|
||||||
|
dailyCostCents: 80000,
|
||||||
|
status: "ACTIVE",
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("timeline allocation fragment support", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits an assignment into left and right fragments around the carved range", async () => {
|
||||||
|
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||||
|
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
|
||||||
|
createAssignmentMock.mockResolvedValue({ id: "assignment_2" });
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await carveTimelineAllocationRange({
|
||||||
|
db: db as never,
|
||||||
|
allocationId: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
action: "split",
|
||||||
|
allocationGroupId: expect.any(String),
|
||||||
|
updatedAllocationIds: ["assignment_1"],
|
||||||
|
createdAllocationIds: ["assignment_2"],
|
||||||
|
deletedAllocationIds: [],
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
});
|
||||||
|
expect(updateAssignmentMock).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
"assignment_1",
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createAssignmentMock).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks the existing assignment when carving from the start edge", async () => {
|
||||||
|
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||||
|
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await carveTimelineAllocationRange({
|
||||||
|
db: db as never,
|
||||||
|
allocationId: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("updated");
|
||||||
|
expect(createAssignmentMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateAssignmentMock).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
"assignment_1",
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the assignment when the carved range covers the full interval", async () => {
|
||||||
|
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await carveTimelineAllocationRange({
|
||||||
|
db: db as never,
|
||||||
|
allocationId: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("deleted");
|
||||||
|
expect(deleteAllocationEntryMock).toHaveBeenCalledWith(
|
||||||
|
db,
|
||||||
|
expect.objectContaining({
|
||||||
|
assignment: expect.objectContaining({ id: "assignment_1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects carve ranges outside the allocation interval", async () => {
|
||||||
|
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
carveTimelineAllocationRange({
|
||||||
|
db: { $transaction: vi.fn() } as never,
|
||||||
|
allocationId: "assignment_1",
|
||||||
|
startDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError(
|
||||||
|
new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The requested carve range must be fully inside the existing allocation.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
createAssignment,
|
||||||
|
deleteAllocationEntry,
|
||||||
|
loadAllocationEntry,
|
||||||
|
updateAssignment,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
const ALLOCATION_GROUP_ID_KEY = "allocationGroupId";
|
||||||
|
const ALLOCATION_ORIGIN_ID_KEY = "allocationOriginId";
|
||||||
|
const ALLOCATION_FRAGMENTED_KEY = "isFragmentedAllocation";
|
||||||
|
|
||||||
|
function toUtcCalendarDate(value: Date): Date {
|
||||||
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = toUtcCalendarDate(date);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSharedAllocationStatus(status: unknown): AllocationStatus {
|
||||||
|
switch (status) {
|
||||||
|
case "PROPOSED":
|
||||||
|
return AllocationStatus.PROPOSED;
|
||||||
|
case "CONFIRMED":
|
||||||
|
return AllocationStatus.CONFIRMED;
|
||||||
|
case "ACTIVE":
|
||||||
|
return AllocationStatus.ACTIVE;
|
||||||
|
case "COMPLETED":
|
||||||
|
return AllocationStatus.COMPLETED;
|
||||||
|
case "CANCELLED":
|
||||||
|
return AllocationStatus.CANCELLED;
|
||||||
|
default:
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Unsupported allocation status "${String(status)}" for fragmented assignment creation.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFragmentMetadata(metadata: unknown, allocationId: string) {
|
||||||
|
const record =
|
||||||
|
metadata && typeof metadata === "object" && !Array.isArray(metadata)
|
||||||
|
? (metadata as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const groupId =
|
||||||
|
typeof record[ALLOCATION_GROUP_ID_KEY] === "string" && record[ALLOCATION_GROUP_ID_KEY].length > 0
|
||||||
|
? (record[ALLOCATION_GROUP_ID_KEY] as string)
|
||||||
|
: crypto.randomUUID();
|
||||||
|
const originId =
|
||||||
|
typeof record[ALLOCATION_ORIGIN_ID_KEY] === "string" && record[ALLOCATION_ORIGIN_ID_KEY].length > 0
|
||||||
|
? (record[ALLOCATION_ORIGIN_ID_KEY] as string)
|
||||||
|
: allocationId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
metadata: {
|
||||||
|
...record,
|
||||||
|
[ALLOCATION_GROUP_ID_KEY]: groupId,
|
||||||
|
[ALLOCATION_ORIGIN_ID_KEY]: originId,
|
||||||
|
[ALLOCATION_FRAGMENTED_KEY]: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineAllocationCarveResult {
|
||||||
|
action: "updated" | "split" | "deleted";
|
||||||
|
allocationGroupId: string;
|
||||||
|
updatedAllocationIds: string[];
|
||||||
|
createdAllocationIds: string[];
|
||||||
|
deletedAllocationIds: string[];
|
||||||
|
projectId: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function carveTimelineAllocationRange(input: {
|
||||||
|
db: PrismaClient;
|
||||||
|
allocationId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}): Promise<TimelineAllocationCarveResult> {
|
||||||
|
const resolved = await loadAllocationEntry(input.db, input.allocationId);
|
||||||
|
if (resolved.kind !== "assignment") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Only staffed assignments can currently be split into fragments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const carveStart = toUtcCalendarDate(input.startDate);
|
||||||
|
const carveEnd = toUtcCalendarDate(input.endDate);
|
||||||
|
if (carveEnd < carveStart) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Carve end date must be on or after the carve start date.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = resolved.assignment;
|
||||||
|
const assignmentStart = toUtcCalendarDate(assignment.startDate);
|
||||||
|
const assignmentEnd = toUtcCalendarDate(assignment.endDate);
|
||||||
|
if (carveStart < assignmentStart || carveEnd > assignmentEnd) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The requested carve range must be fully inside the existing allocation.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id);
|
||||||
|
const leftEnd = addDays(carveStart, -1);
|
||||||
|
const rightStart = addDays(carveEnd, 1);
|
||||||
|
const hasLeftFragment = leftEnd >= assignmentStart;
|
||||||
|
const hasRightFragment = rightStart <= assignmentEnd;
|
||||||
|
|
||||||
|
return input.db.$transaction(async (tx) => {
|
||||||
|
if (!hasLeftFragment && !hasRightFragment) {
|
||||||
|
await deleteAllocationEntry(
|
||||||
|
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
|
||||||
|
resolved,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "deleted" as const,
|
||||||
|
allocationGroupId: groupId,
|
||||||
|
updatedAllocationIds: [],
|
||||||
|
createdAllocationIds: [],
|
||||||
|
deletedAllocationIds: [assignment.id],
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAllocationIds: string[] = [];
|
||||||
|
const createdAllocationIds: string[] = [];
|
||||||
|
|
||||||
|
const keptStart = hasLeftFragment ? assignmentStart : rightStart;
|
||||||
|
const keptEnd = hasLeftFragment ? leftEnd : assignmentEnd;
|
||||||
|
|
||||||
|
const updated = await updateAssignment(
|
||||||
|
tx as unknown as Parameters<typeof updateAssignment>[0],
|
||||||
|
assignment.id,
|
||||||
|
{
|
||||||
|
startDate: keptStart,
|
||||||
|
endDate: keptEnd,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updatedAllocationIds.push(updated.id);
|
||||||
|
|
||||||
|
if (hasLeftFragment && hasRightFragment) {
|
||||||
|
const created = await createAssignment(
|
||||||
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
|
{
|
||||||
|
demandRequirementId: assignment.demandRequirementId ?? undefined,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
startDate: rightStart,
|
||||||
|
endDate: assignmentEnd,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
percentage: assignment.percentage,
|
||||||
|
role: assignment.role ?? undefined,
|
||||||
|
roleId: assignment.roleId ?? undefined,
|
||||||
|
dailyCostCents: assignment.dailyCostCents,
|
||||||
|
status: toSharedAllocationStatus(assignment.status),
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
createdAllocationIds.push(created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: hasLeftFragment && hasRightFragment ? "split" as const : "updated" as const,
|
||||||
|
allocationGroupId: groupId,
|
||||||
|
updatedAllocationIds,
|
||||||
|
createdAllocationIds,
|
||||||
|
deletedAllocationIds: [],
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user