case study · 2026

Learning platform / custom LMS
portaldeestudio.aibymel.site

portaldeestudio.aibymel.site

01

the challenge

IAgency, a Colombian school teaching AI skills, ran its cohorts on three borrowed tools: the course lived on Hotmart, conversations with students on WhatsApp, and the one-to-one session calendar in a spreadsheet. Each worked on its own; together, they never spoke. Nobody could say, at any given moment, where a student stood: what she had watched, what she had asked, when her next session was. The original brief fit in a sentence: a login screen with a link to the Hotmart-hosted course. It grew as each borrowed tool hit its limit: content that can't be released cohort by cohort, work conversations tangled up with personal messages, an appointment charged by hand. Two constraints framed everything else, and they never moved: students come in from their phones, and the hosting is Hostinger shared, with no container, no build on the server, no node_modules in the package.

02

the solution

A private web application, designed and built from scratch, where content, messaging, the paid booking calendar and notifications all live in one place. A student sees her course, her live classes, the recordings of her one-to-one sessions, her goals and her certificate; the team runs all of it from the same portal, without ever touching the database. The structural decision is a contract: a single OpenAPI file describes the API's 116 operations, and Orval generates two packages from it that nobody edits by hand: the Zod schemas the server uses to validate input and shape output, and the TanStack Query hooks the frontend consumes. API and screen can no longer drift apart in silence: the typecheck fails before the user does. The second rule, written into the code, is that the portal belongs to the client: no integration may take it down. Google, the assistant, email, push and payments each switch off their own feature, say so on screen, and everything else carries on. That holds for all seven.

03

the result

In production, with real students. Hotmart, WhatsApp and the spreadsheet are gone: the course now releases cohort by cohort on a fixed date or "X days after your cohort starts", messaging carries attachments behind the session, and a coaching session is booked and paid for without leaving the page. As of today: ~34,000 lines of TypeScript, 37 tables, 116 API operations, 123 Express handlers across 18 routers, and 107 commits in four months. What I take from it sits mostly in the failures. A host suspending the mailbox for "suspicious activity", with no intruder: it was the portal itself, one announcement going out as thirty simultaneous sends. A Google brand verification that failed twice because the crawler was downloading an empty SPA behind a login. A `drizzle-kit push --force` that offered to empty the students table. Each one left a rule written down somewhere: in the code, in package.json, or in the control document that gets read before touching anything.

scale

the project in numbers

~34,000

lines of TypeScript: 22,400 frontend, 11,800 API

37

tables, plus the sessions table

116

operations described in the OpenAPI contract

123

Express handlers, across 18 routers

107

commits, May to September 2026

7

external integrations, every one of them optional

the brief

what the portal replaced

Hotmart

The course now lives inside: modules, lessons, per-student progress, resources and comment threads under each lesson. And above all the scheduled release per cohort (a fixed date, or "X days after your cohort starts"), which simply didn't exist before.

WhatsApp

Direct student ↔ team conversations and one room per cohort, with photos, videos and links. Work moved off the teachers' personal phones, and the conversation stayed where the students are.

the calendar spreadsheet

Each coach opens their own slots, the student picks a day in Colombian time and pays through ePayco without leaving the page. The booking is held for thirty minutes, then releases itself.

feature work

three working surfaces

the student's portal

"My portal"
  • Six sections in a sidebar, and the last one opened is remembered: 1:1 sessions, live classes, IAgency content, certificates, goals and projects.
  • One-to-one sessions are embedded from Google Drive, with their notes and supporting files.
  • Goals read at three levels (hers, her cohort's, and the whole school's) and the label says who each one is for. The student ticks; the team validates.
  • Under projects, a student posts the link to her work (Drive, Figma, Canva, a website) with attachments, and the team replies in a thread.

the team's panel

"Administration"
  • Ten sections: students, team, cohorts, live classes, announcements, courses, comments, goals, the AI assistant, and the user's own coach profile.
  • The cohort is the unit everything hangs off: who sees which content, who a class is aimed at, which cohort gets a free session with a given coach.
  • Three roles. A teacher shares the panel with the admin except for three reserved actions: adding or deleting students, creating or deleting courses, and publishing announcements. The server enforces it; the frontend merely avoids showing buttons that would return 403.
  • A unified comments inbox, lessons and classes mixed together in thread mode, so replies don't mean walking section by section.

