Files
Temp_MSSPLASHPage/master_app_plan.md

40 KiB

OSB Master Application - Plan de Implementación

Resumen Ejecutivo

OSB Master es una aplicación Next.js 14 diseñada para gestionar y monitorear múltiples instancias de SplashPage. Proporciona un dashboard centralizado para administrar clientes, contratos, documentos, métricas y sugerencias.

URL de Producción: https://master.osb.beprime.mx


Stack Tecnológico

Frontend

  • Framework: Next.js 14 con App Router
  • Styling: Tailwind CSS
  • Components: shadcn/ui
  • State Management: TanStack Query (React Query) v5
  • Forms: React Hook Form + Zod validation
  • Charts: Recharts
  • File Upload: react-dropzone
  • PDF Preview: react-pdf

Backend

  • Runtime: Next.js API Routes (serverless)
  • ORM: Prisma
  • Database: PostgreSQL (Vercel Postgres recomendado)
  • Authentication: NextAuth.js v5 + Authentik SSO
  • Storage: MinIO S3 (instancia existente)
  • Email: Resend o SendGrid

Deployment

  • Hosting: Vercel
  • Cron Jobs: Vercel Cron
  • Environment: Production + Preview

Arquitectura de Base de Datos

Catálogos (Tablas de Referencia)

1. manufacturers

