# App API Guide (for the mobile app developer)

This guide explains the backend APIs the mobile app uses. It is written in simple English.
Each section is one app screen or topic. Each API has a small example with comments.

---

**Status:** ✅ Live = available on the live server. ⏳ Local only = finished in code, not deployed yet.

---

## 1. Getting Started

### Base URL

| Where | URL |
|---|---|
| Live server | `http://82.25.108.195:3064/api` |
| Local testing | `http://localhost:7000/api` (use the `PORT` from `backend/.env`) |

Every API below starts with this base URL. Example: `GET {BASE_URL}/masters/levels`.

### Images (avatars, topic icons)

The API sends image links **without the host**, like this:

```
"image_url": "/uploads/avatars/1790592490303-46c033bb477c.png"
```

To show the image, add the **server address** in front (not the `/api` part):

```
http://82.25.108.195:3064  +  /uploads/avatars/1790592490303-46c033bb477c.png
```

### Response format

Every response is JSON and has a `success` field.

```jsonc
// Success
{ "success": true, "data": { ... } }

// Failure
{ "success": false, "message": "Short reason you can show to the user" }
```

### Status codes

| Code | Meaning | What the app should do |
|---|---|---|
| 200 / 201 | OK / Created | Use the data |
| 400 | Wrong or missing input | Show `message` to the user |
| 401 | Not logged in, or token is bad / expired | Send the user to the login screen |
| 403 | Account is deactivated by admin | Show `message`, then log out |
| 404 | Not found | Show "not found" |
| 500 | Server problem | Show "Something went wrong, try again" |

### Which APIs need login?

- **Public APIs** need nothing. Use them any time (even before login).
- **User APIs** need the login token in the header on **every** request:

```
Authorization: Bearer <TOKEN>
```

### Request body

Send JSON, and add this header. Do **not** use form-data for the app APIs.

```
Content-Type: application/json
```

---

## 2. App Flow (read this first)

```
App opens
   |
   |-- No saved token?  --> Login screen
   |                          1. User taps "Continue with Google"
   |                          2. Firebase gives the app an ID token
   |                          3. App calls  POST /auth/google  with that ID token
   |                          4. App saves the returned `token`
   |
   |-- Has token?  --> call GET /user/profile
   |                     - 401?  token is old -> go to Login
   |
   v
Check  user.is_onboarded
   |
   |-- false --> Onboarding (4 screens, then ONE call: PUT /user/onboarding)
   |
   |-- true  --> Home screen
```

Rules to remember:

1. Save the `token` safely on the phone (secure storage).
2. Send it in the `Authorization` header for every user API.
3. If any user API gives **401**, clear the token and open the Login screen.

---

## 3. Screen → API Map

| Section | App screen | APIs | Status |
|---|---|---|---|
| 4 | Login | `POST /auth/google` | ✅ |
| 5 | Onboarding step 1 (avatar) | `GET /masters/avatars` | ✅ |
| 5 | Onboarding step 3 (English level) | `GET /masters/levels` | ✅ |
| 5 | Onboarding step 4 (goal) | `GET /masters/goals` | ✅ |
| 5 | Onboarding finish | `PUT /user/onboarding` | ✅ |
| 6 | Profile | `GET /user/profile` | ✅ |
| 6 | Log out | `POST /user/logout` | ✅ |
| 7 | Match Preferences | `GET /masters/topics`, `GET /user/match-preferences`, `PUT /user/match-preferences` | ⏳ |
| 8 | Finding Available User (matching) | **Socket.IO** events (not REST) | ⏳ |
| 8 | Call screen (voice) | Socket.IO `call:*`, `webrtc:*` events, `GET /user/call-config` | ⏳ |
| 8 | Call Ended ("Did you enjoy the call?") | `PUT /user/calls/:id/feedback` | ⏳ |
| 8 | Report User (after a call) | `GET /masters/report-reasons`, `POST /user/reports` | ⏳ |
| 9 | Learning | `GET /learning/categories`, `GET /learning/materials`, `GET /learning/materials/:id` | ✅ |
| 10 | FAQ | `GET /faqs` | ✅ |
| 10 | About / Privacy / Terms | `GET /pages/:slug` | ✅ |
| 11 | Report an Issue | `GET /masters/issues`, `POST /user/tickets` | ✅ |
| 11 | Support Tickets | `GET /user/tickets`, `GET /user/tickets/:id` | ✅ |

