feat(assistant): add approval inbox and e2e hardening
This commit is contained in:
+188
-153
@@ -36,12 +36,12 @@ type ShiftDbClient = Pick<
|
||||
"project" | "demandRequirement" | "assignment"
|
||||
>;
|
||||
|
||||
type TimelineEntriesDbClient = Pick<
|
||||
export type TimelineEntriesDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
|
||||
>;
|
||||
|
||||
type TimelineEntriesFilters = {
|
||||
export type TimelineEntriesFilters = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
resourceIds?: string[] | undefined;
|
||||
@@ -52,7 +52,7 @@ type TimelineEntriesFilters = {
|
||||
countryCodes?: string[] | undefined;
|
||||
};
|
||||
|
||||
function getAssignmentResourceIds(
|
||||
export function getAssignmentResourceIds(
|
||||
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
|
||||
): string[] {
|
||||
return [
|
||||
@@ -64,7 +64,7 @@ function getAssignmentResourceIds(
|
||||
];
|
||||
}
|
||||
|
||||
async function loadTimelineEntriesReadModel(
|
||||
export async function loadTimelineEntriesReadModel(
|
||||
db: TimelineEntriesDbClient,
|
||||
input: TimelineEntriesFilters,
|
||||
) {
|
||||
@@ -142,6 +142,109 @@ async function loadTimelineEntriesReadModel(
|
||||
return buildSplitAllocationReadModel({ demandRequirements, assignments });
|
||||
}
|
||||
|
||||
export async function loadTimelineHolidayOverlays(
|
||||
db: TimelineEntriesDbClient,
|
||||
input: TimelineEntriesFilters,
|
||||
) {
|
||||
const readModel = await loadTimelineEntriesReadModel(db, input);
|
||||
const resourceIds = [...new Set(
|
||||
readModel.assignments
|
||||
.map((assignment) => assignment.resourceId)
|
||||
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
|
||||
)];
|
||||
|
||||
if (input.resourceIds && input.resourceIds.length > 0) {
|
||||
for (const resourceId of input.resourceIds) {
|
||||
if (resourceId && !resourceIds.includes(resourceId)) {
|
||||
resourceIds.push(resourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasResourceFilters =
|
||||
(input.chapters?.length ?? 0) > 0 ||
|
||||
(input.eids?.length ?? 0) > 0 ||
|
||||
(input.countryCodes?.length ?? 0) > 0;
|
||||
|
||||
if (hasResourceFilters) {
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (input.chapters && input.chapters.length > 0) {
|
||||
andConditions.push({ chapter: { in: input.chapters } });
|
||||
}
|
||||
if (input.eids && input.eids.length > 0) {
|
||||
andConditions.push({ eid: { in: input.eids } });
|
||||
}
|
||||
if (input.countryCodes && input.countryCodes.length > 0) {
|
||||
andConditions.push({ country: { code: { in: input.countryCodes } } });
|
||||
}
|
||||
|
||||
const matchingResources = await db.resource.findMany({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const resource of matchingResources) {
|
||||
if (!resourceIds.includes(resource.id)) {
|
||||
resourceIds.push(resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { id: { in: resourceIds } },
|
||||
select: {
|
||||
id: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const overlays = await Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return holidays.map((holiday) => {
|
||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||
return {
|
||||
id: `calendar-holiday:${resource.id}:${holiday.date}`,
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: "APPROVED" as const,
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
sourceType: holiday.sourceType,
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return overlays.flat().sort((left, right) => {
|
||||
if (left.resourceId !== right.resourceId) {
|
||||
return left.resourceId.localeCompare(right.resourceId);
|
||||
}
|
||||
return left.startDate.getTime() - right.startDate.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
@@ -195,6 +298,74 @@ async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
orderType: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
staffingReqs: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
loadProjectPlanningReadModel(db, {
|
||||
projectId,
|
||||
activeOnly: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
|
||||
const allResourceAllocations =
|
||||
resourceIds.length === 0
|
||||
? []
|
||||
: await listAssignmentBookings(db, {
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
allocations: planningRead.readModel.allocations,
|
||||
demands: planningRead.readModel.demands,
|
||||
assignments: planningRead.readModel.assignments,
|
||||
allResourceAllocations,
|
||||
resourceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewTimelineProjectShift(
|
||||
db: ShiftDbClient,
|
||||
input: {
|
||||
projectId: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
},
|
||||
) {
|
||||
const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId);
|
||||
|
||||
return validateShift({
|
||||
project: {
|
||||
id: project.id,
|
||||
budgetCents: project.budgetCents,
|
||||
winProbability: project.winProbability,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
},
|
||||
newStartDate: input.newStartDate,
|
||||
newEndDate: input.newEndDate,
|
||||
allocations: shiftPlan.validationAllocations,
|
||||
});
|
||||
}
|
||||
|
||||
function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
|
||||
entry: T,
|
||||
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
|
||||
@@ -339,102 +510,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||
const resourceIds = [...new Set(
|
||||
readModel.assignments
|
||||
.map((assignment) => assignment.resourceId)
|
||||
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
|
||||
)];
|
||||
|
||||
if (input.resourceIds && input.resourceIds.length > 0) {
|
||||
for (const resourceId of input.resourceIds) {
|
||||
if (resourceId && !resourceIds.includes(resourceId)) {
|
||||
resourceIds.push(resourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasResourceFilters =
|
||||
(input.chapters?.length ?? 0) > 0 ||
|
||||
(input.eids?.length ?? 0) > 0 ||
|
||||
(input.countryCodes?.length ?? 0) > 0;
|
||||
|
||||
if (hasResourceFilters) {
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (input.chapters && input.chapters.length > 0) {
|
||||
andConditions.push({ chapter: { in: input.chapters } });
|
||||
}
|
||||
if (input.eids && input.eids.length > 0) {
|
||||
andConditions.push({ eid: { in: input.eids } });
|
||||
}
|
||||
if (input.countryCodes && input.countryCodes.length > 0) {
|
||||
andConditions.push({ country: { code: { in: input.countryCodes } } });
|
||||
}
|
||||
|
||||
const matchingResources = await ctx.db.resource.findMany({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const resource of matchingResources) {
|
||||
if (!resourceIds.includes(resource.id)) {
|
||||
resourceIds.push(resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { id: { in: resourceIds } },
|
||||
select: {
|
||||
id: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const overlays = await Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return holidays.map((holiday) => {
|
||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||
return {
|
||||
id: `calendar-holiday:${resource.id}:${holiday.date}`,
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: "APPROVED" as const,
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return overlays.flat().sort((left, right) => {
|
||||
if (left.resourceId !== right.resourceId) {
|
||||
return left.resourceId.localeCompare(right.resourceId);
|
||||
}
|
||||
return left.startDate.getTime() - right.startDate.getTime();
|
||||
});
|
||||
}),
|
||||
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
|
||||
|
||||
/**
|
||||
* Get full project context for a project:
|
||||
@@ -446,48 +522,23 @@ export const timelineRouter = createTRPCRouter({
|
||||
getProjectContext: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [project, planningRead] = await Promise.all([
|
||||
findUniqueOrThrow(
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortCode: true,
|
||||
orderType: true,
|
||||
budgetCents: true,
|
||||
winProbability: true,
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
staffingReqs: true,
|
||||
},
|
||||
}),
|
||||
"Project",
|
||||
),
|
||||
loadProjectPlanningReadModel(ctx.db, {
|
||||
projectId: input.projectId,
|
||||
activeOnly: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
|
||||
const allResourceAllocations =
|
||||
resourceIds.length === 0
|
||||
? []
|
||||
: await listAssignmentBookings(ctx.db, {
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
const {
|
||||
project,
|
||||
allocations,
|
||||
demands,
|
||||
assignments,
|
||||
allResourceAllocations,
|
||||
resourceIds,
|
||||
} = await loadTimelineProjectContext(ctx.db, input.projectId);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return {
|
||||
project,
|
||||
allocations: planningRead.readModel.allocations.map((allocation) =>
|
||||
allocations: allocations.map((allocation) =>
|
||||
anonymizeResourceOnEntry(allocation, directory),
|
||||
),
|
||||
demands: planningRead.readModel.demands,
|
||||
assignments: planningRead.readModel.assignments.map((assignment) =>
|
||||
demands,
|
||||
assignments: assignments.map((assignment) =>
|
||||
anonymizeResourceOnEntry(assignment, directory),
|
||||
),
|
||||
allResourceAllocations: allResourceAllocations.map((allocation) =>
|
||||
@@ -633,23 +684,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
*/
|
||||
previewShift: protectedProcedure
|
||||
.input(ShiftProjectSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { projectId, newStartDate, newEndDate } = input;
|
||||
const { project, shiftPlan } = await loadProjectShiftContext(ctx.db, projectId);
|
||||
|
||||
return validateShift({
|
||||
project: {
|
||||
id: project.id,
|
||||
budgetCents: project.budgetCents,
|
||||
winProbability: project.winProbability,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
},
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
allocations: shiftPlan.validationAllocations,
|
||||
});
|
||||
}),
|
||||
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
|
||||
|
||||
/**
|
||||
* Apply a project shift — validate, then commit all allocation date changes.
|
||||
|
||||
Reference in New Issue
Block a user