Aller au contenu

REST API Reference

Version française
Back to index


General conventions

Base URL

http://localhost:3000   (development)
https://[your-domain]  (production)

All endpoints are under the /api/ prefix.

Response format

Success:

{ "success": true, "data": { ... } }

Error:

{ "error": "Explicit error message" }

HTTP status codes

Code Meaning
200 Success (GET, PATCH, DELETE)
201 Resource created (POST)
400 Invalid request (missing fields, incorrect format)
401 Not authenticated
403 Access forbidden (resource belonging to another user)
404 Resource not found
429 Too many requests (rate limiting)
500 Internal server error

Authentication

Three authentication modes coexist depending on the endpoint:

Type Mechanism Endpoints
Admin auth-token cookie (JWT HS256, 7 days) /api/admin/*, /api/auth/*
Speaker Code in the body (DB lookup, localStorage session) /api/speaker/login, /api/audio/upload
Public None /api/languages, /api/languages/[id]/content, /api/statistics/track, /api/quiz/*, /api/contact, /api/health/*

Health

GET /api/health/live

Liveness probe — only verifies that the Node.js process is responding. Used by the Docker HEALTHCHECK. Does not depend on any external service (no DB connection).

curl http://localhost:3000/api/health/live

200 response:

{ "status": "ok" }

⚠️ This endpoint always responds 200 as long as Next.js is running. It does NOT verify that PostgreSQL or MinIO are available.


Admin Authentication

POST /api/auth/login

Authenticates an administrator. Returns a auth-token JWT cookie valid for 7 days.

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@racines.app","password":"password123"}' \
  -c cookies.txt

Body:

{
  "email": "admin@racines.app",
  "password": "password123"
}

200 response:

{
  "success": true,
  "message": "Authentication successful",
  "admin": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "admin@racines.app"
  }
}

The auth-token cookie is set in the response's Set-Cookie headers (HttpOnly, Secure, SameSite=Lax).

Errors: | Case | Code | Message | |---|---|---| | Missing fields | 400 | "Email and password required" | | Invalid email | 400 | "Invalid email" | | Password < 8 characters | 400 | "Password must be at least 8 characters" | | Incorrect credentials | 401 | "Incorrect email or password" |

Security note: Whether the email doesn't exist or the password is wrong, the response is identical (no information leak about whether the account exists).


GET /api/auth/login

Checks whether the admin session cookie is valid. Used by the frontend to detect an expired session.

curl http://localhost:3000/api/auth/login \
  -b cookies.txt

Response if authenticated:

{
  "authenticated": true,
  "admin": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "admin@racines.app"
  }
}

Response if not authenticated:

{ "authenticated": false }

POST /api/auth/logout

Invalidates the admin session cookie (cookie set to immediate expiration).

curl -X POST http://localhost:3000/api/auth/logout \
  -b cookies.txt

200 response:

{ "success": true }

Speaker Authentication

POST /api/speaker/login

Verifies a speaker's access code. The session is managed client-side in localStorage (no server cookie).

curl -X POST http://localhost:3000/api/speaker/login \
  -H "Content-Type: application/json" \
  -d '{"accessCode":"RACINES-ABCDE-2025"}'

Body:

{
  "accessCode": "RACINES-ABCDE-2025"
}

The code is normalized to uppercase server-side before the database lookup.

200 response:

{
  "success": true,
  "speaker": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Amadou Diallo",
    "access_code": "RACINES-ABCDE-2025",
    "language_id": "language-uuid",
    "language_code": "pul",
    "language_name": "Pular"
  }
}

Errors: | Case | Code | Message | |---|---|---| | Missing code | 400 | "Access code required" | | Invalid code or inactive speaker | 401 | "Invalid access code or inactive speaker" |


Languages

GET /api/languages

Retrieves the list of active languages. Can be filtered by code.

# All active languages
curl http://localhost:3000/api/languages

# Filter by code
curl "http://localhost:3000/api/languages?code=pul"

Query params: | Param | Type | Description | |---|---|---| | code | string | Optional — filter by language code (pul, wol, etc.) |

200 response:

{
  "success": true,
  "languages": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Pular",
      "code": "pul",
      "access_code": "RACINES-XXXXX-2025",
      "is_active": true,
      "display_order": 1,
      "primary_color": "#D97706",
      "icon_url": "https://[MINIO_PUBLIC_URL]/audios/icons/pul.png",
      "created_at": "2025-01-15T10:00:00Z"
    }
  ]
}

Note: speaker_access_code and speaker_name are NOT returned by this public endpoint (admin-only information).


POST /api/languages

Admin auth required.

Creates a new language with its first speaker.

curl -X POST http://localhost:3000/api/languages \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "name": "Wolof",
    "code": "wol",
    "access_code": "RACINES-WOLOF-2025",
    "speaker_access_code": "RACINES-SPK-WOL-2025",
    "speaker_name": "Fatou Diop",
    "primary_color": "#2563EB",
    "is_active": false,
    "display_order": 2
  }'

Body:

{
  "name": "Wolof",
  "code": "wol",
  "access_code": "RACINES-WOLOF-2025",
  "speaker_access_code": "RACINES-SPK-WOL-2025",
  "speaker_name": "Fatou Diop",
  "primary_color": "#2563EB",
  "is_active": false,
  "display_order": 2
}

201 response:

{
  "success": true,
  "data": {
    "id": "new-uuid",
    "name": "Wolof",
    "code": "wol",
    "access_code": "RACINES-WOLOF-2025",
    "is_active": false,
    "display_order": 2,
    "primary_color": "#2563EB",
    "created_at": "2026-01-15T10:00:00Z"
  }
}

PATCH /api/languages/[id]

Admin auth required.

Updates one or more properties of a language. All fields are optional.

curl -X PATCH http://localhost:3000/api/languages/550e8400-e29b-41d4-a716-446655440000 \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"is_active": true}'

Body (all fields optional):

{
  "name": "Pular (Fuuta Tooro)",
  "is_active": true,
  "primary_color": "#B45309",
  "display_order": 1,
  "speaker_name": "Amadou Diallo",
  "speaker_access_code": "RACINES-SPK-NEW-2025",
  "icon_url": "https://[MINIO]/audios/icons/pul-v2.png"
}

200 response:

{
  "success": true,
  "data": { /* complete updated language */ }
}

