import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { assertOwned, requireUserId } from "./lib/helpers"; import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; const MAX_PLAN_OPERATIONS = 100; const planKindValidator = v.union( v.literal("bulk_recategory"), v.literal("category_changes"), ); const planStatusValidator = v.union( v.literal("pending"), v.literal("applied"), v.literal("dismissed"), v.literal("expired"), ); const operationValidator = v.union( v.object({ type: v.literal("set_transaction_category"), transactionId: v.id("transactions"), fromCategoryId: v.optional(v.id("categories")), toCategoryId: v.id("categories"), }), v.object({ type: v.literal("create_category"), name: v.string(), kind: v.union(v.literal("einnahme"), v.literal("ausgabe")), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), color: v.string(), icon: v.optional(v.string()), sortOrder: v.number(), }), v.object({ type: v.literal("update_category"), categoryId: v.id("categories"), name: v.optional(v.string()), kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), color: v.optional(v.string()), icon: v.optional(v.string()), sortOrder: v.optional(v.number()), }), ); const previewRowValidator = v.object({ date: v.optional(v.string()), description: v.string(), amount: v.optional(v.number()), currentCategoryName: v.optional(v.string()), targetCategoryName: v.optional(v.string()), action: v.optional(v.string()), }); const actionPlanValidator = v.object({ _id: v.id("agentActionPlans"), _creationTime: v.number(), userId: v.id("users"), sessionId: v.id("chatSessions"), kind: planKindValidator, status: planStatusValidator, summary: v.string(), affectedCount: v.number(), createdAt: v.number(), expiresAt: v.number(), operations: v.array(operationValidator), previewRows: v.array(previewRowValidator), resultSummary: v.optional(v.string()), }); async function requireOwnedSession( ctx: QueryCtx | MutationCtx, sessionId: Id<"chatSessions">, userId: Id<"users">, ) { const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat"); if (session.isDeleted) throw new Error("Chat nicht gefunden"); return session; } async function requireOwnedPlan( ctx: QueryCtx | MutationCtx, planId: Id<"agentActionPlans">, userId: Id<"users">, ) { const plan = await assertOwned(await ctx.db.get(planId), userId, "Vorschlag"); await requireOwnedSession(ctx, plan.sessionId, userId); return plan; } async function applyTransactionCategory( ctx: MutationCtx, userId: Id<"users">, operation: Extract["operations"][number], { type: "set_transaction_category" }>, ) { const tx = await ctx.db.get(operation.transactionId); if (!tx || tx.userId !== userId) return false; const targetCategory = await ctx.db.get(operation.toCategoryId); if (!targetCategory || targetCategory.userId !== userId) return false; if (tx.categoryId !== operation.fromCategoryId) return false; await ctx.db.patch(tx._id, { categoryId: operation.toCategoryId }); return true; } async function applyCreateCategory( ctx: MutationCtx, userId: Id<"users">, operation: Extract["operations"][number], { type: "create_category" }>, ) { const name = operation.name.trim(); if (!name) return false; if (operation.kind === "ausgabe" && !operation.block) return false; const existing = await ctx.db .query("categories") .withIndex("by_user_name", (index) => index.eq("userId", userId).eq("name", name), ) .unique(); if (existing) return false; await ctx.db.insert("categories", { userId, name, kind: operation.kind, block: operation.kind === "ausgabe" ? operation.block : undefined, color: operation.color, icon: operation.icon, sortOrder: operation.sortOrder, isSystem: false, }); return true; } async function applyUpdateCategory( ctx: MutationCtx, userId: Id<"users">, operation: Extract["operations"][number], { type: "update_category" }>, ) { const category = await ctx.db.get(operation.categoryId); if (!category || category.userId !== userId) return false; const patch: Partial> = {}; for (const key of ["name", "kind", "block", "color", "icon", "sortOrder"] as const) { if (operation[key] !== undefined) { patch[key] = operation[key] as never; } } if (patch.name !== undefined) { const name = patch.name.trim(); if (!name) return false; const existing = await ctx.db .query("categories") .withIndex("by_user_name", (index) => index.eq("userId", userId).eq("name", name)) .unique(); if (existing && existing._id !== operation.categoryId) return false; patch.name = name; } const kind = patch.kind ?? category.kind; const block = patch.block ?? category.block; if (kind === "ausgabe" && !block) return false; if (kind === "einnahme") patch.block = undefined; if (Object.keys(patch).length === 0) return false; await ctx.db.patch(operation.categoryId, patch); return true; } export const listPendingActionPlans = query({ args: { sessionId: v.id("chatSessions"), now: v.optional(v.number()) }, returns: v.array(actionPlanValidator), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const session = await ctx.db.get(args.sessionId); if (!session || session.userId !== userId || session.isDeleted) return []; const now = args.now ?? Date.now(); const plans = await ctx.db .query("agentActionPlans") .withIndex("by_user_session_status_created", (index) => index .eq("userId", userId) .eq("sessionId", args.sessionId) .eq("status", "pending"), ) .order("desc") .take(20); return plans.filter((plan) => plan.expiresAt > now); }, }); export const dismissActionPlan = mutation({ args: { planId: v.id("agentActionPlans") }, returns: v.null(), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const plan = await requireOwnedPlan(ctx, args.planId, userId); if (plan.status !== "pending") return null; await ctx.db.patch(args.planId, { status: "dismissed", resultSummary: "Vorschlag verworfen", }); return null; }, }); export const applyActionPlan = mutation({ args: { planId: v.id("agentActionPlans"), now: v.optional(v.number()) }, returns: v.object({ appliedCount: v.number(), skippedCount: v.number(), status: planStatusValidator, summary: v.string(), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const plan = await requireOwnedPlan(ctx, args.planId, userId); const now = args.now ?? Date.now(); if (plan.status !== "pending") { return { appliedCount: 0, skippedCount: plan.operations.length, status: plan.status as "pending" | "applied" | "dismissed" | "expired", summary: plan.resultSummary ?? "Vorschlag ist nicht mehr offen", }; } if (plan.expiresAt <= now) { await ctx.db.patch(args.planId, { status: "expired" as const, resultSummary: "Vorschlag ist abgelaufen", }); return { appliedCount: 0, skippedCount: plan.operations.length, status: "expired" as const, summary: "Vorschlag ist abgelaufen", }; } if (plan.operations.length > MAX_PLAN_OPERATIONS) { throw new Error("Zu viele Änderungen in einem Vorschlag"); } let appliedCount = 0; let skippedCount = 0; for (const operation of plan.operations) { const applied = operation.type === "set_transaction_category" ? await applyTransactionCategory(ctx, userId, operation) : operation.type === "create_category" ? await applyCreateCategory(ctx, userId, operation) : await applyUpdateCategory(ctx, userId, operation); if (applied) { appliedCount++; } else { skippedCount++; } } const summary = `${appliedCount} Änderung${appliedCount === 1 ? "" : "en"} angewendet, ${skippedCount} übersprungen`; await ctx.db.patch(args.planId, { status: "applied" as const, resultSummary: summary, }); return { appliedCount, skippedCount, status: "applied" as const, summary, }; }, });