diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index e1069a3..38f1a07 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,7 +9,12 @@ */ import type * as auth from "../auth.js"; +import type * as canvases from "../canvases.js"; +import type * as credits from "../credits.js"; +import type * as edges from "../edges.js"; +import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; +import type * as nodes from "../nodes.js"; import type { ApiFromModules, @@ -19,7 +24,12 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; + canvases: typeof canvases; + credits: typeof credits; + edges: typeof edges; + helpers: typeof helpers; http: typeof http; + nodes: typeof nodes; }>; /** diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index a850cc1..f97fd19 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -8,29 +8,29 @@ * @module */ -import { AnyDataModel } from "convex/server"; +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; import type { GenericId } from "convex/values"; - -/** - * No `schema.ts` file found! - * - * This generated code has permissive types like `Doc = any` because - * Convex doesn't know your schema. If you'd like more type safety, see - * https://docs.convex.dev/using/schemas for instructions on how to add a - * schema file. - * - * After you change a schema, rerun codegen with `npx convex dev`. - */ +import schema from "../schema.js"; /** * The names of all of your Convex tables. */ -export type TableNames = string; +export type TableNames = TableNamesInDataModel; /** * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = any; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -42,8 +42,10 @@ export type Doc = any; * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Id = +export type Id = GenericId; /** @@ -55,4 +57,4 @@ export type Id = * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ -export type DataModel = AnyDataModel; +export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/canvases.ts b/convex/canvases.ts new file mode 100644 index 0000000..7221223 --- /dev/null +++ b/convex/canvases.ts @@ -0,0 +1,139 @@ +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { requireAuth } from "./helpers"; + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Alle Canvases des eingeloggten Users, sortiert nach letzter Bearbeitung. + */ +export const list = query({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + return await ctx.db + .query("canvases") + .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) + .order("desc") + .collect(); + }, +}); + +/** + * Einzelnen Canvas laden — mit Owner-Check. + */ +export const get = query({ + args: { canvasId: v.id("canvases") }, + handler: async (ctx, { canvasId }) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + return null; + } + return canvas; + }, +}); + +// ============================================================================ +// Mutations +// ============================================================================ + +/** + * Neuen Canvas erstellen. + */ +export const create = mutation({ + args: { + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { name, description }) => { + const user = await requireAuth(ctx); + const now = Date.now(); + const canvasId = await ctx.db.insert("canvases", { + name, + ownerId: user.userId, + description, + updatedAt: now, + }); + return canvasId; + }, +}); + +/** + * Canvas umbenennen oder Beschreibung ändern. + */ +export const update = mutation({ + args: { + canvasId: v.id("canvases"), + name: v.optional(v.string()), + description: v.optional(v.string()), + }, + handler: async (ctx, { canvasId, name, description }) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + + const updates: Record = { updatedAt: Date.now() }; + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + + await ctx.db.patch(canvasId, updates); + }, +}); + +/** + * Canvas löschen — entfernt auch alle zugehörigen Nodes und Edges. + */ +export const remove = mutation({ + args: { canvasId: v.id("canvases") }, + handler: async (ctx, { canvasId }) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + + // Alle Nodes dieses Canvas löschen + const nodes = await ctx.db + .query("nodes") + .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) + .collect(); + for (const node of nodes) { + await ctx.db.delete(node._id); + } + + // Alle Edges dieses Canvas löschen + const edges = await ctx.db + .query("edges") + .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) + .collect(); + for (const edge of edges) { + await ctx.db.delete(edge._id); + } + + // Canvas selbst löschen + await ctx.db.delete(canvasId); + }, +}); + +/** + * Canvas-Thumbnail aktualisieren. + */ +export const setThumbnail = mutation({ + args: { + canvasId: v.id("canvases"), + thumbnail: v.id("_storage"), + }, + handler: async (ctx, { canvasId, thumbnail }) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + await ctx.db.patch(canvasId, { thumbnail, updatedAt: Date.now() }); + }, +}); diff --git a/convex/credits.ts b/convex/credits.ts new file mode 100644 index 0000000..dc7921d --- /dev/null +++ b/convex/credits.ts @@ -0,0 +1,539 @@ +import { query, mutation, internalMutation } from "./_generated/server"; +import { v } from "convex/values"; +import { requireAuth } from "./helpers"; + +// ============================================================================ +// Tier-Konfiguration +// ============================================================================ + +export const TIER_CONFIG = { + free: { + monthlyCredits: 50, // €0,50 in Cent + dailyGenerationCap: 10, + concurrencyLimit: 1, + premiumModels: false, + topUpLimit: 0, // Kein Top-Up für Free + }, + starter: { + monthlyCredits: 630, // €6,30 in Cent + dailyGenerationCap: 50, + concurrencyLimit: 2, + premiumModels: true, + topUpLimit: 2000, // €20 pro Monat + }, + pro: { + monthlyCredits: 3602, // €36,02 in Cent (+5% Bonus) + dailyGenerationCap: 200, + concurrencyLimit: 2, + premiumModels: true, + topUpLimit: 10000, // €100 pro Monat + }, + business: { + monthlyCredits: 7623, // €76,23 in Cent (+10% Bonus) + dailyGenerationCap: 500, + concurrencyLimit: 2, + premiumModels: true, + topUpLimit: 50000, // €500 pro Monat + }, +} as const; + +export type Tier = keyof typeof TIER_CONFIG; + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Credit-Balance des eingeloggten Users abrufen. + * Gibt balance, reserved und computed available zurück. + */ +export const getBalance = query({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + + if (!balance) { + return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 }; + } + + return { + balance: balance.balance, + reserved: balance.reserved, + available: balance.balance - balance.reserved, + monthlyAllocation: balance.monthlyAllocation, + }; + }, +}); + +/** + * Letzte Transaktionen des Users abrufen. + */ +export const listTransactions = query({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, { limit }) => { + const user = await requireAuth(ctx); + return await ctx.db + .query("creditTransactions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .take(limit ?? 50); + }, +}); + +/** + * Aktuelle Subscription des Users abrufen. + */ +export const getSubscription = query({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + return await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .first(); + }, +}); + +/** + * Heutige Nutzung des Users abrufen (für Abuse Prevention). + */ +export const getDailyUsage = query({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + const today = new Date().toISOString().split("T")[0]; // "2026-03-25" + + const usage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + + return usage ?? { generationCount: 0, concurrentJobs: 0 }; + }, +}); + +// ============================================================================ +// Mutations — Credit Balance Management +// ============================================================================ + +/** + * Credit-Balance für einen neuen User initialisieren. + * Wird beim ersten Login / Signup aufgerufen. + */ +export const initBalance = mutation({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + + // Prüfen ob schon existiert + const existing = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + + if (existing) return existing._id; + + // Free-Tier Credits als Startguthaben + const balanceId = await ctx.db.insert("creditBalances", { + userId: user.userId, + balance: TIER_CONFIG.free.monthlyCredits, + reserved: 0, + monthlyAllocation: TIER_CONFIG.free.monthlyCredits, + updatedAt: Date.now(), + }); + + // Initiale Subscription (Free) + await ctx.db.insert("subscriptions", { + userId: user.userId, + tier: "free", + status: "active", + currentPeriodStart: Date.now(), + currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, // +30 Tage + }); + + // Initiale Transaktion loggen + await ctx.db.insert("creditTransactions", { + userId: user.userId, + amount: TIER_CONFIG.free.monthlyCredits, + type: "subscription", + status: "committed", + description: "Startguthaben — Free Tier", + }); + + return balanceId; + }, +}); + +// ============================================================================ +// Mutations — Reservation + Commit (Kern des Credit-Systems) +// ============================================================================ + +/** + * Credits reservieren — vor einem KI-Call. + * + * Prüft: ausreichend verfügbare Credits, Daily Cap, Concurrency Limit. + * Gibt die Transaction-ID zurück (wird zum Commit/Release benötigt). + */ +export const reserve = mutation({ + args: { + estimatedCost: v.number(), // Geschätzte Kosten in Cent + description: v.string(), + nodeId: v.optional(v.id("nodes")), + canvasId: v.optional(v.id("canvases")), + model: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + + // Balance laden + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + if (!balance) throw new Error("No credit balance found. Call initBalance first."); + + const available = balance.balance - balance.reserved; + if (available < args.estimatedCost) { + throw new Error( + `Insufficient credits. Available: ${available}, required: ${args.estimatedCost}` + ); + } + + // Subscription laden für Tier-Checks + const subscription = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .first(); + const tier = (subscription?.tier ?? "free") as Tier; + const config = TIER_CONFIG[tier]; + + // Daily Cap prüfen + const today = new Date().toISOString().split("T")[0]; + const dailyUsage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + + if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { + throw new Error( + `Daily generation limit reached (${config.dailyGenerationCap}/${tier})` + ); + } + + // Concurrency Limit prüfen + if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { + throw new Error( + `Concurrent job limit reached (${config.concurrencyLimit}/${tier})` + ); + } + + // Credits reservieren + await ctx.db.patch(balance._id, { + reserved: balance.reserved + args.estimatedCost, + updatedAt: Date.now(), + }); + + // Daily Usage aktualisieren + if (dailyUsage) { + await ctx.db.patch(dailyUsage._id, { + generationCount: dailyUsage.generationCount + 1, + concurrentJobs: dailyUsage.concurrentJobs + 1, + }); + } else { + await ctx.db.insert("dailyUsage", { + userId: user.userId, + date: today, + generationCount: 1, + concurrentJobs: 1, + }); + } + + // Reservation-Transaktion erstellen + const transactionId = await ctx.db.insert("creditTransactions", { + userId: user.userId, + amount: -args.estimatedCost, + type: "reservation", + status: "reserved", + description: args.description, + nodeId: args.nodeId, + canvasId: args.canvasId, + model: args.model, + }); + + return transactionId; + }, +}); + +/** + * Reservation committen — nach erfolgreichem KI-Call. + * + * Schreibt die tatsächlichen Kosten ab (können von Reservation abweichen). + */ +export const commit = mutation({ + args: { + transactionId: v.id("creditTransactions"), + actualCost: v.number(), // Tatsächliche Kosten in Cent + openRouterCost: v.optional(v.number()), // Echte API-Kosten + }, + handler: async (ctx, { transactionId, actualCost, openRouterCost }) => { + const user = await requireAuth(ctx); + const transaction = await ctx.db.get(transactionId); + if (!transaction || transaction.userId !== user.userId) { + throw new Error("Transaction not found"); + } + if (transaction.status !== "reserved") { + throw new Error(`Transaction is ${transaction.status}, expected reserved`); + } + + const estimatedCost = Math.abs(transaction.amount); + + // Balance aktualisieren + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + if (!balance) throw new Error("No credit balance found"); + + await ctx.db.patch(balance._id, { + balance: balance.balance - actualCost, + reserved: balance.reserved - estimatedCost, + updatedAt: Date.now(), + }); + + // Transaktion committen + await ctx.db.patch(transactionId, { + amount: -actualCost, + type: "usage", + status: "committed", + openRouterCost, + }); + + // Concurrent Jobs dekrementieren + const today = new Date().toISOString().split("T")[0]; + const dailyUsage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + if (dailyUsage && dailyUsage.concurrentJobs > 0) { + await ctx.db.patch(dailyUsage._id, { + concurrentJobs: dailyUsage.concurrentJobs - 1, + }); + } + }, +}); + +/** + * Reservation freigeben — bei fehlgeschlagenem KI-Call. + * + * Reservierte Credits werden komplett zurückgegeben. + */ +export const release = mutation({ + args: { + transactionId: v.id("creditTransactions"), + }, + handler: async (ctx, { transactionId }) => { + const user = await requireAuth(ctx); + const transaction = await ctx.db.get(transactionId); + if (!transaction || transaction.userId !== user.userId) { + throw new Error("Transaction not found"); + } + if (transaction.status !== "reserved") { + throw new Error(`Transaction is ${transaction.status}, expected reserved`); + } + + const estimatedCost = Math.abs(transaction.amount); + + // Credits freigeben + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + if (!balance) throw new Error("No credit balance found"); + + await ctx.db.patch(balance._id, { + reserved: balance.reserved - estimatedCost, + updatedAt: Date.now(), + }); + + // Transaktion als released markieren + await ctx.db.patch(transactionId, { + status: "released", + }); + + // Concurrent Jobs dekrementieren + const today = new Date().toISOString().split("T")[0]; + const dailyUsage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + if (dailyUsage && dailyUsage.concurrentJobs > 0) { + await ctx.db.patch(dailyUsage._id, { + concurrentJobs: dailyUsage.concurrentJobs - 1, + }); + } + + // Generation Count NICHT zurücksetzen — der Versuch zählt + }, +}); + +// ============================================================================ +// Mutations — Subscription & Top-Up (von Lemon Squeezy Webhooks aufgerufen) +// ============================================================================ + +/** + * Subscription aktivieren / ändern. + * Wird vom Lemon Squeezy Webhook aufgerufen. + */ +export const activateSubscription = internalMutation({ + args: { + userId: v.string(), + tier: v.union( + v.literal("free"), + v.literal("starter"), + v.literal("pro"), + v.literal("business") + ), + lemonSqueezySubscriptionId: v.string(), + lemonSqueezyCustomerId: v.string(), + currentPeriodStart: v.number(), + currentPeriodEnd: v.number(), + }, + handler: async (ctx, args) => { + const config = TIER_CONFIG[args.tier]; + + // Bestehende Subscription deaktivieren + const existing = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .order("desc") + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { status: "cancelled" }); + } + + // Neue Subscription erstellen + await ctx.db.insert("subscriptions", { + userId: args.userId, + tier: args.tier, + status: "active", + currentPeriodStart: args.currentPeriodStart, + currentPeriodEnd: args.currentPeriodEnd, + lemonSqueezySubscriptionId: args.lemonSqueezySubscriptionId, + lemonSqueezyCustomerId: args.lemonSqueezyCustomerId, + }); + + // Credits gutschreiben + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + + if (balance) { + await ctx.db.patch(balance._id, { + balance: balance.balance + config.monthlyCredits, + monthlyAllocation: config.monthlyCredits, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("creditBalances", { + userId: args.userId, + balance: config.monthlyCredits, + reserved: 0, + monthlyAllocation: config.monthlyCredits, + updatedAt: Date.now(), + }); + } + + // Transaktion loggen + await ctx.db.insert("creditTransactions", { + userId: args.userId, + amount: config.monthlyCredits, + type: "subscription", + status: "committed", + description: `Abo-Gutschrift — ${args.tier} Tier`, + }); + }, +}); + +/** + * Credits nachkaufen (Top-Up). + */ +export const topUp = mutation({ + args: { + amount: v.number(), // Betrag in Cent + }, + handler: async (ctx, { amount }) => { + const user = await requireAuth(ctx); + if (amount <= 0) throw new Error("Amount must be positive"); + + // Tier-Limit prüfen + const subscription = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .first(); + const tier = (subscription?.tier ?? "free") as Tier; + const config = TIER_CONFIG[tier]; + + if (config.topUpLimit === 0) { + throw new Error("Top-up not available for Free tier"); + } + + // Monatliches Top-Up-Limit prüfen + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + + const monthlyTopUps = await ctx.db + .query("creditTransactions") + .withIndex("by_user_type", (q) => + q.eq("userId", user.userId).eq("type", "topup") + ) + .collect(); + + const thisMonthTopUps = monthlyTopUps + .filter((t) => t._creationTime >= monthStart.getTime()) + .reduce((sum, t) => sum + t.amount, 0); + + if (thisMonthTopUps + amount > config.topUpLimit) { + throw new Error( + `Monthly top-up limit reached. Limit: ${config.topUpLimit}, used: ${thisMonthTopUps}` + ); + } + + // Credits gutschreiben + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + if (!balance) throw new Error("No credit balance found"); + + await ctx.db.patch(balance._id, { + balance: balance.balance + amount, + updatedAt: Date.now(), + }); + + await ctx.db.insert("creditTransactions", { + userId: user.userId, + amount, + type: "topup", + status: "committed", + description: `Credit-Nachkauf — ${(amount / 100).toFixed(2)}€`, + }); + }, +}); diff --git a/convex/edges.ts b/convex/edges.ts new file mode 100644 index 0000000..8d5715c --- /dev/null +++ b/convex/edges.ts @@ -0,0 +1,96 @@ +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { requireAuth } from "./helpers"; + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Alle Edges eines Canvas laden. + */ +export const list = query({ + args: { canvasId: v.id("canvases") }, + handler: async (ctx, { canvasId }) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + return []; + } + + return await ctx.db + .query("edges") + .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) + .collect(); + }, +}); + +// ============================================================================ +// Mutations +// ============================================================================ + +/** + * Neue Edge (Verbindung) zwischen zwei Nodes erstellen. + */ +export const create = mutation({ + args: { + canvasId: v.id("canvases"), + sourceNodeId: v.id("nodes"), + targetNodeId: v.id("nodes"), + sourceHandle: v.optional(v.string()), + targetHandle: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + const canvas = await ctx.db.get(args.canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + + // Prüfen ob beide Nodes existieren und zum gleichen Canvas gehören + const source = await ctx.db.get(args.sourceNodeId); + const target = await ctx.db.get(args.targetNodeId); + if (!source || !target) { + throw new Error("Source or target node not found"); + } + if (source.canvasId !== args.canvasId || target.canvasId !== args.canvasId) { + throw new Error("Nodes must belong to the same canvas"); + } + + // Keine Self-Loops + if (args.sourceNodeId === args.targetNodeId) { + throw new Error("Cannot connect a node to itself"); + } + + const edgeId = await ctx.db.insert("edges", { + canvasId: args.canvasId, + sourceNodeId: args.sourceNodeId, + targetNodeId: args.targetNodeId, + sourceHandle: args.sourceHandle, + targetHandle: args.targetHandle, + }); + + await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + return edgeId; + }, +}); + +/** + * Edge löschen. + */ +export const remove = mutation({ + args: { edgeId: v.id("edges") }, + handler: async (ctx, { edgeId }) => { + const user = await requireAuth(ctx); + const edge = await ctx.db.get(edgeId); + if (!edge) throw new Error("Edge not found"); + + const canvas = await ctx.db.get(edge.canvasId); + if (!canvas || canvas.ownerId !== user.userId) { + throw new Error("Canvas not found"); + } + + await ctx.db.delete(edgeId); + await ctx.db.patch(edge.canvasId, { updatedAt: Date.now() }); + }, +}); diff --git a/convex/helpers.ts b/convex/helpers.ts new file mode 100644 index 0000000..772673e --- /dev/null +++ b/convex/helpers.ts @@ -0,0 +1,34 @@ +import { QueryCtx, MutationCtx } from "./_generated/server"; +import { authComponent } from "./auth"; + +type SafeAuthUser = NonNullable< + Awaited> +>; + +/** Better-Auth-User mit für die App garantierter userId (Convex-_id als Fallback). */ +export type AuthUser = Omit & { userId: string }; + +/** + * Erfordert einen authentifizierten User und gibt dessen userId zurück. + * Wirft einen Error wenn nicht eingeloggt — für Mutations und geschützte Queries. + */ +export async function requireAuth( + ctx: QueryCtx | MutationCtx +): Promise { + const user = await authComponent.safeGetAuthUser(ctx); + if (!user) { + throw new Error("Unauthenticated"); + } + const userId = user.userId ?? String(user._id); + if (!userId) { + throw new Error("Unauthenticated"); + } + return { ...user, userId }; +} + +/** + * Gibt den User zurück oder null — für optionale Auth-Checks (z.B. public Queries). + */ +export async function optionalAuth(ctx: QueryCtx | MutationCtx) { + return await authComponent.safeGetAuthUser(ctx); +} diff --git a/convex/nodes.ts b/convex/nodes.ts new file mode 100644 index 0000000..7dab827 --- /dev/null +++ b/convex/nodes.ts @@ -0,0 +1,327 @@ +import { query, mutation, QueryCtx, MutationCtx } from "./_generated/server"; +import { v } from "convex/values"; +import { requireAuth } from "./helpers"; +import type { Doc, Id } from "./_generated/dataModel"; + +// ============================================================================ +// Interne Helpers +// ============================================================================ + +/** + * Prüft ob der User Zugriff auf den Canvas hat und gibt ihn zurück. + */ +async function getCanvasOrThrow( + ctx: QueryCtx | MutationCtx, + canvasId: Id<"canvases">, + userId: string +) { + const canvas = await ctx.db.get(canvasId); + if (!canvas || canvas.ownerId !== userId) { + throw new Error("Canvas not found"); + } + return canvas; +} + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Alle Nodes eines Canvas laden. + */ +export const list = query({ + args: { canvasId: v.id("canvases") }, + handler: async (ctx, { canvasId }) => { + const user = await requireAuth(ctx); + await getCanvasOrThrow(ctx, canvasId, user.userId); + + return await ctx.db + .query("nodes") + .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) + .collect(); + }, +}); + +/** + * Einzelnen Node laden. + */ +export const get = query({ + args: { nodeId: v.id("nodes") }, + handler: async (ctx, { nodeId }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) return null; + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + return node; + }, +}); + +/** + * Nodes nach Typ filtern (z.B. alle ai-image Nodes eines Canvas). + */ +export const listByType = query({ + args: { + canvasId: v.id("canvases"), + type: v.string(), + }, + handler: async (ctx, { canvasId, type }) => { + const user = await requireAuth(ctx); + await getCanvasOrThrow(ctx, canvasId, user.userId); + + return await ctx.db + .query("nodes") + .withIndex("by_canvas_type", (q) => + q.eq("canvasId", canvasId).eq("type", type as Doc<"nodes">["type"]) + ) + .collect(); + }, +}); + +// ============================================================================ +// Mutations +// ============================================================================ + +/** + * Neuen Node auf dem Canvas erstellen. + */ +export const create = mutation({ + args: { + canvasId: v.id("canvases"), + type: v.string(), + positionX: v.number(), + positionY: v.number(), + width: v.number(), + height: v.number(), + data: v.any(), + parentId: v.optional(v.id("nodes")), + zIndex: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + await getCanvasOrThrow(ctx, args.canvasId, user.userId); + + const nodeId = await ctx.db.insert("nodes", { + canvasId: args.canvasId, + type: args.type as Doc<"nodes">["type"], + positionX: args.positionX, + positionY: args.positionY, + width: args.width, + height: args.height, + status: "idle", + data: args.data, + parentId: args.parentId, + zIndex: args.zIndex, + }); + + // Canvas updatedAt aktualisieren + await ctx.db.patch(args.canvasId, { updatedAt: Date.now() }); + + return nodeId; + }, +}); + +/** + * Node-Position auf dem Canvas verschieben. + */ +export const move = mutation({ + args: { + nodeId: v.id("nodes"), + positionX: v.number(), + positionY: v.number(), + }, + handler: async (ctx, { nodeId, positionX, positionY }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + await ctx.db.patch(nodeId, { positionX, positionY }); + await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); + }, +}); + +/** + * Node-Größe ändern. + */ +export const resize = mutation({ + args: { + nodeId: v.id("nodes"), + width: v.number(), + height: v.number(), + }, + handler: async (ctx, { nodeId, width, height }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + await ctx.db.patch(nodeId, { width, height }); + await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); + }, +}); + +/** + * Mehrere Nodes gleichzeitig verschieben (Batch Move, z.B. nach Multiselect-Drag). + */ +export const batchMove = mutation({ + args: { + moves: v.array( + v.object({ + nodeId: v.id("nodes"), + positionX: v.number(), + positionY: v.number(), + }) + ), + }, + handler: async (ctx, { moves }) => { + const user = await requireAuth(ctx); + if (moves.length === 0) return; + + // Canvas-Zugriff über den ersten Node prüfen + const firstNode = await ctx.db.get(moves[0].nodeId); + if (!firstNode) throw new Error("Node not found"); + await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId); + + for (const { nodeId, positionX, positionY } of moves) { + await ctx.db.patch(nodeId, { positionX, positionY }); + } + + await ctx.db.patch(firstNode.canvasId, { updatedAt: Date.now() }); + }, +}); + +/** + * Node-Daten aktualisieren (typ-spezifische Payload). + */ +export const updateData = mutation({ + args: { + nodeId: v.id("nodes"), + data: v.any(), + }, + handler: async (ctx, { nodeId, data }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + await ctx.db.patch(nodeId, { data }); + await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); + }, +}); + +/** + * Node-Status aktualisieren (UX-Strategie: Status direkt am Node). + */ +export const updateStatus = mutation({ + args: { + nodeId: v.id("nodes"), + status: v.union( + v.literal("idle"), + v.literal("analyzing"), + v.literal("clarifying"), + v.literal("executing"), + v.literal("done"), + v.literal("error") + ), + statusMessage: v.optional(v.string()), + }, + handler: async (ctx, { nodeId, status, statusMessage }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + await ctx.db.patch(nodeId, { status, statusMessage }); + }, +}); + +/** + * Node-Z-Index ändern (Layering). + */ +export const updateZIndex = mutation({ + args: { + nodeId: v.id("nodes"), + zIndex: v.number(), + }, + handler: async (ctx, { nodeId, zIndex }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + await ctx.db.patch(nodeId, { zIndex }); + }, +}); + +/** + * Node in eine Gruppe/Frame verschieben oder aus Gruppe entfernen. + */ +export const setParent = mutation({ + args: { + nodeId: v.id("nodes"), + parentId: v.optional(v.id("nodes")), + }, + handler: async (ctx, { nodeId, parentId }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + + // Prüfen ob Parent existiert und zum gleichen Canvas gehört + if (parentId) { + const parent = await ctx.db.get(parentId); + if (!parent || parent.canvasId !== node.canvasId) { + throw new Error("Parent not found"); + } + } + + await ctx.db.patch(nodeId, { parentId }); + await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); + }, +}); + +/** + * Node löschen — entfernt auch alle verbundenen Edges. + */ +export const remove = mutation({ + args: { nodeId: v.id("nodes") }, + handler: async (ctx, { nodeId }) => { + const user = await requireAuth(ctx); + const node = await ctx.db.get(nodeId); + if (!node) throw new Error("Node not found"); + + await getCanvasOrThrow(ctx, node.canvasId, user.userId); + + // Alle Edges entfernen, die diesen Node als Source oder Target haben + const sourceEdges = await ctx.db + .query("edges") + .withIndex("by_source", (q) => q.eq("sourceNodeId", nodeId)) + .collect(); + for (const edge of sourceEdges) { + await ctx.db.delete(edge._id); + } + + const targetEdges = await ctx.db + .query("edges") + .withIndex("by_target", (q) => q.eq("targetNodeId", nodeId)) + .collect(); + for (const edge of targetEdges) { + await ctx.db.delete(edge._id); + } + + // Kind-Nodes aus Gruppe/Frame lösen (parentId auf undefined setzen) + const children = await ctx.db + .query("nodes") + .withIndex("by_parent", (q) => q.eq("parentId", nodeId)) + .collect(); + for (const child of children) { + await ctx.db.patch(child._id, { parentId: undefined }); + } + + // Node löschen + await ctx.db.delete(nodeId); + await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 0000000..2a8718f --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +// ============================================================================ +// Node Types +// ============================================================================ + +// Phase 1 Node Types +const phase1NodeTypes = v.union( + // Quelle + v.literal("image"), + v.literal("text"), + v.literal("prompt"), + // KI-Ausgabe + v.literal("ai-image"), + // Canvas & Layout + v.literal("group"), + v.literal("frame"), + v.literal("note"), + v.literal("compare") +); + +// Alle Node Types (Phase 1 + spätere Phasen) +// Phase 2+3 Typen sind hier schon definiert, damit das Schema nicht bei +// jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen +// der jeweiligen Phase an. +const nodeType = v.union( + // Quelle (Phase 1) + v.literal("image"), + v.literal("text"), + v.literal("prompt"), + // Quelle (Phase 2) + v.literal("color"), + v.literal("video"), + v.literal("asset"), + // KI-Ausgabe (Phase 1) + v.literal("ai-image"), + // KI-Ausgabe (Phase 2) + v.literal("ai-text"), + v.literal("ai-video"), + // KI-Ausgabe (Phase 3) + v.literal("agent-output"), + // Transformation (Phase 2) + v.literal("crop"), + v.literal("bg-remove"), + v.literal("upscale"), + // Transformation (Phase 3) + v.literal("style-transfer"), + v.literal("face-restore"), + // Steuerung (Phase 2) + v.literal("splitter"), + v.literal("loop"), + v.literal("agent"), + // Steuerung (Phase 3) + v.literal("mixer"), + v.literal("switch"), + // Canvas & Layout (Phase 1) + v.literal("group"), + v.literal("frame"), + v.literal("note"), + v.literal("compare"), + // Canvas & Layout (Phase 2) + v.literal("text-overlay"), + // Canvas & Layout (Phase 3) + v.literal("comment"), + v.literal("presentation") +); + +// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD) +const nodeStatus = v.union( + v.literal("idle"), + v.literal("analyzing"), + v.literal("clarifying"), + v.literal("executing"), + v.literal("done"), + v.literal("error") +); + +// ============================================================================ +// Node Data — typ-spezifische Payloads +// ============================================================================ + +// Bild-Node: Upload oder URL +const imageNodeData = v.object({ + storageId: v.optional(v.id("_storage")), // Convex File Storage + url: v.optional(v.string()), // Externe URL + mimeType: v.optional(v.string()), // image/png, image/jpeg, image/webp + originalFilename: v.optional(v.string()), + width: v.optional(v.number()), // Natürliche Bildbreite + height: v.optional(v.number()), // Natürliche Bildhöhe +}); + +// Text-Node: Freitext mit Markdown +const textNodeData = v.object({ + content: v.string(), +}); + +// Prompt-Node: Modellinstruktionen +const promptNodeData = v.object({ + content: v.string(), + model: v.optional(v.string()), // OpenRouter Model ID + modelTier: v.optional(v.union( + v.literal("budget"), + v.literal("standard"), + v.literal("premium") + )), +}); + +// KI-Bild-Node: Output einer Bildgenerierung +const aiImageNodeData = v.object({ + storageId: v.optional(v.id("_storage")), // Generiertes Bild in Convex Storage + prompt: v.string(), // Verwendeter Prompt + model: v.string(), // OpenRouter Model ID + modelTier: v.union( + v.literal("budget"), + v.literal("standard"), + v.literal("premium") + ), + parameters: v.optional(v.any()), // Modell-spezifische Parameter + generationTimeMs: v.optional(v.number()), // Latenz-Tracking + creditCost: v.optional(v.number()), // Tatsächliche Kosten in Credits (Cent) + width: v.optional(v.number()), + height: v.optional(v.number()), + errorMessage: v.optional(v.string()), // Bei status: "error" +}); + +// Frame-Node: Artboard mit definierter Auflösung +const frameNodeData = v.object({ + label: v.optional(v.string()), // Artboard-Name + exportWidth: v.number(), // Export-Auflösung + exportHeight: v.number(), + backgroundColor: v.optional(v.string()), // Hex-Farbe +}); + +// Gruppe-Node: Container +const groupNodeData = v.object({ + label: v.optional(v.string()), + collapsed: v.boolean(), +}); + +// Notiz-Node: Annotation +const noteNodeData = v.object({ + content: v.string(), // Markdown + color: v.optional(v.string()), // Hintergrundfarbe +}); + +// Compare-Node: Zwei Bilder nebeneinander +const compareNodeData = v.object({ + leftNodeId: v.optional(v.id("nodes")), // Referenz auf linkes Bild + rightNodeId: v.optional(v.id("nodes")), // Referenz auf rechtes Bild + sliderPosition: v.optional(v.number()), // 0-100, Default: 50 +}); + +// ============================================================================ +// Schema Definition +// ============================================================================ + +export default defineSchema({ + + // ========================================================================== + // Canvas & Nodes + // ========================================================================== + + canvases: defineTable({ + name: v.string(), + ownerId: v.string(), // Better Auth User ID + description: v.optional(v.string()), + thumbnail: v.optional(v.id("_storage")), // Canvas-Vorschaubild + updatedAt: v.number(), // Timestamp (ms) + }) + .index("by_owner", ["ownerId"]) + .index("by_owner_updated", ["ownerId", "updatedAt"]), + + nodes: defineTable({ + canvasId: v.id("canvases"), + type: nodeType, + // Position & Größe auf dem Canvas + positionX: v.number(), + positionY: v.number(), + width: v.number(), + height: v.number(), + // Node-Status (UX-Strategie: Status direkt am Node sichtbar) + status: nodeStatus, + statusMessage: v.optional(v.string()), // z.B. "Timeout — Credits nicht abgebucht" + // Typ-spezifische Daten + // Convex empfiehlt v.any() für polymorphe data-Felder + // Type Safety wird über den `type`-Discriminator + Zod im Frontend sichergestellt + data: v.any(), + // Gruppierung + parentId: v.optional(v.id("nodes")), // Für Nodes in Gruppen/Frames + zIndex: v.optional(v.number()), // Layering-Reihenfolge + }) + .index("by_canvas", ["canvasId"]) + .index("by_canvas_type", ["canvasId", "type"]) + .index("by_parent", ["parentId"]), + + edges: defineTable({ + canvasId: v.id("canvases"), + sourceNodeId: v.id("nodes"), + targetNodeId: v.id("nodes"), + // Edge-Metadaten + sourceHandle: v.optional(v.string()), // Welcher Output-Port + targetHandle: v.optional(v.string()), // Welcher Input-Port + }) + .index("by_canvas", ["canvasId"]) + .index("by_source", ["sourceNodeId"]) + .index("by_target", ["targetNodeId"]), + + // ========================================================================== + // Credit-System + // ========================================================================== + + creditBalances: defineTable({ + userId: v.string(), // Better Auth User ID + balance: v.number(), // Verfügbare Credits (Euro-Cent) + reserved: v.number(), // Gesperrte Credits (laufende Jobs) + // available = balance - reserved (computed, nicht gespeichert) + monthlyAllocation: v.number(), // Credits aus dem Abo (Cent) + updatedAt: v.number(), // Timestamp (ms) + }) + .index("by_user", ["userId"]), + + creditTransactions: defineTable({ + userId: v.string(), // Better Auth User ID + amount: v.number(), // + = Gutschrift, - = Verbrauch (Cent) + type: v.union( + v.literal("subscription"), // Monatliche Abo-Gutschrift + v.literal("topup"), // Manueller Nachkauf + v.literal("usage"), // KI-Verbrauch + v.literal("reservation"), // Vorab-Reservierung + v.literal("refund") // Rückerstattung + ), + status: v.union( + v.literal("committed"), // Abgeschlossen + v.literal("reserved"), // Reserviert, Job läuft + v.literal("released"), // Reservierung aufgehoben (Fehler) + v.literal("failed") // Fehlgeschlagen + ), + description: v.string(), // z.B. "Bildgenerierung — Gemini 2.5 Flash Image" + nodeId: v.optional(v.id("nodes")), // Auslösender Node + canvasId: v.optional(v.id("canvases")), // Zugehöriger Canvas + openRouterCost: v.optional(v.number()), // Tatsächliche API-Kosten (Cent) + model: v.optional(v.string()), // OpenRouter Model ID + }) + .index("by_user", ["userId"]) + .index("by_user_type", ["userId", "type"]) + .index("by_user_status", ["userId", "status"]) + .index("by_node", ["nodeId"]), + + // ========================================================================== + // Subscriptions + // ========================================================================== + + subscriptions: defineTable({ + userId: v.string(), // Better Auth User ID + tier: v.union( + v.literal("free"), + v.literal("starter"), + v.literal("pro"), + v.literal("business") + ), + status: v.union( + v.literal("active"), + v.literal("cancelled"), + v.literal("past_due"), + v.literal("trialing") + ), + currentPeriodStart: v.number(), // Timestamp (ms) + currentPeriodEnd: v.number(), // Timestamp (ms) + lemonSqueezySubscriptionId: v.optional(v.string()), + lemonSqueezyCustomerId: v.optional(v.string()), + cancelAtPeriodEnd: v.optional(v.boolean()), // Kündigung zum Periodenende + }) + .index("by_user", ["userId"]) + .index("by_lemon_squeezy", ["lemonSqueezySubscriptionId"]), + + // ========================================================================== + // Abuse Prevention + // ========================================================================== + + dailyUsage: defineTable({ + userId: v.string(), + date: v.string(), // ISO Date: "2026-03-25" + generationCount: v.number(), // Anzahl Generierungen heute + concurrentJobs: v.number(), // Aktuell laufende Jobs + }) + .index("by_user_date", ["userId", "date"]), +});