# English Speaking & Learning Application — Project Plan

## Scope (as per current instruction)

- ✅ Backend REST API (Node.js + Express + raw MySQL via `mysql2`)
- ✅ Admin Panel Web Frontend (React + Vite)
- ❌ Flutter mobile app — not being built right now
- ❌ Android/iOS APK/build generation — not required
- ❌ Voice calling (WebRTC/third-party) integration — not required right now (backend will still have the DB fields/API routes for it, per SOW, but no implementation yet)

Original SOW covered the full mobile app too (see `sow esp.pdf`). This plan only tracks the **Admin + API side** slice of that SOW.

## Repository Structure

```
english_speaking_portal/
├── backend/                      Node.js + Express + MySQL (mysql2) API
│   ├── src/
│   │   ├── config/
│   │   │   ├── db.js             mysql2 connection pool (XAMPP MySQL)
│   │   │   ├── schema.sql        Full schema for a fresh database
│   │   │   └── migrations/       SQL to upgrade an existing database (run in order)
│   │   ├── constants/            Shared constants (e.g. roles.js — ADMIN_ROLES)
│   │   ├── controllers/          Route handlers (implemented: auth, admins, avatars, category, faqs, goals, learning, levels, pages, topics, user.profile, public.masters, public.pages; others still stubs)
│   │   ├── middleware/           adminAuth.js (JWT + loads active admin from DB), requireRole.js (role check), auth.js (app user JWT + loads active user), errorHandler.js
│   │   ├── models/               SQL queries per table (admin, avatar, category, cmsPage, englishLevel, faq, goal, learningMaterial, topic, user)
│   │   ├── routes/               admin.auth.routes.js, admin.admins.routes.js, admin.avatars.routes.js, admin.faqs.routes.js, admin.goals.routes.js, admin.learning.routes.js, admin.levels.routes.js, admin.pages.routes.js, admin.topics.routes.js, user.routes.js, public.masters.routes.js, public.pages.routes.js
│   │   ├── scripts/              seedAdmin.js — create/update an admin from the CLI
│   │   ├── utils/                asyncHandler.js, upload.js (multer + image URLs), appUser.js (app user response shape)
│   │   ├── app.js                Express app wiring
│   │   └── server.js             Entry point
│   ├── .env.example / .env
│   └── package.json
├── admin-frontend/               React + Vite admin panel
│   └── src/
│       ├── api/client.js         Axios instance + interceptors + ALL API calls (single file)
│       ├── components/           Layout, Sidebar, Topbar, ProtectedRoute, ErrorBoundary, Spinner
│       │   ├── common/           Reusable UI: Modal, ConfirmDialog, Alert, StatusTabs, StatusToggle, icons
│       │   └── learning/         CategoryFormModal, MaterialFormModal (topics/: TopicFormModal, admins/: AdminFormModal, pages/: PageFormModal, levels/: LevelFormModal, goals/: GoalFormModal, faqs/: FaqFormModal, avatars/: AvatarFormModal)
│       ├── config/               navLinks.jsx, roles.js (ADMIN_ROLES)
│       ├── context/              AuthContext (session, login/logout, /me refresh)
│       ├── hooks/                useAuth, useDebounce
│       ├── pages/                Login, Dashboard, Admins, Avatars, Categories, CmsPages, EnglishLevels, Faqs, Goals, Learning, Users, Topics, Reports
│       └── utils/                format.js
└── PLAN.md                       This file
```

## Local Setup

1. Start MySQL via XAMPP.
2. In XAMPP phpMyAdmin (or CLI), create a database, e.g. `english_speaking_portal`.
3. `backend/.env` → confirm `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` match your XAMPP MySQL (defaults: `root` user, empty password, port `3306`).
4. Tables (no ORM/migration tool):
   - **Fresh database:** run `backend/src/config/schema.sql`.
   - **Existing database:** run any files in `backend/src/config/migrations/` that have not been applied yet, in order.
5. Create an admin (the "Add admin" button on the **Admins** page is hidden for now):
   ```
   cd backend
   node src/scripts/seedAdmin.js "Admin Name" admin@example.com "password" [super_admin|sub_admin]
   ```
   Role defaults to `super_admin`. Re-running for an existing email updates name, password **and role** — pass `sub_admin` when resetting a sub admin's password.
6. Backend:
   ```
   cd backend
   npm install
   npm run dev           # starts API on http://localhost:8000
   ```
7. Admin frontend:
   ```
   cd admin-frontend
   npm install
   npm run dev           # starts on http://localhost:5173
   ```

## How This Project Will Be Managed

Work is tracked phase by phase below. Each checklist item = one unit of work (one API route group or one frontend screen). Update the checkboxes in this file as items are completed — this file is the single source of truth for progress.

---

## Phase 1 — Backend Foundation (Setup) ✅ Done

- [x] Node.js/Express project structure
- [x] Plain MySQL connection config via `mysql2` (XAMPP) — no ORM
- [x] Middleware: admin auth (JWT), role check, error handler (app-user auth still a stub — Phase 9)
- [x] Data model / table design — `schema.sql` (`admins`, `topics`, `english_levels`, `goals`, `avatars`, `users`, `faqs`, `cms_pages`, `learning_categories`, `learning_materials`); more tables added per phase
- [x] Routes — added per phase (`/api/admin/auth`, `/api/admin/admins`, `/api/admin/learning`, `/api/admin/levels`, `/api/admin/goals`, `/api/admin/faqs`, `/api/admin/avatars`, `/api/admin/pages`, `/api/admin/topics`, `/api/user`, `/api/masters`, `/api/pages`)

## Phase 2 — Admin Frontend Foundation (Setup) ✅ Done

- [x] React + Vite project scaffolded
- [x] Router (react-router-dom) and HTTP client (axios) installed
- [x] Bootstrap installed and imported globally (`main.jsx`)
- [x] Sidebar component (`components/Sidebar.jsx`) + Layout wrapper (`components/Layout.jsx`, nested routes via `<Outlet />`)
- [x] Login page (`pages/Login.jsx`)
- [x] Dashboard page (`pages/Dashboard.jsx`) — currently static placeholder data (real stats in Phase 4)
- [x] Routing wired in `App.jsx` with lazy-loaded pages and an error boundary

## Phase 3 — Admin Auth (SOW §11.1) ✅ Done

