Skip to main content

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.

Sealearn is a monorepo containing a Node.js/Express backend and a React + Vite frontend in two sibling directories: 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

Browser (React + Vite :5173)

  ├─ HTTP/REST ──────────────────────► Express 5 (:3000)
  │                                       │
  │                                       ├─ routes → controllers → services
  │                                       ├─ Mongoose → MongoDB
  │                                       └─ Cloudinary / Resend / AI providers

  └─ WebSocket (Socket.IO) ─────────► Socket.IO Server (:3000 /socket.io)

                                          ├─ Default namespace  → Duel engine
                                          │    └─ Redis (duel:*, invite:*, pending_duel:*)
                                          └─ /chat namespace    → Chat engine
                                               └─ MongoDB (Message, Conversation)
The backend is a standard Node.js/Express 5 application bootstrapped in 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
const app    = express();
const httpServer = http.createServer(app);

// Global middleware
app.use(cors({ origin: [process.env.CLIENT_URL, /https:\/\/sea-frontend.*\.vercel\.app$/], credentials: true }));
app.use(express.json());
app.use(morgan("dev"));
app.use(passport.initialize());

// REST route groups
app.use("/api/users",    userRoutes);
app.use("/api/profile",  profileRoutes);
app.use("/api/subjects", subjectRoutes);
app.use("/api/lessons",  lessonRoutes);
app.use("/api/questions",questionRoutes);
app.use("/api/progress", progressRoutes);
app.use("/api/auth",     authRoutes);
app.use("/api/password", passwordRoutes);
app.use("/api/friends",  friendsRoutes);
app.use("/api/upload",   uploadRoutes);
app.use("/api/leagues",  leagueRoutes);
app.use("/api/admin",    adminRoutes);
app.use("/api/chat",     chatRoutes);
app.use("/api/shop",     shopRoutes);

// Cron jobs (registered before DB connects)
setupCronJobs();

// Socket.IO (duel default namespace + /chat namespace)
const io = setupDuelSocket(httpServer);
setupChatSocket(io);

connectDB().then(() => httpServer.listen(PORT));
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.
routes/lesson.js
  └─ controllers/lesson.controller.js
       └─ services/adaptive.service.js
       └─ models/lesson.js, models/question.js
Middleware
MiddlewareFilePurpose
JWT authenticationmiddleware/auth.js (inline in route files)Verifies Authorization: Bearer <token> on protected routes
Admin guardroutes/admin.jsChecks user.role === "admin" before any admin endpoint
Rate limitingexpress-rate-limitApplied per-route to auth and AI endpoints
HelmethelmetSets secure HTTP headers in production
Morganmorgan("dev")Logs every request with method, URL, status, and latency
CompressioncompressionGzip compresses JSON responses
PassportAuth.account.jsRegisters Google OAuth 2.0 and Discord OAuth strategies
OAuth — 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.js
cron.schedule("0 0 * * 1", async () => {
  const count = await processWeeklyLeagues();
  console.log(`[Cron] Ligas procesadas: ${count} salas`);
}, { timezone: "America/Bogota" });
One cron job runs every Monday at midnight (Bogotá timezone) and calls league.service.js to promote, demote, and reset all LeagueRoom documents. Hearts are refilled on a separate schedule handled inside the service layer.
Sealearn uses MongoDB as its sole persistent datastore, accessed through Mongoose 9. The connection is established in db/db.js by calling mongoose.connect(process.env.MONGODB_URI) before the HTTP server starts listening.Connection — db/db.js
const connectDB = async () => {
  await mongoose.connect(process.env.MONGODB_URI);
};
Data modelsAll Mongoose schemas live in models/. 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.
ModelCollectionDescription
UserusersCore user document: credentials, OAuth IDs, role, XP, hearts, currency, avatar, banner
SubjectsubjectsTop-level learning subject (e.g., “Python Programming”)
UnitunitsOrdered collection of lessons within a subject
LessonlessonsIndividual lesson node belonging to a unit; stores name, description, and order
QuestionquestionsPolymorphic 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
UserProgressuserprogressesPer-user, per-lesson completion record used by the adaptive engine to calibrate difficulty
StreakstreaksDaily streak counter and last-active date per user
AchievementachievementsEarned badges with unlock timestamps
LeagueRoomleagueroomsWeekly league snapshot: tier, member list, XP scores
FriendshipfriendshipsBidirectional friend relationships with status (pending, accepted)
ConversationconversationsDirect-message thread between two users
MessagemessagesIndividual chat message: text or image type, sender reference, readBy array, optional duelData embed for duel-result cards
DuelduelsPersisted duel record created at accept-time and updated when the duel finishes
ShopItemshopitemsPurchasable cosmetic items with price and type metadata
UserInventoryuserinventoriesItems owned by a user and whether they are currently equipped
Question schema excerptThe Question model is the most complex in the codebase. Sub-schemas (optionSchema, matchPairSchema) handle the type-specific fields:
type: {
  type: String,
  enum: [
    "multiple_choice", "true_false", "fill_blank",
    "order_items",     "match_pairs", "sentence_builder",
    "free_text",       "typing",      "code_python",
  ],
},
// Multiple choice: options[] with isCorrect flag
// True/false:      correctBoolean
// Fill blank:      correctAnswers[]
// Order items:     items[] (correct order)
// Match pairs:     pairs[] with { left, right }
// Code:            evaluationCriteria, maxScore, isCodeExercise
Sealearn’s real-time functionality is built on Socket.IO 4 and is split into two namespaces that share a single http.Server and io instance.Socket.IO server initialization
// duel.socket.js — creates the Server instance
const io = new Server(httpServer, {
  cors: { origin: process.env.FRONTEND_URL, credentials: true },
  path: "/socket.io",
});

