Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Some checks failed
Build and Push Docker Image / lint-and-typecheck (push) Failing after 2m10s
Build and Push Docker Image / build (push) Has been skipped

Major additions:
- Analytics dashboard with charts (line, bar, donut)
- Enterprise client monitoring with container management
- Staff management with 2FA support
- Profile management and settings pages
- Netcup server integration
- DNS verification panel
- Portainer integration
- Container logs and health monitoring
- Automation controls for orders

New API endpoints:
- /api/v1/admin/analytics
- /api/v1/admin/enterprise-clients
- /api/v1/admin/netcup
- /api/v1/admin/settings
- /api/v1/admin/staff
- /api/v1/profile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 12:33:11 +01:00
parent 60493cfbdd
commit 92092760a7
234 changed files with 52896 additions and 2425 deletions

View File

@@ -0,0 +1,272 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('PENDING_VERIFICATION', 'ACTIVE', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "StaffRole" AS ENUM ('ADMIN', 'SUPPORT');
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('TRIAL', 'STARTER', 'PRO', 'ENTERPRISE');
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('HUB_DASHBOARD', 'ADVANCED');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'CANCELED', 'PAST_DUE');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PAYMENT_CONFIRMED', 'AWAITING_SERVER', 'SERVER_READY', 'DNS_PENDING', 'DNS_READY', 'PROVISIONING', 'FULFILLED', 'EMAIL_CONFIGURED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'CLAIMED', 'RUNNING', 'COMPLETED', 'FAILED', 'DEAD');
-- CreateEnum
CREATE TYPE "LogLevel" AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR');
-- CreateEnum
CREATE TYPE "ServerConnectionStatus" AS ENUM ('PENDING', 'REGISTERED', 'ONLINE', 'OFFLINE');
-- CreateEnum
CREATE TYPE "CommandStatus" AS ENUM ('PENDING', 'SENT', 'EXECUTING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"company" TEXT,
"status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
"email_verified" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "staff" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "StaffRole" NOT NULL DEFAULT 'SUPPORT',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "staff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"plan" "SubscriptionPlan" NOT NULL DEFAULT 'TRIAL',
"tier" "SubscriptionTier" NOT NULL DEFAULT 'HUB_DASHBOARD',
"token_limit" INTEGER NOT NULL DEFAULT 10000,
"tokens_used" INTEGER NOT NULL DEFAULT 0,
"trial_ends_at" TIMESTAMP(3),
"stripe_customer_id" TEXT,
"stripe_subscription_id" TEXT,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'TRIAL',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PAYMENT_CONFIRMED',
"tier" "SubscriptionTier" NOT NULL,
"domain" TEXT NOT NULL,
"tools" TEXT[],
"config_json" JSONB NOT NULL,
"server_ip" TEXT,
"server_password_encrypted" TEXT,
"ssh_port" INTEGER NOT NULL DEFAULT 22,
"portainer_url" TEXT,
"dashboard_url" TEXT,
"failure_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"server_ready_at" TIMESTAMP(3),
"provisioning_started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "provisioning_logs" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"level" "LogLevel" NOT NULL DEFAULT 'INFO',
"message" TEXT NOT NULL,
"step" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "provisioning_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "provisioning_jobs" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"job_type" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
"priority" INTEGER NOT NULL DEFAULT 0,
"claimed_at" TIMESTAMP(3),
"claimed_by" TEXT,
"container_name" TEXT,
"attempt" INTEGER NOT NULL DEFAULT 1,
"max_attempts" INTEGER NOT NULL DEFAULT 3,
"next_retry_at" TIMESTAMP(3),
"config_snapshot" JSONB NOT NULL,
"runner_token_hash" TEXT,
"result" JSONB,
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "provisioning_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_logs" (
"id" TEXT NOT NULL,
"job_id" TEXT NOT NULL,
"level" "LogLevel" NOT NULL DEFAULT 'INFO',
"message" TEXT NOT NULL,
"step" TEXT,
"progress" INTEGER,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "job_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "token_usage" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"instance_id" TEXT,
"operation" TEXT NOT NULL,
"tokens_input" INTEGER NOT NULL,
"tokens_output" INTEGER NOT NULL,
"model" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "token_usage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "runner_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"last_used" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "runner_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "server_connections" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"registration_token" TEXT NOT NULL,
"hub_api_key" TEXT,
"orchestrator_url" TEXT,
"agent_version" TEXT,
"status" "ServerConnectionStatus" NOT NULL DEFAULT 'PENDING',
"registered_at" TIMESTAMP(3),
"last_heartbeat" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "server_connections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "remote_commands" (
"id" TEXT NOT NULL,
"server_connection_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" "CommandStatus" NOT NULL DEFAULT 'PENDING',
"result" JSONB,
"error_message" TEXT,
"queued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sent_at" TIMESTAMP(3),
"executed_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"initiated_by" TEXT,
CONSTRAINT "remote_commands_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "staff_email_key" ON "staff"("email");
-- CreateIndex
CREATE INDEX "provisioning_logs_order_id_timestamp_idx" ON "provisioning_logs"("order_id", "timestamp");
-- CreateIndex
CREATE INDEX "provisioning_jobs_status_priority_created_at_idx" ON "provisioning_jobs"("status", "priority", "created_at");
-- CreateIndex
CREATE INDEX "provisioning_jobs_order_id_idx" ON "provisioning_jobs"("order_id");
-- CreateIndex
CREATE INDEX "job_logs_job_id_timestamp_idx" ON "job_logs"("job_id", "timestamp");
-- CreateIndex
CREATE INDEX "token_usage_user_id_created_at_idx" ON "token_usage"("user_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "runner_tokens_token_hash_key" ON "runner_tokens"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_order_id_key" ON "server_connections"("order_id");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_registration_token_key" ON "server_connections"("registration_token");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_hub_api_key_key" ON "server_connections"("hub_api_key");
-- CreateIndex
CREATE INDEX "remote_commands_server_connection_id_status_idx" ON "remote_commands"("server_connection_id", "status");
-- CreateIndex
CREATE INDEX "remote_commands_status_queued_at_idx" ON "remote_commands"("status", "queued_at");
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "provisioning_logs" ADD CONSTRAINT "provisioning_logs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "provisioning_jobs" ADD CONSTRAINT "provisioning_jobs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_logs" ADD CONSTRAINT "job_logs_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "provisioning_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "token_usage" ADD CONSTRAINT "token_usage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_connections" ADD CONSTRAINT "server_connections_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remote_commands" ADD CONSTRAINT "remote_commands_server_connection_id_fkey" FOREIGN KEY ("server_connection_id") REFERENCES "server_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "company_name" TEXT,
ADD COLUMN "customer" TEXT,
ADD COLUMN "license_key" TEXT;
-- CreateTable
CREATE TABLE "system_settings" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"encrypted" BOOLEAN NOT NULL DEFAULT false,
"category" TEXT NOT NULL DEFAULT 'general',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "system_settings_key_key" ON "system_settings"("key");

View File

@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "AutomationMode" AS ENUM ('AUTO', 'MANUAL', 'PAUSED');
-- CreateEnum
CREATE TYPE "DnsRecordStatus" AS ENUM ('PENDING', 'VERIFIED', 'MISMATCH', 'NOT_FOUND', 'ERROR', 'SKIPPED');
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "automationMode" "AutomationMode" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN "automation_paused_at" TIMESTAMP(3),
ADD COLUMN "automation_paused_reason" TEXT,
ADD COLUMN "dns_verified_at" TIMESTAMP(3),
ADD COLUMN "netcup_server_id" TEXT,
ADD COLUMN "source" TEXT;
-- CreateTable
CREATE TABLE "dns_verifications" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"wildcard_passed" BOOLEAN NOT NULL DEFAULT false,
"manual_override" BOOLEAN NOT NULL DEFAULT false,
"all_passed" BOOLEAN NOT NULL DEFAULT false,
"total_subdomains" INTEGER NOT NULL DEFAULT 0,
"passed_count" INTEGER NOT NULL DEFAULT 0,
"last_checked_at" TIMESTAMP(3),
"verified_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dns_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dns_records" (
"id" TEXT NOT NULL,
"dns_verification_id" TEXT NOT NULL,
"subdomain" TEXT NOT NULL,
"full_domain" TEXT NOT NULL,
"expected_ip" TEXT NOT NULL,
"resolved_ip" TEXT,
"status" "DnsRecordStatus" NOT NULL DEFAULT 'PENDING',
"error_message" TEXT,
"checked_at" TIMESTAMP(3),
CONSTRAINT "dns_records_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "dns_verifications_order_id_key" ON "dns_verifications"("order_id");
-- CreateIndex
CREATE INDEX "dns_records_dns_verification_id_idx" ON "dns_records"("dns_verification_id");
-- AddForeignKey
ALTER TABLE "dns_verifications" ADD CONSTRAINT "dns_verifications_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dns_records" ADD CONSTRAINT "dns_records_dns_verification_id_fkey" FOREIGN KEY ("dns_verification_id") REFERENCES "dns_verifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "credentials_synced_at" TIMESTAMP(3),
ADD COLUMN "portainer_password_enc" TEXT,
ADD COLUMN "portainer_username" TEXT;

View File

@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "ErrorSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR', 'CRITICAL');
-- CreateTable
CREATE TABLE "enterprise_clients" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"company_name" TEXT,
"contact_email" TEXT NOT NULL,
"contact_phone" TEXT,
"notes" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "enterprise_clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "enterprise_servers" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"netcup_server_id" TEXT NOT NULL,
"nickname" TEXT,
"purpose" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"portainer_url" TEXT,
"portainer_username" TEXT,
"portainer_password_enc" TEXT,
CONSTRAINT "enterprise_servers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "server_stats_snapshots" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" DOUBLE PRECISION,
"memory_used_mb" DOUBLE PRECISION,
"memory_total_mb" DOUBLE PRECISION,
"disk_read_mbps" DOUBLE PRECISION,
"disk_write_mbps" DOUBLE PRECISION,
"network_in_mbps" DOUBLE PRECISION,
"network_out_mbps" DOUBLE PRECISION,
"containers_running" INTEGER,
"containers_stopped" INTEGER,
CONSTRAINT "server_stats_snapshots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "error_detection_rules" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"pattern" TEXT NOT NULL,
"severity" "ErrorSeverity" NOT NULL DEFAULT 'WARNING',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "error_detection_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "detected_errors" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"rule_id" TEXT NOT NULL,
"container_id" TEXT,
"container_name" TEXT,
"log_line" TEXT NOT NULL,
"context" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledged_at" TIMESTAMP(3),
"acknowledged_by" TEXT,
CONSTRAINT "detected_errors_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "security_verification_codes" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"action" TEXT NOT NULL,
"target_server_id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "security_verification_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "enterprise_servers_client_id_idx" ON "enterprise_servers"("client_id");
-- CreateIndex
CREATE UNIQUE INDEX "enterprise_servers_client_id_netcup_server_id_key" ON "enterprise_servers"("client_id", "netcup_server_id");
-- CreateIndex
CREATE INDEX "server_stats_snapshots_server_id_timestamp_idx" ON "server_stats_snapshots"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "server_stats_snapshots_client_id_timestamp_idx" ON "server_stats_snapshots"("client_id", "timestamp");
-- CreateIndex
CREATE INDEX "error_detection_rules_client_id_idx" ON "error_detection_rules"("client_id");
-- CreateIndex
CREATE INDEX "detected_errors_server_id_timestamp_idx" ON "detected_errors"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "detected_errors_rule_id_timestamp_idx" ON "detected_errors"("rule_id", "timestamp");
-- CreateIndex
CREATE INDEX "security_verification_codes_client_id_code_idx" ON "security_verification_codes"("client_id", "code");
-- AddForeignKey
ALTER TABLE "enterprise_servers" ADD CONSTRAINT "enterprise_servers_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_detection_rules" ADD CONSTRAINT "error_detection_rules_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_rule_id_fkey" FOREIGN KEY ("rule_id") REFERENCES "error_detection_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "security_verification_codes" ADD CONSTRAINT "security_verification_codes_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,84 @@
-- CreateEnum
CREATE TYPE "ContainerEventType" AS ENUM ('CRASH', 'OOM_KILLED', 'RESTART', 'STOPPED');
-- CreateTable
CREATE TABLE "log_scan_positions" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"last_line_count" INTEGER NOT NULL DEFAULT 0,
"last_log_hash" TEXT,
"last_scanned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "log_scan_positions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "container_state_snapshots" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"state" TEXT NOT NULL,
"exit_code" INTEGER,
"captured_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "container_state_snapshots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "container_events" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"event_type" "ContainerEventType" NOT NULL,
"exit_code" INTEGER,
"details" TEXT,
"acknowledged_at" TIMESTAMP(3),
"acknowledged_by" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "container_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_settings" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"critical_errors_only" BOOLEAN NOT NULL DEFAULT true,
"container_crashes" BOOLEAN NOT NULL DEFAULT true,
"recipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cooldown_minutes" INTEGER NOT NULL DEFAULT 30,
"last_notified_at" TIMESTAMP(3),
CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "log_scan_positions_server_id_container_id_key" ON "log_scan_positions"("server_id", "container_id");
-- CreateIndex
CREATE INDEX "container_state_snapshots_server_id_container_id_captured_a_idx" ON "container_state_snapshots"("server_id", "container_id", "captured_at");
-- CreateIndex
CREATE INDEX "container_events_server_id_timestamp_idx" ON "container_events"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "container_events_event_type_acknowledged_at_idx" ON "container_events"("event_type", "acknowledged_at");
-- CreateIndex
CREATE UNIQUE INDEX "notification_settings_client_id_key" ON "notification_settings"("client_id");
-- AddForeignKey
ALTER TABLE "log_scan_positions" ADD CONSTRAINT "log_scan_positions_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "container_state_snapshots" ADD CONSTRAINT "container_state_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "container_events" ADD CONSTRAINT "container_events_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,46 @@
-- CreateEnum
CREATE TYPE "StaffStatus" AS ENUM ('ACTIVE', 'SUSPENDED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "StaffRole" ADD VALUE 'OWNER';
ALTER TYPE "StaffRole" ADD VALUE 'MANAGER';
-- AlterTable
ALTER TABLE "staff" ADD COLUMN "backup_codes_enc" TEXT,
ADD COLUMN "invited_by" TEXT,
ADD COLUMN "status" "StaffStatus" NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "two_factor_secret_enc" TEXT,
ADD COLUMN "two_factor_verified_at" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "users" ADD COLUMN "backup_codes_enc" TEXT,
ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "two_factor_secret_enc" TEXT,
ADD COLUMN "two_factor_verified_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "staff_invitations" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "StaffRole" NOT NULL DEFAULT 'SUPPORT',
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"invited_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "staff_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "staff_invitations_email_key" ON "staff_invitations"("email");
-- CreateIndex
CREATE UNIQUE INDEX "staff_invitations_token_key" ON "staff_invitations"("token");

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "notification_cooldowns" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"last_sent_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_cooldowns_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "notification_cooldowns_type_key" ON "notification_cooldowns"("type");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "staff" ADD COLUMN "profile_photo_key" TEXT;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -21,8 +21,15 @@ enum UserStatus {
}
enum StaffRole {
ADMIN
SUPPORT
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 {
@@ -56,6 +63,21 @@ enum OrderStatus {
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
@@ -72,6 +94,37 @@ enum LogLevel {
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
// ============================================================================
@@ -87,6 +140,12 @@ model User {
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[]
@@ -95,17 +154,40 @@ model User {
}
model Staff {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String
role StaffRole @default(SUPPORT)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
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
// ============================================================================
@@ -134,38 +216,98 @@ model Subscription {
// ============================================================================
model Order {
id String @id @default(cuid())
userId String @map("user_id")
status OrderStatus @default(PAYMENT_CONFIRMED)
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")
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")
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")
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")
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)
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")
@@ -261,3 +403,317 @@ model RunnerToken {
@@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")
}