EduPrime
Platform brief · 23 Sep 2026

The platform your schools run on — not a prototype, a production system.

EduPrime is a multi-tenant learning platform: admissions, live classrooms, tuition billing, AI-assisted teaching and a partner API, all behind one deployment where every school's data stays provably apart from the next school's. It is already running — real students, real live classes, real tuition payments.

This page is the scope of what's built, the numbers behind it, what it means commercially, and — just as importantly — the parts that are honestly still unfinished.

Counted from the codebase and verified test runs, 21 Sep 2026
359
API routes
119
data models
60
DB migrations
~120k
lines of backend TS
543
automated test cases
36
scheduled jobs
688
commits in 8 weeks

394 of the 543 tests were run live alongside other suites with one incidental timeout (it passes alone in 1.3s); CI runs the full 543 on every merge to main. Size figures are counted from source, not estimated.

What's built

Multi-tenant by construction

One Prisma client adds the tenant filter itself; a query with no tenant in context throws instead of returning everyone's rows. CI fails the build if a new table doesn't declare who owns it.

For the business: a second or tenth school is a config change, not a rebuild — and not a data-leak risk.

A payment ledger, not a spreadsheet

Paystack webhooks are signature-checked and idempotent; tuition runs as a per-level ledger that pays the oldest charge first.

For the business: tuition collection that holds up to an audit, on day one.

Live classrooms, built in

LiveKit-based rooms with token minting, automatic recording, and moderation — no separate video vendor to integrate.

For the business: delivery infrastructure you don't buy or integrate separately.

AI-assisted teaching

A class-notes pipeline (transcription and extraction) and an AI tutor with tested failover across three providers.

For the business: less staff time spent re-explaining material, more of it spent teaching.

It watches itself

Deduped incident tracking, a "drift" check comparing what the browser actually rendered against what the database says should show, and a developer console generated from source.

For the business: problems surface before a parent calls to complain about them.

A partner API, not a demo

Scoped keys, cursor pagination, idempotent writes, and outbound webhooks signed and retried for 17 hours.

For the business: ready to be integrated into a partner's systems now, not bolted on later.

Tenant scoping is not opt-in

Every query in EduPrime passes through the same Prisma client, and that client adds the tenant filter itself. A query that runs with no tenant in context throws — it doesn't quietly return every school's rows. Getting the tenant to a query several function calls deep needed AsyncLocalStorage, with the auth gate installing an empty holder first and filling it in once the session lookup returns. A CI check fails the build if a table is added without saying whether it belongs to a tenant.

lib/tenant/context.ts · extension.ts · registry.ts · isolation.test.ts
Browser or partner API HTTPS proxy.ts rate limit, portal gate route Route handler requireAuthSession() sets tenant + actor cold-start retry (only when the DB never answered) soft delete · audit trail · 200-row cap tenant scope adds WHERE tenantId throws if no tenant is set one Prisma client query pooled Neon Postgres audit table rejects UPDATE and DELETE (a trigger) outbound calls guardedFetch() deadline + breaker fails fast Paystack · Groq · Anthropic · DeepSeek one breaker per provider Browser or partner API HTTPS proxy.ts rate limit, portal gate route Route handler requireAuthSession() sets tenant + actor query cold-start retry (only if the DB never answered) soft delete · audit trail · 200-row cap tenant scope adds WHERE tenantId throws if no tenant is set one Prisma client pooled Neon Postgres audit table rejects UPDATE and DELETE (a trigger) Calls to outside providers leave separately: guardedFetch() deadline + breaker fails fast Paystack · Groq · Anthropic · DeepSeek one breaker per provider
Every query goes through the same three layers, so a route can't skip tenant scoping, soft deletes or the audit trail. Anything leaving for a payment provider or an AI vendor goes through one wrapper with a deadline and a circuit breaker.

Deletes it can undo, and a log it can't edit

The schema has 222 cascade relations, so one careless delete could quietly take payments and grades with it. Deletes on the 15 models that matter — users, students, payments, certificates — become soft deletes, and a bulk delete with no filter, or one touching more than 200 rows, is refused. Changes to money, marks and identity get a before-image in an audit table, and a Postgres trigger blocks updates and deletes on that table itself. If someone got into the app, the app would not be the tool they could use to cover their tracks.

lib/prisma-guard.ts · lib/audit-context.ts · lib/audit-restore.ts

Backups that have actually been used

A nightly encrypted pg_dump, plus a monthly job that restores the newest dump into a throwaway Postgres and checks the row counts against the live database. On 3 September, production was reset to an empty schema by an environment mix-up. It was caught from the failing migration step and fully restored from a point-in-time backup — about an hour end to end. A CI check now fails the build if a production database URL turns up somewhere it shouldn't.

