Compare commits
25 Commits
96dedaf60c
...
b84b2f37a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b84b2f37a2 | ||
|
|
4f48b1591c | ||
|
|
7565a7bf46 | ||
|
|
ac3b1cd294 | ||
|
|
f2fb6d24c6 | ||
|
|
df085b6cf1 | ||
|
|
07d41e1f6b | ||
|
|
6f86334c8a | ||
|
|
b91e1685c0 | ||
|
|
9d140d8467 | ||
|
|
795d6a7a19 | ||
|
|
aecfb2c7e3 | ||
|
|
e1f12f94c6 | ||
|
|
6add2f93ea | ||
|
|
17cd03d3c9 | ||
|
|
b776646a50 | ||
|
|
a7339b8f14 | ||
|
|
a6b77b9731 | ||
|
|
7b3b8457c1 | ||
|
|
2cc19147ff | ||
|
|
4e4cc8545e | ||
|
|
49b5910593 | ||
|
|
5fb0d571cd | ||
|
|
0f106423be | ||
|
|
902062d443 |
1811
docs/superpowers/plans/2026-05-30-auth-panel-planes.md
Normal file
1811
docs/superpowers/plans/2026-05-30-auth-panel-planes.md
Normal file
File diff suppressed because it is too large
Load Diff
173
docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Normal file
173
docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Normal 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
45
mvp/b2c/drizzle/0002_overjoyed_the_renegades.sql
Normal file
45
mvp/b2c/drizzle/0002_overjoyed_the_renegades.sql
Normal 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;
|
||||
1161
mvp/b2c/drizzle/meta/0002_snapshot.json
Normal file
1161
mvp/b2c/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1780137082579,
|
||||
"tag": "0001_bored_preak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1780162638625,
|
||||
"tag": "0002_overjoyed_the_renegades",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
10
mvp/b2c/package-lock.json
generated
10
mvp/b2c/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"postcss": "^8.5.15",
|
||||
@@ -3832,6 +3833,15 @@
|
||||
"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": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"postcss": "^8.5.15",
|
||||
|
||||
30
mvp/b2c/src/app/admin/layout.tsx
Normal file
30
mvp/b2c/src/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
mvp/b2c/src/app/admin/page.tsx
Normal file
26
mvp/b2c/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
mvp/b2c/src/app/admin/planes/actions.ts
Normal file
16
mvp/b2c/src/app/admin/planes/actions.ts
Normal 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');
|
||||
}
|
||||
61
mvp/b2c/src/app/admin/planes/page.tsx
Normal file
61
mvp/b2c/src/app/admin/planes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
mvp/b2c/src/app/admin/usuarios/CrearReformistaForm.tsx
Normal file
22
mvp/b2c/src/app/admin/usuarios/CrearReformistaForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
mvp/b2c/src/app/admin/usuarios/actions.ts
Normal file
39
mvp/b2c/src/app/admin/usuarios/actions.ts
Normal 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');
|
||||
}
|
||||
45
mvp/b2c/src/app/admin/usuarios/page.tsx
Normal file
45
mvp/b2c/src/app/admin/usuarios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
mvp/b2c/src/app/login/actions.ts
Normal file
19
mvp/b2c/src/app/login/actions.ts
Normal 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');
|
||||
}
|
||||
27
mvp/b2c/src/app/login/page.tsx
Normal file
27
mvp/b2c/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
mvp/b2c/src/app/logout/route.ts
Normal file
7
mvp/b2c/src/app/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { destroySession } from '@/lib/auth/session';
|
||||
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -3,18 +3,12 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory, tenants } from '@/db/schema';
|
||||
import { TENANT_SLUG } from '@/lib/funnel';
|
||||
import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from '@/db/schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
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];
|
||||
|
||||
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 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(precisionHistory).values({
|
||||
leadId,
|
||||
@@ -94,7 +91,7 @@ export async function recalcularPresupuesto(leadId: string) {
|
||||
desgloseSnapshot: { stage: lead.pipelineStage, result },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(leads.id, leadId));
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
|
||||
|
||||
await db.insert(leadPipelineEventos).values({
|
||||
leadId,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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 · Reformas Ejemplo',
|
||||
title: 'Panel · Reformix',
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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 className="font-extrabold tracking-tight text-black">Reformix</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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} 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';
|
||||
|
||||
@@ -27,6 +33,13 @@ export default async function PanelPage({
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -36,6 +49,18 @@ export default async function PanelPage({
|
||||
</p>
|
||||
</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 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTROS.map((f) => {
|
||||
|
||||
32
mvp/b2c/src/app/signup/actions.ts
Normal file
32
mvp/b2c/src/app/signup/actions.ts
Normal 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');
|
||||
}
|
||||
29
mvp/b2c/src/app/signup/page.tsx
Normal file
29
mvp/b2c/src/app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
mvp/b2c/src/db/admin-queries.ts
Normal file
33
mvp/b2c/src/db/admin-queries.ts
Normal 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));
|
||||
}
|
||||
53
mvp/b2c/src/db/auth-queries.ts
Normal file
53
mvp/b2c/src/db/auth-queries.ts
Normal 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;
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { pricingConfig, catalogItems, tenants } from './schema';
|
||||
import { TENANT_SLUG } from '@/lib/funnel';
|
||||
import { pricingConfig, catalogItems } from './schema';
|
||||
import type { PricingConfig, CatalogItem, ManoObraKey } 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 "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
|
||||
return tenant.id;
|
||||
}
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
||||
demolicion: 0,
|
||||
|
||||
@@ -6,15 +6,8 @@ import {
|
||||
leadEstadoHistory,
|
||||
leadPipelineEventos,
|
||||
precisionHistory,
|
||||
tenants,
|
||||
} from './schema';
|
||||
import { TENANT_SLUG } from '@/lib/funnel';
|
||||
|
||||
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;
|
||||
}
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
index,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
type AnyPgColumn,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// 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 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.
|
||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||
export const tenants = pgTable('tenants', {
|
||||
@@ -64,9 +74,53 @@ export const tenants = pgTable('tenants', {
|
||||
logoUrl: text('logo_url'),
|
||||
provincia: text('provincia'),
|
||||
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(),
|
||||
});
|
||||
|
||||
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(
|
||||
'leads',
|
||||
{
|
||||
@@ -214,3 +268,7 @@ export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
||||
export type PricingConfigRow = typeof pricingConfig.$inferSelect;
|
||||
export type CatalogItemRow = typeof catalogItems.$inferSelect;
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as schema from './schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { computeBudget } from '../budget';
|
||||
import type { BudgetInputs } from '../budget/types';
|
||||
import { hashPassword } from '../lib/auth/password';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
@@ -283,9 +284,52 @@ async function main() {
|
||||
|
||||
console.log('Limpiando datos previos...');
|
||||
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"...');
|
||||
const [tenant] = await db
|
||||
.insert(schema.tenants)
|
||||
@@ -294,9 +338,30 @@ async function main() {
|
||||
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();
|
||||
|
||||
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...`);
|
||||
for (const l of SEED_LEADS) {
|
||||
const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000);
|
||||
|
||||
33
mvp/b2c/src/lib/auth/authz.ts
Normal file
33
mvp/b2c/src/lib/auth/authz.ts
Normal 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;
|
||||
}
|
||||
25
mvp/b2c/src/lib/auth/current-user.ts
Normal file
25
mvp/b2c/src/lib/auth/current-user.ts
Normal 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);
|
||||
}
|
||||
11
mvp/b2c/src/lib/auth/password.ts
Normal file
11
mvp/b2c/src/lib/auth/password.ts
Normal 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);
|
||||
}
|
||||
70
mvp/b2c/src/lib/auth/session.ts
Normal file
70
mvp/b2c/src/lib/auth/session.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
mvp/b2c/src/lib/auth/tokens.ts
Normal file
19
mvp/b2c/src/lib/auth/tokens.ts
Normal 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);
|
||||
}
|
||||
19
mvp/b2c/src/lib/billing/plan.ts
Normal file
19
mvp/b2c/src/lib/billing/plan.ts
Normal 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}`;
|
||||
}
|
||||
12
mvp/b2c/src/lib/billing/stripe.ts
Normal file
12
mvp/b2c/src/lib/billing/stripe.ts
Normal 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).');
|
||||
}
|
||||
21
mvp/b2c/src/lib/validation/signup.ts
Normal file
21
mvp/b2c/src/lib/validation/signup.ts
Normal 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, '');
|
||||
}
|
||||
40
mvp/b2c/tests/auth/authz.test.ts
Normal file
40
mvp/b2c/tests/auth/authz.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
mvp/b2c/tests/auth/password.test.ts
Normal file
15
mvp/b2c/tests/auth/password.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
mvp/b2c/tests/auth/tokens.test.ts
Normal file
34
mvp/b2c/tests/auth/tokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
mvp/b2c/tests/billing/plan.test.ts
Normal file
21
mvp/b2c/tests/billing/plan.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
mvp/b2c/tests/validation/signup.test.ts
Normal file
25
mvp/b2c/tests/validation/signup.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,14 @@ export default defineConfig({
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user