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
108 changes: 65 additions & 43 deletions apps/lfx-pcc/src/app/modules/meeting/meeting.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,24 +107,10 @@ <h2 class="text-xl font-display font-semibold text-gray-900 mb-1 leading-tight"
<div class="bg-gray-50 rounded-lg p-3 space-y-2" data-testid="attendees-card">
<div class="flex items-center gap-2 text-xs text-gray-500">
<i class="fa-light fa-users text-gray-400"></i>
<span>Attendees</span>
<span>Guests</span>
</div>
<div class="space-y-2">
<div class="text-sm font-sans">{{ (meeting().individual_registrants_count || 0) + (meeting().committee_members_count || 0) }} invited</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<div class="flex items-center gap-1">
<i class="fa-light fa-check-circle text-green-500"></i>
<span>{{ meeting().registrants_accepted_count || 0 }} Yes</span>
</div>
<div class="flex items-center gap-1">
<i class="fa-light fa-times-circle text-red-500"></i>
<span>{{ meeting().registrants_declined_count || 0 }} No</span>
</div>
<div class="flex items-center gap-1">
<i class="fa-light fa-question-circle text-amber-500"></i>
<span>{{ meeting().registrants_pending_count || 0 }} Maybe</span>
</div>
</div>
</div>
</div>

Expand All @@ -145,8 +131,7 @@ <h2 class="text-xl font-display font-semibold text-gray-900 mb-1 leading-tight"
<i class="fa-light fa-clock text-gray-400"></i>
<span>Duration</span>
</div>
<div class="text-sm font-sans">{{ meeting().duration }}m</div>
<div class="text-xs text-gray-500">{{ meeting().start_time | meetingTime: meeting().duration : 'time' }}</div>
<div class="text-sm font-sans">{{ meeting().duration }} minutes</div>
</div>
</div>

