# Auth + Multi-tenant + Admin de Planes — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Añadir login propio del reformista, aislamiento multi-tenant real y un área admin que gestiona usuarios y asigna planes (Stripe en stub, sin cobro) a la app `mvp/b2c`. **Architecture:** Sesión server-side (tabla `sessions` + cookie httpOnly), contraseñas con `bcryptjs`. La lógica de autorización se extrae a funciones puras (`src/lib/auth/authz.ts`, `tokens.ts`, `password.ts`) testeables sin DB; los wrappers con DB/cookies (`session.ts`, `current-user.ts`) quedan fuera de cobertura. La resolución de tenant pasa de un `TENANT_SLUG` hardcodeado a `getCurrentTenantId()` derivado del usuario logueado, sin tocar los filtros `tenantId` ya existentes en las queries. **Tech Stack:** Next.js 16 (App Router, Server Actions), Drizzle ORM + postgres.js, Postgres, bcryptjs, zod v4, Vitest. **Spec:** `docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md` --- ## Convenciones del repo (leer antes de empezar) - `mvp/b2c` usa **npm** (hay `package-lock.json`), no pnpm. Todos los comandos corren desde `mvp/b2c/`. - Imports con alias `@/` → `src/`. Tests en `tests/` espejando `src/`. - Dinero en **céntimos** (enteros). TypeScript strict, sin `any`. - Next.js 16: `cookies()` de `next/headers` es **async** (`const store = await cookies()`). - Migraciones Drizzle: `npm run db:generate` crea SQL en `drizzle/`. No editar SQL a mano. - No hay DB en CI: los tests Vitest deben ser **puros** (sin conexión a Postgres). --- ## File Structure **Crear:** - `src/lib/auth/password.ts` — hash/verify de contraseña (bcryptjs). Puro, testeable. - `src/lib/auth/tokens.ts` — generación/hash de token de sesión y cálculo de expiración. Puro, testeable. - `src/lib/auth/authz.ts` — tipo `AuthUser` + decisiones de autorización puras (`isAdmin`, `assertAdmin`, `resolveTenantId`, `canAccessTenant`). - `src/lib/auth/session.ts` — ciclo de vida de la sesión (DB + cookie). Excluido de cobertura. - `src/lib/auth/current-user.ts` — `getCurrentUser`, `requireUser`, `requireAdmin`, `getCurrentTenantId` (DB + redirect). Excluido de cobertura. - `src/lib/validation/signup.ts` — schema zod del signup. Puro, testeable. - `src/lib/billing/plan.ts` — `trialDaysRemaining`, `formatPlanBadge`. Puro, testeable. - `src/lib/billing/stripe.ts` — interfaz stub (sin llamadas reales). Excluido de cobertura. - `src/db/admin-queries.ts` — queries del área admin (listar/crear usuarios y tenants, asignar plan/estado). - `src/db/auth-queries.ts` — `getUserByEmail`, `createTenantWithOwner`. - `src/app/login/page.tsx`, `src/app/login/actions.ts` — login. - `src/app/logout/route.ts` — logout (POST). - `src/app/signup/page.tsx`, `src/app/signup/actions.ts` — signup trial. - `src/app/admin/layout.tsx`, `src/app/admin/page.tsx` — dashboard admin. - `src/app/admin/usuarios/page.tsx`, `src/app/admin/usuarios/actions.ts` — gestión usuarios. - `src/app/admin/planes/page.tsx`, `src/app/admin/planes/actions.ts` — ver/asignar planes. - Tests: `tests/auth/password.test.ts`, `tests/auth/tokens.test.ts`, `tests/auth/authz.test.ts`, `tests/validation/signup.test.ts`, `tests/billing/plan.test.ts`. **Modificar:** - `src/db/schema.ts` — enums `user_role`/`user_status`/`subscription_status`, tablas `users`/`sessions`/`plans`, columnas nuevas en `tenants`. - `src/db/queries.ts`, `src/db/pricing-queries.ts`, `src/app/panel/actions.ts` — sustituir `getTenantId()` local por `getCurrentTenantId()`. - `src/app/panel/layout.tsx` — guard `requireUser`, nombre de empresa real, botón logout. - `src/app/panel/page.tsx` — badge de plan/estado del tenant. - `src/db/seed.ts` — sembrar planes, admin y owner logueable de "Reformas Ejemplo". - `vitest.config.ts` — extender `coverage.include` a los módulos puros nuevos. - `package.json` — dependencia `bcryptjs`. - `mvp/b2b/landing_reformix.html` — CTAs de trial → `/signup`. --- ## FASE 1 — Fundación de auth ### Task 1: Añadir dependencia bcryptjs **Files:** - Modify: `package.json` (dependencies) - [ ] **Step 1: Instalar bcryptjs (incluye tipos en v3)** Run: `npm install bcryptjs@^3` Expected: `package.json` lista `"bcryptjs": "^3..."` en dependencies; sin errores. - [ ] **Step 2: Verificar que resuelve y trae tipos** Run: `npx tsc --noEmit -p tsconfig.json` Expected: exit 0 (bcryptjs aún no se importa; solo confirma que el árbol compila). - [ ] **Step 3: Commit** ```bash git add package.json package-lock.json git commit -m "Add bcryptjs para hashing de contraseñas" ``` --- ### Task 2: Hashing de contraseña (puro, TDD) **Files:** - Create: `src/lib/auth/password.ts` - Test: `tests/auth/password.test.ts` - [ ] **Step 1: Escribir el test que falla** ```typescript // tests/auth/password.test.ts import { describe, it, expect } from 'vitest'; import { hashPassword, verifyPassword } from '@/lib/auth/password'; describe('password', () => { it('verifica una contraseña correcta contra su hash', async () => { const hash = await hashPassword('Reforma2026!'); expect(hash).not.toBe('Reforma2026!'); expect(await verifyPassword('Reforma2026!', hash)).toBe(true); }); it('rechaza una contraseña incorrecta', async () => { const hash = await hashPassword('Reforma2026!'); expect(await verifyPassword('otra', hash)).toBe(false); }); }); ``` - [ ] **Step 2: Ejecutar el test para verlo fallar** Run: `npm test -- tests/auth/password.test.ts` Expected: FAIL con "Cannot find module '@/lib/auth/password'". - [ ] **Step 3: Implementación mínima** ```typescript // src/lib/auth/password.ts import bcrypt from 'bcryptjs'; const SALT_ROUNDS = 10; export function hashPassword(plain: string): Promise { return bcrypt.hash(plain, SALT_ROUNDS); } export function verifyPassword(plain: string, hash: string): Promise { return bcrypt.compare(plain, hash); } ``` - [ ] **Step 4: Ejecutar el test para verlo pasar** Run: `npm test -- tests/auth/password.test.ts` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/lib/auth/password.ts tests/auth/password.test.ts git commit -m "Add hashing y verificación de contraseña" ``` --- ### Task 3: Token y expiración de sesión (puro, TDD) **Files:** - Create: `src/lib/auth/tokens.ts` - Test: `tests/auth/tokens.test.ts` - [ ] **Step 1: Escribir el test que falla** ```typescript // tests/auth/tokens.test.ts import { describe, it, expect } from 'vitest'; import { generateSessionToken, hashSessionToken, isSessionExpired, sessionExpiry, SESSION_TTL_MS, } from '@/lib/auth/tokens'; describe('tokens', () => { it('genera tokens distintos en hex de 64 chars', () => { const a = generateSessionToken(); const b = generateSessionToken(); expect(a).toMatch(/^[0-9a-f]{64}$/); expect(a).not.toBe(b); }); it('hashea el token de forma determinista y distinta al token', () => { const t = generateSessionToken(); expect(hashSessionToken(t)).toBe(hashSessionToken(t)); expect(hashSessionToken(t)).not.toBe(t); }); it('marca expirada una sesión pasada y válida una futura', () => { const now = new Date('2026-05-30T12:00:00Z'); expect(isSessionExpired(new Date('2026-05-29T12:00:00Z'), now)).toBe(true); expect(isSessionExpired(new Date('2026-05-31T12:00:00Z'), now)).toBe(false); }); it('sessionExpiry suma el TTL al instante dado', () => { const now = new Date('2026-05-30T12:00:00Z'); expect(sessionExpiry(now).getTime()).toBe(now.getTime() + SESSION_TTL_MS); }); }); ``` - [ ] **Step 2: Ejecutar el test para verlo fallar** Run: `npm test -- tests/auth/tokens.test.ts` Expected: FAIL con "Cannot find module '@/lib/auth/tokens'". - [ ] **Step 3: Implementación mínima** ```typescript // src/lib/auth/tokens.ts import { randomBytes, createHash } from 'node:crypto'; export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 días export function generateSessionToken(): string { return randomBytes(32).toString('hex'); } export function hashSessionToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export function isSessionExpired(expiresAt: Date, now: Date = new Date()): boolean { return expiresAt.getTime() <= now.getTime(); } export function sessionExpiry(now: Date = new Date()): Date { return new Date(now.getTime() + SESSION_TTL_MS); } ``` - [ ] **Step 4: Ejecutar el test para verlo pasar** Run: `npm test -- tests/auth/tokens.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/lib/auth/tokens.ts tests/auth/tokens.test.ts git commit -m "Add generación y expiración de tokens de sesión" ``` --- ### Task 4: Decisiones de autorización (puro, TDD) **Files:** - Create: `src/lib/auth/authz.ts` - Test: `tests/auth/authz.test.ts` - [ ] **Step 1: Escribir el test que falla** ```typescript // tests/auth/authz.test.ts import { describe, it, expect } from 'vitest'; import { isAdmin, assertAdmin, resolveTenantId, canAccessTenant, type AuthUser, } from '@/lib/auth/authz'; const reformista: AuthUser = { id: 'u1', email: 'r@x.com', nombre: 'R', role: 'reformista', tenantId: 't1', status: 'activo', }; const admin: AuthUser = { id: 'u2', email: 'a@x.com', nombre: 'A', role: 'admin', tenantId: null, status: 'activo', }; describe('authz', () => { it('isAdmin distingue roles', () => { expect(isAdmin(admin)).toBe(true); expect(isAdmin(reformista)).toBe(false); }); it('assertAdmin lanza si no es admin o es null', () => { expect(() => assertAdmin(admin)).not.toThrow(); expect(() => assertAdmin(reformista)).toThrow(); expect(() => assertAdmin(null)).toThrow(); }); it('resolveTenantId devuelve el tenant del reformista y lanza para admin/null', () => { expect(resolveTenantId(reformista)).toBe('t1'); expect(() => resolveTenantId(admin)).toThrow(); expect(() => resolveTenantId(null)).toThrow(); }); it('canAccessTenant: reformista solo el suyo, admin cualquiera', () => { expect(canAccessTenant(reformista, 't1')).toBe(true); expect(canAccessTenant(reformista, 't2')).toBe(false); expect(canAccessTenant(admin, 't2')).toBe(true); }); }); ``` - [ ] **Step 2: Ejecutar el test para verlo fallar** Run: `npm test -- tests/auth/authz.test.ts` Expected: FAIL con "Cannot find module '@/lib/auth/authz'". - [ ] **Step 3: Implementación mínima** ```typescript // src/lib/auth/authz.ts export type Role = 'reformista' | 'admin'; export type UserStatus = 'activo' | 'deshabilitado'; export type AuthUser = { id: string; email: string; nombre: string | null; role: Role; tenantId: string | null; status: UserStatus; }; export function isAdmin(user: AuthUser): boolean { return user.role === 'admin'; } export function assertAdmin(user: AuthUser | null): asserts user is AuthUser { if (!user || user.role !== 'admin') { throw new Error('Acceso restringido a administradores.'); } } export function resolveTenantId(user: AuthUser | null): string { if (!user || !user.tenantId) { throw new Error('El usuario no tiene un tenant asociado.'); } return user.tenantId; } export function canAccessTenant(user: AuthUser, tenantId: string): boolean { if (user.role === 'admin') return true; return user.tenantId === tenantId; } ``` - [ ] **Step 4: Ejecutar el test para verlo pasar** Run: `npm test -- tests/auth/authz.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/lib/auth/authz.ts tests/auth/authz.test.ts git commit -m "Add decisiones de autorización puras" ``` --- ### Task 5: Schema — users, sessions, plans y columnas de suscripción **Files:** - Modify: `src/db/schema.ts` - [ ] **Step 1: Añadir enums nuevos tras los enums existentes (después de `unidadMedida`, línea ~56)** ```typescript export const userRole = pgEnum('user_role', ['reformista', 'admin']); export const userStatus = pgEnum('user_status', ['activo', 'deshabilitado']); export const subscriptionStatus = pgEnum('subscription_status', [ 'trial', 'activo', 'cancelado', 'vencido', ]); ``` - [ ] **Step 2: Añadir columnas de suscripción a `tenants` (dentro del objeto `tenants`, antes de `createdAt`)** ```typescript planId: uuid('plan_id').references((): any => plans.id), subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'), trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }), stripeCustomerId: text('stripe_customer_id'), ``` > `plans` se define más abajo; la referencia perezosa `(): any =>` evita el problema de orden de declaración. Mantén el resto de columnas de `tenants` intactas. - [ ] **Step 3: Añadir tablas nuevas tras la tabla `tenants`** ```typescript export const plans = pgTable('plans', { id: uuid('id').primaryKey().defaultRandom(), slug: text('slug').notNull().unique(), nombre: text('nombre').notNull(), precioMensual: integer('precio_mensual').notNull(), // céntimos leadsIncluidos: integer('leads_incluidos').notNull(), features: jsonb('features').$type().notNull().default([]), activo: boolean('activo').notNull().default(true), }); export const users = pgTable( 'users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull(), nombre: text('nombre'), role: userRole('role').notNull().default('reformista'), tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }), status: userStatus('status').notNull().default('activo'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('users_tenant_idx').on(table.tenantId)] ); export const sessions = pgTable( 'sessions', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), tokenHash: text('token_hash').notNull().unique(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('sessions_user_idx').on(table.userId)] ); ``` - [ ] **Step 4: Añadir los tipos inferidos al final del archivo (junto a los `export type` existentes)** ```typescript export type Plan = typeof plans.$inferSelect; export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Session = typeof sessions.$inferSelect; ``` - [ ] **Step 5: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 6: Generar la migración** Run: `npm run db:generate` Expected: imprime "Your SQL migration file ➜ drizzle/0001_*.sql" (o índice siguiente); el SQL crea los enums, tablas y columnas. - [ ] **Step 7: Commit** ```bash git add src/db/schema.ts drizzle/ git commit -m "Add schema de users, sessions, plans y suscripción de tenant" ``` --- ### Task 6: Sesión (DB + cookie) y helpers de usuario actual **Files:** - Create: `src/lib/auth/session.ts` - Create: `src/lib/auth/current-user.ts` - Create: `src/db/auth-queries.ts` - [ ] **Step 1: Query de usuario por email + creación de tenant con owner** ```typescript // src/db/auth-queries.ts import { eq } from 'drizzle-orm'; import { db } from './index'; import { users, tenants } from './schema'; import { sessionExpiry } from '@/lib/auth/tokens'; export async function getUserByEmail(email: string) { const [row] = await db.select().from(users).where(eq(users.email, email)).limit(1); return row ?? null; } export async function getUserById(id: string) { const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); return row ?? null; } export async function createTenantWithOwner(input: { nombreEmpresa: string; slug: string; provincia: string | null; email: string; passwordHash: string; nombre: string | null; }) { const [tenant] = await db .insert(tenants) .values({ slug: input.slug, nombreEmpresa: input.nombreEmpresa, provincia: input.provincia, subscriptionStatus: 'trial', trialEndsAt: sessionExpiry(new Date()), // placeholder TTL; ajustado abajo a 14 días }) .returning(); const [user] = await db .insert(users) .values({ email: input.email, passwordHash: input.passwordHash, nombre: input.nombre, role: 'reformista', tenantId: tenant.id, }) .returning(); return { tenant, user }; } ``` > **Nota:** el `trialEndsAt` correcto (14 días) lo fija la action de signup (Task 11) tras crear el tenant; aquí se pone un valor por defecto que la action sobreescribe. No dupliques el cálculo de 14 días en dos sitios: la action es la fuente de verdad. - [ ] **Step 2: Implementar session.ts (cookie + DB)** ```typescript // src/lib/auth/session.ts import { cookies } from 'next/headers'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { sessions } from '@/db/schema'; import { getUserById } from '@/db/auth-queries'; import { generateSessionToken, hashSessionToken, isSessionExpired, sessionExpiry, } from './tokens'; import type { AuthUser } from './authz'; const COOKIE = 'session'; export async function createSession(userId: string): Promise { const token = generateSessionToken(); await db.insert(sessions).values({ userId, tokenHash: hashSessionToken(token), expiresAt: sessionExpiry(), }); const store = await cookies(); store.set(COOKIE, token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', expires: sessionExpiry(), }); } export async function destroySession(): Promise { const store = await cookies(); const token = store.get(COOKIE)?.value; if (token) { await db.delete(sessions).where(eq(sessions.tokenHash, hashSessionToken(token))); store.delete(COOKIE); } } export async function getSessionUser(): Promise { const store = await cookies(); const token = store.get(COOKIE)?.value; if (!token) return null; const [session] = await db .select() .from(sessions) .where(eq(sessions.tokenHash, hashSessionToken(token))) .limit(1); if (!session) return null; if (isSessionExpired(session.expiresAt)) { await db.delete(sessions).where(eq(sessions.id, session.id)); return null; } const user = await getUserById(session.userId); if (!user || user.status !== 'activo') return null; return { id: user.id, email: user.email, nombre: user.nombre, role: user.role, tenantId: user.tenantId, status: user.status, }; } ``` - [ ] **Step 3: Implementar current-user.ts (redirect semantics)** ```typescript // src/lib/auth/current-user.ts import { redirect } from 'next/navigation'; import { getSessionUser } from './session'; import { resolveTenantId, type AuthUser } from './authz'; export async function getCurrentUser(): Promise { return getSessionUser(); } export async function requireUser(): Promise { const user = await getSessionUser(); if (!user) redirect('/login'); return user; } export async function requireAdmin(): Promise { const user = await getSessionUser(); if (!user) redirect('/login'); if (user.role !== 'admin') redirect('/panel'); return user; } export async function getCurrentTenantId(): Promise { const user = await getSessionUser(); return resolveTenantId(user); } ``` - [ ] **Step 4: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 5: Commit** ```bash git add src/lib/auth/session.ts src/lib/auth/current-user.ts src/db/auth-queries.ts git commit -m "Add ciclo de vida de sesión y helpers de usuario actual" ``` --- ### Task 7: Migrar resolución de tenant de slug a sesión **Files:** - Modify: `src/db/queries.ts:13-17` - Modify: `src/db/pricing-queries.ts:1-11,54` - Modify: `src/app/panel/actions.ts:7,12-16` - [ ] **Step 1: queries.ts — reemplazar getTenantId local por el de sesión** En `src/db/queries.ts` elimina la función local `getTenantId` (líneas 13-17) y el import de `TENANT_SLUG` y `tenants` si quedan sin uso. Añade arriba: ```typescript import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; ``` Quita `tenants` del import de `./schema` y `TENANT_SLUG` de `@/lib/funnel` si ya no se usan en el archivo. El resto de funciones (`getLeads`, `getLead`, `getResumen`) no cambian: siguen llamando `await getTenantId()`. - [ ] **Step 2: pricing-queries.ts — misma sustitución, manteniendo el export** En `src/db/pricing-queries.ts` elimina la función local `getTenantId` (líneas 7-11) y el import de `TENANT_SLUG`. Añade: ```typescript import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; ``` Mantén `export { getTenantId };` al final (línea 54) para no romper a `precios/actions.ts`, que lo importa. Quita `tenants` del import de `./schema` si queda sin uso. - [ ] **Step 3: panel/actions.ts — usar el helper de sesión** En `src/app/panel/actions.ts` elimina la función local `getTenantId` (líneas 12-16) y el import de `TENANT_SLUG` (línea 7) y de `tenants` (línea 6). Añade: ```typescript import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user'; ``` Las tres actions ya llaman `await getTenantId()`; no cambian. - [ ] **Step 4: Verificar que compila y que no quedan referencias a TENANT_SLUG en queries** Run: `npx tsc --noEmit` Expected: exit 0. Run: `git grep -n "TENANT_SLUG" -- src/db src/app/panel` Expected: sin resultados (TENANT_SLUG solo puede quedar en `src/lib/funnel.ts` y `src/db/seed.ts`). - [ ] **Step 5: Commit** ```bash git add src/db/queries.ts src/db/pricing-queries.ts src/app/panel/actions.ts git commit -m "Migrar resolución de tenant del panel a la sesión" ``` --- ### Task 8: Login y logout **Files:** - Create: `src/app/login/page.tsx` - Create: `src/app/login/actions.ts` - Create: `src/app/logout/route.ts` - [ ] **Step 1: Server action de login** ```typescript // src/app/login/actions.ts 'use server'; import { redirect } from 'next/navigation'; import { getUserByEmail } from '@/db/auth-queries'; import { verifyPassword } from '@/lib/auth/password'; import { createSession } from '@/lib/auth/session'; export async function login(_prev: string | null, formData: FormData): Promise { const email = String(formData.get('email') ?? '').trim().toLowerCase(); const password = String(formData.get('password') ?? ''); if (!email || !password) return 'Introduce email y contraseña.'; const user = await getUserByEmail(email); if (!user || user.status !== 'activo') return 'Credenciales incorrectas.'; if (!(await verifyPassword(password, user.passwordHash))) return 'Credenciales incorrectas.'; await createSession(user.id); redirect(user.role === 'admin' ? '/admin' : '/panel'); } ``` - [ ] **Step 2: Página de login (Client Component con useActionState)** ```tsx // src/app/login/page.tsx 'use client'; import { useActionState } from 'react'; import { login } from './actions'; export default function LoginPage() { const [error, formAction, pending] = useActionState(login, null); return (

