40 KiB
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
- Setup + Auth (2 días)
- Base de datos + Seed (1 día)
- API Routes Core (3 días)
- Storage MinIO (1 día)
- Frontend Dashboard (4 días)
- Cron Jobs + Notifications (2 días)
- 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?