chahinebrini b107262d60 feat(mdm): add NanoMDM health columns migration for UserDevice
Adds mdm_id, mdm_enrolled, mdm_supervised, mdm_last_seen_at and an
index on mdm_id. Uses IF NOT EXISTS to stay idempotent because mdm_id
was added manually before the migration was created.
2026-06-18 03:29:30 +02:00

1372 lines
59 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
/// 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")
// ─── NanoMDM iOS Enrollment ─────────────────────────────────────────────
/// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E).
/// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft.
mdmId String? @map("mdm_id")
/// Gespiegelter Enrollment-Status aus NanoMDM enrollments.enabled.
mdmEnrolled Boolean? @map("mdm_enrolled")
/// Gespiegelter Supervision-Status aus NanoMDM devices.unlock_token IS NOT NULL.
mdmSupervised Boolean? @map("mdm_supervised")
/// Letzter NanoMDM Check-In (enrollments.last_seen_at).
mdmLastSeenAt DateTime? @map("mdm_last_seen_at")
@@unique([userId, deviceId])
@@index([userId])
@@index([deviceId])
@@index([mdmId])
@@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 DeviceProtectionState {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
platform String // ios | android | mac | windows
protectionType String @map("protection_type")
active Boolean
lastSeenAt DateTime? @map("last_seen_at")
changedAt DateTime @default(now()) @map("changed_at")
reason String?
@@unique([userId, deviceId, protectionType])
@@index([userId])
@@index([deviceId])
@@index([protectionType])
@@map("device_protection_states")
@@schema("rebreak")
}
model DeviceProtectionStateLog {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
protectionType String @map("protection_type")
active Boolean
occurredAt DateTime @map("occurred_at")
reason String?
source String // app | mdm | system | heartbeat
@@index([userId, deviceId])
@@map("device_protection_state_logs")
@@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")
}