Entra en tu panel

{error &&

{error}

}
); } ``` - [ ] **Step 3: Route de logout** ```typescript // src/app/logout/route.ts import { redirect } from 'next/navigation'; import { destroySession } from '@/lib/auth/session'; export async function POST() { await destroySession(); redirect('/login'); } ``` - [ ] **Step 4: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 5: Commit** ```bash git add src/app/login src/app/logout git commit -m "Add login y logout" ``` --- ### Task 9: Guard del panel + cabecera con sesión **Files:** - Modify: `src/app/panel/layout.tsx` - [ ] **Step 1: Convertir el layout en async con guard y datos de sesión** ```tsx // src/app/panel/layout.tsx import Link from 'next/link'; import type { Metadata } from 'next'; import { requireUser } from '@/lib/auth/current-user'; import { db } from '@/db'; import { tenants } from '@/db/schema'; import { eq } from 'drizzle-orm'; export const metadata: Metadata = { title: 'Panel · Reformix', description: 'Panel de leads del reformista', }; export default async function PanelLayout({ children }: { children: React.ReactNode }) { const user = await requireUser(); const [tenant] = user.tenantId ? await db.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1) : []; const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix'; return (
R Reformix / {nombreEmpresa}
{children}
); } ``` - [ ] **Step 2: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add src/app/panel/layout.tsx git commit -m "Proteger el panel con sesión y mostrar empresa real" ``` --- ### Task 10: Seed — planes, admin y owner logueable **Files:** - Modify: `src/db/seed.ts` - [ ] **Step 1: Añadir imports de hashing y schema de auth (tras la línea 7)** ```typescript import { hashPassword } from '../lib/auth/password'; ``` > `schema.plans`, `schema.users` ya están disponibles vía `import * as schema`. - [ ] **Step 2: Incluir las tablas nuevas en el TRUNCATE (línea ~286)** Reemplaza la sentencia TRUNCATE para incluir `users`, `sessions` y `plans` (las dependientes primero por CASCADE no es estricto, pero añádelas explícitamente): ```typescript await db.execute( sql`TRUNCATE TABLE ${schema.sessions}, ${schema.users}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.catalogItems}, ${schema.pricingConfig}, ${schema.leads}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE` ); ``` - [ ] **Step 3: Sembrar los 3 planes antes de crear el tenant (tras el TRUNCATE)** ```typescript console.log('Sembrando planes...'); const [starter, pro] = await db .insert(schema.plans) .values([ { slug: 'starter', nombre: 'Starter', precioMensual: 2900, leadsIncluidos: 5, features: ['5 leads procesados / mes', '3 €/lead extra', 'Hasta 100 contactos', 'Branding básico'] }, { slug: 'pro', nombre: 'Pro', precioMensual: 7900, leadsIncluidos: 15, features: ['15 leads procesados / mes', '2,50 €/lead extra', 'White-label completo', 'Sub-flujo licencia urbanística', 'Integraciones Holded/Stel', 'Soporte prioritario'] }, { slug: 'business', nombre: 'Business', precioMensual: 19900, leadsIncluidos: 50, features: ['50 leads procesados / mes', '2 €/lead extra', 'Usuarios ilimitados', 'API', 'Multi-zona', 'Custom price book', 'Dashboard analytics'] }, ]) .returning(); ``` - [ ] **Step 4: Asignar plan Pro + trial al tenant al crearlo (modificar el insert de `tenants`, línea ~290)** ```typescript const [tenant] = await db .insert(schema.tenants) .values({ slug: 'reformas-ejemplo', nombreEmpresa: 'Reformas Ejemplo', provincia: 'Madrid', whatsappBusiness: '+34 600 000 000', planId: pro.id, subscriptionStatus: 'trial', trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), }) .returning(); ``` - [ ] **Step 5: Crear admin + owner logueable (tras crear el tenant, antes del bucle de leads)** ```typescript console.log('Creando usuarios demo (admin + owner)...'); await db.insert(schema.users).values([ { email: 'admin@reformix.es', passwordHash: await hashPassword('AdminReformix2026!'), nombre: 'Admin Reformix', role: 'admin', tenantId: null, }, { email: 'demo@reformas-ejemplo.es', passwordHash: await hashPassword('DemoReformix2026!'), nombre: 'Reformas Ejemplo', role: 'reformista', tenantId: tenant.id, }, ]); ``` > Credenciales demo documentadas aquí: admin `admin@reformix.es` / `AdminReformix2026!`; reformista `demo@reformas-ejemplo.es` / `DemoReformix2026!`. El `starter` sembrado no se asigna a nadie todavía; queda disponible para que el admin lo asigne. - [ ] **Step 6: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 7: Commit** ```bash git add src/db/seed.ts git commit -m "Sembrar planes y usuarios demo (admin + owner logueable)" ``` --- ## FASE 2 — Signup trial ### Task 11: Schema zod del signup (puro, TDD) **Files:** - Create: `src/lib/validation/signup.ts` - Test: `tests/validation/signup.test.ts` - [ ] **Step 1: Escribir el test que falla** ```typescript // tests/validation/signup.test.ts import { describe, it, expect } from 'vitest'; import { signupSchema, slugify } from '@/lib/validation/signup'; describe('signupSchema', () => { it('acepta un alta válida y normaliza email a minúsculas', () => { const r = signupSchema.safeParse({ nombre: 'Ana', email: 'Ana@X.com', empresa: 'Reformas Ana', provincia: 'Madrid', password: 'Segura2026', optInMarketing: 'on', }); expect(r.success).toBe(true); if (r.success) expect(r.data.email).toBe('ana@x.com'); }); it('rechaza email inválido y contraseña corta', () => { expect(signupSchema.safeParse({ nombre: 'Ana', email: 'no-mail', empresa: 'X', provincia: 'Madrid', password: '123', }).success).toBe(false); }); }); describe('slugify', () => { it('genera slug url-safe sin acentos', () => { expect(slugify('Reformas Ándalus, S.L.')).toBe('reformas-andalus-s-l'); }); }); ``` - [ ] **Step 2: Ejecutar el test para verlo fallar** Run: `npm test -- tests/validation/signup.test.ts` Expected: FAIL con "Cannot find module '@/lib/validation/signup'". - [ ] **Step 3: Implementación mínima** ```typescript // src/lib/validation/signup.ts import { z } from 'zod'; export const signupSchema = z.object({ nombre: z.string().trim().min(1, 'Indica tu nombre.'), email: z.string().trim().toLowerCase().email('Email no válido.'), empresa: z.string().trim().min(1, 'Indica el nombre de tu empresa.'), provincia: z.string().trim().min(1, 'Indica tu provincia.'), password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres.'), optInMarketing: z.union([z.literal('on'), z.literal('')]).optional(), }); export type SignupInput = z.infer; export function slugify(value: string): string { return value .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } ``` - [ ] **Step 4: Ejecutar el test para verlo pasar** Run: `npm test -- tests/validation/signup.test.ts` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add src/lib/validation/signup.ts tests/validation/signup.test.ts git commit -m "Add validación y slug del signup" ``` --- ### Task 12: Página y action de signup trial **Files:** - Create: `src/app/signup/actions.ts` - Create: `src/app/signup/page.tsx` - Modify: `src/db/auth-queries.ts` (añadir unicidad de slug) - [ ] **Step 1: Helper de slug único en auth-queries.ts** Añade a `src/db/auth-queries.ts`: ```typescript export async function slugDisponible(slug: string): Promise { const [row] = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, slug)).limit(1); return !row; } ``` - [ ] **Step 2: Server action de signup** ```typescript // src/app/signup/actions.ts 'use server'; import { redirect } from 'next/navigation'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { tenants } from '@/db/schema'; import { signupSchema, slugify } from '@/lib/validation/signup'; import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries'; import { hashPassword } from '@/lib/auth/password'; import { createSession } from '@/lib/auth/session'; const TRIAL_MS = 14 * 24 * 60 * 60 * 1000; export async function signup(_prev: string | null, formData: FormData): Promise { const parsed = signupSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.'; const data = parsed.data; if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.'; let slug = slugify(data.empresa); let n = 1; while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`; const passwordHash = await hashPassword(data.password); const { tenant, user } = await createTenantWithOwner({ nombreEmpresa: data.empresa, slug, provincia: data.provincia, email: data.email, passwordHash, nombre: data.nombre, }); await db .update(tenants) .set({ trialEndsAt: new Date(Date.now() + TRIAL_MS) }) .where(eq(tenants.id, tenant.id)); await createSession(user.id); redirect('/panel'); } ``` - [ ] **Step 3: Página de signup (Client Component)** ```tsx // src/app/signup/page.tsx 'use client'; import { useActionState } from 'react'; import { signup } from './actions'; export default function SignupPage() { const [error, formAction, pending] = useActionState(signup, null); return (

