Compare commits

..

25 Commits

Author SHA1 Message Date
Carlos Narro
b84b2f37a2 Fijar trial a 14 días en alta de tenant y reforzar filtro por tenant en escrituras 2026-05-30 20:02:02 +02:00
Carlos Narro
4f48b1591c Extender cobertura de tests a auth, validación y billing 2026-05-30 19:59:35 +02:00
Carlos Narro
7565a7bf46 Mostrar badge de plan y botón de pago deshabilitado 2026-05-30 19:58:58 +02:00
Carlos Narro
ac3b1cd294 Add stub de Stripe (sin cobro real) 2026-05-30 19:58:29 +02:00
Carlos Narro
f2fb6d24c6 Add cálculo de trial y badge de plan 2026-05-30 19:58:18 +02:00
Carlos Narro
df085b6cf1 Add asignación de planes y estado de suscripción 2026-05-30 19:57:49 +02:00
Carlos Narro
07d41e1f6b Add gestión de usuarios en el admin 2026-05-30 19:57:10 +02:00
Carlos Narro
6f86334c8a Add layout y dashboard del admin 2026-05-30 19:56:31 +02:00
Carlos Narro
b91e1685c0 Add queries del área admin 2026-05-30 19:55:33 +02:00
Carlos Narro
9d140d8467 Cablear CTAs de trial de la landing B2B a /signup 2026-05-30 19:53:03 +02:00
Carlos Narro
795d6a7a19 Add signup trial que crea tenant y owner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:50:50 +02:00
Carlos Narro
aecfb2c7e3 Add validación y slug del signup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:49:09 +02:00
Carlos Narro
e1f12f94c6 Sembrar planes y usuarios demo (admin + owner logueable)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:47:11 +02:00
Carlos Narro
6add2f93ea Proteger el panel con sesión y mostrar empresa real 2026-05-30 19:45:07 +02:00
Carlos Narro
17cd03d3c9 Usar AnyPgColumn en vez de any para la referencia forward de plan_id
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:43:59 +02:00
Carlos Narro
b776646a50 Add login y logout 2026-05-30 19:43:10 +02:00
Carlos Narro
a7339b8f14 Migrar resolución de tenant del panel a la sesión 2026-05-30 19:41:04 +02:00
Carlos Narro
a6b77b9731 Add ciclo de vida de sesión y helpers de usuario actual
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:39:31 +02:00
Carlos Narro
7b3b8457c1 Add schema de users, sessions, plans y suscripción de tenant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:37:35 +02:00
Carlos Narro
2cc19147ff Add decisiones de autorización puras
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 19:35:41 +02:00
Carlos Narro
4e4cc8545e Add generación y expiración de tokens de sesión 2026-05-30 19:34:07 +02:00
Carlos Narro
49b5910593 Add hashing y verificación de contraseña 2026-05-30 19:32:28 +02:00
Carlos Narro
5fb0d571cd Add bcryptjs para hashing de contraseñas 2026-05-30 19:30:46 +02:00
Carlos Narro
0f106423be Add plan de implementación auth + multi-tenant + admin de planes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:22:16 +02:00
Carlos Narro
902062d443 Add design spec for auth, multi-tenant y admin de planes
Diseño aprobado del módulo de login del reformista, aislamiento
multi-tenant y área admin con asignación de planes (Stripe en stub).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:09:05 +02:00
43 changed files with 4178 additions and 38 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Auth + Multi-tenant + Admin de Planes — Diseño
**Fecha:** 2026-05-30
**Estado:** Aprobado (pendiente de plan de implementación)
**Superficie:** `mvp/b2c` (Next.js 16, Tailwind v4, Drizzle, postgres.js, Vitest)
> **Nota de alcance:** Esta funcionalidad está marcada como **F1.5 / post-hackathon** en `specs.md`
> (multi-tenant real, líneas 39/259/265; pasarela de pago, línea 279). Se adelanta por decisión
> explícita del equipo (mayo 2026) tras abrir la conversación que CLAUDE.md exige para tocar F1.5.
> La demo del 11-jun **no depende** de este módulo; se construye desacoplado del pipeline de voz/render/WhatsApp.
---
## Objetivo
Convertir el panel single-tenant hardcodeado (`TENANT_SLUG` = "Reformas Ejemplo") en un SaaS con login
real, aislamiento de datos por reformista, y un área de administración que gestiona usuarios y asigna
planes. **Stripe queda como stub: sin cobro real** (no hay cuenta activa).
## Decisiones clave (confirmadas en brainstorming)
1. **Multi-tenant real:** cada reformista ve SOLO sus leads/precios/catálogo; el admin ve todos.
2. **Alta de cuentas:** self-signup trial público (RF-A-05) **+** creación/gestión por admin.
3. **Auth propio:** email+password con sesión server-side (sin librería de auth externa).
4. **Planes = etiqueta + estado, sin enforcement:** se asignan y se muestran; no bloquean funciones aún.
5. **La suscripción vive en el `tenant`** (= la cuenta), no en el `user`. Un tenant puede tener varios users.
6. **"Reformas Ejemplo" se reconvierte** en una cuenta logueable real para preservar la demo actual.
---
## Arquitectura de datos
### Tabla nueva: `users`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `email` | text unique notNull | |
| `passwordHash` | text notNull | bcryptjs |
| `nombre` | text | |
| `role` | enum `user_role` (`reformista`\|`admin`) notNull default `reformista` | |
| `tenantId` | uuid FK→tenants onDelete cascade, **nullable** | null para admin de plataforma; set para reformista |
| `status` | enum `user_status` (`activo`\|`deshabilitado`) notNull default `activo` | |
| `createdAt` / `updatedAt` | timestamptz notNull defaultNow | |
- Relación **muchos users → un tenant** (soporta "usuarios ilimitados" del plan Business sin migración futura).
- Índice único en `email`. Índice en `tenantId`.
### Tabla nueva: `sessions`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `userId` | uuid FK→users onDelete cascade | |
| `tokenHash` | text notNull | hash del token de sesión (el token en claro solo vive en la cookie) |
| `expiresAt` | timestamptz notNull | |
| `createdAt` | timestamptz notNull defaultNow | |
- Índice en `userId`. Índice/único en `tokenHash`.
- Caducidad: 30 días deslizante (se renueva en cada request válido). Sesiones expiradas se ignoran y se limpian perezosamente.
### Tabla nueva: `plans`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `slug` | text unique (`starter`\|`pro`\|`business`) | |
| `nombre` | text notNull | "Starter" / "Pro" / "Business" |
| `precioMensual` | integer notNull | céntimos: 2900 / 7900 / 19900 |
| `leadsIncluidos` | integer notNull | 5 / 15 / 50 |
| `features` | jsonb `$type<string[]>` notNull default `[]` | bullets del copy |
| `activo` | boolean notNull default true | |
Valores sembrados desde `copy/COPY-GUIDE.md` (§Pricing landing B2B):
- **Starter** — 29 €/mes · 5 leads/mes · 3 €/lead extra · hasta 100 contactos · branding básico.
- **Pro** — 79 €/mes · 15 leads/mes · 2,50 €/lead extra · white-label · sub-flujo licencia urbanística · integraciones Holded/Stel · soporte prioritario.
- **Business** — 199 €/mes · 50 leads/mes · 2 €/lead extra · usuarios ilimitados · API · multi-zona · custom price book · dashboard analytics.
### Columnas nuevas en `tenants`
| Campo | Tipo | Notas |
|---|---|---|
| `planId` | uuid FK→plans, nullable | plan asignado |
| `subscriptionStatus` | enum `subscription_status` (`trial`\|`activo`\|`cancelado`\|`vencido`) notNull default `trial` | |
| `trialEndsAt` | timestamptz nullable | now + 14 días al crear vía signup |
| `stripeCustomerId` | text nullable | reservada para Stripe; sin uso real ahora |
---
## Autenticación
- **Hashing:** `bcryptjs` (JS puro, evita builds nativos en el Docker Node-22-slim). Dep nueva justificada en el commit.
- **Sesión server-side:** token aleatorio (`crypto.randomBytes`) → se guarda **hasheado** en `sessions.tokenHash`; el token en claro va en cookie `session` (`httpOnly`, `secure`, `sameSite: lax`, `path: /`).
- **Helpers** en `src/lib/auth/`:
- `hashPassword(plain)` / `verifyPassword(plain, hash)`
- `createSession(userId)` → setea cookie · `destroySession()` → logout
- `getCurrentUser()` → lee cookie, valida sesión, devuelve user o null
- `getCurrentTenantId()` → deriva del user logueado (lanza si no hay)
- `requireUser()` / `requireAdmin()` → para Server Components / actions; redirigen si falta permiso
- **`getCurrentTenantId()` reemplaza** la resolución por `TENANT_SLUG` en `src/app/panel/actions.ts` y `src/db/pricing-queries.ts`. Las queries ya filtran por `tenantId`; solo cambia la **fuente** del id.
---
## Superficies / rutas
| Ruta | Acceso | Función |
|---|---|---|
| `/signup` | público | RF-A-05: form email+nombre+empresa+provincia+opt-in → crea `tenant` + `user` (owner, role reformista) + `subscriptionStatus=trial`, `trialEndsAt=now+14d`. Redirige a `/panel`. Cableado desde el CTA "Empieza gratis 14 días" de la landing B2B |
| `/login` | público | email+password → crea sesión → `/panel` |
| `/logout` | autenticado | destruye sesión → `/login` |
| `/panel/*` | reformista | guard en `panel/layout.tsx`: sin sesión → redirect `/login`. Muestra SOLO datos de su tenant. Badge de plan/estado en la cabecera |
| `/admin` | admin | `requireAdmin`. Lista tenants + usuarios; ver planes |
| `/admin/usuarios` | admin | crear reformista (crea tenant+user owner), habilitar/deshabilitar usuarios |
| `/admin/planes` | admin | ver catálogo de planes; **asignar plan + `subscriptionStatus`** a un tenant |
> El área admin reutiliza el estilo del panel existente (Tailwind v4, sin shadcn). Copy nuevo que no exista
> se añade primero a `copy/COPY-GUIDE.md` antes de usarse (regla CLAUDE.md).
---
## Control de acceso
- **Reformista:** todas las lecturas/mutaciones se filtran por su `tenantId` (vía `getCurrentTenantId()`). No puede acceder a leads, precios ni catálogo de otro tenant. Acceso a `/admin/*` → 403/redirect.
- **Admin** (`tenantId = null`, `role = admin`): ve y gestiona todos los tenants y usuarios; asigna planes y cambia estado de suscripción. No tiene un "panel de leads" propio (no posee tenant); opcionalmente puede impersonar/ver un tenant en modo lectura — **fuera de alcance ahora**.
---
## Planes (sin enforcement)
- El admin asigna `planId` y `subscriptionStatus` a un tenant desde `/admin/planes`.
- `/panel` muestra un badge: p. ej. *"Plan Pro · trial, 9 días restantes"* (calculado desde `trialEndsAt`).
- **No se bloquea ninguna función por plan ni por caducidad de trial** en esta fase. `trialEndsAt` se calcula y se muestra pero no corta el acceso. El gating efectivo se decidirá más adelante.
- **Stripe stub:** `src/lib/billing/stripe.ts` define la interfaz (`createCustomer`, `createCheckoutSession`, etc.) pero **no hace llamadas reales**; lanza o devuelve no-op documentado. Botón "Gestionar pago" en el panel deshabilitado con copy "Próximamente". `tenants.stripeCustomerId` queda reservado.
---
## Migración y seed
- **Migración Drizzle nueva:** crea enums (`user_role`, `user_status`, `subscription_status`), tablas (`users`, `sessions`, `plans`) y columnas nuevas en `tenants`. Solo añade; no rompe datos existentes.
- **Seed** (gateado por `SEED_FORCE`, comportamiento TRUNCATE+reinsert ya existente):
- Siembra los 3 `plans` (Starter/Pro/Business).
- Crea **1 admin** (email+password de demo, documentado).
- Reconvierte "Reformas Ejemplo" en tenant con **1 user owner logueable** (email+password de demo), `planId` = Pro, `subscriptionStatus` = trial, `trialEndsAt` = now+14d — así toda la demo de leads/presupuesto actual sigue accesible tras login.
---
## Testing (Vitest)
- **Auth helpers:** `hashPassword`/`verifyPassword` (round-trip y rechazo), `createSession`/validación, caducidad de sesión.
- **Control de acceso:** reformista del tenant A no obtiene leads del tenant B; admin sí ve ambos; usuario no-admin es rechazado en acciones admin.
- **Signup:** crea tenant + user owner + trial con `trialEndsAt` correcto; email duplicado rechazado.
- **Asignación de plan:** admin asigna `planId`/`subscriptionStatus`; se refleja en el tenant.
- Cobertura objetivo de la lógica de auth/acceso alineada con RNF-MAINT-01 (≥70% en módulos nuevos críticos).
---
## Fases de implementación
1. **Auth base** — enums + tablas `users`/`sessions`; helpers `src/lib/auth/`; `/login` + `/logout`; guard en `panel/layout`; migrar `getTenantId` a sesión; seed admin + owner de "Reformas Ejemplo".
2. **Signup trial**`/signup` público + cableado desde la landing B2B (CTA "Empieza gratis 14 días"); creación tenant+owner+trial; onboarding mínimo (redirect a `/panel`).
3. **Área admin**`/admin`, `/admin/usuarios`, `/admin/planes`; seed de los 3 planes; asignación de plan/estado.
4. **Stripe stub + display de plan**`src/lib/billing/stripe.ts` (interfaz no-op); badge de plan/estado en `/panel`; botón "Gestionar pago" deshabilitado.
---
## Fuera de alcance (explícito)
- Cobro real / checkout Stripe / webhooks de facturación.
- Enforcement de límites por plan (leads/mes, renders) y bloqueo por trial vencido.
- OAuth / login social, recuperación de contraseña por email, verificación de email.
- Impersonación de tenant por el admin (vista de leads ajena).
- Gestión multi-usuario dentro de un mismo tenant desde el panel del reformista (el schema lo soporta, la UI no se construye ahora).

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,45 @@
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');--> statement-breakpoint
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');--> statement-breakpoint
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"precio_mensual" integer NOT NULL,
"leads_incluidos" integer NOT NULL,
"features" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activo" boolean DEFAULT true NOT NULL,
CONSTRAINT "plans_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"nombre" text,
"role" "user_role" DEFAULT 'reformista' NOT NULL,
"tenant_id" uuid,
"status" "user_status" DEFAULT 'activo' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "plan_id" uuid;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "trial_ends_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1780137082579, "when": 1780137082579,
"tag": "0001_bored_preak", "tag": "0001_bored_preak",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780162638625,
"tag": "0002_overjoyed_the_renegades",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "16.2.6", "next": "16.2.6",
"postcss": "^8.5.15", "postcss": "^8.5.15",
@@ -3832,6 +3833,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",

