diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts new file mode 100644 index 0000000..d0684f5 --- /dev/null +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -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 { + const calculationRuleModel = (db as PrismaClient & { + calculationRule?: { findMany?: (args: unknown) => Promise }; + }).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) ?? {}; + const newMeta: Record = { + ...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[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; + + const allocation = await ctx.db.$transaction(async (tx) => { + const assignment = await createAssignment( + tx as unknown as Parameters[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; + + const createdAssignment = await createAssignment( + tx as unknown as Parameters[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 => 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[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 }; + }), +}; diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index 15c7f9d..df45427 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -1,268 +1,16 @@ -import { - buildSplitAllocationReadModel, - createAssignment, - findAllocationEntry, - loadAllocationEntry, - updateAssignment, - updateDemandRequirement, - updateAllocationEntry, -} from "@capakraken/application"; -import { Prisma, VacationType } from "@capakraken/db"; +import { updateAssignment, updateDemandRequirement } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; -import { calculateAllocation, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; -import type { CalculationRule, AbsenceDay } from "@capakraken/shared"; -import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared"; +import { calculateAllocation, validateShift } from "@capakraken/engine"; +import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { - emitAllocationCreated, - emitAllocationUpdated, - emitProjectShifted, -} from "../sse/event-bus.js"; -import { logger } from "../lib/logger.js"; +import { emitProjectShifted } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; +import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.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 { - const calculationRuleModel = (db as PrismaClient & { - calculationRule?: { findMany?: (args: unknown) => Promise }; - }).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({ ...timelineReadProcedures, - - /** - * 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) ?? {}; - const newMeta: Record = { - ...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[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; - }), + ...timelineAllocationMutationProcedures, /** * Apply a project shift: validate, then commit all allocation date changes. @@ -378,235 +126,4 @@ export const timelineRouter = createTRPCRouter({ 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; - - const allocation = await ctx.db.$transaction(async (tx) => { - const assignment = await createAssignment( - tx as unknown as Parameters[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; - - const createdAssignment = await createAssignment( - tx as unknown as Parameters[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 => 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[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 }; - }), });