---

## 4. Login ✅ Live

### `POST /auth/google` (public)

Logs the user in. If this is the first time, it also creates the account.

```bash
curl -X POST {BASE_URL}/auth/google \
  -H "Content-Type: application/json" \
  -d '{"id_token":"<FIREBASE_ID_TOKEN>"}'
```

```jsonc
// Body
{
  "id_token": "..."   // The Firebase ID token you get after Google sign-in
                      // (in Flutter: await firebaseUser.getIdToken())
}
```

```jsonc
// Response
{
  "success": true,
  "token": "eyJhbGciOi...",   // SAVE THIS. Send it as "Bearer <token>" in all user APIs. Valid for 7 days.
  "is_new_user": true,         // true = first time. false = returning user.
  "user": {
    "id": 1,
    "email": "alex@example.com",
    "name": "Alex Johnson",    // Name from the Google account
    "username": null,          // null until onboarding is done
    "gender": null,
    "avatar": null,
    "english_level": null,
    "goal": null,
    "is_onboarded": false,     // false = show onboarding screens
    "created_at": "2026-09-28T12:13:43.000Z"
  }
}
```

Possible errors:

| Code | `message` | Meaning |
|---|---|---|
| 400 | `id_token is required` | You did not send the token |
| 401 | `Google sign-in failed or expired. Please try again.` | Token is wrong or old. Ask the user to sign in again. |
| 401 | `Please sign in with Google` | The token was not from Google sign-in |
| 401 | `Your Google account email is not verified` | The Google email is not verified |
| 403 | `Your account has been deactivated` | Admin blocked this user |

---

## 5. Onboarding (4 screens, 1 save) ✅ Live

Show the 4 screens one by one. **Keep the answers in the app.** At the end, send them all in one call.

### Step 1: Avatar list: `GET /masters/avatars` (public)

```jsonc
{
  "success": true,
  "data": [
    {
      "id": 3,
      "name": "Boy",                                       // Name for admin only. Do not show it.
      "image_url": "/uploads/avatars/1790592490303-xxxx.png",  // Add the server address in front
      "sort_order": 1                                      // Already sorted. Just show in this order.
    }
  ]
}
```

The user can pick **any** avatar. Keep its `id`.

### Step 2: Gender

No API. These are fixed values. Use exactly these words:

| Button | Value to send |
|---|---|
| Male | `male` |
| Female | `female` |
| Others | `other` |

### Step 3: English level: `GET /masters/levels` (public)

```jsonc
{
  "success": true,
  "data": [
    { "id": 1, "name": "Beginner", "description": "I know basic words and phrases", "sort_order": 1 },
    { "id": 2, "name": "Intermediate", "description": "I can hold simple conversations", "sort_order": 2 }
  ]
}
```

Show `name` as the title and `description` below it. Keep the chosen `id`.

### Step 4: Goal: `GET /masters/goals` (public)

```jsonc
{
  "success": true,
  "data": [
    { "id": 1, "name": "Improve Fluency", "sort_order": 1 },
    { "id": 2, "name": "Travel & Tourism", "sort_order": 2 }
  ]
}
```

Keep the chosen `id`.

### Finish: `PUT /user/onboarding` (login needed)

Call this once, on the last screen ("Go to Home Page").

```bash
curl -X PUT {BASE_URL}/user/onboarding \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"username":"Alex","avatar_id":3,"gender":"male","english_level_id":2,"goal_id":1}'
```

```jsonc
// Body: all 5 fields are required
{
  "username": "Alex",        // 2 to 50 characters
  "avatar_id": 3,            // id from /masters/avatars
  "gender": "male",          // male | female | other
  "english_level_id": 2,     // id from /masters/levels
  "goal_id": 1               // id from /masters/goals
}
```

```jsonc
// Response: the saved user. is_onboarded is now true.
{
  "success": true,
  "message": "Profile saved",
  "user": {
    "id": 1, "email": "alex@example.com", "name": "Alex Johnson", "username": "Alex", "gender": "male",
    "avatar": { "id": 3, "name": "Boy", "image_url": "/uploads/avatars/xxxx.png" },
    "english_level": { "id": 2, "name": "Intermediate" },
    "goal": { "id": 1, "name": "Improve Fluency" },
    "is_onboarded": true,
    "created_at": "2026-09-28T12:13:43.000Z"
  }
}
```

