# SheListens — this pass's changes

All 7 items from the request, plus a dormant-code sweep. TypeScript
(`tsc --noEmit`) and ESLint both pass clean (0 errors) after every change
below — no PHP interpreter was available to run `php -l`, so the backend
files were reviewed by hand against the existing code's conventions instead.

---

## 1. Booking times went past the listener's actual working hours

**Root cause:** `booking-picker.tsx` had a fully hardcoded, fake list of
times (`9:00 AM … 10:15 PM`) that never talked to the real availability
rules at all — the 8:30 PM cutoff you set in Availability was simply never
read by the booking page.

**Fix:**
- New endpoint `backend/api/bookings/slots.php` — for a given date, returns
  the real working-hours window, whether the day is blocked off, every
  already-taken time (across *all* clients — there's one listener/one
  calendar), and the requesting client's same-day booking count.
- New shared `backend/api/lib/booking_rules.php`, used by both
  `bookings/create.php` and `bookings/reschedule.php`, so a booking is
  validated server-side against: working hours, time-off blocks, past
  times, and — new — **double-booking overlap**. Previously nothing
  stopped two different clients from booking the same time slot; there's
  only one listener, so that's now blocked with a 409.
- `booking-picker.tsx` rewritten to generate its slot grid from the real
  window + session length + buffer, gray out taken/past slots, and show a
  clear reason ("Fully booked" / "Closed" / "Daily limit reached") when a
  whole day is unavailable.

## 2. Guidelines for the 3-sessions/day rule

