Sealearn is a monorepo containing a Node.js/Express backend and a React + Vite frontend in two sibling directories:Documentation Index
Fetch the complete documentation index at: https://mintlify.com/DerBasilisk/SEA-ServicioEvaluaconAsistida/llms.txt
Use this file to discover all available pages before exploring further.
backend/ and frontend/sea/. The two layers communicate over a REST API on port 3000 and a shared Socket.IO server that powers both the real-time duel system (default namespace) and the persistent chat feature (/chat namespace). All transient state for live duels and invite tokens lives in Redis, while all persistent data — users, lessons, questions, progress, duels — is stored in MongoDB via Mongoose. Two AI providers (Groq and Google Gemini) are integrated as a tiered service with circuit breakers and key rotation, and Cloudinary handles all binary media uploads.
High-Level Request Flow
Backend Layer
Backend Layer
The backend is a standard Node.js/Express 5 application bootstrapped in Routes → Controllers → Services patternEvery route file delegates to a controller function, which handles HTTP request/response and calls one or more service modules for business logic. Services are the only layer that imports Mongoose models directly. This separation keeps controllers thin and makes services independently testable.Middleware
OAuth — One cron job runs every Monday at midnight (Bogotá timezone) and calls
server.js. It creates an http.Server around the Express app so that Socket.IO can share the same port as the REST API.Entry point — server.js| Middleware | File | Purpose |
|---|---|---|
| JWT authentication | middleware/auth.js (inline in route files) | Verifies Authorization: Bearer <token> on protected routes |
| Admin guard | routes/admin.js | Checks user.role === "admin" before any admin endpoint |
| Rate limiting | express-rate-limit | Applied per-route to auth and AI endpoints |
| Helmet | helmet | Sets secure HTTP headers in production |
| Morgan | morgan("dev") | Logs every request with method, URL, status, and latency |
| Compression | compression | Gzip compresses JSON responses |
| Passport | Auth.account.js | Registers Google OAuth 2.0 and Discord OAuth strategies |
Auth.account.jsPassport is configured with two strategies. For Google, passport-google-oauth20 checks whether an existing user has the same Google ID or email — if found, it links the account; if not, it creates a new user with a sanitized username. Discord follows the same upsert pattern using passport-discord. Both strategies issue a JWT that the client stores and sends on subsequent requests.Cron jobs — cron.jsleague.service.js to promote, demote, and reset all LeagueRoom documents. Hearts are refilled on a separate schedule handled inside the service layer.Database Layer
Database Layer
Sealearn uses MongoDB as its sole persistent datastore, accessed through Mongoose 9. The connection is established in Data modelsAll Mongoose schemas live in
Question schema excerptThe
db/db.js by calling mongoose.connect(process.env.MONGODB_URI) before the HTTP server starts listening.Connection — db/db.jsmodels/. The core schemas (User, Subject, Unit, Lesson, Question, UserProgress, Streak, Achievement, Conversation, Message, ShopItem, UserInventory) are re-exported through models/index.js for convenient imports. The Duel, LeagueRoom, and Friendship models are imported directly by the service files that use them (duel.service.js, league.service.js, friends routes) and are not included in the barrel export.| Model | Collection | Description |
|---|---|---|
User | users | Core user document: credentials, OAuth IDs, role, XP, hearts, currency, avatar, banner |
Subject | subjects | Top-level learning subject (e.g., “Python Programming”) |
Unit | units | Ordered collection of lessons within a subject |
Lesson | lessons | Individual lesson node belonging to a unit; stores name, description, and order |
Question | questions | Polymorphic question document. The type field is an enum of 9 values: multiple_choice, true_false, fill_blank, order_items, match_pairs, sentence_builder, free_text, typing, code_python |
UserProgress | userprogresses | Per-user, per-lesson completion record used by the adaptive engine to calibrate difficulty |
Streak | streaks | Daily streak counter and last-active date per user |
Achievement | achievements | Earned badges with unlock timestamps |
LeagueRoom | leaguerooms | Weekly league snapshot: tier, member list, XP scores |
Friendship | friendships | Bidirectional friend relationships with status (pending, accepted) |
Conversation | conversations | Direct-message thread between two users |
Message | messages | Individual chat message: text or image type, sender reference, readBy array, optional duelData embed for duel-result cards |
Duel | duels | Persisted duel record created at accept-time and updated when the duel finishes |
ShopItem | shopitems | Purchasable cosmetic items with price and type metadata |
UserInventory | userinventories | Items owned by a user and whether they are currently equipped |
Question model is the most complex in the codebase. Sub-schemas (optionSchema, matchPairSchema) handle the type-specific fields:Real-Time Layer
Real-Time Layer
Sealearn’s real-time functionality is built on Socket.IO 4 and is split into two namespaces that share a single Duel namespace (default) — event flowModifiers (
When a duel ends, the duel engine emits
http.Server and io instance.Socket.IO server initializationsetupDuelSocket creates the Server and returns it; setupChatSocket receives that same io and registers the /chat namespace on it. This avoids the overhead of two separate WebSocket servers.Authentication middleware (both namespaces)Every socket connection on both namespaces runs through a JWT verification middleware before connection fires:extra_questions, reduced_time, blackout) can be applied mid-duel using duel:use_modifier. If the challenger is offline when the duel starts, the start payload is stored in Redis under pending_duel:<userId> with a 120-second TTL and delivered on their next connection.Chat namespace (/chat) — events| Event (client → server) | Event (server → client) | Description |
|---|---|---|
chat:join | chat:joined | Subscribe to a conversation room; marks messages as read |
chat:leave | — | Unsubscribe from a conversation room |
chat:send | chat:message | Send a text message; persisted to MongoDB |
chat:send_image | chat:message | Send an image URL (uploaded via REST first) |
chat:typing | chat:typing | Broadcast typing indicator |
chat:mark_read | chat:read, chat:read_confirmed | Mark all messages in a conversation as read |
chat:open_direct | chat:conversation_ready | Get-or-create a direct conversation |
chat:message directly on the /chat namespace with a type: "duel_result" message so both players see a rich result card inside their conversation without any additional client request.Redis Layer
Redis Layer
Redis is used exclusively for ephemeral, high-speed state that does not need long-term persistence. The Key namespaces
Duel state managementAll duel mutations go through four thin functions that wrap This keeps all Redis logic in one service file and makes it straightforward to swap the backing store (e.g., to a Redis Cluster) without touching the socket handler.Why Redis instead of MongoDB for duel state?Live duels can receive dozens of
ioredis client is instantiated in services/duel.service.js and shared across duel operations.| Redis key pattern | TTL | Contents |
|---|---|---|
duel:<duelId> | 30 min | Full duel state object: players, questions, scores, status, modifiers |
invite:<inviteId> | 2 min | Duel invite payload: requester, recipient, lesson, conversation |
pending_duel:<userId> | 2 min | Serialized duel:start payload for an offline challenger |
duel_result:<userId> | 5 min | Final result payload cached per player after duel ends |
redis.setex and redis.get:duel:answer events per second across concurrent games. Storing each state mutation in MongoDB would create excessive write amplification. Redis provides sub-millisecond reads and writes with automatic TTL expiry, keeping the duel engine responsive. MongoDB is only written to once — when createDuelInMongo persists the initial record — and again when finishDuelInMongo records the final scores.AI Layer
AI Layer
The AI layer lives entirely in
Circuit BreakerBoth providers are wrapped in a After In-memory cacheTo avoid redundant AI calls for identical inputs (same lesson content, same hint request), the service maintains a In a production deployment with multiple backend instances, this can be replaced with a Redis-backed cache by storing entries under a deterministic key derived from the prompt hash.
services/ai.service.js and exposes functions for generating questions, providing hints, and producing lesson slide content. It is designed for resilience: if the primary provider is rate-limited or down, the system transparently falls back to the secondary provider without any change to the calling code.Providers| Provider | Model | Role |
|---|---|---|
| Groq | llama-3.3-70b-versatile | Primary — fast inference, up to 3 API keys |
| Google Gemini | gemini-2.0-flash | Fallback — called when all Groq circuit breakers are open |
CircuitBreaker class:failureThreshold (default: 3) consecutive failures, the breaker opens and that provider is skipped for resetTimeMs (default: 60 seconds). After the window expires, one probe request is attempted (half-open state) and the breaker resets on success.Groq key rotationUp to three Groq API keys can be configured as GROQ_API_KEY_1, GROQ_API_KEY_2, GROQ_API_KEY_3. Each key gets its own Groq client instance and its own CircuitBreaker. getNextGroqClient() implements round-robin selection, skipping any client whose breaker is currently open:Map-based cache with a 30-minute TTL:Frontend Layer
Frontend Layer
The frontend is a React 19 single-page application built with Vite 7 and styled with Tailwind CSS 3. All source code lives under
Socket.IO clientThe frontend maintains two persistent socket connections:Both sockets are initialized once per session after login and torn down on logout. The
frontend/sea/src/.Vite + TailwindVite provides instant HMR during development and optimized chunk splitting for production builds. Tailwind is configured via postcss.config.js and tailwind.config.js with JIT mode enabled by default in Tailwind 3.React Router v7Client-side routing uses React Router v7 (react-router-dom ^7.13.1). Routes map to page-level components for the learn flow (subject list → unit → lesson → question), league table, friends, shop, profile, and admin dashboard.Zustand storesGlobal client state is split into five focused Zustand 5 stores:| Store | File | Responsibility |
|---|---|---|
authStore | store/authStore.js | Authenticated user object, JWT token, login/logout actions |
chatStore | store/chatStore.js | Active conversations, messages, unread counts, typing indicators |
progressStore | store/progressStore.js | Per-lesson completion state, XP, hearts, streak data |
audioStore | store/audioStore.js | Sound effect toggles and volume preferences |
themeStore | store/themeStore.js | Light/dark mode preference, persisted to localStorage |
chatStore subscribes to chat:message, chat:typing, and chat:read events to keep the UI reactive without polling.Cloudinary uploadsAvatar and banner images are uploaded through the backend REST route /api/upload, which pipes the file through Multer to Cloudinary using the cloudinary ^2.9.0 SDK. The backend returns a secure Cloudinary URL that is then saved to the User document. This keeps Cloudinary credentials server-side and never exposes them to the browser.UI component libraryThe frontend is built entirely with custom components and does not depend on a third-party component library. Lucide React (lucide-react ^0.577.0) provides icons. react-hot-toast handles toast notifications. swiper ^12.1.3 powers lesson card carousels. react-image-crop ^11.0.10 handles client-side avatar cropping before upload.Environment Variable Reference
The table below consolidates every environment variable the backend reads, for quick reference when deploying to a new environment.| Variable | Required | Description |
|---|---|---|
PORT | No (default: 3000) | HTTP server port |
MONGODB_URI | Yes | MongoDB connection string |
JWT_SECRET | Yes | Secret used to sign and verify JWTs |
CLIENT_URL | Yes | Allowed CORS origin (frontend URL) |
FRONTEND_URL | Yes | Used by Socket.IO CORS config |
CLIENT_URL_WWW | No | Optional www-prefix origin |
CLOUDINARY_CLOUD_NAME | Yes | Cloudinary cloud identifier |
CLOUDINARY_API_KEY | Yes | Cloudinary API key |
CLOUDINARY_API_SECRET | Yes | Cloudinary API secret |
GROQ_API_KEY_1 | Yes | First Groq API key (Llama 3.3) |
GROQ_API_KEY_2 | No | Second Groq key for rotation |
GROQ_API_KEY_3 | No | Third Groq key for rotation |
GEMINI_API_KEY | Yes | Google Gemini 2.0 Flash API key |
RESEND_API_KEY | Yes | Resend API key for transactional email |
GOOGLE_CLIENT_ID | No* | Google OAuth 2.0 client ID |
GOOGLE_CLIENT_SECRET | No* | Google OAuth 2.0 client secret |
GOOGLE_CALLBACK_URL | No* | Google OAuth callback URL |
DISCORD_CLIENT_ID | No* | Discord OAuth client ID |
DISCORD_CLIENT_SECRET | No* | Discord OAuth client secret |
DISCORD_CALLBACK_URL | No* | Discord OAuth callback URL |
REDIS_URL | Yes | Redis connection URL (required for duels) |
Variables marked
No* are not strictly required to boot the server, but OAuth login buttons in the frontend will fail without them. For a complete production deployment all variables should be set.