Errors (all 400): `Username must be 2 to 50 characters`, `Gender must be one of: male, female, other`,
`Please choose a valid avatar`, `Please choose a valid English level`, `Please choose a valid goal`.

To let the user change these answers later, call the **same API** again with new values.

---

## 6. Profile & Log Out ✅ Live

### `GET /user/profile` (login needed)

Use this for the Profile screen. You can also call it when the app opens to check the token.

```jsonc
{
  "success": true,
  "user": {
    "id": 1,
    "email": "alex@example.com",
    "name": "Alex Johnson",
    "username": "Alex",                  // Show this as the display name
    "gender": "male",
    "avatar": { "id": 3, "name": "Boy", "image_url": "/uploads/avatars/xxxx.png" },
    "english_level": { "id": 2, "name": "Intermediate" },   // "My Level" in Preferences
    "goal": { "id": 1, "name": "Improve Fluency" },
    "is_onboarded": true,
    "created_at": "2026-09-28T12:13:43.000Z",
    "level": 1,                          // The number in "Level 4 . Intermediate"
    "stats": {
      "total_minutes": 0,                // Total speaking time
      "hours_spoken": 0,                 // "Hours Spoken" card
      "calls_made": 0                    // "Calls Made" card
    }
  }
}
```

> `stats` come from the user's **completed calls** (section 8). `level` = FLOOR(total minutes / 300) + 1.
> On the live server they stay `0` / `1` until matching and calls are deployed.

`GET /user/me` gives the same user **without** `level` and `stats`. Use `/user/profile` for the Profile screen.

### `POST /user/logout` (login needed)

```bash
curl -X POST {BASE_URL}/user/logout -H "Authorization: Bearer <TOKEN>"
```

```jsonc
{ "success": true, "message": "Logged out" }
```

After this call, **all old tokens of this user stop working** (on every phone).
Delete the saved token in the app and open the Login screen.

---

## 7. Match Preferences ⏳ Local only

> Not on the live server yet (works on local).

The user chooses who to speak with. Use these three APIs.

### `GET /masters/topics` (public): the "Select Topic" list

```jsonc
{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "Daily Life",
      "description": null,
      "icon_url": "/uploads/topics/1790833267069-xxxx.png",   // Can be null. Then show a default icon.
      "sort_order": 1
    }
  ]
}
```

The **"Any"** option is **not** in this list. Add it yourself at the end of the list in the app. When the user picks "Any", send `topic_id: null`.

### `GET /user/match-preferences` (login needed): show the saved choice

```jsonc
{
  "success": true,
  "data": {
    "gender": "any",          // male | female | any
    "partner_level": "any",   // any | similar | fluent
    "topic": null             // null = "Any" topic. Or { "id": 1, "name": "Daily Life", "icon_url": "..." }
  }
}
```

If the user never saved anything, you get all "any" (shown above). Use this to pre-select the buttons.

### `PUT /user/match-preferences` (login needed): save the choice

```bash
curl -X PUT {BASE_URL}/user/match-preferences \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"gender":"male","partner_level":"similar","topic_id":1}'
```

```jsonc
{
  "gender": "male",           // Male card = male, Female card = female, Any card = any
  "partner_level": "similar", // "Any Level" = any, "Similar Level" = similar, "Fluent / Native" = fluent
  "topic_id": 1               // id from /masters/topics, or null for "Any"
}
```

The response has the same shape as the GET response, with `"message": "Match preferences saved"`.

Errors (400): `Gender must be one of: male, female, any`, `Partner level must be one of: any, similar, fluent`,
`Please select a valid topic`.

Good to know: if the admin deletes a topic that the user had chosen, the user's topic becomes "Any" by itself.

---

## 8. Matching — "Finding Available User" (Socket.IO) ⏳ Local only

> Works on **local**. Not on the live server yet.

Matching uses **socket events, not REST APIs** (the server must push events like "someone is calling you").
Before matching, save the Match Preferences (section 7). **Matching always uses the saved values.**

| | Socket URL (no `/api`) |
|---|---|
| Live | `http://82.25.108.195:3064` |
| Local | `http://<PC-IP>:7000` |

### 8.1 Connect the socket

Connect once after login. Close it on logout.