- [x] Backend: `POST /api/admin/auth/login` (verify email/password, issue JWT)
- [x] Backend: `GET /api/admin/auth/me`, `POST /api/admin/auth/logout`
- [x] Frontend: Login page, token storage (remember me → localStorage, else sessionStorage), protected route wrapper
- [x] Axios interceptors: attach token to every request; on 401 clear session and return to login
- [x] Frontend refreshes admin details from `/me` on app load (so saved sessions pick up role changes)

## Phase 3.1 — Admin Roles ✅ Done

- [x] `admins.role` ENUM: `super_admin`, `sub_admin` (new admins default to `sub_admin`; migration `001_add_admin_role.sql` made existing admins `super_admin`)
- [x] `ADMIN_ROLES` constant — `backend/src/constants/roles.js` and `admin-frontend/src/config/roles.js` (keep in sync)
- [x] Backend: `requireRole(...roles)` middleware — uses the admin loaded from the DB by `adminAuth`, returns 403 if not allowed
- [x] Frontend: `navLinks` `roles` field hides links; `<ProtectedRoute roles={[...]}>` blocks routes (redirects to `/dashboard`)
- [ ] Decide role rules for the remaining modules (Users, Reports) — currently only category management is restricted

**Current permission matrix**

| Action | super_admin | sub_admin |
|---|---|---|
| View categories (list — needed for material dropdown) | ✅ | ✅ |
| Create / edit / activate-deactivate / delete category | ✅ | ❌ (403) |
| Categories page + sidebar link | ✅ | hidden |
| Learning material CRUD + activate/deactivate | ✅ | ✅ |
| Topic CRUD + activate/deactivate | ✅ | ✅ |
| CMS pages — add / edit (no delete) | ✅ | ✅ |
| English levels — view / create / edit / activate-deactivate / delete | ✅ | hidden, 403 (even list) |
| Goals — view / create / edit / activate-deactivate / delete | ✅ | hidden, 403 (even list) |
| FAQs — view / create / edit / activate-deactivate / delete | ✅ | hidden, 403 (even list) |
| Avatars — view / upload / edit / activate-deactivate / delete | ✅ | hidden, 403 (even list) |
| Admins page — create / edit / activate-deactivate / delete admins | ✅ (not own account) | hidden, 403 |

## Phase 3.2 — Admin Management (super admin only) ✅ Done

- [x] DB: `admins.is_active` (migration `003_add_admin_is_active.sql`)
- [x] Backend: `/api/admin/admins` — list (status tabs + search), create, update (password optional), activate/deactivate, delete
- [x] Backend: inactive admins cannot log in; `adminAuth` checks the DB on every request so a deactivated admin is logged out immediately
- [x] Safety: an admin cannot deactivate, delete or demote themselves (so at least one active super admin always remains)
- [x] Frontend: Admins page + sidebar link (super admin only)
- [ ] "Add admin" button — commented out in `pages/Admins.jsx` for now (hidden until asked). New admins are created with `seedAdmin.js`.
- [ ] "Admins" sidebar link — commented out in `config/navLinks.jsx` for now (the `/admins` route still exists).

## Phase 4 — Admin Dashboard (SOW §11.2)

- [ ] Backend: implement `GET /api/admin/dashboard/stats` (total/active/inactive users, reports, calls)
- [ ] Frontend: Dashboard page with stat cards (replace current placeholder data)

## Phase 5 — User Management (SOW §12)

- [ ] Backend: list/search users, view user detail, activate/deactivate user
- [ ] Frontend: Users list (search + status filter), user detail view, activate/deactivate action

## Phase 6 — Report Management (SOW §13)

- [x] DB (migration `015_create_user_reports_and_call_feedback.sql`): `report_reasons` (super-admin master, seeded with the 7 app reasons, `icon` key), `user_reports` (one per reporter per call, status `pending` → `in_review` → `resolved` / `dismissed`), `call_feedback` ("Did you enjoy the call?", one per user per call)
- [x] Backend: `/api/admin/reports` — list (status tabs, search, pagination), detail, update status; `/api/admin/reports/call-feedback` totals; `/api/admin/reports/reasons` CRUD (super admin only)
- [x] Frontend: Reports page (status tabs + counts, search, status change, call feedback totals) and Report Reasons page (super admin only)
- [x] App: `GET /api/masters/report-reasons`, `POST /api/user/reports`, `PUT /api/user/calls/:id/feedback`
- [ ] Action on reported user (deactivate) — needs Phase 5 (User Management)

## Phase 7 — Topic Management (SOW §14) ✅ Done

- [x] Backend: CRUD for conversation topics, activate/deactivate (`/api/admin/topics`, table `topics`, migration `002_create_topics.sql`)
- [x] Frontend: Topics page — All/Active/Inactive tabs, search, add/edit modal, status toggle, delete confirm
- [ ] When call sessions / user preferences reference topics (Phase 9), block deleting a topic that is in use (like categories with materials)

## Phase 7.1 — CMS Pages (Privacy Policy, About Us, Help & Support…) ✅ Done

- [x] DB: `cms_pages` (title, unique slug, content) — migration `004_create_cms_pages.sql`
- [x] Backend: `/api/admin/pages` — list, get, create, update (**no delete, no status** — only add and edit)
- [x] Slug is set on create (auto from title, editable) and locked afterwards so app links don't break
- [x] Frontend: CMS Pages list + add/edit modal (plain textarea for content)
- [x] Public read API for the mobile app — `GET /api/pages/:slug` → `{ title, slug, content, updated_at }` (404 if missing). The app opens `about-us`, `terms-and-conditions` and `privacy-policy`
- [ ] Rich text editor for content — needs a new library, discuss first

## Phase 7.2 — English Levels master (super admin only) ✅ Done

Options for the app onboarding question "How would you rate your English?" (Beginner / Intermediate / Advanced / Fluent).
Not the same as the call-minutes level in Phase 9 (`FLOOR(total minutes / 300) + 1`).

- [x] DB: `english_levels` (name, description, sort_order, is_active) — migration `005_create_english_levels.sql` seeds the 4 default levels
- [x] Backend: `/api/admin/levels` — list, create, update, activate/deactivate, delete — **super admin only**
- [x] Frontend: English Levels page + sidebar link (super admin only)
- [x] Public API `GET /api/masters/levels`; a level picked by a user cannot be deleted (409)

## Phase 7.3 — Goals master (super admin only) ✅ Done

Options for the app onboarding question "What do you want to achieve?" (Improve Fluency, Travel & Tourism, Business Communication, Interview Practices, Make Friends).

