A mobile-first language-learning app for Ewondo, a Bantu language spoken in Cameroon. Learners progress through Beginner → Intermediate → Advanced courses, take quizzes, and earn a generated PDF/QR certificate on completion. Two user-facing roles: Learner and Administrator.
- Frontend: Flutter (
ndaminkoaba_app/) — go_router, Dio - Backend: NestJS + Prisma + PostgreSQL (
backend-api/) — JWT auth, role-based access control, server-side quiz grading, PDF/QR certificate generation, an AI tutor ("Nnanga") backed by OpenRouter
ndaminkoaba_app/ (Flutter client)
|
| HTTP + JWT bearer token
v
backend-api/ (NestJS REST API, prefix /api)
|
v
PostgreSQL (via Prisma ORM)
The API wraps every response as { success, data, timestamp } (or { success: false, error: { message, error, statusCode } } on failure). Auth uses short-lived JWTs (7 days by default); the client attaches Authorization: Bearer <token> automatically once logged in.
cd backend-api
npm install # runs `prisma generate` automatically via postinstallMake sure PostgreSQL is running locally and matches backend-api/.env's DATABASE_URL (defaults to postgresql://postgres:1234@localhost:5432/ewondo_learning — change the password/db name there if yours differs, then create the database: createdb ewondo_learning or via pgAdmin).
npx prisma migrate deploy # applies the existing migrations in prisma/migrations
npx prisma db seed # creates demo accounts + one PUBLISHED course per level
npm run start:dev # http://localhost:3000/api, Swagger docs at /api/docsDemo accounts created by the seed script (all use password Passw0rd! on a local database only — this password is public, so the seed script refuses to use it on any non-local database; set SEED_DEMO_PASSWORD to seed a shared one):
| Role | Notes | |
|---|---|---|
| Admin | admin@ndaminkoaba.com | Full access to the Administrator app (Dashboard, Users, Courses, Certificates) |
| Teacher | teacher@ndaminkoaba.com | Backend-only role — content can be attributed to a teacher, but there is no separate teacher UI (see "Role model" below) |
| Learner | learner@ndaminkoaba.com | Pre-enrolled in the Beginner course |
Before a real deployment: rotate JWT_SECRET and OPENROUTER_API_KEY in .env — both were pasted into a chat session at some point and should be treated as no longer private.
cd ndaminkoaba_app
flutter pub get
flutter run # or: flutter run -d chrome / -d windowslib/config/app_config.dart points at http://127.0.0.1:3000/api by default. If you run the app on a physical device or Android emulator, change this to your machine's LAN IP (Android emulators specifically should use http://10.0.2.2:3000/api to reach the host machine).
The database still has three roles (ADMIN, TEACHER, LEARNER) — courses can optionally be attributed to a teacher — but the app only ever presents two experiences: Learner and Administrator. There is no teacher login screen. Registration always creates a LEARNER (the client can't request another role); an Administrator promotes a user to ADMIN from the Users screen.
Course.level and Vocabulary.difficulty are a BEGINNER | INTERMEDIATE | ADVANCED enum. The seed script creates one published course per level, each with a module, lesson, vocabulary word, and quiz. Learners filter both the Courses and Vocabulary screens by level.
A certificate can only be issued (POST /certificates) once the backend independently verifies every lesson in the course is completed and every quiz passed — the learner's claimed score is never trusted for this check. Once issued, POST /certificates/:id/generate-pdf renders a PDF with a QR code linking to the public, unauthenticated verification endpoint (GET /certificates/verify/:code).
From /admin, an Administrator can:
- Manage content through five dedicated sections (the Courses tab opens a "Content Management" hub linking to each): Course, Module, Lesson, Vocabulary, and Quiz Management. Each is a flat, filterable, searchable list with full create/edit/delete for that entity — e.g. Lesson Management shows every lesson across every course with a "Course › Module" breadcrumb and a clearly-numbered "Lesson N" (numbering is scoped per module, so a new module always starts again at Lesson 1). Deleting something that still has dependents (a module with lessons, a lesson with a quiz) returns a clean "still depends on this" error instead of a raw 500 — the API's global exception filter now translates Postgres FK-constraint failures into a proper 409.
- The per-course editor (opened from Course Management) still supports adding modules/lessons inline for quick in-context authoring; both paths write to the same data.
- Quiz Management links through to a question builder: add multiple-choice questions and tap a choice to mark it correct (only one per question); edit or delete existing questions the same way.
- Manage users — search/list every account, create one directly with a temporary password and a role, promote/demote between Learner and Administrator, and activate/deactivate accounts.
- Train the AI — Nnanga isn't a fine-tuned model; it's retrieval-augmented —
POST /nnanga/chatsearches theVocabulary/Lessontables for keyword matches, feeds whatever it finds to the LLM as context, and falls back to a generic "I don't know" if nothing matches. The Train the AI screen makes that loop visible: a "Test Nnanga" console shows whether a given question found a local-knowledge match (with a shortcut into Vocabulary Management if not), plus a feed of learners' real questions so gaps are easy to spot and fill. - Broadcast announcements — one dialog sends a notification to every learner at once (
POST /notifications/broadcast). - See the platform at a glance — the dashboard has quick-action shortcuts to all of the above, a courses-by-level bar chart, a users-by-role breakdown, and a recent-certificates activity feed, backed by a
GET /dashboard/adminthat returns grouped counts instead of just totals. These stats are global (not scoped to the logged-in admin), so every Administrator account sees identical numbers.
Starting point: a real, fairly complete backend (25 modules, 21 database models) with several authorization gaps, and a Flutter app that only called 2 of ~15 endpoint groups.
Backend
- Fixed all
tsc --noEmiterrors (isolatedModulesimport typeviolations, a JWTexpiresIntype mismatch) - Quiz grading is now fully server-side:
POST /quiz-attemptstakes{questionId, choiceId}answers, not a client-supplied score — the score/pass flag can no longer be forged - Closed an answer-leak in
GET /quizzes: it embedded every choice'sisCorrectunredacted and had no auth guard at all - Added admin user management (
GET /users,GET/PATCH /users/:id) so an Administrator can list, search, promote, and deactivate accounts - Expanded the seed script to one published course per level instead of just Beginner
- Verified the previously-flagged IDOR/certificate-auth/quiz-answer-redaction fixes are actually in place across progress, quiz-attempts, bookmarks, enrollments, notifications, nnanga, and certificates
Frontend — built out the screens that only existed as "Coming soon" stubs or didn't exist at all:
- Quiz-taking flow (question/choice UI, submission, pass/fail results with per-question review)
- Certificates (list, detail with QR/PDF, claim-when-eligible button on the course screen)
- Vocabulary browser (search + level filter)
- Nnanga AI chat (markdown-rendered replies)
- Profile (view/edit name & password, logout)
- A full Administrator app: dashboard stats, user management, course publish/draft/archive, issued-certificates list
- Wired lesson completion and course progress to the real
/progressAPI (previously tracked only in localSharedPreferences, so the server never knew what a learner had completed) - Auto-enrollment when a course is opened, and role-based routing after login (
ADMIN→ Administrator app,LEARNER→ learner app) - Fixed API error-message parsing app-wide (it read
data['message']/data['error']as a flat string; the backend's actual envelope nests the message undererror.message, so failed requests were showing a raw stringified object instead of the real message) - Removed dead code (duplicate
AppColorsclass, orphaned module models/repository) that had already been emptied out but not deleted flutter analyzeandnpx tsc --noEmitare both clean
Full findings from the original audit are in NdaMinkoaba_Audit_Findings.md at the repo root (historical — several items there are now resolved; see above).
The Administrator experience above was built in a follow-up pass, plus three real bugs it surfaced along the way:
POST /nnanga/chatwas completely broken.CreateNnangaChatDto.userIdwas a required field, but the controller always overrides it with the authenticated user's id and no real client ever sends it — every chat request from the app was rejected by validation before reaching the controller. Made the field optional (it was always ignored anyway).- Deleting a lesson/module/question with dependents leaked a raw 500. Nothing in this schema uses
onDelete: Cascade, so Postgres's default RESTRICT rejected the query — and that rejection surfaced as an unhandledPrismaClientUnknownRequestError. The global exception filter now recognizes FK-constraint and unique-constraint failures and returns a clean 409 instead. - A 401 from an expired/invalid token cleared the stored token but never redirected to
/login, so every screen just silently showed empty/zero data with no indication why.ApiClientnow force-navigates to/loginon any 401 that isn't the login request itself, via aGlobalKey<NavigatorState>so the Dio interceptor doesn't need aBuildContext.
Also added: POST /users (admin-create with any role), POST /notifications/broadcast, and GET /dashboard/admin now returns usersByRole/coursesByLevel/recentCertificates alongside the existing totals.
- First-login welcome screen.
User.lastLogin(already in the schema, never actually written) is now set on every successful login; since it startsnull, "never logged in before" is exactly "this is their first login."POST /auth/loginreturns anisFirstLoginflag alongside the token. The Flutter login screen routes a first-time learner to/welcome(a full-bleed hero screen with their first name and the three levels) instead of straight to the dashboard; every later login goes straight to the dashboard with a "Welcome back" message. Administrators are unaffected — they always go straight to/admin.- The welcome screen's hero photo lives at
ndaminkoaba_app/assets/images/welcome_hero.jpg(assets/images/is already declared as a whole-directory asset inpubspec.yaml, so dropping a file there is enough — no config change needed). Until that file exists, the screen falls back to a green gradient so it still looks intentional.
- The welcome screen's hero photo lives at
- Premium visual pass on learner-facing screens: login, register, dashboard, and courses screens now share the same gradient-hero / soft-background language already used across the Administrator app — gradient banners with stat pills, colored circular icons per action/level, and a consistent cream-to-background gradient on the two auth screens.
Google sign-in, alongside the existing email/password registration (which was already @IsEmail-validated — no server-side email-verification flow was added). The Flutter client gets an ID token directly from Google, hands it to the backend, which verifies it server-side and issues the platform's own JWT — identical to the email/password flow from there on (isFirstLogin, /welcome routing, etc. all just work).
- Backend:
POST /auth/google(verifies the ID token viagoogle-auth-library'sOAuth2Client.verifyIdToken).User.passwordHashis now nullable andUser.googleIdwas added —UsersService.findOrCreateOAuthUserlinks a provider id onto an existing password account if the emails match, rather than creating a duplicate. Emails are lowercased/trimmed everywhere (normalizeEmailincommon/utils/helpers.ts) soFoo@x.com/foo@x.comcan never become two accounts. - Frontend:
google_sign_inpowers anOrDivider+GoogleSignInButtonon both the login and register screens, sharing the same post-auth navigation as the email/password path (lib/features/auth/presentation/post_login.dart).
Google sign-in is disabled out of the box and fails gracefully (a "not configured yet" snackbar, not a crash) until you provide real credentials:
Google — console.cloud.google.com → configure the OAuth consent screen → create 3 OAuth Client IDs (Web, Android, iOS):
- Web: add your dev/prod origins to Authorized JavaScript origins.
- Android: needs this app's
applicationId(android/app/build.gradle.kts) + your keystore's SHA-1 (keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android). - iOS: needs the bundle ID (
ios/Runner.xcodeproj/project.pbxproj). - Put the Web Client ID in
ndaminkoaba_app/lib/config/app_config.dart(googleWebClientId) and inndaminkoaba_app/web/index.html'sgoogle-signin-client_idmeta tag. Put the comma-separated list of all 3 Client IDs inbackend-api/.env'sGOOGLE_CLIENT_ID(the backend accepts a token minted for any of them). - iOS also needs its Client ID's reversed form in
ios/Runner/Info.plist'sCFBundleURLTypes(replaceREPLACE_WITH_REVERSED_CLIENT_ID).
- No automated tests exist yet on either side beyond the default NestJS boilerplate spec.
- Bookmarks have backend support but no frontend or admin screen.
- The course editor supports create/edit/delete for courses, modules, lessons, quizzes, and questions, but not reordering (
orderNumberis always appended) or editing a question's choices after creation (delete and re-add instead). - No image/audio upload UI yet for lesson or vocabulary media —
audioUrl/videoUrl/imageUrlfields exist on the backend but the admin forms don't expose them. - The welcome-screen hero photo needs to be manually placed at
ndaminkoaba_app/assets/images/welcome_hero.jpg— it wasn't retrievable from the chat attachment directly.