Skip to content

Automatic and Manual Event Ending#417

Open
mikewillems wants to merge 45 commits into
stagingfrom
mw/feat/event-end
Open

Automatic and Manual Event Ending#417
mikewillems wants to merge 45 commits into
stagingfrom
mw/feat/event-end

Conversation

@mikewillems

@mikewillems mikewillems commented May 2, 2026

Copy link
Copy Markdown
Collaborator

Automatic and Manual Event Ending

What is in this PR?

Establish and test a consistent mechanism for ending an event manually and at a particular time. Enable the host to close all meeting rooms manually with a button or automatically at end time (with an optional grace period before closing all rooms). Add and test the UI for this functionality, and limit the grace period to an integer number of minutes between 0 (default) and 120.

Changes in the codebase

General Approach

The core concept is a meetingEndedAt timestamp on the LiveMeeting document. Setting this field triggers two things: (1) the client detects the change via its live meeting stream and auto-leaves, and (2) server-side cleanup runs (stop recordings, send "event ended" email).

Two server paths can set meetingEndedAt:

  • Manual: The host clicks "End Meeting" in the admin panel, which calls the endMeetingForAll Cloud Function. Authorization checks confirm the caller is the event creator or a community mod/admin/owner.
  • Automatic: On first join, GetMeetingJoinInfo schedules a Cloud Task for scheduledTime + durationInMinutes + gracePeriod. When the task fires, ScheduledEndMeeting sets meetingEndedAt via the same shared core logic.

Both paths use a Firestore transaction for the idempotency guard: if meetingEndedAt is already set, the operation is a no-op. This prevents duplicate emails and recording stops even if the manual and scheduled paths race.

On the client, the event settings drawer exposes the autoEndMeeting toggle and a grace period input field. The grace period is validated client-side (0-120 integer, with debounced auto-correction) and clamped server-side as a defense-in-depth measure.

Non-obvious choices

  • Grace period input uses a managed TextEditingController rather than TextFormField with initialValue. The naive approach (using a ValueKey keyed to the model value) causes Flutter to destroy and recreate the TextFormField widget on every keystroke, because onChanged -> updateSettingValue -> setState changes the key. This also unfocuses the field. The managed controller approach avoids this by keeping the controller alive across rebuilds, with a _gracePeriodEditing flag to prevent updateView from overwriting the text while the user is typing.

  • Auto-end task scheduling is best-effort (try/catch). A Cloud Tasks failure during GetMeetingJoinInfo would otherwise block the user from joining the meeting. The task is non-critical -- the host can always end manually.

  • ScheduledEndMeeting validates the X-CloudTasks-TaskName header. Cloud Functions strips this header from external HTTP requests, so its presence confirms the call originated from a Cloud Tasks queue. This prevents unauthorized callers from ending meetings via the raw HTTP endpoint.

  • Recording stops and email delivery run in parallel. These are independent operations. Running them sequentially risked approaching the Cloud Function timeout for events with many breakout rooms and gives inconsistent user experience (not that it really matters in this case but polish is nice).

  • Dialog dismiss is treated as cancel in the host leave prompt. On my first pass, dismissing the "End for all?" dialog (tapping outside) forced the result to false and silently left the meeting. Now it cancels the leave action entirely, requiring an explicit choice.

