diff --git a/apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts b/apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts index 27955820..4a628aeb 100644 --- a/apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts @@ -8,6 +8,7 @@ import { Router } from '@angular/router'; import { MeetingService } from '@app/shared/services/meeting.service'; import { ButtonComponent } from '@components/button/button.component'; import { DashboardMeetingCardComponent } from '@components/dashboard-meeting-card/dashboard-meeting-card.component'; +import { getActiveOccurrences } from '@lfx-one/shared'; import { SkeletonModule } from 'primeng/skeleton'; import { finalize } from 'rxjs'; @@ -38,7 +39,10 @@ export class MyMeetingsComponent { for (const meeting of this.allMeetings()) { // Process occurrences if they exist if (meeting.occurrences && meeting.occurrences.length > 0) { - for (const occurrence of meeting.occurrences) { + // Get only active (non-cancelled) occurrences + const activeOccurrences = getActiveOccurrences(meeting.occurrences); + + for (const occurrence of activeOccurrences) { const startTime = new Date(occurrence.start_time); const startTimeMs = startTime.getTime(); const endTime = startTimeMs + occurrence.duration * 60 * 1000 + buffer; @@ -89,7 +93,10 @@ export class MyMeetingsComponent { for (const meeting of this.allMeetings()) { // Process occurrences if they exist if (meeting.occurrences && meeting.occurrences.length > 0) { - for (const occurrence of meeting.occurrences) { + // Get only active (non-cancelled) occurrences + const activeOccurrences = getActiveOccurrences(meeting.occurrences); + + for (const occurrence of activeOccurrences) { const startTime = new Date(occurrence.start_time); const startTimeMs = startTime.getTime(); diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.html new file mode 100644 index 00000000..320de0ef --- /dev/null +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.html @@ -0,0 +1,69 @@ + + + +
+ +
+
+

Occurrence Details

+ +
+
+ Title: + {{ occurrence.title || meeting.title || 'Untitled Meeting' }} +
+ +
+ Date: + {{ occurrence.start_time | meetingTime: occurrence.duration : 'date' }} +
+ +
+ Time: + {{ occurrence.start_time | meetingTime: occurrence.duration : 'time' }} +
+ +
+ Series: + + + Part of Recurring Series + +
+
+
+
+ + + + +

Warning

+

This will permanently cancel this specific occurrence. Guests will be notified of the cancellation. This action cannot be undone.

+
+
+ + +
+ + + + + +
+
diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.ts new file mode 100644 index 00000000..222065b5 --- /dev/null +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component.ts @@ -0,0 +1,59 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces'; +import { MeetingService } from '@services/meeting.service'; +import { ButtonComponent } from '@components/button/button.component'; +import { MessageComponent } from '@components/message/message.component'; +import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; +import { HttpErrorResponse } from '@angular/common/http'; + +@Component({ + selector: 'lfx-meeting-cancel-occurrence-confirmation', + standalone: true, + imports: [CommonModule, ButtonComponent, MessageComponent, MeetingTimePipe], + templateUrl: './meeting-cancel-occurrence-confirmation.component.html', +}) +export class MeetingCancelOccurrenceConfirmationComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly config = inject(DynamicDialogConfig); + private readonly meetingService = inject(MeetingService); + + public readonly meeting: Meeting = this.config.data.meeting; + public readonly occurrence: MeetingOccurrence = this.config.data.occurrence; + public readonly isCanceling = signal(false); + + public onCancel(): void { + this.dialogRef.close({ confirmed: false }); + } + + public onConfirm(): void { + this.isCanceling.set(true); + + this.meetingService.cancelOccurrence(this.meeting.uid, this.occurrence.occurrence_id).subscribe({ + next: () => { + this.isCanceling.set(false); + this.dialogRef.close({ confirmed: true }); + }, + error: (error: HttpErrorResponse) => { + this.isCanceling.set(false); + let errorMessage = 'Failed to cancel occurrence. Please try again.'; + + if (error.status === 404) { + errorMessage = 'Meeting occurrence not found.'; + } else if (error.status === 403) { + errorMessage = 'You do not have permission to cancel this occurrence.'; + } else if (error.status === 500) { + errorMessage = 'Server error occurred while canceling occurrence.'; + } else if (error.status === 0) { + errorMessage = 'Network error. Please check your connection.'; + } + + this.dialogRef.close({ confirmed: false, error: errorMessage }); + }, + }); + } +} 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 b689ec7a..1c2a7aeb 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 @@ -20,6 +20,7 @@ import { getCurrentOrNextOccurrence, Meeting, MeetingAttachment, + MeetingCancelOccurrenceResult, MeetingOccurrence, MeetingRegistrant, PastMeeting, @@ -37,8 +38,10 @@ import { DialogService } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; import { BehaviorSubject, catchError, filter, finalize, map, of, switchMap, take, tap } from 'rxjs'; +import { MeetingCancelOccurrenceConfirmationComponent } from '../meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component'; import { MeetingCommitteeModalComponent } from '../meeting-committee-modal/meeting-committee-modal.component'; import { MeetingDeleteConfirmationComponent, MeetingDeleteResult } from '../meeting-delete-confirmation/meeting-delete-confirmation.component'; +import { MeetingDeleteTypeSelectionComponent, MeetingDeleteTypeResult } from '../meeting-delete-type-selection/meeting-delete-type-selection.component'; import { RecordingModalComponent } from '../recording-modal/recording-modal.component'; import { RegistrantModalComponent } from '../registrant-modal/registrant-modal.component'; import { SummaryModalComponent } from '../summary-modal/summary-modal.component'; @@ -464,6 +467,85 @@ export class MeetingCardComponent implements OnInit { const meeting = this.meeting(); if (!meeting) return; + // Check if meeting is recurring + const isRecurring = !!meeting.recurrence; + + if (isRecurring) { + // For recurring meetings, first show the delete type selection modal + this.dialogService + .open(MeetingDeleteTypeSelectionComponent, { + header: 'Delete Recurring Meeting', + width: '500px', + modal: true, + closable: true, + dismissableMask: true, + data: { + meeting: meeting, + }, + }) + .onClose.pipe(take(1)) + .subscribe((typeResult: MeetingDeleteTypeResult) => { + if (typeResult) { + if (typeResult.deleteType === 'occurrence') { + // User wants to cancel just this occurrence + this.showCancelOccurrenceModal(meeting); + } else { + // User wants to delete the entire series + this.showDeleteMeetingModal(meeting); + } + } + }); + } else { + // For non-recurring meetings, show delete confirmation directly + this.showDeleteMeetingModal(meeting); + } + } + + private showCancelOccurrenceModal(meeting: Meeting): void { + // Prefer the explicitly selected/current occurrence; fallback to next active + const occurrenceToCancel = this.occurrence() ?? getCurrentOrNextOccurrence(meeting); + + if (!occurrenceToCancel) { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'No upcoming occurrence found to cancel.', + }); + return; + } + + this.dialogService + .open(MeetingCancelOccurrenceConfirmationComponent, { + header: 'Cancel Occurrence', + width: '450px', + modal: true, + closable: true, + dismissableMask: true, + data: { + meeting: meeting, + occurrence: occurrenceToCancel, + }, + }) + .onClose.pipe(take(1)) + .subscribe((result: MeetingCancelOccurrenceResult) => { + if (result?.confirmed) { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Meeting occurrence canceled successfully', + }); + this.meetingDeleted.emit(); + } else if (result?.error) { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: result.error, + }); + } + }); + } + + private showDeleteMeetingModal(meeting: Meeting): void { this.dialogService .open(MeetingDeleteConfirmationComponent, { header: 'Delete Meeting', @@ -477,7 +559,7 @@ export class MeetingCardComponent implements OnInit { }) .onClose.pipe(take(1)) .subscribe((result: MeetingDeleteResult) => { - if (result) { + if (result?.confirmed) { this.meetingDeleted.emit(); } }); diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html index 0107ad56..efd03408 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html @@ -92,12 +92,13 @@

Warning

- + diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.html new file mode 100644 index 00000000..004415a7 --- /dev/null +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.html @@ -0,0 +1,76 @@ + + + +
+ +
+

This is a recurring meeting. Would you like to cancel just this occurrence or delete the entire meeting series?

+
+ + +
+
+
+
+ +
+
+

Cancel This Occurrence

+

Only this specific meeting instance will be canceled. The rest of the series will remain.

+
+
+
+ +
+
+
+ +
+
+

Delete Entire Series

+

The entire recurring meeting series will be permanently deleted.

+
+
+
+
+ + +
+ + + + + +
+
diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.ts new file mode 100644 index 00000000..8eb03ba1 --- /dev/null +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-type-selection/meeting-delete-type-selection.component.ts @@ -0,0 +1,43 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Meeting } from '@lfx-one/shared/interfaces'; +import { ButtonComponent } from '@components/button/button.component'; + +export interface MeetingDeleteTypeResult { + deleteType: 'occurrence' | 'series'; +} + +@Component({ + selector: 'lfx-meeting-delete-type-selection', + standalone: true, + imports: [CommonModule, ButtonComponent], + templateUrl: './meeting-delete-type-selection.component.html', +}) +export class MeetingDeleteTypeSelectionComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly dialogConfig = inject(DynamicDialogConfig); + + public readonly meeting: Meeting = this.dialogConfig.data.meeting; + public selectedType: 'occurrence' | 'series' | null = null; + + public selectType(type: 'occurrence' | 'series'): void { + this.selectedType = type; + } + + public onCancel(): void { + this.dialogRef.close(); + } + + public onContinue(): void { + if (this.selectedType) { + const result: MeetingDeleteTypeResult = { + deleteType: this.selectedType, + }; + this.dialogRef.close(result); + } + } +} 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 76512f98..51f4b582 100644 --- a/apps/lfx-one/src/app/shared/services/meeting.service.ts +++ b/apps/lfx-one/src/app/shared/services/meeting.service.ts @@ -201,6 +201,10 @@ export class MeetingService { ); } + public cancelOccurrence(meetingId: string, occurrenceId: string): Observable { + return this.http.delete(`/api/meetings/${meetingId}/occurrences/${occurrenceId}`).pipe(take(1)); + } + public getMeetingAttachments(meetingId: string): Observable { return this.http.get(`/api/meetings/${meetingId}/attachments`).pipe( catchError((error) => { diff --git a/apps/lfx-one/src/server/controllers/meeting.controller.ts b/apps/lfx-one/src/server/controllers/meeting.controller.ts index 8e0ff2eb..a28ab84b 100644 --- a/apps/lfx-one/src/server/controllers/meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/meeting.controller.ts @@ -285,6 +285,67 @@ export class MeetingController { } } + /** + * DELETE /meetings/:uid/occurrences/:occurrenceId + */ + public async cancelOccurrence(req: Request, res: Response, next: NextFunction): Promise { + const { uid, occurrenceId } = req.params; + const startTime = Logger.start(req, 'cancel_occurrence', { + meeting_uid: uid, + occurrence_id: occurrenceId, + }); + + try { + // Check if the meeting UID is provided + if ( + !validateUidParameter(uid, req, next, { + operation: 'cancel_occurrence', + service: 'meeting_controller', + logStartTime: startTime, + }) + ) { + return; + } + + // Check if the occurrence ID is provided + if (!occurrenceId) { + const validationError = ServiceValidationError.forField('occurrenceId', 'Occurrence ID is required', { + operation: 'cancel_occurrence', + service: 'meeting_controller', + }); + + Logger.error(req, 'cancel_occurrence', startTime, validationError, { + meeting_uid: uid, + occurrence_id: occurrenceId, + }); + + return next(validationError); + } + + // Cancel the occurrence + await this.meetingService.cancelOccurrence(req, uid, occurrenceId); + + // Log the success + Logger.success(req, 'cancel_occurrence', startTime, { + meeting_uid: uid, + occurrence_id: occurrenceId, + status_code: 204, + }); + + // Send the response to the client + res.status(204).send(); + } catch (error) { + // Log the error + Logger.error(req, 'cancel_occurrence', startTime, error, { + meeting_uid: uid, + occurrence_id: occurrenceId, + }); + + // Send the error to the next middleware + next(error); + } + } + /** * GET /meetings/:uid/registrants */ diff --git a/apps/lfx-one/src/server/routes/meetings.route.ts b/apps/lfx-one/src/server/routes/meetings.route.ts index 6cd502c7..4b884cb4 100644 --- a/apps/lfx-one/src/server/routes/meetings.route.ts +++ b/apps/lfx-one/src/server/routes/meetings.route.ts @@ -33,6 +33,9 @@ router.put('/:uid', (req, res, next) => meetingController.updateMeeting(req, res // DELETE /meetings/:uid - delete a meeting router.delete('/:uid', (req, res, next) => meetingController.deleteMeeting(req, res, next)); +// DELETE /meetings/:uid/occurrences/:occurrenceId - cancel a meeting occurrence +router.delete('/:uid/occurrences/:occurrenceId', (req, res, next) => meetingController.cancelOccurrence(req, res, next)); + // Registrant routes router.get('/:uid/registrants', (req, res, next) => meetingController.getMeetingRegistrants(req, res, next)); diff --git a/apps/lfx-one/src/server/services/meeting.service.ts b/apps/lfx-one/src/server/services/meeting.service.ts index ddddad70..686033cb 100644 --- a/apps/lfx-one/src/server/services/meeting.service.ts +++ b/apps/lfx-one/src/server/services/meeting.service.ts @@ -219,6 +219,26 @@ export class MeetingService { ); } + /** + * Cancels a meeting occurrence using ETag for concurrency control + */ + public async cancelOccurrence(req: Request, meetingUid: string, occurrenceId: string): Promise { + // Step 1: Fetch meeting with ETag + const { etag } = await this.etagService.fetchWithETag(req, 'LFX_V2_SERVICE', `/meetings/${meetingUid}`, 'cancel_occurrence'); + + // Step 2: Cancel occurrence with ETag + await this.etagService.deleteWithETag(req, 'LFX_V2_SERVICE', `/meetings/${meetingUid}/occurrences/${occurrenceId}`, etag, 'cancel_occurrence'); + + req.log.info( + { + operation: 'cancel_occurrence', + meeting_uid: meetingUid, + occurrence_id: occurrenceId, + }, + 'Meeting occurrence canceled successfully' + ); + } + /** * Fetches all registrants for a meeting * @param includeRsvp - If true, includes RSVP status for each registrant diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index ffbbb249..bc2d09ac 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -164,6 +164,8 @@ export interface MeetingOccurrence { start_time: string; /** Meeting duration in minutes (0-600) */ duration: number; + /** Whether this occurrence has been cancelled */ + is_cancelled?: boolean; } /** @@ -564,6 +566,15 @@ export interface PastMeetingParticipant { updated_at: string; } +/** + * Result of canceling a meeting occurrence + * @description Contains the result of canceling a meeting occurrence + */ +export interface MeetingCancelOccurrenceResult { + confirmed: boolean; + error?: string; +} + /** * Recording session information * @description Individual session within a past meeting recording diff --git a/packages/shared/src/utils/meeting.utils.ts b/packages/shared/src/utils/meeting.utils.ts index 30ef5abd..7ca64ea7 100644 --- a/packages/shared/src/utils/meeting.utils.ts +++ b/packages/shared/src/utils/meeting.utils.ts @@ -103,6 +103,15 @@ export function buildRecurrenceSummary(pattern: CustomRecurrencePattern): Recurr }; } +/** + * Filter out cancelled occurrences from a list + * @param occurrences Array of meeting occurrences + * @returns Array of active (non-cancelled) occurrences + */ +export function getActiveOccurrences(occurrences: MeetingOccurrence[]): MeetingOccurrence[] { + return occurrences.filter((occurrence) => !occurrence.is_cancelled); +} + /** * Get the current joinable occurrence or next upcoming occurrence for a meeting * @param meeting The meeting object with occurrences @@ -116,8 +125,15 @@ export function getCurrentOrNextOccurrence(meeting: Meeting): MeetingOccurrence const now = new Date(); const earlyJoinMinutes = meeting.early_join_time_minutes || 10; + // Filter out cancelled occurrences + const activeOccurrences = getActiveOccurrences(meeting.occurrences); + + if (activeOccurrences.length === 0) { + return null; + } + // Find the first occurrence that is currently joinable (within the join window) - const joinableOccurrence = meeting.occurrences.find((occurrence) => { + const joinableOccurrence = activeOccurrences.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 @@ -130,7 +146,7 @@ export function getCurrentOrNextOccurrence(meeting: Meeting): MeetingOccurrence } // If no joinable occurrence, find the next future occurrence - const futureOccurrences = meeting.occurrences + const futureOccurrences = activeOccurrences .filter((occurrence) => new Date(occurrence.start_time) > now) .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());