# Medicout Backend — Project Context

> Generated onboarding doc for future Claude Code sessions. Read this instead of re-reading all source files.
> Scope: `medicout-backend/` only (frontend is a separate folder, not covered here).

---

## 1. Project Overview

**Medicout** is an international telemedicine / doctor-appointment marketplace ("telemedicine marketplace" per README). Patients search for doctors by specialty/symptom/location, book appointments (in-person, video, or chat consultation), chat with doctors in real time, leave reviews, and receive notifications (email + push). Doctors manage their profile, availability schedules, online/offline presence, and confirm/reject/reschedule appointments.

### Tech stack (confirmed from `package.json` / config files)
- **Runtime**: Node.js, Express 4
- **DB**: MySQL (via `mysql2/promise`, connection pool) — no ORM, raw SQL everywhere
- **Cache / ephemeral state**: Redis (via `ioredis`) — search cache, doctor online presence, refresh-token store, JWT blacklist, bookingv2 slot locks (with in-memory fallback if Redis is down)
- **Real-time**: Socket.IO 4 (chat messages, presence, appointment/status broadcasts)
- **Auth**: JWT (access + refresh), bcryptjs for password hashing, Google OAuth (ID token or access token) via `google-auth-library` / `googleapis`
- **File storage**: Cloudinary (profile photos, chat file/image uploads) — files land on local tmp disk via Multer first, then uploaded to Cloudinary
- **Push notifications**: Firebase Admin SDK (FCM) — also writes chat messages into Firebase Realtime Database as a secondary real-time channel
- **Google Calendar**: OAuth2 integration to create/update/delete calendar events for confirmed appointments (doctor-side)
- **Email**: Nodemailer (SMTP, e.g. Gmail) with inline-HTML branded templates
- **Security middleware**: Helmet, CORS (dynamic origin allow-list), express-rate-limit (in-memory store), express-validator
- **Testing**: Jest configured (`npm test`) — no test files found in the read scope
- **Deployment target**: cPanel/Passenger (see `app.js` root delegating to `src/app.js`, and `/medicout-backend` URL-prefix stripping middleware, and `deploy/nginx-websocket.conf.example`)

### Entry points
- `app.js` (project root) — cPanel/Passenger entry point, just does `require('./src/app.js')`.
- `src/app.js` — actual Express app: sets up CORS, Helmet, morgan logging, JSON/urlencoded body parsing, mounts all route modules under `/api/*`, initializes Socket.IO, registers a dev-only `DELETE /api/test/reset` endpoint, global error handler, and graceful shutdown (closes MySQL pool + Redis on SIGTERM/SIGINT).

---

## 2. Architecture

### Folder structure
```
medicout-backend/
├── app.js                     # cPanel entry → requires src/app.js
├── migrate.js                 # Runs migrations/*.sql in order, tracks in `migrations` table
├── migrations/                # Incremental SQL patches (NOT a full schema dump — base schema lives in a `medicout.sql` file referenced by README but not present in repo)
├── generate_postman.js        # Script that augments medicout.postman_collection.json → medicout_v2.postman_collection.json (adds tests, missing requests)
├── medicout_v2.postman_collection.json  # Generated Postman collection (API reference)
├── deploy/
│   └── nginx-websocket.conf.example     # Sample nginx reverse-proxy config for Socket.IO + API behind cPanel
└── src/
    ├── app.js                 # Real Express app (see above)
    ├── config/                # Service connectors: database, redis, socket, cloudinary, firebase, googleCalendar
    ├── middleware/             # auth, optionalAuth, role, rateLimiter, upload, errorHandler
    ├── modules/                # One folder per domain, each with controller.js + routes.js (+ service.js, + .socket.js where relevant)
    │   ├── appointments/       # v1 booking system (still active/mounted)
    │   ├── auth/
    │   ├── availability/
    │   ├── bookingv2/          # v2 booking engine — Redis-backed slot locking, mounted at /api/v2
    │   ├── chat/
    │   ├── doctors/
    │   ├── notifications/
    │   ├── presence/           # socket-only, no REST routes
    │   ├── profile/
    │   ├── reviews/
    │   └── search/
    ├── seeders/testUsers.js    # `npm run seed` — inserts patient@test.com / doctor@test.com (password test123)
    └── utils/                  # encryption (AES-256-CBC), fcm, googleCalendar, jwt, mailer (templates), notification, profileCompletion
```

### Module pattern
Each module under `src/modules/<name>/` typically has:
- `<name>.routes.js` — Express router, wires middleware (auth/role/upload) + controller methods
- `<name>.controller.js` — thin HTTP layer: parses req, calls service, shapes `{ success, data }` response, emits Socket.IO events post-write, calls `next(error)` on failure
- `<name>.service.js` — business logic + raw SQL queries against `pool`
- Chat and presence additionally have `<name>.socket.js` for Socket.IO event handlers (registered from `src/config/socket.js`)

All routes are mounted in `src/app.js`:
```
/api/auth            → auth.routes
/api/profile         → profile.routes
/api/search          → search.routes
/api/appointments    → appointments.routes  (v1 booking)
/api/chat            → chat.routes
/api/notifications   → notifications.routes
/api/availability    → availability.routes
/api/reviews         → reviews.routes
/api/doctors         → doctors.routes
/api/v2              → bookingv2.routes    (v2 booking: /api/v2/doctors/:id/availability, /api/v2/appointments/lock|confirm|release)
```
No route module exists for Google Calendar connect/callback despite README mentioning `/api/calendar/connect` and `/api/calendar/callback` — **the calendar OAuth HTTP endpoints are NOT implemented**; only the underlying `src/utils/googleCalendar.js` helper functions exist and are invoked internally by the appointments service. This is a README/implementation mismatch (see Section 10).

