1330 lines
58 KiB
Plaintext
1330 lines
58 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-Calls (DM, nur zwischen gegenseitigen Follows) ───────────────
|
||
// Opt-out: User kann eingehende Anrufe komplett abschalten. Server-seitig
|
||
// erzwungen in social.canCall (mutual-follow + callsEnabled). Default an.
|
||
callsEnabled Boolean @default(true) @map("calls_enabled")
|
||
|
||
// ─── 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")
|
||
|
||
// ─── Screen Time Passcode (iOS Layer 3, Migration 20260601) ─────────────
|
||
// Vom App-generierten 4-stelligen Code beim Onboarding. User setzt ihn
|
||
// manuell als Screen Time Passcode, wir speichern ihn damit er nach dem
|
||
// Cooldown abrufbar ist. Kein Hash — muss plain abrufbar sein.
|
||
// Abruf NUR via GET /api/protection/screentime-passcode + canDisableProtection=true.
|
||
screentimePasscode String? @map("screentime_passcode")
|
||
|
||
// ─── MDM-Managed Flag (Build 19, Migration 20260526) ────────────────────
|
||
// mdmManaged: true wenn User's Device via MDM verwaltet wird (NEFilter-
|
||
// Profil sideloaded + non-removable). Wird vom App-Code nach nativem
|
||
// NEFilterManager.isEnabled-Check via POST /api/users/me/mdm-status gesetzt.
|
||
// Effekt: Cooldown-Selfdeactivation blockiert, Schutz nur via Trustee/
|
||
// Apple Configurator deaktivierbar.
|
||
//
|
||
// mdmDetectedAt: Zeitstempel des ersten mdmManaged=true-Writes (Audit-Trail).
|
||
// Wird nur beim Übergang false→true gesetzt, nie überschrieben.
|
||
mdmManaged Boolean @default(false) @map("mdm_managed")
|
||
mdmDetectedAt DateTime? @map("mdm_detected_at")
|
||
|
||
// ─── Push-Notifications (Migration 20260530) ──────────────────────────
|
||
// Per-User Opt-out für Chat-Push (DM + Room). Default ON. Token-spezifischer
|
||
// Disable (z.B. nach Permission-Revoke) wird in PushToken.enabled gesetzt.
|
||
chatPushEnabled Boolean @default(true) @map("chat_push_enabled")
|
||
|
||
// ─── 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[]
|
||
pushTokens PushToken[]
|
||
|
||
@@index([deletedAt])
|
||
@@index([plan])
|
||
@@map("profiles")
|
||
@@schema("rebreak")
|
||
}
|
||
|
||
// ─── Push-Tokens (Expo) ──────────────────────────────────────────────────────
|
||
//
|
||
// Ein User kann mehrere Geräte (iOS + Android etc.) haben, jedes mit eigenem
|
||
// ExponentPushToken[xxx]. Token ist von Expo serverseitig unique.
|
||
// Genutzt von server/services/push.ts sendChatPush().
|
||
model PushToken {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
userId String @map("user_id") @db.Uuid
|
||
token String @unique
|
||
platform String // "ios" | "android"
|
||
deviceId String? @map("device_id")
|
||
enabled Boolean @default(true)
|
||
/// iOS-only: PushKit VoIP-Token (64-char hex). Rotiert unabhängig vom Expo-Token.
|
||
/// NULL → kein CallKit-Wake-Push möglich (Android, Web, alte iOS-Builds).
|
||
/// Versendet via backend/server/services/voip-push.ts mit .p12-Cert (APNs HTTP/2).
|
||
voipToken String? @map("voip_token")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||
lastUsedAt DateTime? @map("last_used_at")
|
||
|
||
profile Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId])
|
||
@@map("push_tokens")
|
||
@@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")
|
||
deletedAt DateTime? @map("deleted_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[]
|
||
reactions ChatMessageReaction[]
|
||
|
||
@@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 ChatMessageReaction {
|
||
userId String @map("user_id") @db.Uuid
|
||
messageId String @map("message_id") @db.Uuid
|
||
emoji String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||
|
||
// Eine Reaktion pro User pro Message (neues Emoji ersetzt das alte — WhatsApp-Verhalten)
|
||
@@id([userId, messageId])
|
||
@@index([messageId])
|
||
@@map("chat_message_reactions")
|
||
@@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")
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
replyTo DirectMessage? @relation("DmReplies", fields: [replyToId], references: [id], onDelete: SetNull)
|
||
replies DirectMessage[] @relation("DmReplies")
|
||
likes DirectMessageLike[]
|
||
reactions DirectMessageReaction[]
|
||
|
||
// Conversation-Liste (DISTINCT ON partner, neueste zuerst) + Unread-Badge.
|
||
// Ohne diese Indizes ist jeder Conversation-Load ein Full-Table-Scan + Sort.
|
||
@@index([senderId, createdAt(sort: Desc)])
|
||
@@index([receiverId, createdAt(sort: Desc)])
|
||
@@index([receiverId, readAt])
|
||
@@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 DirectMessageReaction {
|
||
userId String @map("user_id") @db.Uuid
|
||
messageId String @map("message_id") @db.Uuid
|
||
emoji String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
message DirectMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([userId, messageId])
|
||
@@index([messageId])
|
||
@@map("direct_message_reactions")
|
||
@@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")
|
||
|
||
// Layer-2-Country-Pivot (2026-05-25): vipDeferUntil + vipEvictAt entfernt.
|
||
// Layer 2 ist nicht mehr User-Custom-gespeist — Pure Country-Curated.
|
||
// DB-Columns werden via drop_vip_swap_fields.sql gedroppt (nach Code-Deploy).
|
||
|
||
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")
|
||
}
|
||
|
||
/// Admin-kuratierte Glücksspiel-Marken für Mail-Display-Name-Matching.
|
||
/// Analog BlocklistDomain — kein User-Bezug, keine Mail-Inhalte (Art. 5 DSGVO).
|
||
/// Source: "seed" (initiale 30 Brands) | "manual" (Admin-App) | "community" (zukünftig).
|
||
/// SCHEMA hinzugefügt 2026-05-28, Migration: 20260528_add_global_mail_display_names.
|
||
model GlobalMailDisplayName {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
pattern String @unique // z.B. "Tipico", "Bet365"
|
||
isActive Boolean @default(true) @map("is_active")
|
||
source String @default("manual") // "manual" | "seed" | "community"
|
||
addedAt DateTime @default(now()) @map("added_at")
|
||
|
||
@@map("global_mail_display_names")
|
||
@@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?
|
||
|
||
// ─── Inkrementeller UID-Scan (Phase 2, Migration 20260605_mail_uid_scan_state) ─
|
||
// folder_scan_state: pro Ordner {lastUid, uidvalidity} — ermöglicht inkrementellen
|
||
// IMAP-Scan (nur neue UIDs seit letztem Run). Format:
|
||
// { "INBOX": {"lastUid":1234,"uidvalidity":5678}, "Junk Email": {...} }
|
||
// DEFAULT '{}' → erster Lauf behandelt alle Ordner als lastUid=0 (Full-Sweep).
|
||
// UIDVALIDITY-Wächter: wenn status.uidValidity != gespeicherter Wert → Reset auf 0
|
||
// (Ordner wurde server-seitig resettet, alle UIDs ungültig).
|
||
//
|
||
// last_full_sweep_at: Zeitstempel des letzten Quality-Full-Sweeps (1×/Tag).
|
||
// Wächter gegen Blocklist-Updates die ältere Mails nicht treffen würden.
|
||
// NULL → noch kein Full-Sweep ausgeführt → erster Lauf wird als Full-Sweep gezählt.
|
||
folderScanState Json @default("{}") @map("folder_scan_state")
|
||
lastFullSweepAt DateTime? @map("last_full_sweep_at") @db.Timestamptz(6)
|
||
|
||
// ─── 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 @map("date") @db.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")
|
||
|
||
// ─── RebreakMagic DNS-Device-Binding ────────────────────────────────────
|
||
/// 48+ char URL-safe random token für DNS-over-HTTPS Client-ID.
|
||
/// NULL → Device nicht als Magic-Client gebunden. Unique → pro Token nur 1 Device.
|
||
magicDnsToken String? @unique @map("magic_dns_token")
|
||
/// Wann Magic-Binding aktiviert wurde (Config-Profil installiert).
|
||
magicEnrolledAt DateTime? @map("magic_enrolled_at")
|
||
/// Killswitch: wenn gesetzt → DNS-Token serverseitig invalidiert (AdGuard-Client deleted).
|
||
/// User kann Config-Profil manuell deinstallieren, aber DNS-Queries werden abgelehnt.
|
||
magicRevokedAt DateTime? @map("magic_revoked_at")
|
||
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
|
||
magicHostname String? @map("magic_hostname")
|
||
/// Server-gehaltenes Removal-Passwort für das Mac/Win-Config-Profil.
|
||
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
|
||
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
|
||
magicRemovalPassword String? @map("magic_removal_password")
|
||
/// Hardware-gebundene ID (z. B. System-UUID, ANDROID_ID, IDFV).
|
||
/// Wird vom Client geliefert, nicht vom Backend generiert.
|
||
hardwareId String? @map("hardware_id")
|
||
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
|
||
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
|
||
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
|
||
/// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv.
|
||
magicCooldownUntil DateTime? @map("magic_cooldown_until")
|
||
|
||
@@unique([userId, deviceId])
|
||
@@unique([userId, hardwareId])
|
||
@@index([userId])
|
||
@@index([deviceId])
|
||
@@index([hardwareId])
|
||
@@map("user_devices")
|
||
@@schema("rebreak")
|
||
}
|
||
|
||
/// RebreakMagic Pairing — Native-App generiert 6-stelligen Code, Mac-App tauscht
|
||
/// gegen MagicSession-Token. Code ist single-use, läuft nach 10min ab.
|
||
// ─── Protection State Log (DiGA-Kernmetrik, additiv) ─────────────────────────
|
||
//
|
||
// Append-only Transitions-Log des Schutz-Zustands pro User.
|
||
// Ein Eintrag beschreibt ab `occurredAt` den neuen aktiven Zustand.
|
||
// Dedup: kein neues Event, wenn active == letztem bekanntem Zustand des Users.
|
||
//
|
||
// Source-Values:
|
||
// 'vpn' — VPN-Filter-App meldet Aktivierung/Deaktivierung
|
||
// 'mdm' — MDM-Profil-Status-Wechsel
|
||
// 'cooldown_disable' — Server: Cooldown abgelaufen, Schutz automatisch AUS
|
||
// 'client' — Generischer Client-Event
|
||
// 'system' — Server-seitig (Migration, Seed, etc.)
|
||
//
|
||
// Nicht-entfernen: Alte streaks/streak_events/profiles.streak bleiben (coach, scores).
|
||
model ProtectionStateLog {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
userId String @map("user_id") @db.Uuid
|
||
/// true = Schutz AN ab occurredAt, false = Schutz AUS ab occurredAt
|
||
active Boolean
|
||
source String // 'vpn' | 'mdm' | 'cooldown_disable' | 'client' | 'system'
|
||
occurredAt DateTime @map("occurred_at") @db.Timestamptz(6)
|
||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||
|
||
@@index([userId, occurredAt])
|
||
@@map("protection_state_log")
|
||
@@schema("rebreak")
|
||
}
|
||
|
||
model MagicPairingCode {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
userId String @map("user_id") @db.Uuid
|
||
/// 6-stelliger numerischer Code (z.B. "482913"). Unique während gültig.
|
||
code String @unique
|
||
expiresAt DateTime @map("expires_at")
|
||
/// Wenn redeemed: Zeitpunkt + erstellte MagicSession-ID. Code danach nicht mehr nutzbar.
|
||
redeemedAt DateTime? @map("redeemed_at")
|
||
sessionId String? @map("session_id") @db.Uuid
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@index([userId])
|
||
@@index([expiresAt])
|
||
@@map("magic_pairing_codes")
|
||
@@schema("rebreak")
|
||
}
|
||
|
||
/// RebreakMagic Session — Mac-App erhält bei Pair-Redeem einen mgc_*-Token,
|
||
/// der statt Supabase-JWT in /api/magic/* Endpoints akzeptiert wird.
|
||
/// Wird in Mac-Keychain gespeichert, kann vom User in Native-App revoked werden.
|
||
model MagicSession {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
userId String @map("user_id") @db.Uuid
|
||
/// "mgc_" + 48 char base64url (token = id wird NICHT preisgegeben).
|
||
token String @unique
|
||
/// Optionaler Mac-Hostname für User-UI ("Chahines MacBook Pro").
|
||
label String?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
lastUsedAt DateTime @default(now()) @map("last_used_at")
|
||
revokedAt DateTime? @map("revoked_at")
|
||
|
||
@@index([userId])
|
||
@@index([token])
|
||
@@map("magic_sessions")
|
||
@@schema("rebreak")
|
||
}
|
||
|
||
// Wenn ein neues Gerät versucht sich zu registrieren UND das Device-Limit
|
||
// erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät
|
||
// eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des
|
||
// Users werden via supabase_realtime benachrichtigt und zeigen ein Sheet mit
|
||
// dem Code + [Erlauben] / [Ablehnen]. User vergleicht visuell den Code auf
|
||
// beiden Geräten (verhindert Code-Forwarding-Attacken).
|
||
//
|
||
// Email-Fallback: Wenn kein anderes Gerät online ODER User klickt "Per Email
|
||
// senden", verschickt der Server eine Mail mit dem Code + One-Click-Approval-Link.
|
||
//
|
||
// Flow:
|
||
// 1. Neues Device → register → 403 DEVICE_LIMIT_REACHED
|
||
// 2. User wählt "Auf anderem Gerät bestätigen" → POST /api/devices/approvals
|
||
// 3. Server erstellt Row, broadcastet via realtime
|
||
// 4. Existing Device → approve → Server marked approved + auto-evictiert das
|
||
// älteste Device (oder das vom User gewählte) + erstellt neuen UserDevice-Slot
|
||
// 5. Neues Device pollt GET /api/devices/approvals/:id → status=approved → retry register
|
||
model DeviceApprovalRequest {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
userId String @map("user_id") @db.Uuid
|
||
|
||
// Info über das NEUE Gerät (das sich anmelden will)
|
||
newDeviceId String @map("new_device_id")
|
||
newPlatform String @map("new_platform")
|
||
newModel String? @map("new_model")
|
||
newName String? @map("new_name")
|
||
newOsVersion String? @map("new_os_version")
|
||
|
||
/// 6-stelliger Code (z.B. "123456"). Wird auf BEIDEN Geräten gezeigt für
|
||
/// visuellen Vergleich (iCloud-Pattern). Plain-text gespeichert weil
|
||
/// kurzlebig (10min TTL) und an userId gebunden.
|
||
code String
|
||
|
||
/// pending | approved | rejected | expired
|
||
status String @default("pending")
|
||
|
||
/// Welches existing Device hat approved (für Audit-Log).
|
||
/// NULL wenn via Email-Link approved.
|
||
approvedByDeviceRowId String? @map("approved_by_device_row_id") @db.Uuid
|
||
approvedAt DateTime? @map("approved_at")
|
||
rejectedAt DateTime? @map("rejected_at")
|
||
|
||
/// Welches Device wurde evictiert um Platz zu machen (UserDevice.id).
|
||
/// NULL wenn User keine Eviction nötig hatte (Slot frei).
|
||
evictedDeviceRowId String? @map("evicted_device_row_id") @db.Uuid
|
||
|
||
/// Wann Email mit Approval-Link/Code verschickt wurde (Rate-Limit: 1x pro Request).
|
||
emailSentAt DateTime? @map("email_sent_at")
|
||
/// One-Time-Token für Approval via Email-Link (statt App-Approval).
|
||
/// 32-char hex. NULL bis email-fallback getriggert.
|
||
emailToken String? @unique @map("email_token")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
/// Approval läuft nach 10 Minuten ab.
|
||
expiresAt DateTime @map("expires_at")
|
||
|
||
@@index([userId, status])
|
||
@@index([userId, createdAt(sort: Desc)])
|
||
@@map("device_approval_requests")
|
||
@@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")
|
||
}
|
||
|
||
/// Client error / crash reports submitted by users or captured automatically.
|
||
/// Intentionally minimal PII: no emails, passwords, tokens, or message content.
|
||
/// Used for debugging during test phases and high-traffic rollouts.
|
||
model ErrorReport {
|
||
id String @id @default(uuid()) @db.Uuid
|
||
source String /// "native" | "magic" | "web" | "manual" | "auto"
|
||
severity String /// "error" | "crash" | "warning" | "info"
|
||
title String
|
||
message String
|
||
stack String?
|
||
context Json? /// free-form structured context (screen, device info, etc.)
|
||
deviceInfo Json? @map("device_info") /// platform, osVersion, appVersion, model
|
||
profileId String? @map("profile_id") @db.Uuid
|
||
/// Original client timestamp if provided, otherwise server time.
|
||
reportedAt DateTime @map("reported_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@index([source])
|
||
@@index([severity])
|
||
@@index([createdAt])
|
||
@@index([profileId])
|
||
@@map("error_reports")
|
||
@@schema("rebreak")
|
||
}
|