Bounded contexts
Los siete módulos incluidos en apps/server/src/modules/, qué posee cada uno, y qué cruza la frontera.
Un bounded context = una carpeta bajo apps/server/src/modules/ con su propio domain/, application/, infrastructure/, interfaces/http/. Los módulos no importan desde domain/ o application/ de otro. Se comunican mediante:
- Eventos de dominio sobre el event bus en memoria (preferido para fan-out).
- HTTP cuando se desea un contrato de API estable.
- Shared kernel en
packages/shared(sólo para primitives genuinos — IDs, errores,Result).
iam — Identidad y acceso
apps/server/src/modules/iam/
Posee User, verificación de email, sessions (delegadas a BetterAuth). Repo:
domain/
user.ts Aggregate. Username, locale, picture, active flag, lastLogin.
email.ts Value object.
user-repository.ts Interface.
application/
ports/
use-cases/ Register, login, change-password, update-profile, list-users…
infrastructure/
Adapter de Prisma, glue de BetterAuth.
interfaces/http/
auth.controller.ts /api/auth/*
auth.middleware.ts authMiddleware (401), sessionHydration (anon-friendly).
users.routes.ts /api/users — admin CRUD gateado por permisos users:*.Los flows de auth basados en cookies funcionan porque apiClient.withCredentials = true en el client. No reintroduzcas bearer tokens.
tenancy — Organizaciones y memberships
apps/server/src/modules/tenancy/
Posee Organization, Membership, filas dinámicas de Role, y el middleware organizationContext que hidrata req.organization (org activa) y sus permisos computados para el user actual.
Un user puede pertenecer a muchas organizaciones; cada membership tiene su propio rol y, por tanto, su propio grant de permisos. Cambiar de org = actualizar la cookie de active-org; la próxima request ve un req.grants distinto.
billing — Subscriptions, plans, providers
apps/server/src/modules/billing/
Posee Subscription, Plan, ingest de webhooks. Provider-agnostic: interface PaymentProvider en domain/, tres implementaciones concretas en infrastructure/ (Stripe, Mercado Pago, Polar). El env PAYMENT_PROVIDER decide cuál se enlaza.
Los webhooks aterrizan en /webhooks/<provider>, son verificados, se convierten en eventos de dominio (subscription.activated, subscription.canceled, …), y actualizan la fila local de Subscription. El frontend lee Subscription.status — nunca la API del provider.
El middleware requireActiveSubscription (infrastructure/http/require-subscription.ts) gatea endpoints premium.
usage — Metering de free-tier y enforcement de cuotas
apps/server/src/modules/usage/
Posee UsageMeterDefinition, UsageCounter, UsageEvent. Los productos downstream registran meters al boot (p.ej. tickets_created, jobs_created), proveen un map plan → cap, y llaman a QuotaEnforcer.checkAndIncrement desde los use cases que producen eventos facturables. Lee la subscription activa de billing vía el port cross-module IGetActiveSubscriptionPort — sin tocar las tablas de billing.
Tres cadencias de reset (lifetime / monthly / yearly); las ventanas periódicas rolan vía el job BullMQ usage-period-rollover. Deshabilitado por default (USAGE_METERING_ENABLED=false); ver Usage metering para la referencia completa.
notifications — Email & in-app
apps/server/src/modules/notifications/
application/ declara el port (EmailSender); infrastructure/ provee Resend, SES, y un adapter no-op. Los listeners (ej. user.created → welcome email) viven en bootstrap y encolan un job BullMQ emails; el worker lo levanta y llama al sender.
Por eso este módulo no tiene carpeta domain/ — no hay aggregate, sólo un side effect.
storage — File uploads
apps/server/src/modules/storage/
Posee los uploads de avatares (y cualquier otro binario que la app necesite). Interface StorageProvider con implementaciones null / local / s3 / uploadthing. STORAGE_PROVIDER=null devuelve 503 desde los endpoints de upload — sin falla silenciosa.
Las keys son content-addressed: avatars/{userId}-{sha256:16}.{ext}. El hash hace seguro al header de cache inmutable (Cache-Control: public, max-age=31536000, immutable) entre re-uploads — un nuevo upload escribe una key nueva, así que un CDN nunca sirve bytes obsoletos.
Las URLs del provider local se construyen desde BETTER_AUTH_URL (la URL del server) porque el static middleware que sirve los uploads corre en el server, no en Next.js.
audit — Audit log
apps/server/src/modules/audit/
Registro append-only de acciones sensibles (cambios de rol, eventos de billing, mutaciones admin de users). Los listeners en el event bus escriben las filas; no hay API pública de mutación.
feature-flags — Gates de runtime
apps/server/src/modules/feature-flags/
Flags por org, por user o globales. Las lecturas se cachean in-process; las escrituras invalidan. El middleware req.featureFlags expone un accessor tipado para que los handlers no salpiquen string keys.
Qué cruza una frontera
| Concern | Mecanismo |
|---|---|
| User se registra → enviar welcome email | iam publica user.created → listener de notifications encola un job |
| Webhook de Stripe activa subscription | billing publica subscription.activated → listener de audit escribe el log |
| Endpoint necesita saber si la subscription está activa | Middleware lee tenancy → billing (llamada cross-module estilo HTTP dentro del proceso) |
Branded ID, Result, DomainError | @app/shared |
| Forma de body / response HTTP | @app/contracts |
Si te encontrás llegando desde domain/ de un módulo al de otro, frená — eso es un evento o un port, no un import.
links (mibio)
apps/server/src/modules/links/
Vertical link-in-bio para coaches y consultores — el producto mibio. Page
es el perfil público (slug, theme, estado de publicación). Block es la
entrada de smart link con type + config: jsonb, validado por schemas Zod
por BlockType (LINK, HEADER, PAYMENT_LINK, BOOKING_LINK,
WHATSAPP, LEAD_CAPTURE, IMAGE, SOCIAL_ROW; EMBED y TESTIMONIAL
reservados). El orden de blocks usa enteros position esparcidos
(10, 20, 30…) para que un reorder no tenga que renumerar todas las filas.
Lead registra capturas de los blocks LEAD_CAPTURE. @@unique([pageId, email])
previene altas duplicadas por página.
Los repositorios siguen el layout DDD estándar. La superficie HTTP se monta en
/api/v1/links/* con permisos links:read / links:write; los datos PII de
leads se gatean por separado con links:leads:read / links:leads:export.
Superficie pública. Dos endpoints sin auth alimentan la página en vivo:
GET /api/v1/public/pages/by-slug/:slug— devuelve la página publicada + blocks habilitados, servido por un cache LRU in-process (60s TTL, 1000 entradas) que envuelve los repos Prisma. La invalidación vive eninfrastructure/cache/caching-repositories.ts: cadasave/deletesobre la página o un block desaloja las claves slug + page-id correspondientes, así las escrituras del admin se ven en el siguiente read público. La response seteaX-Cache: HIT|MISSpara debug +Cache-Control: public, max-age=60, stale-while-revalidate=300para que cualquier CDN al frente cachee de forma coherente.POST /api/v1/public/leads— captura{ blockId, email, name? }, derivapageIddesde el block (así la superficie pública no puede spoofear otra página), upsert sobre(pageId, email)para deduplicar, rate-limited por IP vía el tierauthIpLimiter.
Templates. Cuatro arquetipos de coach hardcodeados en
modules/links/infrastructure/templates/registry.ts (Coach Ejecutivo,
Wellness, Terapeuta, Consultor B2B). NO son un modelo de Prisma — al crear
una página, el use case applyTemplate traduce un BlockSeed[] a filas
reales de Block en posiciones 10, 20, 30, … y copia el theme, título y
bio del template a la página. GET /api/v1/links/templates los lista
(name / description / thumbnail).
Render público. La ruta catch-all del Next App Router
app/[slug]/page.tsx hace SSR fetch a /api/v1/public/pages/by-slug/:slug
y renderiza la página con ocho componentes de block bajo
apps/client/components/public/blocks/ (LeadCapture es el único client
component — administra el estado del form). Slugs reservados se filtran
server-side vía apps/client/lib/public/reserved-slugs.ts (mirror del
SLUG_RESERVED del contrato más rutas de marketing) así /admin,
/login, etc. no chocan con rutas reales. Una imagen OG dinámica en
app/[slug]/opengraph-image.tsx (next/og) renderiza un PNG 1200×630 del
perfil para los links compartidos.
Editor es plan 4.
analytics (mibio)
apps/server/src/modules/analytics/
Event store append-only para tráfico de páginas mibio. PageView y
BlockClick son filas inmutables; DailyRollup es el agregado materializado
((pageId, date) único) que alimenta el tab de analytics del editor sin
re-escanear eventos crudos.
Ruta de ingesta. Dos endpoints POST públicos y sin auth bajo
/api/v1/public/analytics/{view,click} validan un payload Zod chico y
retornan 204 antes de hacer el trabajo. El handler resuelve la página
(por slug para views, por blockId para clicks) e inserta una fila;
payloads inválidos / slugs desconocidos se loguean en warn y nunca llegan
al caller. Esto es fire-and-forget por diseño — la página pública SSR
emite estos beacons vía navigator.sendBeacon (clicks) y
fetch({ keepalive: true }) (view inicial), así no bloquean el render
ni se cancelan al navegar a una URL externa.
Ruta de lectura. GET /api/v1/links/pages/:id/analytics?range=7d|30d|90d
retorna totales + clicks por block + serie densa por día. Gateado por
links:read y scoped a la organización dueña.
Jobs en background. Dos schedules BullMQ:
analytics-rollup— diario a las 03:15 UTC. Para cada página con actividad en las últimas 36h, materializa los totales de ayer enDailyRollup. Idempotente vía la unique key en(pageId, date).analytics-prune— semanal domingos 04:00 UTC. Borra filas raw dePageView+BlockClickcon más de 90 días.DailyRollupse conserva para siempre (barato, una fila por página por día).
Ambos processors viven en analytics/infrastructure/jobs/ y se registran
en bootstrap/worker.ts; el installer del schedule (scheduleAnalyticsJobs)
corre en cada cold start desde index.ts y es no-op si Redis no está
configurado.
El render público ya está vivo (ver links arriba). La UI de analytics
del editor llega en el plan 4.