Das Datenbankschema liegt in einer einzigen Datei —
apps/boilerplate/prisma/schema.prisma — und definiert alle Tabellen, Spalten, Relationen und Indexes. Prisma liest diese Datei, um einen vollständig typisierten TypeScript-Client zu generieren, sodass jede Abfrage in deiner Anwendung zur Kompilierzeit typgeprüft wird.Diese Seite geht durch jedes Modell im Schema. Für die Architektur-Übersicht, siehe Datenbank-Übersicht. Für Informationen zur Schema-Änderung, siehe Migrationen & Seeding.
Schema-Konfiguration
Der Anfang der Schema-Datei konfiguriert den Prisma-Client-Generator und die PostgreSQL-Datenquelle:
prisma/schema.prisma — Configuration
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
// Use pooled connection for queries (with pgbouncer)
url = env("DATABASE_URL")
// Use direct connection for migrations (without pgbouncer)
directUrl = env("DIRECT_URL")
extensions = [vector]
}
Wichtige Punkte:
binaryTargets— Enthältrhel-openssl-3.0.xfür die Bereitstellung in Container-Umgebungen (Docker, AWS Lambda).previewFeatures— AktiviertpostgresqlExtensions, damit Prisma dievector-Erweiterung in der Datenquelle erkennt.extensions = [vector]— Aktiviert die pgvector-Erweiterung für semantische Suche. Diese muss auch in Supabase via SQL aktiviert werden (siehe Migrationen & Seeding).- Duale URLs —
urlfür gepoolte Abfragen,directUrlfür Migrationen. Siehe die Übersicht für Details.
User-Modell
Das
User-Modell ist die zentrale Entität. Es verbindet sich mit Clerk über clerkId und unterstützt Kits duales Preismodell — sowohl kreditbasierte als auch klassische SaaS-Subscription-Felder koexistieren auf demselben Modell. Welches Preismodell aktiv ist, hängt von deiner Konfiguration ab, nicht vom Schema.prisma/schema.prisma — User Model
model User {
id String @id @default(uuid())
clerkId String @unique
email String? // Cached from Clerk for payment processing
name String? // Cached from Clerk for payment processing
hasUsedTrial Boolean @default(false) // Track if user has ever used a trial
downgradeCount Int @default(0) // Track downgrades during trial (max 2)
lastPlanChangeAt DateTime? // Last plan change timestamp
tier String @default("free") // Subscription tier: free, basic, pro, enterprise
// ============================================
// CREDIT-BASED MODEL FIELDS (nullable for lazy initialization)
// ============================================
creditBalance Decimal? @db.Decimal(10, 2) // Current available credits (null = not initialized, supports fractional: 0.2, 0.5, 1.5)
creditsPerMonth Decimal? @db.Decimal(10, 2) // Monthly credit allocation (tier-based, supports fractional)
creditsResetAt DateTime? // Last monthly reset timestamp
bonusCredits Decimal? @db.Decimal(10, 2) // Bonus credits from purchases (top-ups)
bonusCreditsAutoUse Boolean @default(false) // Per-user bonus credit toggle (opt-in, like Claude Desktop "Extra Usage")
// ============================================
// CLASSIC SAAS MODEL FIELDS
// ============================================
isTrial Boolean @default(false) // User is currently in trial period
trialStartDate DateTime? // Trial start timestamp
trialEndDate DateTime? // Trial expiration timestamp
isLocked Boolean @default(false) // Account locked after trial expiry
// ============================================
// SHARED FIELDS
// ============================================
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
emailLogs EmailLog[]
files File[]
subscription Subscription?
aiUsage AIUsage[] // AI usage tracking
aiConversations AIConversation[] // AI conversation history
creditTransactions CreditTransaction[] // Credit transaction log
bonusCreditPurchases BonusCreditPurchase[] // Bonus credit purchase history (credit_based only)
@@index([clerkId])
@@index([tier])
}
Feldgruppen
Identität (synchronisiert von Clerk):
clerkId— Eindeutiger Bezeichner von Clerk. So sucht deine App nach der Authentifizierung nach Benutzern.email,name— Zwischengespeichert aus Clerk-Webhooks für die Zahlungsabwicklung und Anzeige. Wird nicht für die Authentifizierung verwendet.
Kreditbasiertes Preismodell:
creditBalance— Aktuell verfügbare Credits. Nullable für Lazy Initialization (Credits werden bei der ersten Nutzung zugewiesen, nicht bei der Registrierung).creditsPerMonth— Monatliche Credit-Zuteilung basierend auf dem Tier des Benutzers.creditsResetAt— Wann das nächste monatliche Reset stattfindet.bonusCredits— Zusätzliche Credits, die als Top-ups gekauft wurden.bonusCreditsAutoUse— Benutzerspezifischer Umschalter für den automatischen Verbrauch von Bonus-Credits (Standard:false). Nur relevant, wennNEXT_PUBLIC_BONUS_CREDITS_USER_TOGGLE_ENABLED=true.
Credit-Felder verwenden
Decimal(10, 2) statt Float, um Gleitkomma-Präzisionsfehler zu vermeiden. Dies ist entscheidend für Finanzberechnungen — ein Float kann aus 10.00 - 0.20 den Wert 9.799999999999999 machen, während Decimal das Ergebnis 9.80 beibehält.Klassisches SaaS-Preismodell:
isTrial,trialStartDate,trialEndDate— Verfolgung des Testzeitraums.isLocked— Konto gesperrt nach Ablauf des Testzeitraums ohne Umstieg auf ein kostenpflichtiges Modell.
Gemeinsame Felder:
tier— Aktuelles Subscription-Tier:free,basic,prooderenterprise.hasUsedTrial,downgradeCount,lastPlanChangeAt— Geschäftsregel-Felder, die vom Zahlungssystem verwendet werden, um Trial-Missbrauch zu verhindern und Plan-Änderungen zu verfolgen.
Indexes
prisma
@@index([clerkId]) // Schnelle Suche nach Clerk-Authentifizierung
@@index([tier]) // Benutzer nach Subscription-Tier filtern (Admin-Abfragen)
Subscription-Modell
Das
Subscription-Modell verfolgt den Lemon Squeezy Zahlungsstatus. Jeder Benutzer kann höchstens eine aktive Subscription haben (erzwungen durch @unique auf userId).prisma/schema.prisma — Subscription Model
model Subscription {
id String @id @default(uuid())
userId String @unique
customerId String @unique
subscriptionId String @unique
productId String
variantId String
status String
currentPeriodEnd DateTime?
trialEndsAt DateTime? // Renamed for consistency with Lemon Squeezy
canceledAt DateTime?
previousVariantId String? // Track previous plan for downgrades
planHistory String[] @default([]) // Track all plan changes
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([status])
@@index([customerId])
@@index([subscriptionId])
}
Wichtige Felder
customerId,subscriptionId— Lemon Squeezy-Bezeichner. Beide sind@uniquefür direkte Suchen aus Webhook-Events.productId,variantId— Verweisen auf spezifische Pläne in deinem Lemon Squeezy-Dashboard. Die Variante bestimmt das Preistier.status— Spiegelt Lemon Squeezy-Zustände wider:active,on_trial,cancelled,expired,past_due,paused.previousVariantId— Verfolgt den letzten Plan vor einer Änderung und ermöglicht Downgrade-Erkennung.planHistory— Nur-anhängen-Array aller Plan-Änderungen. Nützlich für Analytics und Kundensupport.currentPeriodEnd— Wann der aktuelle Abrechnungszeitraum endet. Gekündigte Subscriptions gewähren bis zu diesem Datum weiterhin Zugang.trialEndsAt— Endzeitpunkt des Testzeitraums. Wird gesetzt, wenn eine Test-Subscription über densubscription_created-Webhook erstellt wird.canceledAt— Zeitstempel, wann die Subscription gekündigt wurde. Subscriptions bleiben biscurrentPeriodEndaktiv.metadata— JSON-Metadaten vom Zahlungsanbieter. Speichert zusätzlichen Kontext aus Lemon Squeezy-Webhook-Events.
Das
onDelete: Cascade auf der user-Relation bedeutet, dass das Löschen eines Benutzers automatisch seine Subscription löscht. Dies hält die Datenbank konsistent, wenn Benutzer ihre Konten über Clerk löschen.File-Modell
Das
File-Modell speichert Metadaten für Dateien, die in Vercel Blob hochgeladen wurden. Der eigentliche Dateiinhalt liegt im Blob-Storage — nur die Referenz-URL und Metadaten befinden sich in der Datenbank.prisma
model File {
id String @id @default(uuid())
userId String
url String // Vercel Blob URL
pathname String // Storage path
originalName String // Display name
contentType String? // MIME type
size Int? // File size in bytes
metadata Json? // Custom metadata
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt])
}
EmailLog-Modell
Das
EmailLog-Modell verfolgt jede über Resend gesendete E-Mail. Es unterstützt Analytics (Zustellraten, Bounce-Tracking) und DSGVO-Konformität (automatisierte Log-Bereinigung).prisma
model EmailLog {
id String @id @default(uuid())
userId String? // Nullable for system emails
to String
from String?
subject String
type String? // "welcome", "password_reset", "invoice", etc.
status String // "sent", "delivered", "bounced", "failed"
provider String @default("resend")
messageId String? // Resend message ID for tracking
metadata Json?
sentAt DateTime?
error String? // Error message if delivery failed
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([to])
@@index([status])
@@index([type])
@@index([createdAt])
}
Das Query-Modul (
queries/email-logs.ts) enthält eine Anti-Spam-Prüfung — wasEmailRecentlySent() —, die doppelte Sendungen innerhalb eines konfigurierbaren Zeitfensters verhindert. Es bietet auch deleteOldEmailLogs() für DSGVO-konforme Log-Aufbewahrung.KI-Modelle
Drei Modelle verfolgen KI-Nutzung, Konversationen und einzelne Nachrichten. Sie unterstützen Kits Multi-Provider-KI-System (OpenAI, Anthropic, Google, xAI).
AIUsage
Verfolgt den KI-Verbrauch pro Anfrage für Kostenmanagement und Analytics:
prisma/schema.prisma — AIUsage
model AIUsage {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
sessionId String? // For anonymous users or session-based tracking
provider String // openai, anthropic, google, xai
model String // gpt-5.2, claude-opus-4-6, gemini-2.5-pro, grok-4-1-fast, etc.
tokens Int // Total tokens consumed (prompt + completion)
cost Float? // Estimated cost in USD
purpose String // faq, chat, completion, stream, embedding
metadata Json? // Additional context (message count, temperature, etc.)
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([sessionId, createdAt])
@@index([provider])
@@index([purpose])
@@index([createdAt])
}
Das
purpose-Feld kategorisiert die Nutzung (faq, chat, completion, stream, embedding) und ermöglicht Feature-spezifische Kostenaufschlüsselungen in deinem Admin-Dashboard.AIConversation und AIMessage
Konversations-Threads und ihre Nachrichten:
prisma/schema.prisma — AIConversation & AIMessage
model AIConversation {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
sessionId String // For session-tracking (even for auth users)
userTier String? // free, pro, enterprise - for rate limiting context
title String? // Optional conversation title (first question)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages AIMessage[]
@@index([userId, createdAt])
@@index([sessionId])
@@index([createdAt])
}
model AIMessage {
id String @id @default(uuid())
conversationId String
conversation AIConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
role String // user, assistant, system
content String @db.Text
tokens Int? // Token count for cost tracking
model String? // gpt-5.2, claude-opus-4-6, etc.
provider String? // openai, anthropic, google, xai
metadata Json? // Additional context (temperature, max_tokens, etc.)
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
}
Das
sessionId-Feld ermöglicht Konversations-Tracking für sowohl authentifizierte als auch anonyme Benutzer. Das userTier-Feld in Konversationen erlaubt der Rate-Limiting-Logik, den Plan des Benutzers zu prüfen, ohne die User-Tabelle zu joinen.FAQChunk-Modell (RAG)
Das
FAQChunk-Modell speichert Wissensdatenbank-Einträge mit Vektor-Embeddings für semantische Suche. Dies ist die Grundlage von Kits RAG-System (Retrieval-Augmented Generation).prisma/schema.prisma — FAQChunk
model FAQChunk {
id String @id @default(uuid())
// Content
content String @db.Text // Full Q&A text
question String // Question headline
answer String @db.Text // Answer preview (first 500 chars)
category String // "Getting Started", "Database", etc.
// Vector embedding (OpenAI text-embedding-3-small = 1536 dimensions)
embedding Unsupported("vector(1536)")?
// Metadata
metadata Json? // { tags: [], relatedTopics: [], keywords: [] }
tokenCount Int? // For cost tracking
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([createdAt])
@@map("faq_chunks")
}
Das Vektor-Feld
Das
embedding-Feld verwendet Unsupported("vector(1536)") — einen Prisma-Escape-Hatch für PostgreSQL-Typen, die Prisma nicht nativ unterstützt. Die Dimension 1536 entspricht OpenAIs text-embedding-3-small-Modell.Da
embedding Unsupported() verwendet, kann Prisma es nicht in Standardabfragen einschließen. Alle Vektor-Operationen müssen Raw SQL über prisma.$queryRaw verwenden. Kit handhabt dies in apps/boilerplate/src/lib/ai/rag-search.ts mit Kosinus-Ähnlichkeit:sql
SELECT id, content, question, answer, category,
1 - (embedding <=> $1::vector) as similarity
FROM faq_chunks
WHERE 1 - (embedding <=> $1::vector) > $2
ORDER BY similarity DESC
LIMIT $3
Der
<=>-Operator berechnet den Kosinus-Abstand. 1 - distance wandelt ihn in einen Ähnlichkeitswert um, bei dem 1.0 identisch und 0.0 völlig unverwandt bedeutet.Die
@@map("faq_chunks")-Direktive ordnet den PascalCase-Prisma-Modellnamen einem snake_case-Tabellennamen in PostgreSQL zu, was der vom pgvector-Index erwarteten Konvention entspricht.Credit-System-Modelle
Zwei Modelle unterstützen Kits kreditbasiertes Preismodell — ein unveränderliches Transaktions-Ledger und ein Kauf-Tracker.
CreditTransaction
Jede Credit-Änderung wird als unveränderliche Transaktion aufgezeichnet. Dies bietet eine vollständige Prüfspur und ermöglicht die Rekonstruktion des Guthabens aus dem Verlauf:
prisma/schema.prisma — CreditTransaction
model CreditTransaction {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
amount Decimal @db.Decimal(10, 2) // Positive = credit added, Negative = credit deducted (supports fractional: 0.2, 0.5, 1.5)
balanceAfter Decimal @db.Decimal(10, 2) // Credit balance after this transaction (supports fractional)
type String // "usage", "refund", "monthly_reset", "purchase", "adjustment"
operation String? // "faq", "chat", "image_gen", "code_analysis", etc.
metadata Json? // Additional context: { model: "gpt-5.2", tokens: 1500, cost: 0.02 }
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([type])
@@index([createdAt])
}
Transaktionstypen:
| Typ | Beschreibung | Betrag |
|---|---|---|
usage | Credits, die durch eine KI-Operation verbraucht wurden | Negativ |
refund | Credits, die aufgrund eines Fehlers oder einer Stornierung zurückgegeben wurden | Positiv |
monthly_reset | Monatliche Credit-Zuteilung basierend auf dem Tier | Positiv |
purchase | Bonus-Credits, die über Lemon Squeezy gekauft wurden | Positiv |
adjustment | Manuelle Admin-Anpassung | Beides |
Das
operation-Feld liefert Details innerhalb eines Typs — eine usage-Transaktion könnte beispielsweise die Operation chat oder embedding haben.BonusCreditPurchase
Verfolgt einmalige Credit-Top-up-Käufe über Lemon Squeezy:
prisma/schema.prisma — BonusCreditPurchase
model BonusCreditPurchase {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Purchase Details
credits Int // Number of credits purchased
price Decimal @db.Decimal(10, 2) // Price paid in currency
expiresAt DateTime? // Expiration date (null = never expires)
// Lemon Squeezy Integration
lemonSqueezyId String @unique // Order ID from Lemon Squeezy
variantId String // Variant ID from Lemon Squeezy
status String // "completed", "refunded", "expired"
// Timestamps
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([status])
@@index([createdAt])
}
Relationen-Übersicht
Alle Modelle sind über das
User-Modell als zentrale Entität verbunden: User
|
+--------+--------+--+--+--------+--------+--------+
| | | | | | |
Subscription File EmailLog | AIConversation | BonusCredit
| | | Purchase
AIUsage AIMessage CreditTransaction
Cascade-Verhalten: Alle Relationen zu User verwenden
onDelete: Cascade — das Löschen eines Benutzers entfernt automatisch alle zugehörigen Datensätze. Die einzige Ausnahme ist EmailLog, das eine nullable userId hat (System-E-Mails sind nicht an einen Benutzer gebunden).Eins-zu-eins:
User ↔ Subscription (jeder Benutzer hat höchstens eine Subscription).Eins-zu-viele:
User → File, EmailLog, AIUsage, AIConversation, CreditTransaction, BonusCreditPurchase.Verschachtelt eins-zu-viele:
AIConversation → AIMessage (Nachrichten gehören zu einer Konversation, die zu einem Benutzer gehört).Indexierungs-Strategie
Kits Indexes sind für die häufigsten Abfragemuster ausgelegt:
| Muster | Index | Warum |
|---|---|---|
| Benutzersuche nach Auth | User.clerkId (unique) | Jede authentifizierte Anfrage sucht den Benutzer nach Clerk-ID |
| Tier-basierte Abfragen | User.tier | Admin-Dashboard filtert Benutzer nach Plan |
| Subscription-Webhooks | Subscription.customerId, subscriptionId (unique) | Lemon Squeezy-Webhooks identifizieren Subscriptions über diese Felder |
| Zeitreihen-Abfragen | Zusammengesetzt (userId, createdAt) bei AIUsage, CreditTransaction | Nutzungs-Analytics und Transaktionsverlauf für einen bestimmten Benutzer |
| E-Mail-Analytics | EmailLog.status, type, to | Berechnung von Zustellraten und empfängerbasierter Verlauf |
| Subscription-Status-Abfragen | Subscription.status | Webhook-Handler und Admin-Abfragen filtern nach Subscription-Status |
| KI-Session-Analytics | Zusammengesetzt (sessionId, createdAt) bei AIUsage | Session-bezogenes Nutzungs-Tracking und Kontingent-Prüfungen |
| Vektorsuche | IVFFlat auf faq_chunks.embedding | Schnelle approximative Nearest-Neighbor-Suche (siehe pgvector-Setup) |
Zusammengesetzte Indexes wie
@@index([userId, createdAt]) sind geordnet — sie beschleunigen Abfragen, die nach userId filtern und dann nach createdAt sortieren. Eine Abfrage, die nur nach createdAt filtert, würde von diesem Index nicht profitieren. Kit fügt eigenständige @@index([createdAt])-Einträge hinzu, wo diese für zeitbasierte Abfragen benötigt werden.Eigene Modelle hinzufügen {#adding-your-own-models}
Um ein neues Modell zum Schema hinzuzufügen:
1
Definiere das Modell in schema.prisma
Füge deine Modelldefinition zu
apps/boilerplate/prisma/schema.prisma hinzu. Halte dich an die bestehenden Konventionen:prisma
model BlogPost {
id String @id @default(uuid())
userId String
title String
content String @db.Text
slug String @unique
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([slug])
@@index([createdAt])
}
Dann füge die umgekehrte Relation zum
User-Modell hinzu:prisma
model User {
// ... existing fields
blogPosts BlogPost[]
}
2
Schema pushen oder migrieren
In der Entwicklung das Schema direkt pushen:
bash
cd apps/boilerplate && npx prisma db push
Für die Produktion eine Migration erstellen:
bash
cd apps/boilerplate && npx prisma migrate dev --name add-blog-post
Siehe Migrationen & Seeding für den vollständigen Workflow.
3
Prisma-Client neu generieren
Nach jeder Schema-Änderung die TypeScript-Typen neu generieren:
bash
cd apps/boilerplate && npx prisma generate
Dies aktualisiert das
@prisma/client-Paket, sodass dein neues Modell mit vollständiger Typsicherheit verfügbar ist.4
Query-Modul erstellen
Eine Query-Datei unter
apps/boilerplate/src/lib/db/queries/blog-posts.ts anlegen, die dem bestehenden Muster folgt:typescript
import { prisma } from '@/lib/db/prisma'
import type { BlogPost } from '@prisma/client'
export async function getBlogPostsByUserId(userId: string): Promise<BlogPost[]> {
return prisma.blogPost.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
}
export async function getBlogPostBySlug(slug: string): Promise<BlogPost | null> {
return prisma.blogPost.findUnique({
where: { slug }
})
}
Aus
apps/boilerplate/src/lib/db/queries/index.ts exportieren, um Imports sauber zu halten.