Arquitectura del Sistema B2B
Sección titulada «Arquitectura del Sistema B2B»Diagrama de Entidades
Sección titulada «Diagrama de Entidades»┌─────────────────────────────────────────────────────────────────┐│ USER (Usuario) ││ • id, email, username ││ • isAdmin (Platform Admin flag) ││ • subscriptionStatus (personal) │└──────────────────────┬──────────────────────────────────────────┘ │ │ 1:N ▼┌─────────────────────────────────────────────────────────────────┐│ ORGANIZATION MEMBER ││ • organizationId (FK) ││ • userId (FK) ││ • role: OWNER | ADMIN | MEMBER ││ • joinedAt │└──────────────────────┬──────────────────────────────────────────┘ │ │ N:1 ▼┌─────────────────────────────────────────────────────────────────┐│ ORGANIZATION ││ • id, name, domain ││ • maxSeats (comprados) ││ • usedSeats (ocupados) ││ • subscriptionStatus: 'active', 'past_due', etc. ││ • paymentProcessorCustomerId (Stripe) │└──────────────────────┬────────────┬─────────────────────────────┘ │ │ │ 1:N │ 1:N ▼ ▼ ┌─────────────────────┐ ┌──────────────────────┐ │ ORG ENROLLMENT │ │ ORG INVITATION │ │ • organizationId │ │ • organizationId │ │ • courseId │ │ • email │ │ • assignedById │ │ • role │ │ • assignedAt │ │ • token (unique) │ └─────────┬───────────┘ │ • expiresAt (7 days)│ │ └──────────────────────┘ │ N:1 ▼ ┌─────────────────────┐ │ COURSE │ │ • id, title, slug │ └─────────────────────┘Control de Acceso a Cursos
Sección titulada «Control de Acceso a Cursos»┌─────────────────────────────────────────────────────────────────┐│ Usuario intenta acceder a /courses/{slug}/learn │└──────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ checkCourseAccess() │ │ courses/operations.ts:958 │ └────────────┬───────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 1. ¿User.subscriptionStatus │ ───YES───┐ │ === 'active'? │ │ └────────────┬───────────────────┘ │ │ NO │ ▼ │ ┌────────────────────────────────┐ │ │ 2. ¿Existe Enrollment │ ───YES───┤ │ individual? │ │ └────────────┬───────────────────┘ │ │ NO │ ▼ │ ┌────────────────────────────────┐ │ │ 3. ¿Es miembro de org con │ │ │ curso asignado Y org │ ───YES───┤ │ subscription active? │ │ └────────────┬───────────────────┘ │ │ NO │ ▼ │ ┌────────────────────────────────┐ │ │ ❌ ACCESO DENEGADO │ │ │ throw HttpError(403) │ │ └────────────────────────────────┘ │ │ ┌────────────────────────────────┐ │ ✅ ACCESO PERMITIDO │◄──┘ └────────────────────────────────┘Flujo de Invitación
Sección titulada «Flujo de Invitación»┌─────────────────────────────────────────────────────────────────┐│ OWNER/ADMIN hace click en "Invitar Miembro" │└──────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ requireOrgAdmin(user, orgId) │ └────────────┬───────────────────┘ │ ▼ ┌────────────────────────────────┐ │ ¿usedSeats < maxSeats? │────NO────┐ └────────────┬───────────────────┘ │ │ YES │ ▼ │ ┌────────────────────────────────┐ │ │ Crear OrganizationInvitation: │ │ │ • email │ │ │ • role │ │ │ • token = random() │ │ │ • expiresAt = +7 days │ │ └────────────┬───────────────────┘ │ │ │ ▼ │ ┌────────────────────────────────┐ │ │ Enviar email con link: │ │ │ /invitation/{token} │ │ └────────────┬───────────────────┘ │ │ │ ▼ │ ┌────────────────────────────────┐ │ │ ✅ Invitación enviada │ │ └────────────────────────────────┘ │ │ ┌────────────────────────────────┐ │ │ ❌ Error: No hay seats │◄─────────┘ └────────────────────────────────┘Flujo de Aceptación
Sección titulada «Flujo de Aceptación»┌─────────────────────────────────────────────────────────────────┐│ Usuario hace click en /invitation/{token} │└──────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ Buscar OrganizationInvitation │ │ WHERE token = {token} │ └────────────┬───────────────────┘ │ ▼ ┌────────────────────────────────┐ │ ¿Invitación válida? │────NO────┐ │ (!acceptedAt && !expired) │ │ └────────────┬───────────────────┘ │ │ YES │ ▼ │ ┌────────────────────────────────┐ │ │ ¿usedSeats < maxSeats? │─NO──┐ │ └────────────┬───────────────────┘ │ │ │ YES │ │ ▼ │ │ ┌────────────────────────────────┐ │ │ │ Crear OrganizationMember │ │ │ │ Incrementar usedSeats │ │ │ │ Marcar acceptedAt │ │ │ └────────────┬───────────────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────────────────────┐ │ │ │ ✅ Invitación aceptada │ │ │ └────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────┐ │ │ │ ❌ Error: No hay seats │◄────┘ │ └────────────────────────────────┘ │ │ ┌────────────────────────────────┐ │ │ ❌ Error: Token inválido │◄─────────┘ └────────────────────────────────┘Helpers de Permisos
Sección titulada «Helpers de Permisos»Archivo: app/src/organizations/permissions.ts
requireAdmin(user)
Sección titulada «requireAdmin(user)»Verifica que el usuario sea Platform Admin (isAdmin === true).
export function requireAdmin(user: AuthUser | null | undefined): asserts user is AuthUser { if (!user?.isAdmin) { throw new HttpError(403, "Platform admin access required"); }}requireOrgOwner(user, organizationId, context)
Sección titulada «requireOrgOwner(user, organizationId, context)»Verifica que el usuario sea OWNER de la organización.
export async function requireOrgOwner( user: AuthUser, organizationId: string, context: any): Promise<void> { const membership = await context.entities.OrganizationMember.findUnique({ where: { organizationId_userId: { organizationId, userId: user.id } }, });
if (!membership || membership.role !== "OWNER") { throw new HttpError(403, "Organization owner access required"); }}requireOrgAdmin(user, organizationId, context)
Sección titulada «requireOrgAdmin(user, organizationId, context)»Verifica que el usuario sea OWNER o ADMIN.
export async function requireOrgAdmin( user: AuthUser, organizationId: string, context: any): Promise<void> { const membership = await context.entities.OrganizationMember.findUnique({ where: { organizationId_userId: { organizationId, userId: user.id } }, });
if (!membership || (membership.role !== "OWNER" && membership.role !== "ADMIN")) { throw new HttpError(403, "Organization admin access required"); }}requireOrgMember(user, organizationId, context)
Sección titulada «requireOrgMember(user, organizationId, context)»Verifica que el usuario sea miembro (cualquier rol).
export async function requireOrgMember( user: AuthUser, organizationId: string, context: any): Promise<void> { const membership = await context.entities.OrganizationMember.findUnique({ where: { organizationId_userId: { organizationId, userId: user.id } }, });
if (!membership) { throw new HttpError(403, "Organization membership required"); }}Operaciones Principales
Sección titulada «Operaciones Principales»Archivo: app/src/organizations/operations.ts
Queries
Sección titulada «Queries»getPaginatedOrganizations (Admin only)
Sección titulada «getPaginatedOrganizations (Admin only)»export const getPaginatedOrganizations: GetPaginatedOrganizations = async ( { skipPages = 0, filter = {} }, context) => { requireAdmin(context.user);
// Retorna: { organizations, totalPages, currentPage }};getMyOrganizations
Sección titulada «getMyOrganizations»export const getMyOrganizations: GetMyOrganizations = async (args, context) => { if (!context.user) throw new HttpError(401);
// Retorna organizaciones donde el usuario es miembro};getOrganizationById
Sección titulada «getOrganizationById»export const getOrganizationById: GetOrganizationById = async ({ organizationId }, context) => { // Verifica permisos (admin o miembro) // Retorna: organización con miembros, enrollments, stats};Actions
Sección titulada «Actions»inviteTeamMember
Sección titulada «inviteTeamMember»export const inviteTeamMember: InviteTeamMember = async ( { organizationId, email, role }, context) => { // 1. Verifica permisos (requireOrgAdmin) // 2. Verifica seats disponibles // 3. Crea OrganizationInvitation // 4. Envía email (opcional)};acceptInvitation
Sección titulada «acceptInvitation»export const acceptInvitation: AcceptInvitation = async ({ token }, context) => { // 1. Busca invitación por token // 2. Verifica validez (no expirada, no aceptada) // 3. Verifica seats disponibles // 4. Crea OrganizationMember // 5. Incrementa usedSeats // 6. Marca acceptedAt};assignCourseToOrganization
Sección titulada «assignCourseToOrganization»export const assignCourseToOrganization: AssignCourseToOrganization = async ( { organizationId, courseId }, context) => { // 1. Verifica permisos (requireOrgAdmin) // 2. Verifica que curso no esté ya asignado // 3. Crea OrganizationEnrollment};generateTeamCheckoutSession
Sección titulada «generateTeamCheckoutSession»export const generateTeamCheckoutSession: GenerateTeamCheckoutSession = async ( { planId, seats, organizationName }, context) => { // 1. Valida plan y seats // 2. Crea/busca Stripe customer // 3. Crea checkout session con: // - quantity = seats // - metadata = { userId, planId, seats, orgName } // 4. Retorna: { url: session.url }};Integración con Stripe
Sección titulada «Integración con Stripe»Webhooks Principales
Sección titulada «Webhooks Principales»Archivo: app/src/payment/stripe/webhook.ts
checkout.session.completed
Sección titulada «checkout.session.completed»if (metadata.type === "team_purchase") { // 1. Extraer metadata (userId, planId, seats, orgName) // 2. Crear Organization // 3. Crear OrganizationMember (OWNER) // 4. Establecer subscriptionStatus = 'active' // 5. Establecer maxSeats = quantity}customer.subscription.updated
Sección titulada «customer.subscription.updated»// 1. Buscar Organization por paymentProcessorCustomerId// 2. Actualizar subscriptionStatus// 3. Si cambió quantity → actualizar maxSeats// 4. Si maxSeats < usedSeats → alertar al OWNERcustomer.subscription.deleted
Sección titulada «customer.subscription.deleted»// 1. Buscar Organization por paymentProcessorCustomerId// 2. Establecer subscriptionStatus = 'deleted'// 3. Enviar email al OWNER// 4. Miembros pierden acceso (checkCourseAccess fail)invoice.payment_failed
Sección titulada «invoice.payment_failed»// 1. Buscar Organization// 2. Establecer subscriptionStatus = 'past_due'// 3. Enviar email urgente al OWNER// 4. Mostrar banner en dashboardEstructura de Archivos
Sección titulada «Estructura de Archivos»app/├── schema.prisma│ └── Organization, OrganizationMember,│ OrganizationEnrollment, OrganizationInvitation│├── main.wasp│ └── Rutas, queries, actions, seeds│└── src/ ├── organizations/ │ ├── operations.ts [Queries & Actions] │ ├── permissions.ts [RBAC helpers] │ └── pages/ │ ├── TeamDashboardPage.tsx │ ├── TeamMembersPage.tsx │ ├── TeamCoursesPage.tsx │ ├── TeamAnalyticsPage.tsx │ ├── TeamBillingPage.tsx │ ├── TeamPurchasePage.tsx │ └── AcceptInvitationPage.tsx │ ├── admin/ │ └── dashboards/ │ └── organizations/ │ ├── AdminOrganizationsPage.tsx │ ├── AdminOrganizationDetailPage.tsx │ └── OrganizationsTable.tsx │ ├── courses/ │ └── operations.ts │ └── checkCourseAccess() [Modificado] │ ├── payment/ │ └── stripe/ │ ├── checkoutUtils.ts [quantity param] │ └── webhook.ts [team webhooks] │ └── server/ └── scripts/ └── seedOrganizations.tsRutas Principales
Sección titulada «Rutas Principales»Dashboard de Equipo
Sección titulada «Dashboard de Equipo»/team/{organizationId}- Vista general/team/{organizationId}/members- Gestión de miembros/team/{organizationId}/courses- Gestión de cursos/team/{organizationId}/analytics- Analíticas/team/{organizationId}/billing- Facturación (OWNER only)
Compra e Invitaciones
Sección titulada «Compra e Invitaciones»/teams/purchase- Comprar plan de equipo/invitation/{token}- Aceptar invitación/teams- Landing page de equipos
Admin Dashboard
Sección titulada «Admin Dashboard»/admin/organizations- Lista de organizaciones/admin/organizations/{organizationId}- Detalle
Modelos de Base de Datos
Sección titulada «Modelos de Base de Datos»Organization
Sección titulada «Organization»model Organization { id String @id @default(uuid()) name String domain String? maxSeats Int usedSeats Int @default(0) subscriptionStatus String? subscriptionPlan String? paymentProcessorCustomerId String? @unique datePaid DateTime?
members OrganizationMember[] enrollments OrganizationEnrollment[] invitations OrganizationInvitation[]
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
@@index([subscriptionStatus]) @@index([domain])}OrganizationMember
Sección titulada «OrganizationMember»model OrganizationMember { id String @id @default(uuid()) organizationId String userId String role OrganizationRole @default(MEMBER) joinedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId]) @@index([userId]) @@index([organizationId])}OrganizationEnrollment
Sección titulada «OrganizationEnrollment»model OrganizationEnrollment { id String @id @default(uuid()) organizationId String courseId Int
assignedById String assignedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([organizationId, courseId]) @@index([organizationId]) @@index([courseId])}OrganizationInvitation
Sección titulada «OrganizationInvitation»model OrganizationInvitation { id String @id @default(uuid()) organizationId String email String role OrganizationRole @default(MEMBER) token String @unique
invitedBy String invitedAt DateTime @default(now()) expiresAt DateTime acceptedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, email]) @@index([token]) @@index([organizationId])}OrganizationRole (Enum)
Sección titulada «OrganizationRole (Enum)»enum OrganizationRole { OWNER // Control total ADMIN // Gestión de equipo MEMBER // Acceso usuario}Comandos de Desarrollo
Sección titulada «Comandos de Desarrollo»# Setupcd appwasp db start # Terminal 1wasp start # Terminal 2
# Seedswasp db seed seedOrganizationTestData # Solo organizacioneswasp db seed seedAllDummyData # Todo
# Migracioneswasp db migrate-dev # Después de cambios en schema
# Base de datospsql $DATABASE_URL # Acceso directo
# Logstail -f .wasp/build/logs/server.log