- [x] DB: `goals` (name, sort_order, is_active) — migration `006_create_goals.sql` seeds the 5 default goals
- [x] Backend: `/api/admin/goals` — list, create, update, activate/deactivate, delete — **super admin only**
- [x] Frontend: Goals page + sidebar link (super admin only)
- [x] Public API `GET /api/masters/goals`; a goal picked by a user cannot be deleted (409)

## Phase 7.4 — FAQs (super admin only) ✅ Done

The app's FAQ screen (question + expandable answer). Separate from Learning Material.

- [x] DB: `faqs` (question, answer, sort_order, is_active) — migration `007_create_faqs.sql` (no seed data)
- [x] Backend: `/api/admin/faqs` — list, create, update, activate/deactivate, delete — **super admin only**
- [x] Frontend: FAQs page + sidebar link (super admin only)
- [x] Public API `GET /api/faqs` (no login) — active FAQs in `sort_order`

## Phase 7.5 — Avatars master (super admin only) ✅ Done

Avatars shown on the app onboarding step "Choose your avatar".

- [x] DB: `avatars` (name, image_path, sort_order, is_active) — migration `008_create_avatars.sql`
- [x] Backend: image upload with `multer` → `backend/uploads/avatars/` (PNG/JPG/WEBP, max 2 MB), served at `/uploads/...`
- [x] Backend: `/api/admin/avatars` — list, create, update (image optional), activate/deactivate, delete — **super admin only**; an avatar picked by a user cannot be deleted (409)
- [x] Frontend: Avatars page (thumbnails, upload with preview) + sidebar link (super admin only)

## Phase 7.6 — Support Tickets ✅ Done

App screens "Support Tickets" (list with status) and "Report an Issue" (select issue + description, max 200 chars).

- [x] DB: `issue_types` (issue master: name, sort order, active), `support_tickets` (user, issue, description, status) — migration `011_create_support_tickets.sql`
- [x] Issue master (super admin only): Issues page — CRUD + active/inactive. No issue categories — the text under the issue on the app's ticket card is the ticket's description.
- [x] Ticket statuses: `open` → `in_progress` → `resolved` → `closed`; new tickets start as `open`; ticket number `BT` + (10000 + id)
- [x] Admin (super + sub admin): Support Tickets page — status tabs with counts, search, pagination, change status
- [x] App: `GET /api/masters/issues` (public), `POST /api/user/tickets`, `GET /api/user/tickets?status=`, `GET /api/user/tickets/:id`

## Phase 7.7 — Match Preferences ✅ Done

App screen "Match Preferences": who the learner wants to speak with.

- [x] DB (migration `012_topic_icons_and_match_preferences.sql`): `topics.icon_path` + `topics.sort_order`; new `match_preferences` (one row per user: `gender` male/female/any, `partner_level` any/similar/fluent, `topic_id` — NULL means "Any")
- [x] Topics master: optional icon upload (`uploads/topics/`), sort order; deleting a topic resets saved preferences to "Any"
- [x] App: `GET /api/masters/topics` (public, active topics with `icon_url`), `GET` + `PUT /api/user/match-preferences`
- [x] Random matching uses these saved preferences — built in Phase 9.1 Step 4 ("similar" = exactly the same English level, "fluent" = the highest active level)

## Phase 8 — Learning Material Management (SOW §15)

- [x] Backend: master category CRUD + active/inactive (`/api/admin/learning/categories`); a category with materials cannot be deleted (409)
- [x] Backend: learning material CRUD + active/inactive, filter by category/status/search (`/api/admin/learning/materials`)
- [x] Frontend: Categories page + Learning Material page (plain `useState`/`useEffect`, All/Active/Inactive tabs with counts, add/edit/delete modals)
- [x] FAQs — now a separate module, see Phase 7.4 (earlier this line said Learning Material covers FAQs; changed on the user's decision after seeing the app's FAQ screen)
- [x] Learning Material: pagination (`page`, `limit`, max 50) + search in title/content + category filter — admin page and app API
- [ ] Pagination for the other list pages (not needed yet; add when data grows)

## Phase 9 — API Side for Mobile-Facing Features (backend only, per SOW §16, no Flutter client)

These are backend API routes only (so the API side is "complete" per SOW), without a mobile client consuming them:

- [x] DB: `users` (google_id, email, google_name, username, avatar_id, gender, english_level_id, goal_id, is_onboarded, is_active, last_login_at) — migration `009_create_users.sql`
- [x] App user sign-in (registration/login) — `POST /api/auth/google { id_token }`: verifies the Firebase Auth ID token with Firebase Admin (Google provider, service account at `FIREBASE_SERVICE_ACCOUNT_PATH`), finds the user by Google id (or email) or creates one, returns an app JWT (`{ id }`, `JWT_SECRET`), `is_new_user` and the user (`is_onboarded` tells the app whether to show onboarding)
- [x] App-user JWT middleware (`middleware/auth.js`, `JWT_SECRET`) — loads the user from the DB, blocks inactive users
- [x] `GET /api/user/me`, `PUT /api/user/onboarding` (avatar, username, gender, English level, goal)
- [x] Public master APIs: `GET /api/masters/levels`, `/api/masters/goals`, `/api/masters/avatars` (active only, in `sort_order`)
- [x] Levels / goals / avatars picked by a user cannot be deleted (409)
- [x] User profile APIs (view/edit) — `GET /api/user/me`; `PUT /api/user/onboarding` also edits the answers later
- [x] `GET /api/user/profile` (Profile screen; `level` and `stats` are 0/1 until call sessions exist)
- [x] `POST /api/user/logout` — bumps `users.token_version` (migration `010_add_user_token_version.sql`), ending all sessions of that user
- [x] User preference APIs — Match Preferences: `GET`/`PUT /api/user/match-preferences` (see Phase 7.7)
- [ ] Random matching ("Finding Available User…" screen) — realtime over Socket.IO (no polling). **Full design: Phase 9.1 below. Build it one point at a time, discussing each point first.**
- [ ] Call session APIs (start/accept/reject/end, duration tracking) — see Phase 9.1
- [ ] Level calculation logic (`FLOOR(total minutes / 300) + 1`) — from the total duration of completed call sessions; later a feature will decide which level a user reaches from call duration
- [x] Learning material APIs (public read side, no login) — `GET /api/learning/categories`, `GET /api/learning/materials?category_id=&search=&page=&limit=` (paginated, search in title/content), `GET /api/learning/materials/:id`; only **active** materials in **active** categories
- [x] User reporting API (submit report) — `POST /api/user/reports`, see Phase 6

## Phase 9.1 — Matching & Calls (in progress — Steps 1–5 built, Steps 6–8 designed)