### Middleware stack
- `helmet()` — security headers, applied globally
- CORS — custom origin-callback function: allows no-origin requests (mobile/Postman), allows any `localhost`/`127.0.0.1` origin in development, otherwise checks `ALLOWED_ORIGINS` env (comma list, supports `*` wildcard)
- `morgan('dev')` — request logging
- `express.json()` / `express.urlencoded({extended:true})`
- Custom middleware strips a leading `/medicout-backend` prefix from `req.url` (Passenger workaround)
- Auth middleware (`src/middleware/auth.js`) — verifies `Authorization: Bearer <JWT>`, checks Redis blacklist by `jti`, sets `req.user = decoded`
- `optionalAuth.js` — same but never fails; silently proceeds without `req.user` if token missing/invalid
- `role.js` — exports `requirePatient`, `requireDoctor`, `requireAdmin` (403 if `req.user.role` mismatches)
- `rateLimiter.js` — `generalLimiter` (100 req/15min default, skips `/health`) and `loginLimiter` (50/15min default — env var name says `RATE_LIMIT_LOGIN_MAX` default 5 in `.env.example` but code default is 50)
- `upload.js` — Multer disk storage to OS tmp dir, max size from `MAX_FILE_SIZE_MB` (default 20MB)
- `errorHandler.js` — global handler: maps `ER_DUP_ENTRY`→409, JWT errors→401, `err.type==='validation'`→400, else uses `err.statusCode`/`err.status` or 500; hides message detail in production

---

## 3. Database Schema (inferred from migrations + all SQL queries in the codebase)

**Important**: No full schema SQL file exists in this repo. The `migrations/` folder only contains 4 incremental ALTER-statement patches (001–004) applied on top of a base schema (`medicout.sql`, referenced by README, not present). The table/column list below is reconstructed from every `SELECT`/`INSERT`/`UPDATE` query found across the service files — treat column types/nullability as best-effort inference, not authoritative DDL.

### `users`
Core account table for both patients and doctors (role-based).
- `id` (PK)
- `email` (unique — `ER_DUP_ENTRY` mapped to "email already registered")
- `password_hash` (bcrypt; `'OAUTH_USER'` sentinel value for Google-only accounts)
- `full_name`
- `role` (enum-like string: `'patient' | 'doctor' | 'admin'`)
- `phone`
- `date_of_birth`
- `gender`
- `country_code`
- `city`
- `profile_photo_url`
- `is_verified` (0/1, email verification flag)
- `is_active` (0/1, login blocked if false)
- `login_attempts` (int, incremented on failed login)
- `locked_until` (timestamp, set after 5 failed attempts, 30-min lock)
- `last_login_at`
- `google_calendar_token` (AES-256-CBC encrypted JSON blob of OAuth tokens; some deployments may not have this column — code catches the query error and treats as "not connected")
- `created_at` / `updated_at` (implied)

### `doctor_profiles`
One row per doctor (1:1 with `users` where `role='doctor'`).
- `id` (PK), `user_id` (FK → users)
- `license_number`, `bio`, `years_experience`, `consultation_fee`
- `clinic_name`, `clinic_city`, `clinic_country`, `clinic_address`, `clinic_latitude`, `clinic_longitude`
- `is_verified` (doctor credential verification, separate from user email verification)
- `is_online` (0/1), `last_seen_at`
- `avg_rating`, `total_reviews` (denormalized, recomputed on each new review)

### `doctor_specialties` (join table)
- `doctor_profile_id` (FK), `specialty_id` (FK) — fully replaced (`DELETE` then bulk `INSERT`) on profile update

### `specialties`
- `id`, `name`, `slug`, `is_active`

### `symptom_specialty_map`
Used for symptom → specialty search mapping.
- `symptom_keyword`, `specialty_id`, `weight`

### `doctor_credentials`
- `doctor_profile_id`, `type`, `title`, `institution`, `issued_year`

### `doctor_insurance_accepted` (join table)
- `doctor_profile_id`, `insurance_id`

### `insurance_providers`
- `id`, `name`, `is_active`

### `search_logs`
- `user_id`, `query_text`, `searched_at` — logged when an authenticated user runs a doctor search with a query string >1 char

### `availability_schedules`
Weekly recurring availability per doctor.
- `id`, `doctor_profile_id`, `day_of_week` (0=Sunday, JS `getUTCDay()` convention), `start_time`, `end_time`, `slot_duration_mins` (default 30), `is_active`
- Unique-ish constraint enforced in app code: one schedule row per `(doctor_profile_id, day_of_week)`

### `availability_exceptions`
Date-specific overrides (blackout days or special hours).
- `id`, `doctor_profile_id`, `exception_date`, `is_available`, `start_time`, `end_time`, `reason`
- Upserted by app logic on `(doctor_profile_id, exception_date)`

### `doctor_availability_log`
Audit trail written by presence socket handlers.
- `doctor_profile_id`, `event_type` (`'went_online' | 'went_offline'`)

### `appointments`
Central booking table, used by BOTH v1 (`appointments` module) and v2 (`bookingv2` module) booking flows — they share the same table.
- `id`, `patient_id` (FK users), `doctor_profile_id` (FK doctor_profiles)
- `scheduled_at_utc` (datetime), `duration_mins`, `type` (`in_person | video | chat`), `patient_notes`
- `status` (`pending | confirmed | rescheduled | cancelled | rejected | completed`)
- `booking_key` (VARCHAR(191), added in migration 003) — unique key `"{doctor_profile_id}#{scheduled_at_utc ISO}"`, enforced via `uq_appointments_booking_key` UNIQUE constraint so only one *active* (non-cancelled/rejected) booking can exist per doctor+slot; set to `NULL` on cancellation to free the slot
- `cancellation_reason`, `cancelled_by`, `cancelled_at`
- `reschedule_reason`, `rescheduled_from` (self-referential, stores previous appointment id)
- `google_calendar_event_id`
- Indexes (migration 004): `idx_appt_doctor_slot_status (doctor_profile_id, scheduled_at_utc, status)` for fast slot-range scans

### `reviews`
- `id`, `appointment_id` (FK, one review per appointment per patient), `patient_id`, `doctor_profile_id`
- `rating` (1–5), `comment`, `is_anonymous`, `is_approved` (auto-approved =1 on create), `created_at`

### `chat_threads`
- `id`, `uuid`, `patient_id`, `doctor_profile_id`, `appointment_id` (nullable), `type` (`'query'` default, others possible), `status`, `last_message_at`, `created_at`
- Uniqueness by app logic: `(patient_id, doctor_profile_id, type[, appointment_id])`