```dart
// package: socket_io_client
final socket = IO.io(
  'http://82.25.108.195:3064',              // no /api
  IO.OptionBuilder()
      .setTransports(['websocket'])
      .setAuth({'token': token})            // login token
      .disableAutoConnect()
      .build(),
);

socket.onConnect((_) => print('connected'));
socket.onConnectError((err) => print('connect error: $err'));   // bad token -> go to Login
socket.connect();

// On logout:
socket.dispose();
```

If the token is wrong or expired, you get `connect_error` (e.g. `Invalid or expired token`). Send the user to Login.

The socket reconnects by itself after a network drop.

---

### 8.2 Events

#### App sends

| Event | Data | When |
|---|---|---|
| `match:start` | none | User taps **Match** |
| `match:cancel` | none | User taps **Cancel Search** |
| `call:start` | `{ "call_session_id": 9 }` | Audio is connected (both phones may send it) |
| `call:end` | `{ "call_session_id": 9, "reason": "ended" }` | User taps **End Call**. Use `"reason": "connection_lost"` if the audio failed |
| `webrtc:offer` | `{ "call_session_id": 9, "sdp": {...} }` | Voice: caller sends its offer (see 8.6) |
| `webrtc:answer` | `{ "call_session_id": 9, "sdp": {...} }` | Voice: the other user sends its answer |
| `webrtc:ice-candidate` | `{ "call_session_id": 9, "candidate": {...} }` | Voice: each network path found (many times, both users) |
| `activity:reveal` | `{ "call_session_id": 9 }` | User taps **Show Answer** (see 8.7) |
| `activity:next` | `{ "call_session_id": 9 }` | User taps **Next** |

```dart
socket.emit('match:start');
socket.emit('call:start', {'call_session_id': callSessionId});
socket.emit('call:end', {'call_session_id': callSessionId, 'reason': 'ended'});
```

#### App receives

| Event | Data | What to do |
|---|---|---|
| `match:searching` | `{ matching_session_id, expires_at, topic }` | Show "Finding Available User…", count down to `expires_at` |
| `match:found` | `{ matching_session_id, call_session_id, is_caller, partner, topic }` | Sent to both users as soon as they fit (no accept / reject). Open the call screen and start the voice connection (8.6) |
| `match:not-found` | `{ matching_session_id }` | Show "No partner found" + Try again |
| `match:cancelled` | `{ matching_session_id }` | Go back to Match Preferences |
| `call:started` | `{ call_session_id, started_at }` | Start the call timer from `started_at` (same on both phones) |
| `call:ended` | `{ call_session_id, duration_seconds, ended_by, reason }` | Close the call screen (`reason`: `ended`, `connection_lost`, `connect_failed`, `partner_disconnected`) |
| `call:partner-reconnecting` | `{ call_session_id }` | Partner's internet dropped: show "Reconnecting…" (keep the timer running) |
| `call:partner-reconnected` | `{ call_session_id }` | Partner is back: hide "Reconnecting…" |
| `call:resume` | `{ call_session_id, is_caller, status, started_at, partner, topic }` | Sent right after connecting if this user is still in a call: open the call screen again. `status` is `connecting` (`started_at` null) or `active` |
| `webrtc:offer` / `webrtc:answer` | `{ call_session_id, sdp }` | Voice: from the partner, set it on the peer connection (8.6) |
| `webrtc:ice-candidate` | `{ call_session_id, candidate }` | Voice: from the partner, add it to the peer connection |
| `activity:show` | `{ call_session_id, number, title, instruction, question }` | Show the activity card ("Activity `number`"), hide the old answer |
| `activity:answer` | `{ call_session_id, number, answer }` | Show the answer (both users tapped Show Answer) |
| `activity:partner-waiting` | `{ call_session_id, action }` | Partner tapped `reveal` (Show Answer) or `next` and waits for you |
| `activity:none` | `{ call_session_id }` | No activities: hide the activity card |
| `match:error` | `{ message }` | Show `message` |

```dart
socket.on('match:searching', (data) { /* open Finding screen */ });
socket.on('match:found', (data) { /* open call screen */ });
// ...same for the other events
```

**Notes**

