diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html index d8ed5909..d8451e70 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html @@ -70,64 +70,78 @@
-
- -

- {{ currentOccurrence()?.title || meeting().title }} -

- - -
- - @if (currentOccurrence()?.start_time || meeting().start_time; as startTime) { -
- - {{ startTime | meetingTime: currentOccurrence()?.duration || meeting().duration : 'compact' }} -
- } +
+
+ +

+ {{ currentOccurrence()?.title || meeting().title }} +

- - @if (meeting().youtube_upload_enabled) { -
- - YouTube Upload -
- } + +
+ + @if (currentOccurrence()?.start_time || meeting().start_time; as startTime) { +
+ + {{ startTime | meetingTime: currentOccurrence()?.duration || meeting().duration : 'compact' }} +
+ } - - @if (meeting().recording_enabled) { -
- - Recording -
- } + + @if (meeting().youtube_upload_enabled) { +
+ + YouTube Upload +
+ } - - @if (meeting().transcript_enabled) { -
- - Transcripts -
- } + + @if (meeting().recording_enabled) { +
+ + Recording +
+ } - - @if (meeting().zoom_config?.ai_companion_enabled) { -
- - Live Chat -
- } + + @if (meeting().transcript_enabled) { +
+ + Transcripts +
+ } + + + @if (meeting().zoom_config?.ai_companion_enabled) { +
+ + Live Chat +
+ } +
+ + @if (messageSeverity() === 'warn') { +
+ +

{{ alertMessage() }}

+
+ } @else if (messageSeverity() === 'info' && canJoinMeeting()) { +
+ +

{{ alertMessage() }}

+
+ }
@@ -147,26 +161,44 @@

+ } @else if (authenticated() && meeting().organizer) { + +
+ + + + + + +
} @else if (authenticated()) { - +
}
- - @if (messageSeverity() === 'warn') { -
- -

{{ alertMessage() }}

-
- } @else if (messageSeverity() === 'info' && canJoinMeeting()) { -
- -

{{ alertMessage() }}

-
- } + + +
@@ -230,130 +262,133 @@

Resources

