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/b2cusa npm (haypackage-lock.json), no pnpm. Todos los comandos corren desdemvp/b2c/.- Imports con alias
@/→src/. Tests entests/espejandosrc/. - Dinero en céntimos (enteros). TypeScript strict, sin
any. - Next.js 16:
cookies()denext/headerses async (const store = await cookies()). - Migraciones Drizzle:
npm run db:generatecrea SQL endrizzle/. 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— tipoAuthUser+ 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— enumsuser_role/user_status/subscription_status, tablasusers/sessions/plans, columnas nuevas entenants.src/db/queries.ts,src/db/pricing-queries.ts,src/app/panel/actions.ts— sustituirgetTenantId()local porgetCurrentTenantId().src/app/panel/layout.tsx— guardrequireUser, 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— extendercoverage.includea los módulos puros nuevos.package.json— dependenciabcryptjs.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 objetotenants, antes decreatedAt)
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'),
plansse define más abajo; la referencia perezosa(): any =>evita el problema de orden de declaración. Mantén el resto de columnas detenantsintactas.
- 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 typeexistentes)
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"
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
// 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
trialEndsAtcorrecto (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.usersya están disponibles víaimport * 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!; reformistademo@reformas-ejemplo.es/DemoReformix2026!. Elstartersembrado 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.ps1ya copiamvp/b2b/landing_reformix.html→mvp/b2c/public/b2b.htmlen 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.tsni las*-queries.tsporque 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:migrateaplica la migración nueva sin error.SEED_FORCE=1 npm run db:seedsiembra planes + admin + owner.npm run devy en el navegador:/panelsin sesión → redirige a/login.- Login con
demo@reformas-ejemplo.es/DemoReformix2026!→ ve sus leads + badge "Plan Pro · trial, N días restantes". /admincon 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.
/signupcrea 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.