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:
10
convex/_generated/api.d.ts
vendored
10
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
34
convex/_generated/dataModel.d.ts
vendored
34
convex/_generated/dataModel.d.ts
vendored
@@ -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<DataModel>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -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<TableName extends TableNames = TableNames> =
|
||||
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||
GenericId<TableName>;
|
||||
|
||||
/**
|
||||
@@ -55,4 +57,4 @@ export type Id<TableName extends TableNames = TableNames> =
|
||||
* This type is used to parameterize methods like `queryGeneric` and
|
||||
* `mutationGeneric` to make them type-safe.
|
||||
*/
|
||||
export type DataModel = AnyDataModel;
|
||||
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||
|
||||
139
convex/canvases.ts
Normal file
139
convex/canvases.ts
Normal 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
539
convex/credits.ts
Normal 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
96
convex/edges.ts
Normal 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
34
convex/helpers.ts
Normal 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
327
convex/nodes.ts
Normal 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
289
convex/schema.ts
Normal 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"]),
|
||||
});
|
||||
Reference in New Issue
Block a user