refactor(api): isolate timeline allocation mutations
This commit is contained in:
@@ -0,0 +1,475 @@
|
|||||||
|
import {
|
||||||
|
buildSplitAllocationReadModel,
|
||||||
|
createAssignment,
|
||||||
|
findAllocationEntry,
|
||||||
|
loadAllocationEntry,
|
||||||
|
updateAllocationEntry,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import { Prisma, VacationType } from "@capakraken/db";
|
||||||
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
||||||
|
import type { AbsenceDay, CalculationRule } from "@capakraken/shared";
|
||||||
|
import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
emitAllocationCreated,
|
||||||
|
emitAllocationUpdated,
|
||||||
|
} from "../sse/event-bus.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { managerProcedure, requirePermission } from "../trpc.js";
|
||||||
|
|
||||||
|
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = error as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
meta?: { table?: unknown };
|
||||||
|
};
|
||||||
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
||||||
|
if (code !== "P2021") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
|
||||||
|
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
||||||
|
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
||||||
|
const calculationRuleModel = (db as PrismaClient & {
|
||||||
|
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
|
||||||
|
}).calculationRule;
|
||||||
|
|
||||||
|
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
|
||||||
|
return DEFAULT_CALCULATION_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = await calculationRuleModel.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ priority: "desc" }],
|
||||||
|
});
|
||||||
|
if (rules.length > 0) {
|
||||||
|
return rules as unknown as CalculationRule[];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
|
||||||
|
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_CALCULATION_RULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAbsenceDays(
|
||||||
|
db: PrismaClient,
|
||||||
|
resourceId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
||||||
|
const absenceDays: AbsenceDay[] = [];
|
||||||
|
const legacyVacationDates: Date[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vacations = await db.vacation.findMany({
|
||||||
|
where: {
|
||||||
|
resourceId,
|
||||||
|
status: "APPROVED",
|
||||||
|
startDate: { lte: endDate },
|
||||||
|
endDate: { gte: startDate },
|
||||||
|
},
|
||||||
|
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const vacation of vacations) {
|
||||||
|
const cur = new Date(vacation.startDate);
|
||||||
|
cur.setHours(0, 0, 0, 0);
|
||||||
|
const vacationEnd = new Date(vacation.endDate);
|
||||||
|
vacationEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const triggerType = vacation.type === VacationType.SICK
|
||||||
|
? "SICK" as const
|
||||||
|
: vacation.type === VacationType.PUBLIC_HOLIDAY
|
||||||
|
? "PUBLIC_HOLIDAY" as const
|
||||||
|
: "VACATION" as const;
|
||||||
|
|
||||||
|
while (cur <= vacationEnd) {
|
||||||
|
absenceDays.push({
|
||||||
|
date: new Date(cur),
|
||||||
|
type: triggerType,
|
||||||
|
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||||
|
});
|
||||||
|
if (triggerType === "VACATION") {
|
||||||
|
legacyVacationDates.push(new Date(cur));
|
||||||
|
}
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, resourceId, startDate, endDate },
|
||||||
|
"Failed to load timeline absence days",
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { absenceDays, legacyVacationDates };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timelineAllocationMutationProcedures = {
|
||||||
|
updateAllocationInline: managerProcedure
|
||||||
|
.input(UpdateAllocationHoursSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (newEndDate < newStartDate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "End date must be after start date",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (resolved.resourceId) {
|
||||||
|
if (!existingResource) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability =
|
||||||
|
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
|
||||||
|
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
||||||
|
|
||||||
|
const [absenceData, calculationRules] = await Promise.all([
|
||||||
|
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
|
||||||
|
loadCalculationRules(ctx.db as PrismaClient),
|
||||||
|
]);
|
||||||
|
|
||||||
|
newDailyCostCents = calculateAllocation({
|
||||||
|
lcrCents: existingResource.lcrCents,
|
||||||
|
hoursPerDay: newHoursPerDay,
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
availability,
|
||||||
|
includeSaturday,
|
||||||
|
...(recurrence ? { recurrence } : {}),
|
||||||
|
vacationDates: absenceData.legacyVacationDates,
|
||||||
|
absenceDays: absenceData.absenceDays,
|
||||||
|
calculationRules,
|
||||||
|
}).dailyCostCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.db.$transaction(async (tx) => {
|
||||||
|
const { allocation: updatedAllocation } = await updateAllocationEntry(
|
||||||
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||||
|
{
|
||||||
|
id: input.allocationId,
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
hoursPerDay: newHoursPerDay,
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
metadata: newMeta,
|
||||||
|
...(input.role !== undefined ? { role: input.role } : {}),
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
hoursPerDay: newHoursPerDay,
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
dailyCostCents: newDailyCostCents,
|
||||||
|
metadata: newMeta,
|
||||||
|
...(input.role !== undefined ? { role: input.role } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
entityType: "Allocation",
|
||||||
|
entityId: input.allocationId,
|
||||||
|
action: "UPDATE",
|
||||||
|
changes: {
|
||||||
|
before: {
|
||||||
|
id: resolved.entry.id,
|
||||||
|
hoursPerDay: existing.hoursPerDay,
|
||||||
|
startDate: existing.startDate,
|
||||||
|
endDate: existing.endDate,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
id: updatedAllocation.id,
|
||||||
|
hoursPerDay: newHoursPerDay,
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
includeSaturday,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedAllocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
emitAllocationUpdated({
|
||||||
|
id: updated.id,
|
||||||
|
projectId: updated.projectId,
|
||||||
|
resourceId: updated.resourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
quickAssign: managerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
||||||
|
role: z.string().min(1).max(200).default("Team Member"),
|
||||||
|
roleId: z.string().optional(),
|
||||||
|
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
if (input.endDate < input.startDate) {
|
||||||
|
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 metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
|
||||||
|
|
||||||
|
const allocation = await ctx.db.$transaction(async (tx) => {
|
||||||
|
const assignment = await createAssignment(
|
||||||
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
|
{
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
hoursPerDay: input.hoursPerDay,
|
||||||
|
percentage,
|
||||||
|
role: input.role,
|
||||||
|
roleId: input.roleId ?? undefined,
|
||||||
|
status: input.status,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildSplitAllocationReadModel({
|
||||||
|
demandRequirements: [],
|
||||||
|
assignments: [assignment],
|
||||||
|
}).allocations[0]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
emitAllocationCreated({
|
||||||
|
id: allocation.id,
|
||||||
|
projectId: allocation.projectId,
|
||||||
|
resourceId: allocation.resourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return allocation;
|
||||||
|
}),
|
||||||
|
|
||||||
|
batchQuickAssign: managerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
assignments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
resourceId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
||||||
|
role: z.string().min(1).max(200).default("Team Member"),
|
||||||
|
status: z
|
||||||
|
.nativeEnum(AllocationStatus)
|
||||||
|
.default(AllocationStatus.PROPOSED),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(50),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
|
for (const assignment of input.assignments) {
|
||||||
|
if (assignment.endDate < assignment.startDate) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "End date must be after start date",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await ctx.db.$transaction(async (tx) => {
|
||||||
|
const created = [];
|
||||||
|
for (const assignment of input.assignments) {
|
||||||
|
const percentage = Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((assignment.hoursPerDay / 8) * 100),
|
||||||
|
);
|
||||||
|
const metadata = {
|
||||||
|
source: "batchQuickAssign",
|
||||||
|
} satisfies Record<string, unknown>;
|
||||||
|
|
||||||
|
const createdAssignment = await createAssignment(
|
||||||
|
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||||
|
{
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
percentage,
|
||||||
|
role: assignment.role,
|
||||||
|
status: assignment.status,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
created.push(createdAssignment);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const assignment of results) {
|
||||||
|
emitAllocationCreated({
|
||||||
|
id: assignment.id,
|
||||||
|
projectId: assignment.projectId,
|
||||||
|
resourceId: assignment.resourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count: results.length };
|
||||||
|
}),
|
||||||
|
|
||||||
|
batchShiftAllocations: managerProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
allocationIds: z.array(z.string()).min(1).max(100),
|
||||||
|
daysDelta: z.number().int().min(-3650).max(3650),
|
||||||
|
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
|
if (input.daysDelta === 0) {
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newStart = new Date(existing.startDate);
|
||||||
|
const newEnd = new Date(existing.endDate);
|
||||||
|
|
||||||
|
if (input.mode === "move") {
|
||||||
|
newStart.setDate(newStart.getDate() + input.daysDelta);
|
||||||
|
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(
|
||||||
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||||
|
{
|
||||||
|
id: existing.id,
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
startDate: newStart,
|
||||||
|
endDate: newEnd,
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
startDate: newStart,
|
||||||
|
endDate: newEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updated.push(result.allocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
entityType: "Allocation",
|
||||||
|
entityId: input.allocationIds.join(","),
|
||||||
|
action: "UPDATE",
|
||||||
|
changes: {
|
||||||
|
operation: "batchShift",
|
||||||
|
mode: input.mode,
|
||||||
|
daysDelta: input.daysDelta,
|
||||||
|
count: resolved.length,
|
||||||
|
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const allocation of results) {
|
||||||
|
emitAllocationUpdated({
|
||||||
|
id: allocation.id,
|
||||||
|
projectId: allocation.projectId,
|
||||||
|
resourceId: allocation.resourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count: results.length };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,268 +1,16 @@
|
|||||||
import {
|
import { updateAssignment, updateDemandRequirement } from "@capakraken/application";
|
||||||
buildSplitAllocationReadModel,
|
|
||||||
createAssignment,
|
|
||||||
findAllocationEntry,
|
|
||||||
loadAllocationEntry,
|
|
||||||
updateAssignment,
|
|
||||||
updateDemandRequirement,
|
|
||||||
updateAllocationEntry,
|
|
||||||
} from "@capakraken/application";
|
|
||||||
import { Prisma, VacationType } from "@capakraken/db";
|
|
||||||
import type { PrismaClient } from "@capakraken/db";
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
import { calculateAllocation, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
import { calculateAllocation, validateShift } from "@capakraken/engine";
|
||||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared";
|
||||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { emitProjectShifted } from "../sse/event-bus.js";
|
||||||
import {
|
|
||||||
emitAllocationCreated,
|
|
||||||
emitAllocationUpdated,
|
|
||||||
emitProjectShifted,
|
|
||||||
} from "../sse/event-bus.js";
|
|
||||||
import { logger } from "../lib/logger.js";
|
|
||||||
import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
|
||||||
import { loadProjectShiftContext, timelineReadProcedures } from "./timeline-read.js";
|
import { loadProjectShiftContext, timelineReadProcedures } from "./timeline-read.js";
|
||||||
|
|
||||||
/** Load active calculation rules from DB, falling back to defaults if none configured. */
|
|
||||||
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
if (error.code !== "P2021") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = error as {
|
|
||||||
code?: unknown;
|
|
||||||
message?: unknown;
|
|
||||||
meta?: { table?: unknown };
|
|
||||||
};
|
|
||||||
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
||||||
if (code !== "P2021") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
|
|
||||||
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
|
||||||
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
|
||||||
const calculationRuleModel = (db as PrismaClient & {
|
|
||||||
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
|
|
||||||
}).calculationRule;
|
|
||||||
|
|
||||||
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
|
|
||||||
return DEFAULT_CALCULATION_RULES;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rules = await calculationRuleModel.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: [{ priority: "desc" }],
|
|
||||||
});
|
|
||||||
if (rules.length > 0) {
|
|
||||||
return rules as unknown as CalculationRule[];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
|
|
||||||
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DEFAULT_CALCULATION_RULES;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build typed absence days from vacations for a resource in a date range. */
|
|
||||||
async function buildAbsenceDays(
|
|
||||||
db: PrismaClient,
|
|
||||||
resourceId: string,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
|
||||||
const absenceDays: AbsenceDay[] = [];
|
|
||||||
const legacyVacationDates: Date[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const vacations = await db.vacation.findMany({
|
|
||||||
where: {
|
|
||||||
resourceId,
|
|
||||||
status: "APPROVED",
|
|
||||||
startDate: { lte: endDate },
|
|
||||||
endDate: { gte: startDate },
|
|
||||||
},
|
|
||||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const vacation of vacations) {
|
|
||||||
const cur = new Date(vacation.startDate);
|
|
||||||
cur.setHours(0, 0, 0, 0);
|
|
||||||
const vacationEnd = new Date(vacation.endDate);
|
|
||||||
vacationEnd.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const triggerType = vacation.type === VacationType.SICK
|
|
||||||
? "SICK" as const
|
|
||||||
: vacation.type === VacationType.PUBLIC_HOLIDAY
|
|
||||||
? "PUBLIC_HOLIDAY" as const
|
|
||||||
: "VACATION" as const;
|
|
||||||
|
|
||||||
while (cur <= vacationEnd) {
|
|
||||||
absenceDays.push({
|
|
||||||
date: new Date(cur),
|
|
||||||
type: triggerType,
|
|
||||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
|
||||||
});
|
|
||||||
if (triggerType === "VACATION") {
|
|
||||||
legacyVacationDates.push(new Date(cur));
|
|
||||||
}
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
|
|
||||||
logger.error(
|
|
||||||
{ err: error, resourceId, startDate, endDate },
|
|
||||||
"Failed to load timeline absence days",
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { absenceDays, legacyVacationDates };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const timelineRouter = createTRPCRouter({
|
export const timelineRouter = createTRPCRouter({
|
||||||
...timelineReadProcedures,
|
...timelineReadProcedures,
|
||||||
|
...timelineAllocationMutationProcedures,
|
||||||
/**
|
|
||||||
* Inline update of an allocation's hours, dates, includeSaturday, or role.
|
|
||||||
* Recalculates dailyCostCents and emits SSE.
|
|
||||||
*/
|
|
||||||
updateAllocationInline: managerProcedure
|
|
||||||
.input(UpdateAllocationHoursSchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
||||||
const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (newEndDate < newStartDate) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "End date must be after start date",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
if (resolved.resourceId) {
|
|
||||||
if (!existingResource) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const availability =
|
|
||||||
existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;
|
|
||||||
const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined;
|
|
||||||
|
|
||||||
const [absenceData, calculationRules] = await Promise.all([
|
|
||||||
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
|
|
||||||
loadCalculationRules(ctx.db as PrismaClient),
|
|
||||||
]);
|
|
||||||
|
|
||||||
newDailyCostCents = calculateAllocation({
|
|
||||||
lcrCents: existingResource.lcrCents,
|
|
||||||
hoursPerDay: newHoursPerDay,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
availability,
|
|
||||||
includeSaturday,
|
|
||||||
...(recurrence ? { recurrence } : {}),
|
|
||||||
vacationDates: absenceData.legacyVacationDates,
|
|
||||||
absenceDays: absenceData.absenceDays,
|
|
||||||
calculationRules,
|
|
||||||
}).dailyCostCents;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await ctx.db.$transaction(async (tx) => {
|
|
||||||
const { allocation: updatedAllocation } = await updateAllocationEntry(
|
|
||||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
|
||||||
{
|
|
||||||
id: input.allocationId,
|
|
||||||
demandRequirementUpdate: {
|
|
||||||
hoursPerDay: newHoursPerDay,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
metadata: newMeta,
|
|
||||||
...(input.role !== undefined ? { role: input.role } : {}),
|
|
||||||
},
|
|
||||||
assignmentUpdate: {
|
|
||||||
hoursPerDay: newHoursPerDay,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
dailyCostCents: newDailyCostCents,
|
|
||||||
metadata: newMeta,
|
|
||||||
...(input.role !== undefined ? { role: input.role } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
|
||||||
data: {
|
|
||||||
entityType: "Allocation",
|
|
||||||
entityId: input.allocationId,
|
|
||||||
action: "UPDATE",
|
|
||||||
changes: {
|
|
||||||
before: {
|
|
||||||
id: resolved.entry.id,
|
|
||||||
hoursPerDay: existing.hoursPerDay,
|
|
||||||
startDate: existing.startDate,
|
|
||||||
endDate: existing.endDate,
|
|
||||||
},
|
|
||||||
after: {
|
|
||||||
id: updatedAllocation.id,
|
|
||||||
hoursPerDay: newHoursPerDay,
|
|
||||||
startDate: newStartDate,
|
|
||||||
endDate: newEndDate,
|
|
||||||
includeSaturday,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedAllocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
emitAllocationUpdated({
|
|
||||||
id: updated.id,
|
|
||||||
projectId: updated.projectId,
|
|
||||||
resourceId: updated.resourceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a project shift: validate, then commit all allocation date changes.
|
* Apply a project shift: validate, then commit all allocation date changes.
|
||||||
@@ -378,235 +126,4 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { project: updatedProject, validation };
|
return { project: updatedProject, validation };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick-assign a resource to a project for a date range.
|
|
||||||
* Overbooking is intentionally allowed.
|
|
||||||
*/
|
|
||||||
quickAssign: managerProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
resourceId: z.string(),
|
|
||||||
projectId: z.string(),
|
|
||||||
startDate: z.coerce.date(),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
|
||||||
role: z.string().min(1).max(200).default("Team Member"),
|
|
||||||
roleId: z.string().optional(),
|
|
||||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
||||||
if (input.endDate < input.startDate) {
|
|
||||||
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 metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
|
|
||||||
|
|
||||||
const allocation = await ctx.db.$transaction(async (tx) => {
|
|
||||||
const assignment = await createAssignment(
|
|
||||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
|
||||||
{
|
|
||||||
resourceId: input.resourceId,
|
|
||||||
projectId: input.projectId,
|
|
||||||
startDate: input.startDate,
|
|
||||||
endDate: input.endDate,
|
|
||||||
hoursPerDay: input.hoursPerDay,
|
|
||||||
percentage,
|
|
||||||
role: input.role,
|
|
||||||
roleId: input.roleId ?? undefined,
|
|
||||||
status: input.status,
|
|
||||||
metadata,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildSplitAllocationReadModel({
|
|
||||||
demandRequirements: [],
|
|
||||||
assignments: [assignment],
|
|
||||||
}).allocations[0]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
emitAllocationCreated({
|
|
||||||
id: allocation.id,
|
|
||||||
projectId: allocation.projectId,
|
|
||||||
resourceId: allocation.resourceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return allocation;
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch quick-assign multiple resources to a project for a date range.
|
|
||||||
*/
|
|
||||||
batchQuickAssign: managerProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
assignments: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
resourceId: z.string(),
|
|
||||||
projectId: z.string(),
|
|
||||||
startDate: z.coerce.date(),
|
|
||||||
endDate: z.coerce.date(),
|
|
||||||
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
|
||||||
role: z.string().min(1).max(200).default("Team Member"),
|
|
||||||
status: z
|
|
||||||
.nativeEnum(AllocationStatus)
|
|
||||||
.default(AllocationStatus.PROPOSED),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1)
|
|
||||||
.max(50),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
||||||
|
|
||||||
for (const assignment of input.assignments) {
|
|
||||||
if (assignment.endDate < assignment.startDate) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "End date must be after start date",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await ctx.db.$transaction(async (tx) => {
|
|
||||||
const created = [];
|
|
||||||
for (const assignment of input.assignments) {
|
|
||||||
const percentage = Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((assignment.hoursPerDay / 8) * 100),
|
|
||||||
);
|
|
||||||
const metadata = {
|
|
||||||
source: "batchQuickAssign",
|
|
||||||
} satisfies Record<string, unknown>;
|
|
||||||
|
|
||||||
const createdAssignment = await createAssignment(
|
|
||||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
|
||||||
{
|
|
||||||
resourceId: assignment.resourceId,
|
|
||||||
projectId: assignment.projectId,
|
|
||||||
startDate: assignment.startDate,
|
|
||||||
endDate: assignment.endDate,
|
|
||||||
hoursPerDay: assignment.hoursPerDay,
|
|
||||||
percentage,
|
|
||||||
role: assignment.role,
|
|
||||||
status: assignment.status,
|
|
||||||
metadata,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
created.push(createdAssignment);
|
|
||||||
}
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const assignment of results) {
|
|
||||||
emitAllocationCreated({
|
|
||||||
id: assignment.id,
|
|
||||||
projectId: assignment.projectId,
|
|
||||||
resourceId: assignment.resourceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count: results.length };
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch-shift multiple allocations by the same number of days.
|
|
||||||
*/
|
|
||||||
batchShiftAllocations: managerProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
allocationIds: z.array(z.string()).min(1).max(100),
|
|
||||||
daysDelta: z.number().int().min(-3650).max(3650),
|
|
||||||
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
|
||||||
|
|
||||||
if (input.daysDelta === 0) {
|
|
||||||
return { count: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 newStart = new Date(existing.startDate);
|
|
||||||
const newEnd = new Date(existing.endDate);
|
|
||||||
|
|
||||||
if (input.mode === "move") {
|
|
||||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
|
||||||
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(
|
|
||||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
|
||||||
{
|
|
||||||
id: existing.id,
|
|
||||||
demandRequirementUpdate: {
|
|
||||||
startDate: newStart,
|
|
||||||
endDate: newEnd,
|
|
||||||
},
|
|
||||||
assignmentUpdate: {
|
|
||||||
startDate: newStart,
|
|
||||||
endDate: newEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
updated.push(result.allocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
|
||||||
data: {
|
|
||||||
entityType: "Allocation",
|
|
||||||
entityId: input.allocationIds.join(","),
|
|
||||||
action: "UPDATE",
|
|
||||||
changes: {
|
|
||||||
operation: "batchShift",
|
|
||||||
mode: input.mode,
|
|
||||||
daysDelta: input.daysDelta,
|
|
||||||
count: resolved.length,
|
|
||||||
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const allocation of results) {
|
|
||||||
emitAllocationUpdated({
|
|
||||||
id: allocation.id,
|
|
||||||
projectId: allocation.projectId,
|
|
||||||
resourceId: allocation.resourceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count: results.length };
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user