- Introduced `adjustmentPresetNodeTypeValidator` for validating new adjustment preset node types. - Added new constants for render output resolutions and formats, including custom dimension constraints. - Implemented normalization functions for render data, ensuring proper validation and error handling. - Updated node creation and update mutations to utilize normalized data for improved consistency.
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
import { defineSchema, defineTable } from "convex/server";
|
|
import { v } from "convex/values";
|
|
|
|
import {
|
|
adjustmentPresetNodeTypeValidator,
|
|
nodeTypeValidator,
|
|
phase1NodeTypeValidator,
|
|
} from "./node-type-validator";
|
|
|
|
// ============================================================================
|
|
// Node Types
|
|
// ============================================================================
|
|
|
|
// Phase 1 Node Types
|
|
const phase1NodeTypes = phase1NodeTypeValidator;
|
|
|
|
// 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 = nodeTypeValidator;
|
|
const adjustmentPresetNodeType = adjustmentPresetNodeTypeValidator;
|
|
|
|
// 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"
|
|
retryCount: v.optional(v.number()), // Anzahl bereits durchgeführter Retries
|
|
// 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"]),
|
|
|
|
mutationRequests: defineTable({
|
|
userId: v.string(),
|
|
mutation: v.string(),
|
|
clientRequestId: v.string(),
|
|
canvasId: v.optional(v.id("canvases")),
|
|
nodeId: v.optional(v.id("nodes")),
|
|
edgeId: v.optional(v.id("edges")),
|
|
createdAt: v.number(),
|
|
}).index("by_user_mutation_request", [
|
|
"userId",
|
|
"mutation",
|
|
"clientRequestId",
|
|
]),
|
|
|
|
adjustmentPresets: defineTable({
|
|
userId: v.string(),
|
|
name: v.string(),
|
|
nodeType: adjustmentPresetNodeType,
|
|
params: v.any(),
|
|
createdAt: v.number(),
|
|
})
|
|
.index("by_userId", ["userId"])
|
|
.index("by_userId_nodeType", ["userId", "nodeType"]),
|
|
|
|
// ==========================================================================
|
|
// 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("max"),
|
|
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)
|
|
polarSubscriptionId: v.optional(v.string()),
|
|
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_polar", ["polarSubscriptionId"])
|
|
.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"]),
|
|
|
|
// ==========================================================================
|
|
// User Settings
|
|
// ==========================================================================
|
|
|
|
userSettings: defineTable({
|
|
userId: v.string(), // Better Auth User ID
|
|
locale: v.optional(v.union(v.literal('de'), v.literal('en'))),
|
|
createdAt: v.number(),
|
|
updatedAt: v.number(),
|
|
})
|
|
.index("by_user", ["userId"]),
|
|
});
|