Empieza gratis 14 días

Sin tarjeta. Configura tu catálogo y recibe leads.

{error &&

{error}

} Ya tengo cuenta
); } ``` - [ ] **Step 4: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 5: Commit** ```bash git add src/app/signup src/db/auth-queries.ts git commit -m "Add signup trial que crea tenant y owner" ``` --- ### Task 13: Cablear los CTA de la landing B2B a /signup **Files:** - Modify: `mvp/b2b/landing_reformix.html` - [ ] **Step 1: Localizar los CTA de trial** Run: `git grep -n -o -E "href=\"[^\"]*\"" -- mvp/b2b/landing_reformix.html | head -40` Después busca los anclajes cuyo texto visible sea "Empieza gratis 14 días", "Pruébalo gratis 14 días" o "Empezar gratis 14 días". Para cada uno, lee el fragmento exacto con Read sobre el rango de la línea coincidente antes de editar. - [ ] **Step 2: Apuntar cada CTA de trial a /signup** Para cada anclaje de trial localizado, usa Edit cambiando su `href` actual (p. ej. `href="#pricing"` o `href="#signup"`) por `href="/signup"`. La landing se sirve en el mismo origen (`/b2b`), así que la ruta relativa `/signup` resuelve correctamente. No toques otros enlaces (FAQ, ancras internas de navegación). - [ ] **Step 3: Verificar que los CTA apuntan a /signup** Run: `git grep -n "/signup" -- mvp/b2b/landing_reformix.html` Expected: una línea por cada CTA de trial editado. - [ ] **Step 4: Commit** ```bash git add mvp/b2b/landing_reformix.html git commit -m "Cablear CTAs de trial de la landing B2B a /signup" ``` > El script `make-deploy-zip.ps1` ya copia `mvp/b2b/landing_reformix.html` → `mvp/b2c/public/b2b.html` en cada build, así que el cambio llega a producción sin pasos extra. --- ## FASE 3 — Área admin ### Task 14: Queries del admin **Files:** - Create: `src/db/admin-queries.ts` - [ ] **Step 1: Implementar las queries (todas asumen que el caller ya validó admin)** ```typescript // src/db/admin-queries.ts import { eq } from 'drizzle-orm'; import { db } from './index'; import { tenants, users, plans } from './schema'; export async function listTenants() { return db.select().from(tenants).orderBy(tenants.createdAt); } export async function listUsers() { return db.select().from(users).orderBy(users.createdAt); } export async function listPlans() { return db.select().from(plans).where(eq(plans.activo, true)).orderBy(plans.precioMensual); } export async function assignPlan(tenantId: string, planId: string) { await db.update(tenants).set({ planId }).where(eq(tenants.id, tenantId)); } export async function setSubscriptionStatus( tenantId: string, status: (typeof tenants.subscriptionStatus.enumValues)[number] ) { await db.update(tenants).set({ subscriptionStatus: status }).where(eq(tenants.id, tenantId)); } export async function setUserStatus( userId: string, status: (typeof users.status.enumValues)[number] ) { await db.update(users).set({ status, updatedAt: new Date() }).where(eq(users.id, userId)); } ``` - [ ] **Step 2: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add src/db/admin-queries.ts git commit -m "Add queries del área admin" ``` --- ### Task 15: Layout y dashboard del admin **Files:** - Create: `src/app/admin/layout.tsx` - Create: `src/app/admin/page.tsx` - [ ] **Step 1: Layout con guard requireAdmin** ```tsx // src/app/admin/layout.tsx import Link from 'next/link'; import type { Metadata } from 'next'; import { requireAdmin } from '@/lib/auth/current-user'; export const metadata: Metadata = { title: 'Admin · Reformix' }; export default async function AdminLayout({ children }: { children: React.ReactNode }) { await requireAdmin(); return (
R Reformix / Admin
{children}
); } ``` - [ ] **Step 2: Dashboard con conteos** ```tsx // src/app/admin/page.tsx import { listTenants, listUsers, listPlans } from '@/db/admin-queries'; export const dynamic = 'force-dynamic'; export default async function AdminHome() { const [tenants, users, plans] = await Promise.all([listTenants(), listUsers(), listPlans()]); const cards = [ { label: 'Reformistas (tenants)', value: tenants.length }, { label: 'Usuarios', value: users.length }, { label: 'Planes activos', value: plans.length }, { label: 'En trial', value: tenants.filter((t) => t.subscriptionStatus === 'trial').length }, ]; return (