CREATE TABLE manufacturers (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL UNIQUE,
  description TEXT,
  logo_url VARCHAR(500),
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Seed data
INSERT INTO manufacturers (name, logo_url) VALUES
  ('Cisco Meraki', '/logos/meraki.svg'),
  ('Ubiquiti', '/logos/ubiquiti.svg'),
  ('Aruba Networks', '/logos/aruba.svg'),
  ('Ruckus', '/logos/ruckus.svg'),
  ('FortiNet', '/logos/fortinet.svg');

2. document_types

CREATE TABLE document_types (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  code VARCHAR(50) NOT NULL UNIQUE,
  description TEXT,
  is_required BOOLEAN DEFAULT FALSE,
  display_order INTEGER DEFAULT 0,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Seed data
INSERT INTO document_types (name, code, is_required, display_order) VALUES
  ('Propuesta Comercial', 'PROPOSAL', FALSE, 1),
  ('Liberación de Proyecto', 'PROJECT_RELEASE', TRUE, 2),
  ('Inicio de Proyecto', 'PROJECT_START', TRUE, 3),
  ('Contrato Firmado', 'CONTRACT_SIGNED', TRUE, 4),
  ('Anexos Técnicos', 'TECH_ANNEX', FALSE, 5),
  ('Orden de Compra', 'PURCHASE_ORDER', FALSE, 6),
  ('Facturas', 'INVOICES', FALSE, 7);

3. contract_statuses

CREATE TABLE contract_statuses (
  id SERIAL PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE,
  color VARCHAR(7) NOT NULL, -- Hex color
  description TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Seed data
INSERT INTO contract_statuses (name, color, description) VALUES
  ('Propuesta', '#3b82f6', 'En negociación'),
  ('Activo', '#10b981', 'Contrato vigente'),
  ('Por Vencer (30d)', '#f59e0b', 'Vence en menos de 30 días'),
  ('Por Vencer (7d)', '#ef4444', 'Vence en menos de 7 días'),
  ('Vencido', '#6b7280', 'Contrato expirado'),
  ('Suspendido', '#8b5cf6', 'Temporalmente inactivo'),
  ('Cancelado', '#000000', 'Contrato terminado');

4. notification_types

CREATE TABLE notification_types (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL UNIQUE,
  template_code VARCHAR(50) NOT NULL,
  priority VARCHAR(20) NOT NULL, -- low, medium, high, critical
  description TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Seed data
INSERT INTO notification_types (name, template_code, priority, description) VALUES
  ('Licencia por vencer (30 días)', 'license_expiring_30d', 'medium', 'Alerta 30 días antes'),
  ('Licencia por vencer (7 días)', 'license_expiring_7d', 'high', 'Alerta 7 días antes'),
  ('Capacidad excedida (90%)', 'capacity_warning_90', 'medium', 'Usuarios al 90%'),
  ('Capacidad excedida (100%)', 'capacity_critical_100', 'critical', 'Usuarios al límite'),
  ('Instancia no responde', 'instance_down', 'critical', 'Health check failed'),
  ('Documentos faltantes', 'missing_documents', 'low', 'Docs requeridos faltantes');

5. suggestion_categories

CREATE TABLE suggestion_categories (
  id SERIAL PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE,
  color VARCHAR(7) NOT NULL,
  icon VARCHAR(50),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Seed data
INSERT INTO suggestion_categories (name, color, icon) VALUES
  ('Bug', '#ef4444', 'bug'),
  ('Feature Request', '#3b82f6', 'lightbulb'),
  ('Improvement', '#10b981', 'arrow-up'),
  ('Performance', '#f59e0b', 'zap'),
  ('UI/UX', '#8b5cf6', 'palette'),
  ('Documentation', '#6b7280', 'book'),
  ('Other', '#64748b', 'help-circle');

Tablas Principales

customers

CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200) NOT NULL,
  slug VARCHAR(200) NOT NULL UNIQUE,
  industry VARCHAR(100),
  contact_name VARCHAR(150),
  contact_email VARCHAR(150),
  contact_phone VARCHAR(50),
  logo_url VARCHAR(500),
  website VARCHAR(500),
  address TEXT,
  notes TEXT,
  is_active BOOLEAN DEFAULT TRUE,
  onboarding_status VARCHAR(50) DEFAULT 'pending', -- pending, in_progress, completed
  onboarding_checklist JSONB DEFAULT '{}',
  onboarded_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_customers_slug ON customers(slug);
CREATE INDEX idx_customers_status ON customers(onboarding_status);

instances

CREATE TABLE instances (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
  name VARCHAR(200) NOT NULL,
  instance_id VARCHAR(100) NOT NULL UNIQUE, -- lc-prod, sultanes-dev, etc.
  environment VARCHAR(20) DEFAULT 'production', -- production, development, staging
  base_url VARCHAR(500) NOT NULL,
  api_key_encrypted TEXT NOT NULL,
  tenant_id INTEGER,
  tenant_name VARCHAR(150),
  status VARCHAR(20) DEFAULT 'active', -- active, suspended, inactive
  last_health_check TIMESTAMP,
  last_metrics_pull TIMESTAMP,
  health_status VARCHAR(20), -- healthy, degraded, down
  app_version VARCHAR(50),
  created_at TIMESTAMP DEFAULT NOW(),
  enrolled_at TIMESTAMP,
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_instances_customer ON instances(customer_id);
CREATE INDEX idx_instances_status ON instances(status);
CREATE INDEX idx_instances_instance_id ON instances(instance_id);

contracts

CREATE TABLE contracts (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
  instance_id INTEGER REFERENCES instances(id) ON DELETE SET NULL,
  contract_number VARCHAR(100) NOT NULL UNIQUE,
  manufacturer_id INTEGER REFERENCES manufacturers(id),
  status_id INTEGER REFERENCES contract_statuses(id),

  -- Fechas
  validity_start DATE NOT NULL,
  validity_end DATE NOT NULL,
  duration_months INTEGER,
  signed_at TIMESTAMP,

  -- Dispositivos
  device_quantity INTEGER NOT NULL,
  device_type VARCHAR(100), -- AP, Switch, Camera, etc.

  -- Precios
  unit_price DECIMAL(12,2) NOT NULL,
  volume_discount_percent DECIMAL(5,2) DEFAULT 0,
  subtotal DECIMAL(12,2),
  monthly_cost DECIMAL(12,2) NOT NULL,
  annual_cost DECIMAL(12,2) NOT NULL,
  currency VARCHAR(3) DEFAULT 'MXN',

  -- Facturación
  billing_frequency VARCHAR(20) DEFAULT 'monthly', -- monthly, quarterly, annual

  -- Renovación
  renewal_reminder_days INTEGER DEFAULT 30,
  auto_renew BOOLEAN DEFAULT FALSE,

  -- Adicionales
  terms_and_conditions TEXT,
  notes TEXT,

  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  created_by VARCHAR(150),
  updated_by VARCHAR(150)
);

CREATE INDEX idx_contracts_customer ON contracts(customer_id);
CREATE INDEX idx_contracts_status ON contracts(status_id);
CREATE INDEX idx_contracts_validity ON contracts(validity_end);
CREATE INDEX idx_contracts_number ON contracts(contract_number);

contract_documents

CREATE TABLE contract_documents (
  id SERIAL PRIMARY KEY,
  contract_id INTEGER REFERENCES contracts(id) ON DELETE CASCADE,
  document_type_id INTEGER REFERENCES document_types(id),

  -- File info
  file_name VARCHAR(500) NOT NULL,
  file_size BIGINT, -- bytes
  mime_type VARCHAR(100),

  -- S3 storage
  s3_bucket VARCHAR(200) NOT NULL,
  s3_key VARCHAR(500) NOT NULL,

  -- Versioning
  version INTEGER DEFAULT 1,
  is_latest BOOLEAN DEFAULT TRUE,

  -- Metadata
  notes TEXT,
  uploaded_by VARCHAR(150),
  uploaded_at TIMESTAMP DEFAULT NOW(),

  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_documents_contract ON contract_documents(contract_id);
CREATE INDEX idx_documents_type ON contract_documents(document_type_id);
CREATE INDEX idx_documents_latest ON contract_documents(contract_id, document_type_id, is_latest);

metrics_history

CREATE TABLE metrics_history (
  id BIGSERIAL PRIMARY KEY,
  instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE,
  metric_name VARCHAR(100) NOT NULL,
  metric_value DECIMAL(18,2),
  metadata JSONB,
  timestamp TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_metrics_instance ON metrics_history(instance_id);
CREATE INDEX idx_metrics_name ON metrics_history(metric_name);
CREATE INDEX idx_metrics_timestamp ON metrics_history(timestamp DESC);
CREATE INDEX idx_metrics_lookup ON metrics_history(instance_id, metric_name, timestamp DESC);

suggestions

CREATE TABLE suggestions (
  id SERIAL PRIMARY KEY,
  instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE,
  customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
  category_id INTEGER REFERENCES suggestion_categories(id),

  -- Usuario origen
  user_id BIGINT, -- ID en la instancia de origen
  user_email VARCHAR(150),
  user_name VARCHAR(150),

  -- Contenido
  subject VARCHAR(200) NOT NULL,
  description TEXT NOT NULL,
  priority VARCHAR(20) DEFAULT 'medium', -- low, medium, high

  -- Estado
  status VARCHAR(20) DEFAULT 'pending', -- pending, in_review, resolved, rejected
  admin_notes TEXT,
  resolved_by VARCHAR(150),
  resolved_at TIMESTAMP,

  -- Attachments (JSON array of S3 URLs)
  attachments JSONB DEFAULT '[]',

  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_suggestions_instance ON suggestions(instance_id);
CREATE INDEX idx_suggestions_customer ON suggestions(customer_id);
CREATE INDEX idx_suggestions_status ON suggestions(status);
CREATE INDEX idx_suggestions_category ON suggestions(category_id);

notifications

CREATE TABLE notifications (
  id SERIAL PRIMARY KEY,
  instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE,
  customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
  notification_type_id INTEGER REFERENCES notification_types(id),

  title VARCHAR(200) NOT NULL,
  message TEXT NOT NULL,
  severity VARCHAR(20) NOT NULL, -- info, warning, critical

  -- Recipients (JSON array of emails)
  recipients JSONB NOT NULL,

  -- Status
  status VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed
  sent_at TIMESTAMP,
  retry_count INTEGER DEFAULT 0,
  last_error TEXT,

  -- Additional data
  metadata JSONB,

  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_notifications_instance ON notifications(instance_id);
CREATE INDEX idx_notifications_status ON notifications(status);
CREATE INDEX idx_notifications_type ON notifications(notification_type_id);

Prisma Schema

Ubicación: prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Catálogos
model Manufacturer {
  id          Int       @id @default(autoincrement())
  name        String    @unique @db.VarChar(100)
  description String?   @db.Text
  logoUrl     String?   @map("logo_url") @db.VarChar(500)
  isActive    Boolean   @default(true) @map("is_active")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  contracts   Contract[]

  @@map("manufacturers")
}

model DocumentType {
  id           Int       @id @default(autoincrement())
  name         String    @db.VarChar(100)
  code         String    @unique @db.VarChar(50)
  description  String?   @db.Text
  isRequired   Boolean   @default(false) @map("is_required")
  displayOrder Int       @default(0) @map("display_order")
  createdAt    DateTime  @default(now()) @map("created_at")

  documents    ContractDocument[]

  @@map("document_types")
}

model ContractStatus {
  id          Int       @id @default(autoincrement())
  name        String    @unique @db.VarChar(50)
  color       String    @db.VarChar(7)
  description String?   @db.Text
  createdAt   DateTime  @default(now()) @map("created_at")

  contracts   Contract[]

  @@map("contract_statuses")
}

model NotificationType {
  id           Int       @id @default(autoincrement())
  name         String    @unique @db.VarChar(100)
  templateCode String    @map("template_code") @db.VarChar(50)
  priority     String    @db.VarChar(20)
  description  String?   @db.Text
  createdAt    DateTime  @default(now()) @map("created_at")

  notifications Notification[]

  @@map("notification_types")
}

model SuggestionCategory {
  id        Int       @id @default(autoincrement())
  name      String    @unique @db.VarChar(50)
  color     String    @db.VarChar(7)
  icon      String?   @db.VarChar(50)
  createdAt DateTime  @default(now()) @map("created_at")

  suggestions Suggestion[]

  @@map("suggestion_categories")
}

// Tablas principales
model Customer {
  id                  Int       @id @default(autoincrement())
  name                String    @db.VarChar(200)
  slug                String    @unique @db.VarChar(200)
  industry            String?   @db.VarChar(100)
  contactName         String?   @map("contact_name") @db.VarChar(150)
  contactEmail        String?   @map("contact_email") @db.VarChar(150)
  contactPhone        String?   @map("contact_phone") @db.VarChar(50)
  logoUrl             String?   @map("logo_url") @db.VarChar(500)
  website             String?   @db.VarChar(500)
  address             String?   @db.Text
  notes               String?   @db.Text
  isActive            Boolean   @default(true) @map("is_active")
  onboardingStatus    String    @default("pending") @map("onboarding_status") @db.VarChar(50)
  onboardingChecklist Json      @default("{}") @map("onboarding_checklist")
  onboardedAt         DateTime? @map("onboarded_at")
  createdAt           DateTime  @default(now()) @map("created_at")
  updatedAt           DateTime  @updatedAt @map("updated_at")

  instances      Instance[]
  contracts      Contract[]
  suggestions    Suggestion[]
  notifications  Notification[]

  @@index([slug])
  @@index([onboardingStatus])
  @@map("customers")
}

model Instance {
  id               Int       @id @default(autoincrement())
  customerId       Int       @map("customer_id")
  name             String    @db.VarChar(200)
  instanceId       String    @unique @map("instance_id") @db.VarChar(100)
  environment      String    @default("production") @db.VarChar(20)
  baseUrl          String    @map("base_url") @db.VarChar(500)
  apiKeyEncrypted  String    @map("api_key_encrypted") @db.Text
  tenantId         Int?      @map("tenant_id")
  tenantName       String?   @map("tenant_name") @db.VarChar(150)
  status           String    @default("active") @db.VarChar(20)
  lastHealthCheck  DateTime? @map("last_health_check")
  lastMetricsPull  DateTime? @map("last_metrics_pull")
  healthStatus     String?   @map("health_status") @db.VarChar(20)
  appVersion       String?   @map("app_version") @db.VarChar(50)
  createdAt        DateTime  @default(now()) @map("created_at")
  enrolledAt       DateTime? @map("enrolled_at")
  updatedAt        DateTime  @updatedAt @map("updated_at")

  customer       Customer   @relation(fields: [customerId], references: [id], onDelete: Cascade)
  contracts      Contract[]
  metricsHistory MetricsHistory[]
  suggestions    Suggestion[]
  notifications  Notification[]

  @@index([customerId])
  @@index([status])
  @@index([instanceId])
  @@map("instances")
}

model Contract {
  id                    Int       @id @default(autoincrement())
  customerId            Int       @map("customer_id")
  instanceId            Int?      @map("instance_id")
  contractNumber        String    @unique @map("contract_number") @db.VarChar(100)
  manufacturerId        Int?      @map("manufacturer_id")
  statusId              Int?      @map("status_id")
  validityStart         DateTime  @map("validity_start") @db.Date
  validityEnd           DateTime  @map("validity_end") @db.Date
  durationMonths        Int?      @map("duration_months")
  signedAt              DateTime? @map("signed_at")
  deviceQuantity        Int       @map("device_quantity")
  deviceType            String?   @map("device_type") @db.VarChar(100)
  unitPrice             Decimal   @map("unit_price") @db.Decimal(12, 2)
  volumeDiscountPercent Decimal   @default(0) @map("volume_discount_percent") @db.Decimal(5, 2)
  subtotal              Decimal?  @db.Decimal(12, 2)
  monthlyCost           Decimal   @map("monthly_cost") @db.Decimal(12, 2)
  annualCost            Decimal   @map("annual_cost") @db.Decimal(12, 2)
  currency              String    @default("MXN") @db.VarChar(3)
  billingFrequency      String    @default("monthly") @map("billing_frequency") @db.VarChar(20)
  renewalReminderDays   Int       @default(30) @map("renewal_reminder_days")
  autoRenew             Boolean   @default(false) @map("auto_renew")
  termsAndConditions    String?   @map("terms_and_conditions") @db.Text
  notes                 String?   @db.Text
  createdAt             DateTime  @default(now()) @map("created_at")
  updatedAt             DateTime  @updatedAt @map("updated_at")
  createdBy             String?   @map("created_by") @db.VarChar(150)
  updatedBy             String?   @map("updated_by") @db.VarChar(150)

  customer     Customer            @relation(fields: [customerId], references: [id], onDelete: Cascade)
  instance     Instance?           @relation(fields: [instanceId], references: [id], onDelete: SetNull)
  manufacturer Manufacturer?       @relation(fields: [manufacturerId], references: [id])
  status       ContractStatus?     @relation(fields: [statusId], references: [id])
  documents    ContractDocument[]

  @@index([customerId])
  @@index([statusId])
  @@index([validityEnd])
  @@index([contractNumber])
  @@map("contracts")
}

model ContractDocument {
  id             Int       @id @default(autoincrement())
  contractId     Int       @map("contract_id")
  documentTypeId Int?      @map("document_type_id")
  fileName       String    @map("file_name") @db.VarChar(500)
  fileSize       BigInt?   @map("file_size")
  mimeType       String?   @map("mime_type") @db.VarChar(100)
  s3Bucket       String    @map("s3_bucket") @db.VarChar(200)
  s3Key          String    @map("s3_key") @db.VarChar(500)
  version        Int       @default(1)
  isLatest       Boolean   @default(true) @map("is_latest")
  notes          String?   @db.Text
  uploadedBy     String?   @map("uploaded_by") @db.VarChar(150)
  uploadedAt     DateTime  @default(now()) @map("uploaded_at")
  createdAt      DateTime  @default(now()) @map("created_at")

  contract     Contract      @relation(fields: [contractId], references: [id], onDelete: Cascade)
  documentType DocumentType? @relation(fields: [documentTypeId], references: [id])

  @@index([contractId])
  @@index([documentTypeId])
  @@index([contractId, documentTypeId, isLatest])
  @@map("contract_documents")
}

model MetricsHistory {
  id          BigInt    @id @default(autoincrement())
  instanceId  Int       @map("instance_id")
  metricName  String    @map("metric_name") @db.VarChar(100)
  metricValue Decimal?  @map("metric_value") @db.Decimal(18, 2)
  metadata    Json?
  timestamp   DateTime  @default(now())

  instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)

  @@index([instanceId])
  @@index([metricName])
  @@index([timestamp(sort: Desc)])
  @@index([instanceId, metricName, timestamp(sort: Desc)])
  @@map("metrics_history")
}

model Suggestion {
  id          Int       @id @default(autoincrement())
  instanceId  Int       @map("instance_id")
  customerId  Int       @map("customer_id")
  categoryId  Int?      @map("category_id")
  userId      BigInt?   @map("user_id")
  userEmail   String?   @map("user_email") @db.VarChar(150)
  userName    String?   @map("user_name") @db.VarChar(150)
  subject     String    @db.VarChar(200)
  description String    @db.Text
  priority    String    @default("medium") @db.VarChar(20)
  status      String    @default("pending") @db.VarChar(20)
  adminNotes  String?   @map("admin_notes") @db.Text
  resolvedBy  String?   @map("resolved_by") @db.VarChar(150)
  resolvedAt  DateTime? @map("resolved_at")
  attachments Json      @default("[]")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  instance Instance           @relation(fields: [instanceId], references: [id], onDelete: Cascade)
  customer Customer           @relation(fields: [customerId], references: [id], onDelete: Cascade)
  category SuggestionCategory? @relation(fields: [categoryId], references: [id])

  @@index([instanceId])
  @@index([customerId])
  @@index([status])
  @@index([categoryId])
  @@map("suggestions")
}

model Notification {
  id                 Int       @id @default(autoincrement())
  instanceId         Int?      @map("instance_id")
  customerId         Int       @map("customer_id")
  notificationTypeId Int       @map("notification_type_id")
  title              String    @db.VarChar(200)
  message            String    @db.Text
  severity           String    @db.VarChar(20)
  recipients         Json
  status             String    @default("pending") @db.VarChar(20)
  sentAt             DateTime? @map("sent_at")
  retryCount         Int       @default(0) @map("retry_count")
  lastError          String?   @map("last_error") @db.Text
  metadata           Json?
  createdAt          DateTime  @default(now()) @map("created_at")

  instance         Instance?        @relation(fields: [instanceId], references: [id], onDelete: Cascade)
  customer         Customer         @relation(fields: [customerId], references: [id], onDelete: Cascade)
  notificationType NotificationType @relation(fields: [notificationTypeId], references: [id])

  @@index([instanceId])
  @@index([status])
  @@index([notificationTypeId])
  @@map("notifications")
}

Estructura de Proyecto Next.js

osb-master/
├── prisma/
│   ├── schema.prisma
│   └── seed.ts
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   ├── auth/
│   │   │   │   └── [...nextauth]/route.ts
│   │   │   ├── cron/
│   │   │   │   ├── poll-instances/route.ts
│   │   │   │   ├── check-contracts/route.ts
│   │   │   │   └── send-notifications/route.ts
│   │   │   ├── contracts/
│   │   │   │   ├── route.ts (GET, POST)
│   │   │   │   ├── [id]/route.ts (GET, PUT, DELETE)
│   │   │   │   ├── [id]/documents/route.ts
│   │   │   │   ├── [id]/documents/upload/route.ts
│   │   │   │   ├── [id]/validate/route.ts
│   │   │   │   └── expiring/route.ts
│   │   │   ├── customers/
│   │   │   │   ├── route.ts
│   │   │   │   └── [id]/route.ts
│   │   │   ├── instances/
│   │   │   │   ├── route.ts
│   │   │   │   ├── [id]/route.ts
│   │   │   │   ├── [id]/metrics/route.ts
│   │   │   │   ├── [id]/suspend/route.ts
│   │   │   │   ├── [id]/activate/route.ts
│   │   │   │   └── [id]/update-license/route.ts
│   │   │   ├── onboarding/
│   │   │   │   ├── enroll/route.ts
│   │   │   │   ├── customers/route.ts
│   │   │   │   └── complete/[customerId]/route.ts
│   │   │   ├── storage/
│   │   │   │   ├── upload/route.ts
│   │   │   │   ├── download/[id]/route.ts
│   │   │   │   └── delete/[id]/route.ts
│   │   │   ├── suggestions/
│   │   │   │   ├── route.ts
│   │   │   │   ├── submit/route.ts (webhook receiver)
│   │   │   │   └── [id]/respond/route.ts
│   │   │   └── notifications/
│   │   │       └── route.ts
│   │   ├── dashboard/
│   │   │   ├── page.tsx (main dashboard)
│   │   │   ├── layout.tsx
│   │   │   ├── customers/
│   │   │   │   ├── page.tsx
│   │   │   │   └── [id]/page.tsx
│   │   │   ├── instances/
│   │   │   │   ├── page.tsx
│   │   │   │   └── [id]/page.tsx
│   │   │   ├── contracts/
│   │   │   │   ├── page.tsx
│   │   │   │   └── [id]/page.tsx
│   │   │   ├── suggestions/
│   │   │   │   └── page.tsx
│   │   │   ├── notifications/
│   │   │   │   └── page.tsx
│   │   │   ├── onboarding/
│   │   │   │   └── page.tsx
│   │   │   └── settings/
│   │   │       └── catalogs/page.tsx
│   │   └── login/
│   │       └── page.tsx
│   ├── components/
│   │   ├── ui/ (shadcn components)
│   │   ├── dashboard/
│   │   │   ├── stats-card.tsx
│   │   │   ├── instance-table.tsx
│   │   │   └── metrics-chart.tsx
│   │   ├── contracts/
│   │   │   ├── contract-form.tsx
│   │   │   ├── document-upload.tsx
│   │   │   └── document-list.tsx
│   │   └── suggestions/
│   │       ├── suggestion-kanban.tsx
│   │       └── suggestion-detail.tsx
│   ├── lib/
│   │   ├── prisma.ts
│   │   ├── auth.ts (NextAuth config)
│   │   ├── storage/
│   │   │   └── minio-client.ts
│   │   └── utils/
│   │       ├── encryption.ts
│   │       ├── date-utils.ts
│   │       └── validators.ts
│   └── types/
│       └── index.ts
├── .env.local
├── next.config.js
├── tailwind.config.js
├── tsconfig.json
├── vercel.json (cron jobs)
└── package.json

Implementación por Fases

Fase 1: Setup Base (Día 1)

1.1 Crear Proyecto

npx create-next-app@latest osb-master --typescript --tailwind --app
cd osb-master

1.2 Instalar Dependencias

pnpm add @prisma/client @auth/prisma-adapter
pnpm add next-auth@beta
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
pnpm add recharts react-query @tanstack/react-query
pnpm add react-hook-form @hookform/resolvers zod
pnpm add react-dropzone react-pdf
pnpm add date-fns
pnpm add -D prisma @types/node

1.3 Configurar Prisma

pnpm prisma init

Copiar el schema completo a prisma/schema.prisma

1.4 Variables de Entorno

# .env.local
DATABASE_URL="postgresql://user:pass@host:5432/osb_master"

# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-random-secret-here"

# Authentik
AUTHENTIK_CLIENT_ID="your-client-id"
AUTHENTIK_CLIENT_SECRET="your-client-secret"
AUTHENTIK_ISSUER="https://your-authentik-domain.com/application/o/osb-master/"

# MinIO S3
MINIO_ENDPOINT="your-minio-url.com"
MINIO_PORT="9000"
MINIO_ACCESS_KEY="your-access-key"
MINIO_SECRET_KEY="your-secret-key"
MINIO_BUCKET="osb-master-contracts"
MINIO_USE_SSL="true"

# Master API
MASTER_API_KEY="your-secure-shared-key"

# Email
RESEND_API_KEY="re_your_key" # or SENDGRID_API_KEY
EMAIL_FROM="noreply@osb.beprime.mx"

1.5 Migrar Base de Datos

pnpm prisma migrate dev --name init
pnpm prisma generate

1.6 Seed de Catálogos

pnpm prisma db seed

Fase 2: Autenticación (Día 1-2)

NextAuth Config

Archivo: src/lib/auth.ts

import NextAuth from 'next-auth';
import type { NextAuthConfig } from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from './prisma';

export const authConfig = {
  adapter: PrismaAdapter(prisma),
  providers: [
    {
      id: 'authentik',
      name: 'Authentik',
      type: 'oidc',
      issuer: process.env.AUTHENTIK_ISSUER,
      clientId: process.env.AUTHENTIK_CLIENT_ID,
      clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
      authorization: { params: { scope: 'openid email profile' } },
    },
  ],
  callbacks: {
    async session({ session, user }) {
      session.user.id = user.id;
      session.user.role = user.role || 'viewer';
      return session;
    },
  },
  pages: {
    signIn: '/login',
  },
} satisfies NextAuthConfig;

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

Middleware: src/middleware.ts

import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
  const isAuthenticated = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith('/login');
  const isDashboard = req.nextUrl.pathname.startsWith('/dashboard');

  if (isDashboard && !isAuthenticated) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  if (isAuthPage && isAuthenticated) {
    return NextResponse.redirect(new URL('/dashboard', req.url));
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Fase 3: API Routes (Días 3-5)

Ejemplo: Onboarding Enroll

Archivo: src/app/api/onboarding/enroll/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  try {
    // Validate API key
    const apiKey = req.headers.get('x-master-api-key');
    if (apiKey !== process.env.MASTER_API_KEY) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const body = await req.json();
    const { instanceId, baseUrl, tenantId, tenantName, environment, appVersion, contactEmail } = body;

    // Check if customer exists
    let customer = await prisma.customer.findFirst({
      where: {
        OR: [
          { contactEmail: contactEmail },
          { name: tenantName },
        ],
      },
    });

    // Create customer if doesn't exist
    if (!customer) {
      customer = await prisma.customer.create({
        data: {
          name: tenantName,
          slug: tenantName.toLowerCase().replace(/\s+/g, '-'),
          contactEmail: contactEmail,
          onboardingStatus: 'auto_enrolled',
          onboardingChecklist: {
            customer_info_complete: true,
            instance_enrolled: false,
            contract_created: false,
            required_documents_uploaded: false,
            billing_configured: false,
            first_metrics_received: false,
            all_complete: false,
          },
        },
      });
    }

    // Generate unique API key for this instance
    const newApiKey = crypto.randomUUID();

    // Encrypt API key (simple example - use proper encryption in production)
    const apiKeyEncrypted = Buffer.from(newApiKey).toString('base64');

    // Upsert instance
    const instance = await prisma.instance.upsert({
      where: { instanceId },
      update: {
        baseUrl,
        appVersion,
        environment,
        lastHealthCheck: new Date(),
        updatedAt: new Date(),
      },
      create: {
        customerId: customer.id,
        instanceId,
        name: `${tenantName} - ${environment}`,
        baseUrl,
        apiKeyEncrypted,
        tenantId,
        tenantName,
        environment,
        appVersion,
        status: 'pending_verification',
        enrolledAt: new Date(),
      },
    });

    // Update onboarding checklist
    await prisma.customer.update({
      where: { id: customer.id },
      data: {
        onboardingChecklist: {
          ...customer.onboardingChecklist as any,
          instance_enrolled: true,
        },
      },
    });

    return NextResponse.json({
      success: true,
      customerId: customer.id,
      instanceId: instance.id,
      apiKey: newApiKey, // Return once, instance should save it
      message: 'Instance enrolled successfully',
    });
  } catch (error) {
    console.error('Enrollment error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Ejemplo: Poll Instances (Cron)

Archivo: src/app/api/cron/poll-instances/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET(req: NextRequest) {
  // Verify Vercel Cron secret
  const authHeader = req.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    // Get all active instances
    const instances = await prisma.instance.findMany({
      where: { status: 'active' },
      include: { customer: true },
    });

    const results = [];

    for (const instance of instances) {
      try {
        // Decrypt API key
        const apiKey = Buffer.from(instance.apiKeyEncrypted, 'base64').toString();

        // Call instance metrics endpoint
        const response = await fetch(`${instance.baseUrl}/api/InstanceMetrics/metrics`, {
          headers: {
            'X-Master-Api-Key': apiKey,
          },
          signal: AbortSignal.timeout(30000), // 30 second timeout
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const metrics = await response.json();

        // Save metrics to history
        const metricEntries = [
          { name: 'total_users', value: metrics.totalUsers },
          { name: 'active_users_24h', value: metrics.activeUsersLast24Hours },
          { name: 'active_users_7d', value: metrics.activeUsersLast7Days },
          { name: 'online_users', value: metrics.onlineUsersNow },
          { name: 'total_access_points', value: metrics.totalAccessPoints },
          { name: 'emails_sent_24h', value: metrics.emailsSentLast24Hours },
        ];

        await prisma.metricsHistory.createMany({
          data: metricEntries.map(({ name, value }) => ({
            instanceId: instance.id,
            metricName: name,
            metricValue: value,
            metadata: metrics,
          })),
        });

        // Update instance health
        await prisma.instance.update({
          where: { id: instance.id },
          data: {
            lastMetricsPull: new Date(),
            lastHealthCheck: new Date(),
            healthStatus: 'healthy',
            appVersion: metrics.appVersion,
          },
        });

        // Check for alerts
        if (metrics.totalUsers >= metrics.totalUsers * 0.9) {
          // Create capacity warning
          await prisma.notification.create({
            data: {
              instanceId: instance.id,
              customerId: instance.customerId,
              notificationTypeId: 3, // Capacity 90%
              title: 'Capacidad al 90%',
              message: `La instancia ${instance.name} ha alcanzado el 90% de capacidad`,
              severity: 'warning',
              recipients: [instance.customer.contactEmail],
              status: 'pending',
            },
          });
        }

        results.push({ instanceId: instance.instanceId, status: 'success' });
      } catch (error) {
        console.error(`Failed to poll instance ${instance.instanceId}:`, error);

        // Update instance as unhealthy
        await prisma.instance.update({
          where: { id: instance.id },
          data: {
            healthStatus: 'down',
            lastHealthCheck: new Date(),
          },
        });

        // Create alert
        await prisma.notification.create({
          data: {
            instanceId: instance.id,
            customerId: instance.customerId,
            notificationTypeId: 5, // Instance down
            title: 'Instancia no responde',
            message: `La instancia ${instance.name} no está respondiendo`,
            severity: 'critical',
            recipients: [instance.customer.contactEmail],
            status: 'pending',
          },
        });

        results.push({ instanceId: instance.instanceId, status: 'failed', error: error.message });
      }
    }

    return NextResponse.json({
      success: true,
      polled: instances.length,
      results,
    });
  } catch (error) {
    console.error('Poll instances error:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Fase 4: Frontend (Días 6-10)

Dashboard Principal

Archivo: src/app/dashboard/page.tsx

'use client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useQuery } from '@tanstack/react-query';
import { Users, Server, FileText, MessageSquare } from 'lucide-react';

export default function DashboardPage() {
  const { data: stats } = useQuery({
    queryKey: ['dashboard-stats'],
    queryFn: async () => {
      const res = await fetch('/api/dashboard/stats');
      return res.json();
    },
  });

  return (
    <div className="container mx-auto p-6 space-y-6">
      <h1 className="text-3xl font-bold">Dashboard OSB Master</h1>

      {/* Stats Cards */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
        <Card>
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-sm font-medium">Total Clientes</CardTitle>
            <Users className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.totalCustomers || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-sm font-medium">Instancias Activas</CardTitle>
            <Server className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.activeInstances || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-sm font-medium">Contratos Activos</CardTitle>
            <FileText className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.activeContracts || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between pb-2">
            <CardTitle className="text-sm font-medium">Sugerencias Pendientes</CardTitle>
            <MessageSquare className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.pendingSuggestions || 0}</div>
          </CardContent>
        </Card>
      </div>

      {/* Alerts */}
      {stats?.alerts && stats.alerts.length > 0 && (
        <Card>
          <CardHeader>
            <CardTitle>Alertas</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-2">
              {stats.alerts.map((alert: any) => (
                <div key={alert.id} className="p-3 bg-yellow-50 rounded border-l-4 border-yellow-400">
                  <p className="font-semibold">{alert.title}</p>
                  <p className="text-sm text-gray-600">{alert.message}</p>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      )}

      {/* More sections... */}
    </div>
  );
}

Vercel Cron Jobs

Archivo: vercel.json

{
  "crons": [
    {
      "path": "/api/cron/poll-instances",
      "schedule": "*/5 * * * *"
    },
    {
      "path": "/api/cron/check-contracts",
      "schedule": "0 9 * * *"
    },
    {
      "path": "/api/cron/send-notifications",
      "schedule": "0 10 * * *"
    }
  ]
}

Deploy a Vercel

# Install Vercel CLI
npm i -g vercel

# Login
vercel login

# Deploy
vercel --prod

Configure las variables de entorno en el dashboard de Vercel.


Timeline Final

Total: 15-19 días

  1. Setup + Auth (2 días)
  2. Base de datos + Seed (1 día)
  3. API Routes Core (3 días)
  4. Storage MinIO (1 día)
  5. Frontend Dashboard (4 días)
  6. Cron Jobs + Notifications (2 días)
  7. Testing + Polish (3 días)

Soporte y Documentación

  • Documentación API: Generar con Swagger/OpenAPI
  • Guía de Usuario: Crear docs para admins
  • Runbook: Procedimientos de mantenimiento

¿Listo para comenzar la implementación?