**How we work on this:** we go **one point at a time**: discuss the point, agree, build it, test it, then move to the next. **"Build progress" below is the source of truth** for what is built and for the final event names, payloads and statuses. Sections 1–18 are a reference plan (written with an outside tool) used as a **guide only**; where it differs, this project's flow and "Build progress" win. Section 19 lists things that were missing or unclear in the reference plan, with the decision taken for each.

**Build progress (one step at a time):**

- [x] **Step 1 — Socket connection:** `server.js` runs Express + Socket.IO on one HTTP server/port; `socket/index.js` checks the app token on connect (`auth.token` or `Authorization: Bearer`) with the shared `verifyUserToken` (`utils/userToken.js`, also used by `middleware/auth.js`) and puts each user in room `user:<id>`. Not yet tested end-to-end.
- [x] **Step 2 — Database:** migration `013_create_matching_and_calls.sql` (`matching_sessions`, `match_attempts`, `call_sessions`) + models `matchingSession.model.js`, `matchAttempt.model.js`, `callSession.model.js`. Status changes are guarded (`WHERE status = 'searching' / 'ringing' / 'connecting' / 'active'`) so a late event cannot overwrite an earlier one; a candidate can be rung only once per search (unique key). Applied and tested locally.
- [x] **Step 3 — Match start / cancel / expiry:** `matching/matching.service.js` (rules), `matching/matchQueue.js` (in-memory queue + expiry timers, the only part to swap for Redis), `config/matching.js` (`MATCH_SESSION_SECONDS`, `CALL_REQUEST_SECONDS` from `.env`). Socket events `match:start` / `match:cancel` → `match:searching { matching_session_id, expires_at, topic }`, `match:cancelled`, `match:not-found`, `match:error { message }`. Decisions: tapping Match while already searching re-sends the running search (same `expires_at`, timer not restarted); users who have not finished onboarding get `match:error`; start/expiry times come from the Node.js clock. Tested locally (service level). Not handled yet: a user who disconnects while searching stays in the queue until the expiry (Step 7).
- [x] **Step 4 — Finding candidates:** `matching/matchRules.js` (pure rules) + `findCandidate(entry)` in the matching service. Decisions: a candidate must **also be searching** (in the queue); preferences must fit **in both directions**; "Any" accepts everyone (gender any / level any / topic null); **Similar = exactly the same English level**; **Fluent / Native = the other user is at the highest active level**; topics match when equal or when either side chose Any (two different topics never match); the **longest-waiting** candidate goes first; users already rung in this search or busy with a call request are skipped. The draft `socket/matchmaker.js` was removed. Tested locally (12 rule cases + candidate order).
- [x] **Step 5 — Ringing:** in `matching.service.js`. Events: app → `call:accept { attempt_id }`, `call:reject { attempt_id }`; server → `call:incoming { attempt_id, caller, topic, expires_at }`, `call:timeout { attempt_id }`, `call:cancelled { attempt_id }`, `match:found { matching_session_id, call_session_id, partner, topic }` (to both). Tested locally (reject → next, timeout → next, wrong user cannot accept, accept → match:found + call session, busy/in-call blocking, cancel mid-ring, expiry).
  - **Busy rule (agreed):** while A is ringing B, **both A and B are busy**: A rings nobody else, B's own search rings nobody, and no third user can ring A or B. On reject / timeout both become free again (A continues with the rest of its 2 minutes, B continues its own search).
  - **Ring vs. 2-minute expiry (agreed):** a ring never outlives the caller's 2 minutes (it rings for `min(CALL_REQUEST_SECONDS, time left)`).
  - When a search finds nobody it simply waits; it is tried again whenever someone starts searching or a ring ends (no polling timer).
  - Two users who were paired in a ring (either direction) are not paired again during those same searches (so B, after rejecting A, is not made to ring A).
  - All changes to the in-memory state run one at a time (a small lock), so an accept and a timeout can never interleave.
  - On accept: both leave the queue, both matching sessions → `matched`, a `call_sessions` row is created (`connecting`), both are marked **in call** (`match:start` → `match:error` "You are already in a call"). Ending a call is Step 6.
  - **One user on two devices:** events go to the user's room `user:<id>`, so every connected device gets `call:incoming`. The first `call:accept` wins; a late accept from the other device gets `match:error` "This call request is no longer available". Both devices receive `match:found`.