- Clarified everywhere the cap is shown that it's **per client, per day**
  (not the listener's total daily capacity) — on the client booking page,
  and on the listener's Availability page.
- The picker now shows "You have X of Y sessions booked this day" live,
  and a clear message when the cap is hit, instead of a generic error.
- The cap, session length, and buffer are now admin-editable (see #6) and
  read live everywhere instead of being hardcoded in three different
  places.

## 3. Admin → Listener revert

The backend (`auth/admin-exit.php`) already fully supported this — it was
just missing a UI control. Added:
- An "Exit to Listener" button in the admin header, both desktop and
  mobile (`AdminShell`).
- Fixed the "Back to listener" link on the admin-locked screen — it
  previously just navigated without actually calling `exitAdmin()`, so the
  session stayed in admin mode underneath.

## 4. Sessions tab, messaging-app style

- New `backend/api/bookings/list.php?scope=all` — every session across
  every client, most recent first.
- New route `/listener/sessions` — a day-grouped inbox; each row opens
  that session's live chat directly (reusing the existing
  `/app/session/$id` screen, the same one clients use), the way a
  messaging app opens a thread. Added to listener nav.
- Client's own `/app/sessions` tab: rows are now clickable/openable too
  (for sessions still pending/confirmed), for the same "open the thread"
  consistency.

## 5. Missed sessions wrongly flipping to "completed"

**Root cause:** the auto-complete effect on `/app/session/$id` fired for
*anyone* who opened a session after its time window had passed —
including an admin checking in on a no-show days later, which silently
overwrote the booking to `completed` and destroyed the "missed" signal
the rest of the app relies on (`is_missed`).

**Fix:** the page now records, once, whether the session was actually
live (already started, not yet ended) at the moment it was first opened.
Only a session that was live and then timed out while someone was
watching auto-completes. A session that was already over when opened
shows a distinct "Session missed" screen and stays missed — nothing
silently rewrites its status.

## 6. General admin platform control

Built out real, live-editable platform configuration where before it was
duplicated/hardcoded across the codebase:

- `platform_settings` now backs: max sessions/day, session length, buffer
  between slots, video session fee, and a **maintenance mode** toggle
  (pauses new client bookings, both frontend and backend-enforced).
- New Admin → Settings → **Platform** section to edit all of the above.
- New Admin → Settings → **Pricing** section: subscription plan prices
  (Weekly/Monthly/Yearly) are now genuinely editable. The `plans` DB table
  already existed but nothing read or wrote it — the whole app (pricing
  page, onboarding, checkout, subscription management, dashboard) was
  using a hardcoded mock copy that could never reflect a real price
  change. Rewired all 7 of those places to read live pricing via a new
  `usePlans()` hook, merged over the static marketing copy (feature
  bullets aren't in the DB, so those stay as shipped).
- New Admin → Users → account controls, replacing two of the four
  "Not wired up yet" placeholder buttons:
  - **Suspend / reactivate account** — blocks login immediately (even for
    already-issued tokens), never deletes data.
  - **Reset password** — generates a one-time temporary password shown
    once to the admin to relay manually (there's no outbound email
    service in this project yet, so a "send a reset link" button would
    have been another dead placeholder).
  - "Issue refund" and "Add wallet credit" are left as-is — they need a
    real payment ledger and wallet system that doesn't exist, and I didn't
    want to half-build financial code. The UI is honest about why.
- Admin → Users list and detail pages now show real suspended/active
  status instead of a hardcoded "Active" badge.

## 7. Dormant-code sweep

Found and fixed while working through the above:
- **Double-booking**: nothing previously prevented two clients from
  booking the same time slot (see #1).
- **Pricing drift**: the real `plans` table was completely disconnected
  from the app; every price shown anywhere was a static mock value (see
  #6).
- **Fake "Active" status badges** on both the admin user list and detail
  page, regardless of actual account state.
- **Reschedule flow** duplicated (and had drifted from) the same
  create-booking validation, missing the working-hours/overlap checks
  entirely — now shares `lib/booking_rules.php` with create.php.
- Two of the four admin user-action buttons were inert placeholders with
  no backend at all — now real (see #6); the other two are honestly
  labeled instead of pretending to work.

---

## New backend files
- `backend/api/lib/booking_rules.php`
- `backend/api/bookings/slots.php`
- `backend/api/plans/list.php`
- `backend/api/admin/plans.php`
- `backend/api/admin/user-suspend.php`
- `backend/api/admin/user-reset-password.php`
- `backend/migrations/005_user_admin_controls.sql`

## New frontend files
- `src/routes/listener/sessions.tsx`
- `src/lib/platform-settings.ts`
- `src/lib/plans.ts`

## Migration
Run `backend/migrations/005_user_admin_controls.sql` against the existing
database before deploying (adds `users.suspended_at`, `payments.booking_id`,
and seeds default platform settings — safe to run on an existing DB,
matches the pattern of migrations 002–004).

---

# Follow-up pass: video-session payment gating

## Video sessions weren't actually being charged for

**Root cause:** `bookings/create.php` stored a `video_fee` number on the
booking, but nothing ever charged a card for it — for *any* plan, not just
ones that don't include video. It was a purely decorative column.

**Fix:**
- `bookings/create.php` now checks the client's active plan
  (`plans.video_included`) and refuses to create a video booking directly
  if their plan doesn't cover it — that case has to go through payment.
- New endpoint `bookings/verify-video-payment.php`: validates the slot and
  daily cap *before* charging (so nobody pays for an invalid slot),
  verifies the Flutterwave transaction server-side, re-validates the slot
  immediately after the charge clears (in case it was taken during
  checkout — if so, the payment is recorded as paid-but-unlinked and
  logged as a critical audit event rather than silently lost, since
  there's no automated refund flow yet), then creates the booking with
  the actual charged amount as `video_fee`.
- `bookings/reschedule.php`: if rescheduling into video and the *original*
  booking already had a paid video fee, that payment is carried forward
  to the new booking (no re-charge). If it wasn't already paid and the
  plan doesn't cover video, reschedule.php now refuses and directs the
  client to the paid flow instead.
- Frontend: `lib/flutterwave.ts` gained a booking-specific payment flow
  (`payForVideoBooking` / `checkForVideoBookingRedirectResult`) alongside
  the existing plan-subscription one. `app/book/index.tsx` and
  `app/book/reschedule/$id.tsx` now check the client's plan first and
  route video bookings through payment only when it's actually needed.
  `booking-picker.tsx` shows "Included in your plan" vs. the real fee
  accordingly, and the post-booking receipt (`app/book/confirm.tsx`) now
  correctly says "₦X video fee paid" instead of always claiming
  everything was "included."
- `payments` table gained a `booking_id` column (nullable, FK to
  `bookings`) so video-fee charges have a real ledger entry, and the admin
  Payments page labels them "Video fee" instead of a blank plan name.

## Voice notes and video calls — status check

Looked into both while I was in this part of the code:

- **Voice notes are not functional.** The mic button in the session chat
  is a labeled stub (`"Voice notes aren't available yet — coming soon."`).
  No recording, upload, or playback code exists.
- **Video calls are not functional, and aren't even stubbed.** A "video"
  session today is the *same text chat* as a text/voice session — the
  type only changes a label in the header and (as of this pass) the fee.
  There's no camera/mic capture, no calling UI, and no WebRTC or
  third-party video SDK integrated anywhere.

Neither was touched in this pass — building real voice-note recording and
real video calling are both substantial features in their own right (voice
needs browser audio capture + storage + playback; video calling needs a
WebRTC integration or a third-party service like Daily.co/Twilio/Agora,
which means picking a vendor and adding API credentials). Flagging clearly
rather than quietly leaving it ambiguous, since the payment gate above
now makes "video" a real paid product — worth deciding what it actually
delivers before charging for it at scale.

---

# Follow-up pass: voice notes and live calling, built

## Voice notes — now functional

Real browser-based recording, upload, storage and playback, no third-party
service needed:

- `MediaRecorder` records locally in the browser (`src/lib/use-voice-recorder.ts`),
  capped at 3 minutes.
- New endpoint `session/voice-note.php` — the recording is sent as base64
  inside a normal JSON POST (no multipart handling required), decoded, and
  written to disk under `backend/api/uploads/voice-notes/{booking_id}/`
  with a random filename. A `.htaccess` in that folder blocks script
  execution and directory listing as defense in depth.
- `session_messages` gained a `voice_note_duration_sec` column; the chat
  now renders an inline `<audio>` player with the duration for any message
  that has a voice note instead of text.
- **Operational note:** this needs `post_max_size` in PHP to comfortably
  exceed ~11MB (an 8MB cap, base64-inflated) — flagged in `config.php`.
  Most shared-hosting defaults already clear that.

## Video and voice calling — now functional (web-based, no app needed)

**Yes, this works directly in the browser** — WebRTC (`getUserMedia` +
`RTCPeerConnection`) is standard in every modern browser, no native app or
plugin required. It does need HTTPS in production (a secure context is
mandatory for camera/mic access; only `localhost` is exempt for local
dev).

What's built:
- New table `call_signals` + endpoint `session/call-signal.php` — since
  this project has no websocket server, both sides short-poll for the
  other party's offer/answer/ICE candidates (fast — ~1.2s — while a call
  is being set up or is active, slow — ~3.5s — while idle, just watching
  for an incoming call). Rows are pruned automatically after a day.
- `src/lib/use-call.ts` — the full call state machine (idle → outgoing/
  incoming → connecting → active), peer connection setup, ICE candidate
  queuing, mute/camera toggles, call timer, and cleanup on hangup or
  disconnect.
- `src/components/call-overlay.tsx` — the full-screen call UI: incoming-call
  banner with accept/decline, outgoing "Calling…" screen, and the active
  call view (video tiles or audio-only, mute/camera/hang-up controls).
- Wired into the session chat header: a call button appears for `voice`
  and `video` type bookings (video sessions get a camera icon and request
  camera access; voice sessions get a phone icon, audio only).
- **STUN only — no TURN server.** Calls use Google's public STUN servers
  for NAT traversal, which is enough for most home/mobile networks. There's
  no TURN server configured, so a small fraction of very restrictive
  networks (symmetric NAT, locked-down corporate firewalls) may fail to
  connect a direct peer-to-peer path. Adding TURN later (e.g. a small
  coturn instance, or a hosted TURN provider) would close that gap — it's
  a real but genuinely rare edge case for a two-person Nigeria-market app,
  not something to pre-optimize for.

## New backend files
- `backend/api/session/voice-note.php`
- `backend/api/session/call-signal.php`
- `backend/api/uploads/.htaccess`, `backend/api/uploads/voice-notes/.gitkeep`
- `backend/migrations/006_voice_and_calls.sql`

## New frontend files
- `src/lib/use-voice-recorder.ts`
- `src/lib/use-call.ts`
- `src/components/call-overlay.tsx`

## Migration
Run `backend/migrations/006_voice_and_calls.sql` before deploying (adds
`session_messages.voice_note_duration_sec` and the `call_signals` table).

---

# Follow-up pass: bug fixes + content management

## Answers to two error messages
- **"This session can no longer be rescheduled"** — the booking's status is
  already `completed` or `cancelled`.
- **"That time has already passed"** — the chosen slot is in the past
  relative to the server's clock (see the timezone fix below — this is
  exactly the kind of message that used to be unreliable before it).

## Timezone — was never explicitly set, now pinned to Africa/Lagos
Neither PHP nor MySQL had an explicit timezone configured anywhere in the
backend. PHP was falling back to the server's own default (varies by
host), and MySQL's `NOW()`/`CURRENT_TIMESTAMP` used a separate default of
its own — the two could silently disagree, and neither was guaranteed to
be Nigeria time. Fixed: `date_default_timezone_set('Africa/Lagos')` in
`lib/cors.php` (included first in every endpoint) and `SET time_zone =
'+01:00'` on the PDO connection in `lib/db.php`, so PHP and MySQL now
always agree on "now," and it's Lagos time (WAT, UTC+1, no DST).

## Global error handler
Any uncaught PHP exception or fatal error previously sent a raw PHP error
page back to the frontend, which can't parse it as JSON — the user just
sees "Server sent an invalid response." Added an exception/shutdown
handler in `lib/cors.php` that catches these and returns a clean JSON
error instead; the real detail still goes to `error_log()` for debugging.
**This does not fix any specific bug** — it just means a future bug fails
gracefully instead of breaking the frontend outright.

## Your video-payment error, specifically
The Fatal error in your log (`Unknown column 'booking_id' in payments`)
means **migrations 005, 006 and 007 haven't been run against your
database yet** — they add columns and tables this pass's features
actually depend on. Run all three (in order) before testing again:
```
mysql -u root aysis < backend/migrations/005_user_admin_controls.sql
mysql -u root aysis < backend/migrations/006_voice_and_calls.sql
mysql -u root aysis < backend/migrations/007_editable_content.sql
```

## Session page fixes
- **Back button** on the countdown/ended/missed screens now returns to
  wherever you actually came from (browser history), instead of always
  dropping you on the dashboard root. Falls back to the dashboard only for
  a session opened via a direct link with no prior page.
- **Confirmation prompt** added before "End" actually ends a session.
- **Cancelled/completed sessions no longer clutter the listener's
  Today/Week views** — `bookings/list.php`'s `today` and `week` scopes now
  only return live (`pending`/`confirmed`) bookings; the calendar and
  today schedule were including dead rows (e.g. the cancelled originals
  left behind by a reschedule) with no filtering, backend or frontend.

## Voice note player rebuilt
Replaced the native `<audio controls>` element with a fully custom player
(`src/components/voice-note-player.tsx`): play/pause and seek only — no
volume slider, no native "download" option, and right-click "Save Audio
As…" is blocked on the underlying element.

## Content management — resources, plans, legal pages
On whether admin should be able to "control the database" directly: **no**
— a raw table/SQL editor in an app handling sensitive client data is a
real security risk and duplicates what phpMyAdmin already does safely.
Instead, everything specific that actually needed to be editable now is,
through validated, purpose-built admin UI:

- **Wellness resources** — previously 100% hardcoded (same issue plans had
  before), never connected to the `resources` table that already existed.
  New `resources/list.php` (public, published-only) and
  `admin/resources.php` (full CRUD), new Admin → Resources page
  (create/edit/delete/publish-toggle), client-facing pages switched from
  the static mock list to live data.
- **Plans** — previously only price was editable. Added a `features`
  column (migration 007) and rebuilt the admin Plans section: name,
  price, period label, video-included toggle, and the feature bullet list
  are all editable per plan now, seeded from the existing marketing copy
  so nothing changes visually until you actually edit something.
- **Legal pages (Terms/Privacy/Confidentiality)** — previously the exact
  same generic placeholder paragraph, hardcoded, repeated for all three
  documents. New `legal_pages` table, `legal/get.php` (public),
  `admin/legal.php` (edit), and a new Admin → Legal page with one editor
  per document. The public `/legal/*` pages now render live content.

## Admin nav changes
- Removed **Audit log** and **Security** from the admin nav (routes still
  exist on disk, just unlinked — trivial to restore if that wasn't the
  intent for both).
- Removed the dead **"Issue refund"** / **"Add wallet credit"** buttons
  from the admin user detail page.

## New backend files
- `backend/api/resources/list.php`, `backend/api/admin/resources.php`
- `backend/api/legal/get.php`, `backend/api/admin/legal.php`
- `backend/migrations/007_editable_content.sql`

## New frontend files
- `src/components/voice-note-player.tsx`
- `src/lib/resources.ts`
- `src/routes/admin/resources.tsx`
- `src/routes/admin/legal.tsx`

## Migration
Run `backend/migrations/007_editable_content.sql` (in addition to 005 and
006, all three are needed) before deploying.

---

# Follow-up pass: History, editable journals, support tickets, plan billing

## Subscription billing period — real bug fixed
Your "monthly → 3 months" question surfaced a genuine gap: `period_label`
was display text only. The actual renewal date was hardcoded per plan id
(`'weekly' => 1 WEEK`, etc.) in **two separate places**
(`subscriptions/select.php` and `subscriptions/verify-payment.php`), so
changing a plan's label would never have changed how often it actually
billed. Added real `interval_count`/`interval_unit` columns (migration
008), factored the renewal-date logic into one shared
`lib/plan_rules.php` helper both files now call, and added the fields to
the admin Plans editor ("Bills every [3] [month(s)]").

## Flutterwave test/live — fixed to match your instinct exactly
There was a manual "Test mode / Live mode" toggle in Admin → Settings that
did nothing (the code even said so in a comment). You were right that it
should be static and the keys should decide: removed the toggle, replaced
it with a read-only badge auto-detected from whether the saved public key
contains `_TEST`. No separate switch to flip when going live.

## Cancelled/rescheduled bookings are now actually deleted
Confirmed the only place a booking ever became "cancelled" was the
reschedule flow. Both `bookings/reschedule.php` and the video-payment
reschedule path now `DELETE` the old booking outright (after moving any
paid receipt to the new booking first) instead of leaving a dead
`cancelled` row behind.

## History tab (listener-only)
New `/listener/history` — separate from the Sessions tab, a flat list of
finished sessions (most recent first). Opening one shows the read-only
transcript plus a quick note box that saves straight into that client's
private notes. This required no data-retention change — messages were
already being kept in the database indefinitely; this just builds a way
to look at them. One important flag on this, not a code issue: the app
still tells users "message content is never logged" in several places,
which is no longer accurate now that a listener can review full
transcripts. Left as-is per instruction, but wanted that contradiction on
record rather than quietly shipped.

## Editable journals, both sides
- **Listener's private client notes** (`client_notes`) — previously
  append-only. Added edit and delete (`clients/notes.php`), wired into the
  existing per-client notes UI with hover-to-reveal Edit/Delete controls.
- **Client's own personal journal** (`journal_entries`) — same gap, same
  fix. New `journal/update.php` and `journal/delete.php`; the entry detail
  page now supports editing and deleting your own entries.

## Support tickets (new)
Messaging only ever existed inside a live, booked session — there was no
way to reach anyone outside that window. New `support_tickets` /
`support_messages` tables (migration 009) and a full ticket flow: clients
open a ticket anytime (`/app/support`), the listener sees an inbox split
into Open/Closed (`/listener/support`), either side can reply (a reply to
a closed ticket reopens it automatically), and either side can mark a
ticket closed. **Assumption flagged**: read this as clients reaching the
listener, since "listener" contacting no one in a single-listener setup
didn't quite parse — let me know if that's not what was meant.

## New backend files
- `backend/api/journal/update.php`, `backend/api/journal/delete.php`
- `backend/api/support/create.php`, `list.php`, `messages.php`,
  `reply.php`, `close.php`
- `backend/api/lib/plan_rules.php`
- `backend/migrations/008_plan_billing_interval.sql`,
  `backend/migrations/009_support_tickets.sql`

## New frontend files
- `src/routes/listener/history.tsx`, `src/routes/listener/history/$id.tsx`
- `src/routes/app/support/index.tsx`, `new.tsx`, `$id.tsx`
- `src/routes/listener/support/index.tsx`, `$id.tsx`

## Migration
Run, in order, before deploying: `008_plan_billing_interval.sql`,
`009_support_tickets.sql` (in addition to everything from earlier passes).




