|
- {{ 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
|