- `topic` is `{ "id": 4, "name": "Technology" }` or `null` (Any → do not show a topic).
- `partner`: `{ id, username, gender, avatar: { image_url }, english_level: { name }, level }`. `avatar` and `english_level` can be `null`.
- `level` (number) is the speaking level for the **"Level"** badge on the call screen (same as `level` in `GET /user/profile`). For your own badge, use `level` from `GET /user/profile`.
- Image: server address + `image_url` (e.g. `http://82.25.108.195:3064/uploads/avatars/x.png`).
- Times (`expires_at`, `started_at`) are server time in UTC.
- After `match:found`, send `call:start` within **30 seconds**, otherwise the call fails (`call:ended` with `connect_failed`).
- `duration_seconds` is counted by the server. Completed calls update the profile (`hours_spoken`, `calls_made`, `level`).
- **Internet drops during a call:** the server notices in about 10–20 s and waits **30 seconds** for the user. Keep the same token and let the socket reconnect by itself (Socket.IO does this). Back in time → `call:resume`, the call goes on. Not back → the partner gets `call:ended` with `partner_disconnected`; the duration counts only up to the drop.
- **The offline phone never gets `call:ended`** (it is offline), so the app must close the call itself:
  1. Socket `disconnect` during a call → show "Reconnecting…" and start a **30-second** timer.
  2. Timer runs out → close the call screen and the voice connection ("Call ended: connection lost").
  3. Socket connects again but **no `call:resume`** arrives within ~3 seconds → the call already ended: close the call screen.
  4. User taps **End Call while offline** → do not emit (the server cannot get it); close the call screen and the voice connection at once. The server ends the call itself after 30 seconds.

---

### 8.3 Flow

```
Match Preferences  --tap Match-->  emit match:start
        |
        v
Finding screen  (match:searching, 2-minute countdown)
   |-- Cancel Search   -> emit match:cancel  -> match:cancelled -> back
   |-- match:not-found -> "No partner found"
   |-- match:found     -> Call screen (both users, straight away)

Call screen  (after match:found)
   |-- audio connected -> emit call:start -> call:started (start the timer)
   |-- tap End Call    -> emit call:end   -> call:ended (both phones)
   |-- no call:start in 30 s              -> call:ended (connect_failed)
   |-- partner's internet drops -> call:partner-reconnecting ("Reconnecting…")
          |-- partner back in 30 s -> call:partner-reconnected
          |-- not back            -> call:ended (partner_disconnected)

My internet drops -> socket reconnects -> call:resume (open the call screen again)
```

---

### 8.4 Rules (handled by the server)

- One search lasts **2 minutes**. Two searching users who fit are connected at once; the one who waited longer is the caller (`is_caller: true`).
- Only users who are **also searching** are matched, and both users' preferences must fit each other.
- "Any" fits everyone. "Similar" = same English level. "Fluent" = top English level.
- Tapping Match again while searching sends the same search again (good after a reconnect).
- After `match:found` the user cannot start a new search until the call ends.
- **Internet drops while searching:** the search is cancelled (no event, the phone is offline). After reconnecting, tap Match again.
- **Server restart:** all searches and calls stop. After the socket reconnects, no `call:resume` comes → go back to the start screen.

### 8.5 `match:error` messages

| Message | Meaning |
|---|---|
| `Please complete your profile first` | Onboarding not done |
| `You are already in a call` | Already matched |
| `Call not found` | `call:start` / `call:end` / `webrtc:*` / `activity:*` for a call that is over or not yours |
| `No activity to show` | `activity:*` while there are no activities |
| `sdp is required` / `candidate is required` | `webrtc:*` sent without its data |
| `Something went wrong. Please try again.` | Server error |

### 8.6 Voice call (WebRTC)

The voice goes **directly between the two phones**. The server only passes the connection messages
(`webrtc:*`) to the partner, unchanged. Use the package `flutter_webrtc`.

#### `GET /user/call-config` (login needed): STUN/TURN servers

```jsonc
// Response 200
{
  "success": true,
  "data": {
    "ice_servers": [
      { "urls": ["stun:stun.l.google.com:19302"] }
      // later also: { "urls": [...], "username": "...", "credential": "..." }  (TURN)
    ]
  }
}
```

Use `ice_servers` as `iceServers` when you create the peer connection. Do not hard-code the servers.

#### Steps (after `match:found`)

