{"id":"6a8eb913fe2cff41fb56cc40","title":"2026 08 26 Video Meetings Architecture","path":"how-to/technical-articles/2026-08-26-video-meetings-architecture","contentMarkdown":"# 26-Aug-2026 — Video Meetings: One Jitsi Server for the Whole Estate\n\n_____\n\n_Updated 3 September 2026 to cover the pre-join sound check, the in-call Sound panel, named guest links, calendar replies, recorded transcripts with named speakers, and staying in the call._\n\n## Background — why build video meetings into a walking group's website?\n\nCommittee meetings are where a walking group actually runs itself: the walks programme gets agreed, the accounts get reviewed, the AGM gets planned. Until now, holding one online meant somebody's personal Zoom or Teams account, a join link pasted into an email chain, and minutes typed up afterwards from memory. [Issue #357](https://github.com/nbarrett/ngx-ramblers/issues/357) set out to fold the whole lifecycle into NGX-Ramblers itself: plan the meeting on the site's calendar, send a proper calendar invite, join in the browser with no account, and come away with notes.\n\nThe design goals were firm from the start:\n\n- **No accounts for anyone.** Members join under their existing site login; guests join from an emailed link. Nobody creates a video-conferencing account.\n- **Committee members are moderators automatically**, derived from the roles the site already knows about.\n- **Nothing is recorded or stored** by a third party. The media server is either Jitsi's public instance or hardware we control.\n- **Near-zero cost.** One small server for every hosted group, not one per group, and it must not need babysitting.\n\n[Jitsi Meet](https://jitsi.org/) — open source, browser-native WebRTC, embeddable via its iframe External API — fits all four. The interesting work was everything around it: where calls run, who gets in, how invites travel, and how one container serves every site.\n\n## Where a call runs — one toggle, three modes\n\nNGX resolves the Jitsi host through a single configuration value, the same pattern the integration worker uses for its own URL. The resolution order is environment variable, then the global Video Meetings config, then a public default ([video-meetings-config.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/video-meetings-config.ts)):\n\n| Mode | What is set | Where the call runs |\n| --- | --- | --- |\n| Default | nothing | Jitsi's public `meet.jit.si` page |\n| Local development | `JITSI_HOST_URL=https://localhost:8443` | a Docker stack on the developer's machine |\n| Self-hosted | host URL in Global [Settings → Video Meetings](https://ngx-ramblers.org.uk/admin/settings \"{{siteUrl}}\") | the estate's own `ngx-ramblers-jitsi` app on Fly |\n\nThe public default exists so the feature works with zero setup, but it comes with a catch: `meet.jit.si` no longer allows production embedding — an iframe call is treated as a demo and ended after five minutes. So when the resolved host is a public Jitsi domain, NGX opens the room on Jitsi's own page instead (unlimited, but their site and their Google/GitHub sign-in to start a room, and the UI warns about exactly that). When the host is ours, NGX embeds the call in the site via the iframe External API and the experience becomes seamless: member identity, no third-party page, no time limit.\n\nTwo derived flags drive everything downstream: `publicHost` (is the resolved host a public Jitsi domain?) and `jwtRequired` (JWT credentials are configured *and* the host is ours). `publicHost:false, jwtRequired:true` is the self-hosted sweet spot — every request to join a room must carry a token the site issued.\n\n## One server, every site\n\nEvery hosted group site in the estate is its own Fly app with its own database. Video meetings deliberately break that one-per-group pattern: there is a **single** Jitsi app for the whole estate, and every site points at it. Meetings are occasional and short — one committee call per group per month or two — so one modest machine covers everyone, and there is one thing to deploy and operate rather than one per group.\n\nThe split between what is global and what is per-site is strict. The host URL, JWT app id, room prefix and behaviour toggles live once in the global Video Meetings config, read by every site. The only per-site values are cosmetic: the brand name that appears on invites and the guest instructions text.\n\n```mermaid\nflowchart TB\n    subgraph estate[\"The NGX-Ramblers estate — one Fly app per group site\"]\n        S1@{ icon: \"ngx:ramblers\", label: \"Group site\", pos: \"b\", h: 48 }\n        S2@{ icon: \"ngx:ramblers\", label: \"Group site\", pos: \"b\", h: 48 }\n        S3@{ icon: \"ngx:ramblers\", label: \"…every other site\", pos: \"b\", h: 48 }\n    end\n\n    CONFIG@{ icon: \"ngx:mongodb\", label: \"Global Video Meetings config<br/>host · JWT app id · toggles\", pos: \"b\", h: 48 }\n\n    subgraph jitsi[\"ngx-ramblers-jitsi — one shared Fly app\"]\n        JIT@{ icon: \"logos:docker-icon\", label: \"Combined Jitsi container\", pos: \"b\", h: 48 }\n    end\n\n    MEMBER@{ icon: \"ngx:user\", label: \"Member<br/>site login → JWT\", pos: \"b\", h: 48 }\n    GUEST@{ icon: \"ngx:user\", label: \"Guest<br/>emailed link → JWT\", pos: \"b\", h: 48 }\n\n    CONFIG --> S1\n    CONFIG --> S2\n    CONFIG --> S3\n    S1 -->|\"embeds call, issues tokens\"| jitsi\n    S2 --> jitsi\n    S3 --> jitsi\n    MEMBER --> S1\n    GUEST --> S1\n\n    style estate fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style jitsi fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:12,ry:12,color:#404143\n```\n\n## Identity and moderation — the site issues every ticket\n\nThe self-hosted server runs JWT-only (`AUTH_TYPE=jwt`, guests off at the server level). Jitsi itself has no user database and no sign-in page; the only way into a room is a token signed with the shared secret, and the only issuer of those tokens is the NGX server. That makes the site's existing membership system the identity provider for the video server, with no directory to sync.\n\nTwo token flavours come out of [video-meetings-controllers.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/video-meetings-controllers.ts):\n\n- **Member tokens** (4-hour expiry). Issued to any logged-in member. The token carries the member's real name — so that is the name everyone sees in the call, with no second sign-in — and a moderator flag derived from the member's committee and admin roles. Committee members can mute, remove and manage the lobby; ordinary members join as participants.\n- **Guest tokens** (12-hour expiry). Issued with **no login at all**, but only for rooms that actually exist as planned meetings — the server looks the room up against the committee-files collection and refuses to issue a token for a room nobody planned. A guest cannot conjure a working meeting link by inventing a room name, and a guest is never a moderator.\n\nThe guest join link that goes out by email is `/video-meetings/guest/<room>` on the group's own site. When the invite is sent from the planner, the link target is rewritten per recipient with their name and email as Brevo merge fields, so each invitee arrives already known: the site pre-fills their name, asks the server for a guest token in that identity, and the token carries an email-keyed occupant id so the same person joining twice is recognised and the stale seat is dropped. A plain link (copied from the room, say) works too — the visitor is asked what name to show and the browser remembers it. A logged-in member who opens a guest link is sent to the member room and keeps their own identity; the name, email and token parameters are stripped on the way.\n\nEither way there is no app to install and no account to create: check your camera and microphone on the pre-join screen, click Join, and you are in.\n\n## Joining — check your sound first\n\nThe first committee calls taught a lesson that every video product learns eventually: people could see each other but not always hear each other, and the person with the problem had no way to tell that their computer had picked the wrong microphone. The original design skipped Jitsi's pre-join page in favour of dropping people straight into the call, which removed the one screen where a level meter and a device picker live.\n\nSo joining is now a two-step affair on purpose:\n\n- **Pre-join screen.** Jitsi's own check screen, themed to the site: camera preview, microphone and camera pickers, and a Join button. The click matters as well as the check — browsers will not play sound in an embedded frame until the person has interacted with it.\n- **Sound panel in the call.** A Sound button on the toolbar opens microphone, speaker and camera pickers driven by the Jitsi External API (`getAvailableDevices`, `setAudioInputDevice` and friends), with a live level bar. The bar comes from the site itself, not Jitsi: a small Web Audio analyser on the chosen microphone, run in the parent page ([mic-level-meter.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/projects/ngx-ramblers/src/app/functions/mic-level-meter.ts)).\n- **Silent microphone warning.** If the microphone is unmuted but the meter sees nothing for ten seconds, a banner says so and opens the picker. On iPhone and iPad the meter only runs while the panel is open, to avoid competing with Jitsi for the microphone.\n\nA related trick handles the other classic failure, two devices in the same room feeding back. Each participant's page emits a short, inaudible 18.5 kHz beacon and listens for anyone else's. Hearing another device's beacon through your own microphone means you are in the same room, and the site offers to silence this device rather than let the echo build.\n\n## Staying in the call\n\nLeaving a meeting by accident used to be a single stray click: any link on the site destroyed the room component and hung up. Three things now protect a live call:\n\n- an Angular `canDeactivate` guard on both room routes, so navigating anywhere else on the site while in a call opens the same \"Leave this meeting?\" dialogue as the Leave button, with Stay as the safe choice\n- a `beforeunload` handler while in a call, so reload, back and closing the tab get the browser's own \"Leave site?\" prompt\n- a rejoin banner: the room and its return path are kept in session storage from the moment you join and cleared only when you leave on purpose, so if you do drop out, every page offers \"Rejoin meeting\", for guests as well as members\n\nFully automatic rejoin is deliberately not attempted: browsers need a gesture before they hand over the microphone and camera again, so a button is the honest option.\n\n## A meeting is one document\n\nThere is no meetings collection. A planned meeting **is** a committee file — the same document type that already holds a group's agendas and minutes — with the meeting's details on it: event date, title, agenda text, room name, format (in person, online or hybrid), venue, invitees, an optional invite note, the invitees' replies, and whether list sends go to committee role addresses. One document, so there is nothing to keep in sync.\n\nEverything else derives from that document:\n\n- the **plan calendar** and the walks calendar show it (only committee meeting file types appear, so financial statements and the like stay off the calendar)\n- the **committee documents page** lists it alongside past agendas and minutes\n- the **booked-meeting banner** reminds members of the next one\n- the **`.ics` calendar attachment** is generated from it on demand, keyed by the document's id\n- the meeting's **format** decides what the invite carries: online and hybrid meetings get a join link, in-person and hybrid meetings get a venue\n- the **Meetings page** lists recent calls, including ad-hoc \"start a meeting now\" rooms that were never planned, so a leftover call can be rejoined or deleted rather than vanishing; deleting a planned meeting names its date and time and can email the cancellation to everyone invited\n\nFile types carry their own meeting role (agenda or minutes), so meeting types are derived from the file types a group has already configured rather than a parallel taxonomy.\n\n## The invite — a real calendar invite, not a link in a paragraph\n\nPlanning a meeting composes an email to the chosen invitees — individual guests and any of the group's mailing lists — through the same Brevo transactional pipeline as every other site email. What makes it a first-class invite is the attachment: the server generates an iCalendar document at `/api/calendar/meeting/<id>.ics` with `METHOD:REQUEST`, the meeting's date and duration, the join link or venue in the description, and the committee secretary as organiser. Mail clients recognise that shape and render the real thing — Accept / Tentative / Decline buttons, a calendar preview, and one-click add-to-calendar ([calendar-controllers.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/calendar/calendar-controllers.ts)).\n\nThe planner can add a short note that goes into the email body so the title can stay short, and when every member of the chosen mailing list holds a committee role it offers to send to the committee role addresses instead of personal ones — the same rule the email composer applies.\n\nReplies come back the same way the invite went out. When an invitee clicks Accept, Tentative or Decline, their mail client sends an iCalendar `REPLY` to the organiser, which is the group's own inbox. The inbox import recognises that shape, matches it to the meeting document by id, and records the RSVP against the invitee ([apply-meeting-calendar-reply.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/apply-meeting-calendar-reply.ts)). The planner shows who has replied and how, with no polling of anyone's calendar.\n\nOne wrinkle worth recording: Brevo fetches attachments by URL, which fails for a developer's localhost and for anything it cannot reach. The attachment layer detects that case, generates the `.ics` server-side, stores it in the site's S3 email-attachments folder and hands Brevo the public URL instead — so the same plan-and-invite flow works identically in development and production.\n\n```mermaid\nflowchart LR\n    PLANNER@{ icon: \"ngx:user\", label: \"Committee member<br/>plans on calendar\", pos: \"b\", h: 48 }\n    NGX@{ icon: \"ngx:ramblers\", label: \"NGX site\", pos: \"b\", h: 48 }\n    DB@{ icon: \"ngx:mongodb\", label: \"Committee file<br/>date · agenda · room · format\", pos: \"b\", h: 48 }\n    ICS@{ icon: \"ngx:file\", label: \".ics invite<br/>METHOD:REQUEST\", pos: \"b\", h: 48 }\n    BREVO@{ icon: \"ngx:brevo\", label: \"Brevo<br/>transactional email\", pos: \"b\", h: 48 }\n    INVITEE@{ icon: \"ngx:user\", label: \"Invitee<br/>RSVP + add to calendar\", pos: \"b\", h: 48 }\n    JITSI@{ icon: \"logos:docker-icon\", label: \"Self-hosted Jitsi\", pos: \"b\", h: 48 }\n\n    PLANNER --> NGX\n    NGX --> DB\n    DB --> ICS\n    ICS --> BREVO\n    BREVO --> INVITEE\n    INVITEE -->|\"named guest link + JWT\"| JITSI\n    INVITEE -->|\"calendar REPLY via the group inbox\"| NGX\n```\n\n## Self-hosting — one container that Fly will actually run\n\nThe standard Jitsi deployment is four cooperating containers (prosody for XMPP signalling, jicofo for conference focus, jvb for the video bridge, and the web front end) orchestrated by Docker Compose. Fly's model is one container per machine, and running four machines for a service that is idle most of the month would be wasteful anyway. So the estate image is a **combined container**: it bases on the official `jitsi/prosody` image and grafts the web (nginx), jicofo and jvb services onto it, letting the standard s6 `/init` supervise all four processes in one machine, driven entirely by environment variables ([jitsi/Dockerfile](https://github.com/nbarrett/ngx-ramblers/blob/main/jitsi/Dockerfile)).\n\nNetworking is the part Jitsi is famous for getting wrong on new infrastructure, so it is worth stating what works: Fly's edge terminates TLS and forwards to nginx on port 80; media flows over UDP 10000 to the video bridge on a dedicated IPv4, with `JVB_ADVERTISE_IPS` telling the bridge what address to put in its candidates; and if a restrictive network blocks UDP, Jitsi falls back to TCP 443, which is fine for small infrequent calls.\n\nDeployment follows the same config-driven, deploy-from-admin pattern as the integration worker. [deploy-jitsi.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/deploy/deploy-jitsi.ts) reads the Video Meetings config from the database, ensures the Fly app and its dedicated IPv4 exist, injects the secrets and host-specific values (`PUBLIC_URL`, `JVB_ADVERTISE_IPS`, the JWT secret, the jicofo and jvb component passwords), then validates and deploys with flyctl and scales the memory. The staging pipeline runs it automatically — but only when image-affecting files have changed, detected by diffing against a `jitsi-deployed` ref, so an ordinary site push never rebuilds the video server.\n\n```mermaid\nflowchart TB\n    ADMIN@{ icon: \"ngx:user\", label: \"Admin<br/>Global Settings → Video Meetings\", pos: \"b\", h: 48 }\n    GHA@{ icon: \"logos:github-actions\", label: \"Deploy workflow<br/>runs only on Jitsi changes\", pos: \"b\", h: 48 }\n    DEPLOY@{ icon: \"logos:nodejs-icon\", label: \"deploy-jitsi.ts<br/>config + secrets from DB\", pos: \"b\", h: 48 }\n\n    subgraph app[\"ngx-ramblers-jitsi — one Fly machine\"]\n        NGINX[\"nginx web<br/>Fly edge TLS → :80\"]\n        PROSODY[\"prosody<br/>XMPP signalling · JWT auth\"]\n        JICOFO[\"jicofo<br/>conference focus\"]\n        JVB[\"jvb video bridge<br/>UDP 10000 · TCP 443 fallback\"]\n    end\n\n    ADMIN --> GHA\n    GHA --> DEPLOY\n    DEPLOY -->|\"flyctl deploy<br/>dedicated IPv4\"| app\n    NGINX --- PROSODY\n    PROSODY --- JICOFO\n    JICOFO --- JVB\n\n    style app fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n## Recording, transcripts and minutes\n\nNothing is recorded by the video server. Recording is a choice one member makes in the room with the Record button, and it runs in that member's browser: the microphone is captured in twenty-second chunks with echo cancellation off, so it hears everyone in the call through the speakers as well as the person at the keyboard. Each chunk is posted to the site and transcribed by a Gemini model through the estate's AI configuration ([meeting-audio-transcription.ts](https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/meeting-audio-transcription.ts)). The prompt names the person whose microphone it is and everyone else in the room, and asks for one line per utterance labelled with the speaker, so the transcript stored in the `meetingTranscript` collection carries each line under the right name rather than putting every word in the recorder's mouth. Unknown voices keep the label the model gave them, unlabelled lines fall back to the recorder, adjacent repeats are dropped, and a words-per-second check throws away chunks where the model has plainly invented more speech than the audio could hold. Only one person should record: with echo cancellation off every microphone hears every voice, so two recorders would double every line.\n\nEvery meeting room also has a shared notes panel. Notes persist to a `meetingNotes` collection tagged with their author, so they survive a reload and are there when the minutes are written. On top of that sits an AI minute-writer: given the transcript, the chat, and any hand-written notes, it drafts structured minutes attributed to the named speakers — and the hand-written notes are folded in rather than overwritten. The AI output is saved as a single note authored \"AI notes\"; writing again **replaces** that note instead of stacking duplicates. A minutes editor then lets the secretary tidy the draft and publish it as a committee document, so it sits next to the agenda on the committee documents page, and the Meetings page lists any recording that still needs its minutes written.\n\nThe generation itself runs on the estate's integration worker when one is configured, keeping model calls off the site servers; without a worker it falls back to in-process generation. The worker suspends when idle — zero running machines between jobs — which keeps the AI capability effectively free until a meeting actually uses it.\n\n## What this adds up to\n\nA committee member plans a meeting on the calendar they already use, invitees get a real calendar invite from an address they recognise and their replies flow back into the meeting record, everyone joins in a browser with no account after a quick sound check, the committee moderates by virtue of being the committee, and a transcript with named speakers, notes and minutes come out the other end — and the whole estate's video traffic runs through one small container on hardware we control, with nothing recorded and no third party in the middle. The public-Jitsi default means a brand-new group has working video meetings before any of the self-hosting is set up; the self-hosted mode is where the feature is at its best.\n\nThe user-facing walkthrough, with screenshots of planning, booking and cancelling, is in the [release note](https://ngx-ramblers.org.uk/how-to/committee/release-notes/2026-08-24).","contentHtml":"<h1>26-Aug-2026 — Video Meetings: One Jitsi Server for the Whole Estate</h1>\n<hr>\n<p><em>Updated 3 September 2026 to cover the pre-join sound check, the in-call Sound panel, named guest links, calendar replies, recorded transcripts with named speakers, and staying in the call.</em></p>\n<h2>Background — why build video meetings into a walking group&#39;s website?</h2>\n<p>Committee meetings are where a walking group actually runs itself: the walks programme gets agreed, the accounts get reviewed, the AGM gets planned. Until now, holding one online meant somebody&#39;s personal Zoom or Teams account, a join link pasted into an email chain, and minutes typed up afterwards from memory. <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/357\">Issue #357</a> set out to fold the whole lifecycle into NGX-Ramblers itself: plan the meeting on the site&#39;s calendar, send a proper calendar invite, join in the browser with no account, and come away with notes.</p>\n<p>The design goals were firm from the start:</p>\n<ul>\n<li><strong>No accounts for anyone.</strong> Members join under their existing site login; guests join from an emailed link. Nobody creates a video-conferencing account.</li>\n<li><strong>Committee members are moderators automatically</strong>, derived from the roles the site already knows about.</li>\n<li><strong>Nothing is recorded or stored</strong> by a third party. The media server is either Jitsi&#39;s public instance or hardware we control.</li>\n<li><strong>Near-zero cost.</strong> One small server for every hosted group, not one per group, and it must not need babysitting.</li>\n</ul>\n<p><a href=\"https://jitsi.org/\">Jitsi Meet</a> — open source, browser-native WebRTC, embeddable via its iframe External API — fits all four. The interesting work was everything around it: where calls run, who gets in, how invites travel, and how one container serves every site.</p>\n<h2>Where a call runs — one toggle, three modes</h2>\n<p>NGX resolves the Jitsi host through a single configuration value, the same pattern the integration worker uses for its own URL. The resolution order is environment variable, then the global Video Meetings config, then a public default (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/video-meetings-config.ts\">video-meetings-config.ts</a>):</p>\n<table>\n<thead>\n<tr>\n<th>Mode</th>\n<th>What is set</th>\n<th>Where the call runs</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>Default</td>\n<td>nothing</td>\n<td>Jitsi&#39;s public <code>meet.jit.si</code> page</td>\n</tr>\n<tr>\n<td>Local development</td>\n<td><code>JITSI_HOST_URL=https://localhost:8443</code></td>\n<td>a Docker stack on the developer&#39;s machine</td>\n</tr>\n<tr>\n<td>Self-hosted</td>\n<td>host URL in Global <a href=\"https://ngx-ramblers.org.uk/admin/settings\" title=\"{{siteUrl}}\">Settings → Video Meetings</a></td>\n<td>the estate&#39;s own <code>ngx-ramblers-jitsi</code> app on Fly</td>\n</tr>\n</tbody></table>\n<p>The public default exists so the feature works with zero setup, but it comes with a catch: <code>meet.jit.si</code> no longer allows production embedding — an iframe call is treated as a demo and ended after five minutes. So when the resolved host is a public Jitsi domain, NGX opens the room on Jitsi&#39;s own page instead (unlimited, but their site and their Google/GitHub sign-in to start a room, and the UI warns about exactly that). When the host is ours, NGX embeds the call in the site via the iframe External API and the experience becomes seamless: member identity, no third-party page, no time limit.</p>\n<p>Two derived flags drive everything downstream: <code>publicHost</code> (is the resolved host a public Jitsi domain?) and <code>jwtRequired</code> (JWT credentials are configured <em>and</em> the host is ours). <code>publicHost:false, jwtRequired:true</code> is the self-hosted sweet spot — every request to join a room must carry a token the site issued.</p>\n<h2>One server, every site</h2>\n<p>Every hosted group site in the estate is its own Fly app with its own database. Video meetings deliberately break that one-per-group pattern: there is a <strong>single</strong> Jitsi app for the whole estate, and every site points at it. Meetings are occasional and short — one committee call per group per month or two — so one modest machine covers everyone, and there is one thing to deploy and operate rather than one per group.</p>\n<p>The split between what is global and what is per-site is strict. The host URL, JWT app id, room prefix and behaviour toggles live once in the global Video Meetings config, read by every site. The only per-site values are cosmetic: the brand name that appears on invites and the guest instructions text.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    subgraph estate[&quot;The NGX-Ramblers estate — one Fly app per group site&quot;]\n        S1@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Group site&quot;, pos: &quot;b&quot;, h: 48 }\n        S2@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Group site&quot;, pos: &quot;b&quot;, h: 48 }\n        S3@{ icon: &quot;ngx:ramblers&quot;, label: &quot;…every other site&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    CONFIG@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Global Video Meetings config&lt;br/&gt;host · JWT app id · toggles&quot;, pos: &quot;b&quot;, h: 48 }\n\n    subgraph jitsi[&quot;ngx-ramblers-jitsi — one shared Fly app&quot;]\n        JIT@{ icon: &quot;logos:docker-icon&quot;, label: &quot;Combined Jitsi container&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    MEMBER@{ icon: &quot;ngx:user&quot;, label: &quot;Member&lt;br/&gt;site login → JWT&quot;, pos: &quot;b&quot;, h: 48 }\n    GUEST@{ icon: &quot;ngx:user&quot;, label: &quot;Guest&lt;br/&gt;emailed link → JWT&quot;, pos: &quot;b&quot;, h: 48 }\n\n    CONFIG --&gt; S1\n    CONFIG --&gt; S2\n    CONFIG --&gt; S3\n    S1 --&gt;|&quot;embeds call, issues tokens&quot;| jitsi\n    S2 --&gt; jitsi\n    S3 --&gt; jitsi\n    MEMBER --&gt; S1\n    GUEST --&gt; S1\n\n    style estate fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style jitsi fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:12,ry:12,color:#404143\n</code></pre>\n<h2>Identity and moderation — the site issues every ticket</h2>\n<p>The self-hosted server runs JWT-only (<code>AUTH_TYPE=jwt</code>, guests off at the server level). Jitsi itself has no user database and no sign-in page; the only way into a room is a token signed with the shared secret, and the only issuer of those tokens is the NGX server. That makes the site&#39;s existing membership system the identity provider for the video server, with no directory to sync.</p>\n<p>Two token flavours come out of <a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/video-meetings-controllers.ts\">video-meetings-controllers.ts</a>:</p>\n<ul>\n<li><strong>Member tokens</strong> (4-hour expiry). Issued to any logged-in member. The token carries the member&#39;s real name — so that is the name everyone sees in the call, with no second sign-in — and a moderator flag derived from the member&#39;s committee and admin roles. Committee members can mute, remove and manage the lobby; ordinary members join as participants.</li>\n<li><strong>Guest tokens</strong> (12-hour expiry). Issued with <strong>no login at all</strong>, but only for rooms that actually exist as planned meetings — the server looks the room up against the committee-files collection and refuses to issue a token for a room nobody planned. A guest cannot conjure a working meeting link by inventing a room name, and a guest is never a moderator.</li>\n</ul>\n<p>The guest join link that goes out by email is <code>/video-meetings/guest/&lt;room&gt;</code> on the group&#39;s own site. When the invite is sent from the planner, the link target is rewritten per recipient with their name and email as Brevo merge fields, so each invitee arrives already known: the site pre-fills their name, asks the server for a guest token in that identity, and the token carries an email-keyed occupant id so the same person joining twice is recognised and the stale seat is dropped. A plain link (copied from the room, say) works too — the visitor is asked what name to show and the browser remembers it. A logged-in member who opens a guest link is sent to the member room and keeps their own identity; the name, email and token parameters are stripped on the way.</p>\n<p>Either way there is no app to install and no account to create: check your camera and microphone on the pre-join screen, click Join, and you are in.</p>\n<h2>Joining — check your sound first</h2>\n<p>The first committee calls taught a lesson that every video product learns eventually: people could see each other but not always hear each other, and the person with the problem had no way to tell that their computer had picked the wrong microphone. The original design skipped Jitsi&#39;s pre-join page in favour of dropping people straight into the call, which removed the one screen where a level meter and a device picker live.</p>\n<p>So joining is now a two-step affair on purpose:</p>\n<ul>\n<li><strong>Pre-join screen.</strong> Jitsi&#39;s own check screen, themed to the site: camera preview, microphone and camera pickers, and a Join button. The click matters as well as the check — browsers will not play sound in an embedded frame until the person has interacted with it.</li>\n<li><strong>Sound panel in the call.</strong> A Sound button on the toolbar opens microphone, speaker and camera pickers driven by the Jitsi External API (<code>getAvailableDevices</code>, <code>setAudioInputDevice</code> and friends), with a live level bar. The bar comes from the site itself, not Jitsi: a small Web Audio analyser on the chosen microphone, run in the parent page (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/projects/ngx-ramblers/src/app/functions/mic-level-meter.ts\">mic-level-meter.ts</a>).</li>\n<li><strong>Silent microphone warning.</strong> If the microphone is unmuted but the meter sees nothing for ten seconds, a banner says so and opens the picker. On iPhone and iPad the meter only runs while the panel is open, to avoid competing with Jitsi for the microphone.</li>\n</ul>\n<p>A related trick handles the other classic failure, two devices in the same room feeding back. Each participant&#39;s page emits a short, inaudible 18.5 kHz beacon and listens for anyone else&#39;s. Hearing another device&#39;s beacon through your own microphone means you are in the same room, and the site offers to silence this device rather than let the echo build.</p>\n<h2>Staying in the call</h2>\n<p>Leaving a meeting by accident used to be a single stray click: any link on the site destroyed the room component and hung up. Three things now protect a live call:</p>\n<ul>\n<li>an Angular <code>canDeactivate</code> guard on both room routes, so navigating anywhere else on the site while in a call opens the same &quot;Leave this meeting?&quot; dialogue as the Leave button, with Stay as the safe choice</li>\n<li>a <code>beforeunload</code> handler while in a call, so reload, back and closing the tab get the browser&#39;s own &quot;Leave site?&quot; prompt</li>\n<li>a rejoin banner: the room and its return path are kept in session storage from the moment you join and cleared only when you leave on purpose, so if you do drop out, every page offers &quot;Rejoin meeting&quot;, for guests as well as members</li>\n</ul>\n<p>Fully automatic rejoin is deliberately not attempted: browsers need a gesture before they hand over the microphone and camera again, so a button is the honest option.</p>\n<h2>A meeting is one document</h2>\n<p>There is no meetings collection. A planned meeting <strong>is</strong> a committee file — the same document type that already holds a group&#39;s agendas and minutes — with the meeting&#39;s details on it: event date, title, agenda text, room name, format (in person, online or hybrid), venue, invitees, an optional invite note, the invitees&#39; replies, and whether list sends go to committee role addresses. One document, so there is nothing to keep in sync.</p>\n<p>Everything else derives from that document:</p>\n<ul>\n<li>the <strong>plan calendar</strong> and the walks calendar show it (only committee meeting file types appear, so financial statements and the like stay off the calendar)</li>\n<li>the <strong>committee documents page</strong> lists it alongside past agendas and minutes</li>\n<li>the <strong>booked-meeting banner</strong> reminds members of the next one</li>\n<li>the <strong><code>.ics</code> calendar attachment</strong> is generated from it on demand, keyed by the document&#39;s id</li>\n<li>the meeting&#39;s <strong>format</strong> decides what the invite carries: online and hybrid meetings get a join link, in-person and hybrid meetings get a venue</li>\n<li>the <strong>Meetings page</strong> lists recent calls, including ad-hoc &quot;start a meeting now&quot; rooms that were never planned, so a leftover call can be rejoined or deleted rather than vanishing; deleting a planned meeting names its date and time and can email the cancellation to everyone invited</li>\n</ul>\n<p>File types carry their own meeting role (agenda or minutes), so meeting types are derived from the file types a group has already configured rather than a parallel taxonomy.</p>\n<h2>The invite — a real calendar invite, not a link in a paragraph</h2>\n<p>Planning a meeting composes an email to the chosen invitees — individual guests and any of the group&#39;s mailing lists — through the same Brevo transactional pipeline as every other site email. What makes it a first-class invite is the attachment: the server generates an iCalendar document at <code>/api/calendar/meeting/&lt;id&gt;.ics</code> with <code>METHOD:REQUEST</code>, the meeting&#39;s date and duration, the join link or venue in the description, and the committee secretary as organiser. Mail clients recognise that shape and render the real thing — Accept / Tentative / Decline buttons, a calendar preview, and one-click add-to-calendar (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/calendar/calendar-controllers.ts\">calendar-controllers.ts</a>).</p>\n<p>The planner can add a short note that goes into the email body so the title can stay short, and when every member of the chosen mailing list holds a committee role it offers to send to the committee role addresses instead of personal ones — the same rule the email composer applies.</p>\n<p>Replies come back the same way the invite went out. When an invitee clicks Accept, Tentative or Decline, their mail client sends an iCalendar <code>REPLY</code> to the organiser, which is the group&#39;s own inbox. The inbox import recognises that shape, matches it to the meeting document by id, and records the RSVP against the invitee (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/apply-meeting-calendar-reply.ts\">apply-meeting-calendar-reply.ts</a>). The planner shows who has replied and how, with no polling of anyone&#39;s calendar.</p>\n<p>One wrinkle worth recording: Brevo fetches attachments by URL, which fails for a developer&#39;s localhost and for anything it cannot reach. The attachment layer detects that case, generates the <code>.ics</code> server-side, stores it in the site&#39;s S3 email-attachments folder and hands Brevo the public URL instead — so the same plan-and-invite flow works identically in development and production.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    PLANNER@{ icon: &quot;ngx:user&quot;, label: &quot;Committee member&lt;br/&gt;plans on calendar&quot;, pos: &quot;b&quot;, h: 48 }\n    NGX@{ icon: &quot;ngx:ramblers&quot;, label: &quot;NGX site&quot;, pos: &quot;b&quot;, h: 48 }\n    DB@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Committee file&lt;br/&gt;date · agenda · room · format&quot;, pos: &quot;b&quot;, h: 48 }\n    ICS@{ icon: &quot;ngx:file&quot;, label: &quot;.ics invite&lt;br/&gt;METHOD:REQUEST&quot;, pos: &quot;b&quot;, h: 48 }\n    BREVO@{ icon: &quot;ngx:brevo&quot;, label: &quot;Brevo&lt;br/&gt;transactional email&quot;, pos: &quot;b&quot;, h: 48 }\n    INVITEE@{ icon: &quot;ngx:user&quot;, label: &quot;Invitee&lt;br/&gt;RSVP + add to calendar&quot;, pos: &quot;b&quot;, h: 48 }\n    JITSI@{ icon: &quot;logos:docker-icon&quot;, label: &quot;Self-hosted Jitsi&quot;, pos: &quot;b&quot;, h: 48 }\n\n    PLANNER --&gt; NGX\n    NGX --&gt; DB\n    DB --&gt; ICS\n    ICS --&gt; BREVO\n    BREVO --&gt; INVITEE\n    INVITEE --&gt;|&quot;named guest link + JWT&quot;| JITSI\n    INVITEE --&gt;|&quot;calendar REPLY via the group inbox&quot;| NGX\n</code></pre>\n<h2>Self-hosting — one container that Fly will actually run</h2>\n<p>The standard Jitsi deployment is four cooperating containers (prosody for XMPP signalling, jicofo for conference focus, jvb for the video bridge, and the web front end) orchestrated by Docker Compose. Fly&#39;s model is one container per machine, and running four machines for a service that is idle most of the month would be wasteful anyway. So the estate image is a <strong>combined container</strong>: it bases on the official <code>jitsi/prosody</code> image and grafts the web (nginx), jicofo and jvb services onto it, letting the standard s6 <code>/init</code> supervise all four processes in one machine, driven entirely by environment variables (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/jitsi/Dockerfile\">jitsi/Dockerfile</a>).</p>\n<p>Networking is the part Jitsi is famous for getting wrong on new infrastructure, so it is worth stating what works: Fly&#39;s edge terminates TLS and forwards to nginx on port 80; media flows over UDP 10000 to the video bridge on a dedicated IPv4, with <code>JVB_ADVERTISE_IPS</code> telling the bridge what address to put in its candidates; and if a restrictive network blocks UDP, Jitsi falls back to TCP 443, which is fine for small infrequent calls.</p>\n<p>Deployment follows the same config-driven, deploy-from-admin pattern as the integration worker. <a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/deploy/deploy-jitsi.ts\">deploy-jitsi.ts</a> reads the Video Meetings config from the database, ensures the Fly app and its dedicated IPv4 exist, injects the secrets and host-specific values (<code>PUBLIC_URL</code>, <code>JVB_ADVERTISE_IPS</code>, the JWT secret, the jicofo and jvb component passwords), then validates and deploys with flyctl and scales the memory. The staging pipeline runs it automatically — but only when image-affecting files have changed, detected by diffing against a <code>jitsi-deployed</code> ref, so an ordinary site push never rebuilds the video server.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    ADMIN@{ icon: &quot;ngx:user&quot;, label: &quot;Admin&lt;br/&gt;Global Settings → Video Meetings&quot;, pos: &quot;b&quot;, h: 48 }\n    GHA@{ icon: &quot;logos:github-actions&quot;, label: &quot;Deploy workflow&lt;br/&gt;runs only on Jitsi changes&quot;, pos: &quot;b&quot;, h: 48 }\n    DEPLOY@{ icon: &quot;logos:nodejs-icon&quot;, label: &quot;deploy-jitsi.ts&lt;br/&gt;config + secrets from DB&quot;, pos: &quot;b&quot;, h: 48 }\n\n    subgraph app[&quot;ngx-ramblers-jitsi — one Fly machine&quot;]\n        NGINX[&quot;nginx web&lt;br/&gt;Fly edge TLS → :80&quot;]\n        PROSODY[&quot;prosody&lt;br/&gt;XMPP signalling · JWT auth&quot;]\n        JICOFO[&quot;jicofo&lt;br/&gt;conference focus&quot;]\n        JVB[&quot;jvb video bridge&lt;br/&gt;UDP 10000 · TCP 443 fallback&quot;]\n    end\n\n    ADMIN --&gt; GHA\n    GHA --&gt; DEPLOY\n    DEPLOY --&gt;|&quot;flyctl deploy&lt;br/&gt;dedicated IPv4&quot;| app\n    NGINX --- PROSODY\n    PROSODY --- JICOFO\n    JICOFO --- JVB\n\n    style app fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h2>Recording, transcripts and minutes</h2>\n<p>Nothing is recorded by the video server. Recording is a choice one member makes in the room with the Record button, and it runs in that member&#39;s browser: the microphone is captured in twenty-second chunks with echo cancellation off, so it hears everyone in the call through the speakers as well as the person at the keyboard. Each chunk is posted to the site and transcribed by a Gemini model through the estate&#39;s AI configuration (<a href=\"https://github.com/nbarrett/ngx-ramblers/blob/main/server/lib/video-meetings/meeting-audio-transcription.ts\">meeting-audio-transcription.ts</a>). The prompt names the person whose microphone it is and everyone else in the room, and asks for one line per utterance labelled with the speaker, so the transcript stored in the <code>meetingTranscript</code> collection carries each line under the right name rather than putting every word in the recorder&#39;s mouth. Unknown voices keep the label the model gave them, unlabelled lines fall back to the recorder, adjacent repeats are dropped, and a words-per-second check throws away chunks where the model has plainly invented more speech than the audio could hold. Only one person should record: with echo cancellation off every microphone hears every voice, so two recorders would double every line.</p>\n<p>Every meeting room also has a shared notes panel. Notes persist to a <code>meetingNotes</code> collection tagged with their author, so they survive a reload and are there when the minutes are written. On top of that sits an AI minute-writer: given the transcript, the chat, and any hand-written notes, it drafts structured minutes attributed to the named speakers — and the hand-written notes are folded in rather than overwritten. The AI output is saved as a single note authored &quot;AI notes&quot;; writing again <strong>replaces</strong> that note instead of stacking duplicates. A minutes editor then lets the secretary tidy the draft and publish it as a committee document, so it sits next to the agenda on the committee documents page, and the Meetings page lists any recording that still needs its minutes written.</p>\n<p>The generation itself runs on the estate&#39;s integration worker when one is configured, keeping model calls off the site servers; without a worker it falls back to in-process generation. The worker suspends when idle — zero running machines between jobs — which keeps the AI capability effectively free until a meeting actually uses it.</p>\n<h2>What this adds up to</h2>\n<p>A committee member plans a meeting on the calendar they already use, invitees get a real calendar invite from an address they recognise and their replies flow back into the meeting record, everyone joins in a browser with no account after a quick sound check, the committee moderates by virtue of being the committee, and a transcript with named speakers, notes and minutes come out the other end — and the whole estate&#39;s video traffic runs through one small container on hardware we control, with nothing recorded and no third party in the middle. The public-Jitsi default means a brand-new group has working video meetings before any of the self-hosting is set up; the self-hosted mode is where the feature is at its best.</p>\n<p>The user-facing walkthrough, with screenshots of planning, booking and cancelling, is in the <a href=\"https://ngx-ramblers.org.uk/how-to/committee/release-notes/2026-08-24\">release note</a>.</p>\n"}