Resumen

{cards.map((c) => (
{c.value}
{c.label}
))}
); } ``` - [ ] **Step 3: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 4: Commit** ```bash git add src/app/admin/layout.tsx src/app/admin/page.tsx git commit -m "Add layout y dashboard del admin" ``` --- ### Task 16: Gestión de usuarios (crear reformista, habilitar/deshabilitar) **Files:** - Create: `src/app/admin/usuarios/actions.ts` - Create: `src/app/admin/usuarios/page.tsx` - [ ] **Step 1: Actions (crear reformista, toggle estado)** ```typescript // src/app/admin/usuarios/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { requireAdmin } from '@/lib/auth/current-user'; import { signupSchema, slugify } from '@/lib/validation/signup'; import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries'; import { setUserStatus } from '@/db/admin-queries'; import { hashPassword } from '@/lib/auth/password'; export async function crearReformista(_prev: string | null, formData: FormData): Promise { await requireAdmin(); const parsed = signupSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.'; const data = parsed.data; if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.'; let slug = slugify(data.empresa); let n = 1; while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`; await createTenantWithOwner({ nombreEmpresa: data.empresa, slug, provincia: data.provincia, email: data.email, passwordHash: await hashPassword(data.password), nombre: data.nombre, }); revalidatePath('/admin/usuarios'); return null; } export async function toggleUsuario(formData: FormData) { await requireAdmin(); const userId = String(formData.get('userId')); const next = String(formData.get('next')) as 'activo' | 'deshabilitado'; await setUserStatus(userId, next); revalidatePath('/admin/usuarios'); } ``` - [ ] **Step 2: Página (lista + formulario de alta)** ```tsx // src/app/admin/usuarios/page.tsx import { listUsers, listTenants } from '@/db/admin-queries'; import { toggleUsuario } from './actions'; import { CrearReformistaForm } from './CrearReformistaForm'; export const dynamic = 'force-dynamic'; export default async function UsuariosPage() { const [users, tenants] = await Promise.all([listUsers(), listTenants()]); const empresaDe = new Map(tenants.map((t) => [t.id, t.nombreEmpresa])); return (