DELETE /api/languages/[id]

Admin auth required.

Deletes a language and all its dependencies in cascade (cards, items, speakers, statistics). Irreversible action. Audio files in MinIO are not deleted.

curl -X DELETE http://localhost:3000/api/languages/550e8400-e29b-41d4-a716-446655440000 \
  -b cookies.txt

200 response:

{
  "success": true,
  "message": "Language deleted successfully"
}

POST /api/languages/verify-code

Verifies that a player access code matches a given language. Used when unlocking a language.

curl -X POST http://localhost:3000/api/languages/verify-code \
  -H "Content-Type: application/json" \
  -d '{"languageId":"language-uuid","code":"RACINES-XXXXX-2025"}'

Body:

{
  "languageId": "550e8400-e29b-41d4-a716-446655440000",
  "code": "RACINES-XXXXX-2025"
}

The code is normalized to uppercase server-side before comparison.

200 response:

{ "valid": true }

or

{ "valid": false }

This endpoint never reveals whether a language exists or not. An unknown language simply returns { "valid": false }.


GET /api/languages/[id]/content

Retrieves the full content of a language for offline download (IndexedDB). Returns the language, all its cards, and all its items in a single request.

curl "http://localhost:3000/api/languages/550e8400-e29b-41d4-a716-446655440000/content"

200 response:

{
  "success": true,
  "language": {
    "id": "uuid",
    "name": "Pular",
    "code": "pul",
    "primary_color": "#D97706",
    "icon_url": "..."
  },
  "cards": [
    {
      "id": "card-uuid",
      "language_id": "uuid",
      "type": "word",
      "card_number": 1,
      "theme": "Family"
    }
  ],
  "items": [
    {
      "id": "item-uuid",
      "card_id": "card-uuid",
      "position": 1,
      "original_text": "Baaba",
      "translation": "Father",
      "audio_url": "https://[CDN]/audios/pul/item-uuid/audio.mp3",
      "lot_number": null,
      "item_type": null
    }
  ]
}

This endpoint is used exclusively for offline download. Content is stored in IndexedDB v3.


