267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
});
|