feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -8,10 +8,10 @@ import {
|
||||
updateDemandRequirement,
|
||||
updateAllocationEntry,
|
||||
} from "@capakraken/application";
|
||||
import { Prisma, VacationType } from "@capakraken/db";
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
||||
import { VacationType } from "@capakraken/db";
|
||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
} from "../sse/event-bus.js";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
type ShiftDbClient = Pick<
|
||||
PrismaClient,
|
||||
@@ -52,6 +54,20 @@ export type TimelineEntriesFilters = {
|
||||
countryCodes?: string[] | undefined;
|
||||
};
|
||||
|
||||
const TimelineWindowFiltersSchema = z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
|
||||
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
|
||||
export function getAssignmentResourceIds(
|
||||
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||
): string[] {
|
||||
@@ -64,6 +80,215 @@ export function getAssignmentResourceIds(
|
||||
];
|
||||
}
|
||||
|
||||
function fmtDate(value: Date | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function createUtcDate(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function createTimelineDateRange(input: {
|
||||
startDate?: string | undefined;
|
||||
endDate?: string | undefined;
|
||||
durationDays?: number | undefined;
|
||||
}): { startDate: Date; endDate: Date } {
|
||||
const now = new Date();
|
||||
const startDate = input.startDate
|
||||
? new Date(`${input.startDate}T00:00:00.000Z`)
|
||||
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid startDate: ${input.startDate}`,
|
||||
});
|
||||
}
|
||||
|
||||
const endDate = input.endDate
|
||||
? new Date(`${input.endDate}T00:00:00.000Z`)
|
||||
: createUtcDate(
|
||||
startDate.getUTCFullYear(),
|
||||
startDate.getUTCMonth(),
|
||||
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
|
||||
);
|
||||
|
||||
if (Number.isNaN(endDate.getTime())) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid endDate: ${input.endDate}`,
|
||||
});
|
||||
}
|
||||
if (endDate < startDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "endDate must be on or after startDate.",
|
||||
});
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
|
||||
const normalized = values
|
||||
?.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
return normalized && normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function createTimelineFilters(input: {
|
||||
resourceIds?: string[] | undefined;
|
||||
projectIds?: string[] | undefined;
|
||||
clientIds?: string[] | undefined;
|
||||
chapters?: string[] | undefined;
|
||||
eids?: string[] | undefined;
|
||||
countryCodes?: string[] | undefined;
|
||||
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
|
||||
return {
|
||||
resourceIds: normalizeStringList(input.resourceIds),
|
||||
projectIds: normalizeStringList(input.projectIds),
|
||||
clientIds: normalizeStringList(input.clientIds),
|
||||
chapters: normalizeStringList(input.chapters),
|
||||
eids: normalizeStringList(input.eids),
|
||||
countryCodes: normalizeStringList(input.countryCodes),
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyTimelineEntriesView() {
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements: [],
|
||||
assignments: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function findOwnedTimelineResourceId(
|
||||
ctx: TimelineSelfServiceContext,
|
||||
): Promise<string | null> {
|
||||
if (!ctx.dbUser?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resource = await ctx.db.resource.findFirst({
|
||||
where: { userId: ctx.dbUser.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return resource?.id ?? null;
|
||||
}
|
||||
|
||||
async function buildSelfServiceTimelineInput(
|
||||
ctx: TimelineSelfServiceContext,
|
||||
input: TimelineWindowFiltersInput,
|
||||
): Promise<TimelineEntriesFilters | null> {
|
||||
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
|
||||
if (!ownedResourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
resourceIds: [ownedResourceId],
|
||||
projectIds: normalizeStringList(input.projectIds),
|
||||
clientIds: normalizeStringList(input.clientIds),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeTimelineEntries(readModel: {
|
||||
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
|
||||
demands: Array<{ projectId: string | null }>;
|
||||
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
|
||||
}) {
|
||||
const projectIds = new Set<string>();
|
||||
const resourceIds = new Set<string>();
|
||||
|
||||
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
|
||||
if (entry.projectId) {
|
||||
projectIds.add(entry.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
|
||||
if (assignment.resourceId) {
|
||||
resourceIds.add(assignment.resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allocationCount: readModel.allocations.length,
|
||||
demandCount: readModel.demands.length,
|
||||
assignmentCount: readModel.assignments.length,
|
||||
projectCount: projectIds.size,
|
||||
resourceCount: resourceIds.size,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHolidayOverlays(
|
||||
overlays: Array<{
|
||||
id: string;
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
note?: string | null;
|
||||
scope?: string | null;
|
||||
calendarName?: string | null;
|
||||
sourceType?: string | null;
|
||||
}>,
|
||||
) {
|
||||
return overlays.map((overlay) => ({
|
||||
id: overlay.id,
|
||||
resourceId: overlay.resourceId,
|
||||
startDate: fmtDate(overlay.startDate),
|
||||
endDate: fmtDate(overlay.endDate),
|
||||
note: overlay.note ?? null,
|
||||
scope: overlay.scope ?? null,
|
||||
calendarName: overlay.calendarName ?? null,
|
||||
sourceType: overlay.sourceType ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeHolidayOverlays(
|
||||
overlays: ReturnType<typeof formatHolidayOverlays>,
|
||||
) {
|
||||
const resourceIds = new Set<string>();
|
||||
const byScope = new Map<string, number>();
|
||||
|
||||
for (const overlay of overlays) {
|
||||
resourceIds.add(overlay.resourceId);
|
||||
const scope = overlay.scope ?? "UNKNOWN";
|
||||
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
overlayCount: overlays.length,
|
||||
holidayResourceCount: resourceIds.size,
|
||||
byScope: [...byScope.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([scope, count]) => ({ scope, count })),
|
||||
};
|
||||
}
|
||||
|
||||
function rangesOverlap(
|
||||
leftStart: Date,
|
||||
leftEnd: Date,
|
||||
rightStart: Date,
|
||||
rightEnd: Date,
|
||||
): boolean {
|
||||
return leftStart <= rightEnd && rightStart <= leftEnd;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export async function loadTimelineEntriesReadModel(
|
||||
db: TimelineEntriesDbClient,
|
||||
input: TimelineEntriesFilters,
|
||||
@@ -147,6 +372,14 @@ export async function loadTimelineHolidayOverlays(
|
||||
input: TimelineEntriesFilters,
|
||||
) {
|
||||
const readModel = await loadTimelineEntriesReadModel(db, input);
|
||||
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
|
||||
}
|
||||
|
||||
async function loadTimelineHolidayOverlaysForReadModel(
|
||||
db: TimelineEntriesDbClient,
|
||||
input: TimelineEntriesFilters,
|
||||
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||
) {
|
||||
const resourceIds = [...new Set(
|
||||
readModel.assignments
|
||||
.map((assignment) => assignment.resourceId)
|
||||
@@ -380,17 +613,56 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
|
||||
}
|
||||
|
||||
/** 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 db.calculationRule.findMany({
|
||||
const rules = await calculationRuleModel.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (rules.length > 0) {
|
||||
return rules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
} 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;
|
||||
}
|
||||
@@ -440,8 +712,14 @@ async function buildAbsenceDays(
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// vacation table may not exist yet
|
||||
} 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 };
|
||||
@@ -452,38 +730,16 @@ export const timelineRouter = createTRPCRouter({
|
||||
* Get all timeline entries (projects + allocations) for a date range.
|
||||
* Includes project startDate, endDate, staffingReqs for demand overlay.
|
||||
*/
|
||||
getEntries: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
getEntries: controllerProcedure
|
||||
.input(TimelineWindowFiltersSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
|
||||
}),
|
||||
|
||||
getEntriesView: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
getEntriesView: controllerProcedure
|
||||
.input(TimelineWindowFiltersSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [readModel, directory] = await Promise.all([
|
||||
loadTimelineEntriesReadModel(ctx.db, input),
|
||||
@@ -497,11 +753,47 @@ export const timelineRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getHolidayOverlays: protectedProcedure
|
||||
getMyEntriesView: protectedProcedure
|
||||
.input(TimelineWindowFiltersSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
|
||||
if (!selfServiceInput) {
|
||||
return createEmptyTimelineEntriesView();
|
||||
}
|
||||
|
||||
const [readModel, directory] = await Promise.all([
|
||||
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
|
||||
getAnonymizationDirectory(ctx.db),
|
||||
]);
|
||||
|
||||
return {
|
||||
...readModel,
|
||||
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
|
||||
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
|
||||
};
|
||||
}),
|
||||
|
||||
getHolidayOverlays: controllerProcedure
|
||||
.input(TimelineWindowFiltersSchema)
|
||||
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
|
||||
|
||||
getMyHolidayOverlays: protectedProcedure
|
||||
.input(TimelineWindowFiltersSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
|
||||
if (!selfServiceInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
|
||||
}),
|
||||
|
||||
getEntriesDetail: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
durationDays: z.number().int().min(1).max(366).optional(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
@@ -510,7 +802,73 @@ export const timelineRouter = createTRPCRouter({
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate } = createTimelineDateRange(input);
|
||||
const filters = createTimelineFilters(input);
|
||||
const timelineInput = { ...filters, startDate, endDate };
|
||||
|
||||
const [readModel, directory] = await Promise.all([
|
||||
loadTimelineEntriesReadModel(ctx.db, timelineInput),
|
||||
getAnonymizationDirectory(ctx.db),
|
||||
]);
|
||||
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
|
||||
ctx.db,
|
||||
timelineInput,
|
||||
readModel,
|
||||
);
|
||||
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
|
||||
|
||||
return {
|
||||
period: {
|
||||
startDate: fmtDate(startDate),
|
||||
endDate: fmtDate(endDate),
|
||||
},
|
||||
filters,
|
||||
summary: {
|
||||
...summarizeTimelineEntries(readModel),
|
||||
...summarizeHolidayOverlays(formattedHolidayOverlays),
|
||||
},
|
||||
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
|
||||
demands: readModel.demands,
|
||||
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
|
||||
holidayOverlays: formattedHolidayOverlays,
|
||||
};
|
||||
}),
|
||||
|
||||
getHolidayOverlayDetail: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
durationDays: z.number().int().min(1).max(366).optional(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate } = createTimelineDateRange(input);
|
||||
const filters = createTimelineFilters(input);
|
||||
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
|
||||
...filters,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
|
||||
|
||||
return {
|
||||
period: {
|
||||
startDate: fmtDate(startDate),
|
||||
endDate: fmtDate(endDate),
|
||||
},
|
||||
filters,
|
||||
summary: summarizeHolidayOverlays(formattedOverlays),
|
||||
overlays: formattedOverlays,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get full project context for a project:
|
||||
@@ -519,7 +877,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
* - all assignment bookings for the same resources (for cross-project overlap display)
|
||||
* Used when: drag starts or project panel opens.
|
||||
*/
|
||||
getProjectContext: protectedProcedure
|
||||
getProjectContext: controllerProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const {
|
||||
@@ -548,6 +906,122 @@ export const timelineRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getProjectContextDetail: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
durationDays: z.number().int().min(1).max(366).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
const derivedStartDate = input.startDate
|
||||
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
|
||||
: projectContext.project.startDate
|
||||
?? projectContext.assignments[0]?.startDate
|
||||
?? projectContext.demands[0]?.startDate
|
||||
?? createTimelineDateRange({ durationDays: 1 }).startDate;
|
||||
const derivedEndDate = input.endDate
|
||||
? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
|
||||
: projectContext.project.endDate
|
||||
?? createTimelineDateRange({
|
||||
startDate: fmtDate(derivedStartDate) ?? undefined,
|
||||
durationDays: input.durationDays ?? 21,
|
||||
}).endDate;
|
||||
|
||||
if (derivedEndDate < derivedStartDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "endDate must be on or after startDate.",
|
||||
});
|
||||
}
|
||||
|
||||
const holidayOverlays = projectContext.resourceIds.length > 0
|
||||
? await loadTimelineHolidayOverlays(ctx.db, {
|
||||
startDate: derivedStartDate,
|
||||
endDate: derivedEndDate,
|
||||
resourceIds: projectContext.resourceIds,
|
||||
projectIds: [input.projectId],
|
||||
})
|
||||
: [];
|
||||
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
|
||||
|
||||
const assignmentConflicts = projectContext.assignments
|
||||
.filter((assignment) => assignment.resourceId && assignment.resource)
|
||||
.map((assignment) => {
|
||||
const overlaps = projectContext.allResourceAllocations
|
||||
.filter((booking) => (
|
||||
booking.resourceId === assignment.resourceId
|
||||
&& booking.id !== assignment.id
|
||||
&& rangesOverlap(
|
||||
toDate(booking.startDate),
|
||||
toDate(booking.endDate),
|
||||
toDate(assignment.startDate),
|
||||
toDate(assignment.endDate),
|
||||
)
|
||||
))
|
||||
.map((booking) => ({
|
||||
id: booking.id,
|
||||
projectId: booking.projectId,
|
||||
projectName: booking.project?.name ?? null,
|
||||
projectShortCode: booking.project?.shortCode ?? null,
|
||||
startDate: fmtDate(toDate(booking.startDate)),
|
||||
endDate: fmtDate(toDate(booking.endDate)),
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
sameProject: booking.projectId === input.projectId,
|
||||
}));
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
resourceId: assignment.resourceId!,
|
||||
resourceName: assignment.resource?.displayName ?? null,
|
||||
startDate: fmtDate(toDate(assignment.startDate)),
|
||||
endDate: fmtDate(toDate(assignment.endDate)),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
overlapCount: overlaps.length,
|
||||
crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
|
||||
overlaps,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
project: projectContext.project,
|
||||
period: {
|
||||
startDate: fmtDate(derivedStartDate),
|
||||
endDate: fmtDate(derivedEndDate),
|
||||
},
|
||||
summary: {
|
||||
...summarizeTimelineEntries({
|
||||
allocations: projectContext.allocations,
|
||||
demands: projectContext.demands,
|
||||
assignments: projectContext.assignments,
|
||||
}),
|
||||
resourceIds: projectContext.resourceIds.length,
|
||||
allResourceAllocationCount: projectContext.allResourceAllocations.length,
|
||||
conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
|
||||
...summarizeHolidayOverlays(formattedHolidayOverlays),
|
||||
},
|
||||
allocations: projectContext.allocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
demands: projectContext.demands,
|
||||
assignments: projectContext.assignments.map((assignment) =>
|
||||
anonymizeResourceOnEntry(assignment, directory),
|
||||
),
|
||||
allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
assignmentConflicts,
|
||||
holidayOverlays: formattedHolidayOverlays,
|
||||
resourceIds: projectContext.resourceIds,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Inline update of an allocation's hours, dates, includeSaturday, or role.
|
||||
* Recalculates dailyCostCents and emits SSE.
|
||||
@@ -682,10 +1156,50 @@ export const timelineRouter = createTRPCRouter({
|
||||
* Preview a project shift — validate without committing.
|
||||
* Returns cost impact, conflicts, warnings.
|
||||
*/
|
||||
previewShift: protectedProcedure
|
||||
previewShift: controllerProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
|
||||
|
||||
getShiftPreviewDetail: controllerProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [project, preview] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
status: true,
|
||||
responsiblePerson: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
previewTimelineProjectShift(ctx.db, input),
|
||||
]);
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
shortCode: project.shortCode,
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson,
|
||||
startDate: fmtDate(project.startDate),
|
||||
endDate: fmtDate(project.endDate),
|
||||
},
|
||||
requestedShift: {
|
||||
newStartDate: fmtDate(input.newStartDate),
|
||||
newEndDate: fmtDate(input.newEndDate),
|
||||
},
|
||||
preview,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Apply a project shift — validate, then commit all allocation date changes.
|
||||
* Reads includeSaturday from each allocation's metadata.
|
||||
@@ -1044,7 +1558,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get budget status for a project.
|
||||
*/
|
||||
getBudgetStatus: protectedProcedure
|
||||
getBudgetStatus: controllerProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await findUniqueOrThrow(
|
||||
@@ -1052,6 +1566,8 @@ export const timelineRouter = createTRPCRouter({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
startDate: true,
|
||||
@@ -1066,7 +1582,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
projectIds: [project.id],
|
||||
});
|
||||
|
||||
return computeBudgetStatus(
|
||||
const budgetStatus = computeBudgetStatus(
|
||||
project.budgetCents,
|
||||
project.winProbability,
|
||||
bookings.map((booking) => ({
|
||||
@@ -1079,5 +1595,13 @@ export const timelineRouter = createTRPCRouter({
|
||||
project.startDate,
|
||||
project.endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
...budgetStatus,
|
||||
projectName: project.name,
|
||||
projectCode: project.shortCode,
|
||||
totalAllocations: bookings.length,
|
||||
budgetCents: project.budgetCents,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user