// Prisma schema for spotlight.cam // Database: PostgreSQL 15 generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Account tier enum enum AccountTier { BASIC SUPPORTER COMFORT } // Users table model User { id Int @id @default(autoincrement()) username String @unique @db.VarChar(50) email String @unique @db.VarChar(255) passwordHash String @map("password_hash") @db.VarChar(255) // WSDC Integration (Phase 1.5) firstName String? @map("first_name") @db.VarChar(100) lastName String? @map("last_name") @db.VarChar(100) wsdcId String? @unique @map("wsdc_id") @db.VarChar(20) // Social Media Links youtubeUrl String? @map("youtube_url") @db.VarChar(255) instagramUrl String? @map("instagram_url") @db.VarChar(255) facebookUrl String? @map("facebook_url") @db.VarChar(255) tiktokUrl String? @map("tiktok_url") @db.VarChar(255) // Location country String? @db.VarChar(100) city String? @db.VarChar(100) // Email Verification (Phase 1.5) emailVerified Boolean @default(false) @map("email_verified") verificationToken String? @unique @map("verification_token") @db.VarChar(255) verificationCode String? @map("verification_code") @db.VarChar(6) verificationTokenExpiry DateTime? @map("verification_token_expiry") // Password Reset (Phase 1.5) resetToken String? @unique @map("reset_token") @db.VarChar(255) resetTokenExpiry DateTime? @map("reset_token_expiry") // Account Lockout (Phase 3 - Security Hardening) failedLoginAttempts Int @default(0) @map("failed_login_attempts") lockedUntil DateTime? @map("locked_until") avatar String? @db.VarChar(255) // Account Tier & Recording Stats accountTier AccountTier @default(BASIC) @map("account_tier") recordingsDone Int @default(0) @map("recordings_done") // How many times this user recorded others recordingsReceived Int @default(0) @map("recordings_received") // How many times others recorded this user createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations messages Message[] matchesAsUser1 Match[] @relation("MatchUser1") matchesAsUser2 Match[] @relation("MatchUser2") ratingsGiven Rating[] @relation("RaterRatings") ratingsReceived Rating[] @relation("RatedRatings") eventParticipants EventParticipant[] heats EventUserHeat[] recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments") @@map("users") } // Events table (dance events from worldsdc.com) model Event { id Int @id @default(autoincrement()) slug String @unique @default(cuid()) @db.VarChar(50) name String @db.VarChar(255) location String @db.VarChar(255) startDate DateTime @map("start_date") @db.Date endDate DateTime @map("end_date") @db.Date worldsdcId String? @unique @map("worldsdc_id") @db.VarChar(100) participantsCount Int @default(0) @map("participants_count") description String? @db.Text createdAt DateTime @default(now()) @map("created_at") // Auto-matching configuration registrationDeadline DateTime? @map("registration_deadline") // When registration closes matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run scheduleConfig Json? @map("schedule_config") // Division order and collision groups // Relations chatRooms ChatRoom[] matches Match[] participants EventParticipant[] checkinToken EventCheckinToken? userHeats EventUserHeat[] recordingSuggestions RecordingSuggestion[] matchingRuns MatchingRun[] @@map("events") } // Event check-in tokens (QR code tokens for event access) model EventCheckinToken { id Int @id @default(autoincrement()) eventId Int @unique @map("event_id") token String @unique @default(cuid()) @db.VarChar(50) createdAt DateTime @default(now()) @map("created_at") // Relations event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@map("event_checkin_tokens") } // Chat rooms (event chat and private 1:1 chat) model ChatRoom { id Int @id @default(autoincrement()) eventId Int? @map("event_id") type String @db.VarChar(20) // 'event' or 'private' createdAt DateTime @default(now()) @map("created_at") // Relations event Event? @relation(fields: [eventId], references: [id]) messages Message[] matches Match[] @@map("chat_rooms") } // Messages (text messages and video links) model Message { id Int @id @default(autoincrement()) roomId Int @map("room_id") userId Int @map("user_id") content String @db.Text type String @db.VarChar(20) // 'text', 'link', 'video' createdAt DateTime @default(now()) @map("created_at") // Relations room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) @@index([roomId]) @@index([createdAt]) @@map("messages") } // Matches (pairs of users for collaboration) model Match { id Int @id @default(autoincrement()) slug String @unique @default(cuid()) @db.VarChar(50) user1Id Int @map("user1_id") user2Id Int @map("user2_id") eventId Int @map("event_id") roomId Int? @map("room_id") status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'completed' createdAt DateTime @default(now()) @map("created_at") user1LastReadAt DateTime? @map("user1_last_read_at") user2LastReadAt DateTime? @map("user2_last_read_at") // Auto-matching integration suggestionId Int? @unique @map("suggestion_id") // Link to auto-matching suggestion (optional) source String @default("manual") @db.VarChar(20) // 'manual' | 'auto' statsApplied Boolean @default(false) @map("stats_applied") // Flag to ensure stats applied only once // Relations user1 User @relation("MatchUser1", fields: [user1Id], references: [id]) user2 User @relation("MatchUser2", fields: [user2Id], references: [id]) event Event @relation(fields: [eventId], references: [id]) room ChatRoom? @relation(fields: [roomId], references: [id]) suggestion RecordingSuggestion? @relation(fields: [suggestionId], references: [id]) ratings Rating[] @@unique([user1Id, user2Id, eventId]) @@index([user1Id]) @@index([user2Id]) @@index([eventId]) @@index([suggestionId]) @@map("matches") } // Ratings (user ratings after collaboration) model Rating { id Int @id @default(autoincrement()) matchId Int @map("match_id") raterId Int @map("rater_id") ratedId Int @map("rated_id") score Int // 1-5 comment String? @db.Text wouldCollaborateAgain Boolean @default(false) @map("would_collaborate_again") createdAt DateTime @default(now()) @map("created_at") // Relations match Match @relation(fields: [matchId], references: [id]) rater User @relation("RaterRatings", fields: [raterId], references: [id]) rated User @relation("RatedRatings", fields: [ratedId], references: [id]) @@unique([matchId, raterId, ratedId]) @@index([ratedId]) @@map("ratings") } // Event participants (tracks which users joined which events) model EventParticipant { id Int @id @default(autoincrement()) userId Int @map("user_id") eventId Int @map("event_id") competitorNumber Int? @map("competitor_number") // Bib number - one per user per event recorderOptOut Boolean @default(false) @map("recorder_opt_out") // Opt-out from being a recorder accountTierOverride AccountTier? @map("account_tier_override") // Override user's global tier for this event (e.g., Comfort Pass) joinedAt DateTime @default(now()) @map("joined_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@unique([userId, eventId]) @@index([userId]) @@index([eventId]) @@map("event_participants") } // Competition divisions (Newcomer, Novice, Intermediate, etc.) model Division { id Int @id @default(autoincrement()) name String @unique @db.VarChar(50) abbreviation String @unique @db.VarChar(3) displayOrder Int @map("display_order") // Relations userHeats EventUserHeat[] @@map("divisions") } // Competition types (Jack & Jill, Strictly, etc.) model CompetitionType { id Int @id @default(autoincrement()) name String @unique @db.VarChar(50) abbreviation String @unique @db.VarChar(3) // Relations userHeats EventUserHeat[] @@map("competition_types") } // User's declared heats for matchmaking model EventUserHeat { id Int @id @default(autoincrement()) userId Int @map("user_id") eventId Int @map("event_id") divisionId Int @map("division_id") competitionTypeId Int @map("competition_type_id") heatNumber Int @map("heat_number") // 1-9 role String? @db.VarChar(10) // 'Leader', 'Follower', or NULL createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) division Division @relation(fields: [divisionId], references: [id]) competitionType CompetitionType @relation(fields: [competitionTypeId], references: [id]) recordingSuggestion RecordingSuggestion? // Constraint: Cannot have same role in same division+competition type @@unique([userId, eventId, divisionId, competitionTypeId, role]) @@index([userId, eventId]) @@index([eventId]) @@map("event_user_heats") } // Recording suggestions from auto-matching algorithm model RecordingSuggestion { id Int @id @default(autoincrement()) eventId Int @map("event_id") heatId Int @unique @map("heat_id") // One suggestion per heat recorderId Int? @map("recorder_id") // NULL if no match found status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'rejected', 'not_found' originRunId Int? @map("origin_run_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) heat EventUserHeat @relation(fields: [heatId], references: [id], onDelete: Cascade) recorder User? @relation("RecorderAssignments", fields: [recorderId], references: [id]) match Match? // Link to created match (if suggestion was accepted) originRun MatchingRun? @relation(fields: [originRunId], references: [id]) @@index([eventId]) @@index([recorderId]) @@index([originRunId]) @@map("recording_suggestions") } // Matching runs audit log model MatchingRun { id Int @id @default(autoincrement()) eventId Int @map("event_id") trigger String @db.VarChar(20) // 'manual' | 'scheduler' status String @default("running") @db.VarChar(20) // 'running' | 'success' | 'error' startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") matchedCount Int @default(0) @map("matched_count") notFoundCount Int @default(0) @map("not_found_count") error String? @db.Text // Relations event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) suggestions RecordingSuggestion[] @@index([eventId, startedAt]) @@map("matching_runs") }