# Auth + Multi-tenant + Admin de Planes — Diseño

**Fecha:** 2026-05-30
**Estado:** Aprobado (pendiente de plan de implementación)
**Superficie:** `mvp/b2c` (Next.js 16, Tailwind v4, Drizzle, postgres.js, Vitest)

> **Nota de alcance:** Esta funcionalidad está marcada como **F1.5 / post-hackathon** en `specs.md`
> (multi-tenant real, líneas 39/259/265; pasarela de pago, línea 279). Se adelanta por decisión
> explícita del equipo (mayo 2026) tras abrir la conversación que CLAUDE.md exige para tocar F1.5.
> La demo del 11-jun **no depende** de este módulo; se construye desacoplado del pipeline de voz/render/WhatsApp.

---

## Objetivo

Convertir el panel single-tenant hardcodeado (`TENANT_SLUG` = "Reformas Ejemplo") en un SaaS con login
real, aislamiento de datos por reformista, y un área de administración que gestiona usuarios y asigna
planes. **Stripe queda como stub: sin cobro real** (no hay cuenta activa).

## Decisiones clave (confirmadas en brainstorming)

1. **Multi-tenant real:** cada reformista ve SOLO sus leads/precios/catálogo; el admin ve todos.
2. **Alta de cuentas:** self-signup trial público (RF-A-05) **+** creación/gestión por admin.
3. **Auth propio:** email+password con sesión server-side (sin librería de auth externa).
4. **Planes = etiqueta + estado, sin enforcement:** se asignan y se muestran; no bloquean funciones aún.
5. **La suscripción vive en el `tenant`** (= la cuenta), no en el `user`. Un tenant puede tener varios users.
6. **"Reformas Ejemplo" se reconvierte** en una cuenta logueable real para preservar la demo actual.

---

## Arquitectura de datos

### Tabla nueva: `users`

| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `email` | text unique notNull | |
| `passwordHash` | text notNull | bcryptjs |
| `nombre` | text | |
| `role` | enum `user_role` (`reformista`\|`admin`) notNull default `reformista` | |
| `tenantId` | uuid FK→tenants onDelete cascade, **nullable** | null para admin de plataforma; set para reformista |
| `status` | enum `user_status` (`activo`\|`deshabilitado`) notNull default `activo` | |
| `createdAt` / `updatedAt` | timestamptz notNull defaultNow | |

- Relación **muchos users → un tenant** (soporta "usuarios ilimitados" del plan Business sin migración futura).
- Índice único en `email`. Índice en `tenantId`.

### Tabla nueva: `sessions`

| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `userId` | uuid FK→users onDelete cascade | |
| `tokenHash` | text notNull | hash del token de sesión (el token en claro solo vive en la cookie) |
| `expiresAt` | timestamptz notNull | |
| `createdAt` | timestamptz notNull defaultNow | |

- Índice en `userId`. Índice/único en `tokenHash`.
- Caducidad: 30 días deslizante (se renueva en cada request válido). Sesiones expiradas se ignoran y se limpian perezosamente.

### Tabla nueva: `plans`

| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `slug` | text unique (`starter`\|`pro`\|`business`) | |
| `nombre` | text notNull | "Starter" / "Pro" / "Business" |
| `precioMensual` | integer notNull | céntimos: 2900 / 7900 / 19900 |
| `leadsIncluidos` | integer notNull | 5 / 15 / 50 |
| `features` | jsonb `$type<string[]>` notNull default `[]` | bullets del copy |
| `activo` | boolean notNull default true | |

Valores sembrados desde `copy/COPY-GUIDE.md` (§Pricing landing B2B):

- **Starter** — 29 €/mes · 5 leads/mes · 3 €/lead extra · hasta 100 contactos · branding básico.
- **Pro** — 79 €/mes · 15 leads/mes · 2,50 €/lead extra · white-label · sub-flujo licencia urbanística · integraciones Holded/Stel · soporte prioritario.
- **Business** — 199 €/mes · 50 leads/mes · 2 €/lead extra · usuarios ilimitados · API · multi-zona · custom price book · dashboard analytics.

### Columnas nuevas en `tenants`

| Campo | Tipo | Notas |
|---|---|---|
| `planId` | uuid FK→plans, nullable | plan asignado |
| `subscriptionStatus` | enum `subscription_status` (`trial`\|`activo`\|`cancelado`\|`vencido`) notNull default `trial` | |
| `trialEndsAt` | timestamptz nullable | now + 14 días al crear vía signup |
| `stripeCustomerId` | text nullable | reservada para Stripe; sin uso real ahora |

---

## Autenticación

- **Hashing:** `bcryptjs` (JS puro, evita builds nativos en el Docker Node-22-slim). Dep nueva justificada en el commit.
- **Sesión server-side:** token aleatorio (`crypto.randomBytes`) → se guarda **hasheado** en `sessions.tokenHash`; el token en claro va en cookie `session` (`httpOnly`, `secure`, `sameSite: lax`, `path: /`).
- **Helpers** en `src/lib/auth/`:
  - `hashPassword(plain)` / `verifyPassword(plain, hash)`
  - `createSession(userId)` → setea cookie · `destroySession()` → logout
  - `getCurrentUser()` → lee cookie, valida sesión, devuelve user o null
  - `getCurrentTenantId()` → deriva del user logueado (lanza si no hay)
  - `requireUser()` / `requireAdmin()` → para Server Components / actions; redirigen si falta permiso
