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 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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
34
convex/_generated/dataModel.d.ts
vendored
34
convex/_generated/dataModel.d.ts
vendored
@@ -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
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