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());