27 KiB
Owner con múltiples RFCs y suscripción por tenant
Estado: ✅ IMPLEMENTADO COMPLETO (2026-04-14) — fases 1-6 cerradas. F6.1 ✅ (auth via memberships), F6.2 ✅ (usuarios.service via memberships), F6.3 ✅ (subscription owner queries via memberships), F6.4 ✅ (drop User.tenantId/User.rolId del schema). Refactor multi-tenant terminado, deuda dual eliminada. Ver sección final "Progreso por fase".
Problema
Modelo actual asume 1:1:1 entre Usuario-Owner, Tenant y RFC:
User ──(belongs_to)──> Tenant ──(1:1)──> RFC
│
└──(has_many)──> Subscription (una activa a la vez)
En la práctica, un dueño de múltiples empresas (caso común en México: contador o empresario con 2-5 RFCs) hoy tendría que:
- Crear un user distinto por cada RFC
- Darse login distinto en cada uno
- Pagar por separado sin visibilidad consolidada
Además, si el mismo dueño registra un segundo RFC, el sistema actual le da 30 días gratis otra vez a ese nuevo RFC — el gate de trial_usages bloquea el mismo RFC, pero no relaciona "este humano ya tuvo trial antes" porque cada tenant es un user distinto para el sistema.
Propuesta
Modelo target
User ──(has_many via TenantMembership)──> Tenant ──(1:1)──> RFC
│
└──(has_many)──> Subscription
model TenantMembership {
id Int
userId String @map("user_id")
tenantId String @map("tenant_id")
rolId Int @map("rol_id") // Rol dentro de este tenant (owner/cfo/contador/...)
isOwner Boolean @default(false) @map("is_owner") // Fast-lookup de "quién es owner de este tenant"
joinedAt DateTime @default(now()) @map("joined_at")
active Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
rol Rol @relation(fields: [rolId], references: [id])
@@unique([userId, tenantId])
@@index([userId, active])
@@index([tenantId, active])
@@map("tenant_memberships")
}
User.tenantId / User.rolId se eliminan (o quedan como "default tenant" para UX al login). La relación verdadera vive en TenantMembership.
Un user puede pertenecer a N tenants, con rol distinto en cada uno:
| user | tenant | rol | isOwner |
|---|---|---|---|
| carlos | Empresa A (RFC X) | owner | ✓ |
| carlos | Empresa B (RFC Y) | owner | ✓ |
| carlos | Empresa C (RFC Z) | cfo | — |
| ivan | Empresa A (RFC X) | contador | — |
Suscripción sigue siendo por-tenant
Cada Tenant tiene su propia Subscription. Como hoy. No cambia.
La diferencia está en el acceso: el owner de múltiples tenants ve cada suscripción desde la misma sesión, vía un selector en la UI.
Regla de trial al agregar RFC nuevo
Cuando un Owner existente agrega un RFC nuevo (crea un tenant nuevo via flow "Agregar empresa"):
- Se crea el Tenant + TenantMembership(userId=owner, rolId=owner, isOwner=true)
- Al llamar
startTrial, además del check por RFC, se agrega un check por Owner:
// Ya existe: RFC en trial_usages → bloquea
// Nuevo: ¿este user (owner) ya tiene otro tenant con trial consumido?
const priorOwned = await prisma.tenantMembership.findFirst({
where: {
userId: ownerUserId,
isOwner: true,
tenant: { trialEndsAt: { not: null } },
NOT: { tenantId: newTenantId },
},
});
if (priorOwned) {
throw new Error(
'Ya consumiste una prueba gratuita con otro RFC. Los RFCs adicionales ' +
'requieren contratar un plan directamente.'
);
}
La lógica es simétrica con la del RFC: "una prueba por humano, no por empresa".
Se puede expresar como una vista o tabla nueva owner_trial_consumed(user_id) para query más rápida, pero para MVP el join está bien.
Impacto
Backend
Auth:
- Login response ya no incluye
tenantIdcomo single value — incluyetenants: [{ id, nombre, rfc, rol, isOwner }]+activeTenantId(default: el último usado o el primero) - JWT lleva
userId+activeTenantId - Endpoint nuevo
POST /auth/switch-tenantque regenera JWT con nuevoactiveTenantId(validando que el user sí es miembro)
Middleware:
authenticateya lo hace bien (decodifica JWT)tenantMiddlewareresuelvereq.activeTenantId = req.user.activeTenantIdX-View-Tenantde admin global sigue funcionando igual (impersonación)
Services y controllers:
- Donde hoy usan
req.user.tenantId, usarreq.user.activeTenantIdoeffectiveTenantId(req) - Query de "todas las suscripciones del owner actual":
SELECT s.* FROM subscriptions s JOIN tenant_memberships tm ON tm.tenant_id = s.tenant_id WHERE tm.user_id = ? AND tm.is_owner = true createTenantnuevo flow: lo llama el owner desde UI self-serve, NO solo admin global. Resultado: nuevo Tenant + TenantMembership del caller como owner.
Trial check ampliado:
- El check existente de
trial_usages.rfcse mantiene - Se agrega el check de "owner con otro tenant trial-consumed" descrito arriba
Frontend
Auth store:
{
user: { id, email, nombre },
tenants: [{ id, nombre, rfc, rol, isOwner }],
activeTenantId: string,
activeTenant: computed,
}
Tenant switcher:
- Se agrega un dropdown visible en el header para CUALQUIER user con
tenants.length > 1(no solo admin global) - Seleccionar otro tenant → llama
POST /auth/switch-tenant→ nuevo JWT → refresca pages - Admin global mantiene su X-View-Tenant para ver OTROS tenants ajenos (comportamiento distinto)
Navegación:
- Toda page actual pasa a trabajar con el
activeTenantId. No hay cambio semántico — solo cambia dónde viene el valor.
Página /mis-empresas (nueva):
- Lista las empresas del owner autenticado
- Cada fila: nombre, RFC, plan, estado de suscripción (activa/pending/cancelled/etc.), siguiente cobro
- Acciones por fila: "Gestionar" (switch a ese tenant) | "Ver suscripción" (ir al
/configuracion/suscripciondel tenant) - Botón "+ Agregar empresa" → flow de crear nuevo RFC
Flujo "Agregar empresa":
- Form pide: nombre, RFC, plan inicial
- Backend valida RFC (no duplicado), crea Tenant + TenantMembership owner para el user actual
- Redirect a
/configuracion/suscripciondel nuevo tenant → el owner contrata un plan (sin trial disponible por el check) - Desde ahí flujo normal de subscribe
Schema central — migración
Una migración pesada en 3 pasos:
-- 1. Crear tabla tenant_memberships
CREATE TABLE tenant_memberships (...);
-- 2. Copiar los users existentes al formato nuevo
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, joined_at, active)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre = 'owner'), u.created_at, u.active
FROM users u
JOIN roles r ON u.rol_id = r.id;
-- 3. (Opcional, después de verificar) eliminar u.tenant_id y u.rol_id
-- ALTER TABLE users DROP COLUMN tenant_id;
-- ALTER TABLE users DROP COLUMN rol_id;
-- Dejar por un tiempo para compatibilidad
Mantener User.tenantId como "default" (el último tenant usado) es útil para UX — al login, ir directo al dashboard de ese tenant en vez de mostrar un selector.
Riesgos
- Massive touchpoint. Cualquier endpoint que asumió
req.user.tenantIdcomo única identidad de tenant activo se puede romper sutilmente. Requiere audit completo. Estimación: ~40-60 archivos con cambios. - Sessions existentes rotas. Los JWT no llevan
activeTenantIdhoy — al primer deploy, todos los sessions activos se invalidan. Mitigar con announce + re-login forzado. - Invalidación de cache y query keys. React Query usa
tenantIden query keys — al cambiar de tenant activo, tienen que re-fetchear. Ya existe patrón conviewingTenantIdde admin global (similar). - Pagos en MP. Cada tenant tiene su propio preapproval. Un owner con 3 tenants tiene 3 preapprovals activos, 3 tarjetas o la misma tarjeta 3 veces. Separación de billing es buena fiscalmente (cada RFC tiene su CFDI de su suscripción) — no hay problema pero hay que estar conscientes.
- Trial por Owner: escenario borde — ¿qué pasa si un Owner le cede su tenant a otra persona? El trial de ese tenant ya se consumió (el nuevo user lo ve como consumed), pero si el nuevo user crea otro tenant, ¿debería recibir trial? Sí — es un Owner nuevo sin historia. Condición: check en tabla
trial_usagespor RFC + check porTenantMembership.userIdcomo owner con tenant trial-consumed.
Alcance estimado
| Área | Estimación |
|---|---|
| Schema + migración | 1 día |
| Backend auth + switching | 1 día |
| Backend controllers/services refactor | 2 días |
| Frontend store + UI tenant switcher | 1 día |
Frontend nuevas páginas (/mis-empresas, + Agregar empresa) |
1 día |
| Testing + regression | 1-2 días |
| Total | ~7-8 días calendario |
Scope similar a la implementación completa de suscripciones self-serve que se hizo en v0.9.1.
Beneficios
- Experiencia mejor para contadores y grupos empresariales (un login, muchas empresas)
- Eliminación del hack "crear user distinto por cada RFC"
- Cierre del hueco de abuso de trial por Owner
- Base para features futuras: reports consolidados, dashboard "todas mis empresas"
Decisiones que posponemos hasta implementar
- ¿Un owner puede tener múltiples roles en el mismo tenant? (Probablemente no — un membership por user-tenant)
- ¿Transferencia de ownership entre users? ¿Con aprobación del nuevo owner?
- ¿Límite de tenants por user? (Probablemente no, pero podría haber spam)
- ¿Invitación de nuevo user a un tenant existente genera email + link? (Probablemente sí, ya hay infra de email)
- ¿Owner puede cancelar membership de otros users? (Sí, es dueño del tenant)
Archivos a tocar cuando se implemente
Backend
apps/api/prisma/schema.prisma—TenantMembershipmodel,User.tenantId/User.rolIddeprecadosapps/api/prisma/seed.ts— migración idempotente que llenatenant_membershipsdesde los users existentesapps/api/src/services/auth.service.ts— login response contenants[], endpointswitch-tenantapps/api/src/services/tenants.service.ts— flujo de agregar tenant nuevo por owner no-admin-globalapps/api/src/services/payment/subscription.service.ts— check ampliado enstartTrial(RFC + Owner)apps/api/src/middlewares/tenant.middleware.ts— resolveractiveTenantIdde forma nuevaapps/api/src/utils/token.ts— JWT payload extendido- Todos los controllers que hoy hacen
req.user.tenantId— audit y cambio areq.user.activeTenantIdo helper
Frontend
apps/web/stores/auth-store.ts— shape nuevoapps/web/components/tenant-selector.tsx— ampliar a owners multi-tenant, no solo admin globalapps/web/app/(dashboard)/mis-empresas/page.tsx— nuevaapps/web/app/(dashboard)/mis-empresas/nueva/page.tsx— nueva (o modal)apps/web/app/(auth)/register/page.tsx— review (registro inicial crea user + primer tenant como owner)apps/web/lib/api/tenants.ts— endpoints nuevos
Docs
CLAUDE.md— sección multi-tenant actualizadaREADME.md— changelog- Doc de implementación al final
Relación con otros planes
2026-04-14-platform-admin-roles.mdes complementario: staff interno obtiene permisos transversales viaUserPlatformRole; owners manejan sus propios tenants viaTenantMembership. Ambos se pueden implementar independientemente pero suman capa de claridad en autorización.2026-04-14-trial-abuse-prevention.mdes precursor: agrega check por RFC. Este plan extiende a check por Owner, cubriendo un segundo vector de abuso.
Progreso por fase
✅ Fase 1 — Schema + backfill (commit 7a80db1, 2026-04-14)
- Nuevo modelo
TenantMembershipenprisma/schema.prismacon@@unique([userId, tenantId])+ índices[userId, active]y[tenantId, active]. FK cononDelete: Cascadedesde User y Tenant. - Relaciones agregadas en User, Tenant, Rol.
User.tenantIdyUser.rolIdse mantienen (default tenant para UX al login).- Backfill idempotente en
prisma/seed.ts:Verificación local: 5 memberships (3 owners, 2 no-owners) cubriendo admin global + demo + test.INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at) SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at FROM users u JOIN roles r ON u.rol_id = r.id ON CONFLICT (user_id, tenant_id) DO NOTHING
Non-breaking: todos los consumidores siguen usando User.tenantId como antes.
✅ Fase 2 — Auth devuelve tenants[] + switch-tenant (commit c333ae2, 2026-04-14)
- Shared types:
TenantMembershipinterface (id, nombre, rfc, plan, role, isOwner)UserInfo.tenants?: TenantMembership[](opcional, backward compat)
- Helper nuevo
apps/api/src/utils/memberships.ts:getUserTenants(userId)— lista memberships activos con tenant+rol joineados, filtra tenants desactivadosverifyMembership(userId, tenantId)— valida acceso antes de emitir JWT
auth.service.ts:login()ahora poblauser.tenants[]víagetUserTenants()switchTenant({ userId, currentRefreshToken, targetTenantId }):- Valida membership activa en el target (403 si no)
- Invalida el refresh token actual (deleteMany idempotente)
- Emite nuevo par de tokens con
roledel target tenant - Audit event
user.tenant_switchedconfrom+to+targetRfc - Retorna
LoginResponsecompleto
POST /auth/switch-tenant(authenticated) con zod{ tenantId: uuid, refreshToken: string }.- Verificado con curl:
admin@demo.com→tenants: [{ id, nombre: "Empresa Demo SA de CV", rfc: "EDE123456AB1", plan: "business_ia", role: "owner", isOwner: true }].
Non-breaking: JWT sigue con tenantId single; frontend aún no consume tenants[].
✅ Fase 3 — Tenant switcher en UI (commit 6ce7daf, 2026-04-14)
- Componente nuevo
apps/web/components/membership-switcher.tsx:- Visible solo si
user.tenants.length > 1Y NO es admin global (admin global usaTenantSelectorpara impersonar viaX-View-Tenant— modelo distinto) - Dropdown muestra cada tenant con nombre, RFC, role, y corona dorada si
isOwner - Tenant activo marcado con
bg-primary/10+ check
- Visible solo si
- Click en otra empresa:
POST /auth/switch-tenant { tenantId, refreshToken }setTokens()con el par nuevo (el refresh anterior queda revocado server-side)setUser()con la nuevaLoginResponse(incluyetenants[]actualizado)queryClient.clear()+window.location.reload()para que React Query re-fetche desde cero con el JWT del nuevo tenant
- API client:
switchTenant(tenantId)enapps/web/lib/api/auth.ts auth-storeno requirió cambios —setUser(response.user)ya guardatenants[]automáticamente porque vive dentro deUserInfo
Coexistencia clara con TenantSelector existente:
- Owner regular con multi-membership →
MembershipSwitcher(cambia JWT real) - Admin global →
TenantSelector(impersonación, los demás tenants no son suyos)
Para probar antes de Fase 4, se puede insertar una membership manual en SQL:
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, t.id, u.rol_id, true, true, NOW()
FROM users u, tenants t
WHERE u.email='admin@demo.com' AND t.rfc='TEST123456XX1';
✅ Fase 4 — /mis-empresas + "Agregar empresa" (commits e0ef001, follow-up access control fix)
Backend (tenants.service.ts):
addTenantToOwner({ userId, nombre, rfc, plan? }): crea tenant nuevo + membership owner para el user existente. Subscription pending.getMyTenantsDetailed(userId, onlyOwner = true): lista memberships con subscription joined (plan, status, currentPeriodEnd, pendingPlan, pendingEffectiveAt, amount, frequency). DefaultonlyOwner=true— solo muestra tenants donde el user es owner.
Backend (controller + routes):
GET /api/tenants/mine— lista filtrada (solo owner).POST /api/tenants/mine— gateado porisOwnerSomewhere(userId): si el user no es owner en ningún tenant, devuelve 403. Esto evita que un contador invitado a una empresa ajena cree RFCs nuevos.
Backend (utils/memberships.ts):
isOwnerSomewhere(userId)helper nuevo — query optimizado (LIMIT 1) para el gate.
Consistencia de memberships:
register(),createTenant()(admin global) yinviteUser()ahora creanTenantMembershipautomáticamente junto conUser. Los users invitados via/usuariosson siempreisOwner=false.
Frontend (/mis-empresas):
- Cards por empresa: nombre, RFC, corona dorada si isOwner, plan, badge de status, próximo cobro, pending changes.
- Botones "Ir a esta empresa" (switch + reload) y "Ver suscripción".
- Modal "Agregar empresa": form RFC + nombre + plan, copy explica que no hay trial para RFCs adicionales (preludio a fase 5).
Frontend (sidebar):
- Item "Mis empresas" usa nuevo flag
requireOwnerSomewhere(noroles[]). Visible siuser.tenants.some(t => t.isOwner). Esto asegura que un user con rol activocontador(porque está en una empresa ajena) pero owner en otra, siga viendo el link.
Casuística cubierta:
- Owner con 1 empresa → ve "Mis empresas" + puede agregar más.
- Contador puro (sin tenant propio) → no ve "Mis empresas",
GET /tenants/mineretorna[],POSTretorna 403. - Contador-Owner híbrido (owner en empresa A, contador en empresa B): ve "Mis empresas" desde cualquier contexto activo, en la página solo aparece A; en el header switcher aparecen ambas.
Bug fix descubierto durante implementación: había 2 clases AppError distintas (utils/errors.ts vs middlewares/error.middleware.ts); el middleware solo reconocía la suya, así que un throw new AppError(403, …) desde controllers que importaran de utils caía a 500. Tenants controller migrado a la versión del middleware. Nota técnica: queda deuda en otros controllers que importan de utils/errors.ts — convergir a una sola fuente en otra pasada.
Verificación:
GET /tenants/mine admin@demo.com → [{Empresa Demo, isOwner:true}]
GET /tenants/mine contador@demo.com → []
POST /tenants/mine contador@demo.com → 403 "Solo los dueños pueden registrar..."
POST /tenants/mine admin@demo.com → 201 con tenant nuevo
✅ Fase 5 — Trial check por Owner (commit 437ef6c, 2026-04-14)
startTrial({ tenantId, plan, frequency, ownerUserId? }):
- Nuevo param opcional
ownerUserId. Si se pasa, agrega el gate 4:const ownedTenantWithTrial = await prisma.tenantMembership.findFirst({ where: { userId: params.ownerUserId, isOwner: true, active: true, tenantId: { not: params.tenantId }, tenant: { trialEndsAt: { not: null } }, }, select: { tenant: { select: { rfc: true } } }, }); if (ownedTenantWithTrial) throw new Error(`Ya consumiste... (${rfc})`); - Mensaje cita el RFC del trial previo para que el user entienda por qué se bloqueó.
Es opcional para no romper otros callers (scripts admin, bootstrap). El controller startMyTrial siempre lo pasa con req.user.userId. El handler de errores agrega "Ya consumiste" / "ya consumió" a los mensajes reconocidos como 400 (no 500).
Combinado con el gate por RFC pre-existente (trial_usages):
- Mismo RFC en distintos tenants → bloqueado por
trial_usages - Mismo humano con distintos RFCs → bloqueado por owner gate
Verificación E2E:
admin@demo.com(owner EDE con trial usado) crea TRT conPOST /tenants/mine- switch-tenant a TRT (membership owner)
POST /subscriptions/me/trial→ 400 "Ya consumiste una prueba gratuita con otro RFC (EDE123456AB1). Cada dueño tiene derecho a una sola prueba de 30 días..."
🚧 Fase 6 — Cleanup User.tenantId/User.rolId (en progreso)
Refactor para eliminar la deuda dual del modelo viejo (User → Tenant 1:1). Sub-fases:
✅ F6.1 — Auth resuelve tenant activo via memberships (commit fbf0f5a, 2026-04-14)
Schema:
- Nuevo campo
User.lastTenantId String?— tracker del último tenant activo. Persiste UX "remember last tenant" sin depender deUser.tenantId.
auth.service.ts:
login(): ya no cargaUser.tenant/User.rol. ResuelveactiveMembershipdesdetenant_memberships:- Si
lastTenantIdset Y user tiene membership activa ahí → ese - Sino → primer membership por
joinedAtASC - Sino → 401 "No tienes acceso a ninguna empresa activa"
El JWT lleva
role/tenantIdderivados de la membership activa. Cada login persistelastTenantId = tenant activo elegido.
- Si
refreshTokens(): re-valida que el user sigue teniendo membership activa en el tenant del JWT. Si lo removieron, cae al primer membership disponible.switchTenant(): persistetargetTenant.idenUser.lastTenantIdantes de emitir tokens.
Verificación E2E:
- Login
admin@demo.com(lastTenantId null) → EDE (primer membership) - switch-tenant TEST →
lastTenantId = TEST - Re-login → cae directo en TEST con role=contador
Bug fix asociado (auth-store): durante el testing apareció un 403 cuando un user no-admin entraba después de que un admin global hubiera usado el TenantSelector en el mismo browser. El localStorage horux-tenant-view quedaba huérfano y el siguiente user heredaba X-View-Tenant, que el tenantMiddleware rechaza si el caller no es admin global. Fix: auth-store.logout() ahora borra ese key del localStorage. Pre-existente, no introducido por F6.1.
✅ F6.2 — Refactor usuarios.service.ts para listar via memberships (commit 010d756, 2026-04-14)
usuarios.service.ts ya no consume User.tenantId/rolId para listar/invitar/borrar. Todo va por tenant_memberships:
getUsuarios(tenantId)— query memberships activos del tenant;rolereflejamembership.rol.nombre(per-tenant).inviteUsuario(tenantId, data):- Si el email ya existe como user global → agrega membership en este tenant en vez de crear duplicado. Cubre el caso "contador X ya trabaja en otra empresa, ahora me invitan a la mía".
- Limit check via
tenant_memberships.countdel tenant, noUser.countglobal. - Upsert por
(userId, tenantId)— re-invitación tras delete reactiva la membership.
updateUsuario(tenantId, userId, data)—rolecambia per-tenant (membership.rolId);activeynombreson globales del user.deleteUsuario(tenantId, userId)—prisma.tenantMembership.deleteMany({where:{userId, tenantId}})— soft-delete por tenant. El user sigue si tiene otros memberships activos.getAllUsuarios()(admin global) — lista por(user, tenant). Un user con N memberships aparece N veces. Cada row con su tenant explícito.updateUsuarioGlobal(userId, data)— si pasatenantId, role cambia esa membership; active es global.deleteUsuarioGlobal(userId)— hard-delete user + cascade limpia memberships.
User.tenantId/rolId se siguen poblando al crear (constraint NOT NULL del schema). F6.4 los borra.
Shared types: UserListItem.role widened de 'admin'|'contador'|'visor' a Role (incluye owner/cfo/auxiliar). El tipo viejo era pre-rename y no reflejaba la realidad.
Verificación E2E: GET /api/usuarios as admin@demo.com (contexto TEST) → [contador (owner), admin (contador), test (owner)] — roles correctos per-tenant.
✅ F6.3 — Refactor subscription.service.ts queries de owner via memberships (commit b6ec37b, 2026-04-14)
Helper nuevo en utils/memberships.ts:
export async function getTenantOwnerEmail(tenantId: string): Promise<string | null> {
const m = await prisma.tenantMembership.findFirst({
where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true } } },
orderBy: { joinedAt: 'asc' },
});
return m?.user.email ?? null;
}
5 callsites en subscription.service.ts migrados de tenant.users.where(rol.nombre='owner').take(1) a getTenantOwnerEmail():
updateSubscriptionStatus(cancelled)— notification email al cancelarrecordPayment(approved/rejected)— payment notificationsgeneratePaymentLink—payerEmaildel preapproval MPcancelMySubscription— notification emailapplyPendingChanges(cron) —adminEmaildentro del loop por sub
Cero referencias a tenant.users[] restantes en este servicio. Los include prisma desaparecen — el query es más limpio y la lógica de "quién es el dueño" queda centralizada en memberships.
Bug fix asociado (jti único en JWT): durante la testing aparecieron errores Unique constraint failed on the fields: (token) cuando React Query disparaba 2 refreshes paralelos. El JWT firmado era idéntico (mismo payload + mismo iat segundo). Fix: generateAccessToken y generateRefreshToken ahora pasan jwtid: randomBytes(8).toString('hex') en SignOptions — cada token tiene 16 chars hex únicos garantizados. Sin cambio de schema. Pre-existente, no introducido por F6.x. Commit 4351bf0.
✅ F6.4 — Drop users.tenant_id y users.rol_id del schema (commit junto con baseline migration)
Schema final:
User.tenantIdyUser.rolIdeliminados del modelo Prisma. Sus relaciones (tenant,rol) también removidas. La relación inversaTenant.users[]yRol.users[]desaparecen — ahora todo va porUser.memberships[].prisma db push --accept-data-lossaplicado al DB de desarrollo (las columnas se borraron físicamente).
Código limpiado:
auth.service.register()—prisma.user.createya no seteatenantId/rolId. El role del JWT inicial se hardcodea como'owner'(es el flujo de signup, siempre crea un owner). EllastTenantIdse setea al tenant recién creado para que el siguiente login caiga ahí.auth.service— 5select: { tenantId: true }cambiados a{ lastTenantId: true }. 5auditLog({ tenantId: user.tenantId })cambiados auser.lastTenantId ?? undefined.tenants.service.createTenant—prisma.user.createsolo email/passwordHash/nombre/lastTenantId.getAllTenants_countahora cuentamemberships(where active) en vez deusers.usuarios.service.inviteUsuario—prisma.user.createsintenantId/rolId.platform-staff.controller—searchUsersylistStaffresuelven el tenant del staff viauser.memberships.where(isOwner=true).take(1)conorderBy: joinedAt asc.platform-admin.isGlobalAdmin— busca user con superset role + membership activa en el tenant (en vez deuser.tenantId).
Baseline migration generada: Antes de F6.4 la BD central no tenía prisma/migrations/ — todos los cambios se aplicaban con prisma db push (sin trail versionado). Aprovechando F6.4, se generó la migración consolidada 20260414152220_initial_schema_v0_9_2/migration.sql (634 líneas con todo el DDL acumulado: enums, tabla central, FKs, índices) y se marcó como aplicada con prisma migrate resolve --applied. A partir de ahora cada cambio del schema central debe generarse con pnpm prisma migrate dev --name <descripción>.
Verificación E2E post-drop:
- Login
admin@demo.com→ resuelve EDE, role=owner ✅ GET /tenants/mine→ Empresa Demo con sub joined ✅GET /usuarios→ 3 users del tenant con roles per-membership ✅prisma migrate status→ "Database schema is up to date!" ✅