Skip to content
Huy Vo
Work

Tanca.io · HR SaaS · corporate training

Tanca LMS

Corporate training across API, web and mobile: learning paths, proctored exams, on-the-job training, SCORM and certificates.

Visit app.tanca.io (opens in a new tab)

Screen recording

Role
Lead front-end, backend modules, mobile learner app
Year
2026
Platforms
API · Web · iOS · Android
Stack
  • NestJS
  • MongoDB
  • BullMQ
  • React 19
  • TanStack Query
  • React Native 0.78

Tanca is an HR platform. Companies wanted to train staff inside it: classes, learning paths, supervised exams and on-the-job training, with reports for auditors. I wrote the system design, then built it on all three surfaces.

My part. Created the core training modules of the API (90–100% of their commits), 93% of the web e-learning app and 302 of 303 commits of the mobile learner module.

Outcomes

  • Oct 2026

    live in production: API, web app and mobile module

    Source: deploy report

  • ~800

    commits on the LMS API, Aug–Nov 2026

    Source: git, rebased copies removed

  • 48

    of the API's 97 release tags

    Source: git tags

What I built

Training backend

Classroom training with QR attendance; learning paths with prerequisite cycle detection and time-zone-aware due dates swept every minute by BullMQ; on-the-job training evidence and grading; SCORM 1.2/2004; certificates and Excel reports.

Exams that hold up

Proctoring with a removal-resistant watermark, the paper hidden when a screenshot is detected, photo check-in, answers shuffled per attempt, and auto-submit against server time so a closed laptop cannot buy extra minutes.

A proctored exam in progress: the question list on the left, an image-map question about a shelf display with the learner's marked point, the countdown timer, and the faint name-and-employee-code watermark grid with its floating time label.

Web e-learning app

A layered module inside a 17-app Turborepo: the learning-path builder with publish preview and prerequisite warnings, an RBAC access matrix, course creation from SCORM packages and a public certificate verification page.

Architecture

WEB PORTAL · TURBOREPOMOBILE SUPER-APPLMS API · NESTJSDATA & QUEUESE-learning appReact 19 · ViteLMS learnerReact Native moduleCourses · examsoriginal APIProctoringwatermark · check-inClassroomrooms · slots · QRLearning pathsprereqs · 60 s sweepOn-the-jobevidence · gradingSCORM1.2 / 2004 runtimeCertificatesverify by tokenTraining reportsExcel exportsMongoDBMongoose · GridFSRedisBullMQ queuesRabbitMQmessage busRESTRESTBULLMQ
The web app and the mobile learner module call the NestJS LMS API over REST. The original course and exam core is the team's; proctoring and the training modules are mine. Data lives in MongoDB, jobs run on BullMQ and Redis, messages go over RabbitMQ.
  • Components:
    • Web portal · Turborepo: E-learning app (React 19 · Vite), built by me
    • Mobile super-app: LMS learner (React Native module), built by me
    • LMS API · NestJS: Courses · exams (original API)
    • LMS API · NestJS: Proctoring (watermark · check-in), built by me
    • LMS API · NestJS: Classroom (rooms · slots · QR), built by me
    • LMS API · NestJS: Learning paths (prereqs · 60 s sweep), built by me
    • LMS API · NestJS: On-the-job (evidence · grading), built by me
    • LMS API · NestJS: SCORM (1.2 / 2004 runtime), built by me
    • LMS API · NestJS: Certificates (verify by token), built by me
    • LMS API · NestJS: Training reports (Excel exports), built by me
    • Data & queues: MongoDB (Mongoose · GridFS)
    • Data & queues: Redis (BullMQ queues)
    • Data & queues: RabbitMQ (message bus)
  • Connections:
    • E-learning app to LMS API · NestJS: REST
    • LMS learner to LMS API · NestJS: REST
    • LMS API · NestJS to MongoDB
    • LMS API · NestJS to Redis: BullMQ
    • LMS API · NestJS to RabbitMQ

Key decisions

  1. A saga, not a transaction

    Closing a class is an idempotent saga with a latch, an idempotency key and an outbox, because a transaction cannot take back a published message.

    Problem, choice and trade-off: A saga, not a transaction
    Problem
    Closing a class touches six groups of data in one click (attendance, results, certificates, learning records, path progress, surveys), and the API had no Mongo transactions anywhere.
    Choice
    I designed closing as an idempotent saga: the plan is saved as a finalize-run document, a conditional findOneAndUpdate latch stops a second closer, an Idempotency-Key with a 3-minute lease covers retries, and certificates and notifications leave through an outbox.
    Why
    A transaction cannot take back a message already published to RabbitMQ or BullMQ, and rolling back 13 certificates because one learner failed is worse than finishing the rest and retrying the one.
    Trade-off
    The class has a visible 'closing' state the UI must show, and every step needs its own test.
  2. Stateless QR attendance tokens

    Rotating QR codes are HMAC-signed tokens verified by pure computation on any pod, with no token collection to write or clean up.

    Problem, choice and trade-off: Stateless QR attendance tokens
    Problem
    Attendance QR codes rotate every 5 minutes and are checked by several API pods; the flow spec proposed a token collection with a 300 s TTL.
    Choice
    No collection. Each token is v1.classId.bucket.nonce signed with HMAC-SHA256 from a per-class secret and checked with timingSafeEqual. Replay protection comes from the unique enrollment index.
    Why
    Verifying is pure computation, so it works on any pod with no lookup or lock, and there are no rows (about 36 per class per 3 hours) to write and clean up. A code that changes every 5 minutes cannot also be typed by hand.
    Trade-off
    The secret rotates when a session opens or closes, so I keep the previous one for 24 hours; without it, scans queued offline and sent late would fail.
  3. Exam time decided by the server

    The deadline is written when the attempt starts, and any request that touches an overdue attempt submits it, stamped at the deadline.

    Problem, choice and trade-off: Exam time decided by the server
    Problem
    A closed laptop or a dropped connection could keep an exam attempt open, because only the client timer ended it.
    Choice
    endsAt is written in the same update that starts the attempt, with a 60-second grace. Any route that touches an overdue attempt submits it through the same compare-and-set as the Submit button, stamped at the deadline. Extending the time applies at once; shortening applies only to new attempts.
    Why
    The server clock decides and the submission carries the deadline, not the time of the next request, so a closed laptop cannot buy extra minutes. A late submit from an older app build still gets a 200 with its result instead of getting stuck.
    Trade-off
    Enforcement runs on each request that touches the attempt, so every route on the attempt has to run the check.

Credit. The original course and exam API, the v2 monorepo shell and the other 15 web apps were built by the Tanca team.

Next case

PIEX

Real-estate marketplace · public site and admin