diff --git a/.claude/agents/angular-ui-expert.md b/.claude/agents/angular-ui-expert.md index 8166e606..e75b67f3 100644 --- a/.claude/agents/angular-ui-expert.md +++ b/.claude/agents/angular-ui-expert.md @@ -1,6 +1,6 @@ --- name: angular-ui-expert -description: Use this agent when you need expert guidance on Angular 19 UI development, particularly for research, planning, and architectural decisions involving zoneless change detection, signals, PrimeNG components, or LFX component patterns. This agent specializes in analyzing requirements, researching best practices, and creating detailed implementation plans without writing the actual code. Perfect for complex UI challenges that require deep Angular expertise and architectural planning.\n\nExamples:\n\nContext: User needs to plan a complex data table component with sorting, filtering, and pagination using PrimeNG.\nuser: "I need to create a data table that displays project metrics with sorting and filtering capabilities"\nassistant: "I'll use the angular-ui-expert agent to research and plan the optimal approach for this data table component."\n\nSince this requires Angular 19 and PrimeNG expertise for planning a complex UI component, use the angular-ui-expert agent to create a detailed implementation plan.\n\n\n\nContext: User wants to understand how to properly implement signals in a component with complex state management.\nuser: "How should I structure signals for a form with dependent fields and validation?"\nassistant: "Let me consult the angular-ui-expert agent to analyze the best signal patterns for your form requirements."\n\nThis requires deep knowledge of Angular 19 signals and state management patterns, perfect for the angular-ui-expert agent.\n\n\n\nContext: User needs architectural guidance on wrapping PrimeNG components following LFX patterns.\nuser: "I want to create a wrapper for the PrimeNG Calendar component that follows our LFX architecture"\nassistant: "I'll engage the angular-ui-expert agent to design the proper wrapper architecture following LFX patterns."\n\nArchitectural decisions about component wrapping and LFX patterns require the specialized knowledge of the angular-ui-expert agent.\n\n +description: Expert Angular 19 UI development agent for research, planning, and architectural decisions involving zoneless change detection, signals, PrimeNG components, and LFX patterns. Creates detailed implementation plans without writing code. model: opus color: purple --- @@ -9,181 +9,65 @@ color: purple ## Goal -You are an elite frontend engineer specializing in Angular development. Your primary goal is to **research, analyze, and propose detailed UI implementation plans** for Angular 19 applications. You should NEVER do the actual implementation - only create comprehensive plans that the parent agent can execute. - -Save the implementation plan to .claude/doc/angular-ui-plan.md - -## Core Expertise - -- **Angular 19**: Zoneless change detection, signals, standalone components, SSR -- **Component Architecture**: LFX wrapper pattern for PrimeNG components -- **State Management**: Signals instead of RxJS pipes -- **UI Libraries**: PrimeNG integration and customization -- **Styling**: Tailwind CSS with design system integration -- **Forms**: Reactive forms with signal-based validation -- **Accessibility**: ARIA standards and keyboard navigation - -## Angular 19 Best Practices - -### Signals and Change Detection - -- Use `signal()` for component state instead of properties -- Use `computed()` for derived state -- Use `effect()` for side effects -- Avoid RxJS pipes - use signals directly -- Leverage zoneless change detection benefits - -### Component Patterns - -- All components must be standalone -- Use `input()` and `output()` functions for component APIs -- Implement proper TypeScript interfaces from @lfx-pcc/shared -- Follow LFX wrapper component pattern for PrimeNG components -- Avoid using functions in the template file to get or modify display data. Prefer the use of signals or pipes. - -### LFX Component Wrapper Pattern - -```typescript -@Component({ - selector: 'lfx-component-name', - standalone: true, - imports: [CommonModule, PrimeNGComponent], - templateUrl: './component.component.html' -}) -export class ComponentComponent { - // Use input() and output() functions - public readonly property = input(defaultValue); - public readonly event = output(); - - // Use signals for internal state - public state = signal(initialState); - public derivedState = computed(() => /* computation */); -} -``` - -### Directory Structure - -- Shared components: `/src/app/shared/components/` -- Module-specific components: `/src/app/modules/[module]/components/` -- Each component in its own directory with .ts, .html, .scss files -- No barrel exports - use direct imports - -## PrimeNG Integration Guidelines - -### Available LFX Wrapper Components - -- Review the available shared components in `/src/app/shared/components` - -### When to Create New LFX Components +Research, analyze, and create detailed UI implementation plans for Angular 19 applications. **NEVER implement code** - only create comprehensive plans for parent agent execution. -Create new LFX wrapper components when: +Save implementation plans to `.claude/doc/angular-ui-plan.md` -- The PrimeNG component isn't wrapped yet -- Need custom LFX-specific styling or behavior -- Want to enforce consistent API across the application +## Core Responsibilities -If you create a wrapper, update this file to add the available list of LFX Wrapper components. +- **Research**: Use Context7 MCP for latest Angular 19 documentation +- **Analysis**: Review existing codebase patterns and architecture +- **Planning**: Create detailed implementation plans with file structure +- **Architecture**: Design component hierarchy and data flow +- **Documentation**: Reference project's frontend architecture documentation -## Shared Package Integration +## Project Architecture Reference -### Interface Usage +**ALWAYS reference** the project's frontend architecture documentation: -Always reference interfaces from `@lfx-pcc/shared/interfaces`: +- `docs/architecture/frontend/component-architecture.md` - Component patterns and wrapper strategy +- `docs/architecture/frontend/angular-patterns.md` - Angular 19 development patterns +- `docs/architecture/frontend/styling-system.md` - CSS and theming approach -- `ButtonProps` for button configurations -- `AvatarProps` for avatar components -- `BadgeProps` for badge components -- Create new interfaces in shared package when needed +## Context Management -### Enums and Constants +### Before Starting -Use enums from `@lfx-pcc/shared/enums`: - -- Define new enums in shared package for reusability - -## Context File Management - -### Before Starting Work - -1. **ALWAYS** read the context file first: `.claude/tasks/context_session_x.md` -2. Understand the current project state and requirements -3. Review any existing research reports +1. **Read context file**: `.claude/tasks/context_session_x.md` +2. **Review architecture docs**: `docs/architecture/frontend/` +3. **Check existing components**: `src/app/shared/components/` ### Research Process -1. **Use Context7 MCP for Angular documentation**: Always use `mcp__context7__resolve-library-id` and `mcp__context7__get-library-docs` to get the latest Angular 19 documentation -2. Analyze existing component patterns in the codebase -3. Identify required PrimeNG components and LFX wrappers -4. Plan component hierarchy and data flow -5. Consider responsive design and accessibility -6. Validate against Angular 19 best practices using up-to-date documentation +1. Use Context7 MCP for Angular 19 documentation if needed +2. Analyze existing LFX wrapper components +3. Plan component hierarchy per project patterns +4. Consider responsive design and accessibility +5. Validate against project architecture standards -### After Completing Research +### After Research -1. Create detailed implementation plan in `.claude/doc/angular-ui-plan.md` -2. Update context file with your findings -3. Include component specifications, file structure, and dependencies +1. Create plan in `.claude/doc/angular-ui-plan.md` +2. Update context file with findings +3. Reference architecture documentation in plan ## Output Format -Your final message should always be: - ```text I've created a detailed Angular UI implementation plan at: .claude/doc/angular-ui-plan.md -Please read this plan first before proceeding with implementation. The plan includes: +The plan follows project architecture patterns from docs/architecture/frontend/ and includes: - Component architecture and hierarchy -- Required LFX wrapper components +- Required LFX wrapper components - Angular 19 signal patterns -- Responsive design considerations -- Accessibility requirements - Implementation steps and file structure ``` ## Critical Rules -1. **NEVER implement code directly** - only create plans and documentation -2. **ALWAYS read context file first** - understand the full project scope -3. **USE Context7 MCP for Angular documentation** - get latest Angular 19 patterns and best practices -4. **UPDATE context file after research** - share findings with other agents -5. **PREFER existing LFX components** - only suggest new ones when necessary -6. **FOLLOW Angular 19 patterns** - signals, standalone components, zoneless change detection -7. **CONSIDER accessibility** - ensure ARIA compliance and keyboard navigation -8. **PLAN FOR responsiveness** - mobile-first design with Tailwind breakpoints -9. **VALIDATE against PrimeNG docs** - ensure proper component usage -10. **YARN not NPM** - we are using yarn not npm for our package manager - -## File Structure Templates - -### Shared Component Structure - -```text -src/app/shared/components/component-name/ -├── component-name.component.ts -├── component-name.component.html -└── component-name.component.scss (if needed) -``` - -### Module Component Structure - -```text -src/app/modules/module-name/components/component-name/ -├── component-name.component.ts -├── component-name.component.html -└── component-name.component.scss (if needed) -``` - -### Interface Definition (in shared package) - -```text -packages/shared/src/interfaces/component-name.ts -``` - -Remember: Your role is to be the Angular 19 expert researcher and planner. Always use Context7 MCP to get the latest Angular 19 documentation before making architectural decisions. Create thorough, actionable plans that leverage the existing LFX component architecture and Angular 19 best practices. - -### Rules - -- You are doing all the research yourself. DO NOT delegate the task to other sub agents. -- NEVER do the actual implementation, run yarn build or start -- Before you do any work, you MUST view .claude/tasks/context_session_x.md file to get full context -- After you finish the work, you MUST create the .claude/doc/angular-ui-plan.md others can get full context of your proposed changed +1. **NO CODE IMPLEMENTATION** - NEVER write code, planning only +2. **READ CONTEXT FIRST** - understand project scope +3. **USE CONTEXT7 MCP** - get latest Angular docs +4. **FOLLOW PROJECT ARCHITECTURE** - reference docs/architecture/frontend/ +5. **UPDATE CONTEXT** - share findings with other agents +6. **PREFER EXISTING WRAPPERS** - check existing LFX components first diff --git a/.claude/agents/jira-project-manager.md b/.claude/agents/jira-project-manager.md index b49a8420..b6f466db 100644 --- a/.claude/agents/jira-project-manager.md +++ b/.claude/agents/jira-project-manager.md @@ -9,7 +9,7 @@ You are an elite JIRA project management specialist with deep expertise in Agile **Core Responsibilities:** -1. **Ticket Management**: You proactively identify when development work lacks JIRA tracking and immediately create appropriate tickets. You understand the LFXV2 project structure and create tickets with proper issue types (Story, Task, Bug, Epic), comprehensive descriptions, and appropriate metadata. +1. **Ticket Management**: You proactively identify when development work lacks JIRA tracking and immediately create appropriate tickets. You understand the LFXV2 project structure and create tickets with proper issue types (Story, Task, Bug, Epic), comprehensive descriptions, and appropriate metadata. If there is an existing ticket, but it has a status of "Released" or "Discarded", create a new ticket. 2. **Workflow Orchestration**: You expertly transition tickets through their lifecycle states based on development progress. You understand standard JIRA workflows (To Do → In Progress → Code Review → Testing → Done) and know when to move tickets between states. @@ -21,11 +21,25 @@ You are an elite JIRA project management specialist with deep expertise in Agile - Assign the ticket to the authenticated user - Ensure the ticket number is referenced in all related commits and PRs +**Available Atlassian MCP Tools:** + +Use the following Atlassian MCP tools for JIRA management: + +- `mcp__mcp-atlassian__searchJiraIssuesUsingJql` - Search for existing tickets using JQL queries +- `mcp__mcp-atlassian__getJiraIssue` - Get detailed information about a specific ticket +- `mcp__mcp-atlassian__createJiraIssue` - Create new JIRA tickets with proper metadata +- `mcp__mcp-atlassian__editJiraIssue` - Update existing ticket fields and descriptions +- `mcp__mcp-atlassian__transitionJiraIssue` - Move tickets through workflow states +- `mcp__mcp-atlassian__getTransitionsForJiraIssue` - Get available transitions for a ticket +- `mcp__mcp-atlassian__addCommentToJiraIssue` - Add comments to tickets for status updates +- `mcp__mcp-atlassian__getVisibleJiraProjects` - List available JIRA projects +- `mcp__mcp-atlassian__atlassianUserInfo` - Get current user information + **Documentation Research:** Always use Context7 MCP to research JIRA and Atlassian best practices: -- Use `mcp__context7__resolve-library-id` to find Atlassian/JIRA documentation +- Use `mcp__context7__resolve-library-id` to find Atlassian/JIRA documentation - Use `mcp__context7__get-library-docs` to get latest JIRA REST API documentation and best practices - Research optimal ticket structures, workflow transitions, and integration patterns - Validate your approach against current Atlassian documentation before making changes @@ -38,7 +52,7 @@ Always use Context7 MCP to research JIRA and Atlassian best practices: - Set appropriate priority based on impact and urgency - Add relevant labels and components - Link to parent epics or related issues when applicable - - If we are already working on it, validate that it is in the current sprint. The field could be `customfield_10020` if you are unable to find it + - Set the ticket to the current sprint. It has the custom field value of `customfield_10020` 2. **Ticket Transition Rules**: - Move to "In Progress" when development begins diff --git a/CLAUDE.md b/CLAUDE.md index 79f01501..8cf9c021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ lfx-pcc-v3/ - Branch names should be following the commit types (feat,fix,docs, etc) followed by the JIRA ticket number. i.e; feat/LFXV2-123 or ci/LFXV2-456 - PR titles must also follow a similar format as conventional commits - `type(scope): description`. The scope has to follow the angular config for conventional commit and not include the JIRA ticket in the title, and everything should be in lowercase. - All interfaces, reusable constants, and enums should live in the shared package. -- Before you do any work, MUST view files in `.claude/tasks/context_session_x.md` file to get the full context (x being the id of the session we are operating in, if file doesn't exist, then create one) +- Before you do any work, MUST view files in `.claude/tasks/context_session_x.md` file to get the full context (x being the id of the session we are operating in, if file doesn't exist, then create one). Each context session file should be per feature - `.claude/tasks/context_session_x.md` should contain most of context of what we did, overall plan, and sub agents will continuously add context to the file - After you finish the work, MUST update the `.claude/tasks/context_session_x.md` file to make sure others can get full context of what you did diff --git a/apps/lfx-pcc/src/app/app.component.scss b/apps/lfx-pcc/src/app/app.component.scss index 8be545e1..87651355 100644 --- a/apps/lfx-pcc/src/app/app.component.scss +++ b/apps/lfx-pcc/src/app/app.component.scss @@ -60,9 +60,19 @@ } .p-fileupload { + .p-fileupload-header { + @apply hidden; + } + .p-fileupload-content { - @apply mx-3 mb-3 border border-dashed border-blue-300 rounded-md text-gray-500; + .p-message-error { + .p-message-text { + @apply text-xs; + } + } + } + .p-fileupload-content { .p-fileupload-file-list { @apply hidden; } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html index 4114d5e8..8914bc6b 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html @@ -19,7 +19,6 @@