messaging

"Conversations"
  • Direct conversations and one room per cohort: a room's members are derived from the group rather than materialised, so joining or leaving leaves nothing to fix up.
  • Attachments are checked by their magic bytes against the declared MIME type: a .png that actually contains HTML is deleted and rejected.
  • The row is created with message_id NULL and claimed when the message is sent, so the server never trusts a client-supplied path; anything left unclaimed is swept after 24 h.
  • Live refresh without WebSockets: the service worker forwards the push to the open tab, and only the messaging cache keys are invalidated.

feature work

the portal's two faces

The bar at the top is the team's read-only preview: an admin sees the portal exactly as one specific student sees it, without being able to tick or submit on her behalf. The same dashboard builder serves both, so the visibility rules are never written twice. Names and emails blurred.
The "email is working" notice, with its test button, came out of the mailbox suspension: since then the sender's health reads at the top of the panel and can be checked without opening the logs.

messaging

what replaced whatsapp

The conversation list: direct threads with the team on the left, plus each cohort's room. A room's members are derived from the group rather than materialised, so joining or leaving leaves nothing to fix up. Names blurred.
Downloads go through GET /api/uploads/:name behind the session, with range support so a video can be scrubbed without downloading the whole file. Messages and names blurred.

stack

what sits underneath

Frontend

React 19.1.0 pinned in the catalog · Vite 7 · Tailwind CSS 4 · ~28 Radix primitives under shadcn-style wrappers (52 components) · Framer Motion · wouter for 9 routes · TanStack Query over generated hooks · installable PWA with manifest and service worker

API

Express 5 on Node.js 24, bundled by esbuild into a single file with sourcemaps · express-session with a MySQL store, httpOnly and secure cookie, 7 days · Zod generated from the contract on the way in and on the way out · pino · 18 routers mounted under /api

Data

MariaDB/MySQL with Drizzle ORM over mysql2, migrated off the original Postgres in July 2026 · 37 tables across 12 files by domain · no migrations: push in development, hand-reviewed DDL in production · primary keys as int().autoincrement(), never serial

Integrations

Google OAuth, hand-rolled code flow with no added dependencies · Google Calendar and Meet · Groq (openai/gpt-oss-120b, OpenAI-compatible endpoint) · ePayco (Davivienda) · Hostinger SMTP · Web Push VAPID · AES-256-GCM for each coach's Google refresh_token

Monorepo

pnpm workspaces, with npm and yarn rejected by a preinstall hook · versions pinned in the catalog: block of pnpm-workspace.yaml · TypeScript 5.9 with project references · minimumReleaseAge at 1,440 minutes: no npm package published less than 24 h ago enters the project

architecture

contract first

The project's structural decision: there is no server-side type and client-side type that somebody keeps in sync by hand. There is a contract, and two packages that descend from it.

↕ orval: neither generated/ directory is ever touched by hand

Contract

lib/api-spec/openapi.yaml

OpenAPI 3.1: 5,823 lines, 116 operations, 143 schemas. The single source of what the API accepts and what it returns.

Generated

lib/api-zod

Zod schemas and TypeScript types. The API runs Schema.safeParse(req.body) on the way in and Response.parse(...) on the way out: the server cannot return something the contract doesn't describe.

lib/api-client-react

TanStack Query hooks with their own fetch wrapper, base /api. The frontend doesn't write a single fetch by hand; caching and invalidation go by key.

Outside the contract

and deliberately so

OAuth redirects, attachment downloads, the certificate PDF and ePayco's server-to-server confirmation. None of them are JSON: they have no business in the YAML, and forcing them in would have described the system crookedly.

Changing the API is always the same move: edit the YAML, rerun codegen, then touch the Express route and the screen. Frontend and backend cannot drift apart in silence: the typecheck shouts before the user does.

screens

the course, from module to lesson

