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
+435 -8
View File
@@ -20,6 +20,13 @@ import {
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import {
loadTimelineEntriesReadModel,
loadTimelineHolidayOverlays,
loadTimelineProjectContext,
previewTimelineProjectShift,
type TimelineEntriesFilters,
} from "./timeline.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import {
emitNotificationCreated,
@@ -29,10 +36,11 @@ import {
emitBroadcastSent,
} from "../sse/event-bus.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
// ─── Mutation tool set for audit logging (EGAI 4.1.3.1 / IAAI 3.6.26) ──────
const MUTATION_TOOLS = new Set([
export const MUTATION_TOOLS = new Set([
"create_allocation", "cancel_allocation", "update_allocation_status",
"update_resource", "deactivate_resource", "create_resource",
"update_project", "create_project", "delete_project",
@@ -49,6 +57,10 @@ const MUTATION_TOOLS = new Set([
export const ADVANCED_ASSISTANT_TOOLS = new Set([
"find_best_project_resource",
"get_timeline_entries_view",
"get_timeline_holiday_overlays",
"get_project_timeline_context",
"preview_project_shift",
]);
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -58,6 +70,9 @@ export type ToolContext = {
userId: string;
userRole: string;
permissions: Set<PermissionKey>;
session?: TRPCContext["session"];
dbUser?: TRPCContext["dbUser"];
roleDefaults?: TRPCContext["roleDefaults"];
};
export interface ToolDef {
@@ -206,6 +221,127 @@ function createDateRange(input: {
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 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 parseIsoDate(value: string, fieldName: string): Date {
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid ${fieldName}: ${value}`);
}
return parsed;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
async function resolveProjectIdentifier(
identifier: string,
db: ToolContext["db"],
@@ -334,6 +470,81 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
},
},
},
{
type: "function",
function: {
name: "get_timeline_entries_view",
description: "Advanced assistant tool: read-only timeline entries view with the same timeline/disposition readmodel used by the app. Returns allocations, demands, assignments, and matching holiday overlays for a period.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the view." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the view." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the view." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the view." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_timeline_holiday_overlays",
description: "Advanced assistant tool: read-only holiday overlays for the timeline, resolved with the same holiday logic as the app. Useful to explain regional holiday differences for assigned or filtered resources.",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the overlays." },
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the overlays via matching assignments." },
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the overlays via matching projects." },
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the overlays." },
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
},
},
},
},
{
type: "function",
function: {
name: "get_project_timeline_context",
description: "Advanced assistant tool: read-only project timeline/disposition context. Reuses the same project context readmodel as the app and adds holiday overlays plus cross-project overlap summaries for assigned resources.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
startDate: { type: "string", description: "Optional holiday/conflict window start date in YYYY-MM-DD. Defaults to the project start date when available." },
endDate: { type: "string", description: "Optional holiday/conflict window end date in YYYY-MM-DD. Defaults to the project end date when available." },
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted." },
},
required: ["projectIdentifier"],
},
},
},
{
type: "function",
function: {
name: "preview_project_shift",
description: "Advanced assistant tool: read-only preview of the timeline shift validation for a project. Uses the same preview logic as the timeline router and does not write changes.",
parameters: {
type: "object",
properties: {
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
newStartDate: { type: "string", description: "New start date in YYYY-MM-DD." },
newEndDate: { type: "string", description: "New end date in YYYY-MM-DD." },
},
required: ["projectIdentifier", "newStartDate", "newEndDate"],
},
},
},
{
type: "function",
function: {
@@ -822,7 +1033,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "set_entitlement",
description: "Set vacation entitlement for a resource for a year. Requires admin permission. Always confirm first.",
description: "Set vacation entitlement for a resource for a year. Requires manageVacations permission. Always confirm first.",
parameters: {
type: "object",
properties: {
@@ -1014,7 +1225,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "create_estimate",
description: "Create a new estimate for a project. Requires manageEstimates permission. Always confirm first.",
description: "Create a new estimate for a project. Requires manageProjects permission. Always confirm first.",
parameters: {
type: "object",
properties: {
@@ -1159,7 +1370,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "list_users",
description: "List system users with their roles and linked resources. Requires admin permission.",
description: "List system users with their roles and linked resources. Requires manageUsers permission.",
parameters: {
type: "object",
properties: {
@@ -1236,7 +1447,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "create_org_unit",
description: "Create a new organizational unit. Requires admin permission. Always confirm first.",
description: "Create a new organizational unit. Requires manageResources permission. Always confirm first.",
parameters: {
type: "object",
properties: {
@@ -1253,7 +1464,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "update_org_unit",
description: "Update an organizational unit. Requires admin permission. Always confirm first.",
description: "Update an organizational unit. Requires manageResources permission. Always confirm first.",
parameters: {
type: "object",
properties: {
@@ -1378,7 +1589,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "create_task_for_user",
description: "Create a task for a specific user. Requires manageProjects or manageResources permission. The task appears in their task list.",
description: "Create a task for a specific user. Requires manageProjects permission. The task appears in their task list.",
parameters: {
type: "object",
properties: {
@@ -1399,7 +1610,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "send_broadcast",
description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manager permission.",
description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manageProjects permission.",
parameters: {
type: "object",
properties: {
@@ -2251,6 +2462,222 @@ const executors = {
};
},
async get_timeline_entries_view(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const { startDate, endDate } = createDateRange(params);
const filters = createTimelineFilters(params);
const input = { ...filters, startDate, endDate };
const [readModel, holidayOverlays] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
loadTimelineHolidayOverlays(ctx.db, input),
]);
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: {
...summarizeTimelineEntries(readModel),
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: readModel.allocations,
demands: readModel.demands,
assignments: readModel.assignments,
holidayOverlays: formattedHolidayOverlays,
};
},
async get_timeline_holiday_overlays(params: {
startDate?: string;
endDate?: string;
durationDays?: number;
resourceIds?: string[];
projectIds?: string[];
clientIds?: string[];
chapters?: string[];
eids?: string[];
countryCodes?: string[];
}, ctx: ToolContext) {
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const { startDate, endDate } = createDateRange(params);
const filters = createTimelineFilters(params);
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,
};
},
async get_project_timeline_context(params: {
projectIdentifier: string;
startDate?: string;
endDate?: string;
durationDays?: number;
}, ctx: ToolContext) {
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db);
if ("error" in project) {
return project;
}
const projectContext = await loadTimelineProjectContext(ctx.db, project.id);
const derivedStartDate = params.startDate
? parseIsoDate(params.startDate, "startDate")
: projectContext.project.startDate
?? projectContext.assignments[0]?.startDate
?? projectContext.demands[0]?.startDate
?? createDateRange({ durationDays: 1 }).startDate;
const derivedEndDate = params.endDate
? parseIsoDate(params.endDate, "endDate")
: projectContext.project.endDate
?? createDateRange({
startDate: fmtDate(derivedStartDate) ?? undefined,
durationDays: params.durationDays ?? 21,
}).endDate;
if (derivedEndDate < derivedStartDate) {
throw new Error("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: [project.id],
})
: [];
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 === project.id,
}));
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,
demands: projectContext.demands,
assignments: projectContext.assignments,
allResourceAllocations: projectContext.allResourceAllocations,
assignmentConflicts,
holidayOverlays: formattedHolidayOverlays,
resourceIds: projectContext.resourceIds,
};
},
async preview_project_shift(params: {
projectIdentifier: string;
newStartDate: string;
newEndDate: string;
}, ctx: ToolContext) {
assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db);
if ("error" in project) {
return project;
}
const newStartDate = parseIsoDate(params.newStartDate, "newStartDate");
const newEndDate = parseIsoDate(params.newEndDate, "newEndDate");
if (newEndDate < newStartDate) {
throw new Error("newEndDate must be on or after newStartDate.");
}
const preview = await previewTimelineProjectShift(ctx.db, {
projectId: project.id,
newStartDate,
newEndDate,
});
return {
project,
requestedShift: {
newStartDate: fmtDate(newStartDate),
newEndDate: fmtDate(newEndDate),
},
preview,
};
},
async list_allocations(params: {
resourceId?: string; projectId?: string;
resourceName?: string; projectCode?: string;
+505 -3
View File
@@ -5,10 +5,11 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { ADVANCED_ASSISTANT_TOOLS, MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
import { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
@@ -16,6 +17,33 @@ import { createAuditEntry } from "../lib/audit.js";
import { logger } from "../lib/logger.js";
const MAX_TOOL_ITERATIONS = 8;
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
type ChatMessage = { role: "user" | "assistant"; content: string };
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
export interface PendingAssistantApproval {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: number;
expiresAt: number;
}
export interface AssistantApprovalPayload {
id: string;
status: "pending" | "approved" | "cancelled";
conversationId: string;
toolName: string;
summary: string;
createdAt: string;
expiresAt: string;
}
const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung.
@@ -38,6 +66,7 @@ Wichtige Regeln:
- Antworte in der Sprache des Users (Deutsch oder Englisch)
- Geldbeträge: intern in Cent, konvertiere zu EUR für den User
- KRITISCH — Human-in-the-Loop (EGAI 4.1.3.1 / IAAI 3.6.26): Bevor du eine Aktion ausführst, die Daten erstellt, ändert oder löscht (create, update, delete, approve, reject, cancel, deactivate, fill, set, generate, remove, send), MUSST du dem User IMMER zuerst eine Zusammenfassung zeigen, was du tun wirst, und EXPLIZIT auf seine Bestätigung warten. Führe NIEMALS eine schreibende Aktion aus ohne vorherige Bestätigung des Users. Wenn der User "ja", "ok", "mach das", "bestätigt" o.ä. antwortet, dann erst ausführen.
- Wenn du eine Bestätigung brauchst, antworte zuerst mit einer Zeile, die GENAU mit "${ASSISTANT_CONFIRMATION_PREFIX}" beginnt, gefolgt von einer kurzen Maßnahmen-Zusammenfassung und der Bitte um Bestätigung. Erst nach einer bestätigenden User-Antwort darfst du ein Mutation-Tool aufrufen.
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
@@ -129,7 +158,360 @@ function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): As
return [...existing, next].slice(-6);
}
function parseToolArguments(args: string): Record<string, unknown> {
try {
const parsed = JSON.parse(args) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
function formatApprovalValue(value: unknown): string {
if (typeof value === "string") {
return value.length > 48 ? `${value.slice(0, 45)}...` : value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
return `[${value.slice(0, 3).map((item) => formatApprovalValue(item)).join(", ")}${value.length > 3 ? ", ..." : ""}]`;
}
if (value && typeof value === "object") {
return "{...}";
}
return "null";
}
function buildApprovalSummary(toolName: string, toolArguments: string): string {
const params = parseToolArguments(toolArguments);
const details = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== "")
.slice(0, 4)
.map(([key, value]) => `${key}=${formatApprovalValue(value)}`)
.join(", ");
const action = toolName.replace(/_/g, " ");
return details ? `${action} (${details})` : action;
}
function mapPendingApproval(record: {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: Date;
expiresAt: Date;
}): PendingAssistantApproval {
return {
id: record.id,
userId: record.userId,
conversationId: record.conversationId,
toolName: record.toolName,
toolArguments: record.toolArguments,
summary: record.summary,
createdAt: record.createdAt.getTime(),
expiresAt: record.expiresAt.getTime(),
};
}
function toApprovalPayload(
approval: PendingAssistantApproval,
status: AssistantApprovalPayload["status"],
): AssistantApprovalPayload {
return {
id: approval.id,
status,
conversationId: approval.conversationId,
toolName: approval.toolName,
summary: approval.summary,
createdAt: new Date(approval.createdAt).toISOString(),
expiresAt: new Date(approval.expiresAt).toISOString(),
};
}
export async function listPendingAssistantApprovals(
db: AssistantApprovalStore,
userId: string,
): Promise<PendingAssistantApproval[]> {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
return approvals.map(mapPendingApproval);
}
export async function clearPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<void> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
}
export async function peekPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
}
export async function consumePendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
const pending = await peekPendingAssistantApproval(db, userId, conversationId);
if (!pending) return null;
const approvedAt = new Date();
const updateResult = await db.assistantApproval.updateMany({
where: {
id: pending.id,
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: approvedAt },
},
data: {
status: AssistantApprovalStatus.APPROVED,
approvedAt,
},
});
if (updateResult.count === 0) return null;
const approved = await db.assistantApproval.findFirst({
where: {
id: pending.id,
userId,
conversationId,
},
});
if (!approved) return null;
return mapPendingApproval(approved);
}
export async function createPendingAssistantApproval(
db: AssistantApprovalStore,
userId: string,
conversationId: string,
toolName: string,
toolArguments: string,
options?: { summary?: string; ttlMs?: number },
): Promise<PendingAssistantApproval> {
const now = new Date();
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
}
function isAffirmativeConfirmationReply(content: string): boolean {
const normalized = content.trim().toLowerCase();
if (!normalized) return false;
const exactMatches = new Set([
"ja",
"yes",
"y",
"ok",
"okay",
"okey",
"mach das",
"bitte machen",
"bitte ausführen",
"bitte ausfuehren",
"ausführen",
"ausfuehren",
"bestätigt",
"bestaetigt",
"bestätigen",
"bestaetigen",
"confirm",
"confirmed",
"do it",
"go ahead",
"proceed",
]);
if (exactMatches.has(normalized)) return true;
const affirmativePatterns = [
/^(ja|yes|ok|okay)\b/,
/\b(mach|make|do|führ|fuehr|execute|run)\b.*\b(das|it|bitte|jetzt)\b/,
/\b(bit(?:te)?|please)\b.*\b(ausführen|ausfuehren|execute|run|machen|do)\b/,
/\b(bestätig|bestaetig|confirm)\w*\b/,
/\b(go ahead|proceed)\b/,
];
return affirmativePatterns.some((pattern) => pattern.test(normalized));
}
function isCancellationReply(content: string): boolean {
const normalized = content.trim().toLowerCase();
if (!normalized) return false;
const exactMatches = new Set([
"nein",
"no",
"abbrechen",
"cancel",
"stopp",
"stop",
"doch nicht",
"nicht ausführen",
"nicht ausfuehren",
]);
if (exactMatches.has(normalized)) return true;
return [
/\b(nein|no|cancel|abbrechen|stop|stopp)\b/,
/\b(doch nicht|nicht ausführen|nicht ausfuehren)\b/,
].some((pattern) => pattern.test(normalized));
}
function hasPendingAssistantConfirmation(messages: ChatMessage[]): boolean {
if (messages.length < 2) return false;
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role !== "user") return false;
for (let index = messages.length - 2; index >= 0; index -= 1) {
const message = messages[index];
if (!message) continue;
if (message.role === "assistant") {
return message.content.trimStart().startsWith(ASSISTANT_CONFIRMATION_PREFIX);
}
}
return false;
}
export function canExecuteMutationTool(
messages: ChatMessage[],
toolName: string,
pendingApproval?: PendingAssistantApproval | null,
): boolean {
if (!MUTATION_TOOLS.has(toolName)) return true;
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage.role !== "user") return false;
if (!isAffirmativeConfirmationReply(lastMessage.content)) return false;
if (pendingApproval) {
return pendingApproval.toolName === toolName && pendingApproval.expiresAt > Date.now();
}
return hasPendingAssistantConfirmation(messages);
}
function readToolError(result: Awaited<ReturnType<typeof executeTool>>): string | null {
if (result.data && typeof result.data === "object" && result.data !== null && "error" in (result.data as Record<string, unknown>)) {
const error = (result.data as Record<string, unknown>).error;
return typeof error === "string" ? error : null;
}
try {
const parsed = JSON.parse(result.content) as unknown;
if (parsed && typeof parsed === "object" && "error" in (parsed as Record<string, unknown>)) {
const error = (parsed as Record<string, unknown>).error;
return typeof error === "string" ? error : null;
}
} catch {
// tool content may be plain text
}
return null;
}
function readToolSuccessMessage(result: Awaited<ReturnType<typeof executeTool>>): string | null {
if (result.data && typeof result.data === "object" && result.data !== null) {
const data = result.data as Record<string, unknown>;
if (typeof data.message === "string" && data.message.trim().length > 0) return data.message;
if (typeof data.description === "string" && data.description.trim().length > 0) return data.description;
}
try {
const parsed = JSON.parse(result.content) as unknown;
if (parsed && typeof parsed === "object") {
const content = parsed as Record<string, unknown>;
if (typeof content.message === "string" && content.message.trim().length > 0) return content.message;
if (typeof content.description === "string" && content.description.trim().length > 0) return content.description;
}
} catch {
// tool content may be plain text
}
return typeof result.content === "string" && result.content.trim().length > 0
? result.content
: null;
}
export const assistantRouter = createTRPCRouter({
listPendingApprovals: protectedProcedure
.query(async ({ ctx }) => {
const approvals = await listPendingAssistantApprovals(ctx.db, ctx.dbUser!.id);
return approvals.map((approval) => toApprovalPayload(approval, "pending"));
}),
chat: protectedProcedure
.input(z.object({
messages: z.array(z.object({
@@ -137,6 +519,7 @@ export const assistantRouter = createTRPCRouter({
content: z.string(),
})).min(1).max(200),
pageContext: z.string().optional(),
conversationId: z.string().max(120).optional(),
}))
.mutation(async ({ ctx, input }) => {
// 1. Load AI settings
@@ -217,9 +600,94 @@ export const assistantRouter = createTRPCRouter({
const availableTools = getAvailableAssistantTools(permissions);
// 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const toolCtx: ToolContext = {
db: ctx.db,
userId: ctx.dbUser!.id,
userRole,
permissions,
session: ctx.session,
dbUser: ctx.dbUser,
roleDefaults: ctx.roleDefaults,
};
const collectedActions: ToolAction[] = [];
let collectedInsights: AssistantInsight[] = [];
const userId = ctx.dbUser!.id;
const conversationId = input.conversationId?.trim().slice(0, 120) || "default";
const pendingApproval = await peekPendingAssistantApproval(ctx.db, userId, conversationId);
if (pendingApproval && lastUserMsg?.role === "user") {
if (isCancellationReply(lastUserMsg.content)) {
await clearPendingAssistantApproval(ctx.db, userId, conversationId);
void createAuditEntry({
db: ctx.db,
entityType: "AiToolExecution",
entityId: pendingApproval.id,
entityName: pendingApproval.toolName,
action: "DELETE",
userId: ctx.dbUser?.id,
source: "ai",
summary: `AI approval cancelled: ${pendingApproval.toolName}`,
after: { approvalId: pendingApproval.id, params: parseToolArguments(pendingApproval.toolArguments), executed: false },
});
return {
content: `Aktion verworfen: ${pendingApproval.summary}`,
role: "assistant" as const,
approval: toApprovalPayload(pendingApproval, "cancelled"),
};
}
if (canExecuteMutationTool(input.messages, pendingApproval.toolName, pendingApproval)) {
const approvedAction = await consumePendingAssistantApproval(ctx.db, userId, conversationId) ?? pendingApproval;
const result = await executeTool(
approvedAction.toolName,
approvedAction.toolArguments,
toolCtx,
);
const insight = buildAssistantInsight(approvedAction.toolName, result.data);
if (insight) {
collectedInsights = mergeInsights(collectedInsights, insight);
}
if (result.action) {
collectedActions.push(result.action);
}
const errorMessage = readToolError(result);
const successMessage = readToolSuccessMessage(result);
const finalContent = errorMessage
? `Die bestätigte Aktion konnte nicht ausgeführt werden: ${errorMessage}`
: successMessage
? `Ausgeführt: ${successMessage}`
: `Ausgeführt: ${approvedAction.summary}`;
void createAuditEntry({
db: ctx.db,
entityType: "AiToolExecution",
entityId: approvedAction.id,
entityName: approvedAction.toolName,
action: "CREATE",
userId: ctx.dbUser?.id,
source: "ai",
summary: errorMessage
? `AI confirmed tool failed: ${approvedAction.toolName}`
: `AI executed previously approved tool: ${approvedAction.toolName}`,
after: {
approvalId: approvedAction.id,
params: parseToolArguments(approvedAction.toolArguments),
executed: !errorMessage,
},
});
return {
content: finalContent,
role: "assistant" as const,
approval: toApprovalPayload(approvedAction, "approved"),
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
}
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -258,6 +726,40 @@ export const assistantRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
if (MUTATION_TOOLS.has(toolCall.function.name)) {
const approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
void createAuditEntry({
db: ctx.db,
entityType: "AiToolExecution",
entityId: toolCall.id,
entityName: toolCall.function.name,
action: "CREATE",
userId: ctx.dbUser?.id,
source: "ai",
summary: `AI tool blocked pending confirmation: ${toolCall.function.name}`,
after: {
approvalId: approval.id,
params: parseToolArguments(toolCall.function.arguments),
executed: false,
},
});
return {
content: `${ASSISTANT_CONFIRMATION_PREFIX} ${approval.summary}. Bitte bestätigen.`,
role: "assistant" as const,
approval: toApprovalPayload(approval, "pending"),
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
const result = await executeTool(
toolCall.function.name,
toolCall.function.arguments,
@@ -296,7 +798,7 @@ export const assistantRouter = createTRPCRouter({
userId: ctx.dbUser?.id,
source: "ai",
summary: `AI executed tool: ${toolCall.function.name}`,
after: { params: parsedArgs },
after: { params: parsedArgs, executed: true },
});
}
+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.