Ir al contenido

┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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 │◄──┘
└────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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 │◄─────────┘
└────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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 │◄─────────┘
└────────────────────────────────┘

Archivo: app/src/organizations/permissions.ts

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");
}
}

Archivo: app/src/organizations/operations.ts

export const getPaginatedOrganizations: GetPaginatedOrganizations = async (
{ skipPages = 0, filter = {} },
context
) => {
requireAdmin(context.user);
// Retorna: { organizations, totalPages, currentPage }
};
export const getMyOrganizations: GetMyOrganizations = async (args, context) => {
if (!context.user) throw new HttpError(401);
// Retorna organizaciones donde el usuario es miembro
};
export const getOrganizationById: GetOrganizationById = async ({ organizationId }, context) => {
// Verifica permisos (admin o miembro)
// Retorna: organización con miembros, enrollments, stats
};
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)
};
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
};
export const assignCourseToOrganization: AssignCourseToOrganization = async (
{ organizationId, courseId },
context
) => {
// 1. Verifica permisos (requireOrgAdmin)
// 2. Verifica que curso no esté ya asignado
// 3. Crea OrganizationEnrollment
};
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 }
};

Archivo: app/src/payment/stripe/webhook.ts

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
}
// 1. Buscar Organization por paymentProcessorCustomerId
// 2. Actualizar subscriptionStatus
// 3. Si cambió quantity → actualizar maxSeats
// 4. Si maxSeats < usedSeats → alertar al OWNER
// 1. Buscar Organization por paymentProcessorCustomerId
// 2. Establecer subscriptionStatus = 'deleted'
// 3. Enviar email al OWNER
// 4. Miembros pierden acceso (checkCourseAccess fail)
// 1. Buscar Organization
// 2. Establecer subscriptionStatus = 'past_due'
// 3. Enviar email urgente al OWNER
// 4. Mostrar banner en dashboard
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.ts
  • /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)
  • /teams/purchase - Comprar plan de equipo
  • /invitation/{token} - Aceptar invitación
  • /teams - Landing page de equipos
  • /admin/organizations - Lista de organizaciones
  • /admin/organizations/{organizationId} - Detalle
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])
}
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])
}
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])
}
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])
}
enum OrganizationRole {
OWNER // Control total
ADMIN // Gestión de equipo
MEMBER // Acceso usuario
}
Ventana de terminal
# Setup
cd app
wasp db start # Terminal 1
wasp start # Terminal 2
# Seeds
wasp db seed seedOrganizationTestData # Solo organizaciones
wasp db seed seedAllDummyData # Todo
# Migraciones
wasp db migrate-dev # Después de cambios en schema
# Base de datos
psql $DATABASE_URL # Acceso directo
# Logs
tail -f .wasp/build/logs/server.log