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 ( +
+