feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -0,0 +1,266 @@
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<Doc<"agentActionPlans">["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<Doc<"agentActionPlans">["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<Doc<"agentActionPlans">["operations"][number], { type: "update_category" }>,
) {
const category = await ctx.db.get(operation.categoryId);
if (!category || category.userId !== userId) return false;
const patch: Partial<Doc<"categories">> = {};
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,
};
},
});