Appmint is a modular monolith with thin clients. One deployable backend, one shared model package, and clients that hold almost no business logic.
The backend process
appengine boots a single NestJS application that serves five transports off one HTTP server:
| Transport | Where |
|---|---|
| REST | 123 controllers |
| GraphQL | Apollo driver, schema auto-generated, playground on |
| Socket.IO | six gateways, Redis adapter for cross-instance fan-out |
| Raw WebSocket | /ws/hub — the hub-agent gateway, attached to the same server |
| Server-Sent Events | streaming AI and voice responses |
Swagger UI is mounted at /documentation, built at boot with deepScanRoutes.
Feature modules
app.module.ts imports 45 feature modules. Grouped by what they are for:
| Group | Modules |
|---|---|
| Core data | RepositoryModule, DynamicQueryModule, HistoryModule, SyncModule |
| Identity | UsersModule, ClientAccountModule, OrgManagementModule |
| Content & sites | SiteModule, ContentStudioModule, NoticeModule, ToolsModule |
| Commerce | StorefrontModule, SalesChannelModule, FinanceModule, AffiliateModule |
| CRM & marketing | CRMModule, DataEnrichmentModule, BroadcastModule, AnalyticsModule (analytics dashboards, the /stats engagement engine and Studio overviews) |
| Operations | BusinessMadeModule, WorkflowModule, CheckinModule, LogisticsModule, StaffPortalModule |
| Communications | ChatModule, PhoneModule, VoiceModule, CommentsModule, NotesModule |
| Social | CommunityModule, EventsModule |
| Money | BankingModule |
| Storage marketplace | StowboModule |
| Hardware | DeviceIntegrationsModule, PrintModule |
| AI | AIModule, DiscoveryModule |
| Platform ops | MonitoringModule, UsageModule, K8sManagementModule, ConnectModule, UpstreamModule |
DiscoveryModule is worth singling out: it serves self-describing API documentation so AI agents can introspect the platform at runtime.
The request pipeline
Order matters here, and several behaviors only make sense once you know it.
1. Express middleware
helmet → cookieParser → compression → rate limit → express.json({ limit: '4mb' }).
Rate limiting is 15-minute windows, RATE_LIMIT_MAX requests (default 10,000), keyed on IP with trust proxy set to exactly TRUST_PROXY_HOPS (default 2 — Cloudflare, then Traefik) so X-Forwarded-For cannot be spoofed. /monitoring/health is exempt.
2. CurrentUserMiddleware
Mounted on *. Drops scanner probes, resolves orgid, resolves the identity from API key / bearer token / cookie, and pre-loads the target record into request.currentData for permission checks. See Identity and access.
3. JwtAuthGuard Layered authorization — public route, own profile, record ownership, per-record roles, decorator roles and permissions, then token validation.
4. ValidationPipe
Global, with whitelist: true, forbidNonWhitelisted: true and transform: true. Unknown body properties are rejected, not ignored — a client sending an extra field gets a 400.
5. Controller and service The handler runs. Domain services talk to the repository layer.
6. ResponseTimeInterceptor Global, stamps timing on the way out.
The rate limiter is an app.use() middleware and Nest applies CORS during init(), which is later. A short-circuited 429 therefore skipped the CORS headers and browsers reported a generic network error instead. The limiter's handler now sets Access-Control-Allow-Origin itself. Keep that in mind if you add any other early short-circuit.
Data stores
| Store | Role |
|---|---|
| MongoDB | Primary. One database per organization; the connection is resolved from the org record and cached per process. |
| Redis | Cache, Socket.IO adapter, BullMQ backing store. |
| Elasticsearch / OpenSearch | Full-text and faceted search. The repository falls back to Mongo queries when a search index is not in play. |
| S3-compatible object storage | Files, via flydrive. Uploads return a signed URL plus generated size variants (xs, sm, md). |
Background work
Three Bull queues, all on Redis:
| Queue | Carries |
|---|---|
datatype-queue | Per-datatype record processing |
schedule-queue | Per-org schedule records and automation timers |
Sync queue (SYNC_QUEUE) | Platform-level syncs — deliberately separate, so a slow Facebook walk cannot delay org schedules. It also overrides Bull's default 30-second lock, which was handing long jobs to a second worker mid-flight. |
Separate workers handle ads, billing, escalation and notifications.
@nestjs/schedule provides cron; @nestjs/event-emitter carries in-process domain events.
Realtime
Six Socket.IO gateways:
| Gateway | Purpose |
|---|---|
chat.gateway | Customer and operator chat |
community-chat.gateway | Community group messaging |
ai-voice.gateway | CRM AI assistant voice |
realtime-voice.gateway, simple-voice.gateway | OpenAI Realtime voice streaming |
device-events.gateway | Hardware events fanned out to subscribed UIs |
All share the Redis adapter, so any instance can deliver to any connected client.
Separately, HubGatewayService attaches a plain ws server to the same HTTP listener, scoped to paths starting /ws/hub. It is attached in a try/catch — if it throws, the API still boots and logs hub-gateway: not attached.
Resilience
main.ts installs process-level handlers for unhandledRejection and uncaughtException that log and keep serving. This exists because a fire-and-forget outbound call — a Twilio request whose upstream 401s — would otherwise take the whole server down. It is a safety net, not a substitute for local error handling.
HTTP keep-alive is 65 s with a 66 s headers timeout, deliberately above a typical 60 s load-balancer idle timeout so connections are closed by the LB rather than accumulating.
The clients
Every client is deliberately thin. Business rules live on the server, so the same behavior applies whether a record is changed from Studio, the mobile app, a storefront or your own code.
The shared contract is @jaclight/dbsdk: DataType, RoleType, BaseModel<T>, permission tables and default schemas, imported by the backend and the web clients. Change a model there and both sides move together.