Usuarios

{users.map((u) => ( ))}
EmailRol EmpresaEstado
{u.email} {u.role} {u.tenantId ? empresaDe.get(u.tenantId) ?? '—' : '—'} {u.status} {u.role !== 'admin' && (
)}
); } ``` - [ ] **Step 3: Formulario de alta (Client Component)** ```tsx // src/app/admin/usuarios/CrearReformistaForm.tsx 'use client'; import { useActionState } from 'react'; import { crearReformista } from './actions'; export function CrearReformistaForm() { const [error, action, pending] = useActionState(crearReformista, null); return (

Crear reformista

{error &&

{error}

}
); } ``` - [ ] **Step 4: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 5: Commit** ```bash git add src/app/admin/usuarios git commit -m "Add gestión de usuarios en el admin" ``` --- ### Task 17: Asignación de planes **Files:** - Create: `src/app/admin/planes/actions.ts` - Create: `src/app/admin/planes/page.tsx` - [ ] **Step 1: Actions de asignación** ```typescript // src/app/admin/planes/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { requireAdmin } from '@/lib/auth/current-user'; import { assignPlan, setSubscriptionStatus } from '@/db/admin-queries'; import { tenants } from '@/db/schema'; export async function asignarPlan(formData: FormData) { await requireAdmin(); const tenantId = String(formData.get('tenantId')); const planId = String(formData.get('planId')); const status = String(formData.get('status')) as (typeof tenants.subscriptionStatus.enumValues)[number]; await assignPlan(tenantId, planId); await setSubscriptionStatus(tenantId, status); revalidatePath('/admin/planes'); } ``` - [ ] **Step 2: Página (catálogo de planes + asignación por tenant)** ```tsx // src/app/admin/planes/page.tsx import { listPlans, listTenants } from '@/db/admin-queries'; import { asignarPlan } from './actions'; import { formatEuros } from '@/lib/funnel'; export const dynamic = 'force-dynamic'; const ESTADOS = ['trial', 'activo', 'cancelado', 'vencido'] as const; export default async function PlanesPage() { const [plans, tenants] = await Promise.all([listPlans(), listTenants()]); const nombrePlan = new Map(plans.map((p) => [p.id, p.nombre])); return (