Changes by Commit

  1. 7a3b4310 -- Add meetingEndedAt field to LiveMeeting, create endMeetingForAll CF with auth checks, extract recording stop logic into stop_all_event_recordings.dart
  2. 34cc52d1 -- Add unit tests for EndMeetingForAll CF
  3. b190f08a -- Client reacts to meetingEndedAt changes (auto-leave), wire up endMeetingForAll call, add "End Meeting" button in admin panel
  4. 547212af -- Add ScheduledEndMeeting CF (Cloud Tasks HTTP handler), schedule auto-end on first join in GetMeetingJoinInfo
  5. c4c34713 -- Add unit tests for ScheduledEndMeeting CF
  6. 29107097 -- Comment _postEventEmailThresholdInMinutes explaining its purpose
  7. 887a9e1c -- Display end time alongside start time in event UI (carousel, event cards, event info)
  8. 4fc78c4d -- Add widget tests for VerticalTimeAndDateIndicator end time display
  9. 5992828e -- Reject join attempts on ended meetings (server-side check in GetMeetingJoinInfo)
  10. aa1fde93 -- Add test for join rejection on ended meetings
  11. b2ef50a8 -- Add host leave prompt ("End for all?" dialog before leaving)
  12. 62bf5e14 -- Add autoEndMeeting and autoEndGracePeriodMinutes fields to EventSettings, add toggle + grace period input to event settings drawer, wire into task scheduling
  13. b42c2bd5 -- Fix settings type error by filtering non-bool values from the dev settings bool display
  14. 4e27021f -- Wrap the meetingEndedAt read-then-write in a Firestore transaction for atomic idempotency (both EndMeetingForAll and ScheduledEndMeeting)
  15. 9a3b0c7b -- Remove dead displayName resolution code and unused imports from GetMeetingJoinInfo
  16. 2a3df808 -- Pass event from the transaction result via MeetingJoinResult instead of re-reading it from Firestore
  17. 97ee210c -- Wrap auto-end task scheduling in try/catch so join is not blocked by Cloud Tasks failures
  18. 09e4eb52 -- Localize hard-coded strings in leave prompt and event settings (ARB files)
  19. 0ed29a9b -- Validate X-CloudTasks-TaskName header in ScheduledEndMeeting to prevent unauthorized external calls
  20. b42c8d3f -- Add email verification to scheduled end meeting test
  21. 295e7db5 -- Clamp grace period to 0-120 minutes server-side in GetMeetingJoinInfo
  22. b56e9a01 -- Add grace period input validation and auto-correction (managed controller, debounce, red error styling)
  23. e428a3fe -- Extract shared end meeting core logic (transaction, recording stop, email) into end_meeting_core.dart, called by both CFs
  24. 5f89aa07 -- Treat dialog dismiss as cancel in host leave prompt
  25. aafef468 -- Parallelize breakout room recording stops with Future.wait
  26. 72f7f3e4 -- Run recording stops in parallel with email delivery in end meeting core
  27. 0938e944 -- Use permissionDenied instead of failedPrecondition for auth errors (better error categorization in logs)
  28. a978fdcb -- Short-circuit auth check for event creator in endMeetingForAll (skip membership lookup when the caller is the host)
  29. d3b0dcd9 -- Guard against double-tap on leave with _leavingInProgress flag
  30. 3b6f104d -- Add widget tests for grace period input validation and auto-correction
  31. cb49d913 -- Add comments explaining the validation scheme

Changes outside the codebase

No infrastructure or environment changes. Cloud Tasks is already enabled in the project. The ScheduledEndMeeting function is registered in main.dart and deployed alongside other Cloud Functions.

Testing this PR

Automated tests:

  • EndMeetingForAll CF unit tests (end_meeting_for_all_test.dart) -- covers auth, idempotency, recording stop, email send
  • ScheduledEndMeeting CF unit tests (scheduled_end_meeting_test.dart) -- covers execution, idempotency, email verify
  • GetMeetingJoinInfo test for join rejection on ended meetings
  • Widget tests for end time display in VerticalTimeAndDateIndicator
  • Widget tests for grace period input (initial value, focus retention, error styling, debounce auto-correction, focus loss correction, error clearing)

Run CF tests:

cd firebase/functions && CLOUD_RUNTIME_CONFIG=./test/test_config.json \
  firebase emulators:exec --only firestore --project frankly-dev-a410b \
  'dart test --concurrency=1'

Run client tests:

cd client && .fvm/flutter_sdk/bin/flutter test --platform chrome

Manual testing checklist:

  • Toggle "Auto-end meeting" on in event settings, set a grace period, save, and verify the meeting auto-ends at the scheduled time
  • Click "End Meeting" in the admin panel during a live meeting and verify all participants are ejected
  • Verify the "End for all?" prompt appears when the host clicks Leave, and that dismissing it cancels the leave action
  • Type invalid values (negative, >120, non-numeric, decimal) in the grace period field and verify red styling + auto-correction
  • Join an ended meeting and verify the join is rejected

Additional information