```
1. Both phones: turn on the microphone, create the peer connection (ice_servers from call-config).
2. is_caller = true  -> create offer  -> emit webrtc:offer
3. is_caller = false -> on webrtc:offer: set it, create answer -> emit webrtc:answer
4. Caller            -> on webrtc:answer: set it
5. Both phones       -> each new ICE candidate -> emit webrtc:ice-candidate
                        on webrtc:ice-candidate -> add it
6. Connection state "connected" -> emit call:start (the call timer starts)
7. Connection state "failed"    -> emit call:end { reason: "connection_lost" }
```

```dart
// Send (caller)
socket.emit('webrtc:offer', {'call_session_id': id, 'sdp': offer.toMap()});
// Send (both)
pc.onIceCandidate = (c) => socket.emit('webrtc:ice-candidate', {'call_session_id': id, 'candidate': c.toMap()});
// Receive
socket.on('webrtc:offer', (d) async { await pc.setRemoteDescription(RTCSessionDescription(d['sdp']['sdp'], d['sdp']['type'])); /* create + send answer */ });
socket.on('webrtc:answer', (d) => pc.setRemoteDescription(RTCSessionDescription(d['sdp']['sdp'], d['sdp']['type'])));
socket.on('webrtc:ice-candidate', (d) => pc.addCandidate(RTCIceCandidate(d['candidate']['candidate'], d['candidate']['sdpMid'], d['candidate']['sdpMLineIndex'])));
```

**Notes**

- Only the **caller** creates the offer. This avoids both phones sending an offer at the same time.
- After a reconnect (`call:resume`), the caller makes a **new offer with ICE restart** to bring the audio back.
- Mute / Speaker are handled on the phone only (no server event).
- Testing: STUN works on most Wi-Fi. Some mobile networks need a TURN server (coming before production).

### 8.7 Call activity (Show Answer / Next)

The activity card on the call screen ("Activity 1 — Brain test, Solve together"). The admin creates the
questions; every call shows them in a random order. **Both users must tap** for the answer or the next one to open.

```
call:started            -> activity:show (Activity 1, no answer)
Show Answer (me)        -> emit activity:reveal -> partner sees "Your partner wants to see the answer"
Show Answer (partner)   -> both get activity:answer
Next (me)               -> emit activity:next   -> partner sees "Your partner wants the next activity"
Next (partner)          -> both get activity:show (Activity 2)
```

```dart
socket.on('activity:show', (d) { /* title, instruction, question; clear answer */ });
socket.on('activity:answer', (d) { /* show d['answer'] */ });
socket.on('activity:partner-waiting', (d) { /* d['action'] == 'reveal' or 'next' */ });
socket.emit('activity:reveal', {'call_session_id': callSessionId});
socket.emit('activity:next', {'call_session_id': callSessionId});
```

**Notes**

- The first activity comes right after `call:started`. After the last one, a new random round starts (`number` keeps counting).
- The answer is never sent before both users tap Show Answer. Tapping twice counts once.
- Next works without Show Answer too.
- After a reconnect, `call:resume` is followed by the current `activity:show` (and `activity:answer` if it was already open).
- Show "Waiting for your partner…" after your own tap (no server event for that).

### 8.8 After the call: feedback and Report User

Use `call_session_id` from `match:found` / `call:ended`.

#### `PUT /user/calls/:id/feedback` (login needed): "Did you enjoy the call?"

```json
{ "enjoyed": true }
```

Response `data`: `{ "call_session_id": 21, "enjoyed": true, "updated_at": "…" }`. Sending again replaces the earlier answer.
`404 Call not found` if the user was not in that call.

#### `GET /masters/report-reasons` (public): the "Why are you reporting this user?" list

```json
{ "success": true, "data": [ { "id": 1, "name": "Inappropriate Behavior", "icon": "warning", "sort_order": 1 } ] }
```

`icon` is one of `flag`, `warning`, `abuse`, `spam`, `harassment`, `fake_profile`, `offensive_language`, `other`.
Show a flag for any key the app does not know. Super admins manage this list (Admin panel → Report Reasons).

#### `POST /user/reports` (login needed): report the partner

```json
{ "call_session_id": 21, "reason_id": 3, "description": "optional, max 200 chars" }
```

The reported user is the other person in that call (the app does not send it).

| Status | Message |
|---|---|
| 201 | `Report submitted` |
| 400 | `Please select a valid reason` / description too long |
| 404 | `Call not found` (not your call) |
| 409 | `You have already reported this user for this call` |

New reports start as `pending`; admins move them to `in_review`, `resolved` or `dismissed` (Admin panel → Reports).

