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,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,
};
});
}