From 6bef8a7ed1408c2c69238abdf20c4642203e5008 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:29:00 +0000 Subject: [PATCH] =?UTF-8?q?Construir=20plataforma=20SaaS=20B2B=20multiserv?= =?UTF-8?q?icio=20de=20tecnolog=C3=ADa=20e=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa la plataforma completa descrita en la especificación: Next.js 14 (App Router, TypeScript estricto), Prisma + PostgreSQL (Supabase), Supabase Auth (email, Google OAuth, recuperación de contraseña), multi-tenancy real con guards centralizados (requireUser/requireOrganization/requireAdmin/ assertOrganizationAccess), y los 20 servicios del catálogo con planes Starter/Pro/Enterprise, flujos operativos data-driven vía ServiceWorkflowRegistry, billing con Stripe + MercadoPago (checkout, Customer Portal, webhooks verificados e idempotentes), landing pública + SEO técnico (sitemap, robots, JSON-LD) + blog, dashboard de cliente y panel administrativo con CRUD de servicios/planes, automatización de LinkedIn (adaptador API v2 + generación con OpenAI + cola de aprobación + cron protegido), seed con los 20 servicios y 5 artículos de blog, y pruebas unitarias/integración (Vitest) + E2E crítico (Playwright). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HBavCJfCnFobnutdfAp7hs --- .env.example | 61 + .eslintrc.json | 7 + .gitignore | 42 + README.md | 259 + app/(auth)/actualizar-password/page.tsx | 12 + app/(auth)/layout.tsx | 17 + app/(auth)/login/page.tsx | 17 + app/(auth)/recuperar-password/page.tsx | 12 + app/(auth)/registro/page.tsx | 17 + app/(public)/blog/[slug]/page.tsx | 159 + app/(public)/blog/page.tsx | 59 + app/(public)/contacto/page.tsx | 24 + app/(public)/layout.tsx | 14 + app/(public)/page.tsx | 287 + app/(public)/precios/page.tsx | 76 + app/(public)/servicios/[slug]/page.tsx | 220 + app/(public)/servicios/page.tsx | 87 + app/admin/auditoria/page.tsx | 53 + app/admin/blog/page.tsx | 63 + app/admin/ejecuciones/page.tsx | 72 + app/admin/facturacion/page.tsx | 81 + app/admin/layout.tsx | 35 + app/admin/leads/page.tsx | 58 + app/admin/linkedin/page.tsx | 73 + app/admin/organizaciones/page.tsx | 63 + app/admin/page.tsx | 114 + app/admin/servicios/[id]/page.tsx | 53 + app/admin/servicios/page.tsx | 72 + app/admin/solicitudes/page.tsx | 71 + app/admin/tickets/[id]/page.tsx | 54 + app/admin/tickets/page.tsx | 54 + app/admin/webhooks/page.tsx | 90 + app/api/admin/blog/[id]/route.ts | 36 + app/api/admin/blog/route.ts | 48 + app/api/admin/executions/[id]/route.ts | 48 + app/api/admin/leads/[id]/route.ts | 19 + app/api/admin/plans/[id]/route.ts | 45 + app/api/admin/service-requests/[id]/route.ts | 51 + app/api/admin/services/[id]/route.ts | 71 + app/api/admin/tickets/[id]/messages/route.ts | 38 + app/api/admin/tickets/[id]/route.ts | 21 + app/api/billing/checkout/route.ts | 168 + app/api/billing/portal/route.ts | 33 + app/api/cron/grace-period/route.ts | 18 + app/api/cron/linkedin/route.ts | 22 + app/api/integrations/route.ts | 63 + app/api/leads/route.ts | 45 + app/api/linkedin/[id]/route.ts | 61 + app/api/linkedin/generate/route.ts | 64 + app/api/linkedin/publish/route.ts | 34 + app/api/notifications/[id]/read/route.ts | 22 + app/api/onboarding/organization/route.ts | 74 + app/api/organization/members/route.ts | 79 + app/api/organization/route.ts | 54 + app/api/service-requests/route.ts | 118 + app/api/tickets/[id]/messages/route.ts | 38 + app/api/tickets/route.ts | 52 + app/api/uploads/route.ts | 47 + app/api/webhooks/mercadopago/route.ts | 122 + app/api/webhooks/stripe/route.ts | 174 + app/auth/callback/route.ts | 16 + app/dashboard/configuracion/page.tsx | 67 + app/dashboard/consumo/page.tsx | 112 + app/dashboard/facturacion/page.tsx | 138 + app/dashboard/integraciones/page.tsx | 76 + app/dashboard/layout.tsx | 38 + app/dashboard/notificaciones/page.tsx | 56 + app/dashboard/page.tsx | 166 + app/dashboard/servicios/[slug]/page.tsx | 205 + app/dashboard/servicios/page.tsx | 55 + app/dashboard/tickets/[id]/page.tsx | 47 + app/dashboard/tickets/page.tsx | 64 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 95 + app/layout.tsx | 36 + app/onboarding/page.tsx | 30 + app/robots.ts | 15 + app/sitemap.ts | 43 + components/admin/admin-ticket-reply-form.tsx | 41 + components/admin/generate-linkedin-dialog.tsx | 112 + components/admin/linkedin-queue-card.tsx | 135 + components/admin/new-blog-post-dialog.tsx | 106 + components/admin/plan-edit-row.tsx | 153 + components/admin/service-edit-form.tsx | 166 + components/admin/sidebar.tsx | 59 + components/admin/status-select.tsx | 52 + components/admin/toggle-publish-button.tsx | 29 + .../dashboard/billing-portal-button.tsx | 28 + components/dashboard/invite-member-dialog.tsx | 92 + components/dashboard/mark-read-button.tsx | 21 + .../dashboard/new-integration-dialog.tsx | 122 + components/dashboard/new-ticket-dialog.tsx | 79 + components/dashboard/org-onboarding-form.tsx | 103 + components/dashboard/org-settings-form.tsx | 79 + .../dashboard/service-checkout-panel.tsx | 129 + components/dashboard/service-request-form.tsx | 307 + components/dashboard/sidebar.tsx | 51 + components/dashboard/ticket-reply-form.tsx | 46 + components/dashboard/topbar.tsx | 74 + components/public/contact-form.tsx | 91 + components/public/forgot-password-form.tsx | 65 + components/public/login-form.tsx | 109 + components/public/mobile-nav.tsx | 45 + components/public/register-form.tsx | 138 + components/public/sheet.tsx | 49 + components/public/site-footer.tsx | 71 + components/public/site-header.tsx | 45 + components/public/update-password-form.tsx | 59 + components/ui/accordion.tsx | 52 + components/ui/alert.tsx | 42 + components/ui/avatar.tsx | 39 + components/ui/badge.tsx | 30 + components/ui/breadcrumb.tsx | 32 + components/ui/button.tsx | 48 + components/ui/card.tsx | 48 + components/ui/checkbox.tsx | 27 + components/ui/dialog.tsx | 96 + components/ui/dropdown-menu.tsx | 169 + components/ui/input.tsx | 23 + components/ui/label.tsx | 20 + components/ui/progress.tsx | 24 + components/ui/radio-group.tsx | 35 + components/ui/select.tsx | 106 + components/ui/separator.tsx | 25 + components/ui/skeleton.tsx | 7 + components/ui/switch.tsx | 28 + components/ui/table.tsx | 71 + components/ui/tabs.tsx | 54 + components/ui/textarea.tsx | 22 + components/ui/toaster.tsx | 16 + lib/auth/actions.ts | 10 + lib/auth/guards.ts | 139 + lib/auth/middleware-client.ts | 41 + lib/auth/supabase-browser.ts | 8 + lib/auth/supabase-server.ts | 50 + lib/billing/activation.ts | 141 + lib/billing/mercadopago.ts | 133 + lib/billing/stripe.ts | 76 + lib/database/prisma.ts | 16 + lib/linkedin/adapter.ts | 79 + lib/linkedin/content-generator.ts | 54 + lib/linkedin/publish.ts | 48 + lib/linkedin/scheduler.ts | 59 + lib/notifications/index.ts | 25 + lib/openai/client.ts | 14 + lib/security/audit.ts | 27 + lib/security/cron.ts | 14 + lib/security/encryption.ts | 60 + lib/security/rate-limit.ts | 48 + lib/security/sanitize.ts | 45 + lib/security/webhooks.ts | 42 + lib/seo/json-ld.ts | 135 + lib/seo/metadata.ts | 49 + lib/services/catalog.ts | 1654 +++ lib/services/plan-helpers.ts | 59 + lib/services/workflows.ts | 752 + lib/storage/index.ts | 49 + lib/usage/index.ts | 128 + lib/utils.ts | 39 + middleware.ts | 25 + next.config.mjs | 17 + package-lock.json | 11483 ++++++++++++++++ package.json | 80 + playwright.config.ts | 21 + postcss.config.mjs | 6 + prisma/blog-seed-data.ts | 233 + prisma/rls-policies.sql | 87 + prisma/schema.prisma | 875 ++ prisma/seed.ts | 345 + tailwind.config.ts | 86 + tests/e2e/critical-flow.spec.ts | 104 + .../multi-tenant-isolation.test.ts | 48 + tests/integration/webhook-idempotency.test.ts | 54 + tests/setup.ts | 3 + tests/stubs/server-only.js | 4 + tests/unit/catalog.test.ts | 46 + tests/unit/cron-protection.test.ts | 22 + tests/unit/encryption.test.ts | 35 + tests/unit/rate-limit.test.ts | 20 + tests/unit/scheduler.test.ts | 19 + tests/unit/usage.test.ts | 39 + tests/unit/utils.test.ts | 37 + tsconfig.json | 30 + types/service.ts | 105 + vercel.json | 6 + vitest.config.ts | 19 + 186 files changed, 26813 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/(auth)/actualizar-password/page.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/recuperar-password/page.tsx create mode 100644 app/(auth)/registro/page.tsx create mode 100644 app/(public)/blog/[slug]/page.tsx create mode 100644 app/(public)/blog/page.tsx create mode 100644 app/(public)/contacto/page.tsx create mode 100644 app/(public)/layout.tsx create mode 100644 app/(public)/page.tsx create mode 100644 app/(public)/precios/page.tsx create mode 100644 app/(public)/servicios/[slug]/page.tsx create mode 100644 app/(public)/servicios/page.tsx create mode 100644 app/admin/auditoria/page.tsx create mode 100644 app/admin/blog/page.tsx create mode 100644 app/admin/ejecuciones/page.tsx create mode 100644 app/admin/facturacion/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/leads/page.tsx create mode 100644 app/admin/linkedin/page.tsx create mode 100644 app/admin/organizaciones/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/servicios/[id]/page.tsx create mode 100644 app/admin/servicios/page.tsx create mode 100644 app/admin/solicitudes/page.tsx create mode 100644 app/admin/tickets/[id]/page.tsx create mode 100644 app/admin/tickets/page.tsx create mode 100644 app/admin/webhooks/page.tsx create mode 100644 app/api/admin/blog/[id]/route.ts create mode 100644 app/api/admin/blog/route.ts create mode 100644 app/api/admin/executions/[id]/route.ts create mode 100644 app/api/admin/leads/[id]/route.ts create mode 100644 app/api/admin/plans/[id]/route.ts create mode 100644 app/api/admin/service-requests/[id]/route.ts create mode 100644 app/api/admin/services/[id]/route.ts create mode 100644 app/api/admin/tickets/[id]/messages/route.ts create mode 100644 app/api/admin/tickets/[id]/route.ts create mode 100644 app/api/billing/checkout/route.ts create mode 100644 app/api/billing/portal/route.ts create mode 100644 app/api/cron/grace-period/route.ts create mode 100644 app/api/cron/linkedin/route.ts create mode 100644 app/api/integrations/route.ts create mode 100644 app/api/leads/route.ts create mode 100644 app/api/linkedin/[id]/route.ts create mode 100644 app/api/linkedin/generate/route.ts create mode 100644 app/api/linkedin/publish/route.ts create mode 100644 app/api/notifications/[id]/read/route.ts create mode 100644 app/api/onboarding/organization/route.ts create mode 100644 app/api/organization/members/route.ts create mode 100644 app/api/organization/route.ts create mode 100644 app/api/service-requests/route.ts create mode 100644 app/api/tickets/[id]/messages/route.ts create mode 100644 app/api/tickets/route.ts create mode 100644 app/api/uploads/route.ts create mode 100644 app/api/webhooks/mercadopago/route.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 app/auth/callback/route.ts create mode 100644 app/dashboard/configuracion/page.tsx create mode 100644 app/dashboard/consumo/page.tsx create mode 100644 app/dashboard/facturacion/page.tsx create mode 100644 app/dashboard/integraciones/page.tsx create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/notificaciones/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/servicios/[slug]/page.tsx create mode 100644 app/dashboard/servicios/page.tsx create mode 100644 app/dashboard/tickets/[id]/page.tsx create mode 100644 app/dashboard/tickets/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 components/admin/admin-ticket-reply-form.tsx create mode 100644 components/admin/generate-linkedin-dialog.tsx create mode 100644 components/admin/linkedin-queue-card.tsx create mode 100644 components/admin/new-blog-post-dialog.tsx create mode 100644 components/admin/plan-edit-row.tsx create mode 100644 components/admin/service-edit-form.tsx create mode 100644 components/admin/sidebar.tsx create mode 100644 components/admin/status-select.tsx create mode 100644 components/admin/toggle-publish-button.tsx create mode 100644 components/dashboard/billing-portal-button.tsx create mode 100644 components/dashboard/invite-member-dialog.tsx create mode 100644 components/dashboard/mark-read-button.tsx create mode 100644 components/dashboard/new-integration-dialog.tsx create mode 100644 components/dashboard/new-ticket-dialog.tsx create mode 100644 components/dashboard/org-onboarding-form.tsx create mode 100644 components/dashboard/org-settings-form.tsx create mode 100644 components/dashboard/service-checkout-panel.tsx create mode 100644 components/dashboard/service-request-form.tsx create mode 100644 components/dashboard/sidebar.tsx create mode 100644 components/dashboard/ticket-reply-form.tsx create mode 100644 components/dashboard/topbar.tsx create mode 100644 components/public/contact-form.tsx create mode 100644 components/public/forgot-password-form.tsx create mode 100644 components/public/login-form.tsx create mode 100644 components/public/mobile-nav.tsx create mode 100644 components/public/register-form.tsx create mode 100644 components/public/sheet.tsx create mode 100644 components/public/site-footer.tsx create mode 100644 components/public/site-header.tsx create mode 100644 components/public/update-password-form.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 lib/auth/actions.ts create mode 100644 lib/auth/guards.ts create mode 100644 lib/auth/middleware-client.ts create mode 100644 lib/auth/supabase-browser.ts create mode 100644 lib/auth/supabase-server.ts create mode 100644 lib/billing/activation.ts create mode 100644 lib/billing/mercadopago.ts create mode 100644 lib/billing/stripe.ts create mode 100644 lib/database/prisma.ts create mode 100644 lib/linkedin/adapter.ts create mode 100644 lib/linkedin/content-generator.ts create mode 100644 lib/linkedin/publish.ts create mode 100644 lib/linkedin/scheduler.ts create mode 100644 lib/notifications/index.ts create mode 100644 lib/openai/client.ts create mode 100644 lib/security/audit.ts create mode 100644 lib/security/cron.ts create mode 100644 lib/security/encryption.ts create mode 100644 lib/security/rate-limit.ts create mode 100644 lib/security/sanitize.ts create mode 100644 lib/security/webhooks.ts create mode 100644 lib/seo/json-ld.ts create mode 100644 lib/seo/metadata.ts create mode 100644 lib/services/catalog.ts create mode 100644 lib/services/plan-helpers.ts create mode 100644 lib/services/workflows.ts create mode 100644 lib/storage/index.ts create mode 100644 lib/usage/index.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 postcss.config.mjs create mode 100644 prisma/blog-seed-data.ts create mode 100644 prisma/rls-policies.sql create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 tailwind.config.ts create mode 100644 tests/e2e/critical-flow.spec.ts create mode 100644 tests/integration/multi-tenant-isolation.test.ts create mode 100644 tests/integration/webhook-idempotency.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/stubs/server-only.js create mode 100644 tests/unit/catalog.test.ts create mode 100644 tests/unit/cron-protection.test.ts create mode 100644 tests/unit/encryption.test.ts create mode 100644 tests/unit/rate-limit.test.ts create mode 100644 tests/unit/scheduler.test.ts create mode 100644 tests/unit/usage.test.ts create mode 100644 tests/unit/utils.test.ts create mode 100644 tsconfig.json create mode 100644 types/service.ts create mode 100644 vercel.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e98e6b --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# --------------------------------------------------------------------------- +# Supabase (Auth + Storage) +# --------------------------------------------------------------------------- +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# --------------------------------------------------------------------------- +# Base de datos (Supabase Postgres vía Prisma) +# --------------------------------------------------------------------------- +DATABASE_URL= +DIRECT_URL= + +# --------------------------------------------------------------------------- +# Stripe +# --------------------------------------------------------------------------- +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= + +# --------------------------------------------------------------------------- +# MercadoPago +# --------------------------------------------------------------------------- +MERCADOPAGO_ACCESS_TOKEN= +MERCADOPAGO_WEBHOOK_SECRET= +NEXT_PUBLIC_MERCADOPAGO_PUBLIC_KEY= + +# --------------------------------------------------------------------------- +# OpenAI (generación de contenido LinkedIn, RAG, documentación asistida, etc.) +# --------------------------------------------------------------------------- +OPENAI_API_KEY= + +# --------------------------------------------------------------------------- +# LinkedIn API v2 +# --------------------------------------------------------------------------- +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +LINKEDIN_ACCESS_TOKEN= +LINKEDIN_ORGANIZATION_ID= + +# --------------------------------------------------------------------------- +# Seguridad +# --------------------------------------------------------------------------- +# Clave de 32 bytes en base64 para cifrar credenciales de integraciones (AES-256-GCM). +# Generar con: openssl rand -base64 32 +ENCRYPTION_KEY= +# Secreto compartido para proteger los endpoints /api/cron/*. +CRON_SECRET= + +# --------------------------------------------------------------------------- +# Seed (opcional, valores por defecto en prisma/seed.ts) +# --------------------------------------------------------------------------- +SEED_ADMIN_EMAIL=admin@nexusops.dev +SEED_ADMIN_PASSWORD=AdminDev123! +SEED_DEMO_EMAIL=demo@nexusops.dev +SEED_DEMO_PASSWORD=DemoClient123! diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3ba9f6e --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/no-explicit-any": "warn" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..9311f56 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# NexusOps — Plataforma SaaS B2B multiservicio de tecnología e IA + +Plataforma multi-tenant que comercializa, gestiona y entrega 20 servicios tecnológicos y de inteligencia artificial +para empresas (automatización, CTO fraccional, chatbots RAG, cumplimiento de datos, formación, observabilidad de +LLM, etc.), cada uno con su propia página pública, planes, flujo de contratación, panel operativo y facturación. + +## Stack + +- **Frontend/Backend**: Next.js 14 (App Router), TypeScript estricto, Tailwind CSS, shadcn/ui-style components +- **Base de datos**: PostgreSQL vía Supabase, Prisma ORM +- **Auth**: Supabase Auth (email/password, Google OAuth, recuperación de contraseña) +- **Storage**: Supabase Storage (archivos con URLs firmadas) +- **Pagos**: Stripe + MercadoPago +- **IA**: OpenAI API (generación de contenido, RAG, documentación) +- **Automatización**: LinkedIn API v2 vía adaptador propio + Vercel Cron +- **Tests**: Vitest (unit/integration) + Playwright (E2E) + +## 1. Requisitos + +- Node.js 20+ +- npm 10+ +- Una cuenta de Supabase (proyecto Postgres + Auth + Storage) +- Cuenta de Stripe (modo test) y, opcionalmente, MercadoPago +- Stripe CLI (para probar webhooks en local) +- Cuenta de OpenAI (opcional, requerida para RAG/documentación asistida/LinkedIn) +- App de LinkedIn Developer (opcional, requerida solo para publicar de verdad) + +## 2. Instalación + +```bash +npm install +cp .env.example .env +``` + +Completa `.env` con tus credenciales (ver secciones siguientes). La aplicación **arranca sin errores** aunque +falten las claves de terceros (Stripe/MercadoPago/OpenAI/LinkedIn); esas integraciones fallan con un error claro +solo al intentar usarlas, nunca de forma silenciosa ni simulada. + +## 3. Configuración de Supabase + +1. Crea un proyecto en [supabase.com](https://supabase.com). +2. En **Project Settings → API**, copia: + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `SUPABASE_SERVICE_ROLE_KEY` (¡nunca exponer al cliente!) +3. En **Project Settings → Database**, copia la cadena de conexión (modo *Transaction* para `DATABASE_URL` con + pooler, y modo *Session*/directa para `DIRECT_URL`, usada por las migraciones de Prisma). +4. En **Authentication → Providers**, habilita **Email** y **Google** (ver sección 6). +5. En **Authentication → URL Configuration**, agrega como Redirect URL: + `http://localhost:3000/auth/callback` (y el equivalente de producción). +6. En **Storage**, crea un bucket llamado `service-files` (privado). El backend sube y lee archivos usando la + Service Role Key con URLs firmadas de corta duración — nunca lo expongas como bucket público. +7. (Opcional, defensa en profundidad) Aplica las políticas RLS incluidas: + ```bash + psql "$DATABASE_URL" -f prisma/rls-policies.sql + ``` + La autorización real ocurre siempre en el servidor (`lib/auth/guards.ts`); RLS es una capa adicional. + +## 4. Configuración de Prisma y migraciones + +```bash +npx prisma generate +npx prisma migrate dev --name init +``` + +Esto crea todas las tablas del `prisma/schema.prisma` (Organization, User, Membership, Service, Plan, Subscription, +Usage, ServiceRequest, ServiceExecution, Deliverable, UploadedFile, SupportTicket, TicketMessage, Invoice, Payment, +OrganizationIntegration, Notification, AuditLog, WebhookEvent, CreditLedger, OnboardingProgress, +ServiceConfiguration, ServiceFormDefinition, ServiceFormSubmission, Lead, BlogPost, LinkedInQueue). + +Para producción: + +```bash +npx prisma migrate deploy +``` + +## 5. Seed (datos iniciales) + +```bash +npm run db:seed +``` + +Carga: + +- Los 20 servicios del catálogo (`lib/services/catalog.ts`) con sus 3 planes cada uno (Starter/Pro/Enterprise) +- El `ServiceFormDefinition` de cada servicio (a partir de `lib/services/workflows.ts`) +- 5 artículos de blog +- Un usuario **administrador** (`SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`, por defecto + `admin@nexusops.dev` / `AdminDev123!`) +- Un usuario y organización de **demostración** (`SEED_DEMO_EMAIL` / `SEED_DEMO_PASSWORD`) con una suscripción, + consumo y créditos de ejemplo + +El seed se niega a ejecutarse con `NODE_ENV=production` a menos que definas `ALLOW_PROD_SEED=true` — **no está +pensado para cargar datos de demostración en producción**. + +## 6. Configuración de Google OAuth + +1. En [Google Cloud Console](https://console.cloud.google.com/), crea credenciales OAuth 2.0 (tipo *Web application*). +2. Authorized redirect URI: la URL de callback de tu proyecto Supabase + (`https://.supabase.co/auth/v1/callback`). +3. Copia Client ID/Secret a **Supabase → Authentication → Providers → Google**. + +## 7. Configuración de Stripe + +1. Copia `STRIPE_SECRET_KEY` y `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` desde el dashboard de Stripe (modo test). +2. En cada `Plan` (desde `/admin/servicios/[id]`), completa `stripePriceIdMonthly` / `stripePriceIdAnnual` con los + Price IDs reales creados en Stripe (Product + Price). Sin esto, el checkout de ese plan devuelve un error claro + en vez de simular un pago. +3. Escucha webhooks en local con Stripe CLI: + ```bash + stripe listen --forward-to localhost:3000/api/webhooks/stripe + ``` + Copia el `whsec_...` que imprime a `STRIPE_WEBHOOK_SECRET`. +4. Eventos procesados: `checkout.session.completed`, `customer.subscription.created`, + `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, + `charge.refunded`. Todos son idempotentes vía la tabla `WebhookEvent`. +5. El Customer Portal de Stripe se usa para cambios de plan/cancelación desde `/dashboard/facturacion`. + +## 8. Configuración de MercadoPago + +1. Copia `MERCADOPAGO_ACCESS_TOKEN` (modo test) y `NEXT_PUBLIC_MERCADOPAGO_PUBLIC_KEY`. +2. Configura la URL de webhooks en el panel de MercadoPago (**Tus integraciones → Webhooks**) apuntando a + `https://tu-dominio/api/webhooks/mercadopago`, y copia el secreto de firma a `MERCADOPAGO_WEBHOOK_SECRET`. +3. Pagos únicos usan **Preference** (Checkout Pro); suscripciones usan **PreApproval**. Ambos flujos están + implementados en `lib/billing/mercadopago.ts` con verificación de firma `x-signature` en el webhook. + +## 9. Configuración de OpenAI + +Define `OPENAI_API_KEY`. Se usa para: + +- Generar las 3 variantes de contenido de LinkedIn (`lib/linkedin/content-generator.ts`) +- (Puntos de extensión ya cableados en el dominio) Chatbot RAG, documentación técnica asistida, etc. + +Sin esta variable, los endpoints que la requieren devuelven un error explícito — nunca generan contenido simulado. + +## 10. Configuración de LinkedIn + +1. Crea una app en [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps) con el producto + *Share on LinkedIn* / *Community Management API* habilitado sobre tu Company Page. +2. Completa `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`, `LINKEDIN_ORGANIZATION_ID` (URN numérico de tu página). +3. Autoriza la app vía OAuth 2.0 (flujo estándar de LinkedIn) para obtener `LINKEDIN_ACCESS_TOKEN`. +4. Sin estas variables, `/admin/linkedin` funciona igual para generar y aprobar contenido, pero la publicación real + falla con `LinkedInNotConfiguredError` — nunca se marca como publicado sin que la API realmente responda 200. +5. El cron (`/api/cron/linkedin`, lunes/miércoles/viernes 09:00 hora de Chile — ver `vercel.json`) **solo publica + contenido en estado `SCHEDULED`**, es decir, ya aprobado por un administrador. + +## 11. Variables de entorno + +Ver `.env.example` para la lista completa. Resumen: + +| Variable | Uso | +|---|---| +| `NEXT_PUBLIC_APP_URL` | URL pública de la app (SEO, redirects de checkout) | +| `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Cliente Supabase (browser + server) | +| `SUPABASE_SERVICE_ROLE_KEY` | Operaciones de confianza server-side (nunca al cliente) | +| `DATABASE_URL` / `DIRECT_URL` | Conexión Prisma (pooler / directa) | +| `STRIPE_*` | Checkout, portal y webhooks de Stripe | +| `MERCADOPAGO_*` | Preferencias, suscripciones y webhooks de MercadoPago | +| `OPENAI_API_KEY` | Generación de contenido con IA | +| `LINKEDIN_*` | Adaptador de publicación en LinkedIn | +| `ENCRYPTION_KEY` | Cifra credenciales de integraciones (AES-256-GCM). Generar con `openssl rand -base64 32` | +| `CRON_SECRET` | Protege `/api/cron/*` | + +## 12. Ejecución local + +```bash +npm run dev +``` + +Abre [http://localhost:3000](http://localhost:3000). Rutas principales: + +- `/` — landing pública +- `/servicios`, `/servicios/[slug]`, `/precios`, `/blog` — catálogo y contenido público +- `/login`, `/registro` — autenticación +- `/onboarding` — creación de organización tras el registro +- `/dashboard` — panel del cliente (requiere sesión + organización) +- `/admin` — panel administrativo (requiere rol `ADMIN`) + +## 13. Pruebas + +```bash +npm run test # Unit + integration (Vitest) — no requieren base de datos real +npm run test:watch # Modo watch +npm run test:e2e # Playwright — requiere `npm run dev` corriendo (o webServer automático) +``` + +- **Unit**: `tests/unit/*` — utilidades, catálogo de 20 servicios, límites de plan, cifrado, rate limiting, + scheduler de LinkedIn, protección del cron. +- **Integration**: `tests/integration/*` — aislamiento multi-tenant (`assertOrganizationAccess`) e idempotencia de + webhooks, con Prisma mockeado (no requieren Postgres real para correr en CI). +- **E2E crítico**: `tests/e2e/critical-flow.spec.ts` cubre + *Registro → organización → servicio → plan → checkout de prueba → webhook → dashboard → solicitud → resultado*. + Los pasos que dependen de credenciales reales de Supabase/Stripe se **omiten explícitamente** (no se simulan) si + no defines `E2E_SUPABASE_TEST_EMAIL`, `E2E_SUPABASE_TEST_PASSWORD` y `STRIPE_WEBHOOK_SECRET` contra un proyecto de + prueba. + +## 14. Despliegue en Vercel + +1. Importa el repositorio en Vercel. +2. Configura todas las variables de `.env.example` en **Project Settings → Environment Variables**. +3. Build command: `npx prisma generate && next build` (o agrega `"postinstall": "prisma generate"` a tus scripts). +4. Ejecuta las migraciones contra la base de producción antes del primer despliegue: + ```bash + npx prisma migrate deploy + npm run db:seed # opcional, solo si quieres el catálogo pre-cargado + ``` +5. Los **Cron Jobs** están declarados en `vercel.json` (`/api/cron/linkedin` y `/api/cron/grace-period`) y se + activan automáticamente al desplegar en Vercel. Verifica que `CRON_SECRET` esté configurado — Vercel lo envía + automáticamente como `Authorization: Bearer $CRON_SECRET` en cada invocación. + +## 15. Seguridad + +- Autorización centralizada en `lib/auth/guards.ts` (`requireUser`, `requireOrganization`, `requireAdmin`, + `assertOrganizationAccess`) — el `organizationId` **nunca** se toma de un valor enviado por el navegador sin + validar contra la sesión y las `Membership` reales. +- Webhooks de Stripe y MercadoPago verifican firma y son idempotentes vía la tabla `WebhookEvent`. +- Credenciales de integraciones (`OrganizationIntegration.encryptedCredentials`) se cifran con AES-256-GCM antes de + persistirse (`lib/security/encryption.ts`). +- Validación de entrada con Zod en todos los endpoints que reciben datos externos. +- Sanitización de texto libre y validación de tipo/tamaño de archivos subidos (`lib/security/sanitize.ts`). +- Rate limiting básico en endpoints públicos sensibles (`lib/security/rate-limit.ts`) — ver limitaciones. +- `AuditLog` registra operaciones administrativas y sensibles. +- Archivos de clientes se sirven exclusivamente vía URLs firmadas de corta duración (nunca URLs públicas). + +## 16. Limitaciones conocidas + +- El **rate limiting** es en memoria por instancia; en un despliegue serverless multi-instancia (Vercel) no es un + límite global estricto. Para eso, sustituir `lib/security/rate-limit.ts` por Upstash Redis / Vercel KV + manteniendo la misma interfaz (`checkRateLimit`). +- Las integraciones que requieren credenciales reales de terceros (WhatsApp Business API, LinkedIn, OpenAI) están + completamente implementadas (interfaz, persistencia, validaciones, adaptador) pero **no pueden ejecutarse de + extremo a extremo sin esas credenciales** — fallan explícitamente en vez de simular resultados. +- MercadoPago Suscripciones (`PreApproval`) no acepta `notification_url` por request; el webhook de suscripciones + se configura a nivel de aplicación en el panel de MercadoPago. +- El entorno en que se generó este proyecto no tiene una base de datos Postgres real conectada; `npm run build` + compila y tipa correctamente, pero las páginas que consultan Prisma están marcadas `force-dynamic` para no + requerir conexión durante el build — se conectan en tiempo de request contra el `DATABASE_URL` configurado. + +## 17. Solución de problemas + +| Problema | Causa probable | Solución | +|---|---|---| +| `PrismaClientInitializationError` | `DATABASE_URL`/`DIRECT_URL` no configuradas o Postgres inaccesible | Revisa las credenciales de Supabase y que el proyecto esté activo | +| Webhook de Stripe responde 400 "Firma inválida" | `STRIPE_WEBHOOK_SECRET` no coincide con el de `stripe listen` | Vuelve a copiar el `whsec_...` que imprime la Stripe CLI | +| El login con Google redirige a un error | Redirect URL no registrada en Supabase/Google Cloud | Agrega `https://tu-dominio/auth/callback` en ambos paneles | +| `LinkedInNotConfiguredError` al publicar | Faltan variables `LINKEDIN_*` | Completa la autorización OAuth de LinkedIn (sección 10) | +| El seed falla creando usuarios | `SUPABASE_SERVICE_ROLE_KEY` no configurada | El seed continúa igual creando usuarios de dominio "locales", pero no podrás iniciar sesión con ellos hasta configurar Supabase | + +## 18. Comandos + +```bash +npm install +npx prisma generate +npx prisma migrate dev +npm run db:seed +npm run dev +npm run test +npm run build +``` diff --git a/app/(auth)/actualizar-password/page.tsx b/app/(auth)/actualizar-password/page.tsx new file mode 100644 index 0000000..e31cb4d --- /dev/null +++ b/app/(auth)/actualizar-password/page.tsx @@ -0,0 +1,12 @@ +import { UpdatePasswordForm } from "@/components/public/update-password-form"; +import { buildMetadata } from "@/lib/seo/metadata"; + +export const metadata = buildMetadata({ + title: "Actualizar contraseña", + description: "Define una nueva contraseña para tu cuenta.", + path: "/actualizar-password", +}); + +export default function UpdatePasswordPage() { + return ; +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..77fc5b7 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { SITE_NAME } from "@/lib/seo/metadata"; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ + {SITE_NAME} + +
+
+
{children}
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ea1d268 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { LoginForm } from "@/components/public/login-form"; +import { buildMetadata } from "@/lib/seo/metadata"; + +export const metadata = buildMetadata({ + title: "Iniciar sesión", + description: "Accede a tu panel de organización.", + path: "/login", +}); + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/app/(auth)/recuperar-password/page.tsx b/app/(auth)/recuperar-password/page.tsx new file mode 100644 index 0000000..3115ac9 --- /dev/null +++ b/app/(auth)/recuperar-password/page.tsx @@ -0,0 +1,12 @@ +import { ForgotPasswordForm } from "@/components/public/forgot-password-form"; +import { buildMetadata } from "@/lib/seo/metadata"; + +export const metadata = buildMetadata({ + title: "Recuperar contraseña", + description: "Restablece la contraseña de tu cuenta.", + path: "/recuperar-password", +}); + +export default function ForgotPasswordPage() { + return ; +} diff --git a/app/(auth)/registro/page.tsx b/app/(auth)/registro/page.tsx new file mode 100644 index 0000000..9abb697 --- /dev/null +++ b/app/(auth)/registro/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { RegisterForm } from "@/components/public/register-form"; +import { buildMetadata } from "@/lib/seo/metadata"; + +export const metadata = buildMetadata({ + title: "Crear cuenta", + description: "Crea tu cuenta y comienza a contratar servicios tecnológicos y de IA para tu empresa.", + path: "/registro", +}); + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/app/(public)/blog/[slug]/page.tsx b/app/(public)/blog/[slug]/page.tsx new file mode 100644 index 0000000..07949aa --- /dev/null +++ b/app/(public)/blog/[slug]/page.tsx @@ -0,0 +1,159 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/database/prisma"; +import { Badge } from "@/components/ui/badge"; +import { Breadcrumb } from "@/components/ui/breadcrumb"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { formatDate } from "@/lib/utils"; +import { buildMetadata } from "@/lib/seo/metadata"; +import { articleJsonLd, breadcrumbJsonLd, faqPageJsonLd } from "@/lib/seo/json-ld"; + +export const dynamic = "force-dynamic"; + +async function getPost(slug: string) { + return prisma.blogPost.findUnique({ where: { slug } }); +} + +export async function generateMetadata({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug); + if (!post) return buildMetadata({ title: "Artículo no encontrado", description: "", path: "/blog" }); + + return buildMetadata({ + title: post.seoTitle ?? post.title, + description: post.seoDescription ?? post.excerpt, + path: `/blog/${post.slug}`, + keywords: (post.seoKeywords as string[] | null) ?? undefined, + type: "article", + }); +} + +function renderContent(content: string) { + const blocks = content.split("\n\n").filter(Boolean); + return blocks.map((block, i) => { + if (block.startsWith("## ")) { + return ( +

+ {block.replace("## ", "")} +

+ ); + } + if (block.startsWith("- ")) { + const items = block.split("\n").map((line) => line.replace(/^- /, "")); + return ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+ ); + } + return ( +

+ {block} +

+ ); + }); +} + +export default async function BlogPostPage({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug); + if (!post || !post.published) notFound(); + + const toc = (post.tableOfContents as { title: string; anchor: string }[] | null) ?? []; + const faq = (post.faq as { question: string; answer: string }[] | null) ?? []; + + return ( +
+