The whole course, phase by phase. Progress counts only the lessons that particular student can see: three cumulative access levels (course, module, lesson), where no rows at all means "visible to everyone".
The lesson opens where it sits instead of taking you elsewhere: the video, its resources and its comment thread, without losing your place in the module.
Her cohort's live classes, all in Colombian time (COT, UTC−5) and labelled as such. Afterwards the same row carries the recording and the materials. Coaches' names blurred.
The label says who each goal is for: "For you" is individual, "IAgency" is for everyone. The student ticks and the team validates, and that validation counts, because the certificate depends on it.

payments

booking, and paying

Until a coach publishes their profile, they appear in no list at all. The free first session is configured coach by coach, and can be limited to particular cohorts.
The calendar runs in Colombian time and the price in pesos, with an informational toggle to dollars (rate cached 24 h). In this capture the coach hasn't opened any dates yet, and the screen says so instead of offering an empty calendar. What decides that a session is paid is not the browser: it's ePayco's server-to-server notice, and only if the SHA-256 signature checks out, the amount is exactly the booking's, and ePayco itself confirms the reference when asked again.

assistant

an ai that only answers with the asker's own data

A real question and its answer. The context is read back from the database on every question (her courses, her cohort's classes, her sessions, goals, certificates, recent announcements and bookings) under the same visibility rules as the portal, and never with another student's data. That's where the Colombian-time dates and her own meeting link come from. Names and links blurred.
The model, the per-student daily cap (30 by default, counted in Colombian time; the team has none) and the greeting are set here, without touching code. The admin also feeds it documents: the text is extracted (PDF with unpdf, DOCX with mammoth), split into roughly thousand-character chunks, and on each question a BM25 ranking keeps only the useful passages, because Groq's free tier cuts off around 8,000 tokens a minute, so sending everything was never an option. Only the text is kept; the original file takes no disk.

delivery

holding up on shared hosting

No container, no build on the server, no node_modules in the package: the Hostinger panel installs only mysql2 and express-mysql-session. Everything else about the delivery is written to live with that constraint rather than against it.

  1. 01

    One file, built here

    esbuild bundles the API into a single .mjs with sourcemaps. The delivery zip carries the already-compiled server and the static frontend, and nothing else. Nothing is built on the production machine, so no build can fail there.

  2. 02

    Schema before zip, every time

    Schema changes ship as SQL ready to paste into phpMyAdmin (deploy/schema-*.sql), and they go before the code, never after. The other order leaves an API querying columns that don't exist yet.

  3. 03

    index.html read once at startup

    Hostinger deploys into versioned folders and deletes the previous one. With sendFile, every route ended in a 404 after a delivery: the path resolved at startup pointed at a folder that was no longer there. So the file is read into memory once, at boot.

  4. 04

    UPLOADS_DIR outside the deploy folder

    Same cause, more expensive consequence: a relative path wiped out the photos students had uploaded on every delivery. The variable now points outside it, and the warning is written into the control document.

  5. 05

    Every delivery carries its own document, and its way back

    deploy/INSTRUCCIONES.md and deploy/QUE-SUBIR.md state what goes up, in what order, which variables are needed, what changes for the user and what was verified locally. The previous package is kept and dated (portal-deploy-2026-09-11-anterior.zip), so rolling back is uploading a file, not rebuilding one.

engineering

five real failures, and the rule each one left

They happened in production, on the real hosting and with real students. It's the part of the project I learned the most from, so I document it with the symptom as it actually presented.

the host suspended the mailbox

email
symptom
Hostinger suspended the mail account for "suspicious activity" and deleted its contents. Email notifications stopped dead.
cause
There was no intruder: it was the portal. One announcement to a cohort went out as about thirty simultaneous sends, every chat message triggered its own, and with the mailbox already closed the server kept retrying, which looked even more like bulk sending.
fix
lib/mail-policy.ts: a sustainable hourly rate, a daily cap both globally and per recipient, a cooldown between chatty notifications (chat and comments) to the same person, a bounce list, and a circuit breaker that stops dead when the fault is the sender's. All in memory on purpose: these are flow-control measures, not portal data.

google couldn't verify the site

search
symptom
Google's brand verification failed twice in a row, with nothing actually broken on the portal.
cause
The crawler doesn't run the SPA. It was downloading an empty page behind a login, and the verification depended on precisely what it could read.
fix
A public page in plain HTML (public/inicio/index.html), written without a single line of JavaScript, deliberately. It explains what the portal is, what's inside and how data is handled, readable by the crawler and by anyone without an account.

push --force offered to empty the students table

data
symptom
During a schema push, drizzle-kit proposed a TRUNCATE of the students table.
cause
On MySQL, drizzle-kit push --force auto-accepts TRUNCATE of tables referenced by new foreign keys. The flag you reach for so you aren't interrupted is exactly the one that answers yes to the question you needed to read.
fix
The command is forbidden, and blocked in package.json as well: it exits with code 1. Schema goes up interactively, reading each proposed statement, or the table is created by hand with Drizzle-matching DDL. The ban is also one of the red lines in the control document.

the sessions table stopped being created, silently

build
symptom
After bundling, sessions stopped holding. No error, no trace: people simply didn't stay logged in.
cause
express-mysql-session reads its schema.sql from its own package directory at runtime. Bundled by esbuild, the file was no longer where the library looked for it, and the table creation failed in silence.
fix
The package stays in esbuild's external list, with the reason written next to it; otherwise the next person optimising the bundle re-bundles it in good faith.

the native date field returned nothing on the client's phone

mobile
symptom
On her phone, picking a date or time appeared to work, but the form submitted with the field empty.
cause
The native date and time input didn't hand back its value on that device. On ours it was fine, which is exactly what makes this class of failure slow to see.
fix
Custom pickers (date-field.tsx, time-field.tsx) in place of the native inputs. Since then every screen is checked at 375 px before it counts as finished: students come in from their phones, so that's where the page has to be right first.

screens

the way in, and the phone

Google as the main route, the code as a fallback for anyone without an account. The students table acts as the allowlist: an email that isn't registered doesn't get in, valid Google account or not.
The plain-HTML page born of the two failed verifications. It doubles as a trust page: the green block explains what the portal does with a coach's Google Calendar: it reads which hours are already taken, never the content, the title or the guests.
Adding a student, her cohort, regenerating her access code, and previewing her portal. There is no open sign-up: someone on the team creates the account, and that is the only door. Names and emails blurred.
Sections stack and get numbered instead of being squeezed into a sidebar; the schedule keeps the date, the Colombian time and the join button; the conversation list takes the full width. On iPhone, adding the portal to the home screen is the only way to receive push notifications, and it's documented that way for the students.

principles

what I take from it

a downed integration cannot take the portal down

All seven degrade without breaking: Google returns 503 and the access code remains, the assistant says it's switched off, ePayco still lets you book and holds the date while explaining why, email leaves the bell and push. The portal belongs to the client: an expired key cannot lock anyone out.

the browser doesn't decide what's been paid

A session is only paid once ePayco confirms it server to server, with the right signature, the exact amount and a follow-up query to its API. It's the same rule as with attachments: the server never trusts what the client tells it about its own state.

the certificate requires both, not either

Every lesson that particular student can see, completed, and every one of her goals validated by the team; ticking them herself isn't enough, because the document asserts that somebody checked. The verification code uses an alphabet without 0/O or 1/I: it gets read aloud.

the control document describes the scar, not the system

The project has a control document that gets read before touching anything, and it doesn't describe the system: it carries the red lines, and each one exists because something cost dearly once. "This already tried to empty students" gets respected; "don't use --force" gets talked around by someone convinced their case is different.

And one honest note the project insists on: the software is proprietary and the repository private. The screenshots are from production on 17 September 2026, with students' names, emails, access codes and messages blurred.

OpenAPI contract as source of truth AI assistant with its own context Messaging with attachments Payment gateway Verifiable PDF certificates Installable (PWA)
stack

React 19·TypeScript·Vite·Tailwind CSS 4·TanStack Query·Express 5·Node.js 24·MySQL/MariaDB·Drizzle ORM·OpenAPI + Orval·Zod·pnpm workspaces·esbuild·Groq / LLM·ePayco·Google OAuth + Calendar·Web Push·PWA

a similar
project?

let's talk