Expand All @@ -160,10 +145,10 @@ <h3 class="text-lg font-display font-semibold text-gray-900">Join This Meeting</
@if (authenticated() && user(); as user) {
<!-- Logged In State -->
<div class="space-y-3">
<div class="bg-green-50 border border-green-200 p-3 rounded-lg">
<div class="bg-blue-50 p-3 rounded-lg">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<div class="w-8 h-8 bg-blue-400 rounded-full flex items-center justify-center">
<i class="fa-light fa-user text-white text-xs"></i>
</div>
<div>
Expand All @@ -178,15 +163,39 @@ <h4 class="font-medium text-gray-900 font-sans">{{ user.name }}</h4>
</div>
</div>

<div class="flex items-center justify-between bg-blue-50 border border-blue-200 p-3 rounded-lg">
<div class="flex items-center gap-3 text-sm text-blue-700">
<i class="fa-light fa-check-circle"></i>
<span class="font-sans">Ready to join as {{ user.name }}</span>
<div
class="flex items-center justify-between p-3 rounded-lg"
[ngClass]="{ 'bg-amber-50 border border-amber-200': !canJoinMeeting(), 'bg-blue-50 border border-blue-200': canJoinMeeting() }">
<div class="flex items-center gap-3 text-sm" [ngClass]="{ 'text-blue-700': canJoinMeeting(), 'text-amber-700': !canJoinMeeting() }">
@if (canJoinMeeting()) {
<i class="fa-light fa-check-circle"></i>
<span class="font-sans">Ready to join as {{ user.name }}</span>
} @else {
<i class="fa-light fa-clock text-amber-600"></i>
<div class="flex flex-col">
<span class="font-sans text-amber-700"
>You may only join the meeting up to <span class="font-bold">{{ meeting().early_join_time_minutes || 10 }} minutes</span> before the
start time.</span
>
</div>
}
</div>
@if (meeting().join_url) {
<lfx-button size="small" [href]="meeting().join_url" severity="primary" label="Join Meeting" icon="fa-light fa-sign-in"></lfx-button>
<lfx-button
size="small"
[href]="canJoinMeeting() ? joinUrlWithParams() : undefined"
[disabled]="!canJoinMeeting()"
severity="primary"
label="Join Meeting"
icon="fa-light fa-sign-in"></lfx-button>
} @else {
<lfx-button size="small" severity="primary" label="Join Meeting" icon="fa-light fa-sign-in" (click)="onJoinMeeting()"></lfx-button>
<lfx-button
size="small"
severity="primary"
label="Join Meeting"
[disabled]="!canJoinMeeting()"
icon="fa-light fa-sign-in"
(click)="onJoinMeeting()"></lfx-button>
}
</div>

Expand All @@ -206,7 +215,7 @@ <h4 class="font-medium text-gray-900 font-sans">{{ user.name }}</h4>
<div class="flex items-center gap-3">
<i class="fa-light fa-sign-in text-blue-600 text-lg"></i>
<div>
<h4 class="font-medium text-gray-900 font-sans">Sign in with your account</h4>
<h4 class="font-medium text-gray-900 font-sans">Sign in with your LFX account</h4>
<p class="text-sm text-gray-600 font-sans">Join quickly with your saved information</p>
</div>
</div>
Expand Down Expand Up @@ -265,24 +274,37 @@ <h4 class="font-medium text-gray-900 font-sans">Enter your information</h4>
</div>
</div>

<div class="flex items-center justify-end pt-3">
@if (meeting().join_url) {
<lfx-button
size="small"
[href]="joinForm.invalid ? undefined : meeting().join_url"
severity="primary"
label="Join Meeting"
icon="fa-light fa-sign-in"
[disabled]="joinForm.invalid"></lfx-button>
} @else {
<lfx-button
size="small"
severity="primary"
label="Join Meeting"
[disabled]="joinForm.invalid"
icon="fa-light fa-sign-in"
(click)="onJoinMeeting()"></lfx-button>
<div class="flex items-center" [ngClass]="{ 'justify-between': !canJoinMeeting(), 'justify-end': canJoinMeeting() }">
@if (!canJoinMeeting()) {
<div class="flex items-center gap-2 text-sm" [ngClass]="{ 'text-blue-700': canJoinMeeting(), 'text-amber-700': !canJoinMeeting() }">
<i class="fa-light fa-clock text-amber-600"></i>
<div class="flex flex-col">
<span class="font-sans text-amber-700"
>You may only join the meeting up to <span class="font-bold">{{ meeting().early_join_time_minutes || 10 }} minutes</span> before
the start time.</span
>
</div>
</div>
}
<div class="flex items-center">
@if (meeting().join_url) {
<lfx-button
size="small"
[href]="joinForm.invalid || !canJoinMeeting() ? undefined : joinUrlWithParams()"
severity="primary"
label="Join Meeting"
icon="fa-light fa-sign-in"
[disabled]="joinForm.invalid || !canJoinMeeting()"></lfx-button>
} @else {
<lfx-button
size="small"
severity="primary"
label="Join Meeting"
[disabled]="joinForm.invalid || !canJoinMeeting()"
icon="fa-light fa-sign-in"
(click)="onJoinMeeting()"></lfx-button>
}
</div>
</div>
</form>
</div>
Expand Down
101 changes: 100 additions & 1 deletion apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT

import { CommonModule } from '@angular/common';
import { HttpParams } from '@angular/common/http';
import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
Expand Down Expand Up @@ -57,19 +58,36 @@ export class MeetingComponent {
public importantLinks: Signal<{ url: string; domain: string }[]>;
public returnTo: Signal<string | undefined>;
public password: WritableSignal<string | null> = signal<string | null>(null);
public canJoinMeeting: Signal<boolean>;
public joinUrlWithParams: Signal<string | undefined>;

// Form value signals for reactivity
private formValues: Signal<{ name: string; email: string; organization: string }>;

public constructor() {
// Initialize all class variables
this.isJoining = signal<boolean>(false);
this.authenticated = this.userService.authenticated;
this.meeting = this.initializeMeeting();
this.joinForm = this.initializeJoinForm();
this.formValues = this.initializeFormValues();
this.meetingTypeBadge = this.initializeMeetingTypeBadge();
this.importantLinks = this.initializeImportantLinks();
this.returnTo = this.initializeReturnTo();
this.canJoinMeeting = this.initializeCanJoinMeeting();
this.joinUrlWithParams = this.initializeJoinUrlWithParams();
}

public onJoinMeeting(): void {
if (!this.canJoinMeeting()) {
this.messageService.add({
severity: 'warn',
summary: 'Meeting Not Available',
detail: 'The meeting has not started yet.',
});
return;
}

this.isJoining.set(true);

this.meetingService
Expand All @@ -80,7 +98,8 @@ export class MeetingComponent {
.subscribe({
next: (res) => {
this.meeting().join_url = res.join_url;
window.open(this.meeting().join_url as string, '_blank');
const joinUrlWithParams = this.buildJoinUrlWithParams(res.join_url);
window.open(joinUrlWithParams, '_blank');
},
error: ({ error }) => {
this.messageService.add({ severity: 'error', summary: 'Error', detail: error.error });
Expand Down Expand Up @@ -118,6 +137,25 @@ export class MeetingComponent {
});
}

private initializeFormValues(): Signal<{ name: string; email: string; organization: string }> {
return toSignal(
this.joinForm.valueChanges.pipe(
map(() => ({
name: this.joinForm.get('name')?.value || '',
email: this.joinForm.get('email')?.value || '',
organization: this.joinForm.get('organization')?.value || '',
}))
),
{
initialValue: {
name: this.joinForm.get('name')?.value || '',
email: this.joinForm.get('email')?.value || '',
organization: this.joinForm.get('organization')?.value || '',
},
}
);
}

private initializeMeetingTypeBadge(): Signal<{ badgeClass: string; icon?: string; text: string } | null> {
return computed(() => {
const meetingType = this.meeting()?.meeting_type || 'none';
Expand Down Expand Up @@ -181,4 +219,65 @@ export class MeetingComponent {
return `${environment.urls.home}/meetings/${this.meeting().uid}?password=${this.password()}`;
});
}

private initializeCanJoinMeeting(): Signal<boolean> {
return computed(() => {
const meeting = this.meeting();
if (!meeting?.start_time) {
return false;
}

const now = new Date();
const startTime = new Date(meeting.start_time);
const earlyJoinMinutes = meeting.early_join_time_minutes || 10; // Default to 10 minutes
const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000);

return now >= earliestJoinTime;
});
}

private initializeJoinUrlWithParams(): Signal<string | undefined> {
return computed(() => {
const meeting = this.meeting();
const joinUrl = meeting?.join_url;

if (!joinUrl) {
return undefined;
}

// Access form values to trigger reactivity
const formValues = this.formValues();
return this.buildJoinUrlWithParams(joinUrl, formValues);
});
}

private buildJoinUrlWithParams(joinUrl: string, formValues?: { name: string; email: string; organization: string }): string {
if (!joinUrl) {
return joinUrl;
}

// Get user name from authenticated user or form
const userName = this.authenticated() ? this.user()?.name : formValues?.name || this.joinForm.get('name')?.value;
const organization = this.authenticated() ? '' : formValues?.organization || this.joinForm.get('organization')?.value;

if (!userName) {
return joinUrl;
}

// Build the display name with organization if available
const displayName = organization ? `${userName} (${organization})` : userName;

// Create base64 encoded version
const encodedName = btoa(unescape(encodeURIComponent(displayName)));

// Build query parameters
const queryParams = new HttpParams().set('uname', displayName).set('un', encodedName);
const queryString = queryParams.toString();

// Append to URL, handling existing query strings
if (joinUrl.includes('?')) {
return `${joinUrl}&${queryString}`;
}
return `${joinUrl}?${queryString}`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export class PublicMeetingController {
// Get the meeting by ID using M2M token
const meeting = await this.fetchMeetingWithM2M(req, id);
const project = await this.projectService.getProjectById(req, meeting.project_uid, false);
const registrants = await this.meetingService.getMeetingRegistrants(req, meeting.uid);
const committeeMembers = registrants.filter((r) => r.type === 'committee').length ?? 0;
meeting.individual_registrants_count = registrants.length - committeeMembers;
meeting.committee_members_count = committeeMembers;

if (!project) {
throw new ResourceNotFoundError('Project', meeting.project_uid, {
Expand All @@ -56,7 +60,10 @@ export class PublicMeetingController {

// Check if the meeting visibility is public and not restricted, if so, get join URL and return the meeting and project
if (meeting.visibility === MeetingVisibility.PUBLIC && !meeting.restricted) {
await this.handleJoinUrlForPublicMeeting(req, meeting, id);
// Only get join URL if within allowed join time window
if (this.isWithinJoinWindow(meeting)) {
await this.handleJoinUrlForPublicMeeting(req, meeting, id);
}
res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } });
return;
}
Expand Down Expand Up @@ -109,6 +116,22 @@ export class PublicMeetingController {
return;
}

// Check if the meeting is within the allowed join time window
if (!this.isWithinJoinWindow(meeting)) {
const earlyJoinMinutes = meeting.early_join_time_minutes || 10;

Logger.error(req, 'post_meeting_join_url', startTime, new Error('Meeting join not available yet'));

const validationError = ServiceValidationError.forField('timing', `You can join the meeting up to ${earlyJoinMinutes} minutes before the start time`, {
operation: 'post_meeting_join_url',
service: 'public_meeting_controller',
path: req.path,
});

next(validationError);
return;
}

// Check that the user has access to the meeting by validating they were invited to the meeting
// Restricted meetings require an email to be provided
if (meeting.restricted) {
Expand Down Expand Up @@ -210,6 +233,22 @@ export class PublicMeetingController {
}
}

/**
* Checks if the current time is within the allowed join window for a meeting
*/
private isWithinJoinWindow(meeting: any): boolean {
if (!meeting?.start_time) {
return false;
}

const now = new Date();
const startTime = new Date(meeting.start_time);
const earlyJoinMinutes = meeting.early_join_time_minutes || 10; // Default to 10 minutes
const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000);

return now >= earliestJoinTime;
}

private async restrictedMeetingCheck(req: Request, next: NextFunction, email: string, id: string, startTime: number): Promise<void> {
// Check that the user has access to the meeting by validating they were invited to the meeting
if (!email) {
Expand Down