Planes

{plans.map((p) => (

{p.nombre}

{formatEuros(p.precioMensual)}/mes
    {p.features.map((f) =>
  • · {f}
  • )}
))}
{tenants.map((t) => ( ))}
ReformistaPlan actual EstadoAsignar
{t.nombreEmpresa} {t.planId ? nombrePlan.get(t.planId) ?? '—' : '—'} {t.subscriptionStatus}
); } ``` - [ ] **Step 3: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 4: Commit** ```bash git add src/app/admin/planes git commit -m "Add asignación de planes y estado de suscripción" ``` --- ## FASE 4 — Stripe stub + display de plan ### Task 18: Cálculo de trial y badge de plan (puro, TDD) **Files:** - Create: `src/lib/billing/plan.ts` - Test: `tests/billing/plan.test.ts` - [ ] **Step 1: Escribir el test que falla** ```typescript // tests/billing/plan.test.ts import { describe, it, expect } from 'vitest'; import { trialDaysRemaining, formatPlanBadge } from '@/lib/billing/plan'; describe('plan', () => { it('calcula días de trial restantes (redondeo hacia arriba, nunca negativo)', () => { const now = new Date('2026-05-30T12:00:00Z'); expect(trialDaysRemaining(new Date('2026-06-05T12:00:00Z'), now)).toBe(6); expect(trialDaysRemaining(new Date('2026-05-30T18:00:00Z'), now)).toBe(1); expect(trialDaysRemaining(new Date('2026-05-29T12:00:00Z'), now)).toBe(0); expect(trialDaysRemaining(null, now)).toBe(0); }); it('formatea el badge según estado', () => { const now = new Date('2026-05-30T12:00:00Z'); expect(formatPlanBadge('Pro', 'trial', new Date('2026-06-05T12:00:00Z'), now)) .toBe('Plan Pro · trial, 6 días restantes'); expect(formatPlanBadge('Pro', 'activo', null, now)).toBe('Plan Pro · activo'); expect(formatPlanBadge(null, 'trial', new Date('2026-06-05T12:00:00Z'), now)) .toBe('Sin plan · trial, 6 días restantes'); }); }); ``` - [ ] **Step 2: Ejecutar el test para verlo fallar** Run: `npm test -- tests/billing/plan.test.ts` Expected: FAIL con "Cannot find module '@/lib/billing/plan'". - [ ] **Step 3: Implementación mínima** ```typescript // src/lib/billing/plan.ts export function trialDaysRemaining(trialEndsAt: Date | null, now: Date = new Date()): number { if (!trialEndsAt) return 0; const ms = trialEndsAt.getTime() - now.getTime(); if (ms <= 0) return 0; return Math.ceil(ms / (24 * 60 * 60 * 1000)); } export function formatPlanBadge( planNombre: string | null, status: string, trialEndsAt: Date | null, now: Date = new Date() ): string { const plan = planNombre ? `Plan ${planNombre}` : 'Sin plan'; if (status === 'trial') { return `${plan} · trial, ${trialDaysRemaining(trialEndsAt, now)} días restantes`; } return `${plan} · ${status}`; } ``` - [ ] **Step 4: Ejecutar el test para verlo pasar** Run: `npm test -- tests/billing/plan.test.ts` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add src/lib/billing/plan.ts tests/billing/plan.test.ts git commit -m "Add cálculo de trial y badge de plan" ``` --- ### Task 19: Stub de Stripe **Files:** - Create: `src/lib/billing/stripe.ts` - [ ] **Step 1: Interfaz no-op documentada** ```typescript // src/lib/billing/stripe.ts // Stub de Stripe: sin cuenta activa, no se hacen llamadas reales (ver spec, "Fuera de alcance"). // Reservado para cuando se conecte la pasarela; cualquier uso real debe lanzar para no fingir cobros. export const STRIPE_ENABLED = false; export async function createCustomer(_email: string): Promise { throw new Error('Stripe no está configurado (stub).'); } export async function createCheckoutSession(_args: { tenantId: string; planSlug: string }): Promise { throw new Error('Stripe no está configurado (stub).'); } ``` - [ ] **Step 2: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add src/lib/billing/stripe.ts git commit -m "Add stub de Stripe (sin cobro real)" ``` --- ### Task 20: Badge de plan en el panel del reformista **Files:** - Modify: `src/app/panel/page.tsx` - [ ] **Step 1: Cargar el plan/estado del tenant y mostrar el badge** En `src/app/panel/page.tsx`, añade imports al inicio: ```typescript import { getCurrentTenantId } from '@/lib/auth/current-user'; import { db } from '@/db'; import { tenants, plans } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { formatPlanBadge } from '@/lib/billing/plan'; import { STRIPE_ENABLED } from '@/lib/billing/stripe'; ``` Dentro de `PanelPage`, tras resolver `filtro` y antes del `return`, carga el tenant y su plan: ```typescript const tenantId = await getCurrentTenantId(); const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1); const [plan] = tenant?.planId ? await db.select().from(plans).where(eq(plans.id, tenant.planId)).limit(1) : []; const badge = formatPlanBadge(plan?.nombre ?? null, tenant?.subscriptionStatus ?? 'trial', tenant?.trialEndsAt ?? null); ``` Justo debajo del bloque del título (`
`, que termina antes de los filtros), inserta: ```tsx
{badge}
``` - [ ] **Step 2: Verificar que compila** Run: `npx tsc --noEmit` Expected: exit 0. - [ ] **Step 3: Commit** ```bash git add src/app/panel/page.tsx git commit -m "Mostrar badge de plan y botón de pago deshabilitado" ``` --- ### Task 21: Extender cobertura de tests a los módulos puros **Files:** - Modify: `vitest.config.ts:11-15` - [ ] **Step 1: Ampliar coverage.include a los módulos puros nuevos** ```typescript coverage: { provider: 'v8', include: [ 'src/budget/**', 'src/lib/auth/password.ts', 'src/lib/auth/tokens.ts', 'src/lib/auth/authz.ts', 'src/lib/validation/signup.ts', 'src/lib/billing/plan.ts', ], thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 }, }, ``` > No se incluyen `session.ts`, `current-user.ts`, `stripe.ts` ni las `*-queries.ts` porque dependen de DB/cookies y no se pueden cubrir sin un Postgres en CI (RNF-MAINT-01 aplica a la lógica pura). - [ ] **Step 2: Ejecutar la suite completa con cobertura** Run: `npm run test:coverage` Expected: PASS en todos los tests; cobertura ≥70% en lines/functions/statements/branches sobre los módulos incluidos. - [ ] **Step 3: Verificar typecheck y lint globales** Run: `npx tsc --noEmit && npm run lint` Expected: exit 0 en ambos. - [ ] **Step 4: Commit** ```bash git add vitest.config.ts git commit -m "Extender cobertura de tests a auth, validación y billing" ``` --- ## Verificación end-to-end (tras todas las tasks) Con un Postgres local y `.env.local` con `DATABASE_URL`: - [ ] `npm run db:migrate` aplica la migración nueva sin error. - [ ] `SEED_FORCE=1 npm run db:seed` siembra planes + admin + owner. - [ ] `npm run dev` y en el navegador: - `/panel` sin sesión → redirige a `/login`. - Login con `demo@reformas-ejemplo.es` / `DemoReformix2026!` → ve sus leads + badge "Plan Pro · trial, N días restantes". - `/admin` con el reformista → redirige a `/panel`. - Login con `admin@reformix.es` / `AdminReformix2026!` → ve `/admin`, crea un reformista, le asigna plan Starter y estado activo. - El nuevo reformista entra y ve su panel vacío (0 leads), aislado del de Reformas Ejemplo. - `/signup` crea una cuenta trial nueva y entra directo al panel. - "Salir" cierra sesión y vuelve a `/login`. --- ## Notas para producción (no parte de las tasks) El despliegue usa autodeploy Gitea→Dokploy y el `docker-entrypoint.sh` corre `db:migrate` → `db:seed` → `start`. La migración nueva se aplica sola en el próximo deploy. **El reseed con datos de auth solo ocurre si `SEED_FORCE=1` está en el env** (y hace TRUNCATE). Para poblar usuarios/planes en prod la primera vez: activar `SEED_FORCE=1`, desplegar, y **quitarlo después** para no borrar cuentas reales en cada deploy.