// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ============================================================================ // ENUMS // ============================================================================ enum UserStatus { PENDING_VERIFICATION ACTIVE SUSPENDED } enum StaffRole { OWNER // Full access, cannot be deleted ADMIN // Full access, can manage staff MANAGER // Orders + servers, no staff/settings SUPPORT // View only + limited actions } enum StaffStatus { ACTIVE SUSPENDED } enum SubscriptionPlan { TRIAL STARTER PRO ENTERPRISE } enum SubscriptionTier { HUB_DASHBOARD ADVANCED } enum SubscriptionStatus { TRIAL ACTIVE CANCELED PAST_DUE } enum OrderStatus { PAYMENT_CONFIRMED AWAITING_SERVER SERVER_READY DNS_PENDING DNS_READY PROVISIONING FULFILLED EMAIL_CONFIGURED FAILED } enum AutomationMode { AUTO // Website orders - self-executing MANUAL // Staff-created - step-by-step PAUSED // Stopped for intervention } enum DnsRecordStatus { PENDING VERIFIED MISMATCH NOT_FOUND ERROR SKIPPED // For wildcard pass or manual override } enum JobStatus { PENDING CLAIMED RUNNING COMPLETED FAILED DEAD } enum LogLevel { DEBUG INFO WARN ERROR } enum ServerConnectionStatus { PENDING // Awaiting orchestrator registration REGISTERED // Orchestrator has registered ONLINE // Recent heartbeat received OFFLINE // No recent heartbeat } enum CommandStatus { PENDING SENT EXECUTING COMPLETED FAILED } // ============================================================================ // SYSTEM SETTINGS // ============================================================================ model SystemSetting { id String @id @default(cuid()) key String @unique value String // Encrypted for sensitive values encrypted Boolean @default(false) category String @default("general") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("system_settings") } // ============================================================================ // USER & STAFF MODELS // ============================================================================ model User { id String @id @default(cuid()) email String @unique passwordHash String @map("password_hash") name String? company String? status UserStatus @default(PENDING_VERIFICATION) emailVerified DateTime? @map("email_verified") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // 2FA fields twoFactorEnabled Boolean @default(false) @map("two_factor_enabled") twoFactorSecretEnc String? @map("two_factor_secret_enc") twoFactorVerifiedAt DateTime? @map("two_factor_verified_at") backupCodesEnc String? @map("backup_codes_enc") subscriptions Subscription[] orders Order[] tokenUsage TokenUsage[] @@map("users") } model Staff { id String @id @default(cuid()) email String @unique passwordHash String @map("password_hash") name String role StaffRole @default(SUPPORT) status StaffStatus @default(ACTIVE) invitedBy String? @map("invited_by") // Staff ID who sent invite createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Profile profilePhotoKey String? @map("profile_photo_key") // S3/MinIO key for profile photo // 2FA fields twoFactorEnabled Boolean @default(false) @map("two_factor_enabled") twoFactorSecretEnc String? @map("two_factor_secret_enc") twoFactorVerifiedAt DateTime? @map("two_factor_verified_at") backupCodesEnc String? @map("backup_codes_enc") @@map("staff") } model StaffInvitation { id String @id @default(cuid()) email String @unique role StaffRole @default(SUPPORT) token String @unique expiresAt DateTime @map("expires_at") invitedBy String @map("invited_by") createdAt DateTime @default(now()) @map("created_at") @@map("staff_invitations") } // ============================================================================ // SUBSCRIPTION & BILLING // ============================================================================ model Subscription { id String @id @default(cuid()) userId String @map("user_id") plan SubscriptionPlan @default(TRIAL) tier SubscriptionTier @default(HUB_DASHBOARD) tokenLimit Int @default(10000) @map("token_limit") tokensUsed Int @default(0) @map("tokens_used") trialEndsAt DateTime? @map("trial_ends_at") stripeCustomerId String? @map("stripe_customer_id") stripeSubscriptionId String? @map("stripe_subscription_id") status SubscriptionStatus @default(TRIAL) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("subscriptions") } // ============================================================================ // ORDERS & PROVISIONING // ============================================================================ model Order { id String @id @default(cuid()) userId String @map("user_id") status OrderStatus @default(PAYMENT_CONFIRMED) tier SubscriptionTier domain String tools String[] configJson Json @map("config_json") // Automation mode automationMode AutomationMode @default(MANUAL) automationPausedAt DateTime? @map("automation_paused_at") automationPausedReason String? @map("automation_paused_reason") source String? // "website" | "staff" | "api" // Customer/provisioning config customer String? @map("customer") // Short name for subdomains (e.g., "acme") companyName String? @map("company_name") // Display name (e.g., "Acme Corporation") licenseKey String? @map("license_key") // Generated: lb_inst_xxx // Server credentials (entered by staff) serverIp String? @map("server_ip") serverPasswordEncrypted String? @map("server_password_encrypted") sshPort Int @default(22) @map("ssh_port") netcupServerId String? @map("netcup_server_id") // Netcup API server ID for linking // Generated after provisioning portainerUrl String? @map("portainer_url") dashboardUrl String? @map("dashboard_url") failureReason String? @map("failure_reason") // Portainer credentials (encrypted, synced from agent) portainerUsername String? @map("portainer_username") // e.g., "admin-xyz123" portainerPasswordEnc String? @map("portainer_password_enc") // AES-256-CBC encrypted credentialsSyncedAt DateTime? @map("credentials_synced_at") // Last sync from agent // Timestamps createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") serverReadyAt DateTime? @map("server_ready_at") provisioningStartedAt DateTime? @map("provisioning_started_at") completedAt DateTime? @map("completed_at") dnsVerifiedAt DateTime? @map("dns_verified_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) provisioningLogs ProvisioningLog[] jobs ProvisioningJob[] serverConnection ServerConnection? dnsVerification DnsVerification? @@map("orders") } // ============================================================================ // DNS VERIFICATION // ============================================================================ model DnsVerification { id String @id @default(cuid()) orderId String @unique @map("order_id") wildcardPassed Boolean @default(false) @map("wildcard_passed") manualOverride Boolean @default(false) @map("manual_override") // Staff skipped check allPassed Boolean @default(false) @map("all_passed") totalSubdomains Int @default(0) @map("total_subdomains") passedCount Int @default(0) @map("passed_count") lastCheckedAt DateTime? @map("last_checked_at") verifiedAt DateTime? @map("verified_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) records DnsRecord[] @@map("dns_verifications") } model DnsRecord { id String @id @default(cuid()) dnsVerificationId String @map("dns_verification_id") subdomain String // "cloud" fullDomain String @map("full_domain") // "cloud.example.com" expectedIp String @map("expected_ip") resolvedIp String? @map("resolved_ip") status DnsRecordStatus @default(PENDING) errorMessage String? @map("error_message") checkedAt DateTime? @map("checked_at") dnsVerification DnsVerification @relation(fields: [dnsVerificationId], references: [id], onDelete: Cascade) @@index([dnsVerificationId]) @@map("dns_records") } model ProvisioningLog { id String @id @default(cuid()) orderId String @map("order_id") level LogLevel @default(INFO) message String step String? timestamp DateTime @default(now()) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) @@index([orderId, timestamp]) @@map("provisioning_logs") } // ============================================================================ // JOB QUEUE // ============================================================================ model ProvisioningJob { id String @id @default(cuid()) orderId String @map("order_id") jobType String @map("job_type") status JobStatus @default(PENDING) priority Int @default(0) claimedAt DateTime? @map("claimed_at") claimedBy String? @map("claimed_by") containerName String? @map("container_name") attempt Int @default(1) maxAttempts Int @default(3) @map("max_attempts") nextRetryAt DateTime? @map("next_retry_at") configSnapshot Json @map("config_snapshot") runnerTokenHash String? @map("runner_token_hash") result Json? error String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) logs JobLog[] @@index([status, priority, createdAt]) @@index([orderId]) @@map("provisioning_jobs") } model JobLog { id String @id @default(cuid()) jobId String @map("job_id") level LogLevel @default(INFO) message String step String? progress Int? timestamp DateTime @default(now()) job ProvisioningJob @relation(fields: [jobId], references: [id], onDelete: Cascade) @@index([jobId, timestamp]) @@map("job_logs") } // ============================================================================ // TOKEN USAGE (AI Tracking) // ============================================================================ model TokenUsage { id String @id @default(cuid()) userId String @map("user_id") instanceId String? @map("instance_id") operation String // chat, analysis, setup tokensInput Int @map("tokens_input") tokensOutput Int @map("tokens_output") model String createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt]) @@map("token_usage") } // ============================================================================ // RUNNER TOKENS // ============================================================================ model RunnerToken { id String @id @default(cuid()) tokenHash String @unique @map("token_hash") name String isActive Boolean @default(true) @map("is_active") lastUsed DateTime? @map("last_used") createdAt DateTime @default(now()) @map("created_at") @@map("runner_tokens") } // ============================================================================ // SERVER CONNECTION (Phone-Home System) // ============================================================================ model ServerConnection { id String @id @default(cuid()) orderId String @unique @map("order_id") // Registration token (generated during provisioning, used by orchestrator to register) registrationToken String @unique @map("registration_token") // Hub API key (issued after successful registration, used for heartbeats/commands) hubApiKey String? @unique @map("hub_api_key") // Orchestrator connection info (provided during registration) orchestratorUrl String? @map("orchestrator_url") agentVersion String? @map("agent_version") // Status tracking status ServerConnectionStatus @default(PENDING) registeredAt DateTime? @map("registered_at") lastHeartbeat DateTime? @map("last_heartbeat") // Timestamps createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) remoteCommands RemoteCommand[] @@map("server_connections") } // ============================================================================ // REMOTE COMMANDS (Support Backdoor) // ============================================================================ model RemoteCommand { id String @id @default(cuid()) serverConnectionId String @map("server_connection_id") // Command details type String // SHELL, RESTART_SERVICE, UPDATE, ECHO, etc. payload Json // Command-specific payload // Execution tracking status CommandStatus @default(PENDING) result Json? // Command result errorMessage String? @map("error_message") // Timestamps queuedAt DateTime @default(now()) @map("queued_at") sentAt DateTime? @map("sent_at") executedAt DateTime? @map("executed_at") completedAt DateTime? @map("completed_at") // Staff who initiated (for audit) initiatedBy String? @map("initiated_by") serverConnection ServerConnection @relation(fields: [serverConnectionId], references: [id], onDelete: Cascade) @@index([serverConnectionId, status]) @@index([status, queuedAt]) @@map("remote_commands") } // ============================================================================ // ENTERPRISE CLIENTS // ============================================================================ enum ErrorSeverity { INFO WARNING ERROR CRITICAL } model EnterpriseClient { id String @id @default(cuid()) name String companyName String? @map("company_name") contactEmail String @map("contact_email") // For security codes contactPhone String? @map("contact_phone") notes String? @db.Text isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations servers EnterpriseServer[] errorRules ErrorDetectionRule[] securityCodes SecurityVerificationCode[] statsHistory ServerStatsSnapshot[] notificationSetting NotificationSetting? @@map("enterprise_clients") } model EnterpriseServer { id String @id @default(cuid()) clientId String @map("client_id") netcupServerId String @map("netcup_server_id") // Link to Netcup server nickname String? // Optional friendly name purpose String? // e.g., "Production", "Staging", "Database" isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Portainer credentials (encrypted) portainerUrl String? @map("portainer_url") portainerUsername String? @map("portainer_username") portainerPasswordEnc String? @map("portainer_password_enc") // Relations client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) statsSnapshots ServerStatsSnapshot[] errorLogs DetectedError[] logScanPositions LogScanPosition[] stateSnapshots ContainerStateSnapshot[] containerEvents ContainerEvent[] @@unique([clientId, netcupServerId]) @@index([clientId]) @@map("enterprise_servers") } // ============================================================================ // ENTERPRISE STATS HISTORY (90-day retention) // ============================================================================ model ServerStatsSnapshot { id String @id @default(cuid()) serverId String @map("server_id") clientId String @map("client_id") timestamp DateTime @default(now()) // Server metrics (from Netcup) cpuPercent Float? @map("cpu_percent") memoryUsedMb Float? @map("memory_used_mb") memoryTotalMb Float? @map("memory_total_mb") diskReadMbps Float? @map("disk_read_mbps") diskWriteMbps Float? @map("disk_write_mbps") networkInMbps Float? @map("network_in_mbps") networkOutMbps Float? @map("network_out_mbps") // Container summary containersRunning Int? @map("containers_running") containersStopped Int? @map("containers_stopped") // Relations server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) @@index([serverId, timestamp]) @@index([clientId, timestamp]) @@map("server_stats_snapshots") } // ============================================================================ // ERROR DETECTION // ============================================================================ model ErrorDetectionRule { id String @id @default(cuid()) clientId String @map("client_id") name String // e.g., "Database Connection Failed" pattern String // Regex pattern severity ErrorSeverity @default(WARNING) isActive Boolean @default(true) @map("is_active") description String? // What this rule detects createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Relations client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) detectedErrors DetectedError[] @@index([clientId]) @@map("error_detection_rules") } model DetectedError { id String @id @default(cuid()) serverId String @map("server_id") ruleId String @map("rule_id") containerId String? @map("container_id") // Optional: which container containerName String? @map("container_name") logLine String @db.Text @map("log_line") // The actual log line that matched context String? @db.Text // Surrounding log context timestamp DateTime @default(now()) acknowledgedAt DateTime? @map("acknowledged_at") acknowledgedBy String? @map("acknowledged_by") // User ID who acknowledged // Relations server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) rule ErrorDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) @@index([serverId, timestamp]) @@index([ruleId, timestamp]) @@map("detected_errors") } // ============================================================================ // SECURITY VERIFICATION (for destructive actions) // ============================================================================ model SecurityVerificationCode { id String @id @default(cuid()) clientId String @map("client_id") code String // 6-digit code action String // "WIPE" | "REINSTALL" targetServerId String @map("target_server_id") // Which server expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") createdAt DateTime @default(now()) @map("created_at") // Relations client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) @@index([clientId, code]) @@map("security_verification_codes") } // ============================================================================ // INTELLIGENT ERROR TRACKING // ============================================================================ enum ContainerEventType { CRASH // Was running, now exited with non-zero exit code OOM_KILLED // Out of memory kill RESTART // Container restarted STOPPED // Intentional stop (exit code 0 or manual) } // Track log scanning position to avoid re-scanning same content model LogScanPosition { id String @id @default(cuid()) serverId String @map("server_id") containerId String @map("container_id") lastLineCount Int @default(0) @map("last_line_count") lastLogHash String? @map("last_log_hash") // Detect log rotation lastScannedAt DateTime @default(now()) @map("last_scanned_at") server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@unique([serverId, containerId]) @@map("log_scan_positions") } // Track container state over time for crash detection model ContainerStateSnapshot { id String @id @default(cuid()) serverId String @map("server_id") containerId String @map("container_id") containerName String @map("container_name") state String // "running", "exited", "dead" exitCode Int? @map("exit_code") capturedAt DateTime @default(now()) @map("captured_at") server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@index([serverId, containerId, capturedAt]) @@map("container_state_snapshots") } // Record significant container lifecycle events model ContainerEvent { id String @id @default(cuid()) serverId String @map("server_id") containerId String @map("container_id") containerName String @map("container_name") eventType ContainerEventType @map("event_type") exitCode Int? @map("exit_code") details String? @db.Text acknowledgedAt DateTime? @map("acknowledged_at") acknowledgedBy String? @map("acknowledged_by") timestamp DateTime @default(now()) server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@index([serverId, timestamp]) @@index([eventType, acknowledgedAt]) @@map("container_events") } // Email notification settings per client model NotificationSetting { id String @id @default(cuid()) clientId String @unique @map("client_id") enabled Boolean @default(false) criticalErrorsOnly Boolean @default(true) @map("critical_errors_only") containerCrashes Boolean @default(true) @map("container_crashes") recipients String[] @default([]) cooldownMinutes Int @default(30) @map("cooldown_minutes") lastNotifiedAt DateTime? @map("last_notified_at") client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) @@map("notification_settings") } // ============================================================================ // SYSTEM-WIDE NOTIFICATION COOLDOWN // ============================================================================ // Track last notification time per notification type for system-wide cooldown model NotificationCooldown { id String @id @default(cuid()) type String @unique // e.g., "container_crash", "critical_error", "stats_cpu" lastSentAt DateTime @map("last_sent_at") @@map("notification_cooldowns") }