// chat.socket.js — attaches to the existing io
const chat = io.of("/chat");
setupDuelSocket 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:
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  socket.userId = decoded._id;
  next();
});
Duel namespace (default) — event flow
Client A                    Server                    Client B
   │                           │                           │
   ├─ duel:invite ────────────►│                           │
   │  { friendId, lessonId }   ├─ duel:invited ───────────►│
   │◄─ duel:invite_sent ───────┤                           │
   │                           │◄──────── duel:accept ─────┤
   │                           │  build duelState in Redis  │
   │◄─ duel:start ─────────────┼──────── duel:start ───────►│
   │                           │                           │
   ├─ duel:answer ────────────►│◄──────── duel:answer ─────┤
   │◄─ duel:answer_result ─────┤                           │
   │                           ├─ duel:opponent_progress ──►│
   │                           │                           │
   │◄─ duel:finished ──────────┼──────── duel:finished ────►│
Modifiers (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:joinchat:joinedSubscribe to a conversation room; marks messages as read
chat:leaveUnsubscribe from a conversation room
chat:sendchat:messageSend a text message; persisted to MongoDB
chat:send_imagechat:messageSend an image URL (uploaded via REST first)
chat:typingchat:typingBroadcast typing indicator
chat:mark_readchat:read, chat:read_confirmedMark all messages in a conversation as read
chat:open_directchat:conversation_readyGet-or-create a direct conversation
When a duel ends, the duel engine emits 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 is used exclusively for ephemeral, high-speed state that does not need long-term persistence. The ioredis client is instantiated in services/duel.service.js and shared across duel operations.
const redis = new Redis(process.env.REDIS_URL);
const DUEL_TTL = 60 * 30; // 30 minutes
Key namespaces
Redis key patternTTLContents
duel:<duelId>30 minFull duel state object: players, questions, scores, status, modifiers
invite:<inviteId>2 minDuel invite payload: requester, recipient, lesson, conversation
pending_duel:<userId>2 minSerialized duel:start payload for an offline challenger
duel_result:<userId>5 minFinal result payload cached per player after duel ends
Duel state managementAll duel mutations go through four thin functions that wrap redis.setex and redis.get:
createDuel(duelId, data)   // setex duel:<id> DUEL_TTL
getDuel(duelId)            // get  duel:<id>
updateDuel(duelId, data)   // setex duel:<id> DUEL_TTL  (resets TTL)
deleteDuel(duelId)         // del  duel:<id>
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 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.
The AI layer lives entirely in 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
ProviderModelRole
Groqllama-3.3-70b-versatilePrimary — fast inference, up to 3 API keys
Google Geminigemini-2.0-flashFallback — called when all Groq circuit breakers are open
Circuit BreakerBoth providers are wrapped in a CircuitBreaker class:
class CircuitBreaker {
  constructor(name, { failureThreshold = 3, resetTimeMs = 60_000 } = {}) { ... }

  get isOpen() {
    // Auto-resets after resetTimeMs (half-open probe)
    if (this.openedAt && Date.now() - this.openedAt > this.resetTimeMs) {
      this.failures = 0;
      this.openedAt = null;
    }
    return this.openedAt !== null;
  }

  recordSuccess() { this.failures = 0; this.openedAt = null; }
  recordFailure()  { if (++this.failures >= this.failureThreshold) this.openedAt = Date.now(); }
}
After 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:
const groqPool = GROQ_KEYS.map(apiKey => ({
  client:  new Groq({ apiKey }),
  breaker: new CircuitBreaker(`Groq-${apiKey.slice(-6)}`),
}));

function getNextGroqClient() {
  for (let i = 0; i < groqPool.length; i++) {
    const entry = groqPool[groqPoolIndex];
    groqPoolIndex = (groqPoolIndex + 1) % groqPool.length;
    if (!entry.breaker.isOpen) return entry;
  }
  throw new Error("Todas las keys de Groq están saturadas");
}
In-memory cacheTo avoid redundant AI calls for identical inputs (same lesson content, same hint request), the service maintains a Map-based cache with a 30-minute TTL:
const cache = new Map();
const CACHE_TTL_MS = 30 * 60 * 1000;
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.
The frontend is a React 19 single-page application built with Vite 7 and styled with Tailwind CSS 3. All source code lives under 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:
StoreFileResponsibility
authStorestore/authStore.jsAuthenticated user object, JWT token, login/logout actions
chatStorestore/chatStore.jsActive conversations, messages, unread counts, typing indicators
progressStorestore/progressStore.jsPer-lesson completion state, XP, hearts, streak data
audioStorestore/audioStore.jsSound effect toggles and volume preferences
themeStorestore/themeStore.jsLight/dark mode preference, persisted to localStorage
Socket.IO clientThe frontend maintains two persistent socket connections:
// Duel namespace (default)
const socket = io("http://localhost:3000", {
  auth: { token: authStore.getState().token },
  path: "/socket.io",
});

// Chat namespace
const chatSocket = io("http://localhost:3000/chat", {
  auth: { token: authStore.getState().token },
  path: "/socket.io",
});
Both sockets are initialized once per session after login and torn down on logout. The 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.
VariableRequiredDescription
PORTNo (default: 3000)HTTP server port
MONGODB_URIYesMongoDB connection string
JWT_SECRETYesSecret used to sign and verify JWTs
CLIENT_URLYesAllowed CORS origin (frontend URL)
FRONTEND_URLYesUsed by Socket.IO CORS config
CLIENT_URL_WWWNoOptional www-prefix origin
CLOUDINARY_CLOUD_NAMEYesCloudinary cloud identifier
CLOUDINARY_API_KEYYesCloudinary API key
CLOUDINARY_API_SECRETYesCloudinary API secret
GROQ_API_KEY_1YesFirst Groq API key (Llama 3.3)
GROQ_API_KEY_2NoSecond Groq key for rotation
GROQ_API_KEY_3NoThird Groq key for rotation
GEMINI_API_KEYYesGoogle Gemini 2.0 Flash API key
RESEND_API_KEYYesResend API key for transactional email
GOOGLE_CLIENT_IDNo*Google OAuth 2.0 client ID
GOOGLE_CLIENT_SECRETNo*Google OAuth 2.0 client secret
GOOGLE_CALLBACK_URLNo*Google OAuth callback URL
DISCORD_CLIENT_IDNo*Discord OAuth client ID
DISCORD_CLIENT_SECRETNo*Discord OAuth client secret
DISCORD_CALLBACK_URLNo*Discord OAuth callback URL
REDIS_URLYesRedis 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.

Build docs developers (and LLMs) love