Cards

POST /api/cards

Admin auth required.

Creates a new card for a language.

curl -X POST http://localhost:3000/api/cards \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "language_id": "language-uuid",
    "type": "word",
    "card_number": 81,
    "theme": "Animals"
  }'

Body:

{
  "language_id": "550e8400-e29b-41d4-a716-446655440000",
  "type": "word",
  "card_number": 81,
  "theme": "Animals"
}

type must be "word" or "phrase".

201 response:

{
  "success": true,
  "data": {
    "id": "new-card-uuid",
    "language_id": "language-uuid",
    "type": "word",
    "card_number": 81,
    "theme": "Animals",
    "created_at": "2026-01-15T10:00:00Z"
  }
}

POST /api/cards/import

Admin auth required.

Bulk import via Excel file (.xlsx). Creates cards and their items from a structured file.

curl -X POST http://localhost:3000/api/cards/import \
  -b cookies.txt \
  -F "file=@content_pular.xlsx" \
  -F "languageId=language-uuid" \
  -F "type=word"

Form data: | Field | Type | Description | |---|---|---| | file | File | .xlsx file | | languageId | string | Language UUID | | type | string | "word" or "phrase" |

For the exact Excel file structure, see 06-import-excel.md.

200 response:

{
  "success": true,
  "cardsCreated": 80,
  "itemsCreated": 480
}

Items

POST /api/items

Admin auth required.

Creates one or more items. Supports individual or bulk insertion.

Individual insertion:

curl -X POST http://localhost:3000/api/items \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "card_id": "card-uuid",
    "position": 1,
    "original_text": "Baaba",
    "translation": "Father"
  }'

Bulk insertion:

curl -X POST http://localhost:3000/api/items \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "items": [
      {"card_id": "card-uuid", "position": 1, "original_text": "Baaba", "translation": "Father"},
      {"card_id": "card-uuid", "position": 2, "original_text": "Yaye", "translation": "Mother"},
      {"card_id": "card-uuid", "position": 3, "original_text": "Tonton", "translation": "Uncle"},
      {"card_id": "card-uuid", "position": 4, "original_text": "Tata", "translation": "Aunt"},
      {"card_id": "card-uuid", "position": 5, "original_text": "Aan", "translation": "Elder sister"},
      {"card_id": "card-uuid", "position": 6, "original_text": "Kaaño", "translation": "Younger brother"}
    ]
  }'

Body fields: | Field | Type | Required | Description | |---|---|---|---| | card_id | string | ✅ | Parent card UUID | | position | integer | ✅ | 1–6 for words, 1–4 for phrases | | original_text | string | ✅ | Text in the African language | | translation | string | ✅ | French translation | | audio_url | string | ❌ | URL to MinIO (null by default) | | lot_number | integer | ❌ | 1 or 2 (phrases only) | | item_type | string | ❌ | "question" or "answer" (phrases only) |

201 response (bulk insertion):

{
  "success": true,
  "data": [
    {
      "id": "item-uuid-1",
      "card_id": "card-uuid",
      "position": 1,
      "original_text": "Baaba",
      "translation": "Father",
      "audio_url": null,
      "lot_number": null,
      "item_type": null,
      "created_at": "2026-01-15T10:00:00Z"
    }
  ]
}

PATCH /api/items/[id]

Admin auth required.

Updates an item. All fields are optional.

curl -X PATCH http://localhost:3000/api/items/item-uuid \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"original_text": "Baaba (correction)", "translation": "Father (formal)"}'

Body:

{
  "original_text": "Baaba (correction)",
  "translation": "Father (formal)",
  "audio_url": "https://[CDN]/audios/pul/item-uuid/audio.mp3"
}

200 response:

{
  "success": true,
  "data": { /* updated item */ }
}

Speakers

POST /api/speakers

Admin auth required.

Creates a new speaker for a language.

curl -X POST http://localhost:3000/api/speakers \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "language_id": "language-uuid",
    "name": "Mariama Baldé",
    "access_code": "RACINES-SPK-MRB-2025",
    "is_active": true
  }'

Body:

{
  "language_id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Mariama Baldé",
  "access_code": "RACINES-SPK-MRB-2025",
  "is_active": true
}

