feat: enhance type definitions in generated API and data model

- Added new module imports for canvases, credits, edges, helpers, and nodes in api.d.ts
- Improved type safety in dataModel.d.ts by utilizing DataModelFromSchemaDefinition and DocumentByName
- Updated Doc and Id types to reflect schema definitions for better type checking
This commit is contained in:
Matthias
2026-03-25 11:13:45 +01:00
parent 50bdabab87
commit f8f86eb990
8 changed files with 1452 additions and 16 deletions

View File

@@ -9,7 +9,12 @@
*/ */
import type * as auth from "../auth.js"; 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 http from "../http.js";
import type * as nodes from "../nodes.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -19,7 +24,12 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auth: typeof auth; auth: typeof auth;
canvases: typeof canvases;
credits: typeof credits;
edges: typeof edges;
helpers: typeof helpers;
http: typeof http; http: typeof http;
nodes: typeof nodes;
}>; }>;
/** /**

View File

@@ -8,29 +8,29 @@
* @module * @module
*/ */
import { AnyDataModel } from "convex/server"; import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values"; import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* 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`.
*/
/** /**
* The names of all of your Convex tables. * The names of all of your Convex tables.
*/ */
export type TableNames = string; export type TableNames = TableNamesInDataModel<DataModel>;
/** /**
* The type of a document stored in Convex. * 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<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/** /**
* An identifier for a document in Convex. * 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 * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/ */
export type Id<TableName extends TableNames = TableNames> = export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>; GenericId<TableName>;
/** /**
@@ -55,4 +57,4 @@ export type Id<TableName extends TableNames = TableNames> =
* This type is used to parameterize methods like `queryGeneric` and * This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe. * `mutationGeneric` to make them type-safe.
*/ */
export type DataModel = AnyDataModel; export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

139
convex/canvases.ts Normal file
View File

@@ -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<string, unknown> = { 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() });
},
});

539
convex/credits.ts Normal file
View File

@@ -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)}`,
});
},
});

96
convex/edges.ts Normal file
View File

@@ -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() });
},
});

34
convex/helpers.ts Normal file
View File

@@ -0,0 +1,34 @@
import { QueryCtx, MutationCtx } from "./_generated/server";
import { authComponent } from "./auth";
type SafeAuthUser = NonNullable<
Awaited<ReturnType<typeof authComponent.safeGetAuthUser>>
>;
/** Better-Auth-User mit für die App garantierter userId (Convex-_id als Fallback). */
export type AuthUser = Omit<SafeAuthUser, "userId"> & { 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<AuthUser> {
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);
}

327
convex/nodes.ts Normal file
View File

@@ -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() });
},
});

289
convex/schema.ts Normal file
View File

@@ -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"]),
});