refactor(api): extract timeline allocation inline support

This commit is contained in:
2026-03-31 16:49:36 +02:00
parent b17110edaf
commit ad4b334f20
3 changed files with 300 additions and 84 deletions
@@ -0,0 +1,193 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
loadAllocationEntryMock,
updateAllocationEntryMock,
calculateTimelineAllocationDailyCostMock,
} = vi.hoisted(() => ({
loadAllocationEntryMock: vi.fn(),
updateAllocationEntryMock: vi.fn(),
calculateTimelineAllocationDailyCostMock: vi.fn(),
}));
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
loadAllocationEntry: loadAllocationEntryMock,
updateAllocationEntry: updateAllocationEntryMock,
};
});
vi.mock("../router/timeline-cost-support.js", () => ({
calculateTimelineAllocationDailyCost: calculateTimelineAllocationDailyCostMock,
}));
import { applyTimelineInlineAllocationUpdate } from "../router/timeline-allocation-inline-support.js";
describe("timeline allocation inline support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updates staffed allocations with recalculated daily cost and audit changes", async () => {
loadAllocationEntryMock.mockResolvedValue({
entry: {
id: "allocation_1",
hoursPerDay: 4,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
metadata: {},
},
resourceId: "resource_1",
});
calculateTimelineAllocationDailyCostMock.mockResolvedValue(54000);
updateAllocationEntryMock.mockResolvedValue({
allocation: {
id: "allocation_1",
projectId: "project_1",
resourceId: "resource_1",
},
});
const tx = {
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
}),
},
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
};
const result = await applyTimelineInlineAllocationUpdate({
db: db as never,
allocationId: "allocation_1",
hoursPerDay: 6,
endDate: new Date("2026-04-06T00:00:00.000Z"),
includeSaturday: true,
role: "Architect",
});
expect(result).toEqual({
id: "allocation_1",
projectId: "project_1",
resourceId: "resource_1",
});
expect(calculateTimelineAllocationDailyCostMock).toHaveBeenCalledWith(
expect.objectContaining({
db,
resourceId: "resource_1",
lcrCents: 5000,
hoursPerDay: 6,
includeSaturday: true,
}),
);
expect(updateAllocationEntryMock).toHaveBeenCalledWith(
tx,
{
id: "allocation_1",
demandRequirementUpdate: {
hoursPerDay: 6,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-06T00:00:00.000Z"),
metadata: { includeSaturday: true },
role: "Architect",
},
assignmentUpdate: {
hoursPerDay: 6,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-06T00:00:00.000Z"),
dailyCostCents: 54000,
metadata: { includeSaturday: true },
role: "Architect",
},
},
);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
entityType: "Allocation",
entityId: "allocation_1",
action: "UPDATE",
}),
});
});
it("updates placeholder allocations without recalculating cost", async () => {
loadAllocationEntryMock.mockResolvedValue({
entry: {
id: "demand_1",
hoursPerDay: 4,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
metadata: {},
},
resourceId: null,
});
updateAllocationEntryMock.mockResolvedValue({
allocation: {
id: "demand_1",
projectId: "project_1",
resourceId: null,
},
});
const tx = {
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
const db = {
resource: {
findUnique: vi.fn(),
},
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
};
await expect(applyTimelineInlineAllocationUpdate({
db: db as never,
allocationId: "demand_1",
hoursPerDay: 5,
})).resolves.toEqual({
id: "demand_1",
projectId: "project_1",
resourceId: null,
});
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(calculateTimelineAllocationDailyCostMock).not.toHaveBeenCalled();
});
it("throws when a staffed allocation references a missing resource", async () => {
loadAllocationEntryMock.mockResolvedValue({
entry: {
id: "allocation_1",
hoursPerDay: 4,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
metadata: {},
},
resourceId: "resource_1",
});
await expect(applyTimelineInlineAllocationUpdate({
db: {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
$transaction: vi.fn(),
} as never,
allocationId: "allocation_1",
})).rejects.toThrowError(new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
}));
});
});
@@ -0,0 +1,99 @@
import { loadAllocationEntry, updateAllocationEntry } from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db";
import type { RecurrencePattern, WeekdayAvailability } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import {
assertTimelineDateRangeValid,
buildTimelineAllocationEntryUpdate,
buildTimelineAllocationMetadata,
buildTimelineAllocationUpdateAuditChanges,
} from "./timeline-allocation-mutation-support.js";
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
export async function applyTimelineInlineAllocationUpdate(input: {
db: PrismaClient;
allocationId: string;
hoursPerDay?: number | undefined;
startDate?: Date | undefined;
endDate?: Date | undefined;
includeSaturday?: boolean | undefined;
role?: string | undefined;
}) {
const resolved = await loadAllocationEntry(input.db, input.allocationId);
const existing = resolved.entry;
const existingResource = resolved.resourceId
? await input.db.resource.findUnique({
where: { id: resolved.resourceId },
select: { id: true, lcrCents: true, availability: true },
})
: null;
const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
const newStartDate = input.startDate ?? existing.startDate;
const newEndDate = input.endDate ?? existing.endDate;
assertTimelineDateRangeValid(newStartDate, newEndDate);
const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({
existingMetadata: existing.metadata as Record<string, unknown> | null | undefined,
includeSaturday: input.includeSaturday,
});
let newDailyCostCents = 0;
if (resolved.resourceId) {
if (!existingResource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const availability = existingResource.availability as unknown as WeekdayAvailability;
const recurrence = newMeta.recurrence as RecurrencePattern | undefined;
newDailyCostCents = await calculateTimelineAllocationDailyCost({
db: input.db,
resourceId: resolved.resourceId,
lcrCents: existingResource.lcrCents,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
});
}
return input.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.allocationId,
...buildTimelineAllocationEntryUpdate({
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
metadata: newMeta,
dailyCostCents: newDailyCostCents,
role: input.role,
}),
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationId,
action: "UPDATE",
changes: buildTimelineAllocationUpdateAuditChanges({
allocationId: resolved.entry.id,
previousHoursPerDay: existing.hoursPerDay,
previousStartDate: existing.startDate,
previousEndDate: existing.endDate,
nextAllocationId: updatedAllocation.id,
nextHoursPerDay: newHoursPerDay,
nextStartDate: newStartDate,
nextEndDate: newEndDate,
includeSaturday,
}),
},
});
return updatedAllocation;
});
}
@@ -1,18 +1,13 @@
import { import {
buildSplitAllocationReadModel, buildSplitAllocationReadModel,
createAssignment, createAssignment,
loadAllocationEntry,
updateAllocationEntry,
} from "@capakraken/application"; } from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db";
import { import {
AllocationStatus, AllocationStatus,
PermissionKey, PermissionKey,
UpdateAllocationHoursSchema, UpdateAllocationHoursSchema,
type RecurrencePattern,
type WeekdayAvailability,
} from "@capakraken/shared"; } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { import {
emitAllocationCreated, emitAllocationCreated,
@@ -21,96 +16,25 @@ import {
import { managerProcedure, requirePermission } from "../trpc.js"; import { managerProcedure, requirePermission } from "../trpc.js";
import { import {
assertTimelineDateRangeValid, assertTimelineDateRangeValid,
buildTimelineAllocationEntryUpdate,
buildTimelineAllocationMetadata,
buildTimelineAllocationUpdateAuditChanges,
buildTimelineQuickAssignAssignmentInput, buildTimelineQuickAssignAssignmentInput,
validateTimelineAllocationDateRanges, validateTimelineAllocationDateRanges,
} from "./timeline-allocation-mutation-support.js"; } from "./timeline-allocation-mutation-support.js";
import { applyTimelineInlineAllocationUpdate } from "./timeline-allocation-inline-support.js";
import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js"; import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js";
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
export const timelineAllocationMutationProcedures = { export const timelineAllocationMutationProcedures = {
updateAllocationInline: managerProcedure updateAllocationInline: managerProcedure
.input(UpdateAllocationHoursSchema) .input(UpdateAllocationHoursSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const resolved = await loadAllocationEntry(ctx.db, input.allocationId); const updated = await applyTimelineInlineAllocationUpdate({
const existing = resolved.entry; db: ctx.db as PrismaClient,
const existingResource = resolved.resourceId allocationId: input.allocationId,
? await ctx.db.resource.findUnique({ hoursPerDay: input.hoursPerDay,
where: { id: resolved.resourceId }, startDate: input.startDate,
select: { id: true, lcrCents: true, availability: true }, endDate: input.endDate,
})
: null;
const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
const newStartDate = input.startDate ?? existing.startDate;
const newEndDate = input.endDate ?? existing.endDate;
assertTimelineDateRangeValid(newStartDate, newEndDate);
const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({
existingMetadata: existing.metadata as Record<string, unknown> | null | undefined,
includeSaturday: input.includeSaturday, includeSaturday: input.includeSaturday,
}); role: input.role,
let newDailyCostCents = 0;
if (resolved.resourceId) {
if (!existingResource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const availability = existingResource.availability as unknown as WeekdayAvailability;
const recurrence = newMeta.recurrence as RecurrencePattern | undefined;
newDailyCostCents = await calculateTimelineAllocationDailyCost({
db: ctx.db as PrismaClient,
resourceId: resolved.resourceId,
lcrCents: existingResource.lcrCents,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
});
}
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.allocationId,
...buildTimelineAllocationEntryUpdate({
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
metadata: newMeta,
dailyCostCents: newDailyCostCents,
role: input.role,
}),
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationId,
action: "UPDATE",
changes: buildTimelineAllocationUpdateAuditChanges({
allocationId: resolved.entry.id,
previousHoursPerDay: existing.hoursPerDay,
previousStartDate: existing.startDate,
previousEndDate: existing.endDate,
nextAllocationId: updatedAllocation.id,
nextHoursPerDay: newHoursPerDay,
nextStartDate: newStartDate,
nextEndDate: newEndDate,
includeSaturday,
}),
},
});
return updatedAllocation;
}); });
emitAllocationUpdated({ emitAllocationUpdated({