feat(assistant): add approval inbox and e2e hardening

This commit is contained in:
2026-03-29 10:10:59 +02:00
parent 4f48afe7b4
commit beae1a5d6e
12 changed files with 2482 additions and 331 deletions
+188 -153
View File
@@ -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.