feat(api): add timeline allocation fragment support

This commit is contained in:
2026-03-31 23:46:23 +02:00
parent f2d511ebc8
commit 9553aa0544
2 changed files with 360 additions and 0 deletions
@@ -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,
};
});
}