### 8.9 Not ready yet

- Call history (next steps).

---

## 9. Learning ✅ Live

Three public APIs. No login needed.

### `GET /learning/categories`: the category list

```jsonc
{
  "success": true,
  "data": [
    { "id": 4, "name": "Basic", "description": "Basic English", "material_count": 1 }   // material_count = lessons inside
  ]
}
```

### `GET /learning/materials`: the lesson list (search, filter, pages)

```bash
curl "{BASE_URL}/learning/materials?category_id=4&search=grammar&page=1&limit=10"
```

All query values are optional:

| Query | Meaning | Default |
|---|---|---|
| `category_id` | Show lessons of one category | all categories |
| `search` | Search in the title **and** the content | no search |
| `page` | Page number | 1 |
| `limit` | Lessons per page (maximum 50) | 10 |

```jsonc
{
  "success": true,
  "data": [
    {
      "id": 3,
      "title": "English grammar",
      "content": "English grammar must important topic",
      "category": { "id": 4, "name": "Basic" },
      "created_at": "2026-09-25T10:22:35.000Z",
      "updated_at": "2026-09-25T10:29:30.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 1,          // All lessons that match
    "total_pages": 1     // Load the next page while page < total_pages
  }
}
```

How to do "load more": when the user scrolls to the end, call again with `page + 1`.
Stop when `page` is equal to `total_pages`.

### `GET /learning/materials/:id`: one lesson

Same object as one item above, inside `data`. If the lesson is not found you get **404**
with `Learning material not found`.

---

## 10. FAQ & Info Pages ✅ Live

### `GET /faqs`

```jsonc
{
  "success": true,
  "data": [
    {
      "id": 1,
      "question": "How can I sound more natural?",   // The title row
      "answer": "Focus on linking words together...",  // Show when the row is opened
      "sort_order": 1
    }
  ]
}
```

### `GET /pages/:slug`: About Us, Privacy Policy, Terms

```bash
curl {BASE_URL}/pages/about-us
```

```jsonc
{
  "success": true,
  "data": { "title": "About us", "slug": "about-us", "content": "...", "updated_at": "..." }
}
```

The `slug` must be **exactly** the one the admin created. Ask the admin which slugs exist
(for example `about-us`, `privacy-page`, `terms-and-condition`). A wrong slug gives **404**
`Page not found`.

---

## 11. Support: Report an Issue & Support Tickets ✅ Live

### Report an Issue screen

**Step 1:** `GET /masters/issues` (public): fill the "Select Issue" dropdown.

```jsonc
{
  "success": true,
  "data": [
    { "id": 1, "name": "Audio problem", "sort_order": 1 },
    { "id": 2, "name": "Unable to update profile", "sort_order": 2 }
  ]
}
```

**Step 2:** `POST /user/tickets` (login needed): the "Submit" button.

```bash
curl -X POST {BASE_URL}/user/tickets \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"issue_id":1,"description":"No sound during calls"}'
```

```jsonc
{
  "issue_id": 1,                         // Required. id from /masters/issues
  "description": "No sound during calls" // Optional. Maximum 200 characters (the 0/200 counter)
}
```

```jsonc
{
  "success": true,
  "message": "Issue reported",
  "data": {
    "id": 1,
    "ticket_no": "BT10001",              // Show as "#BT10001"
    "issue": { "id": 1, "name": "Audio problem" },
    "description": "No sound during calls",   // Can be null
    "status": "open",
    "created_at": "2026-09-29T10:39:05.000Z",
    "updated_at": "2026-09-29T10:39:05.000Z"
  }
}
```

Errors (400): `Please select a valid issue`, `Description must be at most 200 characters`.

### Support Tickets screen

**`GET /user/tickets`** (login needed): the user's own tickets, newest first.

```bash
curl "{BASE_URL}/user/tickets?status=open" -H "Authorization: Bearer <TOKEN>"
```

`status` is optional. Leave it out to get all tickets.

```jsonc
{
  "success": true,
  "data": [
    {
      "id": 1,
      "ticket_no": "BT10001",
      "issue": { "id": 1, "name": "Audio problem" },     // The bold title on the card
      "description": "No sound during calls",            // The grey text under the title (can be null)
      "status": "in_progress",
      "created_at": "2026-09-29T10:39:05.000Z",          // The date on the card
      "updated_at": "2026-09-29T10:45:00.000Z"
    }
  ]
}
```