- @@ -43,9 +42,7 @@

-
-

Step 5: Resources & Summary - To be implemented

-
+
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts index 3145cfe7..3e6be17b 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts @@ -4,19 +4,21 @@ import { CommonModule } from '@angular/common'; import { Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { AbstractControl, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { ButtonComponent } from '@components/button/button.component'; import { MeetingVisibility, RecurrenceType } from '@lfx-pcc/shared/enums'; -import { CreateMeetingRequest, MeetingRecurrence } from '@lfx-pcc/shared/interfaces'; +import { CreateMeetingRequest, MeetingAttachment, MeetingRecurrence, PendingAttachment } from '@lfx-pcc/shared/interfaces'; import { getUserTimezone } from '@lfx-pcc/shared/utils'; import { MeetingService } from '@services/meeting.service'; import { ProjectService } from '@services/project.service'; import { MessageService } from 'primeng/api'; import { StepperModule } from 'primeng/stepper'; +import { forkJoin, Observable, of, take } from 'rxjs'; import { MeetingDetailsComponent } from '../meeting-details/meeting-details.component'; import { MeetingPlatformFeaturesComponent } from '../meeting-platform-features/meeting-platform-features.component'; +import { MeetingResourcesSummaryComponent } from '../meeting-resources-summary/meeting-resources-summary.component'; import { MeetingTypeSelectionComponent } from '../meeting-type-selection/meeting-type-selection.component'; @Component({ @@ -30,6 +32,7 @@ import { MeetingTypeSelectionComponent } from '../meeting-type-selection/meeting MeetingTypeSelectionComponent, MeetingDetailsComponent, MeetingPlatformFeaturesComponent, + MeetingResourcesSummaryComponent, ], templateUrl: './meeting-create.component.html', }) @@ -48,6 +51,11 @@ export class MeetingCreateComponent { public form = signal(this.createMeetingFormGroup()); public submitting = signal(false); + // Get pending attachments from the form + private get pendingAttachments(): PendingAttachment[] { + return this.form().get('attachments')?.value || []; + } + // Validation signals for template public readonly canProceed = signal(false); public readonly canGoNext = computed(() => { @@ -166,16 +174,42 @@ export class MeetingCreateComponent { ai_summary_access: formValue.ai_summary_access || 'PCC', recording_access: formValue.recording_access || 'Members', recurrence: recurrenceObject, + important_links: (this.form().get('important_links') as FormArray).value || [], }; this.meetingService.createMeeting(meetingData).subscribe({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Meeting created successfully', - }); - this.router.navigate(['/project', project.slug, 'meetings']); + next: (meeting) => { + // If we have pending attachments, save them to the database + if (this.pendingAttachments.length > 0) { + this.savePendingAttachments(meeting.id) + .pipe(take(1)) + .subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Meeting created successfully with ${this.pendingAttachments.length} attachment(s)`, + }); + this.router.navigate(['/project', project.slug, 'meetings']); + }, + error: (attachmentError: any) => { + console.error('Error saving attachments:', attachmentError); + this.messageService.add({ + severity: 'warn', + summary: 'Meeting Created', + detail: 'Meeting created but some attachments failed to save. You can add them later.', + }); + this.router.navigate(['/project', project.slug, 'meetings']); + }, + }); + } else { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Meeting created successfully', + }); + this.router.navigate(['/project', project.slug, 'meetings']); + } }, error: (error) => { console.error('Error creating meeting:', error); @@ -273,11 +307,9 @@ export class MeetingCreateComponent { ai_summary_access: new FormControl('PCC'), recording_access: new FormControl('Members'), - // Step 4: Participants - guestEmails: new FormControl([]), - - // Step 5: Resources - importantLinks: new FormControl<{ title: string; url: string }[]>([]), + // Step 4: Resources & Summary + attachments: new FormControl([]), + important_links: new FormArray([]), }, { validators: this.futureDateTimeValidator() } ); @@ -455,7 +487,7 @@ export class MeetingCreateComponent { } private getStepTitle(step: number): string { - const titles = ['Meeting Type', 'Meeting Details', 'Platform & Features', 'Participants', 'Resources & Summary']; + const titles = ['Meeting Type', 'Meeting Details', 'Platform & Features', 'Resources & Summary']; return titles[step] || ''; } @@ -495,4 +527,18 @@ export class MeetingCreateComponent { form.get('topic')?.setValue(generatedTitle); } } + + private savePendingAttachments(meetingId: string): Observable { + const attachmentsToSave = this.pendingAttachments.filter((attachment) => !attachment.uploading && !attachment.uploadError && attachment.fileUrl); + + if (attachmentsToSave.length === 0) { + return of([]); + } + + const saveRequests = attachmentsToSave.map((attachment) => + this.meetingService.createAttachmentFromUrl(meetingId, attachment.fileName, attachment.fileUrl, attachment.fileSize, attachment.mimeType) + ); + + return forkJoin(saveRequests).pipe(take(1)); + } } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html index 0cb236d6..21cecbbd 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html @@ -317,7 +317,7 @@

Meeting Settings

[customUpload]="true" (onSelect)="onFileSelect($event)" [multiple]="true" - accept="image/*, application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document" + accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.txt,.md,image/*" [maxFileSize]="10000000" [showUploadButton]="false" [showCancelButton]="true" diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.ts index bad0706f..76f6b797 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.ts @@ -44,6 +44,8 @@ import { forkJoin, Observable, of, take, tap } from 'rxjs'; styleUrl: './meeting-form.component.scss', }) export class MeetingFormComponent { + private static readonly allowedFileTypesSet = new Set(ALLOWED_FILE_TYPES); + private readonly config = inject(DynamicDialogConfig); private readonly dialogRef = inject(DynamicDialogRef); private readonly meetingService = inject(MeetingService); @@ -273,7 +275,12 @@ export class MeetingFormComponent { }; // Add to pending list with uploading status - this.pendingAttachments.update((current) => [...current, pendingAttachment]); + this.pendingAttachments.update((current) => { + const newAttachments = [...current, pendingAttachment]; + // Update form control to reflect new attachments immediately + this.form().get('attachments')?.setValue(newAttachments); + return newAttachments; + }); // Start the upload this.meetingService.uploadFileToStorage(file).subscribe({ @@ -305,7 +312,7 @@ export class MeetingFormComponent { } // Check file type - if (!ALLOWED_FILE_TYPES.includes(file.type as (typeof ALLOWED_FILE_TYPES)[number])) { + if (!MeetingFormComponent.allowedFileTypesSet.has(file.type as (typeof ALLOWED_FILE_TYPES)[number])) { const allowedTypes = ALLOWED_FILE_TYPES.map((type) => type.split('/')[1]).join(', '); return `File type "${file.type}" is not supported. Allowed types: ${allowedTypes}.`; } @@ -410,6 +417,9 @@ export class MeetingFormComponent { // Recording access recording_access: new FormControl('Members'), + + // Attachments + attachments: new FormControl([]), }, { validators: this.futureDateTimeValidator() } ); diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html new file mode 100644 index 00000000..cacd9609 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.html @@ -0,0 +1,153 @@ + + + +
+
+

Resources & Links

+

Add any important links or resources participants should review.

+
+ +
+ +
+ +

Add documents, presentations, or other files participants should have access to

+ + + +
+ + + @if (pendingAttachments().length > 0) { +
+ +
+ @for (attachment of pendingAttachments(); track attachment.id; let i = $index) { +
+
+
+ +
+
+

{{ attachment.fileName }}

+

{{ getFileType(attachment.fileName) }} • {{ attachment.fileSize | fileSize }}

+
+
+ @if (attachment.uploading) { + + } @else { + + } +
+ } +
+
+ } + + +
+ +

Links to documents, repositories, or other resources relevant to this meeting

+
+ +
+ + + +
+
+
+ + + @if (importantLinksFormArray.length > 0) { +
+ +
+ @for (linkControl of importantLinksFormArray.controls; track $index; let i = $index) { +
+
+

{{ linkControl.get('title')?.value }}

+

{{ linkControl.get('url')?.value }}

+
+ +
+ } +
+
+ } + + +
+
+ +
+
Useful resources to include
+
    +
  • • Project repository or documentation links
  • +
  • • Previous meeting minutes or recordings
  • +
  • • Relevant GitHub issues or pull requests
  • +
  • • Project roadmap or planning documents
  • +
  • • Presentation slides or agenda documents
  • +
  • • Background reading materials
  • +
+
+
+
+
+ + +
+
+

Meeting Summary

+
+
+ Type: + {{ meetingTypeLabel() }} +
+
+ Platform: + {{ form().get('meetingTool')?.value }} +
+
+ Privacy: + {{ form().get('restricted')?.value ? 'Private' : 'Public' }} +
+
+ Features: + {{ selectedFeatures().length }} enabled +
+
+ Resources: + {{ pendingAttachments().length }} files, {{ importantLinksFormArray.length }} links +
+
+
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts new file mode 100644 index 00000000..01abdd54 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-resources-summary/meeting-resources-summary.component.ts @@ -0,0 +1,238 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core'; +import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ButtonComponent } from '@components/button/button.component'; +import { FileUploadComponent } from '@components/file-upload/file-upload.component'; +import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_MB } from '@lfx-pcc/shared/constants'; +import { PendingAttachment } from '@lfx-pcc/shared/interfaces'; +import { FileSizePipe } from '@pipes/file-size.pipe'; +import { MeetingService } from '@services/meeting.service'; +import { MessageService } from 'primeng/api'; + +@Component({ + selector: 'lfx-meeting-resources-summary', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, FormsModule, FileUploadComponent, ButtonComponent, FileSizePipe], + templateUrl: './meeting-resources-summary.component.html', +}) +export class MeetingResourcesSummaryComponent implements OnInit { + // Input from parent + public readonly form = input.required(); + + // File management + public pendingAttachments = signal([]); + + // New link for simple input approach (matching React code) + public newLink = { title: '', url: '' }; + + // Important links management + public get importantLinksFormArray(): FormArray { + return this.form().get('important_links') as FormArray; + } + + // Summary computed values + public formattedDateTime = computed(() => this.formatDateTime()); + public selectedFeatures = computed(() => this.getSelectedFeatures()); + public meetingTypeLabel = computed(() => this.getMeetingTypeLabel()); + public recurrenceLabel = computed(() => this.getRecurrenceLabel()); + + // Inject services + private readonly meetingService = inject(MeetingService); + private readonly messageService = inject(MessageService); + + // Navigation + public readonly goToStep = output(); + + public constructor() {} + + public ngOnInit(): void { + // Initialize attachments from form + const existingAttachments = this.form().get('attachments')?.value || []; + this.pendingAttachments.set(existingAttachments); + } + + // Utility methods + public getFileType(fileName: string): string { + const extension = fileName.split('.').pop()?.toUpperCase(); + return extension || 'FILE'; + } + + // File handling methods + public onFileSelect(event: any): void { + // Handle PrimeNG FileUpload event structure + let files: File[] = []; + if (event.files && Array.isArray(event.files)) { + files = event.files; + } else if (event.currentFiles && Array.isArray(event.currentFiles)) { + files = event.currentFiles; + } else { + console.error('Could not extract files from PrimeNG FileUpload event:', event); + return; + } + + if (!files || files.length === 0) return; + + const newAttachments = Array.from(files) + .map((file) => { + const validationError = this.validateFile(file); + if (validationError) { + this.messageService.add({ + severity: 'error', + summary: 'File Upload Error', + detail: validationError, + life: 5000, + }); + return null; + } + + const pendingAttachment: PendingAttachment = { + id: crypto.randomUUID(), + fileName: file.name, + fileUrl: '', + fileSize: file.size, + mimeType: file.type, + uploading: true, + }; + + // Start the upload + this.meetingService.uploadFileToStorage(file).subscribe({ + next: (result) => { + this.pendingAttachments.update((current) => + current.map((pa) => (pa.id === pendingAttachment.id ? { ...pa, fileUrl: result.url, 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]); + } + + public removeAttachment(id: string): void { + this.pendingAttachments.update((current) => current.filter((f) => f.id !== id)); + this.form().get('attachments')?.setValue(this.pendingAttachments()); + } + + // Link management methods + public addLink(): void { + if (this.newLink.title && this.newLink.url) { + const linkFormGroup = new FormGroup({ + id: new FormControl(crypto.randomUUID()), + title: new FormControl(this.newLink.title), + url: new FormControl(this.newLink.url), + }); + + this.importantLinksFormArray.push(linkFormGroup); + this.newLink = { title: '', url: '' }; + } + } + + public removeLink(index: number): void { + this.importantLinksFormArray.removeAt(index); + } + + // Navigation methods + public editStep(step: number): void { + this.goToStep.emit(step); + } + + // Private methods + private validateFile(file: File): string | null { + // Check file size (10MB limit) + if (file.size > MAX_FILE_SIZE_BYTES) { + return `File "${file.name}" is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.`; + } + + // Check file type + if (!ALLOWED_FILE_TYPES.includes(file.type as (typeof ALLOWED_FILE_TYPES)[number])) { + const allowedTypes = ALLOWED_FILE_TYPES.map((type) => type.split('/')[1]).join(', '); + return `File type "${file.type}" is not supported. Allowed types: ${allowedTypes}.`; + } + + // Check for duplicate filenames in current session + const currentFiles = this.pendingAttachments(); + const isDuplicate = currentFiles.some((attachment) => attachment.fileName === file.name && !attachment.uploadError); + + if (isDuplicate) { + return `A file named "${file.name}" has already been selected for upload.`; + } + + // Check filename safety + if (file.name.includes('..') || file.name.startsWith('.')) { + return `Invalid filename "${file.name}". Filename cannot contain path traversal characters or start with a dot.`; + } + + return null; // File is valid + } + + // Summary formatting methods + private formatDateTime(): string { + const startDate = this.form().get('startDate')?.value; + const startTime = this.form().get('startTime')?.value; + const timezone = this.form().get('timezone')?.value; + + if (!startDate || !startTime) { + return 'Not set'; + } + + const date = new Date(startDate); + const formattedDate = date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return `${formattedDate} at ${startTime} (${timezone})`; + } + + private getSelectedFeatures(): string[] { + const form = this.form(); + const features: string[] = []; + + if (form.get('recording_enabled')?.value) features.push('Recording'); + if (form.get('transcripts_enabled')?.value) features.push('Transcripts'); + if (form.get('youtube_enabled')?.value) features.push('YouTube Upload'); + if (form.get('zoom_ai_enabled')?.value) features.push('AI Summary'); + + return features; + } + + private getMeetingTypeLabel(): string { + const meetingType = this.form().get('meeting_type')?.value; + if (!meetingType) return 'Not selected'; + + // Convert to title case + return meetingType + .split('_') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } + + private getRecurrenceLabel(): string { + const recurrence = this.form().get('recurrence')?.value; + if (!recurrence || recurrence === 'none') { + return 'One-time meeting'; + } + + const labels: { [key: string]: string } = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + }; + + return labels[recurrence] || 'Custom'; + } +} diff --git a/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.html b/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.html index f160eda8..944a3f23 100644 --- a/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.html +++ b/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.html @@ -95,6 +95,48 @@ } "> + } @else { + + + +
+
+
+ +
+
+

+ {{ chooseLabel() || 'Click to upload files or drag and drop' }} +

+

+ @if (accept()) { + {{ getFileTypeHint() }} + } + @if (maxFileSize()) { + • Max {{ maxFileSize()! / 1024 / 1024 | number: '1.0-0' }}MB each + } +

+
+
+
+
} diff --git a/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.ts b/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.ts index 013abeae..0b03f655 100644 --- a/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.ts +++ b/apps/lfx-pcc/src/app/shared/components/file-upload/file-upload.component.ts @@ -70,6 +70,20 @@ export class FileUploadComponent { public readonly onValidationFail = output(); public readonly onCustomUpload = output(); + // Helper methods for template + public getFileTypeHint(): string { + const accept = this.accept(); + if (!accept) return ''; + + const extensions = accept.split(',').map((ext) => ext.trim().replace(/\./g, '').toUpperCase()); + return extensions.join(', '); + } + + public getFileExtension(fileName: string): string { + const extension = fileName.split('.').pop()?.toUpperCase(); + return extension || 'FILE'; + } + // Handlers protected handleBeforeUpload(event: any): void { this.onBeforeUpload.emit(event); diff --git a/apps/lfx-pcc/src/server/helpers/logger.ts b/apps/lfx-pcc/src/server/helpers/logger.ts index 8cb11e09..149711cc 100644 --- a/apps/lfx-pcc/src/server/helpers/logger.ts +++ b/apps/lfx-pcc/src/server/helpers/logger.ts @@ -23,7 +23,7 @@ export class Logger { user_agent: req.get('User-Agent'), ip_address: req.ip, }, - `Starting ${operation.replace('_', ' ')}` + `Starting ${operation.replace(/_/g, ' ')}` ); return startTime; @@ -43,7 +43,7 @@ export class Logger { ...metadata, request_id: req.id, }, - `Successfully completed ${operation.replace('_', ' ')}` + `Successfully completed ${operation.replace(/_/g, ' ')}` ); } @@ -66,7 +66,7 @@ export class Logger { ...metadata, request_id: req.id, }, - `Failed to ${operation.replace('_', ' ')}` + `Failed to ${operation.replace(/_/g, ' ')}` ); } @@ -82,7 +82,7 @@ export class Logger { ...metadata, request_id: req.id, }, - `Validation failed for ${operation.replace('_', ' ')}` + `Validation failed for ${operation.replace(/_/g, ' ')}` ); } @@ -99,7 +99,7 @@ export class Logger { ...metadata, request_id: req.id, }, - `ETag operation: ${operation.replace('_', ' ')}` + `ETag operation: ${operation.replace(/_/g, ' ')}` ); } @@ -114,7 +114,7 @@ export class Logger { ...metadata, request_id: req.id, }, - `Warning during ${operation.replace('_', ' ')}: ${message}` + `Warning during ${operation.replace(/_/g, ' ')}: ${message}` ); } diff --git a/apps/lfx-pcc/src/server/helpers/responder.ts b/apps/lfx-pcc/src/server/helpers/responder.ts index 5e9ec652..71ab6bc8 100644 --- a/apps/lfx-pcc/src/server/helpers/responder.ts +++ b/apps/lfx-pcc/src/server/helpers/responder.ts @@ -139,7 +139,7 @@ export class Responder { // Handle HTTP status code errors const statusCode = errorDetails.statusCode; - const message = errorDetails.message || `Failed to ${operation.replace('_', ' ')}`; + const message = errorDetails.message || `Failed to ${operation.replace(/_/g, ' ')}`; this.error(res, message, { statusCode, diff --git a/apps/lfx-pcc/src/server/middleware/error-handler.middleware.ts b/apps/lfx-pcc/src/server/middleware/error-handler.middleware.ts index 6ac77295..d8d5eae5 100644 --- a/apps/lfx-pcc/src/server/middleware/error-handler.middleware.ts +++ b/apps/lfx-pcc/src/server/middleware/error-handler.middleware.ts @@ -1,8 +1,8 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Request, Response, NextFunction } from 'express'; import { ApiError } from '@lfx-pcc/shared/interfaces'; +import { NextFunction, Request, Response } from 'express'; export function apiErrorHandler(error: ApiError, req: Request, res: Response, next: NextFunction): void { // If response already sent, delegate to default Express error handler diff --git a/apps/lfx-pcc/src/server/services/supabase.service.ts b/apps/lfx-pcc/src/server/services/supabase.service.ts index c4227c87..e921b895 100644 --- a/apps/lfx-pcc/src/server/services/supabase.service.ts +++ b/apps/lfx-pcc/src/server/services/supabase.service.ts @@ -22,6 +22,7 @@ import { User, UserPermissionSummary, } from '@lfx-pcc/shared/interfaces'; +import { createApiError, createHttpError } from '../utils/api-error'; import dotenv from 'dotenv'; dotenv.config(); @@ -715,19 +716,28 @@ export class SupabaseService { public async createMeeting(meeting: CreateMeetingRequest): Promise { const url = `${this.baseUrl}/meetings`; - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(meeting), - signal: AbortSignal.timeout(this.timeout), - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(meeting), + signal: AbortSignal.timeout(this.timeout), + }); - if (!response.ok) { - throw new Error(`Failed to create meeting: ${response.status} ${response.statusText}: ${await response.text()}`); - } + if (!response.ok) { + const errorText = await response.text(); + const error = this.parseSupabaseError(response, errorText, 'Failed to create meeting'); + throw error; + } - const data = await response.json(); - return data?.[0] || data; + const data = await response.json(); + return data?.[0] || data; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw this.createTimeoutError('create meeting'); + } + throw error; + } } public async updateMeeting(id: string, meeting: UpdateMeetingRequest, editType?: 'single' | 'future'): Promise { @@ -1143,4 +1153,124 @@ export class SupabaseService { throw new Error(`Failed to create committee permission: ${response.status} ${response.statusText}`); } } + + // Error handling helper methods + private parseSupabaseError(response: Response, errorText: string, operation: string) { + const status = response.status; + + try { + // Try to parse JSON error response from Supabase + const errorJson = JSON.parse(errorText); + + if (errorJson.message) { + // Supabase returns structured errors + let userMessage = errorJson.message; + let code = 'SUPABASE_ERROR'; + + // Parse common Supabase/PostgreSQL errors into user-friendly messages + if (errorJson.code) { + code = errorJson.code; + userMessage = this.parsePostgresError(errorJson.code, errorJson.message, errorJson.details); + } else if (errorJson.hint) { + userMessage = errorJson.hint; + } + + return createApiError({ + message: userMessage, + status, + code, + service: 'supabase', + originalMessage: errorJson.message, + }); + } + } catch { + // Not valid JSON, treat as plain text error + } + + // Handle HTTP status codes + if (status >= 400) { + return createHttpError(status, response.statusText, errorText); + } + + // Fallback to generic error + return createApiError({ + message: `${operation}: ${errorText}`, + status, + code: 'SUPABASE_ERROR', + service: 'supabase', + originalMessage: errorText, + }); + } + + private parsePostgresError(code: string, message: string, details?: string): string { + switch (code) { + case '23505': // unique_violation + if (message.includes('duplicate key')) { + // Parse specific constraint names for better user messages + if (message.includes('meetings_project_uid_topic_start_time_key')) { + return 'A meeting with this topic and start time already exists for this project'; + } + if (message.includes('committees_project_uid_name_key')) { + return 'A committee with this name already exists for this project'; + } + if (message.includes('users_email_key')) { + return 'An account with this email address already exists'; + } + // Generic fallback + return 'This item already exists. Please use different values.'; + } + return message; + + case '23503': // foreign_key_violation + if (message.includes('project_uid')) { + return 'The specified project does not exist'; + } + if (message.includes('user_id')) { + return 'The specified user does not exist'; + } + if (message.includes('committee_id')) { + return 'The specified committee does not exist'; + } + if (message.includes('meeting_id')) { + return 'The specified meeting does not exist'; + } + return 'Referenced item does not exist'; + + case '23514': // check_violation + return 'Invalid data provided - please check your input values'; + + case '23502': // not_null_violation + if (details) { + // Extract field name from details + const fieldMatch = details.match(/column "([^"]+)"/); + if (fieldMatch) { + const field = fieldMatch[1].replace(/_/g, ' '); + return `Missing required field: ${field}`; + } + } + return 'Missing required information'; + + case '42501': // insufficient_privilege + return 'You do not have permission to perform this action'; + + case '42P01': // undefined_table + return 'System error: Database table not found'; + + case '42703': // undefined_column + return 'System error: Database column not found'; + + default: + // Return the original message for unknown codes + return message; + } + } + + private createTimeoutError(operation: string) { + return createApiError({ + message: `Request timeout while trying to ${operation}`, + status: 408, + code: 'TIMEOUT', + service: 'supabase', + }); + } } diff --git a/packages/shared/src/constants/file-upload.ts b/packages/shared/src/constants/file-upload.ts index 270ac381..c9e79eb9 100644 --- a/packages/shared/src/constants/file-upload.ts +++ b/packages/shared/src/constants/file-upload.ts @@ -14,6 +14,8 @@ export const ALLOWED_FILE_TYPES = [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'text/markdown', ] as const; export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index 4220c0f0..e132723e 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { MeetingVisibility, RecurrenceType, MeetingType } from '../enums'; +import { MeetingType, MeetingVisibility, RecurrenceType } from '../enums'; export interface MeetingRecurrence { end_date_time?: string; @@ -14,6 +14,12 @@ export interface MeetingRecurrence { weekly_days?: string; } +export interface ImportantLink { + id?: string; + title: string; + url: string; +} + export interface MeetingCommittee { uid: string; name: string; @@ -87,6 +93,7 @@ export interface CreateMeetingRequest { recurrence?: MeetingRecurrence; restricted?: boolean; committees?: string[]; + important_links?: ImportantLink[]; } export interface UpdateMeetingRequest {