Case study 03 · Education platform with live classrooms
USS X Academy
A production education platform for a Bulgarian vocational training centre, with course intakes and applications, live classrooms, a regional technician directory and single sign-on across products.
01Overview
USS X Academy is the online platform of a Bulgarian vocational training centre that runs practical courses, including solar installation training. Students apply for city-based course intakes, attend live classes in the browser, and customers can find trained technicians by region.
Why it exists. The training centre needed more than a brochure site. It needed applications with real capacity limits, live lessons with proper host control, and a public directory of technicians, with Bulgarian as the first language.
What I built. The whole software side, alone: two Next.js frontends, an Express 5 API, a separate authentication and single sign-on service, the PostgreSQL schema, the deployment on Railway and the measurement layer for its marketing.
Specification
- Frontends
- Next.js 15, React 19, Tailwind v4: academy site and accounts app
- API
- Express 5 and TypeScript, about 250 routes, hand-written SQL
- Identity
- Separate auth service, Argon2id, single-use SSO tickets
- Classroom
- LiveKit media, admission and moderation owned by the API
- Realtime
- Server-Sent Events fanned out through Redis pub/sub
- Data
- PostgreSQL with core, academy and stemini schemas; 46 migrations
- Contract
- OpenAPI 3.1 (216 operations), generated typed client
- Tests
- 162 backend spec files, 189 frontend test files (Vitest)
- Locales
- Bulgarian by default, English, Russian
- Sign-in works across two registrable domains without sharing a cookie or a credential.
- Classroom chat is deliberately ephemeral. It lives in Redis and is deleted when the lesson ends.
- A student removed mid-lesson stops receiving events immediately, even on a stream that is already open.
- Conversions fire only after the server confirms an application, carry no personal data and are deduplicated.
02What I built
Identity and platform
- A central auth service with registration, verification, password reset, profile, RBAC and GDPR erasure dispatched to each product.
- Cross-domain single sign-on with HMAC-signed, single-use tickets redeemed server to server. Each product mints its own first-party session.
- Sessions stored in PostgreSQL with a Redis cache, sliding idle expiry and an absolute cap, plus CSRF protection on every state change.
- Capability-based access control and an audit log for admin and security events that the code only ever appends to.
Courses and applications
- Courses, modules and lessons, with textbooks, self-tests, free content and an admin course editor.
- City-based intakes with capacity limits. Early-bird and two-person prices are resolved and frozen on the server under a row lock.
- An application funnel for guests and members, with staff review states, email claim links, and notifications to each offering's organiser with a copy to the academy.
- A transactional email outbox drained to Resend, with signature-verified delivery webhooks.
Live classroom
- LiveKit join tokens scoped by room and role, and a waiting room with host approval, lock, mute, mute-all, remove and readmit.
- Host presence, student check-in and attendance records.
- A meeting interface with pre-join checks, an adaptive participant grid, chat and participant panels, and mobile wake-lock and volume fixes.
- An enhanced microphone mode that uses native voice isolation where available, otherwise a DTLN neural model in an AudioWorklet, with a watchdog fallback.
Directory, content and growth
- A technician directory with a keyboard-operable SVG map of Bulgaria's 28 regions and an admin approval, rejection and suspension flow.
- A blog with RSS, server-rendered course landing pages, sitemap, robots rules, llms.txt, hreflang and JSON-LD for courses, FAQs and articles.
- GA4, Google Ads and Google Tag Manager behind Google Consent Mode v2 in basic or advanced mode; the Meta Pixel always waits for consent.
- First-touch UTM attribution stored with each lead, plus a Google Ads provisioning script and runbook for the campaign's technical setup.
03Architecture
Separate services with clear ownership. The API owns academy rules, the auth service owns identity, LiveKit carries media, PostgreSQL is the system of record and Redis holds everything that should not live forever.
Production services
Clients
ClientAcademy frontend
Next.js 15, 72 routes, bg / en / ru
- connects to LiveKit
- connects to Academy API · REST + CSRF, SSE
- connects to Accounts app · login
ClientAccounts app
hosted login, account pages
- connects to Auth and SSO service · proxy
ExternalLiveKit
WebRTC media only; join token from the API
Services
ServiceAcademy API
Express 5, ~250 routes, SSE realtime, LiveKit tokens, signed file URLs
- connects to Auth and SSO service · redeem ticket
- connects to PostgreSQL · SQL
- connects to Redis
- connects to File volume
ServiceAuth and SSO service
Argon2id, master session, single-use tickets
- connects to Academy API · redeem ticket
- connects to Stemini services
- connects to Redis
Other productStemini services
separate product, shared identity
State
StorePostgreSQL
core · academy · stemini, 46 forward-only migrations
StoreRedis
sessions, SSE fan-out, class chat, nonces
StoreFile volume
HMAC-signed, short-lived URLs
Cross-domain sign-in without shared cookies
Row 1
ServiceAcademy /auth/login
redirect with an allow-listed return
- connects to Accounts authorize
ClientAccounts authorize
hosted login, __Host- master cookie
- connects to Signed ticket
StageSigned ticket
HMAC-SHA256, 60 s, bound to one app
- connects to Academy /sso/callback
ServiceAcademy /sso/callback
redeem server-to- server, service auth
- connects to Academy session
- connects to Redis nonce ledger · burn
StoreAcademy session
first-party HttpOnly cookie plus CSRF
Row 2
StoreRedis nonce ledger
burned atomically, replay refused
04Hard problems
Revoking access on streams that are already open
Problem
Checking access when a realtime stream opens is not enough. A student removed in the middle of a lesson could keep receiving chat on a connection that was valid when it started.
What I did
Revocations are pushed over a Redis control channel to every instance, and a cached re-check runs before each sensitive event, which also catches access that simply expired.
Seats and early-bird places under concurrency
Problem
Applications that arrive together can oversell an intake's capacity or its pool of discounted places.
What I did
Seats are counted under a row lock on the course, and the price is resolved and frozen on the server at the moment the application is created.
Single sign-on across domains
Problem
The academy and Stemini live on different registrable domains, so they cannot share a cookie, and no credential or master session should ever cross between them.
What I did
Login happens once on the accounts app. The product receives a 60-second, single-use ticket bound to that product, burns its nonce atomically in Redis and redeems it server to server.
Classroom audio on real devices
Problem
Students join from phones and laptops in noisy rooms, and in-browser neural noise suppression can stutter on slow hardware.
What I did
A three-step chain of native voice isolation, then a DTLN model running as WASM in an AudioWorklet, then standard browser processing, guarded by a watchdog. Each frame over its 20 ms budget adds a strike, each good frame removes one, and three strikes fall back to standard processing.
Two products, one schema and one identity
Problem
The academy and Stemini each started with their own database, which would have meant two accounts per person.
What I did
I consolidated them into namespaced core, academy and stemini schemas, with a data-migration plan and verification SQL.
05Decisions
- Classroom chat is stored only in Redis and deleted when the lesson ends.
- Keeping lesson chatter would create retention, moderation and export obligations for content nobody needs. Attendance and the audit trail stay in PostgreSQL.
- Hosted login with a ticket handoff; every product mints its own session.
- Cookies cannot cross registrable domains, and no credential or master cookie should.
- The email outbox is written in the same transaction as the business fact.
- An owed email is never lost if the process dies between commit and send, and raw claim tokens never reach the database.
- Lead conversions carry no value and no personal data.
- A lead is not revenue, and names, emails and phone numbers are never sent to an ad platform.
- Hand-written SQL and forward-only migrations, with 25 recorded ADRs.
- The SQL schema is the source of truth, and changing a structural rule, such as adding an ORM, requires a written decision.
06Status
Works today
- In production on Railway: academy site, API, accounts app, auth service, PostgreSQL and Redis.
- Live classrooms, course intakes and applications, the technician directory, the blog and consent-aware tracking are deployed.
- Development continues on the branch that production deploys from.
Not built yet
- Stripe billing, entitlements, an AI assessment engine and an AI coach. These are built on a parked branch and are not deployed.
- Lesson recording through LiveKit egress, which is documented but not implemented.
- Separate development and staging environments.
- Automated contract tests between the API and its OpenAPI specification.
The training centre owns the business, so pricing, content and campaign decisions are theirs. My part is the software, the infrastructure and the technical side of marketing (tags, consent, conversion events and campaign setup scripts). Campaign performance data belongs to the client and is not published here.
07What I learned
- Realtime features need access checks on every event, not only when the connection opens.
- Measurement is engineering too. Consent, attribution and deduplication decide whether an ad platform learns from real signal or noise.
- Keeping the schema, the API contract and the decision records in one umbrella repository keeps a multi-service system manageable for one engineer.
08Stack
- Next.js
- React
- TypeScript
- Tailwind CSS
- Node.js
- Express
- PostgreSQL
- Redis
- LiveKit
- Zod
- OpenAPI
- Vitest
- Resend
- Railway
- Docker
- Google Analytics 4
- Google Ads
- Google Tag Manager