- - - @if (authenticated() && user(); as user) { - -
-

I am signed in as:

+ +
-
- -
- {{ (user.name || 'User').substring(0, 2).toUpperCase() }} -
+ + @if (authenticated() && user(); as user) { + +
+

I am signed in as:

- -
-

{{ user.name }}

-

{{ user.email }}

-
+
+ +
+ {{ (user.name || 'User').substring(0, 2).toUpperCase() }} +
+ + +
+

{{ user.name }}

+

{{ user.email }}

+
- - - - Log Out - + + + + Log Out + +
-
- } @else { - -
- -
-
- -
-

Sign in with your LFX account

-

Join quickly with your saved information

+ } @else { + +
+ +
+
+ +
+

Sign in with your LFX account

+

Join quickly with your saved information

+
+ + +
- - -
+ +
+ OR +
- -
- OR -
+ +
+

Enter your information

- -
-

Enter your information

+
+ +
+
+ + + +
- - -
-
- - - +
+ + + +
-
+ +
+ [attr.data-testid]="'guest-form-organization'">
-
- - -
- - - -
- -
- - -
- + +
+ + +
+ +
-
- } + } +
diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts index 831fbd5e..f7bd37f5 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts @@ -14,6 +14,8 @@ import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { ExpandableTextComponent } from '@components/expandable-text/expandable-text.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; +import { MeetingRegistrantsComponent } from '@components/meeting-registrants/meeting-registrants.component'; +import { MeetingRsvpDetailsComponent } from '@components/meeting-rsvp-details/meeting-rsvp-details.component'; import { RsvpButtonGroupComponent } from '@components/rsvp-button-group/rsvp-button-group.component'; import { environment } from '@environments/environment'; import { @@ -46,6 +48,8 @@ import { catchError, combineLatest, debounceTime, filter, map, Observable, of, s CardComponent, InputTextComponent, RsvpButtonGroupComponent, + MeetingRsvpDetailsComponent, + MeetingRegistrantsComponent, ToastModule, TooltipModule, MeetingTimePipe, @@ -86,9 +90,10 @@ export class MeetingJoinComponent { public messageIcon: Signal; public alertMessage: Signal; private hasAutoJoined: WritableSignal = signal(false); + public showRegistrants: WritableSignal = signal(false); // Form value signals for reactivity - private formValues: Signal<{ name: string; email: string; organization: string }>; + public formValues: Signal<{ name: string; email: string; organization: string }>; public constructor() { // Initialize all class variables @@ -120,6 +125,10 @@ export class MeetingJoinComponent { }); } + public onRegistrantsToggle(): void { + this.showRegistrants.set(!this.showRegistrants()); + } + private initializeAutoJoin(): void { // Use toObservable to create an Observable from the signals, then subscribe once // This executes only when all conditions are met diff --git a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html index 28094135..1f4c6d59 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html +++ b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html @@ -81,7 +81,7 @@ data-testid="share-meeting-button" tooltip="Share Meeting"> } - @if (project()?.slug && meeting().organizer) { + @if (meeting().project_slug && meeting().organizer) { + [routerLink]="['/project', meeting().project_slug, 'meetings', meeting().uid, 'edit']"> } - - @if (!pastMeeting() && authenticated() && !meeting().organizer) { -
- -
- } - @if (pastMeeting()) {
@@ -281,102 +274,35 @@

-
-
-
- - {{ pastMeeting() ? 'Attendees' : 'People Invited' }} -
- @if (!pastMeeting()) { - - - } -
- -
-
- - @if (pastMeeting()) { - {{ attendedCount() }} of {{ meetingRegistrantCount() }} attended - } @else { - {{ meeting().registrants_accepted_count || 0 }} of {{ meetingRegistrantCount() }} attending - } - - {{ attendancePercentage() }}% -
- - -
- @if (meeting().registrants_accepted_count && meeting().registrants_accepted_count > 0) { -
- } - @if (!pastMeeting() && meeting().registrants_pending_count && meeting().registrants_pending_count > 0) { -
- } - @if (!pastMeeting() && meeting().registrants_declined_count && meeting().registrants_declined_count > 0) { -
- } -
- - - @if (!pastMeeting()) { -
-
- - {{ meeting().registrants_accepted_count || 0 }} Yes -
-
- - {{ meeting().registrants_pending_count || 0 }} Maybe -
-
- - {{ meeting().registrants_declined_count || 0 }} No -
-
- } - - - @if (pastMeeting() && attendancePercentage() < 50) { -
- -

This meeting was poorly attended

-
- } -
-
+ @if (meeting().organizer) { + + + + } @else { + + + }
- - + @if (meeting().organizer) { + + + } - @if (showRegistrants()) { -
- @if (registrantsLoading()) { -
- -
- } - - @if (!registrantsLoading()) { -
- @if (!pastMeeting()) { - -
-
- -
-
- Add Guests - Click to add new guests -
-
- } - - @if (pastMeeting()) { - @for (participant of pastMeetingParticipants(); track participant.uid) { -
- @if (participant.is_attended) { - - } @else { - - } -
-
- - {{ participant.first_name }} {{ participant.last_name }} -
- {{ participant.email }} -
-
- } - } @else { - @for (registrant of registrants(); track registrant.uid) { -
-
- -
- @if (registrant.invite_accepted === false) { - - } @else if (!registrant.invite_accepted) { - - } @else { - - } -
-
-
-
- @if (registrant.type === 'committee') { - - } @else { - - } - {{ registrant.first_name }} {{ registrant.last_name }} -
- {{ registrant.email }} -
-
- } - } -
- } -
- } + + diff --git a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts index 514bc842..cef3a40f 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts +++ b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts @@ -4,12 +4,11 @@ import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { Component, computed, effect, inject, Injector, input, OnInit, output, runInInjectionContext, signal, Signal, WritableSignal } from '@angular/core'; -import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FileSizePipe } from '@app/shared/pipes/file-size.pipe'; import { FileTypeIconPipe } from '@app/shared/pipes/file-type-icon.pipe'; import { LinkifyPipe } from '@app/shared/pipes/linkify.pipe'; import { RecurrenceSummaryPipe } from '@app/shared/pipes/recurrence-summary.pipe'; -import { AvatarComponent } from '@components/avatar/avatar.component'; import { ButtonComponent } from '@components/button/button.component'; import { CancelOccurrenceConfirmationComponent } from '@components/cancel-occurrence-confirmation/cancel-occurrence-confirmation.component'; import { ExpandableTextComponent } from '@components/expandable-text/expandable-text.component'; @@ -18,6 +17,8 @@ import { MeetingDeleteTypeResult, MeetingDeleteTypeSelectionComponent, } from '@components/meeting-delete-type-selection/meeting-delete-type-selection.component'; +import { MeetingRegistrantsComponent } from '@components/meeting-registrants/meeting-registrants.component'; +import { MeetingRsvpDetailsComponent } from '@components/meeting-rsvp-details/meeting-rsvp-details.component'; import { MenuComponent } from '@components/menu/menu.component'; import { RsvpButtonGroupComponent } from '@components/rsvp-button-group/rsvp-button-group.component'; import { environment } from '@environments/environment'; @@ -32,9 +33,7 @@ import { MeetingAttachment, MeetingCancelOccurrenceResult, MeetingOccurrence, - MeetingRegistrant, PastMeeting, - PastMeetingParticipant, PastMeetingRecording, PastMeetingSummary, } from '@lfx-one/shared'; @@ -51,7 +50,7 @@ import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; -import { BehaviorSubject, catchError, combineLatest, filter, finalize, map, of, switchMap, take, tap } from 'rxjs'; +import { catchError, combineLatest, map, of, switchMap, take, tap } from 'rxjs'; @Component({ selector: 'lfx-meeting-card', @@ -62,7 +61,6 @@ import { BehaviorSubject, catchError, combineLatest, filter, finalize, map, of, MenuComponent, MeetingTimePipe, RecurrenceSummaryPipe, - AvatarComponent, TooltipModule, AnimateOnScrollModule, ConfirmDialogModule, @@ -72,6 +70,8 @@ import { BehaviorSubject, catchError, combineLatest, filter, finalize, map, of, FileSizePipe, ClipboardModule, RsvpButtonGroupComponent, + MeetingRsvpDetailsComponent, + MeetingRegistrantsComponent, ], providers: [ConfirmationService], templateUrl: './meeting-card.component.html', @@ -94,21 +94,14 @@ export class MeetingCardComponent implements OnInit { public showRegistrants: WritableSignal = signal(false); public meeting: WritableSignal = signal({} as Meeting | PastMeeting); public occurrence: WritableSignal = signal(null); - public registrantsLoading: WritableSignal = signal(true); - private refresh$: BehaviorSubject = new BehaviorSubject(false); - public registrants = this.initRegistrantsList(); - public pastMeetingParticipants = this.initPastMeetingParticipantsList(); - public registrantsLabel: Signal = this.initRegistrantsLabel(); public recording: WritableSignal = signal(null); public summary: WritableSignal = signal(null); public additionalRegistrantsCount: WritableSignal = signal(0); - public additionalParticipantsCount: WritableSignal = signal(0); public actionMenuItems: Signal = this.initializeActionMenuItems(); public attachments: Signal = signal([]); // Computed values for template public readonly meetingRegistrantCount: Signal = this.initMeetingRegistrantCount(); - public readonly registrantResponseBreakdown: Signal = this.initRegistrantResponseBreakdown(); public readonly summaryContent: Signal = this.initSummaryContent(); public readonly summaryUid: Signal = this.initSummaryUid(); public readonly summaryApproved: Signal = this.initSummaryApproved(); @@ -185,36 +178,16 @@ export class MeetingCardComponent implements OnInit { } public onRegistrantsToggle(): void { - if (this.pastMeeting()) { - // For past meetings, just show/hide participants - this.registrantsLoading.set(true); - - if (!this.showRegistrants()) { - this.refresh$.next(true); - } - - this.showRegistrants.set(!this.showRegistrants()); - return; - } - if (this.meetingRegistrantCount() === 0) { - // Open add registrant modal this.openAddRegistrantModal(); return; } - // Show/hide inline registrants display - this.registrantsLoading.set(true); - - if (!this.showRegistrants()) { - this.refresh$.next(true); - } - this.showRegistrants.set(!this.showRegistrants()); } public openAddRegistrantModal(): void { - const dialogRef = this.dialogService.open(RegistrantModalComponent, { + this.dialogService.open(RegistrantModalComponent, { header: 'Add Guests', width: '650px', modal: true, @@ -222,15 +195,9 @@ export class MeetingCardComponent implements OnInit { dismissableMask: true, data: { meetingId: this.meeting().uid, - registrant: null, // Add mode + registrant: null, }, }); - - dialogRef.onChildComponentLoaded.pipe(take(1)).subscribe((component) => { - component.registrantSaved.subscribe(() => { - this.refresh$.next(true); - }); - }); } public openCommitteeModal(): void { @@ -254,31 +221,6 @@ export class MeetingCardComponent implements OnInit { }); } - public onRegistrantEdit(registrant: MeetingRegistrant, event: Event): void { - event.stopPropagation(); - - this.dialogService - .open(RegistrantModalComponent, { - header: registrant.type === 'committee' ? 'Committee Member' : 'Edit Guest', - width: '650px', - modal: true, - closable: true, - dismissableMask: true, - data: { - meetingId: this.meeting().uid, - registrant: registrant, - isCommitteeMember: registrant.type === 'committee', - }, - }) - .onClose.pipe(take(1)) - .subscribe((result) => { - if (result) { - // Refresh the current registrant display - this.refresh$.next(true); - } - }); - } - public copyMeetingLink(): void { const meetingUrl: URL = new URL(environment.urls.home + '/meetings/' + this.meeting().uid); meetingUrl.searchParams.set('password', this.meeting().password || ''); @@ -370,126 +312,6 @@ export class MeetingCardComponent implements OnInit { }); } - private initRegistrantsLabel(): Signal { - return computed(() => { - if (this.pastMeeting()) { - const totalParticipants = this.meetingRegistrantCount(); - if (totalParticipants === 0) { - return 'No Participants'; - } - if (totalParticipants === 1) { - return '1 Participant'; - } - return `${totalParticipants} Participants`; - } - - if (this.meetingRegistrantCount() === 0 && this.meeting()?.organizer) { - return 'Add Guests'; - } - - const totalGuests = this.meetingRegistrantCount(); - - if (totalGuests === 1) { - return '1 Guest'; - } - - return `${totalGuests} Guests`; - }); - } - - private initRegistrantResponseBreakdown(): Signal { - return computed(() => { - const meeting = this.meeting(); - if (!meeting) return ''; - - if (this.pastMeeting()) { - // For past meetings, use counts from meeting object (calculated server-side) - const invitedCount = meeting.individual_registrants_count || 0; - const attendedCount = meeting.attended_count || 0; - const totalParticipants = meeting.participant_count || 0; - const didNotAttendCount = totalParticipants - attendedCount; - - const parts: string[] = []; - - // Show invited count if there were formal invitations - if (invitedCount > 0) { - parts.push(`${invitedCount} Invited`); - } - - // Show attendance breakdown - if (attendedCount > 0) parts.push(`${attendedCount} Attended`); - if (didNotAttendCount > 0) parts.push(`${didNotAttendCount} Did Not Attend`); - - return parts.join(', '); - } - - const accepted = meeting.registrants_accepted_count || 0; - const declined = meeting.registrants_declined_count || 0; - const pending = meeting.registrants_pending_count || 0; - - // Only show breakdown if there are individual registrants with responses - if (accepted === 0 && declined === 0 && pending === 0) { - return ''; - } - - const parts: string[] = []; - if (accepted > 0) parts.push(`${accepted} Attending`); - if (declined > 0) parts.push(`${declined} Not Attending`); - if (pending > 0) parts.push(`${pending} Pending Response`); - - return parts.join(', '); - }); - } - - private initRegistrantsList() { - return toSignal( - this.refresh$.pipe( - takeUntilDestroyed(), - filter((refresh) => refresh && !this.pastMeeting()), - switchMap(() => { - this.registrantsLoading.set(true); - return this.meetingService - .getMeetingRegistrants(this.meeting().uid) - .pipe(catchError(() => of([]))) - .pipe( - map((registrants) => { - return registrants; - }), - // Sort registrants by first name - map((registrants) => registrants.sort((a, b) => a.first_name?.localeCompare(b.first_name ?? '') ?? 0) as MeetingRegistrant[]), - tap((registrants) => { - const baseCount = (this.meeting().individual_registrants_count || 0) + (this.meeting().committee_members_count || 0); - this.additionalRegistrantsCount.set(Math.max(0, (registrants?.length || 0) - baseCount)); - }), - finalize(() => this.registrantsLoading.set(false)) - ); - }) - ), - { initialValue: [] } - ); - } - - private initPastMeetingParticipantsList() { - return toSignal( - this.refresh$.pipe( - takeUntilDestroyed(), - filter((refresh) => refresh && this.pastMeeting()), - switchMap(() => { - this.registrantsLoading.set(true); - return this.meetingService - .getPastMeetingParticipants(this.meeting().uid) - .pipe(catchError(() => of([]))) - .pipe( - // Sort participants by first name - map((participants) => participants.sort((a, b) => a.first_name?.localeCompare(b.first_name ?? '') ?? 0) as PastMeetingParticipant[]), - finalize(() => this.registrantsLoading.set(false)) - ); - }) - ), - { initialValue: [] } - ); - } - private deleteMeeting(): void { const meeting = this.meeting(); if (!meeting) return; @@ -655,8 +477,7 @@ export class MeetingCardComponent implements OnInit { tap((meeting) => { this.additionalRegistrantsCount.set(0); this.meeting.set(meeting); - }), - finalize(() => this.refresh$.next(true)) + }) ) .subscribe(); } diff --git a/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.html b/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.html new file mode 100644 index 00000000..a2f67e0e --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.html @@ -0,0 +1,82 @@ + + + +@if (visible()) { +
+ @if (registrantsLoading()) { +
+ +
+ } + + @if (!registrantsLoading()) { +
+ @if (!pastMeeting() && showAddRegistrant()) { + +
+
+ +
+
+ Add Guests + Click to add new guests +
+
+ } + + @if (pastMeeting()) { + @for (participant of pastMeetingParticipants(); track participant.uid) { +
+ @if (participant.is_attended) { + + } @else { + + } +
+
+ + {{ participant.first_name }} {{ participant.last_name }} +
+ {{ participant.email }} +
+
+ } + } @else { + @for (registrant of registrants(); track registrant.uid) { +
+
+ +
+ @if (registrant.invite_accepted === false) { + + } @else if (!registrant.invite_accepted) { + + } @else { + + } +
+
+
+
+ @if (registrant.type === 'committee') { + + } @else { + + } + {{ registrant.first_name }} {{ registrant.last_name }} +
+ {{ registrant.email }} +
+
+ } + } +
+ } +
+} diff --git a/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.ts b/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.ts new file mode 100644 index 00000000..2551b526 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/meeting-registrants/meeting-registrants.component.ts @@ -0,0 +1,138 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, effect, inject, input, InputSignal, output, OutputEmitterRef, Signal, signal, WritableSignal } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { AvatarComponent } from '@components/avatar/avatar.component'; +import { Meeting, MeetingRegistrant, PastMeeting, PastMeetingParticipant } from '@lfx-one/shared'; +import { RegistrantModalComponent } from '@modules/project/meetings/components/registrant-modal/registrant-modal.component'; +import { MeetingService } from '@services/meeting.service'; +import { DialogService } from 'primeng/dynamicdialog'; +import { TooltipModule } from 'primeng/tooltip'; +import { BehaviorSubject, catchError, filter, finalize, map, of, switchMap, take, tap } from 'rxjs'; + +@Component({ + selector: 'lfx-meeting-registrants', + standalone: true, + imports: [CommonModule, AvatarComponent, TooltipModule], + templateUrl: './meeting-registrants.component.html', +}) +export class MeetingRegistrantsComponent { + private readonly meetingService = inject(MeetingService); + private readonly dialogService = inject(DialogService); + + public readonly meeting: InputSignal = input.required(); + public readonly pastMeeting: InputSignal = input(false); + public readonly visible: InputSignal = input(false); + public readonly showAddRegistrant: InputSignal = input(false); + + public readonly registrantsCountChange: OutputEmitterRef = output(); + + public readonly registrantsLoading: WritableSignal = signal(true); + private readonly refresh$: BehaviorSubject = new BehaviorSubject(false); + public readonly registrants: Signal = this.initRegistrantsList(); + public readonly pastMeetingParticipants: Signal = this.initPastMeetingParticipantsList(); + public readonly additionalRegistrantsCount: WritableSignal = signal(0); + + public constructor() { + effect(() => { + if (this.visible()) { + this.registrantsLoading.set(true); + this.refresh$.next(true); + } + }); + } + + public onAddRegistrantClick(): void { + const dialogRef = this.dialogService.open(RegistrantModalComponent, { + header: 'Add Guests', + width: '650px', + modal: true, + closable: true, + dismissableMask: true, + data: { + meetingId: this.meeting().uid, + registrant: null, + }, + }); + + dialogRef.onChildComponentLoaded.pipe(take(1)).subscribe((component) => { + component.registrantSaved.subscribe(() => { + this.refresh(); + }); + }); + } + + public onRegistrantEdit(registrant: MeetingRegistrant): void { + this.dialogService + .open(RegistrantModalComponent, { + header: registrant.type === 'committee' ? 'Committee Member' : 'Edit Guest', + width: '650px', + modal: true, + closable: true, + dismissableMask: true, + data: { + meetingId: this.meeting().uid, + registrant: registrant, + isCommitteeMember: registrant.type === 'committee', + }, + }) + .onClose.pipe(take(1)) + .subscribe((result) => { + if (result) { + this.refresh(); + } + }); + } + + public refresh(): void { + this.refresh$.next(true); + } + + private initRegistrantsList(): Signal { + return toSignal( + this.refresh$.pipe( + takeUntilDestroyed(), + filter((refresh) => refresh && !this.pastMeeting()), + switchMap(() => { + this.registrantsLoading.set(true); + return this.meetingService + .getMeetingRegistrants(this.meeting().uid) + .pipe(catchError(() => of([]))) + .pipe( + map((registrants) => registrants.sort((a, b) => a.first_name?.localeCompare(b.first_name ?? '') ?? 0) as MeetingRegistrant[]), + tap((registrants) => { + const baseCount = (this.meeting().individual_registrants_count || 0) + (this.meeting().committee_members_count || 0); + const additionalCount = Math.max(0, (registrants?.length || 0) - baseCount); + this.additionalRegistrantsCount.set(additionalCount); + this.registrantsCountChange.emit(additionalCount); + }), + finalize(() => this.registrantsLoading.set(false)) + ); + }) + ), + { initialValue: [] } + ); + } + + private initPastMeetingParticipantsList(): Signal { + return toSignal( + this.refresh$.pipe( + takeUntilDestroyed(), + filter((refresh) => refresh && this.pastMeeting()), + switchMap(() => { + this.registrantsLoading.set(true); + return this.meetingService + .getPastMeetingParticipants(this.meeting().uid) + .pipe(catchError(() => of([]))) + .pipe( + map((participants) => participants.sort((a, b) => a.first_name?.localeCompare(b.first_name ?? '') ?? 0) as PastMeetingParticipant[]), + finalize(() => this.registrantsLoading.set(false)) + ); + }) + ), + { initialValue: [] } + ); + } +} diff --git a/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html new file mode 100644 index 00000000..d8ac3b78 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html @@ -0,0 +1,85 @@ + + + +
+ +
+
+ + {{ pastMeeting() ? 'Attendees' : 'People Invited' }} +
+ @if (showAddLink()) { + + + } + @if (showAddModal()) { + + + } +
+ +
+ +
+ + @if (pastMeeting()) { + {{ attendedCount() }} of {{ meetingRegistrantCount() }} attended + } @else { + {{ acceptedCount() }} of {{ meetingRegistrantCount() }} attending + } + + {{ attendancePercentage() }}% +
+ + +
+ @if (acceptedCount() > 0) { +
+ } + @if (!pastMeeting() && maybeCount() > 0) { +
+ } + @if (!pastMeeting() && declinedCount() > 0) { +
+ } +
+ + + @if (!pastMeeting()) { +
+
+ + {{ acceptedCount() }} Yes +
+
+ + {{ maybeCount() }} Maybe +
+
+ + {{ declinedCount() }} No +
+
+ } + + + @if (showPoorAttendanceWarning()) { +
+ +

This meeting was poorly attended

+
+ } +
+
diff --git a/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.ts b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.ts new file mode 100644 index 00000000..b1f87c9c --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.ts @@ -0,0 +1,141 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input, InputSignal, output, OutputEmitterRef, Signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { calculateRsvpCounts, Meeting, MeetingOccurrence, MeetingRsvp, PastMeeting, Project, RsvpCounts } from '@lfx-one/shared'; +import { MeetingService } from '@services/meeting.service'; +import { catchError, of, switchMap } from 'rxjs'; + +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'lfx-meeting-rsvp-details', + standalone: true, + imports: [CommonModule, ButtonComponent], + templateUrl: './meeting-rsvp-details.component.html', +}) +export class MeetingRsvpDetailsComponent { + private readonly meetingService = inject(MeetingService); + + public readonly meeting: InputSignal = input.required(); + public readonly project: InputSignal = input(null); + public readonly currentOccurrence: InputSignal = input(null); + public readonly pastMeeting: InputSignal = input(false); + public readonly showAddLink: InputSignal = input(false); + public readonly showAddModal: InputSignal = input(false); + + public readonly backgroundColor: InputSignal = input(undefined); + public readonly borderColor: InputSignal = input(undefined); + public readonly additionalRegistrantsCount: InputSignal = input(0); + public readonly addParticipant: OutputEmitterRef = output(); + + public readonly rsvps: Signal = this.initializeRsvps(); + public readonly rsvpCounts: Signal = this.initializeRsvpCounts(); + public readonly acceptedCount: Signal = computed(() => this.rsvpCounts().accepted); + public readonly maybeCount: Signal = computed(() => this.rsvpCounts().maybe); + public readonly declinedCount: Signal = computed(() => this.rsvpCounts().declined); + public readonly meetingRegistrantCount: Signal = this.initializeMeetingRegistrantCount(); + public readonly attendedCount: Signal = this.initializeAttendedCount(); + public readonly attendancePercentage: Signal = this.initializeAttendancePercentage(); + public readonly showPoorAttendanceWarning: Signal = computed(() => this.pastMeeting() && this.attendancePercentage() < 50); + public readonly backgroundClasses: Signal = this.initializeBackgroundClasses(); + public readonly editLink: Signal = this.initializeEditLink(); + public readonly borderClasses: Signal = this.initializeBorderClasses(); + public readonly headerTextClasses: Signal = computed(() => (this.showPoorAttendanceWarning() ? 'text-amber-600' : 'text-gray-600')); + public readonly summaryTextClasses: Signal = computed(() => (this.showPoorAttendanceWarning() ? 'text-amber-900' : 'text-gray-900')); + + public onAddParticipantClick(): void { + this.addParticipant.emit(); + } + + private initializeRsvps(): Signal { + return toSignal( + toObservable(this.meeting).pipe( + switchMap((meeting) => + this.meetingService.getMeetingRsvps(meeting.uid).pipe( + catchError((error) => { + console.error('Failed to fetch meeting RSVPs:', error); + return of([]); + }) + ) + ) + ), + { initialValue: [] } + ); + } + + private initializeRsvpCounts(): Signal { + return computed(() => { + const rsvps = this.rsvps(); + const occurrence = this.currentOccurrence(); + const meeting = this.meeting(); + return calculateRsvpCounts(occurrence, rsvps, meeting.start_time); + }); + } + + private initializeMeetingRegistrantCount(): Signal { + return computed(() => { + const meeting = this.meeting(); + const baseCount = (meeting.individual_registrants_count || 0) + (meeting.committee_members_count || 0); + const additionalCount = this.additionalRegistrantsCount(); + return baseCount + additionalCount; + }); + } + + private initializeAttendedCount(): Signal { + return computed(() => { + if ('attended_count' in this.meeting()) { + return (this.meeting() as PastMeeting).attended_count || 0; + } + return 0; + }); + } + + private initializeAttendancePercentage(): Signal { + return computed(() => { + const total = this.meetingRegistrantCount(); + if (total === 0) { + return 0; + } + + if (this.pastMeeting()) { + return Math.round((this.attendedCount() / total) * 100); + } + + return Math.round((this.acceptedCount() / total) * 100); + }); + } + + private initializeBackgroundClasses(): Signal { + return computed(() => { + if (this.showPoorAttendanceWarning()) { + return 'bg-amber-50'; + } + return this.backgroundColor() || 'bg-gray-100/60'; + }); + } + + private initializeBorderClasses(): Signal { + return computed(() => { + if (this.showPoorAttendanceWarning()) { + return 'border border-amber-200'; + } + if (this.borderColor()) { + return `border ${this.borderColor()}`; + } + return ''; + }); + } + + private initializeEditLink(): Signal { + return computed(() => { + const slug = this.project()?.slug; + if (slug) { + return `/project/${slug}/meetings/${this.meeting().uid}/edit`; + } + return `/meetings/${this.meeting().uid}/edit`; + }); + } +} diff --git a/apps/lfx-one/src/server/controllers/public-meeting.controller.ts b/apps/lfx-one/src/server/controllers/public-meeting.controller.ts index 992b8ce4..cf0d5d0d 100644 --- a/apps/lfx-one/src/server/controllers/public-meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/public-meeting.controller.ts @@ -8,6 +8,7 @@ import { ResourceNotFoundError, ServiceValidationError } from '../errors'; import { AuthorizationError } from '../errors/authentication.error'; import { Logger } from '../helpers/logger'; import { validateUidParameter } from '../helpers/validation.helper'; +import { AccessCheckService } from '../services/access-check.service'; import { MeetingService } from '../services/meeting.service'; import { ProjectService } from '../services/project.service'; import { generateM2MToken } from '../utils/m2m-token.util'; @@ -19,6 +20,7 @@ import { validatePassword } from '../utils/security.util'; export class PublicMeetingController { private meetingService: MeetingService = new MeetingService(); private projectService: ProjectService = new ProjectService(); + private accessCheckService: AccessCheckService = new AccessCheckService(); /** * GET /public/api/meetings/:id * Retrieves a single meeting by ID without requiring authentication @@ -35,9 +37,12 @@ export class PublicMeetingController { return; } + // Save the user's original token before setting M2M token + const originalToken = req.bearerToken; + // Get the meeting by ID using M2M token Logger.start(req, 'get_public_meeting_by_id_fetch_meeting', { meeting_uid: id }); - const meeting = await this.fetchMeetingWithM2M(req, id); + let meeting = await this.fetchMeetingWithM2M(req, id); if (!meeting) { // Log the error Logger.error(req, 'get_public_meeting_by_id_fetch_meeting', startTime, new Error('Meeting not found')); @@ -94,6 +99,36 @@ export class PublicMeetingController { return; } + // Check if user is authenticated and add organizer field + if (req.oidc?.isAuthenticated()) { + // Restore user's original token before organizer check + if (originalToken !== undefined) { + req.bearerToken = originalToken; + } + + Logger.start(req, 'get_public_meeting_by_id_check_organizer', { meeting_uid: id }); + try { + meeting = await this.accessCheckService.addAccessToResource(req, meeting, 'meeting', 'organizer'); + Logger.success(req, 'get_public_meeting_by_id_check_organizer', startTime, { + meeting_uid: id, + is_organizer: meeting.organizer, + }); + } catch (error) { + // If organizer check fails, log but continue with organizer = false + req.log.warn( + { + error: error instanceof Error ? error.message : error, + meeting_uid: id, + }, + 'Failed to check organizer status, continuing with organizer = false' + ); + meeting.organizer = false; + } + } else { + // User is not authenticated, set organizer to false + meeting.organizer = false; + } + // Send the meeting and project data to the client res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } }); } catch (error) { diff --git a/apps/lfx-one/src/server/services/meeting.service.ts b/apps/lfx-one/src/server/services/meeting.service.ts index 721d4d82..d1c7dc46 100644 --- a/apps/lfx-one/src/server/services/meeting.service.ts +++ b/apps/lfx-one/src/server/services/meeting.service.ts @@ -897,6 +897,9 @@ export class MeetingService { }) ); - return meetings.map((m) => ({ ...m, project_name: projects.find((p) => p?.uid === m.project_uid)?.name || '' })); + return meetings.map((m) => { + const project = projects.find((p) => p?.uid === m.project_uid); + return { ...m, project_name: project?.name || '', project_slug: project?.slug || '' }; + }); } } diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index 8782e918..51315d24 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -147,6 +147,8 @@ export interface Meeting { occurrences: MeetingOccurrence[]; /** Project name */ project_name: string; + /** Project slug */ + project_slug: string; } /**