201 response:

{
  "success": true,
  "data": {
    "id": "speaker-uuid",
    "language_id": "language-uuid",
    "name": "Mariama Baldé",
    "access_code": "RACINES-SPK-MRB-2025",
    "is_active": true,
    "created_at": "2026-01-15T10:00:00Z"
  }
}

Audio

POST /api/audio/upload

Speaker auth required (code passed in headers or session cookie).

Uploads an audio file for an item. The file is automatically converted to mono 96kbps MP3 via FFmpeg, then stored in MinIO.

curl -X POST http://localhost:3000/api/audio/upload \
  -H "X-Speaker-Code: RACINES-SPK-MRB-2025" \
  -F "audio=@recording.wav" \
  -F "itemId=item-uuid"

Form data: | Field | Type | Description | |---|---|---| | audio | File | Audio file (MP3, WAV, WebM, OGG, M4A, FLAC) | | itemId | string | Target item UUID |

Limits: - Maximum size: 50 MB - Accepted formats: MP3, WAV, WebM, OGG, M4A, FLAC

Automatic FFmpeg processing: - Conversion to MP3 - 96 kbps bitrate - Mono (1 channel) - 44,100 Hz sample rate - Volume normalization (EBU R128 loudnorm)

Generated MinIO key: [language_code]/[item_uuid]/audio.mp3

200 response:

{
  "success": true,
  "audioUrl": "https://[MINIO_PUBLIC_URL]/audios/pul/item-uuid/audio.mp3"
}

The items.audio_url column is automatically updated in the database.

Errors: | Case | Code | Message | |---|---|---| | Speaker not authenticated | 401 | "Authentication required" | | File too large | 400 | "File too large (max 50 MB)" | | Unsupported format | 400 | "Unsupported audio format" | | Item not found | 404 | "Item not found" |


GET /api/audio/serve/[[...path]]

MinIO proxy — used only in local development or when AUDIO_CDN_URL is absent.

curl "http://localhost:3000/api/audio/serve/pul/item-uuid/audio.mp3"

Path traversal protection: Paths containing .. or starting with / are rejected with a 400 error.

200 response:
Binary audio/mpeg stream from MinIO.

In production, audio is served directly from the MinIO CDN (AUDIO_CDN_URL) without going through this proxy. See 05-audio-minio.md.


Statistics

POST /api/statistics/track

Records a user event. Creates or increments a daily entry in the statistics table (upsert by date).

curl -X POST http://localhost:3000/api/statistics/track \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "play_audio",
    "languageId": "language-uuid",
    "cardId": "card-uuid",
    "itemId": "item-uuid"
  }'

Body:

{
  "eventType": "play_audio",
  "languageId": "550e8400-e29b-41d4-a716-446655440000",
  "cardId": "card-uuid",
  "itemId": "item-uuid"
}

Event types:

eventType Trigger cardId itemId
unlock_language Language unlocked null null
view_card Card displayed required null
play_audio Audio played required required

200 response:

{
  "success": true,
  "count": 42
}

count = cumulative number of occurrences for that day.

Offline behavior: Events are queued in IndexedDB (statistics-queue) when the network is unavailable, then automatically synchronized upon reconnection.


GET /api/statistics/by-language

Admin auth required.

Retrieves aggregated statistics by language for the dashboard.

curl "http://localhost:3000/api/statistics/by-language?from=2026-01-01&to=2026-01-31" \
  -b cookies.txt

Query params: | Param | Type | Description | |---|---|---| | from | string | Format YYYY-MM-DD (optional) | | to | string | Format YYYY-MM-DD (optional) |

200 response:

[
  {
    "language_id": "uuid",
    "language_name": "Pular",
    "language_code": "pul",
    "unlock_count": 245,
    "view_count": 1830,
    "audio_count": 956
  }
]

GET /api/statistics/top-cards

Admin auth required.

Retrieves the most viewed cards.

curl "http://localhost:3000/api/statistics/top-cards?languageId=uuid&limit=10" \
  -b cookies.txt

Query params: | Param | Type | Description | |---|---|---| | languageId | string | Optional — filter by language | | limit | integer | Number of results (default: 10) | | from | string | Format YYYY-MM-DD (optional) | | to | string | Format YYYY-MM-DD (optional) |