- **`getCurrentTenantId()` reemplaza** la resolución por `TENANT_SLUG` en `src/app/panel/actions.ts` y `src/db/pricing-queries.ts`. Las queries ya filtran por `tenantId`; solo cambia la **fuente** del id.

---

## Superficies / rutas

| Ruta | Acceso | Función |
|---|---|---|
| `/signup` | público | RF-A-05: form email+nombre+empresa+provincia+opt-in → crea `tenant` + `user` (owner, role reformista) + `subscriptionStatus=trial`, `trialEndsAt=now+14d`. Redirige a `/panel`. Cableado desde el CTA "Empieza gratis 14 días" de la landing B2B |
| `/login` | público | email+password → crea sesión → `/panel` |
| `/logout` | autenticado | destruye sesión → `/login` |
| `/panel/*` | reformista | guard en `panel/layout.tsx`: sin sesión → redirect `/login`. Muestra SOLO datos de su tenant. Badge de plan/estado en la cabecera |
| `/admin` | admin | `requireAdmin`. Lista tenants + usuarios; ver planes |
| `/admin/usuarios` | admin | crear reformista (crea tenant+user owner), habilitar/deshabilitar usuarios |
| `/admin/planes` | admin | ver catálogo de planes; **asignar plan + `subscriptionStatus`** a un tenant |

> El área admin reutiliza el estilo del panel existente (Tailwind v4, sin shadcn). Copy nuevo que no exista
> se añade primero a `copy/COPY-GUIDE.md` antes de usarse (regla CLAUDE.md).

---

## Control de acceso

- **Reformista:** todas las lecturas/mutaciones se filtran por su `tenantId` (vía `getCurrentTenantId()`). No puede acceder a leads, precios ni catálogo de otro tenant. Acceso a `/admin/*` → 403/redirect.
- **Admin** (`tenantId = null`, `role = admin`): ve y gestiona todos los tenants y usuarios; asigna planes y cambia estado de suscripción. No tiene un "panel de leads" propio (no posee tenant); opcionalmente puede impersonar/ver un tenant en modo lectura — **fuera de alcance ahora**.

---

## Planes (sin enforcement)

- El admin asigna `planId` y `subscriptionStatus` a un tenant desde `/admin/planes`.
- `/panel` muestra un badge: p. ej. *"Plan Pro · trial, 9 días restantes"* (calculado desde `trialEndsAt`).
- **No se bloquea ninguna función por plan ni por caducidad de trial** en esta fase. `trialEndsAt` se calcula y se muestra pero no corta el acceso. El gating efectivo se decidirá más adelante.
- **Stripe stub:** `src/lib/billing/stripe.ts` define la interfaz (`createCustomer`, `createCheckoutSession`, etc.) pero **no hace llamadas reales**; lanza o devuelve no-op documentado. Botón "Gestionar pago" en el panel deshabilitado con copy "Próximamente". `tenants.stripeCustomerId` queda reservado.

---

## Migración y seed

- **Migración Drizzle nueva:** crea enums (`user_role`, `user_status`, `subscription_status`), tablas (`users`, `sessions`, `plans`) y columnas nuevas en `tenants`. Solo añade; no rompe datos existentes.
- **Seed** (gateado por `SEED_FORCE`, comportamiento TRUNCATE+reinsert ya existente):
  - Siembra los 3 `plans` (Starter/Pro/Business).
  - Crea **1 admin** (email+password de demo, documentado).
  - Reconvierte "Reformas Ejemplo" en tenant con **1 user owner logueable** (email+password de demo), `planId` = Pro, `subscriptionStatus` = trial, `trialEndsAt` = now+14d — así toda la demo de leads/presupuesto actual sigue accesible tras login.

---

## Testing (Vitest)

- **Auth helpers:** `hashPassword`/`verifyPassword` (round-trip y rechazo), `createSession`/validación, caducidad de sesión.
- **Control de acceso:** reformista del tenant A no obtiene leads del tenant B; admin sí ve ambos; usuario no-admin es rechazado en acciones admin.
- **Signup:** crea tenant + user owner + trial con `trialEndsAt` correcto; email duplicado rechazado.
- **Asignación de plan:** admin asigna `planId`/`subscriptionStatus`; se refleja en el tenant.
- Cobertura objetivo de la lógica de auth/acceso alineada con RNF-MAINT-01 (≥70% en módulos nuevos críticos).

---

## Fases de implementación

1. **Auth base** — enums + tablas `users`/`sessions`; helpers `src/lib/auth/`; `/login` + `/logout`; guard en `panel/layout`; migrar `getTenantId` a sesión; seed admin + owner de "Reformas Ejemplo".
2. **Signup trial** — `/signup` público + cableado desde la landing B2B (CTA "Empieza gratis 14 días"); creación tenant+owner+trial; onboarding mínimo (redirect a `/panel`).
3. **Área admin** — `/admin`, `/admin/usuarios`, `/admin/planes`; seed de los 3 planes; asignación de plan/estado.
4. **Stripe stub + display de plan** — `src/lib/billing/stripe.ts` (interfaz no-op); badge de plan/estado en `/panel`; botón "Gestionar pago" deshabilitado.

---

## Fuera de alcance (explícito)

- Cobro real / checkout Stripe / webhooks de facturación.
- Enforcement de límites por plan (leads/mes, renders) y bloqueo por trial vencido.
- OAuth / login social, recuperación de contraseña por email, verificación de email.
- Impersonación de tenant por el admin (vista de leads ajena).
- Gestión multi-usuario dentro de un mismo tenant desde el panel del reformista (el schema lo soporta, la UI no se construye ahora).