The meetingEndedAt field is write-once. Once set, there is no mechanism to "restart" a meeting. This is intentional -- ending is a final action confirmed by a dialog.

The grace period field is clamped to 0-120 on both client and server. The server-side clamp in GetMeetingJoinInfo is an extra defense (and good hygeine in case of unvalidated input attacks + is coupled with authentication); the client-side validation is the primary UX.

The ScheduledEndMeeting Cloud Function is an HTTP handler (not a callable) because Cloud Tasks dispatches via HTTP POST. The X-CloudTasks-TaskName header check is the auth mechanism -- Cloud Functions infrastructure strips this header from external requests.

Only conflict in
client/lib/features/admin/presentation/views/data_tab.dart
- Verify meetingEndedAt is written and email sent
- Verify idempotency (second call is a no-op)
- Verify non-mod/admin/owner is rejected
- Auto-leave when meetingEndedAt is set on LiveMeeting doc
- Add endMeetingForAll to CloudFunctionsEventService
- Add End Meeting menu item in admin panel for mods/admins/owners
- Show confirmation dialog before ending
- Add l10n strings in all 4 ARB files (en, es, zh, zh_Hant_TW)
- Add ScheduledEndMeeting OnRequestMethod CF with idempotency guard
- Add isFirstJoin flag to MeetingJoinResult
- Schedule end task on first join when event has scheduledTime + duration
- Register ScheduledEndMeeting in main.dart
- Verify meetingEndedAt is written and email sent
- Verify idempotency (second call is a no-op)
# Conflicts:
#	client/lib/features/admin/presentation/views/data_tab.dart
- Add optional endTime parameter to VerticalTimeAndDateIndicator
- Show time range (e.g. '2:30p - 3:30p') when endTime is provided
- Pass calculated endTime (scheduledTime + durationInMinutes) at all 5 call sites:
  event_card, carousel_tabs, home_page_event_card, event_button, event_info
- Verify start-only display
- Verify start-end range display
data_models/build.sh + firebase/functions/build.sh -> build-all.sh
Expanded script to cover all three packages (client, data_models,
firebase/functions) and use the FVM-pinned Flutter/Dart SDK instead of
global binaries, fixing kernel binary format mismatch errors when the
global SDK version differs from the repo pin.
Resolves kernel binary format mismatch errors caused by global Flutter/Dart
differing from the repo-pinned version. All flutter/dart invocations now
prefer client/.fvm/flutter_sdk/bin, with fallback to global. Also clears
build stamps when the active SDK fingerprint changes between runs.
- Add meetingEndedAt (DateTime?) and kFieldMeetingEndedAt to LiveMeeting
- Add EndMeetingForAllRequest to data_models requests
- Break out shared recording-stop logic into stop_all_event_recordings.dart
- Refactor EventEnded CF to use shared helper
- Add EndMeetingForAll callable CF with mod/admin/owner auth check,
  idempotency guard, recording stop, and post-event email
- Register EndMeetingForAll in main.dart
@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit f696265):

https://gen-hls-bkc-7627--pr417-mw-feat-event-end-y4w13zyo.web.app

