chahinebrini 265859467a feat(vip): Curated-Domain-Vorschläge — Suggest-Backend
User können länderspezifische Glücksspielseiten für die kuratierte
VIP-Layer-2-Liste vorschlagen — wichtig für Länder mit kurzer
Starter-Liste (z.B. TN).

- Schema: CuratedDomain (domain, country, status, suggestedByUserId);
  Migration 20260522_curated_domains
- webcontent-domains.get.ts komponiert jetzt JSON-Basis + DB-approved
  Curated-Domains pro Land
- POST /api/curated-domains/suggest legt einen suggested-Eintrag an

Admin-Approve (Endpoint + Admin-App-View) folgt als nächster Block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:09:00 +02:00

1030 lines
43 KiB
Plaintext

generator client {
provider = "prisma-client-js"
output = "../server/generated/prisma"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
schemas = ["rebreak"]
}
model Profile {
id String @id @db.Uuid
username String?
nickname String?
avatar String?
plan String @default("legend")
streak Int @default(0)
followersCount Int @default(0) @map("followers_count")
stripeCustomerId String? @map("stripe_customer_id")
stripeSubId String? @map("stripe_subscription_id")
premiumUntil DateTime? @map("premium_until")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
// ─── DiGA-Demographie (optional, user-initiated only) ───────────────────
// Diese Felder werden ausschließlich vom User über die Profile-Form
// gesetzt — niemals durch Lyra-Extraction oder Memory-Inference.
// Siehe memory/feedback_demographics_user_initiated.md
birthYear Int? @map("birth_year")
gender String?
maritalStatus String? @map("marital_status")
profession String? // legacy — deprecated, nicht mehr im Frontend, DB-Spalte bleibt
employmentStatus String? @map("employment_status")
shiftWork Boolean? @map("shift_work")
industry String?
jobTenure String? @map("job_tenure")
bundesland String?
city String?
demographicsConsentAt DateTime? @map("demographics_consent_at")
demographicsWithdrawnAt DateTime? @map("demographics_withdrawn_at")
// ─── Lyra Voice-Picker (Legend-only Premium) ────────────────────────────
lyraVoiceId String? @map("lyra_voice_id")
// ─── Pro-Trial-Reward (für Demographics-completion) ─────────────────────
proTrialStartedAt DateTime? @map("pro_trial_started_at")
proTrialExpiresAt DateTime? @map("pro_trial_expires_at")
proTrialSource String? @map("pro_trial_source")
proTrialUsedAt DateTime? @map("pro_trial_used_at")
// ─── DiGA-Banner Persistence (server-side, überlebt Re-Install) ─────────
digaBannerDismissedAt DateTime? @map("diga_banner_dismissed_at")
// ─── Interaktives Onboarding (Welcome → Nickname → Block → Done) ────────
// Werte: "welcome" (Default für neue Profile) | "nickname" | "block" | "done"
// Frontend nutzt diesen Wert um den Onboarding-Spotlight-Tour-Stand
// wiederherzustellen — auch nach App-Reinstall (DB-State statt AsyncStorage).
onboardingStep String @default("welcome") @map("onboarding_step")
// ─── Protection-Disable-State (post-cooldown anti-auto-reactivation) ───
// Wird gesetzt wenn der User per Cooldown-Resolve den Schutz explizit
// abschaltet. Solange `!= null`, gibt protection/state.protectionShouldBeActive
// false zurück → Frontend macht KEINE Auto-Reactivation. User muss explizit
// im UI re-aktivieren (POST /api/protection/mark-active setzt das Feld zurück).
// Anti-Pattern: ohne dieses Feld würde der Schutz nach Cooldown sofort wieder
// anspringen, was den Sinn des Cooldowns aushebelt.
protectionDisabledAt DateTime? @map("protection_disabled_at")
// ─── DiGA Rezept-Code-Audit ─────────────────────────────────────────────
// Wenn ein User per Krankenkassen-Rezept reinkommt (DiGA-Pfad), wird der
// Einlöse-Zeitpunkt hier persistiert. Reverse-Lookup auf den Code selbst
// läuft über diga_codes.used_by_profile_id (1:n).
digaCodeRedeemedAt DateTime? @map("diga_code_redeemed_at")
digaCodes DigaCode[]
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
lastInstallAt DateTime? @map("last_install_at")
// ─── Presence / Online-Status (Insta-style green dot + "vor X min") ─────
// Wird via POST /api/me/last-seen (Heartbeat, Phase 1) aktualisiert.
// Phase 2: Supabase-Edge-Function auf presence-leave-Events ersetzt den
// Heartbeat-Fallback für bessere Genauigkeit (kein 60s-Lag).
lastSeenAt DateTime? @map("last_seen_at")
presenceVisible Boolean @default(true) @map("presence_visible")
// ─── Voice-Quota (tages-basiert, UTC-Reset) ─────────────────────────────
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today")
voiceQuotaResetAt DateTime? @map("voice_quota_reset_at")
// ─── Founding Members (erste 100 Signups → automatisch Pro, lifetime) ────
// foundingMember=true → exempt von Downgrade-Reconciliation (ihr Pro ist Geschenk).
// Wird beim Signup gesetzt wenn profile-count < 100.
// Manuell setzbar via /api/dev/set-plan (für Testing).
foundingMember Boolean @default(false) @map("founding_member")
// ─── Globale Blocklist Grace-Period (nach Downgrade auf free) ────────────
// Wenn gesetzt: User sieht noch die volle Blocklist bis zu diesem Datum.
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until")
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
banned Boolean @default(false)
bannedAt DateTime? @map("banned_at")
bannedReason String? @map("banned_reason")
deletedAt DateTime? @map("deleted_at")
communityPosts CommunityPost[]
communityReplies CommunityReply[]
domainSubmissions DomainSubmission[]
@@index([deletedAt])
@@index([plan])
@@map("profiles")
@@schema("rebreak")
}
// ─── DiGA-Codes (Rezept-Einlösung für Krankenkassen-Pfad) ─────────────────────
//
// Codes werden vom Backend ausgegeben (später per Krankenkassen-API erstellt
// + an Patient per E-Mail/Brief geschickt) und im Onboarding eingelöst. Bei
// Einlösung wird der User auf den DiGA-Vollzugang (`grants_plan`, Default
// 'legend') hochgestuft und das Trial-Modell übersprungen.
//
// Test-Codes (label='test_*') werden vom Seed angelegt. Wiederverwendung
// nur per SQL-Reset: UPDATE diga_codes SET used_at=NULL, used_by_profile_id=NULL.
model DigaCode {
id String @id @default(uuid()) @db.Uuid
code String @unique
label String? // z.B. 'test_batch_2026-05' oder Krankenkassen-Code
expiresAt DateTime? @map("expires_at")
usedAt DateTime? @map("used_at")
usedByProfileId String? @map("used_by_profile_id") @db.Uuid
usedByProfile Profile? @relation(fields: [usedByProfileId], references: [id], onDelete: SetNull)
grantsPlan String @default("legend") @map("grants_plan")
notes String?
createdAt DateTime @default(now()) @map("created_at")
@@index([usedByProfileId])
@@map("diga_codes")
@@schema("rebreak")
}
model Streak {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
startDate DateTime @map("start_date") @db.Date
currentDays Int @default(0) @map("current_days")
longestDays Int @default(0) @map("longest_days")
avgMonthlySavings Float? @map("avg_monthly_savings")
isActive Boolean @default(true) @map("is_active")
@@map("streaks")
@@schema("rebreak")
}
model StreakEvent {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type String // "started" | "reset" | "milestone" | "relapse"
meta Json? // z.B. { days: 30 } für Meilensteine
createdAt DateTime @default(now()) @map("created_at")
@@map("streak_events")
@@schema("rebreak")
}
model UrgeLog {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
timestamp DateTime @default(now())
emotion String
wasOvercome Boolean @default(false) @map("was_overcome")
breathingDone Boolean @default(false) @map("breathing_done")
@@map("urge_logs")
@@schema("rebreak")
}
/// SOS-Session für DiGA-Auswertung — kompletter Verlauf einer Notfall-Session
model SosSession {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
startedAt DateTime @default(now()) @map("started_at")
endedAt DateTime? @map("ended_at")
durationSec Int? @map("duration_sec")
/// Voller Chat-Verlauf [{role, content, timestamp}]
messages Json @default("[]")
/// [{game, score, durationSec}]
gamesPlayed Json @default("[]")
breathingCount Int @default(0) @map("breathing_count")
wasOvercome Boolean @default(false) @map("was_overcome")
feedbackBetter Boolean? @map("feedback_better")
feedbackRating Int? @map("feedback_rating") // 1-5
feedbackText String? @map("feedback_text")
locale String?
@@index([userId, startedAt])
@@map("sos_sessions")
@@schema("rebreak")
}
model CommunityPost {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
category String
content String
imageUrl String? @map("image_url")
upvotes Int @default(0)
likesCount Int @default(0) @map("likes_count")
dislikesCount Int @default(0) @map("dislikes_count")
commentsCount Int @default(0) @map("comments_count")
repostsCount Int @default(0) @map("reposts_count")
isAnonymous Boolean @default(false) @map("is_anonymous")
/// Reported-Marker: true wenn ein User den Post gemeldet hat. Admin-Queue
/// listet alle Posts mit isModerated=true. Dismiss → false, Delete → bleibt
/// true zusammen mit isDeleted=true (für audit/spätere Re-Review).
isModerated Boolean @default(false) @map("is_moderated")
/// Soft-Delete durch Moderation. content → "" damit Public-API nichts mehr
/// rendert; Audit-Log behält Original (siehe ModerationAction.contentSnapshot).
isDeleted Boolean @default(false) @map("is_deleted")
deletedAt DateTime? @map("deleted_at")
/// Wann der Post zum ersten Mal gemeldet wurde (queue-Sortierung).
reportedAt DateTime? @map("reported_at")
gameName String? @map("game_name")
repostOfId String? @map("repost_of_id") @db.Uuid
challengeId String? @map("challenge_id") @db.Uuid
/// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01").
/// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt.
/// Frontend rendert t('lyra_posts.<i18nKey>') wenn gesetzt.
i18nKey String? @map("i18n_key")
createdAt DateTime @default(now()) @map("created_at")
author Profile? @relation(fields: [userId], references: [id])
repostOf CommunityPost? @relation("Reposts", fields: [repostOfId], references: [id], onDelete: SetNull)
reposts CommunityPost[] @relation("Reposts")
PostLike PostLike[]
CommunityReply CommunityReply[]
@@index([isModerated, reportedAt])
@@map("community_posts")
@@schema("rebreak")
}
model PostLike {
userId String @map("user_id") @db.Uuid
postId String @map("post_id") @db.Uuid
type String // "like" | "dislike"
post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade)
@@id([userId, postId])
@@map("post_likes")
@@schema("rebreak")
}
model CommunityReply {
id String @id @default(uuid()) @db.Uuid
postId String @map("post_id") @db.Uuid
userId String @map("user_id") @db.Uuid
content String
parentReplyId String? @map("parent_reply_id") @db.Uuid
isAnonymous Boolean @default(false) @map("is_anonymous")
likesCount Int @default(0) @map("likes_count")
/// Reported-Marker analog CommunityPost.isModerated.
isModerated Boolean @default(false) @map("is_moderated")
isDeleted Boolean @default(false) @map("is_deleted")
deletedAt DateTime? @map("deleted_at")
reportedAt DateTime? @map("reported_at")
createdAt DateTime @default(now()) @map("created_at")
post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade)
author Profile? @relation(fields: [userId], references: [id])
CommentLike CommentLike[]
@@index([isModerated, reportedAt])
@@map("community_replies")
@@schema("rebreak")
}
model CommentLike {
userId String @map("user_id") @db.Uuid
commentId String @map("comment_id") @db.Uuid
reply CommunityReply @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@id([userId, commentId])
@@map("comment_likes")
@@schema("rebreak")
}
model ChatRoom {
id String @id @default(uuid()) @db.Uuid
name String
description String?
isPublic Boolean @default(false) @map("is_public")
avatarUrl String? @map("avatar_url")
createdBy String @map("created_by") @db.Uuid
joinMode String @default("open") @map("join_mode") // "open" | "approval" | "invite_only"
inviteCode String? @unique @map("invite_code")
memberCount Int @default(0) @map("member_count")
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members ChatRoomMember[]
messages ChatMessage[]
@@map("chat_rooms")
@@schema("rebreak")
}
model ChatRoomMember {
roomId String @map("room_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role String @default("member") // "owner" | "admin" | "member"
status String @default("active") // "active" | "pending"
joinedAt DateTime @default(now()) @map("joined_at")
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@id([roomId, userId])
@@map("chat_room_members")
@@schema("rebreak")
}
model ChatMessage {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
content String
roomId String? @map("room_id") @db.Uuid
replyToId String? @map("reply_to_id") @db.Uuid
attachmentUrl String? @map("attachment_url")
attachmentType String? @map("attachment_type")
attachmentName String? @map("attachment_name")
likesCount Int @default(0) @map("likes_count")
createdAt DateTime @default(now()) @map("created_at")
room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade)
replyTo ChatMessage? @relation("ChatReplies", fields: [replyToId], references: [id], onDelete: SetNull)
replies ChatMessage[] @relation("ChatReplies")
likes ChatMessageLike[]
@@map("chat_messages")
@@schema("rebreak")
}
model ChatMessageLike {
userId String @map("user_id") @db.Uuid
messageId String @map("message_id") @db.Uuid
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@id([userId, messageId])
@@map("chat_message_likes")
@@schema("rebreak")
}
model DirectMessage {
id String @id @default(uuid()) @db.Uuid
senderId String @map("sender_id") @db.Uuid
receiverId String @map("receiver_id") @db.Uuid
content String
replyToId String? @map("reply_to_id") @db.Uuid
attachmentUrl String? @map("attachment_url")
attachmentType String? @map("attachment_type")
attachmentName String? @map("attachment_name")
likesCount Int @default(0) @map("likes_count")
createdAt DateTime @default(now()) @map("created_at")
readAt DateTime? @map("read_at")
replyTo DirectMessage? @relation("DmReplies", fields: [replyToId], references: [id], onDelete: SetNull)
replies DirectMessage[] @relation("DmReplies")
likes DirectMessageLike[]
@@map("direct_messages")
@@schema("rebreak")
}
model DirectMessageLike {
userId String @map("user_id") @db.Uuid
messageId String @map("message_id") @db.Uuid
message DirectMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@id([userId, messageId])
@@map("direct_message_likes")
@@schema("rebreak")
}
model UserFollow {
followerId String @map("follower_id") @db.Uuid
followingId String @map("following_id") @db.Uuid
@@id([followerId, followingId])
@@map("user_follows")
@@schema("rebreak")
}
model UserScore {
userId String @id @map("user_id") @db.Uuid
totalPoints Int @default(0) @map("total_points")
tier String @default("beginner")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@map("user_scores")
@@schema("rebreak")
}
model ScoreEvent {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
eventType String @map("event_type")
points Int
createdAt DateTime @default(now()) @map("created_at")
meta Json?
@@map("score_events")
@@schema("rebreak")
}
model UserCustomDomain {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
domain String
source String @default("manual")
// "active" | "submitted" | "approved" | "rejected"
status String @default("active")
// "web" | "mail_domain" | "mail_display_name"
// Alle Types teilen den gleichen Slot-Pool (countActiveCustomDomains() zählt total).
// mail_display_name: Substring-Heuristik gegen Sender-Display-Name (kein PII, Art. 4 DSGVO)
type String @default("web")
postId String? @map("post_id") @db.Uuid
addedAt DateTime @default(now()) @map("added_at")
// VIP-Slot-Replace (Layer-2-Swap mit 24h-Cooldown):
// vipDeferUntil — die NEUE Domain ist erst ab hier Teil der VIP-Liste
// (während des Cooldowns nur via Layer 1 geschützt).
// vipEvictAt — die ERSETZTE Domain fällt ab hier aus der VIP-Liste.
// Beide NULL = kein laufender Swap.
vipDeferUntil DateTime? @map("vip_defer_until")
vipEvictAt DateTime? @map("vip_evict_at")
submission DomainSubmission?
@@unique([userId, domain])
@@index([userId, type])
@@map("user_custom_domains")
@@schema("rebreak")
}
// Länderspezifische Curated-Domain-Vorschläge für die VIP-Layer-2-Liste.
// User schlagen Glücksspielseiten ihres Landes vor; `approved`-Einträge
// ergänzen im webcontent-domains-Endpoint die statische gambling-domains.json.
model CuratedDomain {
id String @id @default(uuid()) @db.Uuid
domain String
country String
// "suggested" | "approved" | "rejected"
status String @default("suggested")
suggestedByUserId String? @map("suggested_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
reviewedAt DateTime? @map("reviewed_at")
@@unique([country, domain])
@@index([country, status])
@@map("curated_domains")
@@schema("rebreak")
}
model DomainSubmission {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
domain String
customDomainId String @unique @map("custom_domain_id") @db.Uuid
postId String? @map("post_id") @db.Uuid
// "pending" | "approved" | "rejected"
status String @default("pending")
// "web" | "mail_domain" — spiegelt den Type der zugehörigen UserCustomDomain-Row
type String @default("web")
yesVotes Int @default(0) @map("yes_votes")
noVotes Int @default(0) @map("no_votes")
reviewNote String? @map("review_note")
createdAt DateTime @default(now()) @map("created_at")
reviewedAt DateTime? @map("reviewed_at")
customDomain UserCustomDomain @relation(fields: [customDomainId], references: [id], onDelete: Cascade)
user Profile @relation(fields: [userId], references: [id])
votes DomainVote[]
@@index([status, createdAt])
@@index([type, status])
@@map("domain_submissions")
@@schema("rebreak")
}
model DomainVote {
userId String @map("user_id") @db.Uuid
submissionId String @map("submission_id") @db.Uuid
vote String // "yes" | "no"
createdAt DateTime @default(now()) @map("created_at")
submission DomainSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
@@id([userId, submissionId])
@@map("domain_votes")
@@schema("rebreak")
}
enum FeedbackStatus {
PENDING
REVIEWING
PLANNED
SHIPPED
REJECTED
@@schema("rebreak")
}
model FeedbackItem {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
content String
category String?
status FeedbackStatus @default(PENDING)
adminNote String? @map("admin_note")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("feedback_items")
@@schema("rebreak")
}
model BlocklistDomain {
id String @id @default(uuid()) @db.Uuid
domain String @unique
source String
isActive Boolean @default(true) @map("is_active")
reportCount Int @default(0) @map("report_count")
@@map("blocklist_domains")
@@schema("rebreak")
}
model TrustedContact {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String
phone String?
email String?
createdAt DateTime @default(now()) @map("created_at")
@@map("trusted_contacts")
@@schema("rebreak")
}
model CoachSession {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
content Json
createdAt DateTime @default(now()) @map("created_at")
@@map("coach_sessions")
@@schema("rebreak")
}
model MailConnection {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
email String
provider String @default("imap")
providerName String? @map("provider_name")
imapHost String @map("imap_host")
imapPort Int @map("imap_port")
rejectUnauthorized Boolean @default(true) @map("reject_unauthorized")
useStarttls Boolean @default(false) @map("use_starttls")
passwordEncrypted String @map("password_encrypted")
isActive Boolean @default(true) @map("is_active")
/// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht).
/// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück.
pausedAt DateTime? @map("paused_at")
pausedReason String? @map("paused_reason") // z.B. "plan_downgrade"
scanInterval Int @default(24) @map("scan_interval")
lastScannedAt DateTime? @map("last_scanned_at")
nextScanAt DateTime? @map("next_scan_at")
emailsBlocked Int @default(0) @map("emails_blocked")
emailsScanned Int @default(0) @map("emails_scanned")
lastConnectError String? @map("last_connect_error")
lastConnectErrorAt DateTime? @map("last_connect_error_at")
lastIdleHeartbeatAt DateTime? @map("last_idle_heartbeat_at")
createdAt DateTime @default(now()) @map("created_at")
// ─── OAuth2-Auth-Framework (additiv, Phase 0) ────────────────────────────
// authMethod: 'app_password' (default, alle bestehenden Connections)
// | 'oauth2_microsoft' (Outlook/Hotmail/Live — Phase 1)
// | 'oauth2_google' (Gmail, future — wenn Google Basic-Auth deprecated)
// Bestehende Connections: app_password per DEFAULT, keine Migration nötig.
// OAuth-Felder bleiben NULL für app_password-Connections.
authMethod String @default("app_password") @map("auth_method")
/// AES-256-GCM encrypted (gleiches Verfahren wie passwordEncrypted, gleicher ENCRYPTION_KEY).
/// Format: iv(24hex):tag(32hex):ciphertext(hex) — via server/utils/crypto.ts encrypt()
oauthAccessToken String? @map("oauth_access_token")
/// AES-256-GCM encrypted. Microsoft kann bei Refresh neues refresh_token liefern
/// (Token-Rotation) — bei jedem Refresh-Call persistieren.
oauthRefreshToken String? @map("oauth_refresh_token")
/// UTC-Zeitstempel wann access_token abläuft. Daemon prüft on-connect:
/// wenn expiry < now+5min → refresh vor IMAP-Connect.
oauthTokenExpiry DateTime? @map("oauth_token_expiry")
/// Gespeicherter Scope-String des erteilten Konsents (z.B. "IMAP.AccessAsUser.All offline_access openid").
/// Für Audit und zukünftige Scope-Vergleiche bei Re-Auth.
oauthScope String? @map("oauth_scope")
// ─── User-definierter Anzeige-Titel (optional, max 60 chars auf API-Ebene) ──
// Wird statt der vollen Email-Adresse angezeigt (z.B. "Privat-Gmail").
// NULL → Frontend fällt auf Email-Domain zurück.
title String?
// ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ───────────
// consentAt=NULL für Bestandsrows → "Re-Consent pending".
// Daemon pausiert Mail-Verarbeitung wenn consentAt=NULL (kein Auto-Delete).
// consentVersion: z.B. "art9-mail-v1-2026-05-13". Bump → alle Consents invalid.
// consentIpAddress: Beweispflicht Art. 7 Abs. 1 DSGVO.
consentAt DateTime? @map("consent_at")
consentVersion String? @map("consent_version")
consentIpAddress String? @map("consent_ip_address")
blockedMails MailBlocked[]
blockedStats MailBlockedStat[]
@@unique([userId, email])
@@map("mail_connections")
@@schema("rebreak")
}
enum GameChallengeStatus {
OPEN
ACTIVE
FINISHED
CANCELLED
@@schema("rebreak")
}
model GameChallenge {
id String @id @default(uuid()) @db.Uuid
challengerId String @map("challenger_id") @db.Uuid
challengerName String @map("challenger_name")
opponentId String? @map("opponent_id") @db.Uuid
opponentName String? @map("opponent_name")
status GameChallengeStatus @default(OPEN)
board String @default("---------")
currentTurn String @default("X") @map("current_turn")
winner String?
postId String? @map("post_id") @db.Uuid
gameType String @default("tictactoe") @map("game_type")
isLive Boolean @default(false) @map("is_live")
memoryState Json? @map("memory_state")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("game_challenges")
@@schema("rebreak")
}
model Notification {
id String @id @default(uuid()) @db.Uuid
recipientId String @map("recipient_id") @db.Uuid
type String // "new_comment" | "new_like" | "domain_vote"
actorName String @map("actor_name")
actorAvatar String? @map("actor_avatar")
postId String? @map("post_id") @db.Uuid
preview String?
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([recipientId, readAt])
@@map("notifications")
@@schema("rebreak")
}
model GameScore {
userId String @id @map("user_id") @db.Uuid
playerName String @map("player_name")
wins Int @default(0)
losses Int @default(0)
draws Int @default(0)
points Int @default(0)
updatedAt DateTime @updatedAt @map("updated_at")
@@map("game_scores")
@@schema("rebreak")
}
model GameRating {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
gameName String @map("game_name")
stars Int
feedback String?
score Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
@@map("game_ratings")
@@schema("rebreak")
}
model GameHighScore {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
nickname String
gameName String @map("game_name")
score Int
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, gameName])
@@index([gameName, score(sort: Desc)])
@@map("game_high_scores")
@@schema("rebreak")
}
model MailBlocked {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
connectionId String @map("connection_id") @db.Uuid
gmailMessageId String @map("gmail_message_id")
senderEmail String @map("sender_email")
senderName String? @map("sender_name")
subject String
receivedAt DateTime @map("received_at")
action String
/// Welcher Layer die Blockierung ausgelöst hat (z.B. "domain", "brand+random", "score:85", "llm:0.92").
/// NULL für ältere Einträge (vor Migration 20260514).
triggerSource String? @map("trigger_source") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
connection MailConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([gmailMessageId, userId])
@@map("mail_blocked")
@@schema("rebreak")
}
/// Klassifikations-Samples für ML-Phase 3 (zukünftiges Fine-Tuning / Modell-Evaluation).
/// Enthält Features + Outcomes jeder Mail-Klassifikation.
/// KEIN Mail-Body — nur Metadaten (Sender-Domain, Subject, Score-Komponenten).
/// Cascade-Delete bei User-Löschung (Art. 17 DSGVO).
model MailClassificationSample {
id String @id @default(cuid())
userId String @map("user_id") @db.Uuid
connectionId String? @map("connection_id") @db.Uuid
// Raw features (was analysiert wurde):
senderName String? @map("sender_name") @db.VarChar(255)
senderDomain String? @map("sender_domain") @db.VarChar(255)
relayDecodedDomain String? @map("relay_decoded_domain") @db.VarChar(255)
subject String? @db.VarChar(998) // RFC 5322 max
// Computed features (Score-Komponenten als JSON):
features Json // { score, brandMatch, randomTokens, keywordHits, styleFlags, … }
// Outcome:
finalAction String @map("final_action") // "blocked" | "passed"
triggerSource String @map("trigger_source") // "domain", "brand+random", "score:NN", "llm:0.XX", "whitelist"
// Groq verdict (nur wenn Layer 4 lief):
groqIsGambling Boolean? @map("groq_is_gambling")
groqConfidence Float? @map("groq_confidence")
groqReason String? @map("groq_reason") @db.Text
// User-Feedback (für später):
userFeedback String? @map("user_feedback") // null | "correct" | "false-positive" | "false-negative"
feedbackAt DateTime? @map("feedback_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId])
@@index([createdAt])
@@index([finalAction, triggerSource])
@@map("mail_classification_samples")
@@schema("rebreak")
}
/// Permanente Aggregat-Statistiken blockierter Mails pro Tag + Connection.
/// Befüllt live beim Scan (vor dem 24h-Cleanup von mail_blocked).
/// Enthält KEINE Mail-Inhalte — nur counts/dates (Datenminimierung Art. 5 DSGVO).
/// Bei Connection-Disconnect werden Stats mitgelöscht (Cascade) — Konsistenz
/// mit Art. 17 DSGVO: User-initiierter Disconnect = Recht auf Löschung aller
/// zu dieser Connection gehörenden Daten.
model MailBlockedStat {
id String @id @default(cuid())
userId String @map("user_id") @db.Uuid
/// UTC-Datum (time=00:00:00) — ein Eintrag pro User+Tag+Connection
date DateTime @db.Date @map("date")
mailConnectionId String @map("mail_connection_id") @db.Uuid
/// IMAP-Host-Slug, z.B. "imap.gmail.com" (raw, für resolveProviderMeta zur Read-Zeit)
provider String @map("provider")
/// Human-readable Label, z.B. "Gmail" (wird bei Scan-Zeit aus resolveProviderMeta befüllt)
providerLabel String @map("provider_label")
count Int @default(0)
connection MailConnection @relation(fields: [mailConnectionId], references: [id], onDelete: Cascade)
@@unique([userId, date, mailConnectionId], map: "mail_blocked_stats_unique_day")
@@index([userId, date])
@@index([userId, mailConnectionId])
@@map("mail_blocked_stats")
@@schema("rebreak")
}
model ImapProxyAccount {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
proxyUsername String @unique @map("proxy_username")
proxyPassword String @map("proxy_password")
connectionId String @unique @map("connection_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
@@map("imap_proxy_accounts")
@@schema("rebreak")
}
model CooldownRequest {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
reason String?
cooldownStartedAt DateTime @default(now()) @map("cooldown_started_at")
cooldownEndsAt DateTime @map("cooldown_ends_at")
resolvedAt DateTime? @map("resolved_at")
cancelledAt DateTime? @map("cancelled_at")
tokenJti String @unique @map("token_jti")
@@index([userId, cooldownEndsAt])
@@map("cooldown_requests")
@@schema("rebreak")
}
enum LyraMemoryType {
trigger
habit
strength
relationship
milestone
pain_point
goal
preference
@@schema("rebreak")
}
/// Persistente Erinnerungen von Lyra über den User — injiziert in System-Prompt jeder Session.
/// Enthält Art-9-Gesundheitsdaten (Glücksspielkontext) — strenge RLS: nur service-role schreibt.
model LyraMemory {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type LyraMemoryType
content String @db.VarChar(500)
confidence Float @default(0.7)
source String? // session-id | "manual" | "observed"
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
lastReferencedAt DateTime? @map("last_referenced_at") @db.Timestamptz(6)
@@index([userId, type])
@@map("lyra_memories")
@@schema("rebreak")
}
/// Admin-Allowlist — nur Einträge hier erhalten Zugang zur Admin-App.
/// Seed: INSERT INTO "rebreak"."admin_users" ("user_id", "created_at") VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW());
model AdminUser {
userId String @id @map("user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
addedBy String? @map("added_by")
@@map("admin_users")
@@schema("rebreak")
}
/// Audit-Log für Moderation-Aktionen (DSGVO-konform: Original-Inhalt bleibt für
/// 90 Tage erhalten, danach Cron-Cleanup). Wird von /api/admin/moderation/[id]/*
/// nach jeder Aktion (dismiss / delete / ban-user) geschrieben.
model ModerationAction {
id String @id @default(uuid()) @db.Uuid
/// "post" | "comment"
targetType String @map("target_type")
/// CommunityPost.id oder CommunityReply.id
targetId String @map("target_id") @db.Uuid
/// "dismiss" | "delete" | "ban_user"
action String
/// Profile.id des Admins (aus admin_users-Allowlist).
adminUserId String? @map("admin_user_id") @db.Uuid
/// Snapshot des Original-Contents zum Zeitpunkt der Aktion (Audit-Trail).
contentSnapshot String? @map("content_snapshot")
/// Optionale Begründung vom Admin.
reason String?
createdAt DateTime @default(now()) @map("created_at")
@@index([targetType, targetId])
@@index([createdAt])
@@map("moderation_actions")
@@schema("rebreak")
}
/// Append-only Audit-Log für DSGVO-Einwilligungen und -Widerrufe.
/// Jede Einwilligung (grant) UND jeder Widerruf (revoke) wird als eigener Eintrag geschrieben.
/// Beweispflicht Art. 7 Abs. 1 DSGVO — niemals löschen, nur archivieren.
model ConsentLog {
id String @id @default(cuid())
userId String @map("user_id") @db.Uuid
/// 'art9-mail' | künftige Consent-Typen (z.B. 'art9-lyra-memory')
consentType String @map("consent_type")
/// z.B. "art9-mail-v1-2026-05-13". Gleich der consentVersion in MailConnection.
consentVersion String @map("consent_version")
consentAt DateTime @map("consent_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
/// Optional: welche MailConnection-Row dieser Consent betrifft.
mailConnectionId String? @map("mail_connection_id") @db.Uuid
/// Gesetzt wenn Einwilligung widerrufen wurde.
revokedAt DateTime? @map("revoked_at")
/// 'user_disconnect' | 'account_deleted' | 'text_version_updated'
revokeReason String? @map("revoke_reason")
@@index([userId, consentType])
@@map("consent_logs")
@@schema("rebreak")
}
// Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices).
// Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird
// bei jedem authentifizierten Request via x-device-id Header geprüft.
//
// Device-Account-Lock (Bypass-Schutz):
// boundToPlan — Plan des Users zum Zeitpunkt der Bindung. NULL = noch nicht
// gebunden (Free-User oder Device vor Migration). Lock gilt
// nur wenn boundToPlan IN (pro, legend, standard, premium).
// releaseRequestedAt — wann Original-User "Gerät freigeben" angeklickt hat.
// releaseRequestedAt + 24h = Freigabe (Drang-Cooldown).
// lockNotifiedAt — Rate-Limit-Marker: letzte Mail-Notification bei Login-Versuch
// auf gebundenem Gerät. Max 1 Mail / 6h / Device.
//
// DSGVO Hans-Müller: Art-17-Konto-Löschung kaskadiert user_devices via ON DELETE CASCADE
// im DB-FK → alle Device-Locks automatisch released. Keine gesonderte Logik nötig.
model UserDevice {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id") // Capacitor persistent UUID
platform String // "ios" | "android" | "web"
model String? // z.B. "iPhone15,2"
name String? // z.B. "Chahines iPhone"
osVersion String? @map("os_version") // z.B. "18.4.1"
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
// ─── Device-Account-Lock ────────────────────────────────────────────────
/// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv.
/// Lock gilt wenn: boundToPlan != null AND releaseRequestedAt + 24h > NOW()
boundToPlan String? @map("bound_to_plan")
/// Wann der Original-User "Gerät freigeben" beantragt hat.
/// NULL → noch kein Release-Request. Freigabe wird aktiv nach +24h.
releaseRequestedAt DateTime? @map("release_requested_at")
/// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h.
lockNotifiedAt DateTime? @map("lock_notified_at")
@@unique([userId, deviceId])
@@index([userId])
@@index([deviceId])
@@map("user_devices")
@@schema("rebreak")
}
// Multi-Device DNS-Schutz: Legend-User können bis zu 3 Geräte (Mac/iOS/Android/Windows)
// mit einem per-Device DoH-Token schützen. Token wird in die mobileconfig/DoH-URL
// eingebettet — ist die einzige Auth für den DoH-Server (Phase 2).
model ProtectedDevice {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
/// Per-Device DoH-Token, embedded in mobileconfig ServerURL.
/// Format: 32-char cryptographic-random hex.
dnsToken String @unique @map("dns_token")
/// "mac" | "windows" | "ios" | "android"
platform String
/// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone"
label String
/// pending (enrolled, profile not installed yet) | active (user confirmed install) | degraded (plan downgrade — Grace läuft) | revoked
status String @default("pending")
/// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2)
installedAt DateTime? @map("installed_at")
/// Optional: DoH-server pingt das später (Phase 2, separater Sprint)
lastDnsQueryAt DateTime? @map("last_dns_query_at")
/// Gesetzt wenn Plan-Downgrade das Gerät auf degraded setzt. Nach 14d Passthrough.
/// Bei Re-Upgrade: zurück auf active, degradedAt=null.
degradedAt DateTime? @map("degraded_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId])
@@index([dnsToken])
@@map("protected_devices")
@@schema("rebreak")
}
/// Short-lived PKCE state entries for Microsoft OAuth flow.
/// Created by POST /api/mail/oauth/microsoft/init, consumed + deleted by
/// POST /api/mail/oauth/microsoft/callback.
/// TTL: 10 minutes — entries older than that are rejected and garbage-collected.
model OauthPendingState {
id String @id @default(uuid()) @db.Uuid
/// Random 128-bit hex string used as CSRF state parameter in auth URL.
stateId String @unique @map("state_id")
userId String @map("user_id") @db.Uuid
/// PKCE code_verifier (random 43-128 char string, S256 method).
/// Never leaves the server — only used for token exchange.
codeVerifier String @map("code_verifier")
/// Optional: pre-filled email for login_hint parameter.
email String?
createdAt DateTime @default(now()) @map("created_at")
@@index([stateId])
@@index([createdAt])
@@map("oauth_pending_states")
@@schema("rebreak")
}