Measuring staff, not guessing
A hospitality training organization wanted to measure staff competency at scale. The way they did it was manual and unstandardized, so there was no reliable way to line up one person against a hotel’s team, or an academic cohort against an industry baseline. The platform had to hold four kinds of users at once from a single system: a super-admin curating a large question bank, employers reviewing their own teams, individual test-takers, and academic groups.
What pushed it past a plain quiz tool was the data. Performance results fell under GDPR, so employers could only see post-employment scores while individuals kept their full history. And the numbers had to be trustworthy enough to act as a benchmark, which put exam integrity near the center of the design rather than off to the side.
Three portals behind one gateway
I split the product into three role-scoped portals, super-admin, employer, and quiz, all sitting behind a NestJS API gateway with JWT and RBAC. A single sign-on carries a user across the portals, but permissions stay partitioned at every boundary, so an employer account can never reach into another tenant’s data. PostgreSQL on a managed host (Railway) holds the records, object storage keeps the question multimedia, and Stripe runs the billing.
The assessment engine
The engine took the most design attention. A configurable question bank drives five question types (true/false, multiple-choice, dropdown, multiple-answer, and drag-and-drop matching). Each type supports multimedia, timed sessions, and auto-save, so a quiz interrupted mid-way resumes where the candidate left it.
Generating a quiz is not a plain random draw. Every quiz is assembled in three passes inside a database transaction, then locked to a snapshot of the config it was built from.
That snapshot is the part worth calling out. Because each quiz freezes the configuration it drew from, a later edit to the question bank cannot reach back and change a quiz that is already in progress or already scored. For something meant to be a benchmark, that repeatability is a requirement, not a detail.
Keeping scores honest
Integrity is recorded on the session itself. The case that took real care was separating a tab switch from a click into a different window, because the browser reports both the same way through the Page Visibility API. The tracker keeps a focus flag and classifies the exit only when the candidate comes back:
// The Page Visibility API reports an alt-tab and a click into
// another window identically. Keep a focus flag to tell them apart.
export function useTabSwitchTracker(instanceId: string) {
const leftAt = useRef<Date | null>(null);
const windowFocused = useRef(true);
useEffect(() => {
const onVisibility = () => {
if (document.hidden) {
leftAt.current = new Date();
return;
}
if (!leftAt.current) return;
logSwitch(instanceId, {
type: windowFocused.current ? 'tabSwitch' : 'windowSwitch',
leftAt: leftAt.current.toISOString(),
returnedAt: new Date().toISOString(),
durationSeconds: (Date.now() - leftAt.current.getTime()) / 1000,
});
leftAt.current = null;
};
const blur = () => (windowFocused.current = false);
const focus = () => (windowFocused.current = true);
document.addEventListener('visibilitychange', onVisibility);
addEventListener('blur', blur);
addEventListener('focus', focus);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
removeEventListener('blur', blur);
removeEventListener('focus', focus);
};
}, [instanceId]);
} Every switch lands on the quiz instance with its timestamps and duration, next to the IP and session timing. A reviewer sees the pattern, so a score carries context instead of standing alone.
Running the build
I ran this one across product, design, web, engineering, and QA on a milestone roadmap: the auth and platform foundation first, then the quiz engine, academic management, dashboards and reporting, the user-management portals, and Stripe billing last. I put a demoable slice in front of the client early, auth through to a working quiz and its results, so client sign-off never had to wait on the engine and the analytics being finished.
Privacy and integrity were the two places this product could lose trust and not get it back. That meant GDPR-consented collection, account-conversion rules that preserve a user’s history while limiting what an employer can see, RBAC checked at every portal boundary, and a QA bar of full coverage on new APIs held against a 100-concurrent-session performance target.
Outcomes
- 3 portals
- <3s
- 5 types
- Lead
- GDPR
- Demo → MVP