diff --git a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts index 9e47a40f..7c9a3656 100644 --- a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts @@ -15,7 +15,7 @@ import { ExpandableTextComponent } from '@components/expandable-text/expandable- import { InputTextComponent } from '@components/input-text/input-text.component'; import { MessageComponent } from '@components/message/message.component'; import { environment } from '@environments/environment'; -import { extractUrlsWithDomains, Meeting, MeetingOccurrence, Project, User } from '@lfx-one/shared'; +import { extractUrlsWithDomains, getCurrentOrNextOccurrence, Meeting, MeetingOccurrence, Project, User } from '@lfx-one/shared'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; import { MeetingService } from '@services/meeting.service'; import { UserService } from '@services/user.service'; @@ -149,32 +149,7 @@ export class MeetingJoinComponent { private initializeCurrentOccurrence(): Signal { return computed(() => { const meeting = this.meeting(); - if (!meeting?.occurrences || meeting.occurrences.length === 0) { - return null; - } - - const now = new Date(); - const earlyJoinMinutes = meeting.early_join_time_minutes || 10; - - // Find the first occurrence that is currently joinable (within the join window) - const joinableOccurrence = meeting.occurrences.find((occurrence) => { - const startTime = new Date(occurrence.start_time); - const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000); - const latestJoinTime = new Date(startTime.getTime() + occurrence.duration * 60000 + 40 * 60000); // 40 minutes after end - - return now >= earliestJoinTime && now <= latestJoinTime; - }); - - if (joinableOccurrence) { - return joinableOccurrence; - } - - // If no joinable occurrence, find the next future occurrence - const futureOccurrences = meeting.occurrences - .filter((occurrence) => new Date(occurrence.start_time) > now) - .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); - - return futureOccurrences.length > 0 ? futureOccurrences[0] : null; + return getCurrentOrNextOccurrence(meeting); }); } diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html index 4e38be08..8501a149 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html @@ -93,13 +93,10 @@

} - @if (occurrence()?.start_time || meeting().start_time) { + @if (meetingStartTime()) {
- {{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'date' }} • - {{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'time' }} + {{ meetingStartTime() | meetingTime: meeting().duration : 'date' }} • {{ meetingStartTime() | meetingTime: meeting().duration : 'time' }}
} diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts index 6f1c41f6..781a9f8f 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts @@ -15,7 +15,16 @@ import { ButtonComponent } from '@components/button/button.component'; import { ExpandableTextComponent } from '@components/expandable-text/expandable-text.component'; import { MenuComponent } from '@components/menu/menu.component'; import { environment } from '@environments/environment'; -import { extractUrlsWithDomains, Meeting, MeetingAttachment, MeetingOccurrence, MeetingRegistrant, PastMeetingParticipant } from '@lfx-one/shared'; +import { + extractUrlsWithDomains, + getCurrentOrNextOccurrence, + Meeting, + MeetingAttachment, + MeetingOccurrence, + MeetingRegistrant, + PastMeeting, + PastMeetingParticipant, +} from '@lfx-one/shared'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; import { MeetingService } from '@services/meeting.service'; import { ProjectService } from '@services/project.service'; @@ -61,7 +70,7 @@ export class MeetingCardComponent implements OnInit { private readonly injector = inject(Injector); private readonly clipboard = inject(Clipboard); - public readonly meetingInput = input.required(); + public readonly meetingInput = input.required(); public readonly occurrenceInput = input(null); public readonly pastMeeting = input(false); public readonly loading = input(false); @@ -69,7 +78,7 @@ export class MeetingCardComponent implements OnInit { public readonly meetingRegistrantCount: Signal = this.initMeetingRegistrantCount(); public readonly registrantResponseBreakdown: Signal = this.initRegistrantResponseBreakdown(); public showRegistrants: WritableSignal = signal(false); - public meeting: WritableSignal = signal({} as Meeting); + public meeting: WritableSignal = signal({} as Meeting | PastMeeting); public occurrence: WritableSignal = signal(null); public registrantsLoading: WritableSignal = signal(true); private refresh$: BehaviorSubject = new BehaviorSubject(false); @@ -91,6 +100,8 @@ export class MeetingCardComponent implements OnInit { public readonly attendedCount: Signal = this.initAttendedCount(); public readonly notAttendedCount: Signal = this.initNotAttendedCount(); public readonly participantCount: Signal = this.initParticipantCount(); + public readonly currentOccurrence: Signal = this.initCurrentOccurrence(); + public readonly meetingStartTime: Signal = this.initMeetingStartTime(); public readonly meetingDeleted = output(); public readonly project = this.projectService.project; @@ -103,8 +114,16 @@ export class MeetingCardComponent implements OnInit { public constructor() { effect(() => { this.meeting.set(this.meetingInput()); + // Priority: explicit occurrenceInput > current occurrence for upcoming > null for past without input if (this.occurrenceInput()) { + // If explicitly passed an occurrence, always use it this.occurrence.set(this.occurrenceInput()!); + } else if (!this.pastMeeting()) { + // For upcoming meetings without explicit occurrence, use current occurrence + this.occurrence.set(this.currentOccurrence()); + } else { + // For past meetings without occurrence input, set to null + this.occurrence.set(null); } }); } @@ -604,4 +623,43 @@ export class MeetingCardComponent implements OnInit { return this.meeting()?.participant_count || 0; }); } + + private initCurrentOccurrence(): Signal { + return computed(() => { + const meeting = this.meeting(); + return getCurrentOrNextOccurrence(meeting); + }); + } + + private initMeetingStartTime(): Signal { + return computed(() => { + const meeting = this.meeting(); + + if (!this.pastMeeting()) { + // For upcoming meetings, use current occurrence (next upcoming occurrence) or meeting start_time + const currentOccurrence = this.occurrence(); + if (currentOccurrence?.start_time) { + return currentOccurrence.start_time; + } + if (meeting?.start_time) { + return meeting.start_time; + } + } else { + // For past meetings, use occurrence input or fallback to scheduled_start_time/start_time + const occurrence = this.occurrence(); + if (occurrence?.start_time) { + return occurrence.start_time; + } + if (meeting?.start_time) { + return meeting.start_time; + } + // Handle past meetings that use scheduled_start_time (type-safe check) + if ('scheduled_start_time' in meeting && meeting.scheduled_start_time) { + return meeting.scheduled_start_time; + } + } + + return null; + }); + } } diff --git a/apps/lfx-one/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts b/apps/lfx-one/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts index 3f4c6c11..29f9d3a0 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts @@ -12,7 +12,7 @@ import { InputTextComponent } from '@components/input-text/input-text.component' import { MenuComponent } from '@components/menu/menu.component'; import { SelectButtonComponent } from '@components/select-button/select-button.component'; import { SelectComponent } from '@components/select/select.component'; -import { CalendarEvent, Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces'; +import { CalendarEvent, Meeting, MeetingOccurrence, PastMeeting } from '@lfx-one/shared/interfaces'; import { MeetingService } from '@services/meeting.service'; import { ProjectService } from '@services/project.service'; import { AnimateOnScrollModule } from 'primeng/animateonscroll'; @@ -57,11 +57,11 @@ export class MeetingDashboardComponent { public meetings: Signal; public upcomingMeetings: Signal<(MeetingOccurrence & { meeting: Meeting })[]>; public pastMeetingsLoading: WritableSignal; - public pastMeetings: Signal; + public pastMeetings: Signal; public meetingListView: WritableSignal<'upcoming' | 'past'>; public visibilityOptions: Signal<{ label: string; value: string | null }[]>; public committeeOptions: Signal<{ label: string; value: string | null }[]>; - public filteredMeetings: Signal; + public filteredMeetings: Signal; public publicMeetingsCount: Signal; public privateMeetingsCount: Signal; public menuItems: MenuItem[]; @@ -187,7 +187,7 @@ export class MeetingDashboardComponent { }); } - private initializePastMeetings(): Signal { + private initializePastMeetings(): Signal { return toSignal( this.project() ? this.refresh.pipe( diff --git a/apps/lfx-one/src/app/shared/services/meeting.service.ts b/apps/lfx-one/src/app/shared/services/meeting.service.ts index cf185a78..01e49c2f 100644 --- a/apps/lfx-one/src/app/shared/services/meeting.service.ts +++ b/apps/lfx-one/src/app/shared/services/meeting.service.ts @@ -15,6 +15,7 @@ import { MeetingJoinURL, MeetingRegistrant, MeetingRegistrantWithState, + PastMeeting, PastMeetingParticipant, Project, UpdateMeetingRegistrantRequest, @@ -40,8 +41,8 @@ export class MeetingService { ); } - public getPastMeetings(params?: HttpParams): Observable { - return this.http.get('/api/past-meetings', { params }).pipe( + public getPastMeetings(params?: HttpParams): Observable { + return this.http.get('/api/past-meetings', { params }).pipe( catchError((error) => { console.error('Failed to load past meetings:', error); return of([]); @@ -78,7 +79,7 @@ export class MeetingService { return this.getMeetings(params); } - public getPastMeetingsByProject(projectId: string, limit: number = 3): Observable { + public getPastMeetingsByProject(projectId: string, limit: number = 3): Observable { let params = new HttpParams().set('tags', `project_uid:${projectId}`); if (limit) { @@ -98,8 +99,8 @@ export class MeetingService { ); } - public getPastMeeting(id: string): Observable { - return this.http.get(`/api/past-meetings/${id}`).pipe( + public getPastMeeting(id: string): Observable { + return this.http.get(`/api/past-meetings/${id}`).pipe( catchError((error) => { console.error(`Failed to load past meeting ${id}:`, error); return throwError(() => error); diff --git a/apps/lfx-one/src/server/controllers/past-meeting.controller.ts b/apps/lfx-one/src/server/controllers/past-meeting.controller.ts index 7d3a2541..ba441fdd 100644 --- a/apps/lfx-one/src/server/controllers/past-meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/past-meeting.controller.ts @@ -3,6 +3,7 @@ import { NextFunction, Request, Response } from 'express'; +import { PastMeeting } from '@lfx-one/shared/interfaces'; import { Logger } from '../helpers/logger'; import { validateUidParameter } from '../helpers/validation.helper'; import { MeetingService } from '../services/meeting.service'; @@ -23,7 +24,7 @@ export class PastMeetingController { try { // Get the past meetings using meetingType 'past_meeting' - const meetings = await this.meetingService.getMeetings(req, req.query as Record, 'past_meeting'); + const meetings = (await this.meetingService.getMeetings(req, req.query as Record, 'past_meeting')) as PastMeeting[]; // TODO: Remove this once we have a way to get the registrants count // Process each meeting individually to add registrant and participant counts @@ -73,7 +74,7 @@ export class PastMeetingController { } // Get the past meeting by ID using meetingType 'past_meeting' - const meeting = await this.meetingService.getMeetingById(req, uid, 'past_meeting'); + const meeting = (await this.meetingService.getMeetingById(req, uid, 'past_meetings')) as PastMeeting; // Log the success Logger.success(req, 'get_past_meeting_by_id', startTime, { 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 b4644d68..70039e8a 100644 --- a/apps/lfx-one/src/server/controllers/public-meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/public-meeting.controller.ts @@ -203,7 +203,7 @@ export class PublicMeetingController { */ private async fetchMeetingWithM2M(req: Request, id: string) { await this.setupM2MToken(req); - return await this.meetingService.getMeetingById(req, id, 'meeting', false); + return await this.meetingService.getMeetingById(req, id, 'meetings', false); } /** diff --git a/apps/lfx-one/src/server/services/meeting.service.ts b/apps/lfx-one/src/server/services/meeting.service.ts index 04e073bb..41e0c8de 100644 --- a/apps/lfx-one/src/server/services/meeting.service.ts +++ b/apps/lfx-one/src/server/services/meeting.service.ts @@ -66,15 +66,10 @@ export class MeetingService { /** * Fetches a single meeting by UID */ - public async getMeetingById(req: Request, meetingUid: string, meetingType: string = 'meeting', access: boolean = true): Promise { - const params = { - type: meetingType, - tags: `meeting_uid:${meetingUid}`, - }; + public async getMeetingById(req: Request, meetingUid: string, meetingType: string = 'meetings', access: boolean = true): Promise { + let meeting = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/${meetingType}/${meetingUid}`, 'GET'); - const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - - if (!resources || resources.length === 0) { + if (!meeting || !meeting.uid) { throw new ResourceNotFoundError('Meeting', meetingUid, { operation: 'get_meeting_by_id', service: 'meeting_service', @@ -82,28 +77,17 @@ export class MeetingService { }); } - if (resources.length > 1) { - req.log.warn( - { - meeting_uid: meetingUid, - result_count: resources.length, - }, - 'Multiple meetings found for single UID lookup' - ); - } - - let meeting = resources.map((resource) => resource.data); - - if (meeting[0].committees && meeting[0].committees.length > 0) { - meeting = await this.getMeetingCommittees(req, meeting); + if (meeting.committees && meeting.committees.length > 0) { + const meetingWithCommittees = await this.getMeetingCommittees(req, [meeting]); + meeting = meetingWithCommittees[0]; } if (access) { // Add writer access field to the meeting - return await this.accessCheckService.addAccessToResource(req, meeting[0], 'meeting', 'organizer'); + return await this.accessCheckService.addAccessToResource(req, meeting, 'meeting', 'organizer'); } - return meeting[0]; + return meeting; } /** diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index bf8a39a5..604b2644 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -480,6 +480,38 @@ export interface RecurrenceSummary { fullSummary: string; } +/** + * Meeting session information + * @description Individual session within a meeting (typically for past meetings) + */ +export interface MeetingSession { + /** Session unique identifier */ + uid: string; + /** Session start time */ + start_time: string; + /** Session end time */ + end_time: string; +} + +/** + * Past meeting interface + * @description Extended meeting interface with additional fields specific to past meetings + */ +export interface PastMeeting extends Meeting { + /** Scheduled start time for past meetings */ + scheduled_start_time: string; + /** Scheduled end time for past meetings */ + scheduled_end_time: string; + /** Original meeting UID (different from uid which is the past meeting occurrence UID) */ + meeting_uid: string; + /** The specific occurrence ID for recurring meetings */ + occurrence_id: string; + /** Platform-specific meeting ID (e.g., Zoom meeting ID) */ + platform_meeting_id: string; + /** Array of session objects with start/end times */ + sessions: MeetingSession[]; +} + /** * Past meeting participant information * @description Individual participant who was invited/attended a past meeting diff --git a/packages/shared/src/utils/meeting.utils.ts b/packages/shared/src/utils/meeting.utils.ts index 02340f7e..30ef5abd 100644 --- a/packages/shared/src/utils/meeting.utils.ts +++ b/packages/shared/src/utils/meeting.utils.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { RECURRENCE_DAYS_OF_WEEK, RECURRENCE_WEEKLY_ORDINALS } from '../constants'; -import { CustomRecurrencePattern, RecurrenceSummary } from '../interfaces'; +import { CustomRecurrencePattern, Meeting, MeetingOccurrence, RecurrenceSummary } from '../interfaces'; /** * Build a human-readable recurrence summary from custom recurrence pattern @@ -102,3 +102,37 @@ export function buildRecurrenceSummary(pattern: CustomRecurrencePattern): Recurr fullSummary, }; } + +/** + * Get the current joinable occurrence or next upcoming occurrence for a meeting + * @param meeting The meeting object with occurrences + * @returns The current/next occurrence or null if none available + */ +export function getCurrentOrNextOccurrence(meeting: Meeting): MeetingOccurrence | null { + if (!meeting?.occurrences || meeting.occurrences.length === 0) { + return null; + } + + const now = new Date(); + const earlyJoinMinutes = meeting.early_join_time_minutes || 10; + + // Find the first occurrence that is currently joinable (within the join window) + const joinableOccurrence = meeting.occurrences.find((occurrence) => { + const startTime = new Date(occurrence.start_time); + const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000); + const latestJoinTime = new Date(startTime.getTime() + occurrence.duration * 60000 + 40 * 60000); // 40 minutes after end + + return now >= earliestJoinTime && now <= latestJoinTime; + }); + + if (joinableOccurrence) { + return joinableOccurrence; + } + + // If no joinable occurrence, find the next future occurrence + const futureOccurrences = meeting.occurrences + .filter((occurrence) => new Date(occurrence.start_time) > now) + .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); + + return futureOccurrences.length > 0 ? futureOccurrences[0] : null; +}