diff --git a/src-tauri/src/commands/meetings.rs b/src-tauri/src/commands/meetings.rs index 05a8822..dbda387 100644 --- a/src-tauri/src/commands/meetings.rs +++ b/src-tauri/src/commands/meetings.rs @@ -593,7 +593,11 @@ pub async fn meetings_list_scheduled_bots( let mut url = format!("{base}/v1/bot/list"); if let Some(ids) = calendar_event_ids.as_ref() { if !ids.is_empty() { - let joined = ids.join(","); + let joined = ids + .iter() + .map(|id| encode_query_value(id)) + .collect::>() + .join(","); url.push_str(&format!("?calendarEventIds={joined}")); } } @@ -1524,6 +1528,10 @@ fn is_url_safe_id(s: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') } +fn encode_query_value(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1893,6 +1901,12 @@ mod tests { assert!(!is_url_safe_id("bot#frag")); } + #[test] + fn query_value_encoding_preserves_safe_event_ids_and_escapes_reserved_chars() { + assert_eq!(encode_query_value("event_123-abc.def"), "event_123-abc.def"); + assert_eq!(encode_query_value("event 1?x=2#frag"), "event+1%3Fx%3D2%23frag"); + } + #[test] fn meeting_event_parses_recurring_fields() { let json = r#"{ diff --git a/src/components/MeetingsWindow.svelte b/src/components/MeetingsWindow.svelte index d1422b6..d4e3f00 100644 --- a/src/components/MeetingsWindow.svelte +++ b/src/components/MeetingsWindow.svelte @@ -16,7 +16,11 @@ saveMeetingsCache, } from '../lib/meetingsCache'; import { isAlreadyScheduledError } from '../lib/invite-errors'; - import { botForEvent } from '../desktop-alt/lib/meetings-model'; + import { + botForEvent, + calendarEventIdsForBotLookup, + mergeScheduledBotLookups, + } from '../desktop-alt/lib/meetings-model'; import { UNATTRIBUTED, companyOptions, @@ -511,28 +515,24 @@ loading = true; listError = null; try { - // Fetch events + bots + memberships + connected accounts in parallel. + // Fetch events + memberships + connected accounts in parallel, then + // fetch bots using the actual event ids. hq-pro's full bot list is a + // legacy/recent-bots convenience path and can miss a row; the per-event + // path is the authoritative "does this visible event already have a + // bot?" source. // - // Catch the two primary list calls individually instead of letting + // Catch the primary list calls individually instead of letting // Promise.all reject on first failure: an outage that 500s // /v1/calendar/events should not blank in-memory bot state, and - // vice versa, so the 30s poll can recover one independently. Memberships - // and accounts already swallow to [] for the same reason — a person - // with zero connected Google accounts still wants the events render - // (the empty-state below handles that branch). + // vice versa, so the 30s poll can recover one independently. + // Memberships and accounts already swallow to [] for the same reason. let upcomingErr: unknown = null; let botsErr: unknown = null; - const [evts, bots, membershipRows, accts] = await Promise.all([ + const [evts, membershipRows, accts] = await Promise.all([ invoke('meetings_list_upcoming').catch((err: unknown) => { upcomingErr = err; return null; }), - invoke('meetings_list_scheduled_bots', { - calendarEventIds: null, - }).catch((err: unknown) => { - botsErr = err; - return null; - }), // Memberships are tiny + rarely change — fetched on every open is // cheap and avoids stale company-name display after the user joins // a new company elsewhere. @@ -541,6 +541,28 @@ () => [] as GoogleAccount[], ), ]); + const botEventIds = calendarEventIdsForBotLookup(evts ?? events); + let eventBotsErr: unknown = null; + let fullBotsErr: unknown = null; + const [eventBots, fullBots] = await Promise.all([ + botEventIds.length === 0 + ? Promise.resolve([] as ScheduledBot[]) + : invoke('meetings_list_scheduled_bots', { + calendarEventIds: botEventIds, + }).catch((err: unknown) => { + eventBotsErr = err; + return null; + }), + invoke('meetings_list_scheduled_bots', { + calendarEventIds: null, + }).catch((err: unknown) => { + fullBotsErr = err; + return null; + }), + ]); + const bots = mergeScheduledBotLookups(botEventIds, eventBots, fullBots); + if (botEventIds.length > 0 && eventBots === null) botsErr = eventBotsErr; + else if (botEventIds.length === 0 && fullBots === null) botsErr = fullBotsErr; // Only overwrite on success so a transient 500 keeps the last good // snapshot in memory until recovery. The error banner still wins in // the render branch order, but the cached state is correct for the diff --git a/src/desktop-alt/lib/meetings-model.test.ts b/src/desktop-alt/lib/meetings-model.test.ts index 0bc3599..c698b74 100644 --- a/src/desktop-alt/lib/meetings-model.test.ts +++ b/src/desktop-alt/lib/meetings-model.test.ts @@ -5,10 +5,13 @@ import { botForEvent, buildConnectedCalendarRows, buildRefreshProblemReport, + calendarEventIdsForBotLookup, dayLabel, groupByDay, isAuthError, meetingsRefreshNotice, + mergeScheduledBotLookups, + mergeScheduledBots, pickLiveMeeting, recurringSeriesId, isRecurringMeeting, @@ -357,6 +360,72 @@ describe('meetings-model', () => { }); }); + describe('bot list lookup helpers', () => { + it('dedupes event ids before asking the backend for per-event bot state', () => { + expect( + calendarEventIdsForBotLookup([ + eventAt('event-1', new Date(2026, 4, 27, 12, 0, 0)), + eventAt('event-2', new Date(2026, 4, 27, 13, 0, 0)), + eventAt('event-1', new Date(2026, 4, 27, 14, 0, 0)), + { ...eventAt(' ', new Date(2026, 4, 27, 15, 0, 0)), id: ' ' }, + ]), + ).toEqual(['event-1', 'event-2']); + }); + + it('keeps authoritative per-event bot rows before legacy full-list rows', () => { + const eventBot: ScheduledBot = { + botId: 'bot-1', + meetingUrl: 'https://meet.google.com/abc-defg-hij', + platform: 'google_meet', + status: 'scheduled', + calendarEventId: 'event-1', + autoScheduled: true, + meetingTitle: 'Authoritative row', + }; + const fullListBot: ScheduledBot = { + ...eventBot, + meetingTitle: 'Legacy full-list row', + }; + const recordedBot: ScheduledBot = { + botId: 'bot-2', + meetingUrl: 'https://meet.google.com/def-ghij-klm', + platform: 'google_meet', + status: 'completed', + calendarEventId: 'event-2', + autoScheduled: true, + meetingTitle: 'Recorded row', + }; + + expect(mergeScheduledBots([eventBot], [fullListBot, recordedBot])).toEqual([ + eventBot, + recordedBot, + ]); + }); + + it('requires per-event bot rows when visible event ids are known', () => { + const eventBot: ScheduledBot = { + botId: 'bot-1', + meetingUrl: 'https://meet.google.com/abc-defg-hij', + platform: 'google_meet', + status: 'scheduled', + calendarEventId: 'event-1', + autoScheduled: true, + }; + const fullListBot: ScheduledBot = { + ...eventBot, + botId: 'bot-legacy', + }; + + expect(mergeScheduledBotLookups(['event-1'], null, [fullListBot])).toBeNull(); + expect(mergeScheduledBotLookups(['event-1'], [eventBot], null)).toEqual([ + eventBot, + ]); + expect(mergeScheduledBotLookups([], null, [fullListBot])).toEqual([ + fullListBot, + ]); + }); + }); + // US-010 — "Done — transcript saved" must be gated on the REAL source-landed // signal, not on bot.status === 'completed' alone. The two can diverge: the // bot lifecycle status flips on the Recall webhook / retry path while the diff --git a/src/desktop-alt/lib/meetings-model.ts b/src/desktop-alt/lib/meetings-model.ts index f548991..4e12628 100644 --- a/src/desktop-alt/lib/meetings-model.ts +++ b/src/desktop-alt/lib/meetings-model.ts @@ -175,6 +175,39 @@ export function botForEvent( }); } +export function calendarEventIdsForBotLookup(events: MeetingEvent[]): string[] { + return Array.from( + new Set( + events + .map((event) => event.id?.trim()) + .filter((id): id is string => typeof id === 'string' && id.length > 0), + ), + ); +} + +export function mergeScheduledBots( + primary: ScheduledBot[], + secondary: ScheduledBot[], +): ScheduledBot[] { + const byId = new Map(); + for (const bot of [...primary, ...secondary]) { + if (!byId.has(bot.botId)) byId.set(bot.botId, bot); + } + return Array.from(byId.values()); +} + +export function mergeScheduledBotLookups( + eventIds: string[], + eventBots: ScheduledBot[] | null, + fullBots: ScheduledBot[] | null, +): ScheduledBot[] | null { + if (eventIds.length > 0) { + if (eventBots === null) return null; + return mergeScheduledBots(eventBots, fullBots ?? []); + } + return fullBots; +} + export function isToday(event: MeetingEvent, now = new Date()): boolean { const start = eventStart(event); if (!start) return false; diff --git a/src/desktop-alt/lib/meetings-store.svelte.ts b/src/desktop-alt/lib/meetings-store.svelte.ts index 358ffa1..736ba98 100644 --- a/src/desktop-alt/lib/meetings-store.svelte.ts +++ b/src/desktop-alt/lib/meetings-store.svelte.ts @@ -8,10 +8,12 @@ import { loadMeetingsCache, saveMeetingsCache } from '../../lib/meetingsCache'; import { buildRefreshProblemReport, botForEvent, + calendarEventIdsForBotLookup, eventMeetingUrl, friendlyError, isAuthError, meetingsRefreshNotice, + mergeScheduledBotLookups, recurringSeriesId, } from './meetings-model'; import type { @@ -141,8 +143,9 @@ function hydrateFromCache() { * Live fetch — mirrors MeetingsWindow.svelte's mount `refresh()`. The alt * window must fetch calendar data itself: it can't piggyback on the classic * window's cache because localStorage is per-WebviewWindow. Fetches - * events + bots + memberships + connected accounts in parallel, fans out to - * per-account calendars, populates the model, and persists the snapshot. + * events + memberships + connected accounts, then fetches authoritative + * per-event bot state for the visible agenda before fanning out to calendars, + * populating the model, and persisting the snapshot. * * Errors are NOT swallowed to a blank state: on failure we keep whatever the * cache already painted, log the error, and surface a message (auth failures @@ -153,17 +156,8 @@ async function refresh() { loading = true; membershipsError = ''; try { - const [evts, bots, members, accts] = await Promise.all([ + const [evts, members, accts] = await Promise.all([ invoke('meetings_list_upcoming'), - // Best-effort: a bots-list hiccup must not blank the recording pills or - // trip the "couldn't refresh" notice. The meetings list (above) is the - // only call that defines a healthy refresh; everything else degrades. - invoke('meetings_list_scheduled_bots', { - calendarEventIds: null, - }).catch((err) => { - console.error('meetings_list_scheduled_bots failed:', err); - return null as ScheduledBot[] | null; - }), invoke('meetings_list_memberships').catch((err) => { console.error('meetings_list_memberships failed:', err); membershipsError = 'Could not load calendar routing.'; @@ -180,6 +174,36 @@ async function refresh() { fetchError = ''; refreshBlocked = false; lastRefreshErrorRaw = ''; + const botEventIds = calendarEventIdsForBotLookup(evts ?? []); + let eventBotsErr: unknown = null; + const [eventBots, fullBots] = await Promise.all([ + botEventIds.length === 0 + ? Promise.resolve([] as ScheduledBot[]) + : invoke('meetings_list_scheduled_bots', { + calendarEventIds: botEventIds, + }).catch((err) => { + eventBotsErr = err; + console.error('meetings_list_scheduled_bots per-event failed:', err); + return null as ScheduledBot[] | null; + }), + // Best-effort legacy full list for recorded/recent bot surfaces. Row + // invite state is driven by the per-event list above. + invoke('meetings_list_scheduled_bots', { + calendarEventIds: null, + }).catch((err) => { + console.error('meetings_list_scheduled_bots full-list failed:', err); + return null as ScheduledBot[] | null; + }), + ]); + const bots = mergeScheduledBotLookups(botEventIds, eventBots, fullBots); + if (botEventIds.length > 0 && eventBots === null) { + fetchError = friendlyError( + eventBotsErr, + 'Could not refresh meeting bot status.', + ); + refreshBlocked = !isAuthError(eventBotsErr); + lastRefreshErrorRaw = String(eventBotsErr ?? ''); + } // Keep the previously-painted pills on a transient bots miss. if (bots !== null) { botsByEventId = buildBotMap(bots);