refactor(api): extract timeline allocation inline support
This commit is contained in:
@@ -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;
|
|
||||||
const existingResource = resolved.resourceId
|
|
||||||
? await ctx.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: ctx.db as PrismaClient,
|
db: ctx.db as PrismaClient,
|
||||||
resourceId: resolved.resourceId,
|
allocationId: input.allocationId,
|
||||||
lcrCents: existingResource.lcrCents,
|
hoursPerDay: input.hoursPerDay,
|
||||||
hoursPerDay: newHoursPerDay,
|
startDate: input.startDate,
|
||||||
startDate: newStartDate,
|
endDate: input.endDate,
|
||||||
endDate: newEndDate,
|
includeSaturday: input.includeSaturday,
|
||||||
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,
|
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({
|
||||||
|
|||||||
Reference in New Issue
Block a user