(expires Thu, 21 May 2026 00:07:37 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: eed668cca81618d491d024574a8f8a6003deaa8d

@mikewillems mikewillems marked this pull request as ready for review May 2, 2026 00:33
Copilot AI review requested due to automatic review settings May 2, 2026 00:33
@mikewillems mikewillems marked this pull request as draft May 2, 2026 00:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a host-initiated and scheduled mechanism to end live meetings, including persisting an “ended” marker on the LiveMeeting document, stopping active recordings, and notifying participants. It also updates client UI to display event end times and exposes “End Meeting” controls, plus adds dev/build script improvements.

Changes:

  • Adds Cloud Functions to end a meeting for all participants (manual, host/mod/admin/owner) and to end it automatically at a scheduled time (Cloud Tasks).
  • Persists meetingEndedAt in LiveMeeting, blocks new joins after end, and auto-leaves clients when the meeting is ended.
  • Updates UI to show time ranges (start–end), adds event settings for auto-end + grace period, and adds build/dev scripting improvements.

Reviewed changes

Copilot reviewed 40 out of 40 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
run-dev.sh Uses FVM Flutter/Dart when available; clears build stamps when SDK fingerprint changes.
package.json Adds build script entrypoint.
build-all.sh New build script to run pub get/build steps across client/data_models/functions.
firebase/functions/node/main.dart Registers new EndMeetingForAll (onCall) and ScheduledEndMeeting (onRequest) functions.
firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart Implements callable “end meeting for all” flow (writes meetingEndedAt, stops recordings, emails participants).
firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart Implements scheduled HTTP function to end meeting at a given time.
firebase/functions/lib/events/live_meetings/stop_all_event_recordings.dart Extracts shared logic to stop main + breakout recordings.
firebase/functions/lib/events/live_meetings/get_meeting_join_info.dart Rejects joining ended meetings; schedules auto-end task on first join.
firebase/functions/lib/events/live_meetings/live_meeting_utils.dart Returns isFirstJoin flag to support first-join scheduling.
firebase/functions/lib/events/event_ended.dart Reuses shared “stop all recordings” helper.
firebase/functions/test/events/live_meetings/end_meeting_for_all_test.dart Adds tests for manual end meeting behavior, idempotency, and authorization.
firebase/functions/test/events/live_meetings/scheduled_end_meeting_test.dart Adds tests for scheduled meeting end behavior and idempotency.
firebase/functions/test/events/live_meetings/get_meeting_join_info_test.dart Adds test that join is rejected if meetingEndedAt is set.
data_models/lib/events/live_meetings/live_meeting.dart Adds meetingEndedAt field + Firestore key constant.
data_models/lib/events/live_meetings/live_meeting.g.dart Generated JSON serialization updates for meetingEndedAt.
data_models/lib/events/live_meetings/live_meeting.freezed.dart Generated Freezed updates for meetingEndedAt.
data_models/lib/events/event.dart Adds autoEndMeeting + autoEndGracePeriodMinutes settings keys/defaults.
data_models/lib/events/event.g.dart Generated JSON serialization updates for new EventSettings fields.
data_models/lib/events/event.freezed.dart Generated Freezed updates for new EventSettings fields.
data_models/lib/cloud_functions/requests.dart Adds EndMeetingForAllRequest.
data_models/lib/cloud_functions/requests.g.dart Generated JSON serialization for EndMeetingForAllRequest.
data_models/lib/cloud_functions/requests.freezed.dart Generated Freezed code for EndMeetingForAllRequest.
client/lib/features/events/data/services/cloud_functions_event_service.dart Adds client call for endMeetingForAll.
client/lib/features/events/features/live_meeting/features/admin_panel/presentation/widgets/admin_panel.dart Adds “End Meeting” menu item + confirmation dialog.
client/lib/features/events/features/live_meetings/data/providers/live_meeting_provider.dart Auto-leaves when meeting ended; prompts host/admin to optionally end for all when leaving.
client/lib/features/events/features/event_page/presentation/views/event_settings_drawer.dart Adds UI for auto-end meeting toggle + grace period input.
client/lib/features/events/features/event_page/presentation/event_settings_presenter.dart Adds updateSettingValue to support non-bool settings updates.
client/lib/features/community/presentation/widgets/carousel/time_indicator.dart Displays time range when endTime is provided.
client/test/lib/features/community/presentation/widgets/carousel/time_indicator_test.dart Adds widget tests for time-range display behavior.
client/lib/features/community/presentation/widgets/carousel/carousel_tabs.dart Supplies endTime to time indicator in carousel.
client/lib/features/community/presentation/widgets/event_card.dart Supplies endTime to time indicator.
client/lib/features/home/presentation/views/home_page_event_card.dart Supplies endTime to time indicator.
client/lib/features/events/presentation/widgets/event_button.dart Supplies endTime to time indicator.
client/lib/features/events/features/event_page/presentation/widgets/event_info.dart Supplies endTime to time indicator.
client/lib/l10n/app_en.arb Adds endMeeting and endMeetingConfirmation strings.
client/lib/l10n/app_es.arb Adds endMeeting and endMeetingConfirmation strings.
client/lib/l10n/app_zh.arb Adds endMeeting and endMeetingConfirmation strings.
client/lib/l10n/app_zh_Hant_TW.arb Adds endMeeting and endMeetingConfirmation strings.
firebase/functions/build.sh Removes old functions build script.
data_models/build.sh Removes old data_models build script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart Outdated
Comment thread firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart Outdated
Comment on lines +48 to +52
test('sets meetingEndedAt and sends email', () async {
final event = await createTestEvent();
registerFallbackValue(event);
registerFallbackValue(EventEmailType.ended);

Comment thread firebase/functions/lib/events/live_meetings/end_meeting_for_all.dart Outdated
@mikewillems mikewillems marked this pull request as ready for review May 2, 2026 00:40
@mikewillems mikewillems marked this pull request as draft May 2, 2026 14:44
@mikewillems mikewillems force-pushed the mw/feat/event-end branch from e44572e to cb49d91 Compare May 4, 2026 04:10
@mikewillems mikewillems changed the title Mw/feat/event end Automatic and Manual Event Ending May 4, 2026
@mikewillems mikewillems marked this pull request as ready for review May 4, 2026 04:45
- Move date into own line below event title
- Enlarge event picture and justify left
- Add missing label for message participants button
- Locate that button with the other event control
  buttons (cancel and add to calendar), next to the
  share buttons because it's semantically related
- Emphasize that button mainly for aesthetics but
  also because it's the most common action (cal
  add is probably only done once, canceling the
  event is not generally expected, want to
  encourage messaging through Frankly platform)
Specifically, mini refactor to use it for the calendar_menu_button.

@dariusk dariusk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generally looks great! Love to see the debounces and clamps and all that bread and butter logic flow stuff.

I have more specific comments inline, particularly with some instructions on putting in Matomo hooks for analytics.

🫡 Been a pleasure serving with you!!

type: ActionButtonType.outline,
color: context.theme.colorScheme.surfaceContainerLowest,
icon: Icon(
CupertinoIcons.calendar_badge_plus,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we were moving away from CupertinoIcons? This was my understanding from working on #223

@katherineqian katherineqian May 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is true. Please use Icons (Material Icons) for consistency!

type: ActionButtonType.outline,
color: context.theme.colorScheme.surfaceContainerLowest,
icon: Icon(
CupertinoIcons.paperplane,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above on CupertinoIcons

size: 24,
color: context.theme.colorScheme.onSurfaceVariant,
),
text: 'Message participants',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a localized string

final endDateTime = scheduledTime.add(
Duration(minutes: eventProvider.event.durationInMinutes),
);
final endFmt = DateFormat('h:mma').format(endDateTime);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we possibly reuse some of the time formatting code you add in time_indicator.dart here?

],
],
),
if (canCancelParticipation || _canEditEvent) ...[ SizedBox(height: 4),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange whitespace needs fixing!

}
transaction.update(
liveMeetingRef,
UpdateData.fromMap({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think right around here we need some hooks for the Matomo API. Basically, wherever we have the call to set the meeting end time (or if it's in two places put it in both places), we should put code close to or identical to what's here:

analytics.logEvent(
AnalyticsCompleteEventEvent(
communityId: communityId,
eventId: eventId,
asHost: (event?.eventType != EventType.hostless) &&
event?.creatorId == userService.currentUserId,
templateId: templateId,
duration: durationInSeconds,
),
);

and remove the linked code from meeting_agenda_provider.dart. The linked code is from an attempt to capture a "Complete Event" event for Matomo analytics. It logs 1) that the Frankly event ended, and 2) how many seconds long the event was. However, the linked code was written back before we really had a concept of "meeting ended" so it's really scattershot and only triggers sometimes. Deleting that code and dropping it in here (with a little math to calculate duration) would be ideal.

If we get this working right we'll be able to see in Matomo a list of anonymized meeting IDs alongside duration of the meeting, like in the following screenshot but with more than just the top entry working correctly

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, probably also the hook needs to go in firebase/functions/lib/events/live_meetings/scheduled_end_meeting.dart


// On first join, schedule automatic meeting end if the event has
// autoEndMeeting enabled, a scheduled time, and a duration.
if (result.isFirstJoin) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole conditional branch pleases me to look at

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants