Files
reformix-hackaton/docs/superpowers/plans/2026-05-30-auth-panel-planes.md
2026-05-30 19:22:16 +02:00

60 KiB

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.tsgetCurrentUser, requireUser, requireAdmin, getCurrentTenantId (DB + redirect). Excluido de cobertura.
  • src/lib/validation/signup.ts — schema zod del signup. Puro, testeable.
  • src/lib/billing/plan.tstrialDaysRemaining, 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.tsgetUserByEmail, 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
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

// 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
// src/lib/auth/password.ts
import bcrypt from 'bcryptjs';

const SALT_ROUNDS = 10;

export function hashPassword(plain: string): Promise<string> {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

export function verifyPassword(plain: string, hash: string): Promise<boolean> {
  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
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

// 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
// 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
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

// 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
// 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
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)

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)
  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
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<string[]>().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)
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
git add src/db/schema.ts drizzle/
git commit -m "Add schema de users, sessions, plans y suscripción de tenant"

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

// 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)
// 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<void> {
  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<void> {
  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<AuthUser | null> {
  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)
// 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<AuthUser | null> {
  return getSessionUser();
}

export async function requireUser(): Promise<AuthUser> {
  const user = await getSessionUser();
  if (!user) redirect('/login');
  return user;
}

export async function requireAdmin(): Promise<AuthUser> {
  const user = await getSessionUser();
  if (!user) redirect('/login');
  if (user.role !== 'admin') redirect('/panel');
  return user;
}

export async function getCurrentTenantId(): Promise<string> {
  const user = await getSessionUser();
  return resolveTenantId(user);
}
  • Step 4: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 5: Commit
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:

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:

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:

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

// 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<string | null> {
  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)
// 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 (
    <main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
      <form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
        <h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
        <label className="flex flex-col gap-1 text-sm">
          <span className="font-medium text-gray-700">Email</span>
          <input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
        </label>
        <label className="flex flex-col gap-1 text-sm">
          <span className="font-medium text-gray-700">Contraseña</span>
          <input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
        </label>
        {error && <p className="text-sm text-red-600">{error}</p>}
        <button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
          {pending ? 'Entrando…' : 'Entrar'}
        </button>
      </form>
    </main>
  );
}
  • Step 3: Route de logout
// 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
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

// 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 (
    <div className="min-h-screen bg-gray-50">
      <header className="sticky top-0 z-10 bg-white border-b border-gray-200">
        <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
          <Link href="/panel" className="flex items-center gap-3">
            <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
              R
            </span>
            <span className="font-extrabold tracking-tight text-black">Reformix</span>
            <span className="text-gray-300">/</span>
            <span className="text-sm font-medium text-gray-600">{nombreEmpresa}</span>
          </Link>
          <nav className="flex items-center gap-4 text-xs font-medium">
            <Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
            <Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
            <form action="/logout" method="post">
              <button type="submit" className="text-gray-500 hover:text-black">Salir</button>
            </form>
          </nav>
        </div>
      </header>
      <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
    </div>
  );
}
  • Step 2: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 3: Commit
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)

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):

  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)
  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)
  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)
  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
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

// 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
// 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<typeof signupSchema>;

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
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:

export async function slugDisponible(slug: string): Promise<boolean> {
  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
// 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<string | null> {
  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)
// 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 (
    <main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
      <form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
        <h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
        <p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
        <input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
        <input name="empresa" placeholder="Nombre de tu empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
        <input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
        <input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
        <input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
        <label className="flex items-center gap-2 text-xs text-gray-500">
          <input name="optInMarketing" type="checkbox" /> Quiero recibir novedades de Reformix
        </label>
        {error && <p className="text-sm text-red-600">{error}</p>}
        <button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
          {pending ? 'Creando cuenta…' : 'Crear cuenta'}
        </button>
        <a href="/login" className="text-xs text-gray-400 text-center hover:text-black">Ya tengo cuenta</a>
      </form>
    </main>
  );
}
  • Step 4: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 5: Commit
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
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.htmlmvp/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)

// 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
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

// 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 (
    <div className="min-h-screen bg-gray-50">
      <header className="sticky top-0 z-10 bg-white border-b border-gray-200">
        <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
          <Link href="/admin" className="flex items-center gap-3">
            <span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">R</span>
            <span className="font-extrabold tracking-tight text-black">Reformix</span>
            <span className="text-gray-300">/</span>
            <span className="text-sm font-medium text-gray-600">Admin</span>
          </Link>
          <nav className="flex items-center gap-4 text-xs font-medium">
            <Link href="/admin" className="text-gray-500 hover:text-black">Resumen</Link>
            <Link href="/admin/usuarios" className="text-gray-500 hover:text-black">Usuarios</Link>
            <Link href="/admin/planes" className="text-gray-500 hover:text-black">Planes</Link>
            <form action="/logout" method="post"><button type="submit" className="text-gray-500 hover:text-black">Salir</button></form>
          </nav>
        </div>
      </header>
      <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
    </div>
  );
}
  • Step 2: Dashboard con conteos
// 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 (
    <div className="flex flex-col gap-6">
      <h1 className="text-2xl font-black tracking-tight text-black">Resumen</h1>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        {cards.map((c) => (
          <div key={c.label} className="bg-white border border-gray-200 rounded-xl p-5">
            <div className="text-3xl font-black text-black">{c.value}</div>
            <div className="text-xs text-gray-500 mt-1">{c.label}</div>
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 3: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 4: Commit
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)

// 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<string | null> {
  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)
// 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 (
    <div className="flex flex-col gap-8">
      <h1 className="text-2xl font-black tracking-tight text-black">Usuarios</h1>
      <CrearReformistaForm />
      <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
        <table className="w-full text-sm">
          <thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
            <th className="px-4 py-3">Email</th><th className="px-4 py-3">Rol</th>
            <th className="px-4 py-3">Empresa</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3"></th>
          </tr></thead>
          <tbody>
            {users.map((u) => (
              <tr key={u.id} className="border-b border-gray-100 last:border-0">
                <td className="px-4 py-3 font-medium text-black">{u.email}</td>
                <td className="px-4 py-3 text-gray-600">{u.role}</td>
                <td className="px-4 py-3 text-gray-600">{u.tenantId ? empresaDe.get(u.tenantId) ?? '—' : '—'}</td>
                <td className="px-4 py-3 text-gray-600">{u.status}</td>
                <td className="px-4 py-3 text-right">
                  {u.role !== 'admin' && (
                    <form action={toggleUsuario}>
                      <input type="hidden" name="userId" value={u.id} />
                      <input type="hidden" name="next" value={u.status === 'activo' ? 'deshabilitado' : 'activo'} />
                      <button type="submit" className="text-xs font-medium text-gray-500 hover:text-black">
                        {u.status === 'activo' ? 'Deshabilitar' : 'Habilitar'}
                      </button>
                    </form>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 3: Formulario de alta (Client Component)
// 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 (
    <form action={action} className="bg-white border border-gray-200 rounded-xl p-5 grid grid-cols-1 md:grid-cols-2 gap-3">
      <h2 className="md:col-span-2 font-bold text-black">Crear reformista</h2>
      <input name="nombre" placeholder="Nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
      <input name="empresa" placeholder="Empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
      <input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
      <input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
      <input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
      {error && <p className="md:col-span-2 text-sm text-red-600">{error}</p>}
      <button type="submit" disabled={pending} className="md:col-span-2 bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
        {pending ? 'Creando…' : 'Crear reformista'}
      </button>
    </form>
  );
}
  • Step 4: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 5: Commit
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

// 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)
// 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 (
    <div className="flex flex-col gap-8">
      <h1 className="text-2xl font-black tracking-tight text-black">Planes</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {plans.map((p) => (
          <div key={p.id} className="bg-white border border-gray-200 rounded-xl p-5">
            <div className="flex items-baseline justify-between">
              <h2 className="font-bold text-black">{p.nombre}</h2>
              <span className="text-lg font-black text-black">{formatEuros(p.precioMensual)}/mes</span>
            </div>
            <ul className="mt-3 flex flex-col gap-1 text-xs text-gray-500">
              {p.features.map((f) => <li key={f}>· {f}</li>)}
            </ul>
          </div>
        ))}
      </div>

      <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
        <table className="w-full text-sm">
          <thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
            <th className="px-4 py-3">Reformista</th><th className="px-4 py-3">Plan actual</th>
            <th className="px-4 py-3">Estado</th><th className="px-4 py-3">Asignar</th>
          </tr></thead>
          <tbody>
            {tenants.map((t) => (
              <tr key={t.id} className="border-b border-gray-100 last:border-0">
                <td className="px-4 py-3 font-medium text-black">{t.nombreEmpresa}</td>
                <td className="px-4 py-3 text-gray-600">{t.planId ? nombrePlan.get(t.planId) ?? '—' : '—'}</td>
                <td className="px-4 py-3 text-gray-600">{t.subscriptionStatus}</td>
                <td className="px-4 py-3">
                  <form action={asignarPlan} className="flex items-center gap-2">
                    <input type="hidden" name="tenantId" value={t.id} />
                    <select name="planId" defaultValue={t.planId ?? plans[0]?.id} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
                      {plans.map((p) => <option key={p.id} value={p.id}>{p.nombre}</option>)}
                    </select>
                    <select name="status" defaultValue={t.subscriptionStatus} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
                      {ESTADOS.map((e) => <option key={e} value={e}>{e}</option>)}
                    </select>
                    <button type="submit" className="bg-black text-white rounded-md px-3 py-1 text-xs font-semibold">Guardar</button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 3: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 4: Commit
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

// 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
// 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
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

// 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<never> {
  throw new Error('Stripe no está configurado (stub).');
}

export async function createCheckoutSession(_args: { tenantId: string; planSlug: string }): Promise<never> {
  throw new Error('Stripe no está configurado (stub).');
}
  • Step 2: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 3: Commit
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:

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:

  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 (<div className="flex flex-col gap-1">…</div>, que termina antes de los filtros), inserta:

      <div className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-3">
        <span className="text-sm font-medium text-gray-700">{badge}</span>
        <button
          type="button"
          disabled={!STRIPE_ENABLED}
          title="Próximamente"
          className="text-xs font-semibold text-gray-400 cursor-not-allowed"
        >
          Gestionar pago
        </button>
      </div>
  • Step 2: Verificar que compila

Run: npx tsc --noEmit Expected: exit 0.

  • Step 3: Commit
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

    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
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:migratedb:seedstart. 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.