# Work plan: Video Conferencing Module, Phase 1

Tick tasks (`- [x]`) as they are done. Each story is one feature branch cut from `phase-1` and one or more merge requests into `phase-1`, reviewed by Amit.

## Build order and why

| # | Story | Why here |
|---|---|---|
| 0 | Skeleton (part of HEL-1044) | Repo, local LiveKit + Postgres, config, tests. Every story needs it. |
| 1 | **HEL-1044** Secure access, organisation isolation, embedded interface | Foundation: token check, organisation separation, allowlist, meeting passes, revocation. Every endpoint depends on it. Epic 8 says build it first. |
| 2 | **HEL-972** View the Meetings page | Entry point of the product; first screen users see. |
| 3 | **HEL-973** Start an instant meeting | Creates meetings (at Join now) and makes the starter Host. With 972 it gives the first demo: open Meetings, start, join, see video. |
| 4 | **HEL-975** Allow camera and microphone | Pre-join permission states the join flow relies on. |
| 5 | **HEL-976** Complete the pre-join check and join | Device check and Join now; mic defaults; hands off to admission. |
| 6 | **HEL-977** Access and capacity checks when joining | The ordered join checks, refusal screens, 25-device limit, waiting room, held places. |
| 7 | **HEL-979** Share the meeting link | Needs meetings and admission to exist; link never bypasses admission. |
| 8 | **HEL-980** Conduct the meeting | The meeting screen: media, chat, People, one-at-a-time screen sharing with requests. |
| 9 | **HEL-1032** Raise a hand and send a reaction | Secondary features; must never delay essentials. |
| — | **HEL-1043** APIs and integration | On hold: returned by Management for scope alignment. Resume when the lifecycle decisions are made. |
| — | **HEL-1026** Leave Meeting or End Meeting | Story updated 3 Nov (PRD v0.6); Acting Host after an unexpected drop is HEL-1012, Permanent Meeting Rooms HEL-1136. |

Harsh's related stories (not ours, but we integrate with them): HEL-974, HEL-981 to HEL-986 (Co-hosts, admit/deny, mute, remove, block, guest sharing), HEL-1010/1012/1025/1027/1028/1029 (Epic 4 continuity), HEL-1033 (mobile essentials).

## External dependencies (who we wait on)

| Need | From | Status | Blocks |
|---|---|---|---|
| BA records Management corrections in each story | Harshit Yadav (BA) | Pending | Formal start of each story |
| Developer assignment | Amit | Pending | Formal start |
| `meeting_jwt_token` at login (own token, `token_type: meeting`) | Surinder Chahal | Done on test server (3 Nov) | — |
| Secret for `meeting_jwt_token` | Surinder Chahal | Received 3 Nov; separate from Helix AI's; same for live | — |
| `GET /api/v1/meetings/allowed` (company enablement, set in the CRM) | Surinder Chahal | Done on test server (3 Nov) | — |
| `/allowed` returns 401 for deactivated users | Surinder Chahal | Confirmed 3 Nov | — |
| Add `role_name` to the `/allowed` answer | Surinder Chahal | Done 3 Nov (verified live) | — |
| Note in HEL-1044 that Phase 1 has no HelixtaHR deactivation notice (removal within 5 min) | Harshit Yadav (BA) | To tell | Story wording |
| H3 Meetings item in HelixtaHR menu | HelixtaHR developer | Not started | Launch from HelixtaHR |
| Meaning of `user_type` | Surinder Chahal | Not significant; ignored (3 Nov) | — |
| AC-35.8 (customer company needs approval) | CRM | Enforced in the CRM (3 Nov) | — |
| Embedding | Decided 3 Nov: Meetings screens inside HelixtaHR's frontend | Done | — |
| HelixtaHR frontend web addresses (test, live) | HelixtaHR frontend team | Pending | CORS (`CORS_ALLOWED_ORIGINS`) |
| Stack/hosting + dev LiveKit server with TURN | Amit (HLD decision 12) | Open | Leaving local dev |
| Email channel for Engineering alerts (SMTP), behind `app/core/alerts.py` | Mukesh | To assign | Alerts reaching people (logged until then) |
| Designs: waiting screen, share request, device states, per-browser steps, "removed" screen | Ishank | In progress | Frontend screens |
| Browser list | Done (PRD v0.5) | — | — |
| Lifecycle decisions 1–5 | Management / Harsh | Open | HEL-1026, HEL-1043 |

## Step 0: Skeleton (branch `feature/HEL-1044-skeleton`)

