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.

Status: ProductionLive at ussxacademy.bg, in active development
Role
Sole engineer across architecture, backend, frontends, auth, schema, deployment and tracking
Timeline
May 2026 to present
Context
Client platform; the training centre owns the business and content
Layers
Web · Backend · Realtime
Links
ussxacademy.bg (opens in a new tab)
Private client repositories

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.

Fig. 03.1

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

Two Next.js frontends, an Express 5 API and a separate auth service, all deployed on Railway. PostgreSQL is the system of record with core, academy and stemini schemas; the auth service owns identity in the core schema. LiveKit carries only media: who may join, admission, locking and moderation are decided by the API. Email goes through a transactional outbox to Resend, and background loops run inside the API process.
Fig. 03.2

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

Products on different registrable domains cannot share a cookie, and no credential should cross between them. Login happens once on the accounts app; the product receives a short-lived, single-use signed ticket, redeems it server-to-server and mints its own first-party session.

04Hard problems

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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