1812 lines
60 KiB
Markdown
1812 lines
60 KiB
Markdown
# Auth + Multi-tenant + Admin de Planes — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Añadir login propio del reformista, aislamiento multi-tenant real y un área admin que gestiona usuarios y asigna planes (Stripe en stub, sin cobro) a la app `mvp/b2c`.
|
|
|
|
**Architecture:** Sesión server-side (tabla `sessions` + cookie httpOnly), contraseñas con `bcryptjs`. La lógica de autorización se extrae a funciones puras (`src/lib/auth/authz.ts`, `tokens.ts`, `password.ts`) testeables sin DB; los wrappers con DB/cookies (`session.ts`, `current-user.ts`) quedan fuera de cobertura. La resolución de tenant pasa de un `TENANT_SLUG` hardcodeado a `getCurrentTenantId()` derivado del usuario logueado, sin tocar los filtros `tenantId` ya existentes en las queries.
|
|
|
|
**Tech Stack:** Next.js 16 (App Router, Server Actions), Drizzle ORM + postgres.js, Postgres, bcryptjs, zod v4, Vitest.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md`
|
|
|
|
---
|
|
|
|
## Convenciones del repo (leer antes de empezar)
|
|
|
|
- `mvp/b2c` usa **npm** (hay `package-lock.json`), no pnpm. Todos los comandos corren desde `mvp/b2c/`.
|
|
- Imports con alias `@/` → `src/`. Tests en `tests/` espejando `src/`.
|
|
- Dinero en **céntimos** (enteros). TypeScript strict, sin `any`.
|
|
- Next.js 16: `cookies()` de `next/headers` es **async** (`const store = await cookies()`).
|
|
- Migraciones Drizzle: `npm run db:generate` crea SQL en `drizzle/`. No editar SQL a mano.
|
|
- No hay DB en CI: los tests Vitest deben ser **puros** (sin conexión a Postgres).
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Crear:**
|
|
- `src/lib/auth/password.ts` — hash/verify de contraseña (bcryptjs). Puro, testeable.
|
|
- `src/lib/auth/tokens.ts` — generación/hash de token de sesión y cálculo de expiración. Puro, testeable.
|
|
- `src/lib/auth/authz.ts` — tipo `AuthUser` + decisiones de autorización puras (`isAdmin`, `assertAdmin`, `resolveTenantId`, `canAccessTenant`).
|
|
- `src/lib/auth/session.ts` — ciclo de vida de la sesión (DB + cookie). Excluido de cobertura.
|
|
- `src/lib/auth/current-user.ts` — `getCurrentUser`, `requireUser`, `requireAdmin`, `getCurrentTenantId` (DB + redirect). Excluido de cobertura.
|
|
- `src/lib/validation/signup.ts` — schema zod del signup. Puro, testeable.
|
|
- `src/lib/billing/plan.ts` — `trialDaysRemaining`, `formatPlanBadge`. Puro, testeable.
|
|
- `src/lib/billing/stripe.ts` — interfaz stub (sin llamadas reales). Excluido de cobertura.
|
|
- `src/db/admin-queries.ts` — queries del área admin (listar/crear usuarios y tenants, asignar plan/estado).
|
|
- `src/db/auth-queries.ts` — `getUserByEmail`, `createTenantWithOwner`.
|
|
- `src/app/login/page.tsx`, `src/app/login/actions.ts` — login.
|
|
- `src/app/logout/route.ts` — logout (POST).
|
|
- `src/app/signup/page.tsx`, `src/app/signup/actions.ts` — signup trial.
|
|
- `src/app/admin/layout.tsx`, `src/app/admin/page.tsx` — dashboard admin.
|
|
- `src/app/admin/usuarios/page.tsx`, `src/app/admin/usuarios/actions.ts` — gestión usuarios.
|
|
- `src/app/admin/planes/page.tsx`, `src/app/admin/planes/actions.ts` — ver/asignar planes.
|
|
- Tests: `tests/auth/password.test.ts`, `tests/auth/tokens.test.ts`, `tests/auth/authz.test.ts`, `tests/validation/signup.test.ts`, `tests/billing/plan.test.ts`.
|
|
|
|
**Modificar:**
|
|
- `src/db/schema.ts` — enums `user_role`/`user_status`/`subscription_status`, tablas `users`/`sessions`/`plans`, columnas nuevas en `tenants`.
|
|
- `src/db/queries.ts`, `src/db/pricing-queries.ts`, `src/app/panel/actions.ts` — sustituir `getTenantId()` local por `getCurrentTenantId()`.
|
|
- `src/app/panel/layout.tsx` — guard `requireUser`, nombre de empresa real, botón logout.
|
|
- `src/app/panel/page.tsx` — badge de plan/estado del tenant.
|
|
- `src/db/seed.ts` — sembrar planes, admin y owner logueable de "Reformas Ejemplo".
|
|
- `vitest.config.ts` — extender `coverage.include` a los módulos puros nuevos.
|
|
- `package.json` — dependencia `bcryptjs`.
|
|
- `mvp/b2b/landing_reformix.html` — CTAs de trial → `/signup`.
|
|
|
|
---
|
|
|
|
## FASE 1 — Fundación de auth
|
|
|
|
### Task 1: Añadir dependencia bcryptjs
|
|
|
|
**Files:**
|
|
- Modify: `package.json` (dependencies)
|
|
|
|
- [ ] **Step 1: Instalar bcryptjs (incluye tipos en v3)**
|
|
|
|
Run: `npm install bcryptjs@^3`
|
|
Expected: `package.json` lista `"bcryptjs": "^3..."` en dependencies; sin errores.
|
|
|
|
- [ ] **Step 2: Verificar que resuelve y trae tipos**
|
|
|
|
Run: `npx tsc --noEmit -p tsconfig.json`
|
|
Expected: exit 0 (bcryptjs aún no se importa; solo confirma que el árbol compila).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add package.json package-lock.json
|
|
git commit -m "Add bcryptjs para hashing de contraseñas"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Hashing de contraseña (puro, TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/auth/password.ts`
|
|
- Test: `tests/auth/password.test.ts`
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```typescript
|
|
// tests/auth/password.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import { hashPassword, verifyPassword } from '@/lib/auth/password';
|
|
|
|
describe('password', () => {
|
|
it('verifica una contraseña correcta contra su hash', async () => {
|
|
const hash = await hashPassword('Reforma2026!');
|
|
expect(hash).not.toBe('Reforma2026!');
|
|
expect(await verifyPassword('Reforma2026!', hash)).toBe(true);
|
|
});
|
|
|
|
it('rechaza una contraseña incorrecta', async () => {
|
|
const hash = await hashPassword('Reforma2026!');
|
|
expect(await verifyPassword('otra', hash)).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Ejecutar el test para verlo fallar**
|
|
|
|
Run: `npm test -- tests/auth/password.test.ts`
|
|
Expected: FAIL con "Cannot find module '@/lib/auth/password'".
|
|
|
|
- [ ] **Step 3: Implementación mínima**
|
|
|
|
```typescript
|
|
// src/lib/auth/password.ts
|
|
import bcrypt from 'bcryptjs';
|
|
|
|
const SALT_ROUNDS = 10;
|
|
|
|
export function hashPassword(plain: string): Promise<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**
|
|
|
|
```bash
|
|
git add src/lib/auth/password.ts tests/auth/password.test.ts
|
|
git commit -m "Add hashing y verificación de contraseña"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Token y expiración de sesión (puro, TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/auth/tokens.ts`
|
|
- Test: `tests/auth/tokens.test.ts`
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```typescript
|
|
// tests/auth/tokens.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
generateSessionToken,
|
|
hashSessionToken,
|
|
isSessionExpired,
|
|
sessionExpiry,
|
|
SESSION_TTL_MS,
|
|
} from '@/lib/auth/tokens';
|
|
|
|
describe('tokens', () => {
|
|
it('genera tokens distintos en hex de 64 chars', () => {
|
|
const a = generateSessionToken();
|
|
const b = generateSessionToken();
|
|
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it('hashea el token de forma determinista y distinta al token', () => {
|
|
const t = generateSessionToken();
|
|
expect(hashSessionToken(t)).toBe(hashSessionToken(t));
|
|
expect(hashSessionToken(t)).not.toBe(t);
|
|
});
|
|
|
|
it('marca expirada una sesión pasada y válida una futura', () => {
|
|
const now = new Date('2026-05-30T12:00:00Z');
|
|
expect(isSessionExpired(new Date('2026-05-29T12:00:00Z'), now)).toBe(true);
|
|
expect(isSessionExpired(new Date('2026-05-31T12:00:00Z'), now)).toBe(false);
|
|
});
|
|
|
|
it('sessionExpiry suma el TTL al instante dado', () => {
|
|
const now = new Date('2026-05-30T12:00:00Z');
|
|
expect(sessionExpiry(now).getTime()).toBe(now.getTime() + SESSION_TTL_MS);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Ejecutar el test para verlo fallar**
|
|
|
|
Run: `npm test -- tests/auth/tokens.test.ts`
|
|
Expected: FAIL con "Cannot find module '@/lib/auth/tokens'".
|
|
|
|
- [ ] **Step 3: Implementación mínima**
|
|
|
|
```typescript
|
|
// src/lib/auth/tokens.ts
|
|
import { randomBytes, createHash } from 'node:crypto';
|
|
|
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 días
|
|
|
|
export function generateSessionToken(): string {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export function hashSessionToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
export function isSessionExpired(expiresAt: Date, now: Date = new Date()): boolean {
|
|
return expiresAt.getTime() <= now.getTime();
|
|
}
|
|
|
|
export function sessionExpiry(now: Date = new Date()): Date {
|
|
return new Date(now.getTime() + SESSION_TTL_MS);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Ejecutar el test para verlo pasar**
|
|
|
|
Run: `npm test -- tests/auth/tokens.test.ts`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/auth/tokens.ts tests/auth/tokens.test.ts
|
|
git commit -m "Add generación y expiración de tokens de sesión"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Decisiones de autorización (puro, TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/auth/authz.ts`
|
|
- Test: `tests/auth/authz.test.ts`
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```typescript
|
|
// tests/auth/authz.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isAdmin,
|
|
assertAdmin,
|
|
resolveTenantId,
|
|
canAccessTenant,
|
|
type AuthUser,
|
|
} from '@/lib/auth/authz';
|
|
|
|
const reformista: AuthUser = {
|
|
id: 'u1', email: 'r@x.com', nombre: 'R', role: 'reformista', tenantId: 't1', status: 'activo',
|
|
};
|
|
const admin: AuthUser = {
|
|
id: 'u2', email: 'a@x.com', nombre: 'A', role: 'admin', tenantId: null, status: 'activo',
|
|
};
|
|
|
|
describe('authz', () => {
|
|
it('isAdmin distingue roles', () => {
|
|
expect(isAdmin(admin)).toBe(true);
|
|
expect(isAdmin(reformista)).toBe(false);
|
|
});
|
|
|
|
it('assertAdmin lanza si no es admin o es null', () => {
|
|
expect(() => assertAdmin(admin)).not.toThrow();
|
|
expect(() => assertAdmin(reformista)).toThrow();
|
|
expect(() => assertAdmin(null)).toThrow();
|
|
});
|
|
|
|
it('resolveTenantId devuelve el tenant del reformista y lanza para admin/null', () => {
|
|
expect(resolveTenantId(reformista)).toBe('t1');
|
|
expect(() => resolveTenantId(admin)).toThrow();
|
|
expect(() => resolveTenantId(null)).toThrow();
|
|
});
|
|
|
|
it('canAccessTenant: reformista solo el suyo, admin cualquiera', () => {
|
|
expect(canAccessTenant(reformista, 't1')).toBe(true);
|
|
expect(canAccessTenant(reformista, 't2')).toBe(false);
|
|
expect(canAccessTenant(admin, 't2')).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Ejecutar el test para verlo fallar**
|
|
|
|
Run: `npm test -- tests/auth/authz.test.ts`
|
|
Expected: FAIL con "Cannot find module '@/lib/auth/authz'".
|
|
|
|
- [ ] **Step 3: Implementación mínima**
|
|
|
|
```typescript
|
|
// src/lib/auth/authz.ts
|
|
export type Role = 'reformista' | 'admin';
|
|
export type UserStatus = 'activo' | 'deshabilitado';
|
|
|
|
export type AuthUser = {
|
|
id: string;
|
|
email: string;
|
|
nombre: string | null;
|
|
role: Role;
|
|
tenantId: string | null;
|
|
status: UserStatus;
|
|
};
|
|
|
|
export function isAdmin(user: AuthUser): boolean {
|
|
return user.role === 'admin';
|
|
}
|
|
|
|
export function assertAdmin(user: AuthUser | null): asserts user is AuthUser {
|
|
if (!user || user.role !== 'admin') {
|
|
throw new Error('Acceso restringido a administradores.');
|
|
}
|
|
}
|
|
|
|
export function resolveTenantId(user: AuthUser | null): string {
|
|
if (!user || !user.tenantId) {
|
|
throw new Error('El usuario no tiene un tenant asociado.');
|
|
}
|
|
return user.tenantId;
|
|
}
|
|
|
|
export function canAccessTenant(user: AuthUser, tenantId: string): boolean {
|
|
if (user.role === 'admin') return true;
|
|
return user.tenantId === tenantId;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Ejecutar el test para verlo pasar**
|
|
|
|
Run: `npm test -- tests/auth/authz.test.ts`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/auth/authz.ts tests/auth/authz.test.ts
|
|
git commit -m "Add decisiones de autorización puras"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Schema — users, sessions, plans y columnas de suscripción
|
|
|
|
**Files:**
|
|
- Modify: `src/db/schema.ts`
|
|
|
|
- [ ] **Step 1: Añadir enums nuevos tras los enums existentes (después de `unidadMedida`, línea ~56)**
|
|
|
|
```typescript
|
|
export const userRole = pgEnum('user_role', ['reformista', 'admin']);
|
|
export const userStatus = pgEnum('user_status', ['activo', 'deshabilitado']);
|
|
export const subscriptionStatus = pgEnum('subscription_status', [
|
|
'trial',
|
|
'activo',
|
|
'cancelado',
|
|
'vencido',
|
|
]);
|
|
```
|
|
|
|
- [ ] **Step 2: Añadir columnas de suscripción a `tenants` (dentro del objeto `tenants`, antes de `createdAt`)**
|
|
|
|
```typescript
|
|
planId: uuid('plan_id').references((): any => plans.id),
|
|
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
|
|
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
|
stripeCustomerId: text('stripe_customer_id'),
|
|
```
|
|
|
|
> `plans` se define más abajo; la referencia perezosa `(): any =>` evita el problema de orden de declaración. Mantén el resto de columnas de `tenants` intactas.
|
|
|
|
- [ ] **Step 3: Añadir tablas nuevas tras la tabla `tenants`**
|
|
|
|
```typescript
|
|
export const plans = pgTable('plans', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
slug: text('slug').notNull().unique(),
|
|
nombre: text('nombre').notNull(),
|
|
precioMensual: integer('precio_mensual').notNull(), // céntimos
|
|
leadsIncluidos: integer('leads_incluidos').notNull(),
|
|
features: jsonb('features').$type<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)**
|
|
|
|
```typescript
|
|
export type Plan = typeof plans.$inferSelect;
|
|
export type User = typeof users.$inferSelect;
|
|
export type NewUser = typeof users.$inferInsert;
|
|
export type Session = typeof sessions.$inferSelect;
|
|
```
|
|
|
|
- [ ] **Step 5: Verificar que compila**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 6: Generar la migración**
|
|
|
|
Run: `npm run db:generate`
|
|
Expected: imprime "Your SQL migration file ➜ drizzle/0001_*.sql" (o índice siguiente); el SQL crea los enums, tablas y columnas.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/db/schema.ts drizzle/
|
|
git commit -m "Add schema de users, sessions, plans y suscripción de tenant"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Sesión (DB + cookie) y helpers de usuario actual
|
|
|
|
**Files:**
|
|
- Create: `src/lib/auth/session.ts`
|
|
- Create: `src/lib/auth/current-user.ts`
|
|
- Create: `src/db/auth-queries.ts`
|
|
|
|
- [ ] **Step 1: Query de usuario por email + creación de tenant con owner**
|
|
|
|
```typescript
|
|
// src/db/auth-queries.ts
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from './index';
|
|
import { users, tenants } from './schema';
|
|
import { sessionExpiry } from '@/lib/auth/tokens';
|
|
|
|
export async function getUserByEmail(email: string) {
|
|
const [row] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function getUserById(id: string) {
|
|
const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function createTenantWithOwner(input: {
|
|
nombreEmpresa: string;
|
|
slug: string;
|
|
provincia: string | null;
|
|
email: string;
|
|
passwordHash: string;
|
|
nombre: string | null;
|
|
}) {
|
|
const [tenant] = await db
|
|
.insert(tenants)
|
|
.values({
|
|
slug: input.slug,
|
|
nombreEmpresa: input.nombreEmpresa,
|
|
provincia: input.provincia,
|
|
subscriptionStatus: 'trial',
|
|
trialEndsAt: sessionExpiry(new Date()), // placeholder TTL; ajustado abajo a 14 días
|
|
})
|
|
.returning();
|
|
|
|
const [user] = await db
|
|
.insert(users)
|
|
.values({
|
|
email: input.email,
|
|
passwordHash: input.passwordHash,
|
|
nombre: input.nombre,
|
|
role: 'reformista',
|
|
tenantId: tenant.id,
|
|
})
|
|
.returning();
|
|
|
|
return { tenant, user };
|
|
}
|
|
```
|
|
|
|
> **Nota:** el `trialEndsAt` correcto (14 días) lo fija la action de signup (Task 11) tras crear el tenant; aquí se pone un valor por defecto que la action sobreescribe. No dupliques el cálculo de 14 días en dos sitios: la action es la fuente de verdad.
|
|
|
|
- [ ] **Step 2: Implementar session.ts (cookie + DB)**
|
|
|
|
```typescript
|
|
// src/lib/auth/session.ts
|
|
import { cookies } from 'next/headers';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
import { sessions } from '@/db/schema';
|
|
import { getUserById } from '@/db/auth-queries';
|
|
import {
|
|
generateSessionToken,
|
|
hashSessionToken,
|
|
isSessionExpired,
|
|
sessionExpiry,
|
|
} from './tokens';
|
|
import type { AuthUser } from './authz';
|
|
|
|
const COOKIE = 'session';
|
|
|
|
export async function createSession(userId: string): Promise<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)**
|
|
|
|
```typescript
|
|
// src/lib/auth/current-user.ts
|
|
import { redirect } from 'next/navigation';
|
|
import { getSessionUser } from './session';
|
|
import { resolveTenantId, type AuthUser } from './authz';
|
|
|
|
export async function getCurrentUser(): Promise<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**
|
|
|
|
```bash
|
|
git add src/lib/auth/session.ts src/lib/auth/current-user.ts src/db/auth-queries.ts
|
|
git commit -m "Add ciclo de vida de sesión y helpers de usuario actual"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Migrar resolución de tenant de slug a sesión
|
|
|
|
**Files:**
|
|
- Modify: `src/db/queries.ts:13-17`
|
|
- Modify: `src/db/pricing-queries.ts:1-11,54`
|
|
- Modify: `src/app/panel/actions.ts:7,12-16`
|
|
|
|
- [ ] **Step 1: queries.ts — reemplazar getTenantId local por el de sesión**
|
|
|
|
En `src/db/queries.ts` elimina la función local `getTenantId` (líneas 13-17) y el import de `TENANT_SLUG` y `tenants` si quedan sin uso. Añade arriba:
|
|
|
|
```typescript
|
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
|
```
|
|
|
|
Quita `tenants` del import de `./schema` y `TENANT_SLUG` de `@/lib/funnel` si ya no se usan en el archivo. El resto de funciones (`getLeads`, `getLead`, `getResumen`) no cambian: siguen llamando `await getTenantId()`.
|
|
|
|
- [ ] **Step 2: pricing-queries.ts — misma sustitución, manteniendo el export**
|
|
|
|
En `src/db/pricing-queries.ts` elimina la función local `getTenantId` (líneas 7-11) y el import de `TENANT_SLUG`. Añade:
|
|
|
|
```typescript
|
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
|
```
|
|
|
|
Mantén `export { getTenantId };` al final (línea 54) para no romper a `precios/actions.ts`, que lo importa. Quita `tenants` del import de `./schema` si queda sin uso.
|
|
|
|
- [ ] **Step 3: panel/actions.ts — usar el helper de sesión**
|
|
|
|
En `src/app/panel/actions.ts` elimina la función local `getTenantId` (líneas 12-16) y el import de `TENANT_SLUG` (línea 7) y de `tenants` (línea 6). Añade:
|
|
|
|
```typescript
|
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
|
```
|
|
|
|
Las tres actions ya llaman `await getTenantId()`; no cambian.
|
|
|
|
- [ ] **Step 4: Verificar que compila y que no quedan referencias a TENANT_SLUG en queries**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: exit 0.
|
|
|
|
Run: `git grep -n "TENANT_SLUG" -- src/db src/app/panel`
|
|
Expected: sin resultados (TENANT_SLUG solo puede quedar en `src/lib/funnel.ts` y `src/db/seed.ts`).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/db/queries.ts src/db/pricing-queries.ts src/app/panel/actions.ts
|
|
git commit -m "Migrar resolución de tenant del panel a la sesión"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Login y logout
|
|
|
|
**Files:**
|
|
- Create: `src/app/login/page.tsx`
|
|
- Create: `src/app/login/actions.ts`
|
|
- Create: `src/app/logout/route.ts`
|
|
|
|
- [ ] **Step 1: Server action de login**
|
|
|
|
```typescript
|
|
// src/app/login/actions.ts
|
|
'use server';
|
|
|
|
import { redirect } from 'next/navigation';
|
|
import { getUserByEmail } from '@/db/auth-queries';
|
|
import { verifyPassword } from '@/lib/auth/password';
|
|
import { createSession } from '@/lib/auth/session';
|
|
|
|
export async function login(_prev: string | null, formData: FormData): Promise<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)**
|
|
|
|
```tsx
|
|
// src/app/login/page.tsx
|
|
'use client';
|
|
|
|
import { useActionState } from 'react';
|
|
import { login } from './actions';
|
|
|
|
export default function LoginPage() {
|
|
const [error, formAction, pending] = useActionState(login, null);
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// src/app/logout/route.ts
|
|
import { redirect } from 'next/navigation';
|
|
import { destroySession } from '@/lib/auth/session';
|
|
|
|
export async function POST() {
|
|
await destroySession();
|
|
redirect('/login');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verificar que compila**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/app/login src/app/logout
|
|
git commit -m "Add login y logout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Guard del panel + cabecera con sesión
|
|
|
|
**Files:**
|
|
- Modify: `src/app/panel/layout.tsx`
|
|
|
|
- [ ] **Step 1: Convertir el layout en async con guard y datos de sesión**
|
|
|
|
```tsx
|
|
// src/app/panel/layout.tsx
|
|
import Link from 'next/link';
|
|
import type { Metadata } from 'next';
|
|
import { requireUser } from '@/lib/auth/current-user';
|
|
import { db } from '@/db';
|
|
import { tenants } from '@/db/schema';
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Panel · Reformix',
|
|
description: 'Panel de leads del reformista',
|
|
};
|
|
|
|
export default async function PanelLayout({ children }: { children: React.ReactNode }) {
|
|
const user = await requireUser();
|
|
const [tenant] = user.tenantId
|
|
? await db.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1)
|
|
: [];
|
|
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
|
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/panel/layout.tsx
|
|
git commit -m "Proteger el panel con sesión y mostrar empresa real"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Seed — planes, admin y owner logueable
|
|
|
|
**Files:**
|
|
- Modify: `src/db/seed.ts`
|
|
|
|
- [ ] **Step 1: Añadir imports de hashing y schema de auth (tras la línea 7)**
|
|
|
|
```typescript
|
|
import { hashPassword } from '../lib/auth/password';
|
|
```
|
|
|
|
> `schema.plans`, `schema.users` ya están disponibles vía `import * as schema`.
|
|
|
|
- [ ] **Step 2: Incluir las tablas nuevas en el TRUNCATE (línea ~286)**
|
|
|
|
Reemplaza la sentencia TRUNCATE para incluir `users`, `sessions` y `plans` (las dependientes primero por CASCADE no es estricto, pero añádelas explícitamente):
|
|
|
|
```typescript
|
|
await db.execute(
|
|
sql`TRUNCATE TABLE ${schema.sessions}, ${schema.users}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.catalogItems}, ${schema.pricingConfig}, ${schema.leads}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 3: Sembrar los 3 planes antes de crear el tenant (tras el TRUNCATE)**
|
|
|
|
```typescript
|
|
console.log('Sembrando planes...');
|
|
const [starter, pro] = await db
|
|
.insert(schema.plans)
|
|
.values([
|
|
{ slug: 'starter', nombre: 'Starter', precioMensual: 2900, leadsIncluidos: 5,
|
|
features: ['5 leads procesados / mes', '3 €/lead extra', 'Hasta 100 contactos', 'Branding básico'] },
|
|
{ slug: 'pro', nombre: 'Pro', precioMensual: 7900, leadsIncluidos: 15,
|
|
features: ['15 leads procesados / mes', '2,50 €/lead extra', 'White-label completo', 'Sub-flujo licencia urbanística', 'Integraciones Holded/Stel', 'Soporte prioritario'] },
|
|
{ slug: 'business', nombre: 'Business', precioMensual: 19900, leadsIncluidos: 50,
|
|
features: ['50 leads procesados / mes', '2 €/lead extra', 'Usuarios ilimitados', 'API', 'Multi-zona', 'Custom price book', 'Dashboard analytics'] },
|
|
])
|
|
.returning();
|
|
```
|
|
|
|
- [ ] **Step 4: Asignar plan Pro + trial al tenant al crearlo (modificar el insert de `tenants`, línea ~290)**
|
|
|
|
```typescript
|
|
const [tenant] = await db
|
|
.insert(schema.tenants)
|
|
.values({
|
|
slug: 'reformas-ejemplo',
|
|
nombreEmpresa: 'Reformas Ejemplo',
|
|
provincia: 'Madrid',
|
|
whatsappBusiness: '+34 600 000 000',
|
|
planId: pro.id,
|
|
subscriptionStatus: 'trial',
|
|
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
|
})
|
|
.returning();
|
|
```
|
|
|
|
- [ ] **Step 5: Crear admin + owner logueable (tras crear el tenant, antes del bucle de leads)**
|
|
|
|
```typescript
|
|
console.log('Creando usuarios demo (admin + owner)...');
|
|
await db.insert(schema.users).values([
|
|
{
|
|
email: 'admin@reformix.es',
|
|
passwordHash: await hashPassword('AdminReformix2026!'),
|
|
nombre: 'Admin Reformix',
|
|
role: 'admin',
|
|
tenantId: null,
|
|
},
|
|
{
|
|
email: 'demo@reformas-ejemplo.es',
|
|
passwordHash: await hashPassword('DemoReformix2026!'),
|
|
nombre: 'Reformas Ejemplo',
|
|
role: 'reformista',
|
|
tenantId: tenant.id,
|
|
},
|
|
]);
|
|
```
|
|
|
|
> Credenciales demo documentadas aquí: admin `admin@reformix.es` / `AdminReformix2026!`; reformista `demo@reformas-ejemplo.es` / `DemoReformix2026!`. El `starter` sembrado no se asigna a nadie todavía; queda disponible para que el admin lo asigne.
|
|
|
|
- [ ] **Step 6: Verificar que compila**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/db/seed.ts
|
|
git commit -m "Sembrar planes y usuarios demo (admin + owner logueable)"
|
|
```
|
|
|
|
---
|
|
|
|
## FASE 2 — Signup trial
|
|
|
|
### Task 11: Schema zod del signup (puro, TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/validation/signup.ts`
|
|
- Test: `tests/validation/signup.test.ts`
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```typescript
|
|
// tests/validation/signup.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import { signupSchema, slugify } from '@/lib/validation/signup';
|
|
|
|
describe('signupSchema', () => {
|
|
it('acepta un alta válida y normaliza email a minúsculas', () => {
|
|
const r = signupSchema.safeParse({
|
|
nombre: 'Ana', email: 'Ana@X.com', empresa: 'Reformas Ana',
|
|
provincia: 'Madrid', password: 'Segura2026', optInMarketing: 'on',
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.email).toBe('ana@x.com');
|
|
});
|
|
|
|
it('rechaza email inválido y contraseña corta', () => {
|
|
expect(signupSchema.safeParse({
|
|
nombre: 'Ana', email: 'no-mail', empresa: 'X', provincia: 'Madrid', password: '123',
|
|
}).success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('slugify', () => {
|
|
it('genera slug url-safe sin acentos', () => {
|
|
expect(slugify('Reformas Ándalus, S.L.')).toBe('reformas-andalus-s-l');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Ejecutar el test para verlo fallar**
|
|
|
|
Run: `npm test -- tests/validation/signup.test.ts`
|
|
Expected: FAIL con "Cannot find module '@/lib/validation/signup'".
|
|
|
|
- [ ] **Step 3: Implementación mínima**
|
|
|
|
```typescript
|
|
// src/lib/validation/signup.ts
|
|
import { z } from 'zod';
|
|
|
|
export const signupSchema = z.object({
|
|
nombre: z.string().trim().min(1, 'Indica tu nombre.'),
|
|
email: z.string().trim().toLowerCase().email('Email no válido.'),
|
|
empresa: z.string().trim().min(1, 'Indica el nombre de tu empresa.'),
|
|
provincia: z.string().trim().min(1, 'Indica tu provincia.'),
|
|
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres.'),
|
|
optInMarketing: z.union([z.literal('on'), z.literal('')]).optional(),
|
|
});
|
|
|
|
export type SignupInput = z.infer<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**
|
|
|
|
```bash
|
|
git add src/lib/validation/signup.ts tests/validation/signup.test.ts
|
|
git commit -m "Add validación y slug del signup"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Página y action de signup trial
|
|
|
|
**Files:**
|
|
- Create: `src/app/signup/actions.ts`
|
|
- Create: `src/app/signup/page.tsx`
|
|
- Modify: `src/db/auth-queries.ts` (añadir unicidad de slug)
|
|
|
|
- [ ] **Step 1: Helper de slug único en auth-queries.ts**
|
|
|
|
Añade a `src/db/auth-queries.ts`:
|
|
|
|
```typescript
|
|
export async function slugDisponible(slug: string): Promise<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**
|
|
|
|
```typescript
|
|
// src/app/signup/actions.ts
|
|
'use server';
|
|
|
|
import { redirect } from 'next/navigation';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
import { tenants } from '@/db/schema';
|
|
import { signupSchema, slugify } from '@/lib/validation/signup';
|
|
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
|
|
import { hashPassword } from '@/lib/auth/password';
|
|
import { createSession } from '@/lib/auth/session';
|
|
|
|
const TRIAL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
|
export async function signup(_prev: string | null, formData: FormData): Promise<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)**
|
|
|
|
```tsx
|
|
// src/app/signup/page.tsx
|
|
'use client';
|
|
|
|
import { useActionState } from 'react';
|
|
import { signup } from './actions';
|
|
|
|
export default function SignupPage() {
|
|
const [error, formAction, pending] = useActionState(signup, null);
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/signup src/db/auth-queries.ts
|
|
git commit -m "Add signup trial que crea tenant y owner"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Cablear los CTA de la landing B2B a /signup
|
|
|
|
**Files:**
|
|
- Modify: `mvp/b2b/landing_reformix.html`
|
|
|
|
- [ ] **Step 1: Localizar los CTA de trial**
|
|
|
|
Run: `git grep -n -o -E "href=\"[^\"]*\"" -- mvp/b2b/landing_reformix.html | head -40`
|
|
Después busca los anclajes cuyo texto visible sea "Empieza gratis 14 días", "Pruébalo gratis 14 días" o "Empezar gratis 14 días". Para cada uno, lee el fragmento exacto con Read sobre el rango de la línea coincidente antes de editar.
|
|
|
|
- [ ] **Step 2: Apuntar cada CTA de trial a /signup**
|
|
|
|
Para cada anclaje de trial localizado, usa Edit cambiando su `href` actual (p. ej. `href="#pricing"` o `href="#signup"`) por `href="/signup"`. La landing se sirve en el mismo origen (`/b2b`), así que la ruta relativa `/signup` resuelve correctamente. No toques otros enlaces (FAQ, ancras internas de navegación).
|
|
|
|
- [ ] **Step 3: Verificar que los CTA apuntan a /signup**
|
|
|
|
Run: `git grep -n "/signup" -- mvp/b2b/landing_reformix.html`
|
|
Expected: una línea por cada CTA de trial editado.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add mvp/b2b/landing_reformix.html
|
|
git commit -m "Cablear CTAs de trial de la landing B2B a /signup"
|
|
```
|
|
|
|
> El script `make-deploy-zip.ps1` ya copia `mvp/b2b/landing_reformix.html` → `mvp/b2c/public/b2b.html` en cada build, así que el cambio llega a producción sin pasos extra.
|
|
|
|
---
|
|
|
|
## FASE 3 — Área admin
|
|
|
|
### Task 14: Queries del admin
|
|
|
|
**Files:**
|
|
- Create: `src/db/admin-queries.ts`
|
|
|
|
- [ ] **Step 1: Implementar las queries (todas asumen que el caller ya validó admin)**
|
|
|
|
```typescript
|
|
// src/db/admin-queries.ts
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from './index';
|
|
import { tenants, users, plans } from './schema';
|
|
|
|
export async function listTenants() {
|
|
return db.select().from(tenants).orderBy(tenants.createdAt);
|
|
}
|
|
|
|
export async function listUsers() {
|
|
return db.select().from(users).orderBy(users.createdAt);
|
|
}
|
|
|
|
export async function listPlans() {
|
|
return db.select().from(plans).where(eq(plans.activo, true)).orderBy(plans.precioMensual);
|
|
}
|
|
|
|
export async function assignPlan(tenantId: string, planId: string) {
|
|
await db.update(tenants).set({ planId }).where(eq(tenants.id, tenantId));
|
|
}
|
|
|
|
export async function setSubscriptionStatus(
|
|
tenantId: string,
|
|
status: (typeof tenants.subscriptionStatus.enumValues)[number]
|
|
) {
|
|
await db.update(tenants).set({ subscriptionStatus: status }).where(eq(tenants.id, tenantId));
|
|
}
|
|
|
|
export async function setUserStatus(
|
|
userId: string,
|
|
status: (typeof users.status.enumValues)[number]
|
|
) {
|
|
await db.update(users).set({ status, updatedAt: new Date() }).where(eq(users.id, userId));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verificar que compila**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/db/admin-queries.ts
|
|
git commit -m "Add queries del área admin"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Layout y dashboard del admin
|
|
|
|
**Files:**
|
|
- Create: `src/app/admin/layout.tsx`
|
|
- Create: `src/app/admin/page.tsx`
|
|
|
|
- [ ] **Step 1: Layout con guard requireAdmin**
|
|
|
|
```tsx
|
|
// src/app/admin/layout.tsx
|
|
import Link from 'next/link';
|
|
import type { Metadata } from 'next';
|
|
import { requireAdmin } from '@/lib/auth/current-user';
|
|
|
|
export const metadata: Metadata = { title: 'Admin · Reformix' };
|
|
|
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
await requireAdmin();
|
|
return (
|
|
<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**
|
|
|
|
```tsx
|
|
// src/app/admin/page.tsx
|
|
import { listTenants, listUsers, listPlans } from '@/db/admin-queries';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default async function AdminHome() {
|
|
const [tenants, users, plans] = await Promise.all([listTenants(), listUsers(), listPlans()]);
|
|
const cards = [
|
|
{ label: 'Reformistas (tenants)', value: tenants.length },
|
|
{ label: 'Usuarios', value: users.length },
|
|
{ label: 'Planes activos', value: plans.length },
|
|
{ label: 'En trial', value: tenants.filter((t) => t.subscriptionStatus === 'trial').length },
|
|
];
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/admin/layout.tsx src/app/admin/page.tsx
|
|
git commit -m "Add layout y dashboard del admin"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Gestión de usuarios (crear reformista, habilitar/deshabilitar)
|
|
|
|
**Files:**
|
|
- Create: `src/app/admin/usuarios/actions.ts`
|
|
- Create: `src/app/admin/usuarios/page.tsx`
|
|
|
|
- [ ] **Step 1: Actions (crear reformista, toggle estado)**
|
|
|
|
```typescript
|
|
// src/app/admin/usuarios/actions.ts
|
|
'use server';
|
|
|
|
import { revalidatePath } from 'next/cache';
|
|
import { requireAdmin } from '@/lib/auth/current-user';
|
|
import { signupSchema, slugify } from '@/lib/validation/signup';
|
|
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
|
|
import { setUserStatus } from '@/db/admin-queries';
|
|
import { hashPassword } from '@/lib/auth/password';
|
|
|
|
export async function crearReformista(_prev: string | null, formData: FormData): Promise<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)**
|
|
|
|
```tsx
|
|
// src/app/admin/usuarios/page.tsx
|
|
import { listUsers, listTenants } from '@/db/admin-queries';
|
|
import { toggleUsuario } from './actions';
|
|
import { CrearReformistaForm } from './CrearReformistaForm';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default async function UsuariosPage() {
|
|
const [users, tenants] = await Promise.all([listUsers(), listTenants()]);
|
|
const empresaDe = new Map(tenants.map((t) => [t.id, t.nombreEmpresa]));
|
|
return (
|
|
<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)**
|
|
|
|
```tsx
|
|
// src/app/admin/usuarios/CrearReformistaForm.tsx
|
|
'use client';
|
|
|
|
import { useActionState } from 'react';
|
|
import { crearReformista } from './actions';
|
|
|
|
export function CrearReformistaForm() {
|
|
const [error, action, pending] = useActionState(crearReformista, null);
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/admin/usuarios
|
|
git commit -m "Add gestión de usuarios en el admin"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Asignación de planes
|
|
|
|
**Files:**
|
|
- Create: `src/app/admin/planes/actions.ts`
|
|
- Create: `src/app/admin/planes/page.tsx`
|
|
|
|
- [ ] **Step 1: Actions de asignación**
|
|
|
|
```typescript
|
|
// src/app/admin/planes/actions.ts
|
|
'use server';
|
|
|
|
import { revalidatePath } from 'next/cache';
|
|
import { requireAdmin } from '@/lib/auth/current-user';
|
|
import { assignPlan, setSubscriptionStatus } from '@/db/admin-queries';
|
|
import { tenants } from '@/db/schema';
|
|
|
|
export async function asignarPlan(formData: FormData) {
|
|
await requireAdmin();
|
|
const tenantId = String(formData.get('tenantId'));
|
|
const planId = String(formData.get('planId'));
|
|
const status = String(formData.get('status')) as (typeof tenants.subscriptionStatus.enumValues)[number];
|
|
await assignPlan(tenantId, planId);
|
|
await setSubscriptionStatus(tenantId, status);
|
|
revalidatePath('/admin/planes');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Página (catálogo de planes + asignación por tenant)**
|
|
|
|
```tsx
|
|
// src/app/admin/planes/page.tsx
|
|
import { listPlans, listTenants } from '@/db/admin-queries';
|
|
import { asignarPlan } from './actions';
|
|
import { formatEuros } from '@/lib/funnel';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
const ESTADOS = ['trial', 'activo', 'cancelado', 'vencido'] as const;
|
|
|
|
export default async function PlanesPage() {
|
|
const [plans, tenants] = await Promise.all([listPlans(), listTenants()]);
|
|
const nombrePlan = new Map(plans.map((p) => [p.id, p.nombre]));
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/admin/planes
|
|
git commit -m "Add asignación de planes y estado de suscripción"
|
|
```
|
|
|
|
---
|
|
|
|
## FASE 4 — Stripe stub + display de plan
|
|
|
|
### Task 18: Cálculo de trial y badge de plan (puro, TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/billing/plan.ts`
|
|
- Test: `tests/billing/plan.test.ts`
|
|
|
|
- [ ] **Step 1: Escribir el test que falla**
|
|
|
|
```typescript
|
|
// tests/billing/plan.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import { trialDaysRemaining, formatPlanBadge } from '@/lib/billing/plan';
|
|
|
|
describe('plan', () => {
|
|
it('calcula días de trial restantes (redondeo hacia arriba, nunca negativo)', () => {
|
|
const now = new Date('2026-05-30T12:00:00Z');
|
|
expect(trialDaysRemaining(new Date('2026-06-05T12:00:00Z'), now)).toBe(6);
|
|
expect(trialDaysRemaining(new Date('2026-05-30T18:00:00Z'), now)).toBe(1);
|
|
expect(trialDaysRemaining(new Date('2026-05-29T12:00:00Z'), now)).toBe(0);
|
|
expect(trialDaysRemaining(null, now)).toBe(0);
|
|
});
|
|
|
|
it('formatea el badge según estado', () => {
|
|
const now = new Date('2026-05-30T12:00:00Z');
|
|
expect(formatPlanBadge('Pro', 'trial', new Date('2026-06-05T12:00:00Z'), now))
|
|
.toBe('Plan Pro · trial, 6 días restantes');
|
|
expect(formatPlanBadge('Pro', 'activo', null, now)).toBe('Plan Pro · activo');
|
|
expect(formatPlanBadge(null, 'trial', new Date('2026-06-05T12:00:00Z'), now))
|
|
.toBe('Sin plan · trial, 6 días restantes');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Ejecutar el test para verlo fallar**
|
|
|
|
Run: `npm test -- tests/billing/plan.test.ts`
|
|
Expected: FAIL con "Cannot find module '@/lib/billing/plan'".
|
|
|
|
- [ ] **Step 3: Implementación mínima**
|
|
|
|
```typescript
|
|
// src/lib/billing/plan.ts
|
|
export function trialDaysRemaining(trialEndsAt: Date | null, now: Date = new Date()): number {
|
|
if (!trialEndsAt) return 0;
|
|
const ms = trialEndsAt.getTime() - now.getTime();
|
|
if (ms <= 0) return 0;
|
|
return Math.ceil(ms / (24 * 60 * 60 * 1000));
|
|
}
|
|
|
|
export function formatPlanBadge(
|
|
planNombre: string | null,
|
|
status: string,
|
|
trialEndsAt: Date | null,
|
|
now: Date = new Date()
|
|
): string {
|
|
const plan = planNombre ? `Plan ${planNombre}` : 'Sin plan';
|
|
if (status === 'trial') {
|
|
return `${plan} · trial, ${trialDaysRemaining(trialEndsAt, now)} días restantes`;
|
|
}
|
|
return `${plan} · ${status}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Ejecutar el test para verlo pasar**
|
|
|
|
Run: `npm test -- tests/billing/plan.test.ts`
|
|
Expected: PASS (2 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/billing/plan.ts tests/billing/plan.test.ts
|
|
git commit -m "Add cálculo de trial y badge de plan"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: Stub de Stripe
|
|
|
|
**Files:**
|
|
- Create: `src/lib/billing/stripe.ts`
|
|
|
|
- [ ] **Step 1: Interfaz no-op documentada**
|
|
|
|
```typescript
|
|
// src/lib/billing/stripe.ts
|
|
// Stub de Stripe: sin cuenta activa, no se hacen llamadas reales (ver spec, "Fuera de alcance").
|
|
// Reservado para cuando se conecte la pasarela; cualquier uso real debe lanzar para no fingir cobros.
|
|
|
|
export const STRIPE_ENABLED = false;
|
|
|
|
export async function createCustomer(_email: string): Promise<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**
|
|
|
|
```bash
|
|
git add src/lib/billing/stripe.ts
|
|
git commit -m "Add stub de Stripe (sin cobro real)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Badge de plan en el panel del reformista
|
|
|
|
**Files:**
|
|
- Modify: `src/app/panel/page.tsx`
|
|
|
|
- [ ] **Step 1: Cargar el plan/estado del tenant y mostrar el badge**
|
|
|
|
En `src/app/panel/page.tsx`, añade imports al inicio:
|
|
|
|
```typescript
|
|
import { getCurrentTenantId } from '@/lib/auth/current-user';
|
|
import { db } from '@/db';
|
|
import { tenants, plans } from '@/db/schema';
|
|
import { eq } from 'drizzle-orm';
|
|
import { formatPlanBadge } from '@/lib/billing/plan';
|
|
import { STRIPE_ENABLED } from '@/lib/billing/stripe';
|
|
```
|
|
|
|
Dentro de `PanelPage`, tras resolver `filtro` y antes del `return`, carga el tenant y su plan:
|
|
|
|
```typescript
|
|
const tenantId = await getCurrentTenantId();
|
|
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
|
|
const [plan] = tenant?.planId
|
|
? await db.select().from(plans).where(eq(plans.id, tenant.planId)).limit(1)
|
|
: [];
|
|
const badge = formatPlanBadge(plan?.nombre ?? null, tenant?.subscriptionStatus ?? 'trial', tenant?.trialEndsAt ?? null);
|
|
```
|
|
|
|
Justo debajo del bloque del título (`<div className="flex flex-col gap-1">…</div>`, que termina antes de los filtros), inserta:
|
|
|
|
```tsx
|
|
<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**
|
|
|
|
```bash
|
|
git add src/app/panel/page.tsx
|
|
git commit -m "Mostrar badge de plan y botón de pago deshabilitado"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 21: Extender cobertura de tests a los módulos puros
|
|
|
|
**Files:**
|
|
- Modify: `vitest.config.ts:11-15`
|
|
|
|
- [ ] **Step 1: Ampliar coverage.include a los módulos puros nuevos**
|
|
|
|
```typescript
|
|
coverage: {
|
|
provider: 'v8',
|
|
include: [
|
|
'src/budget/**',
|
|
'src/lib/auth/password.ts',
|
|
'src/lib/auth/tokens.ts',
|
|
'src/lib/auth/authz.ts',
|
|
'src/lib/validation/signup.ts',
|
|
'src/lib/billing/plan.ts',
|
|
],
|
|
thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 },
|
|
},
|
|
```
|
|
|
|
> No se incluyen `session.ts`, `current-user.ts`, `stripe.ts` ni las `*-queries.ts` porque dependen de DB/cookies y no se pueden cubrir sin un Postgres en CI (RNF-MAINT-01 aplica a la lógica pura).
|
|
|
|
- [ ] **Step 2: Ejecutar la suite completa con cobertura**
|
|
|
|
Run: `npm run test:coverage`
|
|
Expected: PASS en todos los tests; cobertura ≥70% en lines/functions/statements/branches sobre los módulos incluidos.
|
|
|
|
- [ ] **Step 3: Verificar typecheck y lint globales**
|
|
|
|
Run: `npx tsc --noEmit && npm run lint`
|
|
Expected: exit 0 en ambos.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add vitest.config.ts
|
|
git commit -m "Extender cobertura de tests a auth, validación y billing"
|
|
```
|
|
|
|
---
|
|
|
|
## Verificación end-to-end (tras todas las tasks)
|
|
|
|
Con un Postgres local y `.env.local` con `DATABASE_URL`:
|
|
|
|
- [ ] `npm run db:migrate` aplica la migración nueva sin error.
|
|
- [ ] `SEED_FORCE=1 npm run db:seed` siembra planes + admin + owner.
|
|
- [ ] `npm run dev` y en el navegador:
|
|
- `/panel` sin sesión → redirige a `/login`.
|
|
- Login con `demo@reformas-ejemplo.es` / `DemoReformix2026!` → ve sus leads + badge "Plan Pro · trial, N días restantes".
|
|
- `/admin` con el reformista → redirige a `/panel`.
|
|
- Login con `admin@reformix.es` / `AdminReformix2026!` → ve `/admin`, crea un reformista, le asigna plan Starter y estado activo.
|
|
- El nuevo reformista entra y ve su panel vacío (0 leads), aislado del de Reformas Ejemplo.
|
|
- `/signup` crea una cuenta trial nueva y entra directo al panel.
|
|
- "Salir" cierra sesión y vuelve a `/login`.
|
|
|
|
---
|
|
|
|
## Notas para producción (no parte de las tasks)
|
|
|
|
El despliegue usa autodeploy Gitea→Dokploy y el `docker-entrypoint.sh` corre `db:migrate` → `db:seed` → `start`. La migración nueva se aplica sola en el próximo deploy. **El reseed con datos de auth solo ocurre si `SEED_FORCE=1` está en el env** (y hace TRUNCATE). Para poblar usuarios/planes en prod la primera vez: activar `SEED_FORCE=1`, desplegar, y **quitarlo después** para no borrar cuentas reales en cada deploy.
|
|
|