Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -149,32 +149,7 @@ export class MeetingJoinComponent {
private initializeCurrentOccurrence(): Signal<MeetingOccurrence | null> {
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);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,10 @@ <h3 class="text-xl font-display font-semibold text-gray-900 mb-1 leading-tight"
</h3>
}
</div>
@if (occurrence()?.start_time || meeting().start_time) {
@if (meetingStartTime()) {
<div class="flex items-center gap-1 text-xs text-gray-600 bg-gray-100 px-2 py-1 rounded flex-shrink-0" data-testid="meeting-datetime">
<i class="fa-light fa-calendar-days text-gray-400"></i>
<span
>{{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'date' }} •
{{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'time' }}</span
>
<span>{{ meetingStartTime() | meetingTime: meeting().duration : 'date' }} • {{ meetingStartTime() | meetingTime: meeting().duration : 'time' }}</span>
</div>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,15 +70,15 @@ export class MeetingCardComponent implements OnInit {
private readonly injector = inject(Injector);
private readonly clipboard = inject(Clipboard);

public readonly meetingInput = input.required<Meeting>();
public readonly meetingInput = input.required<Meeting | PastMeeting>();
public readonly occurrenceInput = input<MeetingOccurrence | null>(null);
public readonly pastMeeting = input<boolean>(false);
public readonly loading = input<boolean>(false);
public readonly showBorder = input<boolean>(false);
public readonly meetingRegistrantCount: Signal<number> = this.initMeetingRegistrantCount();
public readonly registrantResponseBreakdown: Signal<string> = this.initRegistrantResponseBreakdown();
public showRegistrants: WritableSignal<boolean> = signal(false);
public meeting: WritableSignal<Meeting> = signal({} as Meeting);
public meeting: WritableSignal<Meeting | PastMeeting> = signal({} as Meeting | PastMeeting);
public occurrence: WritableSignal<MeetingOccurrence | null> = signal(null);
public registrantsLoading: WritableSignal<boolean> = signal(true);
private refresh$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
Expand All @@ -91,6 +100,8 @@ export class MeetingCardComponent implements OnInit {
public readonly attendedCount: Signal<number> = this.initAttendedCount();
public readonly notAttendedCount: Signal<number> = this.initNotAttendedCount();
public readonly participantCount: Signal<number> = this.initParticipantCount();
public readonly currentOccurrence: Signal<MeetingOccurrence | null> = this.initCurrentOccurrence();
public readonly meetingStartTime: Signal<string | null> = this.initMeetingStartTime();

public readonly meetingDeleted = output<void>();
public readonly project = this.projectService.project;
Expand All @@ -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);
}
});
}
Expand Down Expand Up @@ -604,4 +623,43 @@ export class MeetingCardComponent implements OnInit {
return this.meeting()?.participant_count || 0;
});
}

private initCurrentOccurrence(): Signal<MeetingOccurrence | null> {
return computed(() => {
const meeting = this.meeting();
return getCurrentOrNextOccurrence(meeting);
});
}

private initMeetingStartTime(): Signal<string | null> {
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;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,11 +57,11 @@ export class MeetingDashboardComponent {
public meetings: Signal<Meeting[]>;
public upcomingMeetings: Signal<(MeetingOccurrence & { meeting: Meeting })[]>;
public pastMeetingsLoading: WritableSignal<boolean>;
public pastMeetings: Signal<Meeting[]>;
public pastMeetings: Signal<PastMeeting[]>;
public meetingListView: WritableSignal<'upcoming' | 'past'>;
public visibilityOptions: Signal<{ label: string; value: string | null }[]>;
public committeeOptions: Signal<{ label: string; value: string | null }[]>;
public filteredMeetings: Signal<Meeting[]>;
public filteredMeetings: Signal<Meeting[] | PastMeeting[]>;
public publicMeetingsCount: Signal<number>;
public privateMeetingsCount: Signal<number>;
public menuItems: MenuItem[];
Expand Down Expand Up @@ -187,7 +187,7 @@ export class MeetingDashboardComponent {
});
}

private initializePastMeetings(): Signal<Meeting[]> {
private initializePastMeetings(): Signal<PastMeeting[]> {
return toSignal(
this.project()
? this.refresh.pipe(
Expand Down
11 changes: 6 additions & 5 deletions apps/lfx-one/src/app/shared/services/meeting.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MeetingJoinURL,
MeetingRegistrant,
MeetingRegistrantWithState,
PastMeeting,
PastMeetingParticipant,
Project,
UpdateMeetingRegistrantRequest,
Expand All @@ -40,8 +41,8 @@ export class MeetingService {
);
}

public getPastMeetings(params?: HttpParams): Observable<Meeting[]> {
return this.http.get<Meeting[]>('/api/past-meetings', { params }).pipe(
public getPastMeetings(params?: HttpParams): Observable<PastMeeting[]> {
return this.http.get<PastMeeting[]>('/api/past-meetings', { params }).pipe(
catchError((error) => {
console.error('Failed to load past meetings:', error);
return of([]);
Expand Down Expand Up @@ -78,7 +79,7 @@ export class MeetingService {
return this.getMeetings(params);
}

public getPastMeetingsByProject(projectId: string, limit: number = 3): Observable<Meeting[]> {
public getPastMeetingsByProject(projectId: string, limit: number = 3): Observable<PastMeeting[]> {
let params = new HttpParams().set('tags', `project_uid:${projectId}`);

if (limit) {
Expand All @@ -98,8 +99,8 @@ export class MeetingService {
);
}

public getPastMeeting(id: string): Observable<Meeting> {
return this.http.get<Meeting>(`/api/past-meetings/${id}`).pipe(
public getPastMeeting(id: string): Observable<PastMeeting> {
return this.http.get<PastMeeting>(`/api/past-meetings/${id}`).pipe(
catchError((error) => {
console.error(`Failed to load past meeting ${id}:`, error);
return throwError(() => error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, any>, 'past_meeting');
const meetings = (await this.meetingService.getMeetings(req, req.query as Record<string, any>, '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
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
32 changes: 8 additions & 24 deletions apps/lfx-one/src/server/services/meeting.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,44 +66,28 @@ export class MeetingService {
/**
* Fetches a single meeting by UID
*/
public async getMeetingById(req: Request, meetingUid: string, meetingType: string = 'meeting', access: boolean = true): Promise<Meeting> {
const params = {
type: meetingType,
tags: `meeting_uid:${meetingUid}`,
};
public async getMeetingById(req: Request, meetingUid: string, meetingType: string = 'meetings', access: boolean = true): Promise<Meeting> {
let meeting = await this.microserviceProxy.proxyRequest<Meeting>(req, 'LFX_V2_SERVICE', `/${meetingType}/${meetingUid}`, 'GET');

const { resources } = await this.microserviceProxy.proxyRequest<QueryServiceResponse<Meeting>>(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',
path: `/meetings/${meetingUid}`,
});
}

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;
}

/**
Expand Down
32 changes: 32 additions & 0 deletions packages/shared/src/interfaces/meeting.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading