diff --git a/documentation/CLIENT__IDAA_and_customized_mods.md b/documentation/CLIENT__IDAA_and_customized_mods.md index 1a9fda64..85705bba 100644 --- a/documentation/CLIENT__IDAA_and_customized_mods.md +++ b/documentation/CLIENT__IDAA_and_customized_mods.md @@ -490,6 +490,13 @@ Both modes use the same filtered data set — switching views does not reset fil A "Reset Filters" button appears whenever any filter is non-default. +In edit mode, two extra toggles appear: +- **Show excluded IDs** — temporarily include the UUIDs listed in `jitsi_exclude_uuids` +- **Show all meetings** — temporarily ignore `jitsi_known_meetings` + +An "Active Exclusions" panel below the filter bar shows the currently applied Novi UUID exclusions +and known meeting-name whitelist values. Each list is collapsible so the page stays compact. + ### Staff / Meeting Filtering **Problem:** Staff/test accounts and one-off test rooms distort the reports. @@ -505,12 +512,16 @@ A "Reset Filters" button appears whenever any filter is non-default. **How it works:** 1. The page reads `$ae_loc.site_cfg_json?.jitsi_exclude_uuids` and excludes matching participants by Novi UUID. + The UUID comes from the Jitsi log `url_params.uuid` field. `g_uuid` is the meeting/group UUID and is not used here. 2. If a participant record does not include a UUID in the activity log, it is left visible; UUIDs are used whenever available. 3. `real_participant_count = real_participants.length` drives filters, exports, and the per-meeting attendee count. 4. Room-level unique participant counts are computed from Novi UUIDs when present, with display-name fallback only for UUID-less records. 5. If `$ae_loc.site_cfg_json?.jitsi_known_meetings` is non-empty, only meetings whose `room_name` matches one of the listed names are shown. 6. The Room Name filter is only shown when global edit mode is enabled. +**Temporary stopgap:** the report also hides these staff display names through the same UUID-exclusion toggle until the long-term logging fix lands: +`Scott I.`, `Brie P.`, `Michelle V.` + **Note:** matching is case-insensitive on the stored `room_name` / meeting name. ### Summary Stats @@ -524,6 +535,12 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered + In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available). +### Caching / Load Behavior + +The page now reads cached `activity_log` rows from IndexedDB first, renders that result immediately, +then refreshes from the API in the background. That keeps the report usable even when the network +round-trip is slow. + ### Jitsi URL Builder Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`. diff --git a/src/lib/ae_core/ae_core__activity_log.ts b/src/lib/ae_core/ae_core__activity_log.ts index 9c71dbe1..63c1fdc9 100644 --- a/src/lib/ae_core/ae_core__activity_log.ts +++ b/src/lib/ae_core/ae_core__activity_log.ts @@ -270,6 +270,9 @@ export const properties_to_save = [ 'user_id', 'user_id_random', 'external_client_id', + 'url_root', + 'url_full_path', + 'url_params', 'source', 'object_type', 'object_id', diff --git a/src/lib/ae_reports/reports_functions.ts b/src/lib/ae_reports/reports_functions.ts index f5ec3f82..22b08d79 100644 --- a/src/lib/ae_reports/reports_functions.ts +++ b/src/lib/ae_reports/reports_functions.ts @@ -7,6 +7,28 @@ import { import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie'; import { db_core } from '$lib/ae_core/db_core'; +interface MeetingEvent { + timestamp: string; + action: string; + details: { full_name?: string }; +} + +interface MeetingParticipant { + displayName: string; + role: string; + novi_uuid?: string; +} + +interface MeetingReport { + meeting_id: string; + room_name: string; + start_time: string; + final_duration: string; + final_participant_count: number; + final_participants: MeetingParticipant[]; + events: MeetingEvent[]; +} + // MariaDB TEXT columns come back as JSON strings from the API — parse safely. function safe_parse_meta(raw: unknown): Record { if (!raw) return {}; @@ -32,6 +54,16 @@ function normalize_uuid(value: unknown): string { return normalize_text(value).toLowerCase(); } +function extract_uuid_from_url_params(raw: unknown): string { + if (typeof raw !== 'string' || !raw.trim()) return ''; + try { + const params = new URLSearchParams(raw); + return normalize_uuid(params.get('uuid')); + } catch { + return ''; + } +} + function extract_participant_uuid(source: Record): string { const nested_user = source.user as Record | undefined; const nested_context = source.context as Record | undefined; @@ -45,12 +77,8 @@ function extract_participant_uuid(source: Record): string { source.novi_customer_uid, nested_user?.novi_uuid, nested_user?.uuid, - nested_user?.id, nested_context_user?.novi_uuid, - nested_context_user?.uuid, - nested_context_user?.id, - source.user_id, - source.id + nested_context_user?.uuid ]; for (const candidate of candidates) { @@ -73,6 +101,236 @@ function extract_participant_uuid(source: Record): string { * @param log_lvl The logging level. * @returns A structured array of meeting report objects. */ +function build_jitsi_report_from_logs( + flat_log_list: any[], + log_lvl = 0 +): MeetingReport[] { + // Participants come from two sources — both are needed for a complete list: + // 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined) + // 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role + // Source 2 has role info; source 1 may catch participants who left before the snapshot. + // We merge both into a Map per meeting, keeping the + // stable display-name key used by the existing report while preserving Novi UUIDs + // when the log payload includes them. + // + // Duration: take the MAX across all init/stats/end events — periodic stats may + // have a higher value than the final init summary in some Jitsi configurations. + + const meetings = new Map(); + const participant_maps = new Map>(); + const max_duration_secs = new Map(); + + for (const log of flat_log_list) { + const meeting_id = log.external_client_id; + if (!meeting_id) continue; + if (!log.name?.startsWith('jitsi_')) continue; + + if (!meetings.has(meeting_id)) { + meetings.set(meeting_id, { + meeting_id, + room_name: 'Unknown', + start_time: log.created_on, + final_duration: '00:00:00', + final_participants: [], + final_participant_count: 0, + events: [] + }); + participant_maps.set(meeting_id, new Map()); + max_duration_secs.set(meeting_id, 0); + } + + const meeting_report = meetings.get(meeting_id)!; + const p_map = participant_maps.get(meeting_id)!; + const meta = safe_parse_meta(log.meta_json); + const log_novi_uuid = extract_uuid_from_url_params(log.url_params); + + if (log.action === 'jitsi_meeting_init') { + // Strip "Event in room: " prefix Jitsi sometimes prepends to the description + meeting_report.room_name = + (log.description ?? '').replace(/^Event in room:\s*/i, '').trim() || + 'Unknown'; + meeting_report.start_time = log.created_on; + } + + // Parse duration from init, stats, or end — keep the maximum seen + if ( + log.action === 'jitsi_meeting_init' || + log.action === 'jitsi_meeting_stats' || + log.action === 'jitsi_meeting_end' + ) { + const dur_str = ((meta.duration ?? meta.final_duration) as string) || ''; + if (dur_str) { + const secs = parse_duration_seconds(dur_str); + if (secs > (max_duration_secs.get(meeting_id) ?? 0)) { + max_duration_secs.set(meeting_id, secs); + meeting_report.final_duration = dur_str; + } + } + + // Merge snapshot participant list (has role info — preferred source) + const snapshot = meta.participants as + | Array> + | undefined; + if (Array.isArray(snapshot)) { + for (const p of snapshot) { + const display_name = normalize_text(p.displayName); + if (!display_name) continue; + + const role = + typeof p.role === 'string' ? p.role : 'participant'; + const novi_uuid = + extract_participant_uuid(p) || + (p.role === 'moderator' + ? normalize_uuid(meta.moderator_novi_uuid) + : ''); + const existing_participant = p_map.get(display_name); + + if (!existing_participant) { + p_map.set(display_name, { + displayName: display_name, + role, + ...(novi_uuid ? { novi_uuid } : {}) + }); + continue; + } + + if ( + existing_participant.role !== 'moderator' && + role === 'moderator' + ) { + existing_participant.role = 'moderator'; + } + if (!existing_participant.novi_uuid && novi_uuid) { + existing_participant.novi_uuid = novi_uuid; + } + } + } + } + + // Collect participants from join events (may catch people who left before the snapshot) + if (log.action === 'jitsi_meeting_participant_joined') { + const display_name = normalize_text(meta.full_name); + if (display_name) { + const role = + typeof meta.role === 'string' ? meta.role : 'participant'; + const novi_uuid = log_novi_uuid || extract_participant_uuid(meta); + const existing_participant = p_map.get(display_name); + + if (!existing_participant) { + p_map.set(display_name, { + displayName: display_name, + role, + ...(novi_uuid ? { novi_uuid } : {}) + }); + } else { + if ( + existing_participant.role !== 'moderator' && + role === 'moderator' + ) { + existing_participant.role = 'moderator'; + } + if (!existing_participant.novi_uuid && novi_uuid) { + existing_participant.novi_uuid = novi_uuid; + } + } + } + } + + // Discrete events for the timeline (all non-init actions) + if (log.action !== 'jitsi_meeting_init') { + meeting_report.events.push({ + timestamp: log.created_on, + action: log.action, + details: { + full_name: (meta.full_name as string) ?? undefined + } + }); + } + } + + // Compile final participant lists from the deduplicated maps + for (const [meeting_id, p_map] of participant_maps) { + const meeting_report = meetings.get(meeting_id); + if (!meeting_report) continue; + if (p_map.size > 0) { + // Sort: moderators first, then alphabetically + meeting_report.final_participants = Array.from(p_map.values()).sort( + (a: MeetingParticipant, b: MeetingParticipant) => { + if (a.role === 'moderator' && b.role !== 'moderator') return -1; + if (a.role !== 'moderator' && b.role === 'moderator') return 1; + return a.displayName.localeCompare(b.displayName); + } + ); + meeting_report.final_participant_count = p_map.size; + } + } + + // Sort events within each meeting chronologically + for (const report of meetings.values()) { + report.events.sort( + (a: MeetingEvent, b: MeetingEvent) => + new Date(a.timestamp).getTime() - + new Date(b.timestamp).getTime() + ); + } + + const final_report = Array.from(meetings.values()); + final_report.sort( + (a, b) => + new Date(b.start_time).getTime() - new Date(a.start_time).getTime() + ); + + if (log_lvl) console.log('Final Jitsi report:', final_report); + + return final_report; +} + +export async function load_jitsi_report_from_cache({ + account_id, + limit = 500, + log_lvl = 0 +}: { + account_id: string; + limit?: number; + log_lvl?: number; +}): Promise { + try { + const cached_logs = await db_core.activity_log + .where('account_id_random') + .equals(account_id) + .and( + (log) => + log.name === 'jitsi_meeting_event' || + log.name === 'jitsi_meeting_stats' + ) + .limit(limit) + .toArray(); + + if (cached_logs.length === 0) return null; + if ( + cached_logs.some( + (log) => typeof log.url_params !== 'string' || !log.url_params + ) + ) { + if (log_lvl) { + console.log( + 'Jitsi report cache is missing url_params; using API refresh for accurate UUID filtering.' + ); + } + return null; + } + if (log_lvl) { + console.log( + `Jitsi report cache hit: ${cached_logs.length} activity_log rows` + ); + } + return build_jitsi_report_from_logs(cached_logs, log_lvl); + } catch (err) { + if (log_lvl) console.warn('Jitsi report cache read failed.', err); + return null; + } +} + export async function qry__jitsi_report({ api_cfg, account_id, @@ -149,182 +407,7 @@ export async function qry__jitsi_report({ }); } - // Step 2: Process the flat list into a structured report. - // - // Participants come from two sources — both are needed for a complete list: - // 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined) - // 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role - // Source 2 has role info; source 1 may catch participants who left before the snapshot. - // We merge both into a Map per meeting, keeping the - // stable display-name key used by the existing report while preserving Novi UUIDs - // when the log payload includes them. - // - // Duration: take the MAX across all init/stats/end events — periodic stats may - // have a higher value than the final init summary in some Jitsi configurations. - - const meetings = new Map(); - // Per-meeting participant map: displayName → participant record - const participant_maps = new Map>(); - // Per-meeting max observed duration in seconds - const max_duration_secs = new Map(); - - for (const log of flat_log_list) { - const meeting_id = log.external_client_id; - if (!meeting_id) continue; - if (!log.name?.startsWith('jitsi_')) continue; - - if (!meetings.has(meeting_id)) { - meetings.set(meeting_id, { - meeting_id, - room_name: 'Unknown', - start_time: log.created_on, - final_duration: '00:00:00', - final_participants: [], - final_participant_count: 0, - events: [] - }); - participant_maps.set(meeting_id, new Map()); - max_duration_secs.set(meeting_id, 0); - } - - const meeting_report = meetings.get(meeting_id); - const p_map = participant_maps.get(meeting_id)!; - const meta = safe_parse_meta(log.meta_json); - - if (log.action === 'jitsi_meeting_init') { - // Strip "Event in room: " prefix Jitsi sometimes prepends to the description - meeting_report.room_name = - (log.description ?? '').replace(/^Event in room:\s*/i, '').trim() || - 'Unknown'; - meeting_report.start_time = log.created_on; - } - - // Parse duration from init, stats, or end — keep the maximum seen - if ( - log.action === 'jitsi_meeting_init' || - log.action === 'jitsi_meeting_stats' || - log.action === 'jitsi_meeting_end' - ) { - const dur_str = ((meta.duration ?? meta.final_duration) as string) || ''; - if (dur_str) { - const secs = parse_duration_seconds(dur_str); - if (secs > (max_duration_secs.get(meeting_id) ?? 0)) { - max_duration_secs.set(meeting_id, secs); - meeting_report.final_duration = dur_str; - } - } - - // Merge snapshot participant list (has role info — preferred source) - const snapshot = meta.participants as - | Array> - | undefined; - if (Array.isArray(snapshot)) { - for (const p of snapshot) { - const display_name = normalize_text(p.displayName); - if (!display_name) continue; - - const role = - typeof p.role === 'string' ? p.role : 'participant'; - const novi_uuid = extract_participant_uuid(p); - const existing_participant = p_map.get(display_name); - - if (!existing_participant) { - p_map.set(display_name, { - displayName: display_name, - role, - ...(novi_uuid ? { novi_uuid } : {}) - }); - continue; - } - - if ( - existing_participant.role !== 'moderator' && - role === 'moderator' - ) { - existing_participant.role = 'moderator'; - } - if (!existing_participant.novi_uuid && novi_uuid) { - existing_participant.novi_uuid = novi_uuid; - } - } - } - } - - // Collect participants from join events (may catch people who left before the snapshot) - if (log.action === 'jitsi_meeting_participant_joined') { - const display_name = normalize_text(meta.full_name); - if (display_name) { - const role = - typeof meta.role === 'string' ? meta.role : 'participant'; - const novi_uuid = extract_participant_uuid(meta); - const existing_participant = p_map.get(display_name); - - if (!existing_participant) { - p_map.set(display_name, { - displayName: display_name, - role, - ...(novi_uuid ? { novi_uuid } : {}) - }); - } else { - if ( - existing_participant.role !== 'moderator' && - role === 'moderator' - ) { - existing_participant.role = 'moderator'; - } - if (!existing_participant.novi_uuid && novi_uuid) { - existing_participant.novi_uuid = novi_uuid; - } - } - } - } - - // Discrete events for the timeline (all non-init actions) - if (log.action !== 'jitsi_meeting_init') { - meeting_report.events.push({ - timestamp: log.created_on, - action: log.action, - details: { - full_name: (meta.full_name as string) ?? undefined - } - }); - } - } - - // Compile final participant lists from the deduplicated maps - for (const [meeting_id, p_map] of participant_maps) { - const meeting_report = meetings.get(meeting_id); - if (!meeting_report) continue; - if (p_map.size > 0) { - // Sort: moderators first, then alphabetically - meeting_report.final_participants = Array.from(p_map.values()) - .sort((a: any, b: any) => { - if (a.role === 'moderator' && b.role !== 'moderator') return -1; - if (a.role !== 'moderator' && b.role === 'moderator') return 1; - return a.displayName.localeCompare(b.displayName); - }); - meeting_report.final_participant_count = p_map.size; - } - } - - // Sort events within each meeting chronologically - for (const report of meetings.values()) { - report.events.sort( - (a: any, b: any) => - new Date(a.timestamp).getTime() - - new Date(b.timestamp).getTime() - ); - } - - const final_report = Array.from(meetings.values()); - final_report.sort( - (a, b) => - new Date(b.start_time).getTime() - new Date(a.start_time).getTime() - ); - - if (log_lvl) console.log('Final Jitsi report:', final_report); - - return final_report; + return build_jitsi_report_from_logs(flat_log_list, log_lvl); } export const load_jitsi_report = qry__jitsi_report; diff --git a/src/lib/types/ae_types.ts b/src/lib/types/ae_types.ts index a8f291d3..896dfea6 100644 --- a/src/lib/types/ae_types.ts +++ b/src/lib/types/ae_types.ts @@ -415,6 +415,9 @@ export interface ae_ActivityLog extends ae_BaseObj { external_client_id?: string; source?: string; + url_root?: string; + url_full_path?: string; + url_params?: string; object_type?: string; object_id_random?: string; diff --git a/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte b/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte index 34370f02..12bbbe57 100644 --- a/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte +++ b/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte @@ -1,7 +1,10 @@