- [ ] Step 6 — Call session: create on accept, start / end, duration, Profile stats and level
  - **In-call rule (agreed):** after an accept, A and B leave the queue and stay **in call** until the call ends: nobody can pick them as a candidate or ring them, and they cannot start a new Match (`match:error` "You are already in a call"). When the call ends both are free again.
  - The call screen ("English Speaking Practice") has **two flows**: (1) the **call conversation** (this step) and (2) the **activity Q&A** (Step 8, built after the call flow works).
  - **Call screen → backend (draft, to confirm before building):**

    | On the call screen | Backend |
    |---|---|
    | Both users' avatar, name | Already in `match:found` (`partner`) |
    | Call timer (e.g. 00:25s) | Server sends `call:started { call_session_id, started_at }` to both; the app counts from the **server** start time so both phones show the same timer |
    | "Level: 1" under each user | Speaking level from call minutes (`FLOOR(total minutes / 300) + 1`); add `level` to the partner details (today only `english_level` is sent) |
    | End Call | App sends `call:end { call_session_id }`; server sets `completed`, calculates `duration_seconds`, sends `call:ended { call_session_id, duration_seconds, ended_by }` to both; both become free |
    | Mute / Speaker | Phone only (WebRTC), no backend |
    | "Feel uncomfortable? You can report the user after the call" | Report API after the call (separate step, Phase 9 "User reporting API") |

  - **Decisions:**
    1. **Call start — decided:** the app sends `call:start { call_session_id }` when the audio (WebRTC) is connected, so only real talk time counts (in testing it can be sent from Postman).
       **Connect timeout — decided:** if `call:start` does not arrive within **30 seconds** of `match:found` (e.g. the audio never connected), the call becomes `failed`, both users get `call:ended { reason: "connect_failed" }` and both are free again. Configurable: `CALL_CONNECT_TIMEOUT_SECONDS=30` in `.env`.
    2. **WebRTC signalling relay** (`webrtc:offer`, `webrtc:answer`, `webrtc:ice-candidate`): build it in this step (6b), so the app team can test audio.
    3. **Maximum call length — decided: no fixed time limit.** A dropped call is ended by the connection-drop handling below, so a call that broke after 2 minutes counts 2 minutes, not 60.
    4. **TURN server:** STUN only (Google's free server) for testing now; set up **coturn** on our VPS (free) before the production launch. *(Alternative: a paid TURN service.)*
  - **Implementation order:**
    - **6a — Call start / end + stats** (testable in Postman without WebRTC), built in three parts:
      - [x] **6a-1:** `call:start` → call `active`, both get `call:started { call_session_id, started_at }`; `call:end` → `completed`, duration calculated on the server, both get `call:ended { call_session_id, duration_seconds, ended_by, reason }`, both users leave "in call"; 30-second connect timeout (`connect_failed`); Profile stats (`hours_spoken`, `calls_made`, `level`) from real completed calls. Built in `calls/call.service.js` (live calls + connect timer; the matching service hands a call over on accept), shared lock `utils/serialLock.js`, socket events `call:start` / `call:end`. Start/end times and the duration use the Node.js clock. A call ended before it started is `cancelled` (0 s). Tested locally (start by both phones, wrong user rejected, end → duration + stats, connect timeout → `failed`, end before start, `connection_lost` reason, match again after the call).
      - [x] **6a-2:** connection-drop handling (heartbeat, 30-second grace, `call:partner-reconnecting` / `call:partner-reconnected` / `call:resume`).
      - [ ] **6a-3:** partner speaking `level` in the call screen data *(done: `level` in `partner` / `caller`)*, call history `GET /api/user/calls` *(pending)*.
    - [x] **6b — WebRTC signalling:** the three forward events with validation, `GET /api/user/call-config` (STUN/TURN list from `.env`: `STUN_URLS`, `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL`). After this the app team can test real audio. Added `is_caller` to `match:found` / `call:resume` so the app knows who creates the offer. Tested locally (relay to the partner only, stranger / ended call rejected, missing `sdp` rejected).
    - **Before production:** TURN server (coturn).
  - **Connection drop handling (agreed):**
    - **How a call ends:**

      | Case | What happens |
      |---|---|
      | User taps **End Call** | `call:end` → ends at once; duration = start → end |
      | Audio (WebRTC) fails but the internet still works | The app sees the WebRTC "failed" state and sends `call:end { reason: "connection_lost" }` |
      | Internet drops, or the app crashes / is closed | The server notices the socket `disconnect` (heartbeat) and starts a **30-second grace period** |
      | The user reconnects within the grace period | The call continues; nothing is ended |

    - **Detecting a dead connection (heartbeat):** Socket.IO pings each phone and waits for a pong. Settings: `pingInterval` 10 s, `pingTimeout` 10 s, so a dead connection is detected in about 10–20 seconds and the `disconnect` event fires.
    - **Grace period (`CALL_RECONNECT_GRACE_SECONDS=30` in `.env`):**
      1. On `disconnect` of a user who is in an active call (and has **no other connected device**), the server saves the **disconnect time**, starts a 30-second timer (in memory, per call) and sends the partner `call:partner-reconnecting` (screen shows "Reconnecting…").
      2. **Reconnected within 30 s** (new socket with the same token): cancel the timer; send the user `call:resume { call_session_id, started_at, partner, topic }` (restores the call screen and timer) and the partner `call:partner-reconnected`. Audio is re-established with a WebRTC ICE restart, using the same signalling events.
      3. **Not back after 30 s:** end the call → `completed`, `ended_at` = the **disconnect time** (not the end of the grace period), `duration_seconds` up to that moment; the partner gets `call:ended { reason: "partner_disconnected" }`; both users are free.
    - **Edge cases:**
      - Same user on two devices: if one device is still connected, no grace timer starts.
      - Both users drop: each gets a grace period; if nobody returns, the call ends with the duration up to the first disconnect.
      - Accuracy: the server learns about a drop 10–20 s late (heartbeat). Option: subtract the heartbeat time from `ended_at` for a more exact duration.
      - Server restart: in-memory timers are lost; open calls are closed on start-up (Step 7).
    - `call:ended` reasons: `ended` (End Call), `connection_lost` (WebRTC failed), `partner_disconnected` (grace period ran out), `connect_failed` (no `call:start` within 30 seconds of `match:found`).
  - Call history API (part of 6a): `GET /api/user/calls`, paginated — partner, topic, date, duration of the user's completed calls.
  - **WebRTC (voice) — how it will work:**
    - The audio goes **directly between the two phones** (peer-to-peer). It does **not** pass through our server.
    - Our backend only does **signalling**: it forwards the connection messages between the two users over the same Socket.IO connection.
    - Who does what:

      | Part | Done by |
      |---|---|
      | Microphone, audio connection, mute / speaker | Flutter app (`flutter_webrtc`) |
      | Forwarding offer / answer / ICE candidates | Our backend (Socket.IO) |
      | STUN (a phone finds its public address) | Google's free STUN server (`stun:stun.l.google.com:19302`) |
      | TURN (relays audio when a direct connection is impossible) | Our own server (coturn) or a paid service — **to decide** |

    - Flow after `match:found`:
      1. Both phones turn on the microphone and create a WebRTC peer connection.
      2. The **caller (`user_1`) always creates the offer** (so both phones never create one at the same time) → `webrtc:offer` → server forwards it to the candidate.
      3. The candidate sets the offer, creates an answer → `webrtc:answer` → server forwards it to the caller.
      4. Both phones exchange network paths → `webrtc:ice-candidate` (both directions, several messages) → server forwards each one.
      5. When the audio is connected, the app sends `call:start` → call `active`, `started_at` saved → both get `call:started { started_at }`.
      6. End Call → `call:end` → `completed`, duration calculated → both get `call:ended { duration_seconds, ended_by }`.
    - **Backend implementation:**
      - Three forward-only events: `webrtc:offer { call_session_id, sdp }`, `webrtc:answer { call_session_id, sdp }`, `webrtc:ice-candidate { call_session_id, candidate }`. The server sends each one to the **other** user's room, unchanged.
      - **Validation:** only the two users of that `call_session_id` may send signalling, and only while the call is `connecting` / `active`. Anyone else gets `match:error`.
      - A small API `GET /api/user/call-config` returns the STUN/TURN server list (from `.env`), so changing the TURN server never needs an app update.
      - No database writes for signalling messages (they are short-lived).
    - **TURN decision:** STUN alone is enough for testing (most Wi-Fi). In production around 10–20% of calls (strict or mobile networks) will not connect without TURN. Options: install **coturn** (free, open source) on our VPS, or use a paid service (e.g. Twilio, Metered).
- [x] Step 7 — Disconnects and server restart (in-call drops are handled in Step 6 with the 30-second grace period; this step covers searching / ringing and restarts)
  - Only when the user has **no other connected device** (see the two-device rule):
    - **Searching user disconnects:** cancel the search (`matching_sessions` → `cancelled`, removed from the queue). Not a grace period — a search is short and the user can tap Match again.
    - **Caller disconnects while ringing:** stop the ring (attempt → `cancelled`), the candidate gets `call:cancelled`; the caller's search is cancelled as above.
    - **Candidate disconnects while ringing:** stop the ring (attempt → `unavailable`), the caller is free again and the server looks for the next candidate; the candidate's own search is cancelled.
  - **Server restart** (all in-memory state is lost, phones reconnect by themselves):
    - On start-up: open searches → `expired`, open rings → `cancelled`, open calls (`connecting` / `active`) are closed.
    - **Decided:** a call that was `active` at the restart → `completed`, duration counted up to the server start time (a PM2 restart takes seconds). A `connecting` call → `failed`.
    - Built: `closeOpenSessions()` in `server.js` runs before `listen`; `matching.userDisconnected()` from the socket `disconnect` (two-device rule). Tested locally (caller / candidate disconnect while ringing, restart clean-up).
    - Reconnected apps simply start again (tap Match); a call screen that was open gets `call:ended` / an error and returns to the start.
- [x] Step 8 — Call activity (Q&A) on the call screen *(built: migration `014_create_activities.sql`, admin `/api/admin/activities` + "Activities" page (super admin), `calls/activity.service.js` with `activity:*` socket events; tested locally)*: e.g. "Activity 1 — Brain test, Solve together", a question (riddle), "Show Answer" and "Next". The answer is shown, and the next activity opens, **only when both users click** (synced over the socket). Build after Step 6.
  - **Decisions (agreed):**
    - The **admin designs all questions** in the admin panel; users only see them.
    - Activities are **general** (the same pool for every call, not linked to the call topic).
    - Order is **random** for each call.
    - When every activity has been shown in a call, **repeat** them (start again with a new random order).
    - The Activities master is managed by **super admin only**.
    - History of which activities were shown in a call is **not saved** for now (kept in memory during the call).
  - **Admin panel — Activities master (super admin):** list (status tabs, search), add, edit, active/inactive, delete — like Goals / FAQs. Only **active** activities are used in calls.

    | Field | Example (from the screen) |
    |---|---|
    | `title` | "Brain test" (shown under "Activity 1") |
    | `instruction` | "Solve together" |
    | `question` | "I'm tall when I'm young, and I'm short when I'm old. What am I?" |
    | `answer` | "A candle" |
    | `sort_order`, `is_active` | Admin list order (calls use a random order) / on-off |

    Table `activities` (migration `014_create_activities.sql`); admin API `/api/admin/activities` (super admin only); admin panel page "Activities" (sidebar link for super admin).
  - **Flow during a call (Socket.IO):**
    1. When the call starts (`call:started`), the server shuffles the active activities for that call and sends the first one to **both** users **without the answer**: `activity:show { call_session_id, number, title, instruction, question }` (`number` = "Activity 1", 2, …).
    2. **Show Answer:** a user sends `activity:reveal { call_session_id }`. If only one user has clicked, the partner gets `activity:partner-waiting { action: "reveal" }` ("Your partner wants to see the answer"). When **both** have clicked, both get `activity:answer { number, answer }`.
    3. **Next:** a user sends `activity:next { call_session_id }`. One click → partner gets `activity:partner-waiting { action: "next" }`. Both clicked → both get `activity:show` with the next activity (after the last one, a new random round starts).
    4. If there are no active activities, both get `activity:none` and the activity card is hidden.
  - **Rules:**
    - The **answer stays on the server** until both users click Show Answer, so nobody can see it early.
    - Clicks are counted per user (double taps do not count twice); the clicks reset for each activity.
    - Only the two users of that `call_session_id`, and only while the call is `active`, can send activity events.
    - State (shuffled list, current activity, clicks) is kept in memory per call and removed when the call ends.
    - After a reconnect (`call:resume`), the server sends the current activity again (and the answer, if it was already revealed).

**Deployment checklist (before matching / calls go live):**

- [ ] Run migrations `012_topic_icons_and_match_preferences.sql` and `013_create_matching_and_calls.sql` on the live database (and `014` when Step 8 is built) **before** merging the code into `main`.
- [ ] Add `MATCH_SESSION_SECONDS`, `CALL_REQUEST_SECONDS`, `CALL_CONNECT_TIMEOUT_SECONDS`, `CALL_RECONNECT_GRACE_SECONDS`, `STUN_URLS`, `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL` to the server `backend/.env`; restart with `pm2 restart speaking-api --update-env`.
- [ ] Install the TURN server (coturn) on the VPS and open its ports — steps in `API_README.md` section 14.2.
- [ ] The backend must run as **one PM2 process** (no cluster mode): the matching queue, timers and live calls are in that process's memory.
- [ ] If a reverse proxy (e.g. Nginx) sits in front of the API, it must allow WebSocket upgrades (`Upgrade` / `Connection` headers, long `proxy_read_timeout`), otherwise sockets cannot connect. Direct access on port 3064 needs nothing extra.
- [ ] Test end-to-end on the live server with two users (Postman Socket.IO tabs or two phones).

**Project rule — matching uses the saved Match Preferences:** the user picks gender, partner level and topic on the Match Preferences screen, the app saves them with `PUT /api/user/match-preferences`, and matching uses **those saved values**. The app does not send preferences again in `match:start`. Field names follow the existing API: `gender` (male / female / any), `partner_level` (any / similar / fluent), `topic_id` (a topic id, or `null` = Any). A user who never saved anything is treated as all "any".

### Stack and ground rules

- Server: Node.js + Express + **Socket.IO** on the same HTTP server and port (`server.js` + `socket/index.js`). Database: MySQL.
- Client: the learner app is a **Flutter mobile app built by another developer** (it uses a Flutter Socket.IO client, e.g. `socket_io_client`). **Our scope is the backend only** (plus the admin panel only if something there needs to change). The "React" in the reference plan does not apply.
- **No Redis at first.** One Node.js process (PM2 `speaking-api`). The temporary matching queue and timers live in Node.js memory. Permanent records live in MySQL.
- Keep the queue/timer code behind one module (a "queue manager") so Redis can replace it later if the app runs on several Node.js servers.
- Time limits come from `.env`, not hard-coded: `MATCH_SESSION_SECONDS=120` (whole matching session) and `CALL_REQUEST_SECONDS=10` (one ring). A small config file (`src/config/matching.js`) reads them with these defaults if missing/invalid. Add both to `.env.example`. Changing them needs only `.env` + a backend restart (handy for testing, e.g. 30 seconds).
- The server sets `expires_at = start + MATCH_SESSION_SECONDS` once (saved in `matching_sessions`) and sends it to the app in `match:searching`, so the app's countdown follows the server time.

### 1. Match Preferences screen

- Gender: Male / Female / Any
- Partner level: Any Level / Similar Level / Fluent / Native
- Topic: from the Topics master (Daily Life, Travel, Business, Technology, Education, Hobbies, Entertainment, Conversation, Current Affairs…) or Any
- Choosing preferences is **optional**. If the user chooses nothing it means `{ gender: "any", partner_level: "any", topic_id: null }`.
- Already built: `GET /api/masters/topics`, `GET`/`PUT /api/user/match-preferences` (saved per user). The app saves the choice, then the user taps "Match" to start; matching reads the saved values.

### 2. Matching screen

- Shows "Finding Available User…" with a radar/search animation and a "Cancel Search" button.
- It only searches for an available partner. If no preference was chosen, no topic or preference summary is shown.

### 3. Matching session

- Every tap on Match creates a matching session with a **fixed maximum of 2 minutes** (00:00 start → 02:00 expiry).
- The 2 minutes belong to the **whole** session, not to a candidate. A rejection or a no-response does **not** restart the timer.
- The session runs until a candidate accepts, the user cancels, or 2 minutes pass.

### 4. Matching queue (temporary, in Node.js memory)

When matching starts: (1) create a matching session in MySQL; (2) add the user to the in-memory queue (e.g. `waitingUsers = new Map()`) with `userId`, `socketId`, `sessionId`, `preferences`, `startedAt`, `expiresAt`, `attemptedUsers: []`. The queue only exists while the session is active.

### 5. Finding candidates

The server looks for another user who: is available now, is not the same user, is not already in another active call, was not already tried in this matching session, and fits the preferences where they apply. If every preference is "any", any eligible available user is fine.

### 6. Call request flow

- Candidates are tried **one after another** (never all at once): call B → wait → accept / reject / timeout → next candidate (C, D…) while the 2 minutes are still running.
- If B rejects: B is skipped for this matching session. If B does not respond: the request expires and B is skipped.

### 7. Call request timeout

- Each call request has its own **10-second** timeout. No response → mark the attempt `timeout` → search the next candidate.
- Explicit reject → the request ends at once → attempt `rejected` → next candidate.
- Accept → stop matching immediately.

### 8. Edge case: only one candidate

User A starts matching, only B is available, A calls B, B rejects (e.g. at 00:20). A's session is **not** cancelled. A keeps searching until 02:00. If C becomes available at 01:10, A calls C; if C accepts, matching succeeds. If nobody is found by 02:00 the session expires and the app shows "No partner found".

### 9. How a matching session ends (exactly three ways)

- **A. Partner accepts:** status `matched`, stop searching, go to the call screen.
- **B. User cancels:** status `cancelled`, remove from the queue, stop, go back to Match Preferences.
- **C. 2-minute timeout:** status `expired`, remove from the queue, stop, show "No partner found". No more call attempts after expiry.

### 10. Socket.IO events

- Client → server: `match:start` (no body — the server reads the user's saved Match Preferences), `match:cancel`, `call:accept`, `call:reject`
- Server → client: `match:searching`, `match:found`, `match:not-found`, `match:cancelled`, `call:incoming`, `call:timeout`, `match:error`
- Flow: the app sends `match:start` → the server finds a candidate and sends `call:incoming` to it → on accept both users get `match:found` → on expiry the searching user gets `match:not-found` → on cancel the user gets `match:cancelled`.
- *Note:* the final event list (also `call:cancelled`, payloads, Step 6–8 events) is in "Build progress" and `API_README.md` section 7.

### 11. Table `matching_sessions`

`id`, `user_id`, `started_at`, `expires_at`, `status` (`searching` / `matched` / `cancelled` / `expired`), `matched_user_id`, `created_at`, `updated_at`. Example: searching → `matched_user_id` NULL; matched → `matched_user_id` = 48; timeout → `expired`; manual cancel → `cancelled`.

### 12. Table `match_attempts`

`id`, `matching_session_id`, `candidate_user_id`, `status` (`pending` / `ringing` / `accepted` / `rejected` / `timeout`), `called_at`, `responded_at`, `created_at`, `updated_at`. One matching session can have many attempts (e.g. 48 rejected, 51 timeout, 65 accepted).
*Note:* as built (Step 2) there is **no `pending`** status (a ring starts at once as `ringing`), and two extra statuses exist: `cancelled` and `unavailable`. A candidate can be rung only once per search (unique key).

### 13. Table `call_sessions`

`id`, `user_1_id`, `user_2_id`, `started_at`, `ended_at`, `duration`, `status` (`connecting` / `active` / `completed` / `failed` / `cancelled`), `created_at`, `updated_at`.
Flow: candidate accepts → create the call session → WebRTC / conversation starts → call ends → set `ended_at` → calculate `duration` → `completed`.
**A matching session and a call session are different:** matching session = "find me a partner for up to 2 minutes"; call session = "record the real conversation between two users".
As built (Step 2, migration `013_create_matching_and_calls.sql`): these fields plus `matching_session_id`, `topic_id`, `ended_by_user_id`; the duration column is `duration_seconds` (counted by the server).

### 14. Complete flow

User opens Match Preferences → chooses preferences (optional) → taps Match → create `matching_session` → start the 2-minute timer → add the user to the in-memory queue → "Finding Available User" screen → search eligible users → candidate found → create `match_attempt` → send the call request over Socket.IO → wait 10 seconds → **accept** (matched, stop) / **reject** (skip, next user) / **no response** (timeout, next user) → keep searching while the 2-minute timer runs → partner found (stop matching, create call session, conversation) or time expired (expire the session, "No partner found").

### 15. Important rules

1. The 2-minute timer starts only once, when Match is tapped.
2. Rejecting a candidate does not restart the timer.
3. A candidate timeout does not restart the timer.
4. If only one candidate exists and they reject or do not answer, keep searching.
5. If a new candidate becomes available in the remaining time, try them.
6. Never call the same candidate again in the same matching session.
7. A candidate who is receiving a call request must not get another request at the same time.
8. Once a candidate accepts, stop all further attempts.
9. Once 2 minutes expire, stop all attempts.
10. If the user cancels, stop everything at once.
11. No Redis at first.
12. Temporary queue in Node.js memory.
13. Permanent matching session, attempt and call records in MySQL.
14. Keep the design modular so Redis can be added when several Node.js servers are used.

### 16. Example scenario

A taps Match at 12:00:00 (expires 12:02:00). 12:00:05 candidate B found, called. 12:00:15 B does not respond → `timeout`. 12:00:30 candidate C found, called. 12:00:34 C rejects → `rejected`. 12:01:20 D becomes available, called. 12:01:25 D accepts → session `matched`, matching stops, a call session is created and the conversation starts.

### 17. Architecture

React/mobile app → Socket.IO client → Node.js + Express + Socket.IO → temporary in-memory matching queue → MySQL (`users`, `matching_sessions`, `match_attempts`, `call_sessions`). Do not add Redis unless several Node.js instances need shared matching state.

### 18. Expected code structure and build order

- Keep these separate: match controller/service, matching service, Socket.IO event handlers, matching queue manager, timer management, SQL queries (repository), call session service.
- Do **not** put the matching logic directly inside the Socket.IO event handlers.
- The matching service handles: starting matching, finding candidates, tracking attempted candidates, sending call requests, handling accept/reject/timeout, checking the 2-minute expiry, cancelling matching, creating/updating database records, and stopping a session cleanly.
- **First build the matching flow without WebRTC.** Add WebRTC for the real audio only after matching is stable.

### 19. Added by review — missing or unclear in the reference plan (with the decision taken)

- [x] **Who is "available"?** → Only users who are **also searching** (Step 4). States: searching, busy (in a ring, as caller or candidate), in call. A user in a ring is never offered to anyone else and makes no other ring (Step 5).
- [x] **Missing events:** → added `call:cancelled`, `match:found` to **both** users, and `expires_at` in `match:searching` (server time) (Steps 3 and 5).
- [x] **Race conditions:** → all in-memory changes run one at a time (lock), and database updates are guarded (`WHERE status = 'ringing'` etc.), so a late accept is ignored. A ring is cut at the caller's 2:00 expiry (Step 5).
- [x] **What triggers "a new user became available"?** → event-based: the server looks for candidates whenever someone starts searching or a ring ends. **No polling timer** (Step 5).
- [ ] **Disconnect and restart:** → designed in Step 6 (in-call drops, 30-second grace) and Step 7 (searching / ringing drops, server restart). Not built yet.
- [x] **Extra table fields:** → built in Step 2 (`matching_sessions` keeps the preferences, `ended_at`, `call_session_id`; `match_attempts` has `cancelled` and `unavailable`; `call_sessions` has `topic_id`, `matching_session_id`, `ended_by_user_id`, server-counted `duration_seconds`).
- [x] **Candidate order:** → longest-waiting first (Step 4).
- [x] **Both users' preferences:** → yes, both directions (Step 4).
- [x] **"Similar level"** → exactly the same English level; "Fluent / Native" → the other user is at the highest active level (Step 4).
- [x] **Socket security:** → token checked on connect (valid token, active user, `token_version`); only the candidate of a ring can accept / reject it; the server uses the authenticated user, never an id from the client (Steps 1 and 5).
- [x] **One user on two phones:** → both devices ring, the first accept wins, a late accept gets `match:error`; both devices get `match:found` (Step 5).
- [x] **Privacy:** → `call:incoming` / `match:found` send only id, username, gender, avatar, English level and topic — no email (`toPartner`).
- [x] **Bad input:** → tapping Match while searching re-sends the running search; while in a call → `match:error`; double taps create only one search (Step 3).
- [x] **Without WebRTC (first stage):** → `call:start` / `call:end` events (Step 6a, recommended default), testable in Postman; WebRTC signalling uses the same socket (Step 6b).
- [x] **Socket CORS:** → `origin: '*'` for the socket; the real guard is the token check (mobile apps are not limited by CORS).

### 20. Draft code that already exists (older "instant match" idea — to be replaced)

Before this plan was written, a first draft for instant matching (no ringing, no 2-minute session) was started.
Already replaced: the draft migration `013_create_call_sessions.sql` (deleted; now `013_create_matching_and_calls.sql`), the draft `call_sessions` table in `schema.sql`, `callSession.model.js` (rewritten in Step 2) and `socket/index.js` (rewritten in Step 1).
`socket/matchmaker.js` was removed in Step 4 (replaced by `matching/matchRules.js`); `findTopActiveId` (englishLevel model) is used by Step 4; `toPartner` (`utils/appUser.js`) is used by Step 5 for the safe user details in `call:incoming` / `match:found`. **No draft code is left.**

## Phase 10 — Testing & Handover (reduced scope)

- [ ] API testing (admin + app-facing routes)
- [ ] Admin panel functional testing
- [ ] Basic technical documentation (README with setup + API list)
- [ ] Source code handover

---

## Out of Scope Reminders (do not build unless explicitly asked)

- Flutter mobile app UI/screens
- Android/iOS build & app store submission
- Actual WebRTC/third-party voice calling integration
- Push notifications, SMS/email services

## Notes

- No ORM is used — DB access is plain `mysql2` (`backend/src/config/db.js`, a connection pool). SQL is written by hand in `models/`.
- Schema changes: update `schema.sql` (fresh installs) **and** add a numbered file in `config/migrations/` (existing databases).
- Admin JWT and App-user JWT use separate secrets (`ADMIN_JWT_SECRET`, `JWT_SECRET`) so admin and mobile sessions stay isolated. Both are in use.
- Controllers for dashboard, users and reports are still empty stubs — filled in phase by phase.
- `backend/src/middleware/auth.js` verifies the app-user JWT (`JWT_SECRET`) and loads the user; admin tokens are rejected there (different secret).
- Realtime: **Socket.IO** is the chosen approach for matching (and later call signalling) instead of polling — see Phase 9.1. It runs on the same HTTP server and port as Express (`server.js`, `socket/index.js`).
- Frontend conventions:
  - All API calls go in `admin-frontend/src/api/client.js` (one file) so every request passes through the interceptors. Never import `axios` directly in pages/components.
  - Keep state simple (`useState`/`useEffect` in the page). Redux or other new libraries/abstractions only after discussing first.
  - Reuse `components/common/*` for list pages (tabs, toggles, modals, confirm dialog).