**`GET /user/tickets/:id`** (login needed): one ticket, same object inside `data`.
You get **404** `Ticket not found` if it does not exist or belongs to another user.

### Ticket status values

The admin changes the status from the admin panel. The app only shows it.

| `status` value | Show on the card as |
|---|---|
| `open` | Open |
| `in_progress` | In Progress |
| `resolved` | Resolved |
| `closed` | Closed |

---

## 12. Error Handling

1. **Every call:** check `success`. If `false`, show `message`.
2. **401 on a user API:** delete the saved token and open the Login screen.
3. **403:** the account is blocked. Show `message` and log out.
4. **Network or 500 error:** show "Something went wrong, please try again" and add a Retry button.
5. **Empty lists** (`data: []`) are normal. Show an "empty" message, not an error.

---

## 13. Testing (curl)

```bash
# 1. Public API: no token needed
curl http://localhost:7000/api/masters/levels

# 2. User API: you need a token from POST /auth/google
curl http://localhost:7000/api/user/profile -H "Authorization: Bearer <TOKEN>"

# 3. Voice call servers (STUN/TURN)
curl http://localhost:7000/api/user/call-config -H "Authorization: Bearer <TOKEN>"
```

---

## 14. Production Setup (voice calls)

Sections 7 and 8 (Match Preferences, matching, voice call) go live only after these steps.

### 14.1 App side

- **Microphone permission:** Android `RECORD_AUDIO` (+ `MODIFY_AUDIO_SETTINGS`), iOS `NSMicrophoneUsageDescription` in `Info.plist`.
- **ICE servers:** always read them from `GET /user/call-config` before each call. When the TURN server is added, the app gets it automatically (no app update).
- **Plain HTTP:** the live server is `http://` for now. Android blocks plain HTTP by default, so allow it (`usesCleartextTraffic`) until the server moves to HTTPS (then use `https://` and the socket URL `https://...`).
- **Test on mobile data too** (not only Wi-Fi): this is where calls fail without TURN.

### 14.2 Server side (backend team)

1. **Database:** run migrations `012_topic_icons_and_match_preferences.sql`, `013_create_matching_and_calls.sql` and `014_create_activities.sql` on the live database **before** the code is merged to `main`.
2. **`backend/.env` on the server:**
   ```env
   MATCH_SESSION_SECONDS=120
   CALL_CONNECT_TIMEOUT_SECONDS=30
   CALL_RECONNECT_GRACE_SECONDS=30
   STUN_URLS=stun:stun.l.google.com:19302
   TURN_URLS=turn:82.25.108.195:3478?transport=udp,turn:82.25.108.195:3478?transport=tcp
   TURN_USERNAME=<turn user>
   TURN_CREDENTIAL=<turn password>
   ```
   Then restart: `pm2 restart speaking-api --update-env`.
3. **One process only:** run `speaking-api` as **one** PM2 process (no cluster mode). Matching, timers and live calls are kept in that process's memory.
4. **Reverse proxy (only if Nginx is used):** allow WebSocket upgrade (`proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";`, `proxy_read_timeout 3600s`). Direct access on port 3064 needs nothing.
5. **TURN server (coturn)**, needed for calls on strict / mobile networks (about 10–20% of calls):
   ```bash
   sudo apt install coturn
   ```
   `/etc/turnserver.conf`:
   ```conf
   listening-port=3478
   fingerprint
   lt-cred-mech
   user=<turn user>:<turn password>
   realm=btalk-eng
   external-ip=82.25.108.195
   min-port=49152
   max-port=65535
   no-cli
   ```
   - Enable it: set `TURNSERVER_ENABLED=1` in `/etc/default/coturn`, then `sudo systemctl enable --now coturn`.
   - Open the firewall: **3478 UDP + TCP** and **49152–65535 UDP**.
   - Check it: open the "Trickle ICE" test page (webrtc.github.io/samples), add the TURN url, user and password → a `relay` candidate must appear.
   - Put the same url, user and password in `.env` (step 2) and restart the API.
6. **Final test:** two phones, one on Wi-Fi and one on mobile data: match → voice → End Call → `GET /user/profile` shows the new stats.

**Later (recommended):** a domain with HTTPS (`wss://`), and short-lived TURN passwords instead of one fixed user.