View File

@@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"next": "16.2.6", "next": "16.2.6",
"postcss": "^8.5.15", "postcss": "^8.5.15",

View File

@@ -0,0 +1,30 @@
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>
);
}

View File

@@ -0,0 +1,26 @@
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>
);
}

View File

@@ -0,0 +1,16 @@
'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');
}

View File

@@ -0,0 +1,61 @@
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>
);
}

View File

@@ -0,0 +1,22 @@
'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>
);
}

View File

@@ -0,0 +1,39 @@
'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');
}

View File

@@ -0,0 +1,45 @@
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>
);
}

View File

@@ -0,0 +1,19 @@
'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');
}

View File

@@ -0,0 +1,27 @@
'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>
);
}

View File

@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation';
import { destroySession } from '@/lib/auth/session';
export async function POST() {
await destroySession();
redirect('/login');
}

View File

@@ -3,18 +3,12 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { db } from '@/db'; import { db } from '@/db';
import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory, tenants } from '@/db/schema'; import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel'; import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries'; import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget'; import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types'; import type { BudgetInputs } from '@/budget/types';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error('Tenant no encontrado.');
return tenant.id;
}
type Estado = (typeof leads.estado.enumValues)[number]; type Estado = (typeof leads.estado.enumValues)[number];
export async function cambiarEstado(leadId: string, estado: Estado) { export async function cambiarEstado(leadId: string, estado: Estado) {
@@ -51,7 +45,10 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
const finalCents = Math.round(precioFinalEuros * 100); const finalCents = Math.round(precioFinalEuros * 100);
const deltaPct = ((finalCents - lead.presupuestoEstimado) / lead.presupuestoEstimado) * 100; const deltaPct = ((finalCents - lead.presupuestoEstimado) / lead.presupuestoEstimado) * 100;
await db.update(leads).set({ estado: 'ganado', updatedAt: new Date() }).where(eq(leads.id, leadId)); await db
.update(leads)
.set({ estado: 'ganado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'ganado' }); await db.insert(leadEstadoHistory).values({ leadId, estado: 'ganado' });
await db.insert(precisionHistory).values({ await db.insert(precisionHistory).values({
leadId, leadId,
@@ -94,7 +91,7 @@ export async function recalcularPresupuesto(leadId: string) {
desgloseSnapshot: { stage: lead.pipelineStage, result }, desgloseSnapshot: { stage: lead.pipelineStage, result },
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(leads.id, leadId)); .where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadPipelineEventos).values({ await db.insert(leadPipelineEventos).values({
leadId, leadId,

View File

@@ -1,12 +1,22 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Metadata } from 'next'; 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 = { export const metadata: Metadata = {
title: 'Panel · Reformas Ejemplo', title: 'Panel · Reformix',
description: 'Panel de leads del reformista', description: 'Panel de leads del reformista',
}; };
export default function PanelLayout({ children }: { children: React.ReactNode }) { 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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200"> <header className="sticky top-0 z-10 bg-white border-b border-gray-200">
@@ -17,15 +27,14 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
</span> </span>
<span className="font-extrabold tracking-tight text-black">Reformix</span> <span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="text-gray-300">/</span> <span className="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span> <span className="text-sm font-medium text-gray-600">{nombreEmpresa}</span>
</Link> </Link>
<nav className="flex items-center gap-4 text-xs font-medium"> <nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/panel" className="text-gray-500 hover:text-black"> <Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
Leads <Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
</Link> <form action="/logout" method="post">
<Link href="/panel/precios" className="text-gray-500 hover:text-black"> <button type="submit" className="text-gray-500 hover:text-black">Salir</button>
Precios </form>
</Link>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -9,6 +9,12 @@ import {
formatEuros, formatEuros,
formatFecha, formatFecha,
} from '@/lib/funnel'; } from '@/lib/funnel';
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';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -27,6 +33,13 @@ export default async function PanelPage({
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]); const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
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);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -36,6 +49,18 @@ export default async function PanelPage({
</p> </p>
</div> </div>
<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>
{/* Filtros por estado */} {/* Filtros por estado */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{FILTROS.map((f) => { {FILTROS.map((f) => {

View File

@@ -0,0 +1,32 @@
'use server';
import { redirect } from 'next/navigation';
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';
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 { user } = await createTenantWithOwner({
nombreEmpresa: data.empresa,
slug,
provincia: data.provincia,
email: data.email,
passwordHash,
nombre: data.nombre,
});
await createSession(user.id);
redirect('/panel');
}

View File

@@ -0,0 +1,29 @@
'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>
);
}

View File

@@ -0,0 +1,33 @@
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));
}

View File

@@ -0,0 +1,53 @@
import { eq } from 'drizzle-orm';
import { db } from './index';
import { users, tenants } from './schema';
const TRIAL_MS = 14 * 24 * 60 * 60 * 1000;
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: new Date(Date.now() + TRIAL_MS),
})
.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 };
}
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;
}

View File

@@ -1,14 +1,8 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from './index'; import { db } from './index';
import { pricingConfig, catalogItems, tenants } from './schema'; import { pricingConfig, catalogItems } from './schema';
import { TENANT_SLUG } from '@/lib/funnel';
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types'; import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
return tenant.id;
}
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = { const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
demolicion: 0, demolicion: 0,

View File

@@ -6,15 +6,8 @@ import {
leadEstadoHistory, leadEstadoHistory,
leadPipelineEventos, leadPipelineEventos,
precisionHistory, precisionHistory,
tenants,
} from './schema'; } from './schema';
import { TENANT_SLUG } from '@/lib/funnel'; import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
return tenant.id;
}
export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos'; export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos';

View File

@@ -11,6 +11,7 @@ import {
index, index,
doublePrecision, doublePrecision,
uniqueIndex, uniqueIndex,
type AnyPgColumn,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano. // Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
@@ -55,6 +56,15 @@ export const categoriaMaterial = pgEnum('categoria_material', [
export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']); export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']);
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',
]);
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded. // Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello. // Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', { export const tenants = pgTable('tenants', {
@@ -64,9 +74,53 @@ export const tenants = pgTable('tenants', {
logoUrl: text('logo_url'), logoUrl: text('logo_url'),
provincia: text('provincia'), provincia: text('provincia'),
whatsappBusiness: text('whatsapp_business'), whatsappBusiness: text('whatsapp_business'),
planId: uuid('plan_id').references((): AnyPgColumn => plans.id),
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
stripeCustomerId: text('stripe_customer_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}); });
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)]
);
export const leads = pgTable( export const leads = pgTable(
'leads', 'leads',
{ {
@@ -214,3 +268,7 @@ export type PrecisionHistory = typeof precisionHistory.$inferSelect;
export type PricingConfigRow = typeof pricingConfig.$inferSelect; export type PricingConfigRow = typeof pricingConfig.$inferSelect;
export type CatalogItemRow = typeof catalogItems.$inferSelect; export type CatalogItemRow = typeof catalogItems.$inferSelect;
export type NewCatalogItem = typeof catalogItems.$inferInsert; export type NewCatalogItem = typeof catalogItems.$inferInsert;
export type Plan = typeof plans.$inferSelect;
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Session = typeof sessions.$inferSelect;

View File

@@ -5,6 +5,7 @@ import * as schema from './schema';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { computeBudget } from '../budget'; import { computeBudget } from '../budget';
import type { BudgetInputs } from '../budget/types'; import type { BudgetInputs } from '../budget/types';
import { hashPassword } from '../lib/auth/password';
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
if (!connectionString) { if (!connectionString) {
@@ -283,9 +284,52 @@ async function main() {
console.log('Limpiando datos previos...'); console.log('Limpiando datos previos...');
await db.execute( await db.execute(
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.tenants} RESTART IDENTITY CASCADE` sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
); );
console.log('Sembrando planes...');
const [, 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();
console.log('Creando tenant "Reformas Ejemplo"...'); console.log('Creando tenant "Reformas Ejemplo"...');
const [tenant] = await db const [tenant] = await db
.insert(schema.tenants) .insert(schema.tenants)
@@ -294,9 +338,30 @@ async function main() {
nombreEmpresa: 'Reformas Ejemplo', nombreEmpresa: 'Reformas Ejemplo',
provincia: 'Madrid', provincia: 'Madrid',
whatsappBusiness: '+34 600 000 000', whatsappBusiness: '+34 600 000 000',
planId: pro.id,
subscriptionStatus: 'trial',
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
}) })
.returning(); .returning();
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,
},
]);
console.log(`Insertando ${SEED_LEADS.length} leads...`); console.log(`Insertando ${SEED_LEADS.length} leads...`);
for (const l of SEED_LEADS) { for (const l of SEED_LEADS) {
const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000); const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000);

View File

@@ -0,0 +1,33 @@
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;
}

View File

@@ -0,0 +1,25 @@
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);
}

View File

@@ -0,0 +1,11 @@
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);
}

View File

@@ -0,0 +1,70 @@
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,
};
}

View File

@@ -0,0 +1,19 @@
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);
}

View File

@@ -0,0 +1,19 @@
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}`;
}

View File

@@ -0,0 +1,12 @@
// 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).');
}

View File

@@ -0,0 +1,21 @@
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, '');
}

View File

@@ -0,0 +1,40 @@
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);
});
});

View File

@@ -0,0 +1,15 @@
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);
});
});

View File

@@ -0,0 +1,34 @@
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);
});
});

View File

@@ -0,0 +1,21 @@
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');
});
});

View File

@@ -0,0 +1,25 @@
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');
});
});

View File

@@ -10,7 +10,14 @@ export default defineConfig({
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['src/budget/**'], 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 }, thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 },
}, },
}, },