diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html index b2e320fb..846311f2 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html @@ -237,15 +237,19 @@

Resources

@for (attachment of attachments(); track attachment.uid; let i = $index) { - - - - + + @if (attachment.type === 'link') { + + } @else { + + } + + {{ attachment.name }} } diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts index f7bd37f5..df0047c1 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts @@ -18,16 +18,7 @@ import { MeetingRegistrantsComponent } from '@components/meeting-registrants/mee import { MeetingRsvpDetailsComponent } from '@components/meeting-rsvp-details/meeting-rsvp-details.component'; import { RsvpButtonGroupComponent } from '@components/rsvp-button-group/rsvp-button-group.component'; import { environment } from '@environments/environment'; -import { - canJoinMeeting, - extractUrlsWithDomains, - getCurrentOrNextOccurrence, - Meeting, - MeetingAttachment, - MeetingOccurrence, - Project, - User, -} from '@lfx-one/shared'; +import { canJoinMeeting, getCurrentOrNextOccurrence, Meeting, MeetingAttachment, MeetingOccurrence, Project, User } from '@lfx-one/shared'; import { FileTypeIconPipe } from '@pipes/file-type-icon.pipe'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; import { MeetingService } from '@services/meeting.service'; @@ -78,7 +69,6 @@ export class MeetingJoinComponent { public meeting: Signal; public currentOccurrence: Signal; public meetingTypeBadge: Signal<{ badgeClass: string; icon?: string; text: string } | null>; - public importantLinks: Signal<{ url: string; domain: string }[]>; public returnTo: Signal; public password: WritableSignal = signal(null); public canJoinMeeting: Signal; @@ -103,7 +93,6 @@ export class MeetingJoinComponent { 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.fetchedJoinUrl = this.initializeFetchedJoinUrl(); @@ -268,20 +257,6 @@ export class MeetingJoinComponent { }); } - private initializeImportantLinks(): Signal<{ url: string; domain: string }[]> { - return computed(() => { - const meeting = this.meeting(); - const currentOccurrence = this.currentOccurrence(); - - // Use current occurrence description if available, otherwise fallback to meeting description - const description = currentOccurrence?.description || meeting?.description; - if (!description) { - return []; - } - return extractUrlsWithDomains(description); - }); - } - private initializeReturnTo(): Signal { return computed(() => { return `${environment.urls.home}/meetings/${this.meeting().uid}?password=${this.password()}`; diff --git a/apps/lfx-one/src/app/modules/project/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.html b/apps/lfx-one/src/app/modules/project/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.html index 3b691ed3..a7d462da 100644 --- a/apps/lfx-one/src/app/modules/project/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.html +++ b/apps/lfx-one/src/app/modules/project/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.html @@ -7,7 +7,7 @@ + [pTooltip]="upcomingMeeting()!.title || 'Meeting'"> {{ upcomingMeeting()!.title || 'Meeting' }}
diff --git a/apps/lfx-one/src/app/modules/project/dashboard/project-dashboard/project.component.html b/apps/lfx-one/src/app/modules/project/dashboard/project-dashboard/project.component.html index 54e1ec8a..2a6cd00c 100644 --- a/apps/lfx-one/src/app/modules/project/dashboard/project-dashboard/project.component.html +++ b/apps/lfx-one/src/app/modules/project/dashboard/project-dashboard/project.component.html @@ -183,7 +183,7 @@

Upcoming Meetings

- {{ row.title }} + {{ row.title }}
@@ -242,7 +242,7 @@

Recently Updated Committees

- {{ row.title }} + {{ row.title }}
diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html index 5d84010b..c937644b 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html @@ -55,8 +55,11 @@

+ (deleteAttachment)="deleteAttachment($event)" + (undoDeleteAttachment)="undoDeleteAttachment($event)" + (deleteLinkAttachment)="deleteLinkAttachment($event)"> @@ -114,8 +117,10 @@

Resources & Summary

