refactor(api): extract timeline allocation mutation support
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assertTimelineDateRangeValid,
|
||||||
|
buildTimelineAllocationMetadata,
|
||||||
|
buildTimelineBatchShiftAuditChanges,
|
||||||
|
buildTimelineQuickAssignMetadata,
|
||||||
|
calculateTimelineAllocationPercentage,
|
||||||
|
shiftTimelineAllocationWindow,
|
||||||
|
} from "../router/timeline-allocation-mutation-support.js";
|
||||||
|
|
||||||
|
describe("timeline allocation mutation support", () => {
|
||||||
|
it("preserves existing metadata while updating includeSaturday", () => {
|
||||||
|
const result = buildTimelineAllocationMetadata({
|
||||||
|
existingMetadata: {
|
||||||
|
recurrence: { frequency: "weekly", interval: 2 },
|
||||||
|
includeSaturday: false,
|
||||||
|
},
|
||||||
|
includeSaturday: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
metadata: {
|
||||||
|
recurrence: { frequency: "weekly", interval: 2 },
|
||||||
|
includeSaturday: true,
|
||||||
|
},
|
||||||
|
includeSaturday: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects inverted date ranges", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertTimelineDateRangeValid(
|
||||||
|
new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
new Date("2026-04-09T00:00:00.000Z"),
|
||||||
|
)).toThrowError(TRPCError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds allocation percentages and clamps them to 100", () => {
|
||||||
|
expect(calculateTimelineAllocationPercentage(3.9)).toBe(49);
|
||||||
|
expect(calculateTimelineAllocationPercentage(9)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds source metadata for quick assign operations", () => {
|
||||||
|
expect(buildTimelineQuickAssignMetadata("quickAssign")).toEqual({ source: "quickAssign" });
|
||||||
|
expect(buildTimelineQuickAssignMetadata("batchQuickAssign")).toEqual({ source: "batchQuickAssign" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts and clamps allocation windows for each batch-shift mode", () => {
|
||||||
|
expect(
|
||||||
|
shiftTimelineAllocationWindow({
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
daysDelta: 2,
|
||||||
|
mode: "move",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
startDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-14T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shiftTimelineAllocationWindow({
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
daysDelta: 5,
|
||||||
|
mode: "resize-start",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
startDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shiftTimelineAllocationWindow({
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
daysDelta: -5,
|
||||||
|
mode: "resize-end",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds batch-shift audit payloads", () => {
|
||||||
|
expect(
|
||||||
|
buildTimelineBatchShiftAuditChanges({
|
||||||
|
mode: "move",
|
||||||
|
daysDelta: 3,
|
||||||
|
count: 2,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
operation: "batchShift",
|
||||||
|
mode: "move",
|
||||||
|
daysDelta: 3,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Prisma } from "@capakraken/db";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export type TimelineBatchShiftMode = "move" | "resize-start" | "resize-end";
|
||||||
|
|
||||||
|
export function assertTimelineDateRangeValid(startDate: Date, endDate: Date): void {
|
||||||
|
if (endDate >= startDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "End date must be after start date",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAllocationMetadata(input: {
|
||||||
|
existingMetadata: Record<string, unknown> | null | undefined;
|
||||||
|
includeSaturday: boolean | undefined;
|
||||||
|
}): { metadata: Record<string, unknown>; includeSaturday: boolean } {
|
||||||
|
const existingMetadata = input.existingMetadata ?? {};
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
...existingMetadata,
|
||||||
|
...(input.includeSaturday !== undefined
|
||||||
|
? { includeSaturday: input.includeSaturday }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const includeSaturday =
|
||||||
|
input.includeSaturday ?? (existingMetadata.includeSaturday as boolean | undefined) ?? false;
|
||||||
|
|
||||||
|
return { metadata, includeSaturday };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTimelineAllocationPercentage(hoursPerDay: number): number {
|
||||||
|
return Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineQuickAssignMetadata(source: "quickAssign" | "batchQuickAssign") {
|
||||||
|
return { source } satisfies Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftTimelineAllocationWindow(input: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
daysDelta: number;
|
||||||
|
mode: TimelineBatchShiftMode;
|
||||||
|
}): { startDate: Date; endDate: Date } {
|
||||||
|
const startDate = new Date(input.startDate);
|
||||||
|
const endDate = new Date(input.endDate);
|
||||||
|
|
||||||
|
if (input.mode === "move") {
|
||||||
|
startDate.setDate(startDate.getDate() + input.daysDelta);
|
||||||
|
endDate.setDate(endDate.getDate() + input.daysDelta);
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.mode === "resize-start") {
|
||||||
|
startDate.setDate(startDate.getDate() + input.daysDelta);
|
||||||
|
if (startDate > endDate) {
|
||||||
|
startDate.setTime(endDate.getTime());
|
||||||
|
}
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
endDate.setDate(endDate.getDate() + input.daysDelta);
|
||||||
|
if (endDate < startDate) {
|
||||||
|
endDate.setTime(startDate.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAllocationUpdateAuditChanges(input: {
|
||||||
|
allocationId: string;
|
||||||
|
previousHoursPerDay: number;
|
||||||
|
previousStartDate: Date;
|
||||||
|
previousEndDate: Date;
|
||||||
|
nextAllocationId: string;
|
||||||
|
nextHoursPerDay: number;
|
||||||
|
nextStartDate: Date;
|
||||||
|
nextEndDate: Date;
|
||||||
|
includeSaturday: boolean;
|
||||||
|
}): Prisma.InputJsonValue {
|
||||||
|
return {
|
||||||
|
before: {
|
||||||
|
id: input.allocationId,
|
||||||
|
hoursPerDay: input.previousHoursPerDay,
|
||||||
|
startDate: input.previousStartDate,
|
||||||
|
endDate: input.previousEndDate,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
id: input.nextAllocationId,
|
||||||
|
hoursPerDay: input.nextHoursPerDay,
|
||||||
|
startDate: input.nextStartDate,
|
||||||
|
endDate: input.nextEndDate,
|
||||||
|
includeSaturday: input.includeSaturday,
|
||||||
|
},
|
||||||
|
} as unknown as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineBatchShiftAuditChanges(input: {
|
||||||
|
mode: TimelineBatchShiftMode;
|
||||||
|
daysDelta: number;
|
||||||
|
count: number;
|
||||||
|
}): Prisma.InputJsonValue {
|
||||||
|
return {
|
||||||
|
operation: "batchShift",
|
||||||
|
mode: input.mode,
|
||||||
|
daysDelta: input.daysDelta,
|
||||||
|
count: input.count,
|
||||||
|
} as unknown as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
updateAllocationEntry,
|
updateAllocationEntry,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
import {
|
||||||
|
AllocationStatus,
|
||||||
|
PermissionKey,
|
||||||
|
UpdateAllocationHoursSchema,
|
||||||
|
type RecurrencePattern,
|
||||||
|
type WeekdayAvailability,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +20,15 @@ import {
|
|||||||
emitAllocationUpdated,
|
emitAllocationUpdated,
|
||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
import { managerProcedure, requirePermission } from "../trpc.js";
|
import { managerProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import {
|
||||||
|
assertTimelineDateRangeValid,
|
||||||
|
buildTimelineAllocationMetadata,
|
||||||
|
buildTimelineAllocationUpdateAuditChanges,
|
||||||
|
buildTimelineBatchShiftAuditChanges,
|
||||||
|
buildTimelineQuickAssignMetadata,
|
||||||
|
calculateTimelineAllocationPercentage,
|
||||||
|
shiftTimelineAllocationWindow,
|
||||||
|
} from "./timeline-allocation-mutation-support.js";
|
||||||
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||||
|
|
||||||
export const timelineAllocationMutationProcedures = {
|
export const timelineAllocationMutationProcedures = {
|
||||||
@@ -34,22 +49,11 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
const newStartDate = input.startDate ?? existing.startDate;
|
const newStartDate = input.startDate ?? existing.startDate;
|
||||||
const newEndDate = input.endDate ?? existing.endDate;
|
const newEndDate = input.endDate ?? existing.endDate;
|
||||||
|
|
||||||
if (newEndDate < newStartDate) {
|
assertTimelineDateRangeValid(newStartDate, newEndDate);
|
||||||
throw new TRPCError({
|
const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({
|
||||||
code: "BAD_REQUEST",
|
existingMetadata: existing.metadata as Record<string, unknown> | null | undefined,
|
||||||
message: "End date must be after start date",
|
includeSaturday: input.includeSaturday,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
|
|
||||||
const newMeta: Record<string, unknown> = {
|
|
||||||
...existingMeta,
|
|
||||||
...(input.includeSaturday !== undefined
|
|
||||||
? { includeSaturday: input.includeSaturday }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const includeSaturday =
|
|
||||||
input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;
|
|
||||||
|
|
||||||
let newDailyCostCents = 0;
|
let newDailyCostCents = 0;
|
||||||
if (resolved.resourceId) {
|
if (resolved.resourceId) {
|
||||||
@@ -57,9 +61,8 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const availability =
|
const availability = existingResource.availability as unknown as WeekdayAvailability;
|
||||||
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
|
const recurrence = newMeta.recurrence as RecurrencePattern | undefined;
|
||||||
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
|
||||||
newDailyCostCents = await calculateTimelineAllocationDailyCost({
|
newDailyCostCents = await calculateTimelineAllocationDailyCost({
|
||||||
db: ctx.db as PrismaClient,
|
db: ctx.db as PrismaClient,
|
||||||
resourceId: resolved.resourceId,
|
resourceId: resolved.resourceId,
|
||||||
@@ -101,21 +104,17 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
entityType: "Allocation",
|
entityType: "Allocation",
|
||||||
entityId: input.allocationId,
|
entityId: input.allocationId,
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
changes: {
|
changes: buildTimelineAllocationUpdateAuditChanges({
|
||||||
before: {
|
allocationId: resolved.entry.id,
|
||||||
id: resolved.entry.id,
|
previousHoursPerDay: existing.hoursPerDay,
|
||||||
hoursPerDay: existing.hoursPerDay,
|
previousStartDate: existing.startDate,
|
||||||
startDate: existing.startDate,
|
previousEndDate: existing.endDate,
|
||||||
endDate: existing.endDate,
|
nextAllocationId: updatedAllocation.id,
|
||||||
},
|
nextHoursPerDay: newHoursPerDay,
|
||||||
after: {
|
nextStartDate: newStartDate,
|
||||||
id: updatedAllocation.id,
|
nextEndDate: newEndDate,
|
||||||
hoursPerDay: newHoursPerDay,
|
includeSaturday,
|
||||||
startDate: newStartDate,
|
}),
|
||||||
endDate: newEndDate,
|
|
||||||
includeSaturday,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,12 +145,10 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
if (input.endDate < input.startDate) {
|
assertTimelineDateRangeValid(input.startDate, input.endDate);
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100));
|
const percentage = calculateTimelineAllocationPercentage(input.hoursPerDay);
|
||||||
const metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
|
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(
|
||||||
@@ -210,24 +207,14 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
for (const assignment of input.assignments) {
|
for (const assignment of input.assignments) {
|
||||||
if (assignment.endDate < assignment.startDate) {
|
assertTimelineDateRangeValid(assignment.startDate, assignment.endDate);
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "End date must be after start date",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = Math.min(
|
const percentage = calculateTimelineAllocationPercentage(assignment.hoursPerDay);
|
||||||
100,
|
const metadata = buildTimelineQuickAssignMetadata("batchQuickAssign");
|
||||||
Math.round((assignment.hoursPerDay / 8) * 100),
|
|
||||||
);
|
|
||||||
const metadata = {
|
|
||||||
source: "batchQuickAssign",
|
|
||||||
} satisfies Record<string, unknown>;
|
|
||||||
|
|
||||||
const createdAssignment = await createAssignment(
|
const createdAssignment = await createAssignment(
|
||||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
@@ -289,35 +276,24 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
const updated = [];
|
const updated = [];
|
||||||
for (const entry of resolved) {
|
for (const entry of resolved) {
|
||||||
const existing = entry.entry;
|
const existing = entry.entry;
|
||||||
const newStart = new Date(existing.startDate);
|
const shiftedWindow = shiftTimelineAllocationWindow({
|
||||||
const newEnd = new Date(existing.endDate);
|
startDate: existing.startDate,
|
||||||
|
endDate: existing.endDate,
|
||||||
if (input.mode === "move") {
|
daysDelta: input.daysDelta,
|
||||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
mode: input.mode,
|
||||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
});
|
||||||
} else if (input.mode === "resize-start") {
|
|
||||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
|
||||||
if (newStart > newEnd) {
|
|
||||||
newStart.setTime(newEnd.getTime());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
|
||||||
if (newEnd < newStart) {
|
|
||||||
newEnd.setTime(newStart.getTime());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateAllocationEntry(
|
const result = await updateAllocationEntry(
|
||||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||||
{
|
{
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
demandRequirementUpdate: {
|
demandRequirementUpdate: {
|
||||||
startDate: newStart,
|
startDate: shiftedWindow.startDate,
|
||||||
endDate: newEnd,
|
endDate: shiftedWindow.endDate,
|
||||||
},
|
},
|
||||||
assignmentUpdate: {
|
assignmentUpdate: {
|
||||||
startDate: newStart,
|
startDate: shiftedWindow.startDate,
|
||||||
endDate: newEnd,
|
endDate: shiftedWindow.endDate,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -329,12 +305,11 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
entityType: "Allocation",
|
entityType: "Allocation",
|
||||||
entityId: input.allocationIds.join(","),
|
entityId: input.allocationIds.join(","),
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
changes: {
|
changes: buildTimelineBatchShiftAuditChanges({
|
||||||
operation: "batchShift",
|
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
daysDelta: input.daysDelta,
|
daysDelta: input.daysDelta,
|
||||||
count: resolved.length,
|
count: resolved.length,
|
||||||
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user