refactor(api): extract timeline allocation batch shift support

This commit is contained in:
2026-03-31 16:04:37 +02:00
parent 803de725ad
commit b17110edaf
3 changed files with 226 additions and 60 deletions
@@ -0,0 +1,146 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
const {
findAllocationEntryMock,
updateAllocationEntryMock,
} = vi.hoisted(() => ({
findAllocationEntryMock: vi.fn(),
updateAllocationEntryMock: vi.fn(),
}));
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
findAllocationEntry: findAllocationEntryMock,
updateAllocationEntry: updateAllocationEntryMock,
};
});
import { applyTimelineBatchAllocationShift } from "../router/timeline-allocation-shift-support.js";
describe("timeline allocation shift support", () => {
it("returns an empty result when the shift delta is zero", async () => {
const db = {
$transaction: vi.fn(),
};
await expect(applyTimelineBatchAllocationShift({
db: db as never,
allocationIds: ["allocation_1"],
daysDelta: 0,
mode: "move",
})).resolves.toEqual([]);
expect(findAllocationEntryMock).not.toHaveBeenCalled();
expect(db.$transaction).not.toHaveBeenCalled();
});
it("throws when no allocations can be resolved", async () => {
findAllocationEntryMock.mockResolvedValue(null);
await expect(applyTimelineBatchAllocationShift({
db: {
$transaction: vi.fn(),
} as never,
allocationIds: ["missing_1", "missing_2"],
daysDelta: 2,
mode: "move",
})).rejects.toThrowError(new TRPCError({
code: "NOT_FOUND",
message: "No allocations found",
}));
});
it("updates resolved allocations and writes a batch audit record", async () => {
findAllocationEntryMock
.mockResolvedValueOnce({
entry: {
id: "allocation_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
},
})
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
entry: {
id: "allocation_3",
startDate: new Date("2026-04-10T00:00:00.000Z"),
endDate: new Date("2026-04-12T00:00:00.000Z"),
},
});
updateAllocationEntryMock
.mockResolvedValueOnce({
allocation: { id: "allocation_1", projectId: "project_1", resourceId: "resource_1" },
})
.mockResolvedValueOnce({
allocation: { id: "allocation_3", projectId: "project_2", resourceId: "resource_2" },
});
const tx = {
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
const db = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
};
const result = await applyTimelineBatchAllocationShift({
db: db as never,
allocationIds: ["allocation_1", "missing_2", "allocation_3"],
daysDelta: 2,
mode: "move",
});
expect(result).toEqual([
{ id: "allocation_1", projectId: "project_1", resourceId: "resource_1" },
{ id: "allocation_3", projectId: "project_2", resourceId: "resource_2" },
]);
expect(updateAllocationEntryMock).toHaveBeenNthCalledWith(
1,
tx,
{
id: "allocation_1",
demandRequirementUpdate: {
startDate: new Date("2026-04-03T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
},
assignmentUpdate: {
startDate: new Date("2026-04-03T00:00:00.000Z"),
endDate: new Date("2026-04-07T00:00:00.000Z"),
},
},
);
expect(updateAllocationEntryMock).toHaveBeenNthCalledWith(
2,
tx,
{
id: "allocation_3",
demandRequirementUpdate: {
startDate: new Date("2026-04-12T00:00:00.000Z"),
endDate: new Date("2026-04-14T00:00:00.000Z"),
},
assignmentUpdate: {
startDate: new Date("2026-04-12T00:00:00.000Z"),
endDate: new Date("2026-04-14T00:00:00.000Z"),
},
},
);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: {
entityType: "Allocation",
entityId: "allocation_1,missing_2,allocation_3",
action: "UPDATE",
changes: {
operation: "batchShift",
mode: "move",
daysDelta: 2,
count: 2,
},
},
});
});
});
@@ -1,7 +1,6 @@
import { import {
buildSplitAllocationReadModel, buildSplitAllocationReadModel,
createAssignment, createAssignment,
findAllocationEntry,
loadAllocationEntry, loadAllocationEntry,
updateAllocationEntry, updateAllocationEntry,
} from "@capakraken/application"; } from "@capakraken/application";
@@ -25,11 +24,10 @@ import {
buildTimelineAllocationEntryUpdate, buildTimelineAllocationEntryUpdate,
buildTimelineAllocationMetadata, buildTimelineAllocationMetadata,
buildTimelineAllocationUpdateAuditChanges, buildTimelineAllocationUpdateAuditChanges,
buildTimelineBatchShiftAuditChanges,
buildTimelineQuickAssignAssignmentInput, buildTimelineQuickAssignAssignmentInput,
shiftTimelineAllocationWindow,
validateTimelineAllocationDateRanges, validateTimelineAllocationDateRanges,
} from "./timeline-allocation-mutation-support.js"; } from "./timeline-allocation-mutation-support.js";
import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js";
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
export const timelineAllocationMutationProcedures = { export const timelineAllocationMutationProcedures = {
@@ -227,65 +225,13 @@ export const timelineAllocationMutationProcedures = {
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.daysDelta === 0) { const results = await applyTimelineBatchAllocationShift({
return { count: 0 }; db: ctx.db as PrismaClient,
} allocationIds: input.allocationIds,
const entries = await Promise.all(
input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)),
);
const resolved = entries.filter(
(entry): entry is NonNullable<typeof entry> => entry !== null,
);
if (resolved.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
}
const results = await ctx.db.$transaction(async (tx) => {
const updated = [];
for (const entry of resolved) {
const existing = entry.entry;
const shiftedWindow = shiftTimelineAllocationWindow({
startDate: existing.startDate,
endDate: existing.endDate,
daysDelta: input.daysDelta, daysDelta: input.daysDelta,
mode: input.mode, mode: input.mode,
}); });
const result = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: existing.id,
demandRequirementUpdate: {
startDate: shiftedWindow.startDate,
endDate: shiftedWindow.endDate,
},
assignmentUpdate: {
startDate: shiftedWindow.startDate,
endDate: shiftedWindow.endDate,
},
},
);
updated.push(result.allocation);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationIds.join(","),
action: "UPDATE",
changes: buildTimelineBatchShiftAuditChanges({
mode: input.mode,
daysDelta: input.daysDelta,
count: resolved.length,
}),
},
});
return updated;
});
for (const allocation of results) { for (const allocation of results) {
emitAllocationUpdated({ emitAllocationUpdated({
id: allocation.id, id: allocation.id,
@@ -0,0 +1,74 @@
import { findAllocationEntry, updateAllocationEntry } from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import {
buildTimelineBatchShiftAuditChanges,
shiftTimelineAllocationWindow,
type TimelineBatchShiftMode,
} from "./timeline-allocation-mutation-support.js";
export async function applyTimelineBatchAllocationShift(input: {
db: PrismaClient;
allocationIds: string[];
daysDelta: number;
mode: TimelineBatchShiftMode;
}) {
if (input.daysDelta === 0) {
return [];
}
const entries = await Promise.all(
input.allocationIds.map((allocationId) => findAllocationEntry(input.db, allocationId)),
);
const resolved = entries.filter(
(entry): entry is NonNullable<typeof entry> => entry !== null,
);
if (resolved.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
}
return input.db.$transaction(async (tx) => {
const updated = [];
for (const entry of resolved) {
const existing = entry.entry;
const shiftedWindow = shiftTimelineAllocationWindow({
startDate: existing.startDate,
endDate: existing.endDate,
daysDelta: input.daysDelta,
mode: input.mode,
});
const result = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: existing.id,
demandRequirementUpdate: {
startDate: shiftedWindow.startDate,
endDate: shiftedWindow.endDate,
},
assignmentUpdate: {
startDate: shiftedWindow.startDate,
endDate: shiftedWindow.endDate,
},
},
);
updated.push(result.allocation);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationIds.join(","),
action: "UPDATE",
changes: buildTimelineBatchShiftAuditChanges({
mode: input.mode,
daysDelta: input.daysDelta,
count: resolved.length,
}),
},
});
return updated;
});
}