- [x] GitLab repo cloned; `phase-1` branch created from `main` and pushed
- [x] Project files: `requirements.txt` + `requirements-dev.txt` (dependencies), `pyproject.toml` (tool settings), `.gitignore` (incl. `.env`), `.gitattributes`, `.env.example`, `README.md`
- [x] Local services without Docker: LiveKit server v1.13.11 as a plain program (using `livekit.yaml`, webhook → `http://localhost:8001/livekit/webhook`) + locally installed PostgreSQL (port 5432, user `livekit_meetings`, databases `livekit_meetings` + `livekit_meetings_test`)
- [x] `app/main.py` with lifespan; `app/core/config.py` (pydantic settings), `db.py`, `logging.py`, `errors.py`
- [x] `GET /health` (checks DB + LiveKit reachability)
- [x] Alembic initialised
- [x] No CI (gitlab.rhysley.com has no runner set up for it): ruff + pytest run locally before every push, results in the merge request description
- [x] README: local run steps

## HEL-1044: Secure access, organisation isolation, embedded interface (branch `feature/HEL-1044-access-and-isolation`)

The meeting module stays product-neutral: it knows only product → organisation → user. Everything HelixtaHR-specific (token format, company, CRM-based enablement, its APIs) lives in `app/integrations/helixtahr/`; a test fails if the rest of `app/` mentions it. Company enablement comes from HelixtaHR (set in the CRM), not from our own allowlist. Three merge requests.

**MR 1: who is calling, and is their organisation enabled**

Step 1: verify the token
- [x] `core/principal.py`: neutral `Principal(product, org_id, user_id, name, is_org_admin)`
- [x] `integrations/base.py`: `ProductAdapter` interface + `InvalidTokenError`
- [x] `integrations/registry.py`: token issuer (`iss`) → adapter
- [x] `integrations/installed.py`: product name → adapter factory; `ENABLED_PRODUCTS` picks which ones run
- [x] `integrations/helixtahr/`: own settings (`HELIXTAHR_*`); verify `meeting_jwt_token`: HS256 only; signature, `iss`, `exp`, `nbf` (30 s leeway); require `sub`, `company_id`, `name`; `token_type` must be `meeting` (keeps the Helix AI token out); `company_id` → org_id; `role_name` admin/superadmin → org admin; `role_id` ignored
- [x] Tests: token rules, registry, product-neutral core guard
- [x] Real test-server secret in `.env` (from Surinder); real test token (user 674, org 26) verified, tampered copy refused (3 Nov)

