# 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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 ```sql 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` ```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 ```bash npx create-next-app@latest osb-master --typescript --tailwind --app cd osb-master ``` #### 1.2 Instalar Dependencias ```bash 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 ```bash pnpm prisma init ``` Copiar el schema completo a `prisma/schema.prisma` #### 1.4 Variables de Entorno ```bash # .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 ```bash pnpm prisma migrate dev --name init pnpm prisma generate ``` #### 1.6 Seed de Catálogos ```bash pnpm prisma db seed ``` --- ### Fase 2: Autenticación (Día 1-2) #### NextAuth Config **Archivo**: `src/lib/auth.ts` ```typescript 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` ```typescript 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` ```typescript 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` ```typescript 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` ```typescript '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 (

Dashboard OSB Master

{/* Stats Cards */}
Total Clientes
{stats?.totalCustomers || 0}
Instancias Activas
{stats?.activeInstances || 0}
Contratos Activos
{stats?.activeContracts || 0}
Sugerencias Pendientes
{stats?.pendingSuggestions || 0}
{/* Alerts */} {stats?.alerts && stats.alerts.length > 0 && ( Alertas
{stats.alerts.map((alert: any) => (

{alert.title}

{alert.message}

))}
)} {/* More sections... */}
); } ``` --- ## Vercel Cron Jobs **Archivo**: `vercel.json` ```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 ```bash # 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?