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
+265
View File
@@ -0,0 +1,265 @@
import { expect, test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ADMIN_EMAIL = "admin@capakraken.dev";
const ADMIN_PASSWORD = "admin123";
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
const WEB_ENV_PATHS = [
resolve(process.cwd(), ".env.local"),
resolve(process.cwd(), "apps/web/.env.local"),
];
function resolveDatabaseUrl(): string {
const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"];
if (explicitPlaywrightUrl) {
return explicitPlaywrightUrl;
}
for (const envPath of WEB_ENV_PATHS) {
if (!existsSync(envPath)) {
continue;
}
const envFile = readFileSync(envPath, "utf8");
for (const rawLine of envFile.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) {
return line.slice("PLAYWRIGHT_DATABASE_URL=".length);
}
if (line.startsWith("DATABASE_URL_TEST=")) {
return line.slice("DATABASE_URL_TEST=".length);
}
if (line.startsWith("DATABASE_URL=")) {
return line.slice("DATABASE_URL=".length);
}
}
}
const fallbackTestUrl = process.env["DATABASE_URL_TEST"];
if (fallbackTestUrl) {
return fallbackTestUrl;
}
const fallback = process.env["DATABASE_URL"];
if (fallback) {
return fallback;
}
throw new Error("DATABASE_URL is not available for the Playwright assistant approval test.");
}
function runDbJson<T>(body: string): T {
const script = `
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
try {
${body}
} finally {
await prisma.$disconnect();
}
`;
const output = execFileSync("node", ["--input-type=module", "-e", script], {
cwd: DB_WORKDIR,
env: {
...process.env,
DATABASE_URL: resolveDatabaseUrl(),
},
encoding: "utf8",
}).trim();
return JSON.parse(output) as T;
}
function runDb(body: string): void {
runDbJson<null>(`${body}\nconsole.log("null");`);
}
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', ADMIN_EMAIL);
await page.fill('input[type="password"]', ADMIN_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
}
test.describe("Assistant approvals", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript((conversationId) => {
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
}, CURRENT_CONVERSATION_ID);
runDb(`
await prisma.systemSettings.upsert({
where: { id: "singleton" },
update: {
aiProvider: "openai",
azureOpenAiApiKey: "e2e-dummy-key",
azureOpenAiDeployment: "gpt-4o-mini",
},
create: {
id: "singleton",
aiProvider: "openai",
azureOpenAiApiKey: "e2e-dummy-key",
azureOpenAiDeployment: "gpt-4o-mini",
},
});
const admin = await prisma.user.findUniqueOrThrow({
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
select: { id: true },
});
await prisma.assistantApproval.deleteMany({
where: { userId: admin.id },
});
await prisma.client.deleteMany({
where: {
name: {
startsWith: "E2E Approval Client",
},
},
});
`);
});
test.afterEach(async () => {
runDb(`
await prisma.assistantApproval.deleteMany({
where: {
conversationId: {
startsWith: "assistant-e2e-",
},
},
});
await prisma.client.deleteMany({
where: {
name: {
startsWith: "E2E Approval Client",
},
},
});
`);
});
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
const suffix = Date.now();
const currentClientName = `E2E Approval Client Current ${suffix}`;
const otherClientName = `E2E Approval Client Other ${suffix}`;
const otherConversationId = `assistant-e2e-other-${suffix}`;
const { currentApproval, otherApproval } = runDbJson<{
currentApproval: { id: string; summary: string };
otherApproval: { id: string; summary: string };
}>(`
const admin = await prisma.user.findUniqueOrThrow({
where: { email: ${JSON.stringify(ADMIN_EMAIL)} },
select: { id: true },
});
const currentApproval = await prisma.assistantApproval.create({
data: {
userId: admin.id,
conversationId: ${JSON.stringify(CURRENT_CONVERSATION_ID)},
toolName: "create_client",
toolArguments: ${JSON.stringify(JSON.stringify({ name: currentClientName }))},
summary: ${JSON.stringify(`create client (name=${currentClientName})`)},
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
},
select: { id: true, summary: true },
});
const otherApproval = await prisma.assistantApproval.create({
data: {
userId: admin.id,
conversationId: ${JSON.stringify(otherConversationId)},
toolName: "create_client",
toolArguments: ${JSON.stringify(JSON.stringify({ name: otherClientName }))},
summary: ${JSON.stringify(`create client (name=${otherClientName})`)},
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
},
select: { id: true, summary: true },
});
console.log(JSON.stringify({ currentApproval, otherApproval }));
`);
await signInAsAdmin(page);
await page.goto("/dashboard");
await page.getByTestId("assistant-open-button").click();
await expect(page.getByRole("heading", { name: "HartBOT" })).toBeVisible();
await expect(page.getByTestId("assistant-open-approvals")).toBeVisible();
await expect(page.getByText(currentApproval.summary)).toBeVisible();
await expect(page.getByText(otherApproval.summary)).toBeVisible();
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
await expect(currentCard).toContainText("This chat");
await expect(otherCard).toContainText("Other chat");
await otherCard.getByTestId("assistant-approval-cancel").click();
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
await expect
.poll(async () => {
return runDbJson<string | null>(`
const approval = await prisma.assistantApproval.findUnique({
where: { id: ${JSON.stringify(otherApproval.id)} },
select: { status: true },
});
console.log(JSON.stringify(approval?.status ?? null));
`);
})
.toBe("CANCELLED");
await currentCard.getByTestId("assistant-approval-confirm").click();
await expect(page.getByText(`Ausgeführt: Created client: ${currentClientName}`)).toBeVisible();
await expect(page.getByText(currentApproval.summary)).toBeVisible();
await expect
.poll(async () => {
return runDbJson<string | null>(`
const approval = await prisma.assistantApproval.findUnique({
where: { id: ${JSON.stringify(currentApproval.id)} },
select: { status: true },
});
console.log(JSON.stringify(approval?.status ?? null));
`);
})
.toBe("APPROVED");
await expect
.poll(async () => {
return runDbJson<"created" | "missing">(`
const client = await prisma.client.findFirst({
where: { name: ${JSON.stringify(currentClientName)} },
select: { id: true },
});
console.log(JSON.stringify(client ? "created" : "missing"));
`);
})
.toBe("created");
await expect(page.getByTestId("assistant-approval-card")).toHaveCount(0);
});
});
+1 -1
View File
@@ -78,7 +78,7 @@ function writeManagedWebEnv(rootEnv) {
const contents = managedEnvKeys
.map((key) => {
const value = rootEnv[key] ?? process.env[key];
const value = process.env[key] ?? rootEnv[key];
return value ? `${key}=${value}` : null;
})
.filter(Boolean)
+221 -21
View File
@@ -39,6 +39,17 @@ interface Message {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
approval?: AssistantApproval;
}
interface AssistantApproval {
id: string;
status: "pending" | "approved" | "cancelled";
conversationId: string;
toolName: string;
summary: string;
createdAt: string;
expiresAt: string;
}
interface AssistantInsightMetric {
@@ -61,6 +72,19 @@ interface AssistantInsight {
}
const STORAGE_KEY = "capakraken-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
const approval = value as Partial<AssistantApproval>;
return typeof approval.id === "string"
&& (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled")
&& typeof approval.conversationId === "string"
&& typeof approval.toolName === "string"
&& typeof approval.summary === "string"
&& typeof approval.createdAt === "string"
&& typeof approval.expiresAt === "string";
}
/** Load messages from sessionStorage (survives page reloads, clears on tab close). */
function loadPersistedMessages(): Message[] {
@@ -81,6 +105,7 @@ function loadPersistedMessages(): Message[] {
role: item.role,
content: item.content,
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
}));
}
}
@@ -91,17 +116,47 @@ function loadPersistedMessages(): Message[] {
/** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */
let cachedMessages: Message[] | null = null;
function generateConversationId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `conversation-${Date.now()}`;
}
function loadConversationId(): string {
if (typeof window === "undefined") return generateConversationId();
try {
const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim();
if (raw) return raw;
} catch {
// ignore storage access errors
}
return generateConversationId();
}
export function ChatPanel({ onClose }: { onClose: () => void }) {
const pathname = usePathname();
const router = useRouter();
const utils = trpc.useUtils();
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvalNotice, setApprovalNotice] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatMutation = trpc.assistant.chat.useMutation();
const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, {
refetchInterval: 30_000,
});
const pendingApprovals = pendingApprovalsQuery.data ?? [];
const inlineApprovalIds = new Set(
messages
.map((message) => message.approval?.id)
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
);
const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id));
// Sync to module-level cache + sessionStorage on every change
useEffect(() => {
@@ -109,6 +164,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
}, [messages]);
useEffect(() => {
try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ }
}, [conversationId]);
// Auto-scroll to bottom on new messages
useEffect(() => {
const el = scrollRef.current;
@@ -120,37 +179,58 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
inputRef.current?.focus();
}, []);
const sendMessage = useCallback(async () => {
const text = input.trim();
const sendMessage = useCallback(async (
overrideText?: string,
targetConversationId?: string,
options?: { persistInCurrentChat?: boolean },
) => {
const text = (overrideText ?? input).trim();
if (!text || isLoading) return;
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
const activeConversationId = targetConversationId ?? conversationId;
setInput("");
if (!overrideText) {
setInput("");
}
setError(null);
setApprovalNotice(null);
const userMsg: Message = { role: "user", content: text };
const updated = [...messages, userMsg];
setMessages(updated);
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
if (persistInCurrentChat) {
setMessages(updated);
}
setIsLoading(true);
try {
const reply = await chatMutation.mutateAsync({
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
role: message.role,
content: message.content,
})),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
conversationId: activeConversationId,
});
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
approval?: AssistantApproval;
};
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
},
]);
if (persistInCurrentChat) {
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}),
},
]);
} else {
setApprovalNotice(typedReply.content);
}
// Handle actions from the AI (navigation, data invalidation)
const actions = typedReply.actions;
@@ -172,13 +252,14 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
}
}
}
await utils.assistant.listPendingApprovals.invalidate();
} catch (err) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, chatMutation, pathname]);
}, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]);
// Track user message history for up-arrow recall
const userHistory = useRef<string[]>([]);
@@ -221,6 +302,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
const clearChat = () => {
setMessages([]);
setError(null);
setApprovalNotice(null);
setConversationId(generateConversationId());
cachedMessages = null;
try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
};
@@ -269,6 +352,79 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
{(visiblePendingApprovals.length > 0 || approvalNotice) && (
<div
data-testid="assistant-open-approvals"
className="space-y-2 rounded-2xl border border-amber-200 bg-amber-50/80 p-3 text-xs text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-50"
>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-amber-800 dark:text-amber-200">
Open approvals
</div>
{visiblePendingApprovals.length > 0 && (
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[11px] font-medium dark:border-amber-800">
{visiblePendingApprovals.length}
</span>
)}
</div>
{approvalNotice && (
<div className="rounded-xl border border-amber-300/80 bg-white/70 px-3 py-2 text-[12px] text-amber-900 dark:border-amber-800 dark:bg-slate-900/40 dark:text-amber-100">
{approvalNotice}
</div>
)}
{visiblePendingApprovals.map((approval) => {
const belongsToCurrentConversation = approval.conversationId === conversationId;
return (
<div
key={approval.id}
data-testid="assistant-approval-card"
data-approval-id={approval.id}
data-conversation-scope={belongsToCurrentConversation ? "current" : "other"}
className="rounded-xl border border-amber-200/90 bg-white/80 px-3 py-2.5 shadow-sm dark:border-amber-900/50 dark:bg-slate-900/40"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-amber-950 dark:text-amber-50">
{approval.summary}
</div>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
<span>{approval.toolName}</span>
<span>Expires {new Date(approval.expiresAt).toLocaleString()}</span>
</div>
</div>
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] dark:border-amber-800">
{belongsToCurrentConversation ? "This chat" : "Other chat"}
</span>
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
data-testid="assistant-approval-confirm"
onClick={() => void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
disabled={isLoading}
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
>
Confirm
</button>
<button
type="button"
data-testid="assistant-approval-cancel"
onClick={() => void sendMessage("Abbrechen.", approval.conversationId, {
persistInCurrentChat: belongsToCurrentConversation,
})}
disabled={isLoading}
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
Cancel
</button>
</div>
</div>
);
})}
</div>
)}
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-12 text-center text-sm text-gray-400 dark:text-gray-500">
<svg className="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -279,12 +435,56 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
</div>
)}
{messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
<div key={i} className="space-y-2">
<ChatMessage
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
{msg.role === "assistant" && msg.approval && (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl border border-amber-200 bg-amber-50/90 px-3 py-2.5 text-xs text-amber-900 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold uppercase tracking-[0.08em]">
{msg.approval.status === "pending" ? "Approval pending" : msg.approval.status}
</span>
<span className="rounded-full border border-amber-300/70 px-2 py-0.5 font-medium dark:border-amber-800">
{msg.approval.toolName}
</span>
</div>
<div className="mt-1.5 text-sm font-medium text-amber-950 dark:text-amber-50">
{msg.approval.summary}
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
<span>Created: {new Date(msg.approval.createdAt).toLocaleString()}</span>
{msg.approval.status === "pending" && (
<span>Expires: {new Date(msg.approval.expiresAt).toLocaleString()}</span>
)}
</div>
{msg.approval.status === "pending" && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => void sendMessage("Ja, bitte ausführen.")}
disabled={isLoading}
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
>
Confirm
</button>
<button
type="button"
onClick={() => void sendMessage("Abbrechen.")}
disabled={isLoading}
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
Cancel
</button>
</div>
)}
</div>
</div>
)}
</div>
))}
{isLoading && <TypingIndicator />}
{error && (
@@ -910,6 +910,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<button
type="button"
onClick={() => setChatOpen(true)}
data-testid="assistant-open-button"
className="fixed bottom-6 right-6 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-brand-600 text-white shadow-lg shadow-brand-600/30 transition-all hover:bg-brand-700 hover:shadow-xl hover:shadow-brand-600/40 active:scale-95"
title="HartBOT"
>
@@ -75,6 +75,33 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
export type ViewMode = "resource" | "project";
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
const savedPrefs = readAppPreferences();
const next: TimelineFilters = {
...DEFAULT_FILTERS,
hideCompletedProjects: savedPrefs.hideCompletedProjects,
showPlaceholders: savedPrefs.showDemandProjects,
};
const eids = searchParams.get("eids");
if (eids) next.eids = eids.split(",").filter(Boolean);
const projectIds = searchParams.get("projectIds");
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
const chapters = searchParams.get("chapters");
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
const clientIds = searchParams.get("clientIds");
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
const countryCodes = searchParams.get("countryCodes");
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
if (eids || projectIds) {
next.showDrafts = true;
next.hideCompletedProjects = false;
}
return next;
}
// ─── Derived resource type used throughout the timeline ─────────────────────
export type ResourceBrief = {
id: string;
@@ -218,77 +245,31 @@ export function TimelineProvider({
const viewEnd = addDays(viewStart, viewDays);
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
const [filters, setFilters] = useState<TimelineFilters>(() => {
const savedPrefs = readAppPreferences();
const base: TimelineFilters = {
...DEFAULT_FILTERS,
hideCompletedProjects: savedPrefs.hideCompletedProjects,
showPlaceholders: savedPrefs.showDemandProjects,
};
const eids = searchParams.get("eids");
if (eids) base.eids = eids.split(",").filter(Boolean);
const projectIds = searchParams.get("projectIds");
if (projectIds) base.projectIds = projectIds.split(",").filter(Boolean);
const chapters = searchParams.get("chapters");
if (chapters) base.chapters = chapters.split(",").filter(Boolean);
const clientIds = searchParams.get("clientIds");
if (clientIds) base.clientIds = clientIds.split(",").filter(Boolean);
const countryCodes = searchParams.get("countryCodes");
if (countryCodes) base.countryCodes = countryCodes.split(",").filter(Boolean);
// If URL params specify filters, also show drafts and don't hide completed
if (eids || projectIds) {
base.showDrafts = true;
base.hideCompletedProjects = false;
}
return base;
});
// Track whether this is the initial mount (URL params already applied via useState initializers)
const isInitialMount = useRef(true);
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
// Sync filters/viewStart/viewDays when URL search params change AFTER initial mount
// (e.g. when the AI assistant calls router.push("/timeline?eids=...") while already on /timeline)
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
// Update viewStart if param changed
const spStart = searchParams.get("startDate");
if (spStart) {
const d = new Date(spStart);
if (!isNaN(d.getTime())) setViewStart(d);
}
setViewStart(() => {
if (spStart) {
const d = new Date(spStart);
if (!isNaN(d.getTime())) return d;
}
return addDays(today, -30);
});
// Update viewDays if param changed
const spDays = searchParams.get("days");
if (spDays) {
const n = parseInt(spDays, 10);
if (n > 0 && n <= 365) setViewDays(n);
}
setViewDays(() => {
if (spDays) {
const n = parseInt(spDays, 10);
if (n > 0 && n <= 365) return n;
}
return 180;
});
// Update filters if any filter params present
const eids = searchParams.get("eids");
const projectIds = searchParams.get("projectIds");
const chapters = searchParams.get("chapters");
const clientIds = searchParams.get("clientIds");
const countryCodes = searchParams.get("countryCodes");
if (eids || projectIds || chapters || clientIds || countryCodes) {
setFilters((prev) => {
const next = { ...prev };
if (eids) next.eids = eids.split(",").filter(Boolean);
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
if (eids || projectIds) {
next.showDrafts = true;
next.hideCompletedProjects = false;
}
return next;
});
}
}, [searchParams]);
setFilters(buildTimelineFiltersFromSearchParams(searchParams));
}, [searchParams, today]);
const [filterOpen, setFilterOpen] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("resource");
@@ -301,7 +282,9 @@ export function TimelineProvider({
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
const mountedRef = useRef(false);
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
@@ -314,17 +297,29 @@ export function TimelineProvider({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
) as {
data: TimelineEntriesView | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? [];
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
const {
data: vacationEntries = [],
refetch: refetchVacations,
} = trpc.vacation.list.useQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
const {
data: holidayOverlayEntries = [],
refetch: refetchHolidayOverlays,
} = trpc.timeline.getHolidayOverlays.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
@@ -337,6 +332,17 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
// Harden client-side route transitions: the timeline must actively refresh
// its core read models once on mount instead of relying on a prefetched shell.
void refetchEntriesView();
void refetchVacations();
void refetchHolidayOverlays();
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
const mergedEntries = [...(vacationEntries as VacationEntry[])];
+52 -74
View File
@@ -63,7 +63,12 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
### Teilweise abgedeckt
- Timeline: nur indirekt ueber Navigation und Allokations-Basisabfragen
- Timeline/Disposition read-only:
- `get_timeline_entries_view`
- `get_timeline_holiday_overlays`
- `get_project_timeline_context`
- `preview_project_shift`
- basiert bereits auf denselben Timeline-Readmodels/Shift-Preview-Helfern wie die UI
- Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle
- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
- Audit/History: nur einfache History-Abfragen, keine volle Audit-API
@@ -82,78 +87,48 @@ Es gibt aktuell vier Permission-/Scope-Ebenen:
- Import/Export-Flows
- User Self-Service und Preferences
- Country- und Metro-City-Administration
- Volle Timeline-Readmodels und Timeline-Mutationen
- Timeline-Mutationen und Dispo-spezifische Write-Flows
- Voller Estimate-Lifecycle
- Dispo-/Import-spezifische Flows
## Kritische Inkonsistenzen und Risiken
### P0: Human-in-the-Loop nur im Prompt, nicht serverseitig erzwungen
Stand 2026-03-28: Die frueheren P0s bei Notification-Scoping, `list_users`, Mutation-Audit und reinen Permission-Texten sind behoben. Die folgenden Punkte bleiben relevant.
Der System Prompt fordert bestaetigte Freigabe vor jeder schreibenden Aktion. Technisch wird das aber nicht serverseitig erzwungen. Wenn das Modell direkt ein Mutation-Tool aufruft, wird es ausgefuehrt.
### P0: Human-in-the-Loop ist serverseitig persistiert, aber noch nicht als vollwertiger Approval-Workspace ausgebaut
Betroffene Stellen:
Der Assistant blockiert Mutation-Tools serverseitig, legt dafuer persistente `assistantApproval`-Eintraege pro Nutzer und `conversationId` an und fuehrt die Aktion erst nach expliziter Bestaetigung in derselben Konversation aus.
- `packages/api/src/router/assistant.ts`
- `packages/api/src/router/assistant-tools.ts`
Die aktuelle Chat-UI persistiert die `conversationId` bereits im Browser-Session-Kontext und rendert Approval-Karten im Verlauf. Damit ist der grundlegende End-to-End-Fluss zwischen Frontend und Backend konsistent.
Aktuell noch offen:
- keine eigene Approval-Inbox / Uebersicht fuer mehrere offene Freigaben
- keine dedizierte Verwaltungs-UI ausserhalb des Chatverlaufs
- Timeout-/Ablauf-Handling ist funktional vorhanden, aber noch nicht als eigenstaendige Nutzerfuehrung sichtbar
Konsequenz:
- Die wichtigste Governance-Regel ist aktuell nur Prompt-Disziplin, keine technische Policy.
- Die zentrale Governance-Regel ist nicht mehr nur Prompt-Disziplin, sondern serverseitig gebunden.
- Fuer komplexe Mehrschritt-Workflows waere eine explizite Approval-Oberflaeche trotzdem robuster als reine Chat-Karten.
### P0: Notification-Scoping im Assistant ist fachlich/sicherheitsseitig falsch
### P1: Rechte-Paritaet zur Gesamt-App ist noch nicht vollstaendig
Die dedizierte `notificationRouter` scoped strikt auf den aktuellen Nutzer. Die Assistant-Tools tun das in `list_notifications` und `mark_notification_read` nicht.
Assistant-Verhalten:
- `list_notifications` listet Notifications ohne `userId`-Filter.
- `mark_notification_read` markiert per ID ohne Ownership-Check.
Die groben Tool-Guards sind deutlich konsistenter, aber der Assistant nutzt weiterhin in vielen Faellen eigene Queries statt die Readmodels der Fachrouter.
Konsequenz:
- Der Assistant kann Informationen sehen oder veraendern, die der Nutzer in der normalen Notification-UI nicht sehen duerfte.
- Objekt-Sichtbarkeit und Detailtiefe koennen in Randfaellen noch von der UI abweichen.
- Besonders Timeline-, Admin- und Spezial-Readmodels brauchen weiterhin dedizierte Assistant-Pendants.
### P0: `list_users` ist als admin-only beschrieben, aber nicht effektiv admin-only
### P1: Fehlende tiefe Fach-Readmodels und Write-Paritaet bleiben die groesste funktionale Luecke
Der Tool-Text sagt "Requires admin permission", aber es gibt weder einen Eintrag in `TOOL_PERMISSION_MAP` noch einen `assertPermission(...)` im Executor.
Der Assistant kann viele Kernfaelle, aber noch nicht denselben Arbeitsmodus wie spezialisierte Oberflaechen.
Konsequenz:
- Jeder Nutzer mit Assistant-Zugriff kann potenziell die User-Liste lesen, obwohl die normale App dies ueber `userRouter.list` nur Admins gibt.
### P1: Permission-Beschreibungen und technische Guards sind nicht konsistent
Beispiele:
- `create_estimate`
- Beschreibung: "Requires manageEstimates permission"
- Technik: `TOOL_PERMISSION_MAP` und Executor verlangen `manageProjects`
- `create_org_unit` / `update_org_unit`
- Beschreibung: "Requires admin permission"
- Technik: `manageResources`
- `send_broadcast`
- Beschreibung: "Requires manager permission"
- Technik: `manageProjects`
Konsequenz:
- Der Assistant ist fuer Nutzer und fuer uns selbst schwer vorhersehbar.
- Ein sauberer Rechteabgleich "User kann X in UI, also Assistant auch" ist dadurch nicht belastbar.
### P1: Nicht alle Assistant-Mutationen sind als Mutation-Typ sauber nachverfolgbar
`MUTATION_TOOLS` dient dem Logging von AI-Mutationen. Nicht jede schreibende Aktion ist dort gleich gut abgebildet.
Beispiel:
- `mark_notification_read` aendert Daten, ist aber nicht in `MUTATION_TOOLS`.
Konsequenz:
- Luecken im AI-spezifischen Audit-Trail.
- Timeline-Readmodel-Paritaet ist jetzt fuer die wichtigsten read-only Faelle vorhanden, aber komplexe Write-, Audit-, Admin- und Estimate-Workflows bleiben teilweise unvollstaendig
- tiefe Erklaerungen fuer Herleitungen und Governance sind noch nicht auf UI-Niveau
## Was der Assistant heute noch nicht "weiss"
@@ -179,15 +154,18 @@ Fehlend:
### Timeline und Disposition
- Vollstaendiges Timeline-Readmodel:
- `getEntriesView`
- Projekt-/Demand-/Assignment-Kontext in derselben Struktur wie die UI
- Holiday-Overlays der Timeline
- Projektkontext fuer Drag/Shift/Panel-Interaktionen
- Timeline-spezifische Vorschau-/Validierungsdaten:
- `previewShift`
- genaue Konflikte, Kosten-Delta, Auswirkungen vor Commit
- Batch- und Inline-Operationen der Timeline:
Bereits vorhanden:
- `get_timeline_entries_view`
- `get_timeline_holiday_overlays`
- `get_project_timeline_context`
- `preview_project_shift`
- Reuse derselben Timeline-Readmodels und Shift-Preview-Helfer wie in `timelineRouter`
Noch fehlend:
- vollstaendige Write-Paritaet fuer Timeline-/Dispo-Workflows
- Inline-/Batch-Operationen der Timeline:
- `updateAllocationInline`
- `quickAssign`
- `batchQuickAssign`
@@ -197,7 +175,7 @@ Fehlend:
Konsequenz:
- Der Assistant kann heute nicht denselben Timeline-Arbeitsmodus wie ein Nutzer in der UI abbilden.
- Der Assistant kann die wichtigsten Timeline-/Disposition-Readfaelle jetzt fachlich deutlich naeher an der UI abbilden, aber noch nicht denselben operativen Arbeitsmodus fuer Schreibaktionen und Imports.
### Transparenz, Herleitungen und Berechnungsgraphen
@@ -325,7 +303,7 @@ Fehlend:
### Deutlich unvollstaendige Router-Paritaet
- `timeline`
- `timeline` (read-only Kernfaelle vorhanden, Write-Paritaet fehlt)
- `vacation`
- `estimate`
- `notification`
@@ -353,22 +331,25 @@ Der Prompt suggeriert an mehreren Stellen mehr Paritaet, als technisch heute vor
### Problematische Aussagen
- "Urlaub, Feiertage" ist fuer Leseabfragen ok, aber nicht fuer Holiday-Calendar-Administration.
- "Notifications anzeigen" stimmt nur eingeschraenkt, weil das Assistant-Tooling aktuell nicht sauber auf den aktuellen Nutzer scoped.
- "Notifications anzeigen" ist fuer die Basisfaelle inzwischen sauberer gescoped, deckt aber weiterhin nicht die volle Notification-/Reminder-Paritaet der App ab.
- "Dashboard-Details abrufen" stimmt nur fuer einen Teil der Dashboard-/Analysewelt.
- "Den User zu relevanten Seiten navigieren" stimmt, ersetzt aber keine echte Daten-/Aktionsparitaet in Timeline, Holiday Editor oder Admin-Bereichen.
- "Ressourcenplanung und Projektmanagement" klingt umfassender, als die reale Tool-Abdeckung in spezialisierten Subsystemen ist.
### Wichtigste Prompt-Luecke
Die Human-in-the-Loop-Regel wird als harte Pflicht formuliert, ist technisch aber nicht hart erzwungen.
Die Human-in-the-Loop-Regel ist inzwischen serverseitig erzwungen. Der Prompt sollte trotzdem nicht so formulieren, als gaebe es bereits eine vollwertige Approval-Workbench oder vollstaendige Workflow-Paritaet.
## Was getan werden muss, damit der Assistant wirklich dieselben Nutzerfaehigkeiten hat
### P0: Sicherheits- und Governance-Hardening
1. Serverseitige Confirm-Policy fuer alle schreibenden Assistant-Tools einziehen.
- Schreibende Tool-Calls duerfen ohne bestaetigten Confirmation-Token nicht ausgefuehrt werden.
- Diese Policy darf nicht nur im Prompt stehen.
- Status: im Kern erledigt.
- Weiter offen:
- Approval-UX ausserhalb des Chatverlaufs
- bessere Sichtbarkeit mehrerer offener Freigaben
- explizitere Admin-/Audit-Auswertung offener/abgelaufener Freigaben
2. Assistant-Tools auf denselben Objekt-Scope wie die eigentlichen Router bringen.
- Besonders:
@@ -399,10 +380,7 @@ Die Human-in-the-Loop-Regel wird als harte Pflicht formuliert, ist technisch abe
2. Timeline-Assistant-Strang bauen
- Read:
- `get_timeline_entries_view`
- `get_timeline_holiday_overlays`
- `get_timeline_project_context`
- `preview_project_shift`
- Status: fuer die zentralen read-only Faelle umgesetzt
- Write:
- `update_allocation_inline`
- `apply_project_shift`
@@ -458,14 +436,14 @@ Die Human-in-the-Loop-Regel wird als harte Pflicht formuliert, ist technisch abe
### Stream A: Safety / Policy
- serverseitige Confirmation-Gates
- Approval-UX / Inbox / Lifecycle auf die persistente Server-Approval-Logik aufsetzen
- Ownership-/Permission-Fixes
- Mutation-Audit vervollstaendigen
### Stream B: Holiday + Timeline Parity
- Holiday-Calendar-Editor-Tools
- Timeline-Readmodels
- Timeline-Write-Aktionen
- Timeline-Shift-/Assign-Aktionen
### Stream C: Explainability / Analytics
@@ -487,6 +465,6 @@ Der Assistant ist bereits breit genug, um viele operative Fragen und Standardakt
Die drei groessten Blocker dafuer sind:
1. fehlende serverseitige Absicherung fuer schreibende AI-Aktionen,
1. fehlende vollwertige Approval-UX trotz bereits vorhandener serverseitiger Persistenz und Durchsetzung,
2. unvollstaendige fachliche Paritaet in Holiday/Timeline/Analytics/Admin-Bereichen,
3. inkonsistente oder zu schwache Permission- und Ownership-Pruefungen in einzelnen Tools.
@@ -1,12 +1,183 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { getAvailableAssistantTools } from "../router/assistant.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
clearPendingAssistantApproval,
consumePendingAssistantApproval,
createPendingAssistantApproval,
getAvailableAssistantTools,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
} from "../router/assistant.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
function getToolNames(permissions: PermissionKeyValue[]) {
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
}
const TEST_USER_ID = "assistant-test-user";
const TEST_CONVERSATION_ID = "assistant-test-conversation";
function createApprovalStoreMock() {
const records = new Map<string, {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
status: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
approvedAt: Date | null;
cancelledAt: Date | null;
createdAt: Date;
expiresAt: Date;
updatedAt: Date;
}>();
return {
assistantApproval: {
findFirst: vi.fn(async ({
where,
orderBy,
}: {
where: {
id?: string;
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
};
orderBy?: { createdAt: "desc" | "asc" };
}) => {
const matches = [...records.values()]
.filter((record) => (
(!where.id || record.id === where.id)
&& (!where.userId || record.userId === where.userId)
&& (!where.conversationId || record.conversationId === where.conversationId)
&& (!where.status || record.status === where.status)
))
.sort((a, b) => (
orderBy?.createdAt === "asc"
? a.createdAt.getTime() - b.createdAt.getTime()
: b.createdAt.getTime() - a.createdAt.getTime()
));
return matches[0] ?? null;
}),
findMany: vi.fn(async ({
where,
orderBy,
}: {
where: {
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
expiresAt?: { lte?: Date; gt?: Date };
};
orderBy?: { createdAt: "desc" | "asc" };
}) => (
[...records.values()]
.filter((record) => (
(!where.userId || record.userId === where.userId)
&& (!where.conversationId || record.conversationId === where.conversationId)
&& (!where.status || record.status === where.status)
&& (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte)
&& (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt)
))
.sort((a, b) => (
orderBy?.createdAt === "asc"
? a.createdAt.getTime() - b.createdAt.getTime()
: b.createdAt.getTime() - a.createdAt.getTime()
))
)),
create: vi.fn(async ({
data,
}: {
data: {
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: Date;
expiresAt: Date;
};
}) => {
const record = {
id: `approval-${records.size + 1}`,
...data,
status: "PENDING" as const,
approvedAt: null,
cancelledAt: null,
updatedAt: data.createdAt,
};
records.set(record.id, record);
return record;
}),
updateMany: vi.fn(async ({
where,
data,
}: {
where: {
id?: string;
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
expiresAt?: { lte?: Date; gt?: Date };
};
data: Partial<{
status: "APPROVED" | "CANCELLED" | "EXPIRED";
cancelledAt: Date;
approvedAt: Date;
}>;
}) => {
let count = 0;
for (const [id, record] of records.entries()) {
if (where.id && record.id !== where.id) continue;
if (where.userId && record.userId !== where.userId) continue;
if (where.conversationId && record.conversationId !== where.conversationId) continue;
if (where.status && record.status !== where.status) continue;
if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue;
if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue;
records.set(id, {
...record,
...data,
updatedAt: new Date(),
});
count += 1;
}
return { count };
}),
update: vi.fn(async ({
where,
data,
}: {
where: { id: string };
data: {
status: "APPROVED";
approvedAt: Date;
};
}) => {
const record = records.get(where.id);
if (!record) throw new Error("Record not found");
const next = {
...record,
...data,
updatedAt: new Date(),
};
records.set(where.id, next);
return next;
}),
},
};
}
describe("assistant router tool gating", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
approvalStore = createApprovalStoreMock();
});
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
const withAdvanced = getToolNames([
@@ -31,4 +202,200 @@ describe("assistant router tool gating", () => {
expect(names).not.toContain("find_best_project_resource");
});
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
], "create_project")).toBe(false);
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
{ role: "assistant", content: "Ich werde jetzt das Projekt erstellen." },
{ role: "user", content: "ja" },
], "create_project")).toBe(false);
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` },
{ role: "user", content: "ja, bitte ausführen" },
], "create_project")).toBe(true);
});
it("requires a matching server-side pending approval for mutation execution when provided", async () => {
const pendingApproval = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo", status: "DRAFT" }),
);
expect(canExecuteMutationTool([
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
{ role: "user", content: "ja" },
], "create_project", pendingApproval)).toBe(true);
expect(canExecuteMutationTool([
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
{ role: "user", content: "ja" },
], "delete_project", pendingApproval)).toBe(false);
});
it("stores and consumes pending approvals independently from chat text", async () => {
const approval = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }),
);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
id: approval.id,
toolName: "create_project",
summary: expect.stringContaining("create project"),
});
await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
id: approval.id,
toolName: "create_project",
});
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("expires stale pending approvals", async () => {
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
{ ttlMs: -1 },
);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("clears pending approvals for cancellation semantics", async () => {
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("isolates pending approvals by conversation", async () => {
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
otherConversationId,
"create_project",
JSON.stringify({ name: "Hermes" }),
);
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({
toolName: "create_project",
summary: expect.stringContaining("Hermes"),
});
});
it("lists only still-pending approvals for the current user across conversations", async () => {
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
otherConversationId,
"create_project",
JSON.stringify({ name: "Hermes" }),
);
const cancelled = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
`${TEST_CONVERSATION_ID}-cancelled`,
"create_project",
JSON.stringify({ name: "Cancelled" }),
);
await approvalStore.assistantApproval.updateMany({
where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" },
data: { status: "CANCELLED", cancelledAt: new Date() },
});
await createPendingAssistantApproval(
approvalStore,
"other-user",
`${TEST_CONVERSATION_ID}-foreign`,
"create_project",
JSON.stringify({ name: "Foreign" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
`${TEST_CONVERSATION_ID}-expired`,
"create_project",
JSON.stringify({ name: "Expired" }),
{ ttlMs: -1 },
);
const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID);
const approvalSummaries = approvals.map((approval) => approval.summary).join(" ");
expect(approvals).toHaveLength(2);
expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([
otherConversationId,
TEST_CONVERSATION_ID,
].sort());
expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true);
expect(approvalSummaries).toContain("Apollo");
expect(approvalSummaries).toContain("Hermes");
expect(approvalSummaries).not.toContain("Cancelled");
expect(approvalSummaries).not.toContain("Expired");
expect(approvalSummaries).not.toContain("Foreign");
});
it("does not require confirmation for read-only assistant tools", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Zeig mir meine Notifications" },
], "list_notifications")).toBe(true);
});
it("keeps assistant tool descriptions aligned with runtime permissions", () => {
const toolDescriptions = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]),
);
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
});
});
@@ -7,6 +7,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
@@ -201,6 +202,346 @@ describe("assistant advanced tools and scoping", () => {
);
});
it("returns timeline entries view with demand, assignment, and holiday overlay context", async () => {
const ctx = createToolContext(
{
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_1",
projectId: "project_1",
resourceId: null,
role: "Artist",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
clientId: "client_1",
budgetCents: 0,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
},
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_by",
projectId: "project_1",
resourceId: "res_by",
role: "Artist",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_by",
displayName: "Bayern User",
eid: "EMP-BY",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
clientId: "client_1",
budgetCents: 0,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
},
roleEntity: null,
},
{
id: "asg_hh",
projectId: "project_1",
resourceId: "res_hh",
role: "Artist",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-09T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_hh",
displayName: "Hamburg User",
eid: "EMP-HH",
chapter: "Delivery",
lcrCents: 10000,
},
project: {
id: "project_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
clientId: "client_1",
budgetCents: 0,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
responsiblePerson: "Larissa",
color: "#fff",
},
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
{
id: "res_hh",
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE" },
metroCity: { name: "Hamburg" },
},
]),
},
project: {
findMany: vi.fn(),
},
},
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_timeline_entries_view",
JSON.stringify({
startDate: "2026-01-05",
endDate: "2026-01-09",
projectIds: ["project_1"],
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
summary: {
demandCount: number;
assignmentCount: number;
overlayCount: number;
resourceCount: number;
};
demands: Array<{ id: string }>;
assignments: Array<{ id: string }>;
holidayOverlays: Array<{ resourceId: string; startDate: string; note: string; scope: string }>;
};
expect(parsed.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 2,
overlayCount: 1,
resourceCount: 2,
}),
);
expect(parsed.demands).toHaveLength(1);
expect(parsed.assignments).toHaveLength(2);
expect(parsed.holidayOverlays).toEqual([
expect.objectContaining({
resourceId: "res_by",
startDate: "2026-01-06",
note: "Heilige Drei Könige",
scope: "STATE",
}),
]);
});
it("returns project timeline context with cross-project overlap summaries", async () => {
const project = {
id: "project_ctx",
name: "Gelddruckmaschine",
shortCode: "GDM",
orderType: "CHARGEABLE",
budgetCents: 100000,
winProbability: 100,
status: "ACTIVE",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
staffingReqs: null,
};
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_ctx", name: "Gelddruckmaschine", shortCode: "GDM", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
{
id: "asg_other",
projectId: "project_other",
resourceId: "res_1",
startDate: new Date("2026-01-08T00:00:00.000Z"),
endDate: new Date("2026-01-10T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_other", name: "Other Project", shortCode: "OTH", status: "ACTIVE", orderType: "CHARGEABLE", dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "Delivery" },
},
]);
const ctx = createToolContext(
{
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce(project)
.mockResolvedValueOnce(project),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_ctx",
projectId: "project_ctx",
resourceId: null,
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "OPEN",
metadata: null,
project,
roleEntity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asg_project",
projectId: "project_ctx",
resourceId: "res_1",
role: "Artist",
hoursPerDay: 6,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
metadata: null,
resource: {
id: "res_1",
displayName: "Alice",
eid: "EMP-1",
chapter: "Delivery",
lcrCents: 10000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
project,
roleEntity: null,
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
},
]),
},
},
[PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"get_project_timeline_context",
JSON.stringify({
projectIdentifier: "project_ctx",
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: { id: string; shortCode: string };
summary: {
demandCount: number;
assignmentCount: number;
conflictedAssignmentCount: number;
overlayCount: number;
};
assignmentConflicts: Array<{
assignmentId: string;
crossProjectOverlapCount: number;
overlaps: Array<{ projectShortCode: string; sameProject: boolean }>;
}>;
holidayOverlays: Array<{ startDate: string }>;
};
expect(parsed.project).toEqual(
expect.objectContaining({
id: "project_ctx",
shortCode: "GDM",
}),
);
expect(parsed.summary).toEqual(
expect.objectContaining({
demandCount: 1,
assignmentCount: 1,
conflictedAssignmentCount: 1,
overlayCount: 1,
}),
);
expect(parsed.assignmentConflicts).toEqual([
expect.objectContaining({
assignmentId: "asg_project",
crossProjectOverlapCount: 1,
overlaps: expect.arrayContaining([
expect.objectContaining({
projectShortCode: "OTH",
sameProject: false,
}),
]),
}),
]);
expect(parsed.holidayOverlays).toEqual([
expect.objectContaining({
startDate: "2026-01-06",
}),
]);
});
it("scopes assistant notification listing to the current user", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const ctx = createToolContext({
+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.
+29
View File
@@ -201,6 +201,7 @@ model User {
comments Comment[]
activeSessions ActiveSession[]
reportTemplates ReportTemplate[]
assistantApprovals AssistantApproval[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -208,6 +209,13 @@ model User {
@@map("users")
}
enum AssistantApprovalStatus {
PENDING
APPROVED
CANCELLED
EXPIRED
}
enum ReportTemplateEntity {
RESOURCE
PROJECT
@@ -234,6 +242,27 @@ model ReportTemplate {
@@map("report_templates")
}
model AssistantApproval {
id String @id @default(cuid())
userId String
conversationId String
toolName String
toolArguments String @db.Text
summary String
status AssistantApprovalStatus @default(PENDING)
approvedAt DateTime?
cancelledAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, conversationId, status, expiresAt])
@@index([status, expiresAt])
@@map("assistant_approvals")
}
model Account {
id String @id @default(cuid())
userId String