How the RISE AI Interview Coach is built, deployed, and operated — the application layers, data model, external integrations, hosting topology, and the automated release pipeline.
00 — Executive Summary
RISE is the AI-powered interview-coaching platform delivered under the Erasmus+ project KA210-VET-778D8F70. It lets vocational-school students practise realistic job interviews and receive immediate, rubric-based feedback in their own language, while educators and counsellors follow each student's progress. This document describes the technical architecture and infrastructure that deliver that vision — and shows how each engineering choice maps to a stated objective of the funded proposal.
The platform's guiding principle is a small, low-cost, provider-agnostic footprint that protects student data by default and can grow — from a single server, to a school's own network, to the cloud — without being rebuilt.
v2.2 — 15 September 2026. One new layer, and two sentences that had stopped being true:
v2.1 — 14 September 2026. Four tables the document had never named, and one sentence that had become untrue:
password_resets, platform_settings, org_settings and transcription_calls. The last had been missing since voice input shipped.v2.0 — 3 September 2026. Three changes since v1.0 alter the shape of the system rather than its details, and each is why a section below now reads differently:
| Goal (KA210-VET proposal) | How the architecture delivers it |
|---|---|
| AI-driven interview practice with real-time, structured feedback | A server-controlled interview engine calls a hosted LLM per answer and returns validated, rubric-scored JSON feedback and a session summary. |
| Multilingual & inclusive (Serbian, English, French, Portuguese) | Four-language UI and content; feedback generated in the student's language. |
| Accessible across devices (desktop, tablet, mobile) and for SEN students | Responsive single-page app; accessibility baseline extended at launch (see §12 and the platform PRD). |
| Data privacy & GDPR compliance for minors and SEN students | Inference and hosting inside the EU; pseudonymous student accounts; every staff read of a student record recorded; a person downloads their own data themselves. Student data can stay entirely on-site via the on-prem model (§15). See §12. |
| Progress tracking & analytics for students and educators | Full interview history, scores, and per-answer feedback persisted in PostgreSQL, ready for longitudinal dashboards and KPI export. |
| Educator / counsellor guidance tailored to each student | Role-based access (student · teacher · school administrator · platform administrator), scoped to the teacher's own school. Neither administrator role can read a transcript; the only readers of a student's answers are that student and teachers at their own school. |
| Staff-managed content without developer involvement | Each school points the platform at its own spreadsheet and imports it when ready — no code change to add content, and editing a sheet changes nothing until it is imported. |
| Scalable, reliable, modular architecture for future expansion | Stateless app behind a proxy, isolated module boundaries for AI, email, content, and persistence; scales horizontally and swaps providers cleanly. |
| Low-cost, sustainable operation | Runs on a single modest host; per-message model/token accounting provides the basis for cost and eco-footprint reporting. |
| Provider independence & open, portable deployment | One codebase runs on a managed VPS, a school's own network with a local LLM, or AWS — configuration only, no re-architecture (§15). |
01 — Introduction
RISE is a server-rendered-agnostic web application: a React single-page app served by a single Node.js/Express process, backed by PostgreSQL, with AI feedback from a hosted large-language model and interview content managed in a spreadsheet CMS. The design favours a small, low-cost, provider-agnostic footprint that can run on one modest server and move to larger infrastructure without rework.
| Concern | Choice |
|---|---|
| Frontend | React 19 + Vite 6 SPA |
| Backend | Node.js + Express 4 |
| Database | PostgreSQL |
| AI | Scaleway Generative APIs (Qwen), Paris |
| Content store | PostgreSQL (imported from Sheets) |
| Content authoring | Google Sheets → DB import |
| Resend (transactional) | |
| Edge | nginx reverse proxy + TLS |
| Runtime mgmt | systemd |
| Release | GitHub push-to-production (CI/CD) |
02 — The big picture
The browser talks to a single application server through nginx. That server owns the database and, at request time, calls out to just two managed services — the LLM provider and the email provider. Interview content lives in PostgreSQL; a spreadsheet is only a human authoring surface, imported on demand and never read during a user request. Each of the three schools has its own spreadsheet and its own content, and one system holds all three, separated from each other.
03 — Technology
Deliberately mainstream, well-supported components with no exotic dependencies — chosen so the platform is cheap to run and easy to hand over.
| Layer | Technology | Role |
|---|---|---|
| Client | React 19, React Router 7, Vite 6 | Single-page app; built to static assets by Vite |
| Server | Node.js, Express 4 | REST API, session handling, serves the built SPA |
| Auth | express-session, connect-pg-simple, bcryptjs | Cookie sessions stored in Postgres; hashed passwords |
| Validation | Zod | Schema validation of Sheets rows and LLM JSON output |
| Database | PostgreSQL (pgcrypto) | Durable store; UUID keys; JSONB feedback |
| AI | Scaleway Generative APIs — Qwen (OpenAI-compatible), Paris region | Rubric feedback & session summaries as structured JSON. Chosen over Together.ai in August 2026 for EU data residency: student answers are written by minors and must not leave the EU |
| Content store | PostgreSQL | Scenarios, questions, rubrics served from the database |
| Content authoring | Google Sheets API (google-auth-library) | Human editing surface; imported into the DB offline |
| Resend | Transactional invitation emails | |
| Edge | nginx | TLS termination, reverse proxy to :3002 |
| Runtime | systemd | Process supervision, restart, logging |
| OS | Linux VPS | Single host; provider-agnostic |
04 — Application
One Express process exposes a REST API under /api and serves the compiled React app for everything else, with an SPA fallback. Internally the backend is organised into thin routers over a set of focused service modules.
AuthContext holds the logged-in user; the API is called with credentialed fetch (session cookie)./api, one per audience — auth, student, teacher, school administrator, platform administrator — plus a /health endpoint for uptime checks.stats/rules.js holds the decisions and touches nothing — which window a period means, what it compares against, when a cohort is too small to publish — so they are testable as functions rather than as fragments of a query. db/stats.js is only SQL. stats/report.js runs them together. One router mounts twice, at /api/platform/statistics and /api/admin/statistics, and takes its scope from the actor rather than the request.05 — Runtime
The most involved path in the system: a student submits an answer and receives structured AI feedback. In a single request it reads and writes the database and calls the LLM — content is already local, so the spreadsheet is never touched.
POST /api/sessions/:id/answers arrives; nginx forwards it to Node on 127.0.0.1:3002 with forwarding headers.interview_messages.answer_feedback (JSONB + overall score) and an AI message row records model + token usage.Worth tracing separately because it is the only path that can refuse, and because what it refuses on is counted at exactly one moment.
completed_at is null on an abandoned session and a cooldown anchored there would never begin for somebody who opens an interview and walks away.06 — Data
Four core interview tables (below), a set of imported content tables, and a session store — all in PostgreSQL. UUID primary keys (pgcrypto), foreign keys with cascade delete, CHECK constraints for enumerations, and JSONB for AI feedback. Schema is versioned by an in-repo migration runner.
organizations — slug, name, country, default language, and a status that blocks sign-in when suspended.users.org_id — required for every role except super_admin, which must have none. Both directions are a CHECK constraint, not a convention.user_invitations.org_id — an invitation carries the organisation it was issued for, so accepting a link cannot place someone in a school nobody chose.groups, group_teachers, group_students — classes within a school. Built and deliberately empty. Scoping is additive: a teacher with no group assignment sees their whole school, so an empty table behaves exactly as if groups were switched off.org_settings — one row per school, created lazily: the paragraphs of the privacy notice only a school can supply (controller identity, data protection officer, lawful basis, retention periods, supervisory authority) and the support contact its students are shown. Kept off organizations deliberately — that row is read on every request through loadActor, and a data protection officer's telephone number has no business being carried through all of them to serve a page opened twice a year.platform_settings — a single row, enforced by a boolean primary key with a CHECK, holding what every school inherits: the interview limits and the platform's own support address. A school overrides a limit by setting its own; NULL means "whatever the platform says".users.status — active or suspended. Two values and not three: to the person holding the account, it is either usable or it is not.org_content_source — which spreadsheet a school imports from, and the result of the last connection test. No credentials are stored: access is granted by sharing the sheet with the platform's service account.content_versions — one row per import, at most one current per school.content_scenarios, content_questions, content_rubrics — keyed by version. An import never overwrites; it adds a version and makes it current, so reverting a bad import is a flag, not a reconstruction.interview_sessions.content_version_id — the pin, ON DELETE RESTRICT.audit_log — every staff read of a student's record: actor, action, subject, time. It holds no content, and survives the deletion of the account it describes, so an erasure cannot quietly erase the evidence that the data was read. Its CHECK-constrained action now also admits suspend and reactivate: suspension is done to a data subject and nothing else would record who did it. Reactivation is logged for the same reason — an account suspended and quietly restored is the shape of a mistake.data_requests — erasure requests as rows with a stored due date, because Article 12(3) starts a one-month clock and an inbox has neither a due date nor evidence.answer_criterion_scores — per-competency scores as rows rather than inside the JSON, so reporting is a query and the numbers survive encryption of the prose.answer_feedback.answer_char_count — kept outside the text it measures, for the same reason.session — the connect-pg-simple cookie-session store, auto-created. Note it stores sess as json, not jsonb, which is why ending somebody's open sessions matches on sess ->> 'userId' and never ->: there is no equality operator against jsonb.password_resets — single-use, one-hour tokens, stored as a hash. The only self-service recovery route in the platform, and therefore the reason an account on an address that receives no mail cannot recover itself.transcription_calls — one row per dictation sent to Whisper: model, audio seconds, and the cost, CO₂ and water estimates that go with it. No audio and no transcript. The recording is held in memory for one upstream call and dropped, so there is no recording of a minor's voice anywhere in this system — which is what makes the privacy notice for that feature short.llm_model, token, cost, CO₂ and water columns — the foundation for cost & sustainability reporting. Costs are estimates from the retail catalogue, never billed amounts, and carry the rate-table version that produced them.(student_id, started_at) and (session_id, created_at) for the common list/replay reads, and (student_id, scenario_id) for the interview-limit check that runs on every attempt to start one.0001…0006) idempotently at deploy time — no external migration tool. Roles grew from student/teacher to include admin, and languages from sr/en to add fr/pt, each as its own migration.07 — External services
Two managed services are called at request time — the LLM and email — each behind its own module so it can be swapped or mocked independently. A third integration, Google Sheets, is used only offline to author content and import it into the database; it is never on the user request path. All calls are outbound HTTPS authenticated with secrets from the environment file.
google-auth-library), validates rows with Zod, and upserts them into PostgreSQL.MAIL_TRANSPORT chooses. A host may legitimately carry the configuration for both, so the choice is declared rather than inferred from which variables happen to be set — inference is how a host ends up sending through the transport nobody meant to use, with two plausible explanations and only one of them looked at.08 — Security
Classic, robust server-side sessions — not JWTs in local storage. The session identifier lives in a hardened cookie; all session state is in PostgreSQL.
| Property | Value | Why |
|---|---|---|
| Mechanism | express-session + connect-pg-simple | Server-side sessions persisted in Postgres; survive restarts |
| Cookie name | rise.sid | Opaque session id only — no user data in the cookie |
| Flags | httpOnly, sameSite=lax, secure (prod) | Not readable by JS; CSRF-resistant; HTTPS-only in production |
| Lifetime | 7 days | Balances convenience against stale sessions |
| Passwords | bcryptjs hashes | Salted, slow hashing; plaintext never stored |
| Proxy trust | trust proxy = 1 (prod) | Correct protocol/secure-cookie behaviour behind nginx |
| Roles | student · teacher · admin · super_admin | Route-level authorization, ownership checks on sessions, and an organisation predicate on every query that reaches a student record |
| Tenancy | org_id from the database, never the request | Read fresh on each request rather than trusted from the cookie, so suspending a school takes effect at once and a client-supplied organisation identifier — the classic tenancy bypass — is never consulted |
| Account status | users.status, re-read on every request | Suspension must take effect now, not when a cookie happens to expire. See the three-point enforcement below |
| Recovery | Single-use token, one hour, stored hashed | Self-service password reset. The response never differs between a known and an unknown address, so the form cannot be used to ask which children are registered |
withActor re-reads the status from the database on every request that matters, because a session store is a cache and a permission check that trusts a cache is not a permission check.
And suspending deletes the account's open sessions, so the person stops where they are rather than continuing until their cookie lapses.
Sign-in alone would leave live cookies working; session deletion alone would trust the cache.The shortest true statement of the access model, and the one the impact assessment rests on: that student, and teachers at that student's own school. Nobody else.
A school administrator manages accounts and carries out erasures — the deletion dialogue counts what it destroys and shows none of it — and a platform administrator manages organisations. Neither can open a transcript or export one, and no endpoint exists that would return either. That is what makes the claim durable rather than a matter of interface design: it holds exactly as long as no such endpoint is added.
Aggregates are the exception, and are bounded on purpose. The statistics pages give both kinds of administrator counts, sums and means over groups — never a row about a person, and never a link from a figure to the student behind it. Two things keep that from eroding. Every query in db/stats.js returns a group, so there is no endpoint to misuse; and any cohort below five students is withheld rather than averaged, because an average over two students read by their own teacher is not anonymous. Withheld rows are returned with a reason rather than dropped, so a suppressed group cannot be mistaken for an empty one.
WHERE — and here what leaks is interview transcripts written by minors. Row-level security is the intended backstop and is not yet in place; it needs a transaction or a dedicated client per request, and was deliberately separated from the scoping change so that a regression in either would be attributable. See §14.09 — Hosting
Everything runs on a single Linux host: nginx at the edge, one Node process under systemd, and PostgreSQL on the same machine over the loopback interface. No containers, no orchestration.
/opt/apps/rise/current — the deployed Git checkout (working tree)./opt/apps/rise/shared/.env — secrets & config, outside the repo.frontend/dist — the built SPA served by Node./var/log/rise/app.log — combined stdout/stderr.deploy user; Restart=always with a 5s backoff.SIGINT with a 20s stop timeout.EnvironmentFile — the same file the deploy script sources for migrations.:3002, owns a single database, and is fronted by its own nginx server block.10 — Delivery
Deployment is driven by GitHub. Merging to the production branch through a reviewed pull request triggers a GitHub Actions workflow that builds, tests, and then releases to the server — a conventional, auditable push-to-deploy pipeline. Builds and tests run on GitHub's runners, not blind on the production host.
production branch via a reviewed pull request — the single trigger for a release, with approvals and full history recorded on GitHub.rise.service and probes /health; a failed check fails the deployment so a bad release is caught immediately. systemd keeps the process up with Restart=always as a safety net.11 — Environments
Three environments across the engagement, sharing one codebase and configured entirely through environment variables.
| Environment | Where | Purpose |
|---|---|---|
| Local | Developer machine | Vite dev server + Node with live reload; local Postgres |
| Staging / Pilot | Vendor-owned server (funded by Zoran Krnetić) | Real students run pilot sessions against the Month-1 build |
| Production | Infrastructure provided by Marabunta | Launch environment for real operation (Month 3) |
12 — Security
The baseline below is in place today. The launch-preparation phase extends it with GDPR-grade controls (see the platform PRD, §7.1) — encryption at rest for student-authored content, an audit log of record access, data-subject-rights endpoints, and retention automation.
GET /api/me/export — a person downloads their own data immediately. No administrator can export anyone else's; that endpoint was removed.DPIA.md) and a transfer pack drafted for the three schools' advisers.psql level is evidenced too — the application log cannot see it.z.object strips what it does not declare, a body carrying "role": "admin" arrives at the handler without it. A test fails if the field is ever added — validating it would let a teacher mint an administrator of their own school with nothing on screen looking different.<placeholder> text visible to students — see the data model note on org_settings.student_answer and feedback prose. Unblocked — the scores and answer length were moved out of the JSON so they survive it — and scheduled after the infrastructure migration, so a key that must never be lost is not introduced during a database move.13 — Operations
GET /health returns a simple OK for uptime checks and proxy probes./var/log/rise/app.log; deploy output visible via journalctl.app.log (logrotate) to bound disk use./health with alerting.14 — Evolution
The current single-process design comfortably serves classroom-scale, bursty pilot usage. The modular seams mean it can grow along well-understood axes without a rewrite.
llm/llm.js.15 — Options
The default target is a single managed host (a VPS or the infrastructure provided by Marabunta). Because the stack is provider-agnostic and every external dependency sits behind a module boundary, two other models are fully supported without re-architecture — a fully on-premises install inside a school's own network with a locally hosted LLM, and a managed cloud deployment on AWS.
The entire platform — application, database, and the language model — runs on a machine inside the school's own network. Nothing about a student's practice ever leaves the building. This is the strongest possible privacy posture for processing minors' and SEN students' data, and it keeps working even with poor or no internet connectivity.
Ollama or vLLM.For scale, high availability, and managed operations, the same application maps cleanly onto standard AWS building blocks — nothing in the code changes, only where each piece runs.
eu-central-1) and keep student data within EU data residency.| Managed VPS / Marabunta default | On-prem + local LLM | AWS cloud | |
|---|---|---|---|
| Data location | Vendor / partner host | Inside the school | AWS region (choose EU) |
| AI inference | Scaleway, Paris (hosted) | Local model (Ollama / vLLM) | Bedrock, EU region |
| Internet needed at runtime | Yes | No | Yes |
| Ongoing cost profile | Low, fixed | Hardware up-front, low run | Usage-based, higher |
| Scaling & HA | Manual, single host | Single site | Managed, elastic |
| Best for | Pilot & launch | Max privacy / low connectivity | Large multi-school scale |