200 response:

[
  {
    "card_id": "uuid",
    "card_number": 5,
    "type": "word",
    "theme": "Family",
    "language_name": "Pular",
    "view_count": 312,
    "audio_count": 187
  }
]

Quiz (MCQ Revision)

POST /api/quiz/start

Starts a quiz session. Creates an entry in quiz_sessions with completed_at = null.

curl -X POST http://localhost:3000/api/quiz/start \
  -H "Content-Type: application/json" \
  -d '{
    "languageId": "language-uuid",
    "categorySlug": "family",
    "categoryName": "Family",
    "totalQuestions": 15
  }'

Body:

{
  "languageId": "550e8400-e29b-41d4-a716-446655440000",
  "categorySlug": "family",
  "categoryName": "Family",
  "totalQuestions": 15
}

totalQuestions: 15, 18, or 20 depending on the number of items available in the category (calculated by getQuestionCount()).

201 response:

{
  "sessionId": "session-uuid"
}

POST /api/quiz/complete

Records the final score of a quiz session. Updates quiz_sessions.score and completed_at.

curl -X POST http://localhost:3000/api/quiz/complete \
  -H "Content-Type: application/json" \
  -d '{"sessionId": "session-uuid", "score": 12}'

Body:

{
  "sessionId": "session-uuid",
  "score": 12
}

score = number of correct answers (between 0 and totalQuestions).

200 response:

{ "success": true }

Incomplete sessions (completed_at = null) are considered abandoned and are not counted in statistics.


Contact

POST /api/contact

Sends a contact message. Persists the message in the database, sends an email to admins via SMTP, and sends a confirmation to the sender.

curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Smith",
    "email": "john@example.com",
    "subject": "Question about the app",
    "message": "Hello, I have a question..."
  }'

Body:

{
  "name": "John Smith",
  "email": "john@example.com",
  "subject": "Question about the app",
  "message": "Hello, I have a question...",
  "_t": 5000
}

Fields: | Field | Type | Required | Description | |---|---|---|---| | name | string | ✅ | Sender's name | | email | string | ✅ | Sender's email | | subject | string | ✅ | Message subject | | message | string | ✅ | Message body | | website | string | ❌ | Honeypot — must remain empty | | _t | number | ❌ | Typing start timestamp (anti-spam timing) |

Anti-spam protection (4 layers):

  1. Honeypot (website): If filled, silent 200 response (fake success)
  2. Minimum timing (_t): Silent rejection if submitted in < 4 seconds
  3. Rate limiting: Maximum 3 messages per hour per IP (via X-Forwarded-For)
  4. Disposable domains: Rejection of emails from temporary domains

200 response:

{ "success": true, "message": "Emails sent successfully" }

Errors: | Case | Code | Message | |---|---|---| | Missing fields | 400 | "All required fields must be filled" | | Disposable email domain | 400 | "Please use a permanent email address." | | Rate limit reached | 429 | "Too many messages sent. Please try again in an hour." | | SMTP error | 500 | "Error sending emails" |


Contact Messages (Admin)

GET /api/admin/contact

Admin auth required.

Retrieves contact messages with pagination and filters.

curl "http://localhost:3000/api/admin/contact?page=1&status=unread&search=smith" \
  -b cookies.txt

Query params: | Param | Type | Description | |---|---|---| | page | integer | Page number (default: 1, 20 messages/page) | | status | string | Filter: unread, read, replied, archived | | search | string | Search in name, email, subject |

200 response:

{
  "success": true,
  "messages": [
    {
      "id": "uuid",
      "name": "John Smith",
      "email": "john@example.com",
      "subject": "Question about the app",
      "message": "Hello...",
      "status": "unread",
      "admin_notes": null,
      "created_at": "2026-01-15T10:00:00Z"
    }
  ],
  "total": 47,
  "page": 1,
  "totalPages": 3
}

PATCH /api/admin/contact/[id]

Admin auth required.

Updates the status or notes of a message.

curl -X PATCH http://localhost:3000/api/admin/contact/message-uuid \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"status": "replied", "admin_notes": "Replied by email on 01/15"}'

Body:

{
  "status": "replied",
  "admin_notes": "Replied by email on 01/15"
}

status values: unread, read, replied, archived

200 response:

{ "success": true }

DELETE /api/admin/contact/[id]

Admin auth required.

Permanently deletes a contact message.

curl -X DELETE http://localhost:3000/api/admin/contact/message-uuid \
  -b cookies.txt

200 response:

{ "success": true }

Admin Statistics

GET /api/admin/statistics

Admin auth required.

Counts the number of rows in the statistics table according to filters.

curl "http://localhost:3000/api/admin/statistics?languageId=uuid&from=2026-01-01&to=2026-01-31" \
  -b cookies.txt

Query params: | Param | Type | Description | |---|---|---| | languageId | string | Optional — filter by language | | from | string | Format YYYY-MM-DD | | to | string | Format YYYY-MM-DD |

200 response:

{ "count": 12450 }

DELETE /api/admin/statistics

Admin auth required.

Deletes statistics rows according to filters. Irreversible action.

# Delete stats for a language over a period
curl -X DELETE \
  "http://localhost:3000/api/admin/statistics?languageId=uuid&from=2025-01-01&to=2025-12-31" \
  -b cookies.txt

# Full purge (required header)
curl -X DELETE http://localhost:3000/api/admin/statistics \
  -H "x-delete-all-confirmed: true" \
  -b cookies.txt

Query params: same as GET /api/admin/statistics

Full purge protection: Without any filter, the header x-delete-all-confirmed: true is required to prevent accidental deletions.

200 response:

{ "success": true, "deleted": 12450 }

GET /api/admin/quiz/stats

Admin auth required.

Retrieves quiz session statistics (completed and abandoned).

curl "http://localhost:3000/api/admin/quiz/stats?languageId=uuid&from=2026-01-01" \
  -b cookies.txt

200 response:

{
  "total": 320,
  "completed": 215,
  "abandoned": 105,
  "averageScore": 11.3,
  "completionRate": 67.2,
  "byCategory": [
    {
      "categoryName": "Family",
      "sessions": 45,
      "avgScore": 12.1
    }
  ]
}

Client errors

POST /api/client-errors

Receives client-side JavaScript errors for server-side logging. Used by AudioPlayer to report audio playback errors.

curl -X POST http://localhost:3000/api/client-errors \
  -H "Content-Type: application/json" \
  -d '{"request":"HEAD https://cdn/audio.mp3","response":"403 Forbidden"}'

Body:

{
  "request": "HEAD https://[CDN]/audios/pul/item-uuid/audio.mp3",
  "response": "403 Forbidden"
}

200 response:

{ "ok": true }

Errors are logged server-side without personally identifiable data.


Summary table

Endpoint Method Auth Description
/api/health/live GET None Liveness probe
/api/auth/login POST Admin login
/api/auth/login GET Admin Check session
/api/auth/logout POST Admin Logout
/api/speaker/login POST Speaker login
/api/languages GET None List active languages
/api/languages POST Admin Create a language
/api/languages/[id] PATCH Admin Update a language
/api/languages/[id] DELETE Admin Delete (cascade)
/api/languages/verify-code POST None Verify player code
/api/languages/[id]/content GET None Full content (offline)
/api/cards POST Admin Create a card
/api/cards/import POST Admin Excel import
/api/items POST Admin Create one or more items
/api/items/[id] PATCH Admin Update an item
/api/speakers POST Admin Create a speaker
/api/audio/upload POST Speaker Upload audio
/api/audio/serve/[[...path]] GET None MinIO proxy (dev)
/api/statistics/track POST None Record an event
/api/statistics/by-language GET Admin Stats by language
/api/statistics/top-cards GET Admin Top cards
/api/quiz/start POST None Start a quiz
/api/quiz/complete POST None Complete a quiz
/api/contact POST None Send a message
/api/admin/contact GET Admin List contact messages
/api/admin/contact/[id] PATCH Admin Update message
/api/admin/contact/[id] DELETE Admin Delete message
/api/admin/statistics GET Admin Count stats
/api/admin/statistics DELETE Admin Delete stats
/api/admin/quiz/stats GET Admin Quiz stats
/api/client-errors POST None Client JS errors

Next steps