feat: add guarded savings agent tools
This commit is contained in:
266
convex/savingsChatActionPlans.ts
Normal file
266
convex/savingsChatActionPlans.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user