[existingAttachments]="attachments()" [isEditMode]="isEditMode()" [deletingAttachmentId]="deletingAttachmentId()" + [pendingAttachmentDeletions]="pendingAttachmentDeletions()" (goToStep)="goToStep($event)" - (deleteAttachment)="deleteAttachment($event)"> + (deleteAttachment)="deleteAttachment($event)" + (undoDeleteAttachment)="undoDeleteAttachment($event)">
diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts index fef640a6..92630927 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts @@ -23,6 +23,7 @@ import { MeetingVisibility } from '@lfx-one/shared/enums'; import { BatchRegistrantOperationResponse, CreateMeetingRequest, + ImportantLinkFormValue, Meeting, MeetingAttachment, MeetingRegistrant, @@ -78,7 +79,6 @@ export class MeetingManageComponent { private readonly meetingService = inject(MeetingService); private readonly projectService = inject(ProjectService); private readonly messageService = inject(MessageService); - private readonly confirmationService = inject(ConfirmationService); private readonly destroyRef = inject(DestroyRef); // Mode and state signals @@ -103,6 +103,7 @@ export class MeetingManageComponent { public form = signal(this.createMeetingFormGroup()); public submitting = signal(false); public deletingAttachmentId = signal(null); + public pendingAttachmentDeletions = signal([]); // Registrant updates refresh public registrantUpdatesRefresh$ = new BehaviorSubject(undefined); @@ -220,13 +221,17 @@ export class MeetingManageComponent { } public deleteAttachment(attachmentId: string): void { - const meetingId = this.meetingId(); - if (!meetingId) return; + this.pendingAttachmentDeletions.update((current) => [...current, attachmentId]); + } - const attachment = this.attachments().find((att: MeetingAttachment) => att.uid === attachmentId); - const fileName = attachment?.name || 'this attachment'; + public undoDeleteAttachment(attachmentId: string): void { + this.pendingAttachmentDeletions.update((current) => current.filter((id) => id !== attachmentId)); + } - this.showDeleteAttachmentConfirmation(meetingId, attachmentId, fileName); + public deleteLinkAttachment(attachmentId: string): void { + // When a link with an existing attachment uid is removed from the form, + // add it to pending deletions so it gets deleted on save + this.pendingAttachmentDeletions.update((current) => [...current, attachmentId]); } public onManageRegistrants(): void { @@ -352,20 +357,43 @@ export class MeetingManageComponent { return; } - // If we have pending attachments, save them to the database - if (this.pendingAttachments.length > 0) { - this.savePendingAttachments(meeting.uid) - .pipe(take(1)) + const hasPendingDeletions = this.pendingAttachmentDeletions().length > 0; + const hasPendingUploads = this.pendingAttachments.length > 0; + const importantLinksArray = this.form().get('important_links') as FormArray; + const hasPendingLinks = importantLinksArray.length > 0; + + // If we have pending deletions, uploads, or links, process them + if (hasPendingDeletions || hasPendingUploads || hasPendingLinks) { + // Process deletions, then uploads, then links + this.deletePendingAttachments(meeting.uid) + .pipe( + switchMap((deletionResult) => + this.savePendingAttachments(meeting.uid).pipe( + switchMap((uploadResult) => + this.saveLinkAttachments(meeting.uid).pipe( + switchMap((linkResult) => + of({ + deletions: deletionResult, + uploads: uploadResult, + links: linkResult, + }) + ) + ) + ) + ) + ), + take(1) + ) .subscribe({ next: (result) => { - // Process attachments after meeting save - this.handleAttachmentResults(result, project); + // Process attachment operations after meeting save + this.handleAttachmentOperationsResults(result, project); }, error: (attachmentError: any) => { - console.error('Error saving attachments:', attachmentError); + console.error('Error processing attachments:', attachmentError); const warningMessage = this.isEditMode() - ? 'Meeting updated but attachments failed to save. You can add them later.' - : 'Meeting created but attachments failed to save. You can add them later.'; + ? 'Meeting updated but some attachment operations failed. You can manage them later.' + : 'Meeting created but some attachment operations failed. You can manage them later.'; this.messageService.add({ severity: 'warn', summary: this.isEditMode() ? 'Meeting Updated' : 'Meeting Created', @@ -394,36 +422,72 @@ export class MeetingManageComponent { this.submitting.set(false); } - private handleAttachmentResults(result: { successes: MeetingAttachment[]; failures: { fileName: string; error: any }[] }, project: any): void { - const { successes, failures } = result; + private handleAttachmentOperationsResults( + result: { + deletions: { successes: number; failures: string[] }; + uploads: { successes: MeetingAttachment[]; failures: { fileName: string; error: any }[] }; + links: { successes: MeetingAttachment[]; failures: { linkName: string; error: any }[] }; + }, + project: any + ): void { + const totalDeleteSuccesses = result.deletions.successes; + const totalDeleteFailures = result.deletions.failures.length; + const totalUploadSuccesses = result.uploads.successes.length; + const totalUploadFailures = result.uploads.failures.length; + const totalLinkSuccesses = result.links.successes.length; + const totalLinkFailures = result.links.failures.length; + + const totalOperations = totalDeleteSuccesses + totalDeleteFailures + totalUploadSuccesses + totalUploadFailures + totalLinkSuccesses + totalLinkFailures; + + if (totalDeleteFailures === 0 && totalUploadFailures === 0 && totalLinkFailures === 0 && totalOperations > 0) { + // All operations successful + const parts = []; + if (totalDeleteSuccesses > 0) parts.push(`${totalDeleteSuccesses} attachment(s) deleted`); + if (totalUploadSuccesses > 0) parts.push(`${totalUploadSuccesses} file(s) uploaded`); + if (totalLinkSuccesses > 0) parts.push(`${totalLinkSuccesses} link(s) added`); - if (failures.length === 0) { - // All attachments saved successfully const successMessage = this.isEditMode() - ? `Meeting updated successfully with ${successes.length} new attachment(s)` - : `Meeting created successfully with ${successes.length} attachment(s)`; + ? `Meeting updated successfully${parts.length > 0 ? ': ' + parts.join(', ') : ''}` + : `Meeting created successfully${parts.length > 0 ? ' with ' + parts.join(' and ') : ''}`; this.messageService.add({ severity: 'success', summary: 'Success', detail: successMessage, }); - } else if (successes.length > 0) { + } else if (totalOperations > 0 && (totalDeleteSuccesses > 0 || totalUploadSuccesses > 0 || totalLinkSuccesses > 0)) { // Partial success + const successParts = []; + if (totalDeleteSuccesses > 0) successParts.push(`${totalDeleteSuccesses} deleted`); + if (totalUploadSuccesses > 0) successParts.push(`${totalUploadSuccesses} files uploaded`); + if (totalLinkSuccesses > 0) successParts.push(`${totalLinkSuccesses} links added`); + + const failureParts = []; + if (totalDeleteFailures > 0) failureParts.push(`${totalDeleteFailures} failed to delete`); + if (totalUploadFailures > 0) failureParts.push(`${totalUploadFailures} files failed to upload`); + if (totalLinkFailures > 0) failureParts.push(`${totalLinkFailures} links failed to add`); + const partialMessage = this.isEditMode() - ? `Meeting updated with ${successes.length} attachments. ${failures.length} failed to save.` - : `Meeting created with ${successes.length} attachments. ${failures.length} failed to save.`; + ? `Meeting updated: ${successParts.join(', ')}. ${failureParts.join(', ')}.` + : `Meeting created: ${successParts.join(', ')}. ${failureParts.join(', ')}.`; this.messageService.add({ severity: 'warn', summary: this.isEditMode() ? 'Meeting Updated' : 'Meeting Created', detail: partialMessage, }); + } else if (totalOperations === 0) { + // No attachment operations + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Meeting ${this.isEditMode() ? 'updated' : 'created'} successfully`, + }); } else { // All failed const errorMessage = this.isEditMode() - ? 'Meeting updated but all attachments failed to save. You can add them later.' - : 'Meeting created but all attachments failed to save. You can add them later.'; + ? 'Meeting updated but attachment operations failed. You can manage them later.' + : 'Meeting created but attachment operations failed. You can manage them later.'; this.messageService.add({ severity: 'warn', @@ -433,55 +497,24 @@ export class MeetingManageComponent { } // Log individual failures for debugging - failures.forEach((failure) => { - console.error(`Failed to save attachment ${failure.fileName}:`, failure.error); + result.uploads.failures.forEach((failure) => { + console.error(`Failed to upload attachment ${failure.fileName}:`, failure.error); + }); + result.links.failures.forEach((failure) => { + console.error(`Failed to add link ${failure.linkName}:`, failure.error); + }); + result.deletions.failures.forEach((attachmentId) => { + console.error(`Failed to delete attachment ${attachmentId}`); }); - // Refresh attachments list if we're in edit mode - if (this.isEditMode()) { - this.attachmentsRefresh$.next(); + // Clear pending deletions when operations complete without failures + if (totalDeleteFailures === 0 && this.pendingAttachmentDeletions().length > 0) { + this.pendingAttachmentDeletions.set([]); } this.router.navigate(['/project', project.slug, 'meetings']); } - private showDeleteAttachmentConfirmation(meetingId: string, attachmentId: string, fileName: string): void { - this.confirmationService.confirm({ - message: `Are you sure you want to delete "${fileName}"? This action cannot be undone.`, - header: 'Delete Attachment', - icon: 'fa-light fa-exclamation-triangle', - acceptIcon: 'fa-light fa-trash', - rejectIcon: 'fa-light fa-times', - acceptLabel: 'Delete', - rejectLabel: 'Cancel', - acceptButtonStyleClass: 'p-button-danger p-button-text', - rejectButtonStyleClass: 'p-button-text', - accept: () => { - this.deletingAttachmentId.set(attachmentId); - this.meetingService.deleteAttachment(meetingId, attachmentId).subscribe({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Attachment deleted successfully', - }); - this.attachmentsRefresh$.next(); - this.deletingAttachmentId.set(null); - }, - error: (error) => { - console.error('Error deleting attachment:', error); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to delete attachment. Please try again.', - }); - this.deletingAttachmentId.set(null); - }, - }); - }, - }); - } - private initializeMeeting() { return toSignal( this.route.paramMap.pipe( @@ -602,10 +635,41 @@ export class MeetingManageComponent { }); } + // Populate important_links FormArray with existing link-type attachments + this.populateExistingLinks(); + // Update the form validator to use edit mode validator with original start time this.updateFormValidator(); } + private populateExistingLinks(): void { + const attachments = this.attachments(); + const linkAttachments = attachments.filter((att: MeetingAttachment) => att.type === 'link'); + + if (linkAttachments.length === 0) { + return; + } + + const importantLinksArray = this.form().get('important_links') as FormArray; + + // Clear existing form array + while (importantLinksArray.length > 0) { + importantLinksArray.removeAt(0); + } + + // Add existing link attachments to the form array + linkAttachments.forEach((linkAttachment: MeetingAttachment) => { + const linkFormGroup = new FormGroup({ + id: new FormControl(crypto.randomUUID()), + title: new FormControl(linkAttachment.name), + url: new FormControl(linkAttachment.link || ''), + uid: new FormControl(linkAttachment.uid), // Track the attachment UID + }); + + importantLinksArray.push(linkFormGroup); + }); + } + private canNavigateToStep(step: number): boolean { // Allow navigation to previous steps or current step if (step <= this.currentStep()) { @@ -751,6 +815,30 @@ export class MeetingManageComponent { } } + private deletePendingAttachments(meetingId: string): Observable<{ successes: number; failures: string[] }> { + const attachmentIdsToDelete = this.pendingAttachmentDeletions(); + + if (attachmentIdsToDelete.length === 0) { + return of({ successes: 0, failures: [] }); + } + + return from(attachmentIdsToDelete).pipe( + mergeMap((attachmentId) => + this.meetingService.deleteAttachment(meetingId, attachmentId).pipe( + switchMap(() => of({ success: attachmentId, failure: null })), + catchError(() => of({ success: null, failure: attachmentId })) + ) + ), + toArray(), + switchMap((results) => { + const successes = results.filter((r) => r.success).length; + const failures = results.filter((r) => r.failure).map((r) => r.failure!); + return of({ successes, failures }); + }), + take(1) + ); + } + private savePendingAttachments(meetingId: string): Observable<{ successes: MeetingAttachment[]; failures: { fileName: string; error: any }[] }> { const attachmentsToSave = this.pendingAttachments.filter( (attachment) => !attachment.uploading && !attachment.uploadError && !attachment.uploaded && attachment.file @@ -777,6 +865,33 @@ export class MeetingManageComponent { ); } + private saveLinkAttachments(meetingId: string): Observable<{ successes: MeetingAttachment[]; failures: { linkName: string; error: any }[] }> { + const importantLinksArray = this.form().get('important_links') as FormArray; + // Only save links that don't have a uid (new links) + // Links with uid already exist as attachments and don't need to be recreated + const linksToSave = (importantLinksArray.value as ImportantLinkFormValue[]).filter((link) => link.title && link.url && !link.uid); + + if (linksToSave.length === 0) { + return of({ successes: [], failures: [] }); + } + + return from(linksToSave).pipe( + mergeMap((link: ImportantLinkFormValue) => + this.meetingService.createAttachmentFromUrl(meetingId, link.title, link.url).pipe( + switchMap((result) => of({ success: result, failure: null })), + catchError((error) => of({ success: null, failure: { linkName: link.title, error } })) + ) + ), + toArray(), + switchMap((results) => { + const successes = results.filter((r) => r.success).map((r) => r.success!); + const failures = results.filter((r) => r.failure).map((r) => r.failure!); + return of({ successes, failures }); + }), + take(1) + ); + } + private initializeAttachments() { return toSignal( this.attachmentsRefresh$.pipe( diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html index 874e1eb1..d8e88462 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html @@ -67,47 +67,88 @@

Resources & Links

@for (attachment of existingAttachments(); track attachment.uid; let i = $index) {
-
- +
+
-

{{ attachment.name }}

+

+ {{ attachment.name }} +

{{ getFileType(attachment.name) }} @if (attachment.file_size) { • {{ attachment.file_size | fileSize }} } + @if (isPendingDeletion(attachment.uid)) { + • Marked for deletion + }

- @if (attachment.link) { + @if (attachment.type === 'file' && !isPendingDeletion(attachment.uid)) { + + + + data-testid="download-attachment-btn" + title="Download"> } - + @if (isPendingDeletion(attachment.uid)) { + + } @else { + + }
} diff --git a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts index f87f20ed..94666f37 100644 --- a/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts +++ b/apps/lfx-one/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts @@ -11,7 +11,6 @@ import { RecurrenceType } from '@lfx-one/shared/enums'; import { CustomRecurrencePattern, MeetingAttachment, PendingAttachment } from '@lfx-one/shared/interfaces'; import { buildRecurrenceSummary, generateAcceptString } from '@lfx-one/shared/utils'; import { FileSizePipe } from '@pipes/file-size.pipe'; -import { MeetingService } from '@services/meeting.service'; import { MessageService } from 'primeng/api'; @Component({ @@ -27,6 +26,7 @@ export class MeetingResourcesSummaryComponent implements OnInit { public readonly isEditMode = input(false); public readonly deletingAttachmentId = input(null); public readonly meetingId = input(null); + public readonly pendingAttachmentDeletions = input([]); // File management public pendingAttachments = signal([]); @@ -46,12 +46,13 @@ export class MeetingResourcesSummaryComponent implements OnInit { public recurrenceLabel = computed(() => this.getRecurrenceLabel()); // Inject services - private readonly meetingService = inject(MeetingService); private readonly messageService = inject(MessageService); // Navigation public readonly goToStep = output(); public readonly deleteAttachment = output(); + public readonly undoDeleteAttachment = output(); + public readonly deleteLinkAttachment = output(); // Output when a link with uid is removed // File upload configuration public readonly acceptString = generateAcceptString(); @@ -70,6 +71,10 @@ export class MeetingResourcesSummaryComponent implements OnInit { return extension || 'FILE'; } + public isPendingDeletion(attachmentId: string): boolean { + return this.pendingAttachmentDeletions().includes(attachmentId); + } + // File handling methods public onFileSelect(event: any): void { // Handle PrimeNG FileUpload event structure @@ -104,30 +109,16 @@ export class MeetingResourcesSummaryComponent implements OnInit { file: file, fileSize: file.size, mimeType: file.type, - uploading: true, + uploading: false, + uploaded: false, }; - // Start the upload - this.meetingService.createFileAttachment(this.meetingId()!, file).subscribe({ - next: (result) => { - this.pendingAttachments.update((current) => - current.map((pa) => (pa.id === pendingAttachment.id ? { ...pa, link: result.link, uploading: false } : pa)) - ); - this.form().get('attachments')?.setValue(this.pendingAttachments()); - }, - error: (error) => { - this.pendingAttachments.update((current) => - current.map((pa) => (pa.id === pendingAttachment.id ? { ...pa, uploading: false, uploadError: error.message || 'Upload failed' } : pa)) - ); - console.error(`Failed to upload ${file.name}:`, error); - }, - }); - return pendingAttachment; }) .filter(Boolean) as PendingAttachment[]; this.pendingAttachments.update((current) => [...current, ...newAttachments]); + this.form().get('attachments')?.setValue(this.pendingAttachments()); } public removeAttachment(id: string): void { @@ -142,6 +133,7 @@ export class MeetingResourcesSummaryComponent implements OnInit { id: new FormControl(crypto.randomUUID()), title: new FormControl(this.newLink.title), url: new FormControl(this.newLink.url), + uid: new FormControl(null), // New links don't have a uid yet }); this.importantLinksFormArray.push(linkFormGroup); @@ -150,6 +142,14 @@ export class MeetingResourcesSummaryComponent implements OnInit { } public removeLink(index: number): void { + const linkControl = this.importantLinksFormArray.at(index); + const uid = linkControl?.get('uid')?.value; + + // If this link has a uid (exists as an attachment), emit for deletion tracking + if (uid) { + this.deleteLinkAttachment.emit(uid); + } + this.importantLinksFormArray.removeAt(index); } diff --git a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html index 0c415902..e600dad6 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html +++ b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.html @@ -188,24 +188,16 @@

- + [pTooltip]="attachment.name + ' (' + (attachment.file_size || 0 | fileSize) + ')'"> + @if (attachment.type === 'link') { + + } @else { + + } {{ attachment.name }} } - @for (link of importantLinks(); track link.url) { - - - {{ link.domain }} - - }

}
diff --git a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts index cef3a40f..ba8a3061 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts +++ b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts @@ -26,7 +26,6 @@ import { buildJoinUrlWithParams, canJoinMeeting, DEFAULT_MEETING_TYPE_CONFIG, - extractUrlsWithDomains, getCurrentOrNextOccurrence, Meeting, MEETING_TYPE_CONFIGS, @@ -127,11 +126,6 @@ export class MeetingCardComponent implements OnInit { public readonly meetingDeleted = output(); public readonly project = this.projectService.project; - // Extract important links from description - public readonly importantLinks = this.initImportantLinks(); - - // Meeting attachments - public constructor() { effect(() => { this.meeting.set(this.meetingInput()); @@ -482,18 +476,6 @@ export class MeetingCardComponent implements OnInit { .subscribe(); } - private initImportantLinks(): Signal<{ url: string; domain: string }[]> { - return computed(() => { - const description = this.meeting().description; - if (!description) { - return []; - } - - // Use shared utility to extract URLs with domains - return extractUrlsWithDomains(description); - }); - } - private initAttachments(): Signal { return runInInjectionContext(this.injector, () => { return toSignal(this.meetingService.getMeetingAttachments(this.meetingInput().uid).pipe(catchError(() => of([]))), { initialValue: [] }); @@ -555,7 +537,7 @@ export class MeetingCardComponent implements OnInit { private initTotalResourcesCount(): Signal { return computed(() => { - return this.attachments().length + this.importantLinks().length; + return this.attachments().length; }); } diff --git a/apps/lfx-one/src/server/controllers/meeting.controller.ts b/apps/lfx-one/src/server/controllers/meeting.controller.ts index c4a3f6d1..793ce3a2 100644 --- a/apps/lfx-one/src/server/controllers/meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/meeting.controller.ts @@ -1007,13 +1007,17 @@ export class MeetingController { /** * GET /meetings/:uid/attachments/:attachmentId + * Query params: + * - download: 'true' to force download (attachment), omit or 'false' to view inline */ public async getMeetingAttachment(req: Request, res: Response, next: NextFunction): Promise { const { uid, attachmentId } = req.params; + const { download } = req.query; const startTime = Logger.start(req, 'get_meeting_attachment', { meeting_uid: uid, attachment_id: attachmentId, + download_mode: download === 'true' ? 'download' : 'inline', }); try { @@ -1066,13 +1070,14 @@ export class MeetingController { status_code: 200, }); - // Set proper headers for file download + // Set proper headers for file delivery res.setHeader('Content-Type', contentType); // Use RFC 5987 encoding for Content-Disposition filename // This properly handles spaces, special characters, and Unicode const encodedFilename = encodeURIComponent(filename); - res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`); + const disposition = download === 'true' ? 'attachment' : 'inline'; + res.setHeader('Content-Disposition', `${disposition}; filename*=UTF-8''${encodedFilename}`); res.setHeader('Content-Length', attachmentData.length.toString()); diff --git a/apps/lfx-one/src/server/services/api-client.service.ts b/apps/lfx-one/src/server/services/api-client.service.ts index 4f163693..9147a0a1 100644 --- a/apps/lfx-one/src/server/services/api-client.service.ts +++ b/apps/lfx-one/src/server/services/api-client.service.ts @@ -51,7 +51,28 @@ export class ApiClientService { customHeaders?: Record ): Promise> { const fullUrl = this.getFullUrl(url, query); - return this.makeBinaryRequest(type, fullUrl, bearerToken, customHeaders); + + const headers: Record = { + ['User-Agent']: 'LFX-PCC-Server/1.0', + }; + + // Only add Authorization header if bearerToken is provided + if (bearerToken) { + headers['Authorization'] = `Bearer ${bearerToken}`; + } + + // Add custom headers + if (customHeaders) { + Object.assign(headers, customHeaders); + } + + const requestInit: RequestInit = { + method: type, + headers, + signal: AbortSignal.timeout(this.config.timeout), + }; + + return this.executeRequest(fullUrl, requestInit, { binary: true }); } private async makeRequest(method: string, url: string, bearerToken?: string, data?: any, customHeaders?: Record): Promise> { @@ -118,31 +139,7 @@ export class ApiClientService { return this.executeRequest(url, requestInit); } - private async makeBinaryRequest(method: string, url: string, bearerToken?: string, customHeaders?: Record): Promise> { - const headers: Record = { - ['User-Agent']: 'LFX-PCC-Server/1.0', - }; - - // Add custom headers - if (customHeaders) { - Object.assign(headers, customHeaders); - } - - // Only add Authorization header if bearerToken is provided - if (bearerToken) { - headers['Authorization'] = `Bearer ${bearerToken}`; - } - - const requestInit: RequestInit = { - method, - headers, - signal: AbortSignal.timeout(this.config.timeout), - }; - - return this.executeBinaryRequest(url, requestInit); - } - - private async executeRequest(url: string, requestInit: RequestInit): Promise> { + private async executeRequest(url: string, requestInit: RequestInit, options: { binary: boolean } = { binary: false }): Promise> { try { const response = await fetch(url, requestInit); @@ -161,81 +158,27 @@ export class ApiClientService { const errorMessage = errorBody?.message || errorBody?.error || response.statusText; throw new MicroserviceError(errorMessage, response.status, getHttpErrorCode(response.status), { - operation: 'api_client_request', + operation: options.binary ? 'api_client_binary_request' : 'api_client_request', service: 'api_client_service', path: url, errorBody: errorBody, }); } - // If the response is text, parse it as JSON - const data = await response.text(); - - const apiResponse: ApiResponse = { - data: data ? JSON.parse(data) : null, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - }; - - return apiResponse; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.name === 'AbortError') { - throw new MicroserviceError(`Request timeout after ${this.config.timeout}ms`, 408, 'TIMEOUT', { - operation: 'api_client_timeout', - service: 'api_client_service', - path: url, - }); - } - - const errorWithCause = error as Error & { cause?: { code?: string } }; - if (errorWithCause.cause?.code) { - throw new MicroserviceError(`Request failed: ${error.message}`, 500, errorWithCause.cause.code || 'NETWORK_ERROR', { - operation: 'api_client_network_error', - service: 'api_client_service', - path: url, - originalError: error, - }); - } - } - - throw error; - } - } - - private async executeBinaryRequest(url: string, requestInit: RequestInit): Promise> { - try { - const response = await fetch(url, requestInit); - - if (!response.ok) { - // Try to parse error response body for additional details - let errorBody: any = null; - try { - const errorText = await response.text(); - if (errorText) { - errorBody = JSON.parse(errorText); - } - } catch { - // If we can't parse the error body, we'll use the basic HTTP error - } - - const errorMessage = errorBody?.message || errorBody?.error || response.statusText; - - throw new MicroserviceError(errorMessage, response.status, getHttpErrorCode(response.status), { - operation: 'api_client_binary_request', - service: 'api_client_service', - path: url, - errorBody: errorBody, - }); + // Process response based on binary flag + let data: T; + if (options.binary) { + // Get the response as an ArrayBuffer and convert to Buffer + const arrayBuffer = await response.arrayBuffer(); + data = Buffer.from(arrayBuffer) as T; + } else { + // Parse as JSON + const text = await response.text(); + data = text ? JSON.parse(text) : null; } - // Get the response as an ArrayBuffer and convert to Buffer - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const apiResponse: ApiResponse = { - data: buffer, + const apiResponse: ApiResponse = { + data, status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), @@ -246,7 +189,7 @@ export class ApiClientService { if (error instanceof Error) { if (error.name === 'AbortError') { throw new MicroserviceError(`Request timeout after ${this.config.timeout}ms`, 408, 'TIMEOUT', { - operation: 'api_client_binary_timeout', + operation: options.binary ? 'api_client_binary_timeout' : 'api_client_timeout', service: 'api_client_service', path: url, }); @@ -255,7 +198,7 @@ export class ApiClientService { const errorWithCause = error as Error & { cause?: { code?: string } }; if (errorWithCause.cause?.code) { throw new MicroserviceError(`Request failed: ${error.message}`, 500, errorWithCause.cause.code || 'NETWORK_ERROR', { - operation: 'api_client_binary_network_error', + operation: options.binary ? 'api_client_binary_network_error' : 'api_client_network_error', service: 'api_client_service', path: url, originalError: error, diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index 51315d24..ec53319f 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -54,6 +54,21 @@ export interface ImportantLink { url: string; } +/** + * Important link form value with attachment tracking + * @description Form-specific interface for managing important links with attachment UIDs + */ +export interface ImportantLinkFormValue { + /** Unique identifier for the form control */ + id: string; + /** Display title for the link */ + title: string; + /** URL of the external resource */ + url: string; + /** Attachment UID (null for new links, present for existing link attachments) */ + uid: string | null; +} + /** * Committee associated with a meeting * @description Basic committee information for meeting association