Step 2: is Meetings allowed for the organisation
- [x] Adapter asks HelixtaHR `GET /api/v1/meetings/allowed` (user's own token, 3 s timeout) → `{"meetings_allowed": bool}`; HelixtaHR 401/403 → not signed in; no answer / odd answer → unavailable (never a yes)
- [x] `core/cache.py`: in-memory TTL cache, 60 s per organisation; `fresh=True` (Join now, re-check) always asks again (Redis only when several backend copies run)
- [x] HelixtaHR unreachable on ordinary requests → 503 "Meetings are temporarily unavailable". Behaviour for people already in a meeting (decision 8) belongs to the re-check, parked

Step 3: one shared check on every endpoint
- [x] `access/deps.py`: `current_principal`; token only from `Authorization: Bearer`; missing/invalid → 401 `not_signed_in` (frontend redirects to sign-in); not allowed → 403 "You don't have access to Meetings"
- [x] `GET /v1/me`: who you are + org admin flag
- [x] Test: every endpoint except `/health` requires the check (via OpenAPI security)
- [x] Checked against the real test server (3 Nov): Akash's token → 200 with his details

Step 3b: our own short-lived session token (updated story, BR-35.2; decision 19)
- [x] `POST /v1/session`: the only endpoint taking the product token; checks it and asks the product fresh; returns our session token (`SESSION_TTL_SECONDS`, default 900), signed with our own `SESSION_SECRET`
- [x] Every other endpoint takes only the session token; the product token stays on the server (memory) for later checks, never logged or returned; restart → sign in again
- [x] Tests + real test server: session issued for Akash, `/v1/me` works with it, HelixtaHR token refused there

**MR 2: organisation isolation, audit, LiveKit passes**
- [x] Every table carries `product` + `org_id`; query helpers always filter by the caller's organisation (built in HEL-972)
- [x] Meeting IDs random and non-sequential (HEL-972)
- [x] `audit_records` table + migration (built in HEL-973)
- [x] Tests: org A can never read or act on org B (AC-35.4) (HEL-972, HEL-973)
- [x] `livekit/adapter.py`: `make_pass` + `create_room` (HEL-973); `delete_room`, `remove_participant`, `connected_identities` (HEL-977, 1026); `mute_microphone` (HEL-983). Original plan: `make_pass` (identity `{product}:{org}:{user}:{device}`, 10-min TTL, camera + microphone; screen_share only when granted), `create_room`, `end_room`, `remove_participant`, `list_participants`; fake adapter to match
- [x] Webhook receiver: built in HEL-977

**MR 3: removal within 5 minutes** (no HelixtaHR notices in Phase 1; decided 3 Nov). Built on `feature/HEL-977-join-checks` (it needs HEL-977's tracking of who is connected and waiting)
- [x] Re-check job (`app/revocation/recheck.py`) every `RECHECK_INTERVAL_SECONDS` (default and maximum 240): everyone with a device connected (or about to connect) or a request waiting is asked about with their own latest product token (the session service keeps each person's latest), fresh, 8 at a time
- [x] HelixtaHR refuses the token (401) → removed: LiveKit disconnects their devices and refuses their earlier passes (`revoke_token_ts`), waiting request closed, member `removed_at`, audit `participant_removed` (actor "system"); the screen shows "You've been removed from the meeting" on LiveKit's removed reason; their place frees at once; rejoining is refused (fresh check) and a revoked device reconnecting is removed again by the webhook; access restored → fresh admission
- [x] Role from `role_name` in the `/allowed` answer: each new session takes the current role (org admin or not), so role changes show up within 15 minutes
- [x] HelixtaHR unreachable never removes anyone; re-checked next round; new joins refused (503) and alert (decision 8)
- [x] Company switched off mid-meeting: running meetings go on, nothing new starts (decision 8a)
- [x] No token held (e.g. after a restart): kept and logged until their next session
- [x] Each round corrects our devices against LiveKit's participant list (a lost "left" webhook no longer holds a place)
- [x] 17 tests; each rule above proven by removing it; LiveKit calls checked against the local server

**Done when**
- [x] AC-35.1 to AC-35.6 pass as amended (backend; see STORIES.md)
- [x] Tests for token rules, isolation, enablement, revocation fallback
- [ ] Deepak's story-level QA approval recorded in Jira

## HEL-972: View the Meetings page

Branch `feature/HEL-972-meetings-page`. Also builds HEL-1044's organisation isolation (first tables).

**Data (first Alembic migration)**
- [x] `meetings`: random public `id` (in the link, e.g. `k3x9-p2mq-7ad`), `product`, `org_id`, title, kind (normal / permanent room), room name (unique per product + organisation), owner, current Host id + name, status (active / ended / disabled), created/ended times, grace period (default 120 s); database rules: rooms have a name, ended needs an end time, rooms never "end"
- [x] `meeting_members`: meeting, user id + name, role (host / co-host / standing co-host / participant; "invited" later), first admitted time, removed / blocked times
- [x] Migration `c7a1a958db73`; tests migrate the test database down and up on every run
- [x] Organisation isolation: `app/core/org_scope.py` (`OrgScoped` columns + `in_org` filter) used by every query; test that org A / another product never sees org B, proven to fail without the filter (AC-01.5, AC-35.4)

**API**
- [x] `GET /v1/meetings`: Permanent Meeting Rooms the user owns or is a standing Co-host of (not disabled, also between sessions), by name; then not-ended normal meetings the user hosts, co-hosts or has joined (admitted at least once; blocked hidden, removed kept), newest first. Row: id, title, Host name, kind, room name
- [x] `GET /v1/meetings/history`: ended normal meetings the user hosted, co-hosted or joined, newest first, 20 per page with a cursor (US-30); rows: id, title, Host, started, ended
- [x] Empty list → frontend shows "No meetings yet" with Start meeting (AC-01.1)
- [x] Checked live as the test user (3 Nov): both endpoints return empty lists
- [x] Company not enabled → "You don't have access to Meetings" (HEL-1044 gate)
- [x] Settings tab only for org admins (`GET /v1/me`, HEL-1044)
- [x] "Invited" = opened the meeting's link at least once (`link_opened_at`); then on the list until it ends, then in history
- [x] `GET /v1/meetings/{id}`: open by link for pre-join (never admits). Checks in HEL-977 order: other organisation → 403 "You can't join this meeting. Your {product} account doesn't have access to this meeting." (product name from the adapter); unknown / ended / disabled → 404 "This meeting link is no longer valid" (AC-01.3); blocked → 403 (wording to confirm with HEL-985)
- [ ] Permanent Meeting Room links by room name: with the conversion story. The backend stays product-neutral (room name unique per organisation; the frontend builds the URL, with or without a company part)
- [ ] Frontend (HelixtaHR team): Meetings page per Ishank's prototype; builds links from id / room name
- [ ] AC-01.1 to AC-01.12 pass

## HEL-973: Start an instant meeting

Branch `feature/HEL-973-instant-meeting`, cut from `feature/HEL-972-meetings-page` (972 and 973 stay out of `phase-1` until frontend development begins). Also delivers HEL-1044's audit records and LiveKit meeting pass.

**Data**
- [x] `meetings.host_joined_at`: the Host's first Join now (null = started, not joined yet)
- [x] `audit_records`: product, organisation, action (`meeting_created`), meeting, actor id + name, time, details (no foreign key, so records outlive meetings)
- [x] Database guard: at most one not-yet-joined meeting per Host (double-click safe; proven by removing the code check)
- [x] Migration `5e2b9d4f1a37` (applied to the local database)

**API**
- [x] `POST /v1/meetings` (Start meeting): creates "Instant meeting", caller = Host, active and listed; 201; returns the caller's existing not-yet-joined meeting instead if there is one (200, AC-02.5); database or LiveKit down → 503 "Meetings are temporarily unavailable" (AC-02.3); company without Meetings refused by the gate (AC-02.4)
- [x] `POST /v1/meetings/{id}/join` (Join now) with `device_id`: fresh Meetings-allowed check; Host and Co-hosts only; everyone else 403 `admission_required` until the waiting room (HEL-977); first time → `host_joined_at` + audit record in one transaction (AC-02.6, BR-02.5); LiveKit room created; Host pass (identity product:org:user:device, ~10 min to connect, camera + microphone, mic on); LiveKit down → 503, no audit
- [x] Never joined within 10 minutes of Start → forgotten (deleted: off the list, never in history, link invalid), no audit (AC-02.7). Background job every 60 s, plus at Start for the caller; Join now is refused on time even before the job runs
- [x] LiveKit adapter: `create_room` (25 devices, waits 600 s for the first, stays 120 s after the last leaves), `make_pass`; fake adapter to match; checked against the local LiveKit server (3 Nov)
- [x] Database or LiveKit unreachable → 503 "Meetings are temporarily unavailable" (any endpoint for the database)
- [ ] Frontend (HelixtaHR team): ready panel with link from `id`, Copy link + confirmation, note "Only the Host and Co-hosts join directly. Others wait until you or a Co-host admit them."; device ID per tab in sessionStorage
- [ ] AC-02.1 to AC-02.8 (AC-02.8 waiting itself: HEL-977)

## HEL-975: Allow camera and microphone (mostly frontend)

Closed 3 Nov, no backend change: Join now never sees the browser's devices, so joining without camera and mic follows admission like any join.

- [x] AC-04.8 Join without camera and mic follows admission (`test_others_wait_for_admission`)
- [x] Reload after allowing: 2-minute reconnection, muted (`test_dropped_participant_comes_back_within_2_minutes_without_waiting`, `test_participant_who_left_can_come_back_within_2_minutes`)
- [ ] Frontend (Deepak): permission states (waiting, allowed, blocked: camera, mic or both); three steps per browser (Ishank's wording) for desktop Chrome, Edge, Firefox, Safari, iPhone Safari, Android Chrome; retry; Join without camera and mic; "No camera/microphone detected" vs "can't be used right now"; no consent screen

## HEL-976: Complete the pre-join check and join

Closed 3 Nov: backend built by HEL-973 / HEL-977; two assertions added on `feature/HEL-975-976-prejoin`.

- [x] AC-05.6 / 05.9 Host and Co-hosts direct, everyone else waits (`test_host_join_now_gives_a_meeting_pass_with_mic_on`, `test_others_wait_for_admission`, `test_session_admission`)
- [x] AC-05.10 mic: Host first join on; standing Co-host first join on (new assertion in `test_session_admission`); admitted participants off (`test_admitted_person_joins_with_the_mic_off`); any return muted, Co-hosts too (new assertion in `test_co_host_keeps_the_role_and_rejoins_directly`), Host too (`test_host_coming_back_is_muted`). Both new assertions proven by breaking the rule
- [x] AC-05.11 people waiting get no meeting pass, so nothing reaches LiveKit (`test_others_wait_for_admission`)
- [x] Display name: the pass carries the HelixtaHR name from the token (`test_host_join_now_gives_a_meeting_pass_with_mic_on`)
- [x] Speaker selection where unsupported: decided (hide the list, default speaker)
- [ ] Frontend (Deepak): pre-join (name, title, preview, level meter, camera/mic/speaker lists carried in, Test speaker, toggles, "You can still join with your camera off"); speaker list hidden where unsupported; connect with `microphone_on`; waiting screen without connecting; keyboard and screen-reader labels
- [ ] QA: 1-second visibility benchmark (with HEL-980's)

## HEL-977: Pass access and capacity checks when joining (+ backend of HEL-982 admit/deny, HEL-981 Co-hosts)

Branch `feature/HEL-977-join-checks`, cut from `feature/HEL-973-instant-meeting` (stays out of `phase-1` until frontend development begins). Already in place from earlier stories: sign-in redirect (401), other organisation, invalid link, expired token, fresh access check, "temporarily unavailable".

**Data (migration)**
- [x] `meeting_devices`: one row per LiveKit identity (person + device) in a meeting: user, device, pass issued, connected, left (from LiveKit webhooks)
- [x] `admission_requests`: meeting, person, status (waiting / admitted / denied / left), asked, last seen, decided by + when; one open request per person per meeting
- [x] `meeting_members`: `first_admitted_at` on admit; role `co_host` on appointment

**Joining (HEL-977)**
- [x] Places in use = connected + passes not yet connected + held places (Host/Co-hosts: whole meeting; admitted participants: 2 minutes); decided under a lock on the meeting
- [x] Join now: Host/Co-host → direct (held place); admitted participant within the hold → direct, muted, no re-admission (AC-06.9); everyone else: full → 409 `meeting_full` "This meeting is full. The meeting has reached its limit of 25 participants." (AC-06.3), else waiting (202)
- [x] Mic on only on a Host's/Co-host's first connection; admitted and every return muted
- [x] Pass carries the role as a LiveKit attribute (labels in People); attendance groups devices by person (identity prefix)
- [x] `POST /livekit/webhook`: verified with our LiveKit key; participant joined/left → `meeting_devices` (an old connection leaving after a reconnect is ignored); local `livekit.yaml` and run commands → port 8001; real LiveKit's signed webhooks accepted (3 Nov)
- [x] Alerts: `app/core/alerts.py`, logged `ALERT` lines (rate-limited) for LiveKit / database / HelixtaHR unavailable; email channel: Mukesh

**Waiting room and admission (HEL-982 backend)**
- [x] `GET /v1/meetings/{id}/admission`: the waiting screen's status (waiting / admitted / denied), every ~3 s; `DELETE` = leave the waiting room; not seen for 30 s → gone
- [x] `GET /v1/meetings/{id}/waiting`: Host/Co-hosts only, oldest first (ordinary participants refused, AC-10.5)
- [x] `POST /v1/meetings/{id}/waiting/{request}/admit`: place check → 409 `meeting_full` (AC-10.6); admitted → their next Join now gets the pass, mic off (AC-10.2)
- [x] `POST /v1/meetings/{id}/waiting/{request}/deny`: "Your request to join was declined"; Join now again = new request (AC-10.3)
- [x] LiveKit data message `meetings.waiting` to Host/Co-host devices on each change (AC-10.1)
- [x] Audit: `participant_admitted`, `participant_denied`

**Co-hosts (HEL-981 backend)**
- [x] `POST /v1/meetings/{id}/co-hosts/{user_id}`: Host only (Co-hosts refused, AC-09.6); target admitted to this meeting (AC-09.3); several allowed; role kept on rejoin (AC-09.5); label via LiveKit attribute on their devices (AC-09.4); audit `co_host_appointed`

- [x] Migration `8d41c6e2b5f0` (applied locally); 244 tests; the lock, held places, 2-minute hold, host-only actions, muted returns, full at join and the webhook connection check each proven by removing them

**Not here:** Acting Host (HEL-1026), mute / remove / block (HEL-983/984/985), End Meeting and auto-end (HEL-1026). The 4-minute re-check (HEL-1044 MR 3) is built on this branch too
- [ ] Frontend: waiting screen, People > Waiting to join, pop-up/badge, Make Co-host (HelixtaHR team)
- [ ] AC-06.1 to AC-06.13, AC-10.1 to AC-10.9, AC-09.1/3/4/5/6 (backend part)

## HEL-979: Share the meeting link

No new backend code: every rule was built by earlier stories. Branch `feature/HEL-979-share-link` (docs only).

- [x] Copy joining link: the frontend builds it from `GET /v1/meetings/{id}`; a room session gives `kind: permanent_room` + `room_name`, so it copies the room's address (`test_opening_a_room_session_does_not_put_the_room_on_the_list`, `test_both_addresses_work_until_the_converting_session_ends`)
- [x] Opening a link never bypasses admission, forwarded or not, employees included (AC-07.3/07.4: `test_others_wait_for_admission` both ways, `test_colleague_waits_and_hosts_are_told`, `test_session_admission`)
- [x] Ended / turned-off links: "This meeting link is no longer valid", no details (AC-07.5: `test_ended_meeting_link_is_no_longer_valid`, `test_leave_end.py`, `test_rooms.py`)
- [x] Ready-panel note: new wording in DECISIONS.md and Frontend Integration Guide v1.8, section 9
- [ ] Frontend (Deepak): ready panel with the new note; Meeting details > Copy joining link (room address in a room session); not on the waiting screen

## HEL-980: Conduct the meeting (backend part)

Branch `feature/HEL-980-conduct-meeting`, cut from `feature/HEL-1136-permanent-rooms` (stacked). Audio/video toggles, the People panel and device choice are LiveKit + frontend; the backend gives the share rules, chat permission and labels.

**Data (migration)**
- [x] `meeting_shares` (one row per meeting): presenter (identity, user, since), waiting request (identity, user, name, since)

**Screen sharing (one at a time, granted by the backend)**
- [x] `POST /v1/meetings/{id}/share` `{device_id}`: nobody sharing → this device may publish `screen_share` (+ its audio), 200 `sharing`; someone sharing, nobody waiting → 202 `waiting`, the presenter and Host / Co-hosts / Acting Host told (AC-08.7); someone already waiting → 409 "Someone is already waiting to share." (AC-08.12)
- [x] `DELETE /v1/meetings/{id}/share/request`: B cancels (AC-08.13)
- [x] `POST /v1/meetings/{id}/share/stop`: the presenter stops; `POST …/share/stop-presenter`: Host, Co-host or Acting Host stops anyone's (AC-08.9; others 403, AC-08.10; audit `share_stopped_by_host`)
- [x] Any end of the share (stop, host stop, presenter drops or unpublishes, from LiveKit's webhook) → the waiting B gets the permission and goes live automatically (AC-08.8, AC-08.13); B drops → the request closes; the meeting ends → both close
- [x] `GET /v1/meetings/{id}/share`: who is sharing and who is waiting (for screens joining mid-meeting); data message `meetings.share` to everyone on every change
- [x] LiveKit adapter: set a device's publish permissions (full set each time: LiveKit replaces them); webhook reads the track source
- [ ] Phones: the Share button is hidden on mobile (decided, E5): frontend only

**Chat**
- [x] Meeting passes allow data messages (chat goes participant to participant through LiveKit, never through or stored by us; AC-08.5, AC-08.6, AC-08.11)

**People panel**
- [x] `role` and `user_id` attributes on every pass (HEL-977): labels and one entry per person (AC-08.4)

- [x] Migration `b2d8f61a4c97` (applied locally); 16 sharing tests + adapter test; seven rules proven by removing them (one waiting request, the waiting one goes live, only hosts stop others, the permission is taken away, a presenter dropping ends it, only the screen track counts, the meeting end clears it); LiveKit accepts the permission update (3 Nov). Still to see with a real browser: LiveKit stopping the screen track when the permission is taken away
- [ ] Frontend (Deepak): grid, toggles, share button and request flow (prepare B's share on click, publish when granted), chat panel with the "not saved" note, People panel; trust system topics only from the server
- [ ] QA benchmarks (1 / 2 / 3 s) with Deepak Srivastava
- [ ] AC-08.7 to AC-08.13 (backend part); AC-08.1 to 08.6 with the frontend

## HEL-1032: Raise a hand and send a reaction (no blur)

Branch `feature/HEL-1032-hands-reactions`, cut from `feature/HEL-980-conduct-meeting` (stacked). No database change: hands live only in LiveKit while the meeting runs (BR-28.3).

- [x] `POST /v1/meetings/{id}/hand` `{device_id, raised}`: anyone in the meeting raises or lowers their own hand; the backend sets or clears the device's `hand_raised_at` attribute (AC-28.1); no message to everyone (AC-28.6, BR-28.4)
- [x] `POST /v1/meetings/{id}/hands/{user_id}/lower`: Host, Co-host, standing Co-host or Acting Host lowers anyone's hand (AC-28.3); others 403 (AC-28.7); clears it on all that person's devices; data message `meetings.hand_lowered` to that person (they see a message, the icon disappears)
- [x] Raised hands list earliest first from `hand_raised_at`; the hosts' indicator also while presenting (AC-28.4): frontend, from the attributes
- [x] The hand goes when its device leaves (AC-28.5): LiveKit drops the participant and its attributes
- [x] Reactions (Thumbs up, Clap, Heart, Laugh, Wow): LiveKit data messages between participants, shown briefly, never stored (AC-28.2): frontend only
- [x] 8 tests; four rules proven by removing them (only hosts lower others, all of the person's devices, no message when raising, the device must be in the meeting); no database change
- [ ] Frontend (Deepak): raise-hand button, Raised hands (n) in People, hosts' indicator and lower control, reaction tray
- [ ] AC-28.1 to AC-28.7

## HEL-1026: Leave Meeting or End Meeting

Branch `feature/HEL-1026-leave-end`, cut from `feature/HEL-977-join-checks` (stacked; out of `phase-1` until frontend development begins). Normal meetings only: Permanent Meeting Room parts (AC-24.3, AC-24.10) come with HEL-1136; the Acting Host after an unexpected drop with HEL-1012.

**Leave (Leave meeting)**
- [x] `POST /v1/meetings/{id}/leave` `{ device_id, new_host_user_id?, end_meeting? }`; the screen calls it, then disconnects. Anyone may leave. Not the last Host/Co-host → that device leaves (an admitted participant keeps 2 minutes to come back; a Host who leaves while a Co-host stays is still Host)
- [x] Last Host/Co-host connected, nothing chosen → 409 `handover_required` with the candidates (admitted people connected now); the meeting is never ended by accident
- [x] `new_host_user_id` → handover if that person is still connected (else 409 `new_host_gone`, choose again, AC-24.13): they become Host (meeting Host name changes), the leaver becomes Co-host (BR-24.6), LiveKit role labels updated, data message `meetings.host_changed` to everyone (notice, AC-24.15), audit `host_handed_over`
- [x] `end_for_everyone: true` (also when there is nobody to choose, after the warning) → the meeting ends for everyone (AC-24.7, AC-24.17)
- [x] `GET /v1/meetings/{id}/handover-candidates` for the "Choose a new Host" list

**End (End meeting)**
- [x] `POST /v1/meetings/{id}/end`: Host and Co-hosts (Acting Host with HEL-1012); others 403 (AC-24.4)
- [x] Ending (also last-Host-leaves and auto-end): status ended + end time (link "no longer valid", into history); LiveKit room deleted → everyone disconnected → "The meeting has ended" with Back to Meetings (AC-24.2, AC-24.16); waiting requests closed → their screen shows "The meeting has ended" (AC-24.9); audit `meeting_ended` with the reason
- [x] Leave and End work for a switched-off company and while HelixtaHR is down (session check only: they only take access away)

**Auto-end**
- [x] LiveKit "room finished" webhook (empty room for 2 minutes, `departure_timeout` 120) → the meeting ends (reason `nobody_connected`)

**Records**
- [x] Audit per webhook event: `participant_connected`, `participant_reconnected`, `participant_left` (person, device); with the existing ones this covers creation, end, attendance, joins/leaves/reconnections, Host and Co-host changes, admission, denial, removal and link termination. Organisation settings / retention: with the Settings story

- [x] 23 tests; nine rules proven by removing them (never ended by accident, own device not another manager, Host stays Host with a Co-host, End refused for participants, chosen must be in the meeting, auto-end once, leave recorded once, a second device isn't a reconnect, Leave/End without the product check); LiveKit room closing and broadcast checked against the local server
- [ ] Frontend (Deepak): Leave or end dialog (Leave only for participants), Choose a new Host with "leave without choosing" warning, handover notice, "The meeting has ended"
- [ ] AC-24.1, 24.2, 24.4 to 24.9, 24.11 to 24.17 (backend part); AC-24.3 / 24.10 with HEL-1136; Acting Host parts with HEL-1012

## HEL-1012: Acting Host after an unexpected drop

Branch `feature/HEL-1012-acting-host`, cut from `feature/HEL-1026-leave-end` (stacked). Normal meetings only (rooms never have an Acting Host, HEL-1136).

**Data**
- [x] Member role `acting_host` (migration `3b7f0d9c2e14` widens the role check; applied locally)

**Unexpected drop** (LiveKit "left" not preceded by Leave; also a removal by the re-check and a late-noticed leave)
- [x] Host drops, another Host/Co-host connected → they manage; data message `meetings.manager_changed` naming them (several Co-hosts → "the Co-hosts are managing", BR-22.4) (AC-22.1)
- [x] The last Host/Co-host (or the Acting Host) drops → the admitted person connected longest in their current connection, not waiting, not blocked or removed, becomes Acting Host (AC-22.2, AC-22.3, BR-22.1/22.2/22.6); the previous Acting Host goes back to participant; role label updated; notice naming them; waiting alert to them (AC-22.5); audit `acting_host_appointed`
- [x] Nobody eligible → nothing; the 2-minute auto-end (HEL-1026) ends it (AC-22.4)
- [x] Leave on purpose never promotes (AC-22.8, BR-22.5)

**Coming back**
- [x] A Host or Co-host connects while an Acting Host manages → the Acting Host goes back to participant; notice naming the returning manager; audit `acting_host_ended` (AC-22.7)

**Acting Host rights** (BR-22.3)
- [x] Waiting list, Admit, Deny, End meeting, handover candidates: Host, Co-host, Acting Host
- [x] Not: Make Co-host (already Host only), converting to a room (HEL-1136)
- [x] Acting Host leaving on purpose as the last one managing: choose-or-end; the chosen person becomes the next Acting Host; the Host role stays with the original Host (BR-24.9, AC-24.14)
- [x] Mute, remove, stop shares: with their own stories (HEL-983, 984, 980): the Acting Host can do all three

- [x] 16 tests; six rules proven by removing them (present longest wins, a Co-host carries on, Leave is not a drop, a Co-host coming back takes over, an Acting Host passes on only the acting role, the Acting Host gets the waiting alerts)
- [ ] Frontend (Deepak): the pop-up from `meetings.manager_changed`; host controls for `role` = `acting_host` (no "Acting Host" label in People, as decided for HEL-980)
- [ ] AC-22.1 to AC-22.5, AC-22.7, AC-22.8; AC-22.6 with HEL-1136

## HEL-1136: Permanent Meeting Rooms

Branch `feature/HEL-1136-permanent-rooms`, cut from `feature/HEL-1012-acting-host` (stacked). Settings live in an in-meeting Meeting Settings panel for the original Host (not the org-admin Settings tab).

**Data (migration)**
- [x] `rooms`: id, product, org, owner (id + name), name, URL, grace period (2 to 30 min, default 2), status (active / disabled), turn off after this session, created from meeting, disabled at
- [x] `room_standing_co_hosts`: room, user id + name
- [x] `meetings.room_id`; meeting kinds `normal` and `room_session` (replaces HEL-972's `permanent` kind and `room_name` on meetings); each session is a meeting row
- [x] URL rules in the database where possible: unique per product + organisation among active rooms; the 90-day rule in code under a lock

**Convert (original Host only)**
- [x] `GET /v1/room-urls/{url}/available`: format (3 to 30, a-z 0-9 -), reserved/offensive, used in the organisation, disabled < 90 days
- [x] `POST /v1/meetings/{id}/room` `{name, url, grace_minutes, standing_co_host_ids}`: owner of a running normal meeting only (Co-host / Acting Host 403, AC-39.2); once only (AC-39.28); standing Co-hosts admitted to this meeting; one transaction (AC-39.25); URL taken meanwhile → 409 "This URL was just taken" (AC-39.26); the meeting becomes session 1 without interruption (AC-39.1); audit `room_created`

**The room**
- [x] `GET /v1/rooms/{org}/{url}`: another organisation → "You can't join this meeting"; unknown / disabled → "This meeting link is no longer valid"; no session → `not_started` ("The host hasn't started this meeting yet", the screen asks again every few seconds, AC-39.12); a session → its meeting id for pre-join and Join now (admission as usual, AC-39.13)
- [x] `POST /v1/rooms/{org}/{url}/start`: owner or standing Co-host only (AC-39.10, AC-39.11, AC-39.22); a running session is returned instead of a second one; audit `room_session_started`
- [x] Meetings page: one row per room for the owner and standing Co-hosts (AC-39.9, AC-39.19); sessions don't add rows
- [x] History: each ended session (AC-39.19)

**Sessions**
- [x] Owner = Host of every session; standing Co-hosts join as Co-hosts; both join directly with places held for the whole session (BR-39.15, AC-39.23); session Co-hosts can end but not start
- [x] No Acting Host, no handover on Leave (BR-39.6)
- [x] Grace period: no owner or standing Co-host connected for the room's grace period → the session ends (AC-39.14), checked by a background job
- [x] End meeting / auto-end → ends the session only; the room and URL stay (AC-39.15); after the converting session ends, its temporary link is "no longer valid" (AC-39.8)

**Settings (owner, only during a session; AC-39.21)**
- [x] `PATCH /v1/rooms/{room_id}`: name, URL, grace period, standing Co-hosts (removal takes effect at once, AC-39.27), turn off (takes effect when the session ends, AC-39.16, AC-39.24); standing Co-host → 403 (AC-39.20); audit `room_settings_changed`, `standing_co_host_added` / `removed`, `room_disabled`

**Owner loses access**
- [x] The re-check refuses the owner (401) → their rooms are disabled (a live session finishes first); standing Co-hosts don't inherit (AC-39.17); audit `room_disabled`

- [x] Migration `9c4e2a7b6d51` (applied locally; replaces HEL-972's room fields on meetings); 39 tests; ten rules proven by removing them (original Host only, once only, 90-day block, who may start, a standing Co-host present keeps it going, turning off after the session, standing removal at once, settings only in a session, no handover in a room, owner losing access)
- [ ] Frontend (Deepak): Meeting Settings > Permanent Meeting Room (original Host only), URL availability, Start on the room row, the not-started screen, `/meet/<org>/<url>` routes
- [ ] AC-39.1 to AC-39.28

## HEL-983 / HEL-984: Mute, Mute all, Remove (Harsh's stories; backend built by us)

Branch `feature/HEL-983-984-host-controls`, cut from `feature/HEL-975-976-prejoin`. HEL-985 (Remove and Block) is Phase 2.

- [x] Who on whom: managers only (`not_a_host`); the Host on anyone but themselves; Co-hosts, standing Co-hosts, Acting Host on ordinary participants only (`not_allowed`)
- [x] `POST /v1/meetings/{id}/participants/{user_id}/mute`: LiveKit server mute of the microphone on all their devices; `meetings.muted` notice; audit `participant_muted`; mic already off → nothing
- [x] `POST /v1/meetings/{id}/mute-all` → `{muted}`: spares the caller, the Host and every Co-host; one-time; audit `all_muted`
- [x] `POST /v1/meetings/{id}/participants/{user_id}/remove`: all devices disconnected, passes refused, place freed; fresh admission to come back; a removed Co-host loses the role; audit with the host as actor; LiveKit down → nothing saved (503)
- [x] Shared removal code with the access re-check (`app/meetings/removal.py`)
- [x] 28 tests (plus 2 adapter tests); each rule broken on purpose made a test fail (13 checks); 403 tests pass
- [ ] Real-browser check: after a server mute, the person can unmute themselves (LiveKit allows the publisher to unmute; confirm with a browser)
- [ ] Frontend (Deepak): People > more menu > Mute / Remove (only what the caller may do), Mute all, the muted notice, "You were removed from this meeting" + Back to Meetings
- [ ] AC-11.1 to 11.6, AC-12.1 to 12.4 with the frontend

## HEL-1043 (on hold)

Do not start until it is resubmitted. Expected Phase 1 scope: only the module APIs the interface uses + HelixtaHR H1–H3. Guest invitations, scheduling, interviews, webhooks to HelixtaHR → Phase 2.

## Definition of done (every story)

- Rules implemented in the backend with tests; audit records for every action
- Messages match STORIES.md exactly
- Merge request reviewed by Amit; `pytest`, `ruff check .` and `ruff format --check .` pass locally (no CI)
- Jira updated; Deepak's story-level QA approval recorded
