refactor(api): extract timeline allocation mutation payload helpers
This commit is contained in:
@@ -2,11 +2,14 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertTimelineDateRangeValid,
|
assertTimelineDateRangeValid,
|
||||||
|
buildTimelineAllocationEntryUpdate,
|
||||||
buildTimelineAllocationMetadata,
|
buildTimelineAllocationMetadata,
|
||||||
buildTimelineBatchShiftAuditChanges,
|
buildTimelineBatchShiftAuditChanges,
|
||||||
|
buildTimelineQuickAssignAssignmentInput,
|
||||||
buildTimelineQuickAssignMetadata,
|
buildTimelineQuickAssignMetadata,
|
||||||
calculateTimelineAllocationPercentage,
|
calculateTimelineAllocationPercentage,
|
||||||
shiftTimelineAllocationWindow,
|
shiftTimelineAllocationWindow,
|
||||||
|
validateTimelineAllocationDateRanges,
|
||||||
} from "../router/timeline-allocation-mutation-support.js";
|
} from "../router/timeline-allocation-mutation-support.js";
|
||||||
|
|
||||||
describe("timeline allocation mutation support", () => {
|
describe("timeline allocation mutation support", () => {
|
||||||
@@ -46,6 +49,33 @@ describe("timeline allocation mutation support", () => {
|
|||||||
expect(buildTimelineQuickAssignMetadata("batchQuickAssign")).toEqual({ source: "batchQuickAssign" });
|
expect(buildTimelineQuickAssignMetadata("batchQuickAssign")).toEqual({ source: "batchQuickAssign" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds assignment create payloads for quick assign flows", () => {
|
||||||
|
expect(
|
||||||
|
buildTimelineQuickAssignAssignmentInput({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
role: "Engineer",
|
||||||
|
roleId: "role_1",
|
||||||
|
status: "PROPOSED",
|
||||||
|
source: "quickAssign",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
percentage: 75,
|
||||||
|
role: "Engineer",
|
||||||
|
roleId: "role_1",
|
||||||
|
status: "PROPOSED",
|
||||||
|
metadata: { source: "quickAssign" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shifts and clamps allocation windows for each batch-shift mode", () => {
|
it("shifts and clamps allocation windows for each batch-shift mode", () => {
|
||||||
expect(
|
expect(
|
||||||
shiftTimelineAllocationWindow({
|
shiftTimelineAllocationWindow({
|
||||||
@@ -98,4 +128,47 @@ describe("timeline allocation mutation support", () => {
|
|||||||
count: 2,
|
count: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates date ranges in bulk", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateTimelineAllocationDateRanges([
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-11T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
])).toThrowError(TRPCError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds shared update payloads for demand and assignment changes", () => {
|
||||||
|
expect(
|
||||||
|
buildTimelineAllocationEntryUpdate({
|
||||||
|
hoursPerDay: 7.5,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
metadata: { includeSaturday: true },
|
||||||
|
dailyCostCents: 48000,
|
||||||
|
role: "Architect",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
hoursPerDay: 7.5,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
metadata: { includeSaturday: true },
|
||||||
|
role: "Architect",
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
hoursPerDay: 7.5,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
dailyCostCents: 48000,
|
||||||
|
metadata: { includeSaturday: true },
|
||||||
|
role: "Architect",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Prisma } from "@capakraken/db";
|
import { Prisma } from "@capakraken/db";
|
||||||
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
export type TimelineBatchShiftMode = "move" | "resize-start" | "resize-end";
|
export type TimelineBatchShiftMode = "move" | "resize-start" | "resize-end";
|
||||||
@@ -39,6 +40,39 @@ export function buildTimelineQuickAssignMetadata(source: "quickAssign" | "batchQ
|
|||||||
return { source } satisfies Record<string, unknown>;
|
return { source } satisfies Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildTimelineQuickAssignAssignmentInput(input: {
|
||||||
|
resourceId: string;
|
||||||
|
projectId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
role: string;
|
||||||
|
roleId?: string | undefined;
|
||||||
|
status: AllocationStatus;
|
||||||
|
source: "quickAssign" | "batchQuickAssign";
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
hoursPerDay: input.hoursPerDay,
|
||||||
|
percentage: calculateTimelineAllocationPercentage(input.hoursPerDay),
|
||||||
|
role: input.role,
|
||||||
|
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
|
||||||
|
status: input.status,
|
||||||
|
metadata: buildTimelineQuickAssignMetadata(input.source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimelineAllocationDateRanges(
|
||||||
|
ranges: Array<{ startDate: Date; endDate: Date }>,
|
||||||
|
): void {
|
||||||
|
for (const range of ranges) {
|
||||||
|
assertTimelineDateRangeValid(range.startDate, range.endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function shiftTimelineAllocationWindow(input: {
|
export function shiftTimelineAllocationWindow(input: {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
@@ -98,6 +132,33 @@ export function buildTimelineAllocationUpdateAuditChanges(input: {
|
|||||||
} as unknown as Prisma.InputJsonValue;
|
} as unknown as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAllocationEntryUpdate(input: {
|
||||||
|
hoursPerDay: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
dailyCostCents: number;
|
||||||
|
role?: string | undefined;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
hoursPerDay: input.hoursPerDay,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
metadata: input.metadata,
|
||||||
|
...(input.role !== undefined ? { role: input.role } : {}),
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
hoursPerDay: input.hoursPerDay,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
dailyCostCents: input.dailyCostCents,
|
||||||
|
metadata: input.metadata,
|
||||||
|
...(input.role !== undefined ? { role: input.role } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTimelineBatchShiftAuditChanges(input: {
|
export function buildTimelineBatchShiftAuditChanges(input: {
|
||||||
mode: TimelineBatchShiftMode;
|
mode: TimelineBatchShiftMode;
|
||||||
daysDelta: number;
|
daysDelta: number;
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import {
|
|||||||
import { managerProcedure, requirePermission } from "../trpc.js";
|
import { managerProcedure, requirePermission } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
assertTimelineDateRangeValid,
|
assertTimelineDateRangeValid,
|
||||||
|
buildTimelineAllocationEntryUpdate,
|
||||||
buildTimelineAllocationMetadata,
|
buildTimelineAllocationMetadata,
|
||||||
buildTimelineAllocationUpdateAuditChanges,
|
buildTimelineAllocationUpdateAuditChanges,
|
||||||
buildTimelineBatchShiftAuditChanges,
|
buildTimelineBatchShiftAuditChanges,
|
||||||
buildTimelineQuickAssignMetadata,
|
buildTimelineQuickAssignAssignmentInput,
|
||||||
calculateTimelineAllocationPercentage,
|
|
||||||
shiftTimelineAllocationWindow,
|
shiftTimelineAllocationWindow,
|
||||||
|
validateTimelineAllocationDateRanges,
|
||||||
} from "./timeline-allocation-mutation-support.js";
|
} from "./timeline-allocation-mutation-support.js";
|
||||||
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||||
|
|
||||||
@@ -81,21 +82,14 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||||
{
|
{
|
||||||
id: input.allocationId,
|
id: input.allocationId,
|
||||||
demandRequirementUpdate: {
|
...buildTimelineAllocationEntryUpdate({
|
||||||
hoursPerDay: newHoursPerDay,
|
hoursPerDay: newHoursPerDay,
|
||||||
startDate: newStartDate,
|
startDate: newStartDate,
|
||||||
endDate: newEndDate,
|
endDate: newEndDate,
|
||||||
metadata: newMeta,
|
metadata: newMeta,
|
||||||
...(input.role !== undefined ? { role: input.role } : {}),
|
|
||||||
},
|
|
||||||
assignmentUpdate: {
|
|
||||||
hoursPerDay: newHoursPerDay,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
dailyCostCents: newDailyCostCents,
|
dailyCostCents: newDailyCostCents,
|
||||||
metadata: newMeta,
|
role: input.role,
|
||||||
...(input.role !== undefined ? { role: input.role } : {}),
|
}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,24 +141,13 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
assertTimelineDateRangeValid(input.startDate, input.endDate);
|
assertTimelineDateRangeValid(input.startDate, input.endDate);
|
||||||
|
|
||||||
const percentage = calculateTimelineAllocationPercentage(input.hoursPerDay);
|
|
||||||
const metadata = buildTimelineQuickAssignMetadata("quickAssign");
|
|
||||||
|
|
||||||
const allocation = await ctx.db.$transaction(async (tx) => {
|
const allocation = await ctx.db.$transaction(async (tx) => {
|
||||||
const assignment = await createAssignment(
|
const assignment = await createAssignment(
|
||||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
{
|
buildTimelineQuickAssignAssignmentInput({
|
||||||
resourceId: input.resourceId,
|
...input,
|
||||||
projectId: input.projectId,
|
source: "quickAssign",
|
||||||
startDate: input.startDate,
|
}),
|
||||||
endDate: input.endDate,
|
|
||||||
hoursPerDay: input.hoursPerDay,
|
|
||||||
percentage,
|
|
||||||
role: input.role,
|
|
||||||
roleId: input.roleId ?? undefined,
|
|
||||||
status: input.status,
|
|
||||||
metadata,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return buildSplitAllocationReadModel({
|
return buildSplitAllocationReadModel({
|
||||||
@@ -205,30 +188,17 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
validateTimelineAllocationDateRanges(input.assignments);
|
||||||
for (const assignment of input.assignments) {
|
|
||||||
assertTimelineDateRangeValid(assignment.startDate, assignment.endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await ctx.db.$transaction(async (tx) => {
|
const results = await ctx.db.$transaction(async (tx) => {
|
||||||
const created = [];
|
const created = [];
|
||||||
for (const assignment of input.assignments) {
|
for (const assignment of input.assignments) {
|
||||||
const percentage = calculateTimelineAllocationPercentage(assignment.hoursPerDay);
|
|
||||||
const metadata = buildTimelineQuickAssignMetadata("batchQuickAssign");
|
|
||||||
|
|
||||||
const createdAssignment = await createAssignment(
|
const createdAssignment = await createAssignment(
|
||||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
{
|
buildTimelineQuickAssignAssignmentInput({
|
||||||
resourceId: assignment.resourceId,
|
...assignment,
|
||||||
projectId: assignment.projectId,
|
source: "batchQuickAssign",
|
||||||
startDate: assignment.startDate,
|
}),
|
||||||
endDate: assignment.endDate,
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
percentage,
|
|
||||||
role: assignment.role,
|
|
||||||
status: assignment.status,
|
|
||||||
metadata,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
created.push(createdAssignment);
|
created.push(createdAssignment);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user