.github/workflows/backup-database.yml · restore-drill.yml · guard-prod-db-url.yml

Every outbound call has a deadline and a breaker

Calls to Paystack and the AI providers used to have no timeout — a call that just hangs never trips a breaker that only counts failures. Now every outbound call goes through one wrapper: a deadline first, a breaker second. Network errors, timeouts, 5xx and 429 count against a provider; a declined card doesn't, so a few mistyped emails can't switch off payments for everyone. Neon suspends when idle, so the first query after a quiet spell can fail — that one connection error is retried, because nothing reached the server, so a retry can't write twice.

lib/resilience.ts · lib/guarded-fetch.ts · lib/prisma-cold-start-retry.ts

36 scheduled jobs that no longer wait on each other

It used to be one daily function running all 36 jobs in a row inside a 300-second limit — one partner endpoint that never answered would starve everything behind it. The jobs now run in five lanes split by what they depend on (payments, delivery, nudges, housekeeping, AI, media), each with its own time budget, running at the same time. A stuck job is dropped at its lane's cap and reported as timed out; the rest of the lane carries on.

lib/cron-lanes.ts · app/api/cron/tick/route.ts
Before: one function, jobs in a row schematic; where the hang lands is an example a partner webhook never answers function killed at 300s 21 jobs never started, so they wait until tomorrow After: a short prelude, then five lanes at once prelude 30s. payment-plans decides who is locked out, runs first and alone. delivery (6 jobs) 90s nudges (16 jobs) 150s housekeeping (9 jobs) 120s. Webhook cut at 30s, lane goes on. ai (2 jobs) 150s media (2 jobs) 200s whole tick capped at 270s 0 100s 200s 300s Before: one function, jobs in a row one webhook never answers 21 jobs never started, they wait a day function killed at 300s After: prelude, then five lanes at once tick capped at 270s prelude: payment-plans, 30s delivery, 6 jobs, 90s nudges, 16 jobs, 150s housekeeping, 9 jobs, 120s webhook cut at 30s (red), the lane carries on ai, 2 jobs, 150s media, 2 jobs, 200s 0 100s 200s 300s
Same 36 jobs. The old version lets one hang eat the whole function. The new one runs five lanes side by side, and the hung webhook is dropped after 30 seconds while the rest of its lane finishes. Lane budgets are the real ones from the code.

Margins are an engineering problem too

On one day, close to a terabyte moved through serverless functions because every video range request was being proxied through them. Recordings, materials and offline downloads now redirect to short-lived signed bucket URLs instead, and a CI check fails any route that streams a file without explaining why that's okay. Transfer spend went from about $55 on the worst day to roughly $1.50 a day — a cut of more than 97%, with nothing removed for the user.

lib/storage.ts · scripts/check-file-streaming.mjs

Payments, and the rule that used to disagree with itself

The Paystack webhook checks its signature with a constant-time compare and is idempotent on the payment reference, so the same event landing twice does nothing twice. The question "is this student allowed into the portal?" had been worked out separately in 13 places, and the copies disagreed — the admin screen said open while the student saw a padlock. It's now one function, with a CI check that fails the build if a route works it out on its own.

app/api/paystack/webhook/route.ts · lib/access.ts · lib/finance/ledger.ts

Proven with a real tenant, not a demo

EduPrime's first tenant is a German-language school in Nigeria — roughly 400 students, live classes, and card payments switched on for real since 2 September 2026. Everything above is what runs underneath the page below.

An admin console showing enrollment, tuition, a payment funnel and attendance for a tenant running on EduPrime; the specific figures are shown blurred, matching the console's own privacy mode.
The console a tenant's staff logs into — registrations, tuition, a payment funnel, attendance. Most of it ships in the product's own privacy-blur mode by default; the few figures that weren't already blurred here have been, so this brief doesn't put anyone's live numbers on a page a stranger can open.
The public home page of a school running on EduPrime: a panel with a headline about learning German for relocation and a list of what students get.
The tenant's public front page, live today. Everything on this brief is about what sits behind it.

What this means for a business

Honest gaps, and what closes them

What it isn't, yet. One Postgres primary in one region — no multi-region failover. Circuit-breaker and bulkhead state live inside each server instance rather than a shared store, a deliberate choice to keep another dependency out of the failure path, but it means the breakers don't share state across instances at higher scale. Postgres row-level security is written but not switched on — tenant isolation today is enforced at the application layer, which is strong but not the database's own last line of defense. The architecture doc has a section called "Honest limits" — it's the fastest way to see exactly what's finished and what isn't.

Interested in EduPrime for your institution?

Happy to walk through the architecture, the numbers, or a live demo on a call.

Get in touch