Skip to content

0049. Alta delegada de tenants: reusar /auth/onboarding, no reimplementarlo

Estado

Aceptada

Contexto

platform-onboarding-tenants (2026-07-21) le dio al superadmin una vía para dar de alta un tenant desde camaroneras_platform: crea la organización (ya nombrada) e invita al primer admin por email. Al planificar su retiro del signup público (deprecar-signup-publico, ver backlog) apareció un caso que esa tarea no cubría: el superadmin no siempre sabe el nombre legal de la empresa del cliente, y forzarlo a inventarlo (o a pedírselo por otro canal antes de dar de alta) es peor experiencia que dejar que el propio cliente lo escriba, como hacía el signup público que se está por retirar.

La pregunta de diseño: ¿cómo nombra su empresa un cliente que llega por invitación, sin que exista auto-registro público?

Dos caminos:

  1. Reimplementar la creación de organización del lado del cliente — un endpoint nuevo (PATCH o POST sobre la organización) que el admin invitado llama tras aceptar.
  2. Reusar /auth/onboarding, el endpoint que ya hace exactamente esto — pero dándole una segunda vía para llegar a pending_onboarding además del signup.

Decisión

Se reusa /auth/onboarding tal cual. No se le cambia la lógica (sigue creando Organization + sembrando roles/curvas + vinculando OrganizationUser + activando al usuario, vía el mismo helper createOrganizationWithUniqueSlug que ya comparte con el signup) — solo se corrige el mensaje de su único chequeo de precondición (dbUser.status !== 'pending_onboarding'), que asumía por texto que el único camino previo era la verificación de email.

Lo que cambia es quién puede dejar a un usuario en pending_onboarding:

  • POST /platform/organizations gana un modo delegado: si se omite name, crea solo el User invitado (sin org, sin roles, sin curvas, sin membresía) — el alta completa queda pendiente de que el cliente la complete.
  • POST /auth/accept-invitation bifurca: si el invitado que acepta no tiene membresía (llegó por el modo delegado), en vez de sesión completa le emite un onboardingToken — el mismo token de scope onboarding que hoy emite signup — y lo deja en pending_onboarding. Antes de esta tarea, ese caso ("invitación sin organización") era imposible por diseño: cualquier invitación (UsersService.invite, o el alta directa de plataforma) siempre creaba la membresía junto con el usuario, y el código lo trataba como un 400 inalcanzable ("La invitación no tiene organización asociada"). Ese branch pasó a ser el camino feliz del modo delegado.

Toda la maquinaria del scope onboarding (issueOnboardingToken, OnboardingJwtGuard, onboarding.strategy.ts, JWT_ONBOARDING_SECRET) queda intacta y compartida entre las dos vías — no hay dos tokens ni dos guards paralelos.

Por qué no el camino 1 (endpoint nuevo)

  • Habría duplicado la secuencia de siembra (org + roles + curvas + membresía) que ya vive en un solo lugar compartido entre signup y el alta directa de plataforma — exactamente el tipo de divergencia que ese helper compartido (createOrganizationWithUniqueSlug, extraído en platform-onboarding-tenants) se creó para evitar.
  • El endpoint nuevo habría necesitado su propio guard, su propia validación de estado, su propio DTO — todo ya resuelto por /auth/onboarding y su guard existente.
  • El costo del camino 2 es solo un if de bifurcación en dos lugares (create y accept-invitation), contra reimplementar un flujo completo.

Efecto colateral en deprecar-signup-publico

Esta decisión corrige el relevamiento original de esa tarea (hecho antes de esta): el token de onboarding y pending_onboarding NO quedan huérfanos al retirar el signup — el modo delegado los mantiene vivos. Solo pending_verification queda sin productor tras ese retiro (ver backlog, entrada actualizada).

Listado mixto en GET /platform/organizations: sin paginar las filas delegadas

Corolario de diseño: una invitación delegada pendiente no tiene organización, así que no puede vivir en la tabla Organization ni en su paginación. Se expone como una fila más en el mismo listado (kind: 'pending_invitation', id = el del usuario invitado) para que el superadmin no tenga que mirar dos pantallas — pero esas filas se devuelven siempre completas y al principio, fuera de la paginación (meta describe solo organizaciones reales). Es una inconsistencia deliberada y acotada: el volumen esperado de invitaciones delegadas pendientes es bajo y transitorio (se resuelven o expiran en días), así que no justifica un UNION paginado sobre dos tablas de forma distinta. Documentado explícitamente en plataforma/superadmin-flow.md para que ningún consumidor futuro asuma data.length <= meta.pageSize.

Consecuencias

  • POST /platform/organizations: name pasa a opcional, cambia la forma de la respuesta ({ organization: {...} | null, adminEmail }, antes { id, name, slug, adminEmail }) — breaking, pero su único consumidor (camaroneras_platform) se actualizó en la misma tarea.
  • POST /auth/accept-invitation gana un discriminador needsOnboarding en la respuesta — mismo patrón polimórfico (oneOf en el spec) que ya usaba POST /auth/signin.
  • POST /platform/invitations/:userId/resend, endpoint nuevo: el reenvío existente (POST /platform/organizations/:id/resend-invitation) está scopeado por organización y no sirve para una invitación delegada, que todavía no tiene una.
  • Un usuario pending_onboarding sin membresía es un estado nuevo en el sistema (antes, todo usuario que llegaba a ese estado ya tenía una organización esperándolo desde el signup). No requirió cambios adicionales: el access token de scope full exige organizationId en su payload (AccessTokenPayload, no opcional) y solo lo emite issueSession, que nunca se llama para este caso — no hay ningún guard que necesite un chequeo nuevo para no asumir organizationId en un usuario sin membresía.
  • camaroneras_admin's AcceptInvitationPage bifurca igual que el backend: con needsOnboarding: true guarda el onboardingToken y redirige a /onboarding en vez de abrir sesión. El caso límite "el usuario recarga la página y pierde el token en memoria" no necesitó código nuevo: SignInPage ya redirige un pending_onboarding a /onboarding con un token fresco al iniciar sesión (mecanismo preexistente del signup, reusado sin tocar).

Referencias

  • Tarea platform-onboarding-delegado (2026-07-21).
  • [ADR anterior de la cadena: alta directa] platform-onboarding-tenants (2026-07-21, sin ADR propio — extensión aditiva sobre RBAC de plataforma, ver ADR-0048).
  • camaroneras_backend/src/modules/auth/auth.service.ts (acceptInvitation, onboarding), src/modules/platform/platform-organizations.service.ts (create, resendDelegatedInvitation), src/modules/platform/platform.service.ts (listOrganizations).
  • camaroneras_docs/plataforma/superadmin-flow.md, plataforma/rbac-flow.md, plataforma/auth-flow.md (contratos actualizados).
  • _planning/_backlog/backlog.mddeprecar-signup-publico (relevamiento corregido por esta decisión).