diff --git a/documentation/CLIENT__IDAA_and_customized_mods.md b/documentation/CLIENT__IDAA_and_customized_mods.md index 77d08de0..1df1a0fe 100644 --- a/documentation/CLIENT__IDAA_and_customized_mods.md +++ b/documentation/CLIENT__IDAA_and_customized_mods.md @@ -85,8 +85,8 @@ src/routes/idaa/ │ │ └── [event_id]/ │ │ ├── +page.svelte # Meeting detail page — renders view OR edit based on session flag │ │ └── +page.ts -│ └── video_conferences/ # Jitsi video conference integration -└── jitsi_reports/ # External Jitsi reporting +│ ├── video_conferences/ # Jitsi video conference integration +│ └── jitsi_reports/ # Jitsi meeting activity log report (trusted_access only) ``` > **Note:** Recovery Meetings has **two UI entry points**: @@ -183,6 +183,12 @@ fetch(`${api_root_url}/customers/${uuid}`, { - **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails. +- **`jitsi_exclude_names`**: (optional) Array of display name strings to exclude from Jitsi Reports. + Used to hide known staff and test accounts from the activity report so participant counts and lists + reflect real member activity only. Names are matched case-insensitively against `final_participants[].displayName`. + Example: `["Scott I.", "Michelle V.", "Brie K."]`. The filter is applied before the "Real meetings only" + threshold check — a session with only excluded participants is treated as having 0 real participants. + - **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings). ### Stores / runtime fields set by verification @@ -446,6 +452,94 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store. --- +## Module 5: Jitsi Reports + +**Route:** `/idaa/jitsi_reports/` +**Access:** `trusted_access` or `novi_verified` — same gate as the rest of `(idaa)/` +**Data source:** `activity_log` table — `jitsi_meeting_event` and `jitsi_meeting_stats` log types +**Library function:** `qry__jitsi_report()` in `src/lib/ae_reports/reports_functions.ts` + +An admin/staff reporting tool that aggregates raw Jitsi activity logs into human-readable meeting sessions. It is **not** a member-facing page — IDAA members do not see it. + +### View Modes + +Two display modes, toggled via a button in the page header: + +| Mode | Description | +| --- | --- | +| **Grouped by Room** (default) | One collapsible section per `room_name`. Each section contains a compact table: Date / Time / Duration / Attendees / Participant List. Mirrors the output of the offline Python script (`create_jitsi_report.py`). | +| **Flat List** | Original card-per-session accordion layout. Better for drilling into event timelines and raw participant lists. | + +Both modes use the same filtered data set — switching views does not reset filters. + +### Filters + +| Filter | Default | Logic | +| --- | --- | --- | +| **Real meetings only** | off | Show only sessions where `real_participant_count >= 2` OR `duration > 5 min`. Applied **after** staff exclusion (see below). | +| **Min. Participants** | 0 | Minimum `real_participant_count` to display a session. | +| **Room Name** | (empty) | Case-insensitive substring match against `room_name`. | +| **From / To** | (empty) | Date range applied to `start_time`. "To" date includes the full end of day. | + +A "Reset Filters" button appears whenever any filter is non-default. + +### Staff / Test Exclusion + +**Problem:** Staff and test accounts (Scott, Michelle, Brie) join real member meetings for setup, testing, and tech support. Their presence inflates participant counts and pollutes the participant list. + +**Solution:** A configurable exclusion list in `site_cfg_json`: +```json +{ "jitsi_exclude_names": ["Scott I.", "Michelle V.", "Brie K."] } +``` + +**How it works (client-side only, no backend change needed):** + +1. On load, the page reads `$ae_loc.site_cfg_json?.jitsi_exclude_names` (string array, defaults to `[]`). +2. For every session, a `real_participants` derived list is computed by filtering `final_participants` against the exclusion list (case-insensitive display name match). +3. `real_participant_count = real_participants.length` — this count drives all filters, stats, and the participant list column in grouped view. +4. The raw `final_participant_count` from the API is never shown to the user once an exclusion list is configured. + +**Why display-name matching (not Novi UUID):** Jitsi participant data (`meta_json.participants`) only contains `displayName` and `role` — the Novi UUID is not passed through to the activity log. UUID-based exclusion would require a Jitsi config change plus a backend schema update and is deferred. Display names for OSIT staff are stable and controlled. + +### Summary Stats + +Shown above the meeting list when data is loaded. Stats reflect the **filtered + exclusion-applied** view: + +- **Meetings Shown** — count of sessions passing all filters +- **Total Participants** — sum of `real_participant_count` across all shown sessions +- **Avg Duration** — mean session duration (HH:MM:SS) +- **Total Duration** — sum of all session durations (HH:MM:SS) + +In grouped view, each room header also shows its own subtotals (meeting count, unique participants). + +### 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`. + +### Export + +CSV and JSON export buttons in the page header export the **currently filtered + exclusion-applied** data set. + +### Room Name Fragmentation + +The same logical meeting can appear as multiple rooms (e.g. `IDAA-BIPOC-Meeting`, `IDAA-BIPOC-Meeting-2026`, `IDAA-BIPOC-Meeting-March-31`) because the Jitsi URL builder appends a date suffix to generate unique per-session room names. In grouped view, these appear as separate groups. A future normalization pass (strip trailing date suffixes) could optionally merge them — not implemented yet. + +### Data Flow + +```text +activity_log table + └── qry__jitsi_report() # reports_functions.ts — fetches + aggregates by meeting_id + └── MeetingReport[] # { meeting_id, room_name, start_time, final_duration, + # final_participants, final_participant_count, events } + └── jitsi_reports/+page.svelte + ├── apply exclusion list → real_participants / real_participant_count + ├── apply filters → meetings_filtered + ├── derive grouped view → Map + └── render flat or grouped +``` + +--- + ## State Management (`ae_idaa_stores.ts`) Four stores manage all IDAA state: @@ -582,6 +676,7 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... }; | Bulletin Board | ❌ None | Priority — most sensitive module | | Recovery Meetings | ✅ Substantial | `tests/idaa_recovery_meeting_edit.test.ts` — form render, field interactions, PATCH payload verification (all sections), real backend save, creation linkage (Novi UUID in POST body) | | Video Conferences | ❌ None | Jitsi complexity, lower priority | +| Jitsi Reports | ❌ None | Admin-only tool; lower privacy risk than member modules | **Pending:** BB Post and Post Comment creation linkage tests (pattern established in Recovery Meetings test). @@ -634,4 +729,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... }; --- **Document Status:** ✅ Current -**Last Verified:** 2026-04-07 — updated for Novi UUID triple-linkage enforcement, staff editing rules, Contact 1 convention, test coverage +**Last Verified:** 2026-05-05 — added Module 5: Jitsi Reports (grouped view, real-meetings filter, staff exclusion via `jitsi_exclude_names`); fixed route tree (`jitsi_reports/` is inside `(idaa)/`) diff --git a/src/lib/ae_reports/reports_functions.ts b/src/lib/ae_reports/reports_functions.ts index d3916dfb..24086bc2 100644 --- a/src/lib/ae_reports/reports_functions.ts +++ b/src/lib/ae_reports/reports_functions.ts @@ -7,6 +7,23 @@ 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'; +// MariaDB TEXT columns come back as JSON strings from the API — parse safely. +function safe_parse_meta(raw: unknown): Record { + if (!raw) return {}; + if (typeof raw === 'object') return raw as Record; + try { + return JSON.parse(raw as string) as Record; + } catch { + return {}; + } +} + +function parse_duration_seconds(d: string): number { + if (!d || !d.includes(':')) return 0; + const [h, m, s] = d.split(':').map(Number); + return (h || 0) * 3600 + (m || 0) * 60 + (s || 0); +} + /** * @description Queries all Jitsi-related activity logs and processes them into a structured report, * grouped by meeting ID. @@ -96,50 +113,121 @@ 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, deduplicating by name. + // + // 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 → role + 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; - // Make sure the name field is prefixed with "jitsi_" - if (!log.name.startsWith('jitsi_')) continue; - - // Ensure a base entry for the meeting exists if (!meetings.has(meeting_id)) { meetings.set(meeting_id, { - meeting_id: meeting_id, + meeting_id, room_name: 'Unknown', - start_time: log.created_on, // Fallback start time + 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') { - // This is the main log entry, containing the final state. - meeting_report.room_name = log.description; - meeting_report.start_time = log.created_on; // The init log has the true start time - if (log.meta_json) { - meeting_report.final_duration = log.meta_json.duration; - meeting_report.final_participants = log.meta_json.participants; - meeting_report.final_participant_count = - log.meta_json.participant_count; + // 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; + } } - } else { - // This is a discrete event log. + + // Merge snapshot participant list (has role info — preferred source) + const snapshot = meta.participants as + | Array<{ displayName?: string; role?: string }> + | undefined; + if (Array.isArray(snapshot)) { + for (const p of snapshot) { + if (!p.displayName) continue; + // Only overwrite an existing entry if we're upgrading from participant → moderator + const existing_role = p_map.get(p.displayName); + if (!existing_role || (existing_role !== 'moderator' && p.role === 'moderator')) { + p_map.set(p.displayName, p.role ?? 'participant'); + } + } + } + } + + // Collect participants from join events (may catch people who left before the snapshot) + if (log.action === 'jitsi_meeting_participant_joined') { + const full_name = (meta.full_name as string) || ''; + if (full_name && !p_map.has(full_name)) { + p_map.set(full_name, (meta.role as string) ?? 'participant'); + } + } + + // 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: log.meta_json + 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.entries()) + .map(([displayName, role]) => ({ displayName, role })) + .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( diff --git a/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte b/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte index a1ab4d68..5f78fc2f 100644 --- a/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte +++ b/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte @@ -27,12 +27,16 @@ interface MeetingReport { events: MeetingEvent[]; } +// Extends MeetingReport with exclusion-applied participant data +interface MeetingReportEnriched extends MeetingReport { + real_participants: MeetingParticipant[]; + real_participant_count: number; +} + // --- Data state --- -// Resolve the streamed promise into reactive state so we can filter and export it. let meetings_all = $state([]); let meetings_loading = $state(true); let meetings_error = $state(null); - let meetings_load_started = $state(false); $effect(() => { @@ -65,33 +69,96 @@ $effect(() => { }); }); +// --- Staff/test exclusion --- +// Read display names to exclude from site_cfg_json.jitsi_exclude_names. +// Matched case-insensitively against MeetingParticipant.displayName. +let exclude_names = $derived( + (($ae_loc.site_cfg_json as Record) + ?.jitsi_exclude_names as string[] | undefined) + ?.map((n) => n.toLowerCase().trim()) ?? [] +); + +// Apply exclusion to every meeting — produces the "real" participant list +// used by all downstream filters, stats, and display. +let meetings_enriched = $derived( + meetings_all.map((m) => { + // final_participants can be null/undefined if meta_json.participants was null in the API response + const all_participants: MeetingParticipant[] = m.final_participants ?? []; + const real_participants = + exclude_names.length > 0 + ? all_participants.filter( + (p) => + !exclude_names.includes( + p.displayName.toLowerCase().trim() + ) + ) + : all_participants; + return { + ...m, + real_participants, + real_participant_count: real_participants.length + }; + }) +); + +// --- View mode --- +let group_by_room = $state(true); + // --- Filter state --- -// Default 0 so historical logs (which lack an init record and have count=0) are visible. -// Once logging is fully established, this can be raised to 1 to hide empty/test meetings. +let filter_real_only = $state(false); let filter_min_participants = $state(0); let filter_room_name = $state(''); let filter_date_from = $state(''); let filter_date_to = $state(''); let filters_are_modified = $derived( - filter_min_participants !== 0 || + filter_real_only || + filter_min_participants !== 0 || filter_room_name !== '' || filter_date_from !== '' || filter_date_to !== '' ); function reset_filters() { + filter_real_only = false; filter_min_participants = 0; filter_room_name = ''; filter_date_from = ''; filter_date_to = ''; } +// --- Duration helpers --- +function parse_duration_seconds(d: string): number { + if (!d) return 0; + const parts = d.split(':').map(Number); + return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0); +} + +function format_seconds(total: number): string { + const h = Math.floor(total / 3600).toString().padStart(2, '0'); + const m = Math.floor((total % 3600) / 60).toString().padStart(2, '0'); + const s = Math.floor(total % 60).toString().padStart(2, '0'); + return `${h}:${m}:${s}`; +} + +function compute_end_time(start_time: string, duration: string): string { + if (!start_time || !duration) return '—'; + const start = new Date(start_time); + const secs = parse_duration_seconds(duration); + if (secs === 0) return '—'; + const end = new Date(start.getTime() + secs * 1000); + return end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + // --- Derived: filtered meetings --- +// All counts and thresholds use real_participant_count (post-exclusion). let meetings_filtered = $derived.by(() => { - return meetings_all.filter((m) => { - if ((m.final_participant_count ?? 0) < filter_min_participants) - return false; + return meetings_enriched.filter((m) => { + if (filter_real_only) { + const dur_secs = parse_duration_seconds(m.final_duration); + if (m.real_participant_count < 2 && dur_secs <= 300) return false; + } + if (m.real_participant_count < filter_min_participants) return false; if ( filter_room_name && !m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase()) @@ -102,7 +169,6 @@ let meetings_filtered = $derived.by(() => { return false; } if (filter_date_to) { - // Include full end-of-day by appending T23:59:59 to the date string if ( Date.parse(m.start_time) > Date.parse(filter_date_to + 'T23:59:59.999') @@ -113,30 +179,36 @@ let meetings_filtered = $derived.by(() => { }); }); +// --- Derived: grouped by room --- +// Rooms sorted by most-recent session desc; sessions within each room sorted asc. +let meetings_grouped = $derived.by>(() => { + const groups = new Map(); + for (const m of meetings_filtered) { + const room = m.room_name ?? 'Unknown'; + if (!groups.has(room)) groups.set(room, []); + groups.get(room)!.push(m); + } + for (const sessions of groups.values()) { + sessions.sort( + (a, b) => + new Date(a.start_time).getTime() - + new Date(b.start_time).getTime() + ); + } + return new Map( + [...groups.entries()].sort((a, b) => { + const a_last = a[1].at(-1)?.start_time ?? ''; + const b_last = b[1].at(-1)?.start_time ?? ''; + return new Date(b_last).getTime() - new Date(a_last).getTime(); + }) + ); +}); + // --- Summary stats --- -function parse_duration_seconds(d: string): number { - if (!d) return 0; - const parts = d.split(':').map(Number); - return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0); -} - -function format_seconds(total: number): string { - const h = Math.floor(total / 3600) - .toString() - .padStart(2, '0'); - const m = Math.floor((total % 3600) / 60) - .toString() - .padStart(2, '0'); - const s = Math.floor(total % 60) - .toString() - .padStart(2, '0'); - return `${h}:${m}:${s}`; -} - let summary = $derived.by(() => { const count = meetings_filtered.length; const total_participants = meetings_filtered.reduce( - (sum, m) => sum + (m.final_participant_count ?? 0), + (sum, m) => sum + m.real_participant_count, 0 ); const total_secs = meetings_filtered.reduce( @@ -152,14 +224,41 @@ let summary = $derived.by(() => { }; }); -// --- Accordion state --- -let open_accordions = $state<{ [key: string]: boolean }>({}); -let show_url_builder = $state(false); +function room_stats(sessions: MeetingReportEnriched[]) { + const total_secs = sessions.reduce( + (sum, m) => sum + parse_duration_seconds(m.final_duration), + 0 + ); + const unique_names = new Set( + sessions.flatMap((m) => m.real_participants.map((p) => p.displayName)) + ); + return { + meeting_count: sessions.length, + total_duration: format_seconds(total_secs), + unique_participant_count: unique_names.size + }; +} + +// --- Accordion / room open state --- +let open_accordions = $state>({}); +// Rooms default to open; only track explicitly-closed rooms. +let closed_rooms = $state>({}); function toggle_accordion(meeting_id: string) { open_accordions[meeting_id] = !open_accordions[meeting_id]; } +function toggle_room(room_name: string) { + closed_rooms[room_name] = !closed_rooms[room_name]; +} + +function is_room_open(room_name: string): boolean { + return !closed_rooms[room_name]; +} + +// --- URL Builder --- +let show_url_builder = $state(false); + // --- Export --- function download_file(content: string, filename: string, mime: string) { const blob = new Blob([content], { type: mime }); @@ -180,7 +279,8 @@ function export_csv() { 'Room Name', 'Start Time', 'Duration', - 'Participant Count' + 'Participant Count', + 'Participants' ] ]; for (const m of meetings_filtered) { @@ -189,7 +289,8 @@ function export_csv() { m.room_name ?? '', m.start_time ? new Date(m.start_time).toISOString() : '', m.final_duration ?? '', - String(m.final_participant_count ?? 0) + String(m.real_participant_count), + m.real_participants.map((p) => p.displayName).join('; ') ]); } const csv = rows @@ -202,7 +303,15 @@ function export_csv() { function export_json() { download_file( - JSON.stringify(meetings_filtered, null, 2), + JSON.stringify( + meetings_filtered.map((m) => ({ + ...m, + final_participants: m.real_participants, + final_participant_count: m.real_participant_count + })), + null, + 2 + ), 'jitsi_meeting_report.json', 'application/json' ); @@ -214,10 +323,33 @@ function export_json() {
- +

Jitsi Meeting Reports

-
+
+ +
+ + +
@@ -268,6 +400,16 @@ function export_json() {
+ + +
+ {:else} +
+ {#if meetings_all.length > 0} +
+ No meetings match the current filters +
+

+ Try lowering the minimum participants or clearing + the date range. +

+ + {:else} +
No Meeting Reports Found
+

+ There are no Jitsi activity logs to display. +

+ {/if} +
+ {/if} {/if} {/if}