### `chat_messages`
- `id`, `thread_id` (FK), `sender_id`, `recipient_id` (added in migration 001, nullable, backfilled by app logic at insert time)
- `message_type` (`'text'` or file-type), `content_encrypted` (AES-256-CBC ciphertext of message body, or of sentinel markers `__deleted__` / `__edited__:<text>` — see quirks), `content` (used transiently — NOT actually a persisted plaintext column; controller manually sets it in-memory after insert for the immediate response)
- `file_url`, `file_name`, `file_mime_type`, `file_size_bytes`
- `is_read` (0/1), `read_at`
- `created_at`
- Index (migration 001): `idx_recipient_unread (recipient_id, is_read)`

### `notifications`
- `id`, `recipient_id`, `sender_id`, `type` (ENUM, expanded in migration 002: `appointment_booked, appointment_confirmed, appointment_rescheduled, appointment_cancelled, appointment_rejected, status_changed, new_message, doctor_online, query_received, review_request, new_review, system`), `title`, `body`, `data_payload` (JSON string), `is_read`, `read_at`, `created_at`

### `push_notification_tokens`
- `user_id`, `platform`, `fcm_token`, `is_active`, `updated_at` — upserted with `ON DUPLICATE KEY UPDATE`, implies a unique key on `(user_id, fcm_token)` or similar

### `verification_codes`
Email OTP verification.
- `id`, `user_id`, `type` (`'email_verify'`), `code_hash` (SHA-256 of OTP), `expires_at`, `used_at`
- Old codes for the same user+type are deleted before inserting a new one (only one active code at a time)

### `migrations`
Bookkeeping table auto-created by `migrate.js`.
- `id`, `name` (unique), `applied_at`

---

## 4. Modules In Detail

### 4.1 `auth` — Registration, login, Google OAuth, email verification, tokens
Routes (`src/modules/auth/auth.routes.js`), all public except `/logout`:
- `POST /api/auth/register` — public. Validates email/password(min 6)/fullName. Creates user (unverified), sends OTP email, issues JWT pair immediately (registration is NOT blocked on verification — tokens returned even though `requiresVerification: true`). Partial-failure tolerant: if email or token generation fails post-insert, still returns 201 with `partialSuccess: true`.
- `POST /api/auth/login` — public, rate-limited (`loginLimiter`). Validates email/password present. 401 on bad creds or missing user; 403 if `is_active=0` or currently locked. 5 failed attempts → 30-min lock. On success resets `login_attempts`, updates `last_login_at`, stores refresh token hash in Redis (`refresh:<sha256>` → userId, TTL from `REDIS_TTL_REFRESH`).
- `POST /api/auth/google` — public. Accepts `googleIdToken` OR `googleAccessToken` (auto-detects JWT format by `.` count == 3). Verifies via `google-auth-library` (ID token) or fetches profile via `googleapis` `oauth2.userinfo.get()` (access token). Creates user if new (role from `role` body param, defaults patient), or promotes existing user to doctor role if `role==='doctor'` requested and not already; auto-creates empty `doctor_profiles` row for doctor role. Returns `isNewUser` flag.
- `POST /api/auth/verify-email` — public. Body `{email, otp}`. Hashes provided OTP, compares to stored `code_hash`, checks `used_at`/expiry. Marks user verified, marks code used, sends role-specific welcome email.
- `POST /api/auth/resend-verification` — public. Regenerates OTP (deletes old one first).
- `POST /api/auth/refresh` — public. Verifies refresh JWT signature AND cross-checks Redis-stored token hash (defense against tampering/reuse after logout). Issues new short-lived access token only (refresh token unchanged).
- `POST /api/auth/logout` — requires auth. Deletes Redis refresh-token entry; blacklists current access token's `jti` for 24h (`blacklist:<jti>`).

Quirk: `login.controller` has a stray `console.log(error)` AFTER calling `next(error)` — harmless but dead-ish code / ordering smell.

### 4.2 `profile` — Get/update own profile, stats, photo upload
All routes require auth (`router.use(authMiddleware)`).
- `GET /api/profile` — returns `{user, doctorProfile, specialties, completion}`; joins `doctor_profiles` + `doctor_specialties`/`specialties` when role=doctor. Also computes profile completion score.
- `GET /api/profile/stats` — patient: total appointments, distinct doctors consulted, messages sent. doctor: distinct patients, total appointments, average rating (0 if no `doctor_profiles` row exists yet).
- `PUT /api/profile` — normalizes both snake_case and camelCase input field names (e.g. `full_name`/`fullName`), validates lat/lng ranges, validates specialties as array of positive ints. Runs inside a DB transaction; auto-creates `doctor_profiles` row on first doctor update. Replaces `doctor_specialties` wholesale if `specialties` array provided. Invalidates all `search:doctors:*` Redis cache keys after a doctor profile update (cache invalidation failure is logged but non-fatal).
- `POST /api/profile/photo` — Multer single-file upload (`photo` field) → Cloudinary (400x400 fill crop, `public_id: user_<id>`) → updates `users.profile_photo_url`.
- `GET /api/profile/completion` — just re-runs `getProfile` and returns the `completion` sub-object.

`calculateCompletion` (in `utils/profileCompletion.js`) is a weighted-field checklist, different weight schemes for patient vs doctor; doctor also gets +15 if they have ≥1 specialty.

### 4.3 `search` — Doctor discovery
- `GET /api/search/symptoms?q=` — public. LIKE-matches `symptom_specialty_map.symptom_keyword`, returns highest-weight specialty match (single result only).
- `GET /api/search/doctors` — `optionalAuth` (logs search query only if authenticated). Filters: `specialtyId` OR free-text `searchQuery` (auto-mapped to specialty via symptom search if no explicit specialtyId), `countryCode`, `insuranceId`, `lat/lng/radiusKm` (haversine via `ST_Distance_Sphere`), `isOnline`, pagination. Redis-cached per unique filter combo (`REDIS_TTL_SEARCH_CACHE`, default 300s). After cache read, live-overrides `is_online` from Redis `doctor:online:<id>` keys (so cached stale DB `is_online` doesn't lag). Logs query to `search_logs` if `userId` present and query length > 1.
- `GET /api/search/doctors/:id/profile` — public. Full doctor profile: base info, specialties, credentials, up to 20 approved reviews, weekly schedules, accepted insurances, live online status.
- `GET /api/search/specialties`, `/countries` (hardcoded ~50-country dial-code list, not DB-backed), `/insurances`, `/keywords` (distinct symptom keywords, limit clamped 5–50) — all public.
- `GET /api/search/recent` — requires auth. Distinct recent search queries for the current user (limit clamped 1–20).

### 4.4 `doctors` — Presence toggle (small module, only 1 route)
All routes require auth.
- `PATCH /api/doctors/status` — body `{isOnline}`. Updates `doctor_profiles.is_online` + `last_seen_at`, broadcasts `status_changed` (+ `doctor_online`/`doctor_offline`) to ALL connected sockets via `io.emit` (not room-scoped). Note: this is a REST-driven presence update, separate/parallel to the Socket.IO-driven presence flow in the `presence` module — see quirks (Section 10) about two presence-update pathways with slightly different Redis semantics (this one does NOT touch Redis `doctor:online:*` keys at all, only the `presence.socket.js` handlers do).

### 4.5 `availability` — Doctor weekly schedule + date exceptions
All routes require auth + `requireDoctor`.
- `GET/POST/PUT/DELETE /api/availability/schedules[/:id]` — weekly recurring rules. One row per day-of-week enforced at creation. Updating/deleting a schedule that affects times/duration/active-flag is BLOCKED (409) if there are future non-cancelled appointments on that weekday (`hasActiveAppointmentsForWeekday`).
- `GET/POST/DELETE /api/availability/exceptions[/:id]` — date-specific overrides; `createException` is an upsert keyed on `(doctor_profile_id, exception_date)`. Blocking a date (`isAvailable:false`) is rejected (409) if that date already has active appointments.

### 4.6 `appointments` — v1 booking system (still mounted and active)
All routes require auth.
- `GET /api/appointments/doctors/:id/slots?date=` — computes slot grid for ONE day from that doctor's weekly schedule + exceptions, then marks each slot `available|pending|booked` by cross-referencing `appointments` in that UTC day range. No Redis locking — pure DB read, race-prone between "slot list" and actual booking (this is the gap bookingv2 was built to close).
- `POST /api/appointments` — patient only. Transactional: `SELECT ... FOR UPDATE` row-lock check for existing active booking at that doctor+time, then INSERT with `booking_key`. Relies on the unique constraint as a backstop (`ER_DUP_ENTRY` → 409). Fires notification to doctor inside the transaction; email + Google Calendar event creation happen AFTER commit as fire-and-forget (`Promise.resolve().then(...)`, errors swallowed). Returns the patient's refreshed appointment list (not just the created record) — unconventional API shape.
- `GET /api/appointments` — paginated list scoped to caller's role (patient sees own bookings, doctor sees bookings against their profile), optional `status` filter.
- `GET /api/appointments/:id` — single record, 403 if caller isn't the patient or the doctor on it.
- `PUT /api/appointments/:id/confirm` — doctor only (`requireDoctor`), must own the appointment. Sets `confirmed`. **Auto-rejects all other pending/rescheduled appointments at the same doctor+timeslot** (multiple patients can request the same open slot in v1 since there's no locking — first confirm wins, rest get rejected with email/notification).
- `PUT /api/appointments/:id/reschedule` — either party (patient or doctor on the appointment). Updates time, sets status `rescheduled`, stores `rescheduled_from = id` (self-reference, though same row — looks like it should reference the OLD id but here it's set to its own id post-update, a likely bug — see quirks). Updates Google Calendar event if one exists AND the doctor is the one rescheduling.
- `PUT /api/appointments/:id/cancel` — either party. Sets `cancelled`, clears `booking_key` (frees the slot for others), deletes Google Calendar event if present.
- Controller layer emits `appointment_updated` Socket.IO events to both `user_<patientId>` and `user_<doctorUserId>` rooms after confirm/reschedule/cancel.

### 4.7 `bookingv2` — v2 booking engine (Redis-locked, race-free)
Mounted at `/api/v2`. This is the newer, more robust booking flow — see Section 10 for the "why two booking systems" quirk.
- `GET /api/v2/doctors/:doctorId/availability?start_date&end_date&timezone` — `optionalAuth` (used for `your_lock` detection). Date range capped to 90 days. Builds a full multi-day slot grid in **3 bulk DB queries** (schedules, exceptions, appointments) + a Redis `SCAN` for all active locks for that doctor, entirely in-memory — much more efficient than the v1 per-day approach. Slot statuses: `available | locked | pending | booked | your_lock`.
- `POST /api/v2/appointments/lock` — patient only. Checks DB for existing hard booking first, then acquires a Redis lock via a Lua script (`SETEX` on both `slot:lock:{doctorProfileId}:{iso}` and `slot:bk:{bookingKey}` atomically; allows idempotent re-lock by the same patient). 10-minute TTL (`LOCK_TTL_SECS=600`). Falls back to an **in-memory Map-based lock** (`memoryLockBySlot`/`memoryLockByBooking`) if Redis is unreachable — explicitly documented as single-node-only, won't work correctly behind multiple app instances/load balancer.
- `POST /api/v2/appointments/confirm` — patient only. Validates the caller owns the lock (Redis or memory), re-checks DB inside a transaction with `FOR UPDATE` (defense in depth against races the lock alone can't fully prevent), inserts the `appointments` row (status implicitly `pending` — the INSERT doesn't set status explicitly, so it takes the DB default), releases the Redis lock, fires notification + emails post-commit. Emits `appointment_new` to the doctor's socket room.
- `POST /api/v2/appointments/release` — either role (`req.user?.id`, no role check) — explicit lock release if the patient closes the booking UI early. Ownership-checked.

### 4.8 `chat` — Threaded messaging with encryption + attachments
All routes require auth.
- `POST /api/chat/threads` — get-or-create a thread for `(patientId, doctorProfileId, type)` [+ optional `appointmentId`].
- `GET /api/chat/threads?limit=` — role-aware (doctor sees patient threads, patient sees doctor threads), includes unread count per thread and a decrypted last-message preview.
- `GET /api/chat/threads/:id/messages?page&limit` — 403 if caller isn't a participant. Decrypts each message, marks all messages from the OTHER party as read as a side effect of fetching.
- `POST /api/chat/threads/:id/read` — explicit bulk mark-as-read.
- `POST /api/chat/messages` — encrypts text content (AES-256-CBC) before insert, resolves `recipient_id` server-side, creates a `new_message` notification, then emits Socket.IO events (`message_received`/`new_message`/`notification_created`) to both the thread room and the recipient's user room.
- `PUT /api/chat/messages/:id` — sender-only, text-only messages, re-encrypts with an `__edited__:` prefix marker (see quirks — encryption used as an ad-hoc state-tagging mechanism, not just for confidentiality).
- `DELETE /api/chat/messages/:id` — sender-only soft-delete: overwrites content with an encrypted `__deleted__` sentinel and nulls out file fields; `message_type` forced back to `'text'` even for file messages.
- `POST /api/chat/upload` — Multer + Cloudinary (auto-detects `medicout/chat-images` vs `medicout/chat-documents` by mimetype), returns the URL for the client to then call `POST /messages` with.
- `GET /api/chat/unread-count` — total unread across all threads for the badge.

`chat.socket.js` also independently implements `send_message` over the socket (parallel path to the REST `POST /messages` — see quirks) plus `join_thread`, `typing_start/stop` → `typing_indicator`, `mark_read` (client-only broadcast, does NOT touch the DB — differs from the REST `/threads/:id/read` which does).

Chat message content is ALSO mirrored into **Firebase Realtime Database** (`messages/{threadId}/{messageId}`) as a secondary real-time transport, in addition to Socket.IO and the MySQL row — three parallel delivery mechanisms for the same message.

### 4.9 `notifications` — In-app notification feed + FCM token registry
All routes require auth.
- `GET /api/notifications?page&limit` — paginated feed for the current user.
- `PUT /api/notifications/:id/read`, `PUT /api/notifications/read-all` — mark read.
- `POST /api/notifications/register-token` — upserts `{platform, fcmToken}` into `push_notification_tokens` (`ON DUPLICATE KEY UPDATE is_active=1`).
- `DELETE /api/notifications/token` — removes a specific token row.
- `notifications.service.createNotification(recipientId, senderId, type, title, body, dataPayload)` is the shared entry point called from appointments/chat/reviews services — inserts the DB row AND best-effort sends FCM push to all of the recipient's active tokens (Android high-priority channel config included). Silent no-op if Firebase Admin isn't initialized (`admin.apps.length === 0`, i.e. `FIREBASE_PROJECT_ID` not set).

### 4.10 `presence` — Socket-only doctor online/offline (no REST routes; registered directly in `config/socket.js`)
- `doctor_go_online` / `doctor_go_offline` — resolves the doctor profile either from the authenticated socket's `userId` or an explicit `doctorProfileId` payload (supports non-authenticated-socket callers, oddly — see quirks). Sets Redis `doctor:online:<id>` (TTL `REDIS_TTL_DOCTOR_ONLINE`, default 300s) + `doctor:socket:<id>` (maps doctor→current socket id, used for disconnect cleanup), updates `doctor_profiles.is_online`/`last_seen_at`, logs to `doctor_availability_log`, broadcasts `status_changed` globally (`io.emit`, not room-scoped). Going offline also busts the doctor search cache.
- `presence_heartbeat` — refreshes the Redis TTL only if the key still exists (doesn't resurrect an expired session).
- `disconnect` — scans all `doctor:socket:*` keys to find the one matching this socket id (O(n) over all currently-online doctors, `KEYS`-based scan) and force-marks that doctor offline. This is the auto-offline-on-disconnect safety net.

### 4.11 `profile` — see 4.2 above.

### 4.12 `reviews` — Post-appointment ratings
- `GET /api/reviews/doctor/:doctorProfileId` — public, approved reviews only.
- `POST /api/reviews` — auth required. Validates rating 1–5, verifies the appointment belongs to the calling patient AND the target doctor, requires appointment status `completed` or `confirmed`, prevents duplicate review per appointment (unique per `appointment_id + patient_id`). Recomputes `doctor_profiles.avg_rating`/`total_reviews` synchronously via a correlated-subquery UPDATE. Busts `search:doctors:*` cache. Fire-and-forget notification + email to the doctor.

Bug note: `redisClient.keys(...)` result (an array) is passed directly to `redisClient.del(keys)` instead of being spread (`...keys`) — inconsistent with `profile.service.js` and `presence.socket.js` which correctly spread. `ioredis.del(arrayArg)` may not delete keys as intended (see quirks).

---

## 5. Config / Integrations (`src/config/`)

- **`database.js`** — `mysql2/promise` pool (`waitForConnections:true`, `connectionLimit` from `DB_POOL_MAX`, default 10). Fires a startup `SELECT 1` ping, logs success/failure but does not crash the process on failure.
- **`redis.js`** — `ioredis` client. Supports either `REDIS_URL` (cloud/Upstash, with TLS auto-detected from `rediss://` scheme) or discrete `REDIS_HOST/PORT/PASSWORD`. `lazyConnect:true`, custom `retryStrategy` that gives up after 1 retry (no infinite reconnect spam). Exposes wrapped `setEx/get/del/exists` helpers that **silently no-op and return null/0 if Redis is currently marked unavailable** — this is why so much of the codebase treats Redis as optional/best-effort. The raw `redisClient` object is also exported directly for direct ioredis calls (`.keys`, `.scan`, `.eval`, `.ttl`, `.mget`, `.ping`) used throughout bookingv2/presence/search.
- **`socket.js`** — Socket.IO server setup. CORS origin list from `ALLOWED_ORIGINS` (+ dev localhost ports, + a hardcoded fallback `designwithismail.site` domain that looks like leftover/unrelated boilerplate — see quirks). Path defaults to `/medicout-backend/socket.io/` (cPanel-prefixed). Registers `initChatSocket` and `initPresenceSocket` on the shared `io` instance. Exposes `getIo()` singleton getter (throws if called before `initSocket`).
- **`cloudinary.js`** — configures the Cloudinary SDK from env vars; used by profile photo upload and chat file upload.
- **`firebase.js`** — initializes `firebase-admin` only if `FIREBASE_PROJECT_ID` is set (private key `\n` un-escaping handled); otherwise exports the un-initialized `admin` module and logs a warning — all FCM call-sites check `admin.apps.length` before use.
- **`googleCalendar.js`** — creates a shared `google.auth.OAuth2` client from `GOOGLE_CALENDAR_CLIENT_ID/SECRET/REDIRECT_URI`. Note this is a SEPARATE OAuth client/credential set from the `auth` module's Google Sign-In (`GOOGLE_CLIENT_ID/SECRET`) — two independent Google integrations.

---

## 6. Utils (`src/utils/`)

- **`encryption.js`** — AES-256-CBC via Node `crypto`. `encrypt()`/`decrypt()`, key from `AES_ENCRYPTION_KEY` (must be exactly 32 chars, warns at startup if not), random IV per call prefixed to ciphertext (`iv:ciphertext` hex format). Used for chat message bodies and the stored Google Calendar OAuth token blob on `users.google_calendar_token`.
- **`fcm.js`** — lower-level FCM helpers (`sendPushNotification`, `sendToMultipleTokens` using deprecated `sendMulticast`). Largely superseded by the more complete `notifications.service.js` push logic; still present, unclear if actively called anywhere in the read modules (not found wired into any controller — likely legacy/unused).
- **`googleCalendar.js`** — `getAuthUrl`, `handleCallback` (exchanges code, encrypts+stores tokens on `users`), `createCalendarEvent`, `updateCalendarEvent`, `deleteCalendarEvent`. Called from `appointments.service.js` around create/reschedule/cancel. No route currently calls `getAuthUrl`/`handleCallback` (see Section 10 — calendar connect endpoints are missing).
- **`jwt.js`** — `generateTokens(user)`: access token (payload `{id, role, email, jti}`, TTL `JWT_ACCESS_TTL` default 15m) + refresh token (payload `{id}` only, TTL `JWT_REFRESH_TTL` default 30d). `jti` is a random UUID used for blacklisting on logout.
- **`mailer.js`** — Nodemailer transporter + a `templates` object with ~13 pre-built branded HTML email templates (verify OTP, welcome patient/doctor, appointment created/confirmed/rescheduled/cancelled/rejected, new message, new review, password reset). `sendMail()` no-ops with a console warning if `MAIL_USER` isn't configured (dev-friendly). Note: a `passwordReset` template exists but **no route/service calls it** — password reset flow appears unimplemented (see quirks).
- **`notification.js`** — a SECOND, largely redundant FCM notification helper (`sendNotification`, `sendMulticastNotification`, `sendTopicNotification`, `subscribeToTopic`) distinct from both `fcm.js` and `notifications.service.js`'s inline push logic. Uses topic-based (`user_<id>`) messaging as a fallback when no explicit device token given. Not obviously wired into any controller in the read scope — likely dead/legacy code, or reserved for a topic-broadcast feature not yet built.
- **`profileCompletion.js`** — `calculateCompletion(user, doctorProfile, specialties)`, pure function, weighted scoring described in Section 4.2.

---

## 7. Auth & Security Model

- **Access token**: short-lived JWT (`JWT_ACCESS_TTL`, default 15m), signed with `JWT_ACCESS_SECRET`, carries `{id, role, email, jti}`. Sent as `Authorization: Bearer <token>`.
- **Refresh token**: long-lived JWT (`JWT_REFRESH_TTL`, default 30d), signed with `JWT_REFRESH_SECRET`, carries only `{id}`. Its SHA-256 hash is stored in Redis (`refresh:<hash>` → userId) as the source of truth — the JWT signature alone isn't sufficient to refresh; the hash must also still exist in Redis (revocable, single-active-session-ish semantics, though nothing evicts OLDER refresh tokens on new login, so multiple concurrent refresh tokens can coexist per user).
- **Logout**: deletes the specific refresh-token Redis entry (if provided) AND blacklists the current access token's `jti` for 24h regardless of the token's own remaining TTL (so a leaked/soon-to-be-stale access token can't be replayed within a day window even after its natural 15-min expiry — this is somewhat redundant since it'll expire naturally in 15 min anyway, but harmless).
- **Password hashing**: bcrypt, 12 rounds in production, 10 rounds in development (`registerUser`), 12 rounds fixed in the seeder.
- **Account lockout**: 5 failed login attempts → 30 minutes locked (`users.locked_until`), independent of rate limiting.
- **Rate limiting**: `express-rate-limit` **in-memory store** (explicitly chosen over Redis "to work on shared hosting" per the code comment) — meaning rate limits do NOT persist across process restarts and do NOT share state across multiple Node instances/PM2 clusters. General limiter: 100 req/15min (skips `/health`). Login limiter: `RATE_LIMIT_LOGIN_MAX` env (default in code is 50, but `.env.example` documents 5 — mismatch, see quirks).
- **Role-based access**: `requirePatient`/`requireDoctor`/`requireAdmin` middleware, simple equality check on `req.user.role`. No granular permission/scopes system.
- **Google Sign-In**: accepts either an ID token (verified via `google-auth-library` against `GOOGLE_CLIENT_ID` audience) or a raw OAuth access token (profile fetched live via `googleapis` `oauth2.userinfo.get()`) — auto-detected by counting `.`-separated JWT segments. This is a slightly unusual dual-mode acceptance that trusts client-supplied token *type* implicitly (a 3-segment string is *assumed* to be a JWT rather than, say, a malformed opaque token — low risk but worth flagging).
- **CORS**: origin allow-list is dynamic; in development ANY localhost/127.0.0.1 origin is allowed regardless of `ALLOWED_ORIGINS`. Production relies entirely on `ALLOWED_ORIGINS` env (supports literal `*` wildcard, which combined with `credentials:true` would be a real CORS security footgun if ever set in production — the code doesn't warn against this combination).
- **Data encryption at rest**: chat message text and the stored Google Calendar OAuth token are AES-256-CBC encrypted with a single static app-wide key (`AES_ENCRYPTION_KEY`) — no per-user key derivation, no key rotation support evident.

---

## 8. Real-Time Features (Socket.IO)

Initialized in `src/config/socket.js`, path `/medicout-backend/socket.io/` (configurable via `SOCKET_PATH`), transports `[websocket, polling]`. Two socket modules registered: `chat.socket.js` and `presence.socket.js`.

### Client → Server events
| Event | Module | Purpose |
|---|---|---|
| `authenticate` | chat | Verifies JWT, joins `user_<id>` room, sets `socket.userId/userRole` |
| `join_thread` | chat | Joins `thread_<id>` room |
| `send_message` | chat | Saves + broadcasts a message (parallel path to REST `POST /chat/messages`) |
| `typing_start` / `typing_stop` | chat | Broadcasts `typing_indicator` to thread room (excludes sender) |
| `mark_read` | chat | Broadcasts `message_read` only — does NOT persist to DB (inconsistent with REST equivalent) |
| `doctor_go_online` / `doctor_go_offline` | presence | Sets Redis + DB online status, logs to `doctor_availability_log`, broadcasts globally |
| `presence_heartbeat` | presence | Refreshes online TTL if still active |
| `disconnect` | presence (built-in) | Auto marks doctor offline if their socket disconnects uncleanly |

### Server → Client events
`authenticated`, `auth_error`, `thread_joined`, `message_received`, `new_message`, `message_updated`, `message_deleted`, `message_error`, `typing_indicator`, `message_read`, `notification_created`, `presence_updated`, `presence_error`, `status_changed`, `doctor_online`, `doctor_offline`, `appointment_updated` (v1 confirm/reschedule/cancel), `appointment_new` (v2 confirm).

Presence broadcasts (`status_changed`, `doctor_online/offline`) are global `io.emit()` — NOT scoped to interested clients/rooms, so every connected socket receives every doctor's presence change regardless of relevance. Same for the REST-driven `PATCH /api/doctors/status` broadcast.

---

## 9. Notable Environment Variables (names only, see `.env.example`)

**Server**: `PORT`, `NODE_ENV`, `FRONTEND_URL`, `ALLOWED_ORIGINS`
**MySQL**: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_POOL_MIN`, `DB_POOL_MAX`
**Redis**: `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` (also supports undocumented `REDIS_URL` per `config/redis.js`), `REDIS_TTL_DOCTOR_ONLINE`, `REDIS_TTL_SEARCH_CACHE`, `REDIS_TTL_SESSION` (defined but not seen used in read scope), `REDIS_TTL_REFRESH`, `REDIS_TTL_EMAIL_OTP`, `REDIS_TTL_RATE_LIMIT` (defined but not seen used — rate limiter uses in-memory store, not Redis, despite `rate-limit-redis` being a listed dependency)
**JWT**: `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`
**Google OAuth (Sign-In)**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` (defined but no callback route implemented)
**Google Calendar**: `GOOGLE_CALENDAR_CLIENT_ID`, `GOOGLE_CALENDAR_CLIENT_SECRET`, `GOOGLE_CALENDAR_REDIRECT_URI`
**Mail**: `MAIL_HOST`, `MAIL_PORT`, `MAIL_SECURE`, `MAIL_USER`, `MAIL_PASSWORD`, `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`
**Cloudinary**: `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`, `CLOUDINARY_UPLOAD_PRESET` (preset var defined but uploads in code don't reference an upload preset — they upload directly with inline options)
**Firebase FCM**: `FIREBASE_PROJECT_ID`, `FIREBASE_DATABASE_URL`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL`
**Encryption**: `AES_ENCRYPTION_KEY` (must be 32 chars), `AES_IV_LENGTH` (default 16)
**Rate limiting**: `RATE_LIMIT_WINDOW_MS`, `RATE_LIMIT_MAX_REQUESTS`, `RATE_LIMIT_LOGIN_MAX`
**App/business rules**: `MAX_FILE_SIZE_MB`, `APPOINTMENT_SLOT_DURATION_MINS`, `GUEST_SESSION_TTL_HOURS` (defined but no guest-session feature found in read scope), `OTP_LENGTH` (defined but `auth.service.js` hardcodes `crypto.randomInt(100000, 999999)` — a fixed 6-digit OTP regardless of this env var), `PROFILE_PHOTO_MAX_SIZE_MB` (defined but not enforced separately from the general `MAX_FILE_SIZE_MB` Multer limit)

Not in `.env.example` but referenced in code: `SOCKET_PATH` (optional override for Socket.IO path).

---

## 10. Known Quirks / TODOs / Inconsistencies

1. **Two parallel booking systems share one table.** `appointments` module (v1, mounted at `/api/appointments`) and `bookingv2` module (v2, mounted at `/api/v2`) both read/write the same `appointments` table but use fundamentally different concurrency strategies: v1 relies solely on a DB unique constraint + `FOR UPDATE` row lock at insert time (race window exists between "check slots" and "book"), while v2 adds an explicit Redis-backed 10-minute reservation lock (lock → confirm flow) to close that race. Both are live/mounted simultaneously — it's unclear from the backend alone whether the frontend has fully migrated to v2 or still uses v1 for some flows. v1's `confirmAppointment` "auto-reject competing pending appointments at the same slot" logic is essentially a manual workaround for the race that v2's lock avoids by construction.

2. **README documents endpoints that don't exist.** `/api/calendar/connect` and `/api/calendar/callback` are listed in `README.md` but no route module registers them anywhere in `src/app.js` or any `*.routes.js`. Only the underlying helper functions (`utils/googleCalendar.js`: `getAuthUrl`, `handleCallback`) exist, called nowhere from an HTTP handler. Google Calendar sync currently only happens as a side effect of appointment create/reschedule/cancel for doctors who already have a `google_calendar_token` — but there's no way to populate that token via this backend's exposed API today.

3. **Password reset is templated but not implemented.** `mailer.js` has a full `passwordReset` email template, but no controller/service/route calls it or generates a reset token/link anywhere in the codebase.

4. **`RATE_LIMIT_LOGIN_MAX` default mismatch.** `.env.example` comments/implies 5 attempts; `middleware/rateLimiter.js` code default (when the env var is unset) is `50`. If the env var isn't set in a given deployment, login rate limiting is 10x looser than the example file suggests.

5. **`rate-limit-redis` dependency is unused.** It's listed in `package.json` but `rateLimiter.js` explicitly uses the in-memory store "to work on shared hosting without Redis dependency" — meaning rate limits reset on every process restart/deploy and aren't shared across multiple app instances if ever horizontally scaled.

6. **Two/three redundant FCM helper modules.** `utils/fcm.js`, `utils/notification.js`, and inline push logic in `modules/notifications/notifications.service.js` (`sendPushToUserTokens`) all independently wrap Firebase Admin messaging. Only the `notifications.service.js` version appears to be actually wired into live controllers (appointments, chat, reviews all call `notificationsService.createNotification`, which internally pushes). `fcm.js` and `notification.js` look like earlier/alternate implementations left in the tree — good candidates for cleanup once confirmed unused, but should be verified against the Flutter frontend before deleting (client might call these indirectly through routes not covered in this backend read).

7. **`reviews.service.js` Redis `del` call bug.** `await redisClient.del(keys)` passes an array directly instead of spreading (`...keys`), inconsistent with the correct spread usage in `profile.service.js` (`redisClient.del(...keys)`) and `presence.socket.js` (`redisClient.del(...keys)`). Depending on the ioredis version's argument coercion this may silently fail to delete the intended cache keys after a new review, leaving stale `avg_rating`/`total_reviews` in search results until the cache TTL naturally expires (up to `REDIS_TTL_SEARCH_CACHE`, default 300s) — low severity due to the TTL safety net, but worth fixing for consistency.

8. **`rescheduled_from` self-reference looks wrong.** In `appointments.service.js` `rescheduleAppointment`, the SQL sets `rescheduled_from = id` in the SAME UPDATE statement that changes that row's own `id`'s data — so `rescheduled_from` ends up pointing to itself, not to a "previous" appointment record. If the intent was appointment-history chaining, this doesn't achieve it (there's only ever one row per appointment; reschedule mutates in place rather than creating a new row). This may be intentional (self-pointer as a "this appointment has been rescheduled at least once" flag) but the column name strongly implies lineage tracking that isn't actually happening.

9. **Chat has three parallel real-time delivery paths for the same message**: Socket.IO room broadcast, a Firebase Realtime Database mirror write (`admin.database().ref('messages/...')`), and the underlying MySQL row read via REST polling. No single source of truth is enforced — a Firebase write failure is caught and logged but doesn't roll back or flag the MySQL-committed message, so Firebase and MySQL can drift.

10. **Socket `mark_read` doesn't persist.** The socket-based `mark_read` handler only re-broadcasts `message_read` to the room; it does not update `chat_messages.is_read` in the DB (unlike the REST `POST /chat/threads/:id/read` and the read-on-fetch behavior in `getThreadMessages`). A client relying solely on the socket event for "mark as read" state would diverge from the DB-backed unread counts used elsewhere (`GET /chat/unread-count`, thread list `unread_count`).

11. **Chat encryption doubles as ad-hoc state tagging.** `content_encrypted` stores not just the encrypted message text but also special sentinel plaintexts before encryption: `__deleted__` and `__edited__:<new text>`. This conflates "confidentiality" with "message lifecycle state" — a message's edited/deleted status is only knowable by decrypting and pattern-matching the prefix, rather than via dedicated `is_deleted`/`is_edited` DB columns. Functionally works but is a fragile design (e.g., a legitimate message that happens to start with `__edited__:` would be misinterpreted — extremely unlikely in practice but technically possible).

12. **Presence has two independent write paths with different Redis semantics.** `PATCH /api/doctors/status` (REST, `doctors.controller.js`) updates only `doctor_profiles.is_online` in MySQL and broadcasts via socket — it does NOT touch the `doctor:online:<id>` Redis key that `search.service.js` and `bookingv2`/`presence.socket.js` treat as the live-status source of truth. Meanwhile `presence.socket.js`'s `doctor_go_online`/`doctor_go_offline` events DO set that Redis key. If a doctor's client only calls the REST endpoint (not the socket events), Redis-backed "live" online status (used to override cached search results) will not reflect reality until the doctor also emits the socket events or the stale Redis key naturally expires/absent.

13. **Presence socket handlers trust client-supplied `doctorProfileId` when unauthenticated.** `resolveDoctorProfileId` in `presence.socket.js` falls back to trusting `data.doctorProfileId` from the socket payload if `socket.userId` isn't set (i.e., the socket never called `authenticate`). This means an unauthenticated socket connection could toggle any doctor's online/offline status and write to `doctor_availability_log` by simply guessing/providing a `doctorProfileId`, since there's no ownership check tying the acting socket to that specific doctor when `socket.userId` is absent. Worth a security follow-up.

14. **Leftover/unrelated CORS fallback domain.** `src/config/socket.js` hardcodes `https://designwithismail.site` / `www.designwithismail.site` as the DEFAULT Socket.IO CORS origins when `ALLOWED_ORIGINS` is unset — this looks like boilerplate copied from an unrelated project template and left in, rather than a Medicout domain (compare to `FRONTEND_URL=https://medicout.designik.agency` in `.env.example`). Should be updated/removed to avoid accidentally trusting a foreign origin if `ALLOWED_ORIGINS` is ever misconfigured/empty in production.

15. **`search.service.js` `ST_Distance_Sphere` ORDER BY parameter duplication.** The code comment itself flags this: `lng, lat` params are pushed twice (once for the WHERE distance filter, once for the ORDER BY distance sort) because MySQL doesn't allow reusing a bound parameter placeholder across clauses — functionally correct but the code author left an explanatory comment expressing mild uncertainty about it, worth a second look if geo-search misbehaves.

16. **No base schema file in the repo.** `migrations/` only contains 4 incremental patches; the foundational `CREATE TABLE` statements (referenced as `medicout.sql` in the README) are not present in this checkout. Anyone provisioning a fresh DB from this repo alone cannot do so — they'd need the original `medicout.sql` dump from wherever it's kept (not in version control here).

17. **Dev-only backdoor route.** `src/app.js` registers `DELETE /api/test/reset` when `NODE_ENV==='development'`, which deletes the two seeded test users and flushes the ENTIRE Redis database (`redisClient.flushall()` — not scoped to test keys, wipes all cache/session/lock state). Correctly gated behind `NODE_ENV` check, but worth double-checking that check can't be bypassed and that `NODE_ENV` is never accidentally left as `development` in a production deploy.

18. **`OTP_LENGTH` env var is defined but unused.** `auth.service.js` hardcodes a 6-digit OTP (`crypto.randomInt(100000, 999999)`) regardless of the configured `OTP_LENGTH` value.

19. **Postman collection is generated/augmented, not hand-written.** `generate_postman.js` reads a `../medicout.postman_collection.json` (parent directory, NOT present in this backend folder — implies it lives in the monorepo root or was a one-time local file) and writes `medicout_v2.postman_collection.json` in-place. This script is not part of the runtime app; it's a dev tool that was run once to produce the checked-in collection. Re-running it requires that missing parent-relative source file.
