Skip to content

Commit 8b4450f

Browse files
authored
feat(meetings): implement time-based join control and user info (#81)
* feat(meetings): implement time-based join control and user info - Add frontend validation to disable join button based on early_join_time_minutes - Implement server-side time validation for meeting join URL access - Append user information (name, organization) as query parameters to join URLs - Make form reactive to update join URL parameters in real-time LFXV2-460, LFXV2-461, LFXV2-462, LFXV2-463 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(ui): add fallback for join time minutes Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 744f124 commit 8b4450f

File tree

3 files changed

+205
-45
lines changed

3 files changed

+205
-45
lines changed

apps/lfx-pcc/src/app/modules/meeting/meeting.component.html

Lines changed: 65 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -107,24 +107,10 @@ <h2 class="text-xl font-display font-semibold text-gray-900 mb-1 leading-tight"
107107
<div class="bg-gray-50 rounded-lg p-3 space-y-2" data-testid="attendees-card">
108108
<div class="flex items-center gap-2 text-xs text-gray-500">
109109
<i class="fa-light fa-users text-gray-400"></i>
110-
<span>Attendees</span>
110+
<span>Guests</span>
111111
</div>
112112
<div class="space-y-2">
113113
<div class="text-sm font-sans">{{ (meeting().individual_registrants_count || 0) + (meeting().committee_members_count || 0) }} invited</div>
114-
<div class="flex items-center justify-between text-xs text-gray-500">
115-
<div class="flex items-center gap-1">
116-
<i class="fa-light fa-check-circle text-green-500"></i>
117-
<span>{{ meeting().registrants_accepted_count || 0 }} Yes</span>
118-
</div>
119-
<div class="flex items-center gap-1">
120-
<i class="fa-light fa-times-circle text-red-500"></i>
121-
<span>{{ meeting().registrants_declined_count || 0 }} No</span>
122-
</div>
123-
<div class="flex items-center gap-1">
124-
<i class="fa-light fa-question-circle text-amber-500"></i>
125-
<span>{{ meeting().registrants_pending_count || 0 }} Maybe</span>
126-
</div>
127-
</div>
128114
</div>
129115
</div>
130116

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

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

181-
<div class="flex items-center justify-between bg-blue-50 border border-blue-200 p-3 rounded-lg">
182-
<div class="flex items-center gap-3 text-sm text-blue-700">
183-
<i class="fa-light fa-check-circle"></i>
184-
<span class="font-sans">Ready to join as {{ user.name }}</span>
166+
<div
167+
class="flex items-center justify-between p-3 rounded-lg"
168+
[ngClass]="{ 'bg-amber-50 border border-amber-200': !canJoinMeeting(), 'bg-blue-50 border border-blue-200': canJoinMeeting() }">
169+
<div class="flex items-center gap-3 text-sm" [ngClass]="{ 'text-blue-700': canJoinMeeting(), 'text-amber-700': !canJoinMeeting() }">
170+
@if (canJoinMeeting()) {
171+
<i class="fa-light fa-check-circle"></i>
172+
<span class="font-sans">Ready to join as {{ user.name }}</span>
173+
} @else {
174+
<i class="fa-light fa-clock text-amber-600"></i>
175+
<div class="flex flex-col">
176+
<span class="font-sans text-amber-700"
177+
>You may only join the meeting up to <span class="font-bold">{{ meeting().early_join_time_minutes || 10 }} minutes</span> before the
178+
start time.</span
179+
>
180+
</div>
181+
}
185182
</div>
186183
@if (meeting().join_url) {
187-
<lfx-button size="small" [href]="meeting().join_url" severity="primary" label="Join Meeting" icon="fa-light fa-sign-in"></lfx-button>
184+
<lfx-button
185+
size="small"
186+
[href]="canJoinMeeting() ? joinUrlWithParams() : undefined"
187+
[disabled]="!canJoinMeeting()"
188+
severity="primary"
189+
label="Join Meeting"
190+
icon="fa-light fa-sign-in"></lfx-button>
188191
} @else {
189-
<lfx-button size="small" severity="primary" label="Join Meeting" icon="fa-light fa-sign-in" (click)="onJoinMeeting()"></lfx-button>
192+
<lfx-button
193+
size="small"
194+
severity="primary"
195+
label="Join Meeting"
196+
[disabled]="!canJoinMeeting()"
197+
icon="fa-light fa-sign-in"
198+
(click)="onJoinMeeting()"></lfx-button>
190199
}
191200
</div>
192201

@@ -206,7 +215,7 @@ <h4 class="font-medium text-gray-900 font-sans">{{ user.name }}</h4>
206215
<div class="flex items-center gap-3">
207216
<i class="fa-light fa-sign-in text-blue-600 text-lg"></i>
208217
<div>
209-
<h4 class="font-medium text-gray-900 font-sans">Sign in with your account</h4>
218+
<h4 class="font-medium text-gray-900 font-sans">Sign in with your LFX account</h4>
210219
<p class="text-sm text-gray-600 font-sans">Join quickly with your saved information</p>
211220
</div>
212221
</div>
@@ -265,24 +274,37 @@ <h4 class="font-medium text-gray-900 font-sans">Enter your information</h4>
265274
</div>
266275
</div>
267276

268-
<div class="flex items-center justify-end pt-3">
269-
@if (meeting().join_url) {
270-
<lfx-button
271-
size="small"
272-
[href]="joinForm.invalid ? undefined : meeting().join_url"
273-
severity="primary"
274-
label="Join Meeting"
275-
icon="fa-light fa-sign-in"
276-
[disabled]="joinForm.invalid"></lfx-button>
277-
} @else {
278-
<lfx-button
279-
size="small"
280-
severity="primary"
281-
label="Join Meeting"
282-
[disabled]="joinForm.invalid"
283-
icon="fa-light fa-sign-in"
284-
(click)="onJoinMeeting()"></lfx-button>
277+
<div class="flex items-center" [ngClass]="{ 'justify-between': !canJoinMeeting(), 'justify-end': canJoinMeeting() }">
278+
@if (!canJoinMeeting()) {
279+
<div class="flex items-center gap-2 text-sm" [ngClass]="{ 'text-blue-700': canJoinMeeting(), 'text-amber-700': !canJoinMeeting() }">
280+
<i class="fa-light fa-clock text-amber-600"></i>
281+
<div class="flex flex-col">
282+
<span class="font-sans text-amber-700"
283+
>You may only join the meeting up to <span class="font-bold">{{ meeting().early_join_time_minutes || 10 }} minutes</span> before
284+
the start time.</span
285+
>
286+
</div>
287+
</div>
285288
}
289+
<div class="flex items-center">
290+
@if (meeting().join_url) {
291+
<lfx-button
292+
size="small"
293+
[href]="joinForm.invalid || !canJoinMeeting() ? undefined : joinUrlWithParams()"
294+
severity="primary"
295+
label="Join Meeting"
296+
icon="fa-light fa-sign-in"
297+
[disabled]="joinForm.invalid || !canJoinMeeting()"></lfx-button>
298+
} @else {
299+
<lfx-button
300+
size="small"
301+
severity="primary"
302+
label="Join Meeting"
303+
[disabled]="joinForm.invalid || !canJoinMeeting()"
304+
icon="fa-light fa-sign-in"
305+
(click)="onJoinMeeting()"></lfx-button>
306+
}
307+
</div>
286308
</div>
287309
</form>
288310
</div>

apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
import { CommonModule } from '@angular/common';
5+
import { HttpParams } from '@angular/common/http';
56
import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core';
67
import { toSignal } from '@angular/core/rxjs-interop';
78
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@@ -57,19 +58,36 @@ export class MeetingComponent {
5758
public importantLinks: Signal<{ url: string; domain: string }[]>;
5859
public returnTo: Signal<string | undefined>;
5960
public password: WritableSignal<string | null> = signal<string | null>(null);
61+
public canJoinMeeting: Signal<boolean>;
62+
public joinUrlWithParams: Signal<string | undefined>;
63+
64+
// Form value signals for reactivity
65+
private formValues: Signal<{ name: string; email: string; organization: string }>;
6066

6167
public constructor() {
6268
// Initialize all class variables
6369
this.isJoining = signal<boolean>(false);
6470
this.authenticated = this.userService.authenticated;
6571
this.meeting = this.initializeMeeting();
6672
this.joinForm = this.initializeJoinForm();
73+
this.formValues = this.initializeFormValues();
6774
this.meetingTypeBadge = this.initializeMeetingTypeBadge();
6875
this.importantLinks = this.initializeImportantLinks();
6976
this.returnTo = this.initializeReturnTo();
77+
this.canJoinMeeting = this.initializeCanJoinMeeting();
78+
this.joinUrlWithParams = this.initializeJoinUrlWithParams();
7079
}
7180

7281
public onJoinMeeting(): void {
82+
if (!this.canJoinMeeting()) {
83+
this.messageService.add({
84+
severity: 'warn',
85+
summary: 'Meeting Not Available',
86+
detail: 'The meeting has not started yet.',
87+
});
88+
return;
89+
}
90+
7391
this.isJoining.set(true);
7492

7593
this.meetingService
@@ -80,7 +98,8 @@ export class MeetingComponent {
8098
.subscribe({
8199
next: (res) => {
82100
this.meeting().join_url = res.join_url;
83-
window.open(this.meeting().join_url as string, '_blank');
101+
const joinUrlWithParams = this.buildJoinUrlWithParams(res.join_url);
102+
window.open(joinUrlWithParams, '_blank');
84103
},
85104
error: ({ error }) => {
86105
this.messageService.add({ severity: 'error', summary: 'Error', detail: error.error });
@@ -118,6 +137,25 @@ export class MeetingComponent {
118137
});
119138
}
120139

140+
private initializeFormValues(): Signal<{ name: string; email: string; organization: string }> {
141+
return toSignal(
142+
this.joinForm.valueChanges.pipe(
143+
map(() => ({
144+
name: this.joinForm.get('name')?.value || '',
145+
email: this.joinForm.get('email')?.value || '',
146+
organization: this.joinForm.get('organization')?.value || '',
147+
}))
148+
),
149+
{
150+
initialValue: {
151+
name: this.joinForm.get('name')?.value || '',
152+
email: this.joinForm.get('email')?.value || '',
153+
organization: this.joinForm.get('organization')?.value || '',
154+
},
155+
}
156+
);
157+
}
158+
121159
private initializeMeetingTypeBadge(): Signal<{ badgeClass: string; icon?: string; text: string } | null> {
122160
return computed(() => {
123161
const meetingType = this.meeting()?.meeting_type || 'none';
@@ -181,4 +219,65 @@ export class MeetingComponent {
181219
return `${environment.urls.home}/meetings/${this.meeting().uid}?password=${this.password()}`;
182220
});
183221
}
222+
223+
private initializeCanJoinMeeting(): Signal<boolean> {
224+
return computed(() => {
225+
const meeting = this.meeting();
226+
if (!meeting?.start_time) {
227+
return false;
228+
}
229+
230+
const now = new Date();
231+
const startTime = new Date(meeting.start_time);
232+
const earlyJoinMinutes = meeting.early_join_time_minutes || 10; // Default to 10 minutes
233+
const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000);
234+
235+
return now >= earliestJoinTime;
236+
});
237+
}
238+
239+
private initializeJoinUrlWithParams(): Signal<string | undefined> {
240+
return computed(() => {
241+
const meeting = this.meeting();
242+
const joinUrl = meeting?.join_url;
243+
244+
if (!joinUrl) {
245+
return undefined;
246+
}
247+
248+
// Access form values to trigger reactivity
249+
const formValues = this.formValues();
250+
return this.buildJoinUrlWithParams(joinUrl, formValues);
251+
});
252+
}
253+
254+
private buildJoinUrlWithParams(joinUrl: string, formValues?: { name: string; email: string; organization: string }): string {
255+
if (!joinUrl) {
256+
return joinUrl;
257+
}
258+
259+
// Get user name from authenticated user or form
260+
const userName = this.authenticated() ? this.user()?.name : formValues?.name || this.joinForm.get('name')?.value;
261+
const organization = this.authenticated() ? '' : formValues?.organization || this.joinForm.get('organization')?.value;
262+
263+
if (!userName) {
264+
return joinUrl;
265+
}
266+
267+
// Build the display name with organization if available
268+
const displayName = organization ? `${userName} (${organization})` : userName;
269+
270+
// Create base64 encoded version
271+
const encodedName = btoa(unescape(encodeURIComponent(displayName)));
272+
273+
// Build query parameters
274+
const queryParams = new HttpParams().set('uname', displayName).set('un', encodedName);
275+
const queryString = queryParams.toString();
276+
277+
// Append to URL, handling existing query strings
278+
if (joinUrl.includes('?')) {
279+
return `${joinUrl}&${queryString}`;
280+
}
281+
return `${joinUrl}?${queryString}`;
282+
}
184283
}

apps/lfx-pcc/src/server/controllers/public-meeting.controller.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export class PublicMeetingController {
3838
// Get the meeting by ID using M2M token
3939
const meeting = await this.fetchMeetingWithM2M(req, id);
4040
const project = await this.projectService.getProjectById(req, meeting.project_uid, false);
41+
const registrants = await this.meetingService.getMeetingRegistrants(req, meeting.uid);
42+
const committeeMembers = registrants.filter((r) => r.type === 'committee').length ?? 0;
43+
meeting.individual_registrants_count = registrants.length - committeeMembers;
44+
meeting.committee_members_count = committeeMembers;
4145

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

5761
// Check if the meeting visibility is public and not restricted, if so, get join URL and return the meeting and project
5862
if (meeting.visibility === MeetingVisibility.PUBLIC && !meeting.restricted) {
59-
await this.handleJoinUrlForPublicMeeting(req, meeting, id);
63+
// Only get join URL if within allowed join time window
64+
if (this.isWithinJoinWindow(meeting)) {
65+
await this.handleJoinUrlForPublicMeeting(req, meeting, id);
66+
}
6067
res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } });
6168
return;
6269
}
@@ -109,6 +116,22 @@ export class PublicMeetingController {
109116
return;
110117
}
111118

119+
// Check if the meeting is within the allowed join time window
120+
if (!this.isWithinJoinWindow(meeting)) {
121+
const earlyJoinMinutes = meeting.early_join_time_minutes || 10;
122+
123+
Logger.error(req, 'post_meeting_join_url', startTime, new Error('Meeting join not available yet'));
124+
125+
const validationError = ServiceValidationError.forField('timing', `You can join the meeting up to ${earlyJoinMinutes} minutes before the start time`, {
126+
operation: 'post_meeting_join_url',
127+
service: 'public_meeting_controller',
128+
path: req.path,
129+
});
130+
131+
next(validationError);
132+
return;
133+
}
134+
112135
// Check that the user has access to the meeting by validating they were invited to the meeting
113136
// Restricted meetings require an email to be provided
114137
if (meeting.restricted) {
@@ -210,6 +233,22 @@ export class PublicMeetingController {
210233
}
211234
}
212235

236+
/**
237+
* Checks if the current time is within the allowed join window for a meeting
238+
*/
239+
private isWithinJoinWindow(meeting: any): boolean {
240+
if (!meeting?.start_time) {
241+
return false;
242+
}
243+
244+
const now = new Date();
245+
const startTime = new Date(meeting.start_time);
246+
const earlyJoinMinutes = meeting.early_join_time_minutes || 10; // Default to 10 minutes
247+
const earliestJoinTime = new Date(startTime.getTime() - earlyJoinMinutes * 60000);
248+
249+
return now >= earliestJoinTime;
250+
}
251+
213252
private async restrictedMeetingCheck(req: Request, next: NextFunction, email: string, id: string, startTime: number): Promise<void> {
214253
// Check that the user has access to the meeting by validating they were invited to the meeting
215254
if (!email) {

0 commit comments

Comments
 (0)