diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6aa639b7..1e8d1dc3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -44,6 +44,12 @@ on: TEST_PASSWORD: description: 'Password for test authentication' required: false + AI_API_KEY: + description: 'API key for AI' + required: false + AI_PROXY_URL: + description: 'Proxy URL for AI' + required: false outputs: test-results: description: 'Test results summary' @@ -95,7 +101,6 @@ jobs: secret-ids: | SUPABASE, /cloudops/managed-secrets/cloud/supabase/api_key AUTH0, /cloudops/managed-secrets/auth0/LFX_V2_PCC - - name: Setup Turborepo cache uses: actions/cache@v4 with: @@ -115,11 +120,23 @@ jobs: # Check AWS Secrets Manager secrets (masked environment variables) if [ -z "$AUTH0" ]; then missing_secrets="$missing_secrets AUTH0 (from AWS Secrets Manager)" + else + AUTH0_CLIENT_ID=$(echo "$AUTH0" | jq -r '.client_id // empty') + AUTH0_CLIENT_SECRET=$(echo "$AUTH0" | jq -r '.client_secret // empty') + if [ -z "$AUTH0_CLIENT_ID" ] || [ -z "$AUTH0_CLIENT_SECRET" ]; then + missing_secrets="$missing_secrets AUTH0.client_id or AUTH0.client_secret (from AWS Secrets Manager)" + fi fi if [ -z "$SUPABASE" ]; then missing_secrets="$missing_secrets SUPABASE (from AWS Secrets Manager)" + else + SUPABASE_URL=$(echo "$SUPABASE" | jq -r '.url // empty') + SUPABASE_API_KEY=$(echo "$SUPABASE" | jq -r '.api_key // empty') + if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_API_KEY" ]; then + missing_secrets="$missing_secrets SUPABASE.url or SUPABASE.api_key (from AWS Secrets Manager)" + fi fi - + # Check GitHub secrets (fallback) if [ -z "${{ secrets.TEST_USERNAME }}" ]; then missing_secrets="$missing_secrets TEST_USERNAME" @@ -127,7 +144,13 @@ jobs: if [ -z "${{ secrets.TEST_PASSWORD }}" ]; then missing_secrets="$missing_secrets TEST_PASSWORD" fi - + if [ -z "${{ secrets.AI_API_KEY }}" ]; then + missing_secrets="$missing_secrets AI_API_KEY" + fi + if [ -z "${{ secrets.AI_PROXY_URL }}" ]; then + missing_secrets="$missing_secrets AI_PROXY_URL" + fi + if [ -n "$missing_secrets" ]; then echo "❌ Missing required secrets for E2E testing:$missing_secrets" echo "Please configure these secrets to enable E2E tests." @@ -145,6 +168,7 @@ jobs: echo "PCC_AUTH0_ISSUER_BASE_URL=https://linuxfoundation-dev.auth0.com/" >> $GITHUB_ENV echo "PCC_AUTH0_AUDIENCE=https://api-gw.dev.platform.linuxfoundation.org/" >> $GITHUB_ENV echo "LFX_V2_SERVICE=http://lfx-api.dev.v2.cluster.linuxfound.info" >> $GITHUB_ENV + echo "NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222" >> $GITHUB_ENV echo "CI=true" >> $GITHUB_ENV - name: Set up sensitive environment variables @@ -164,27 +188,35 @@ jobs: echo "PCC_AUTH0_CLIENT_SECRET=$AUTH0_CLIENT_SECRET" >> $GITHUB_ENV echo "✅ AUTH0 secrets set as masked environment variables" fi - + # Parse and set SUPABASE secrets if [ -n "$SUPABASE" ]; then SUPABASE_URL=$(echo "$SUPABASE" | jq -r '.url // empty') SUPABASE_API_KEY=$(echo "$SUPABASE" | jq -r '.api_key // empty') - + # Explicitly mask the values echo "::add-mask::$SUPABASE_URL" echo "::add-mask::$SUPABASE_API_KEY" - + # Set as environment variables echo "SUPABASE_URL=$SUPABASE_URL" >> $GITHUB_ENV echo "POSTGRES_API_KEY=$SUPABASE_API_KEY" >> $GITHUB_ENV echo "✅ SUPABASE secrets set as masked environment variables" fi - + + # Set AI secrets + echo "::add-mask::${{ secrets.AI_API_KEY }}" + echo "::add-mask::${{ secrets.AI_PROXY_URL }}" + echo "AI_API_KEY=${{ secrets.AI_API_KEY }}" >> $GITHUB_ENV + echo "AI_PROXY_URL=${{ secrets.AI_PROXY_URL }}" >> $GITHUB_ENV + echo "✅ AI secrets set as masked environment variables" + # Set test credentials echo "::add-mask::${{ secrets.TEST_USERNAME }}" echo "::add-mask::${{ secrets.TEST_PASSWORD }}" echo "TEST_USERNAME=${{ secrets.TEST_USERNAME }}" >> $GITHUB_ENV echo "TEST_PASSWORD=${{ secrets.TEST_PASSWORD }}" >> $GITHUB_ENV + echo "✅ TEST_USERNAME and TEST_PASSWORD secrets set as masked environment variables" - name: Install Playwright browsers if: steps.validate-secrets.outputs.can_run_tests == 'true' @@ -239,6 +271,7 @@ jobs: echo "AWS Secrets Manager (required):" echo " - /cloudops/managed-secrets/auth0/LFX_V2_PCC (AUTH0 configuration)" echo " - /cloudops/managed-secrets/cloud/supabase/api_key (SUPABASE configuration)" + echo " - /cloudops/managed-secrets/ai/ai_config (AI configuration)" echo "" echo "GitHub Secrets (required for authenticated tests):" echo " - TEST_USERNAME" @@ -263,16 +296,6 @@ jobs: echo "results=failure" >> $GITHUB_OUTPUT fi - - name: Upload Playwright report - id: upload-report - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report-${{ inputs.browser }}-${{ github.run_id }} - path: | - apps/lfx-pcc/playwright-report/ - retention-days: 7 - - name: Comment test results on PR if: github.event_name == 'pull_request' && always() uses: actions/github-script@v7 diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml index b686179f..d67a2386 100644 --- a/.github/workflows/quality-check.yml +++ b/.github/workflows/quality-check.yml @@ -84,3 +84,5 @@ jobs: secrets: TEST_USERNAME: ${{ secrets.TEST_USERNAME }} TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + AI_API_KEY: ${{ secrets.AI_API_KEY }} + AI_PROXY_URL: ${{ secrets.AI_PROXY_URL }} diff --git a/.github/workflows/weekly-e2e-tests.yml b/.github/workflows/weekly-e2e-tests.yml index 684b2b0e..58b2514b 100644 --- a/.github/workflows/weekly-e2e-tests.yml +++ b/.github/workflows/weekly-e2e-tests.yml @@ -64,6 +64,8 @@ jobs: secrets: TEST_USERNAME: ${{ secrets.TEST_USERNAME }} TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + AI_API_KEY: ${{ secrets.AI_API_KEY }} + AI_PROXY_URL: ${{ secrets.AI_PROXY_URL }} report-results: name: Report Weekly Test Results diff --git a/.vscode/settings.json b/.vscode/settings.json index 675397e3..97876a14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "iconfield", "inputicon", "Linkify", + "litellm", "networkidle", "nonexistentproject", "PostgreSQL", diff --git a/CLAUDE.md b/CLAUDE.md index 9f3ae976..cd63d8a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ LFX PCC is a Turborepo monorepo containing an Angular 19 SSR application with ex ## Monorepo Structure ```text -lfx-pcc-v3/ +lfx-v2-pcc-ui/ ├── apps/ │ └── lfx-pcc/ # Angular 19 SSR application with zoneless change detection │ ├── eslint.config.mjs # Angular-specific ESLint rules @@ -96,6 +96,8 @@ lfx-pcc-v3/ - Logging uses Pino for structured JSON logs with sensitive data redaction - Health checks are available at /health and are not logged or authenticated - All shared types, interfaces, and constants are centralized in @lfx-pcc/shared package +- **AI Service Integration**: Claude Sonnet 4 model via LiteLLM proxy for meeting agenda generation +- **AI Environment Variables**: AI_PROXY_URL and AI_API_KEY required for AI functionality - Use TypeScript interfaces instead of union types for better maintainability - Shared package uses direct source imports during development for hot reloading - **Interfaces go into the shared packages** diff --git a/apps/lfx-pcc/.env.example b/apps/lfx-pcc/.env.example index 3f157f57..968481e2 100644 --- a/apps/lfx-pcc/.env.example +++ b/apps/lfx-pcc/.env.example @@ -25,6 +25,11 @@ SUPABASE_STORAGE_BUCKET=your-supabase-bucket-name # Internal k8s service DNS for NATS cluster NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222 +# AI Service Configuration +# OpenAI-compatible proxy for meeting agenda generation +AI_PROXY_URL=https://litellm.tools.lfx.dev/chat/completions +AI_API_KEY=your-litellm-ai-api-key + # E2E Test Configuration (Optional) # Test user credentials for automated testing TEST_USERNAME=your-test-username diff --git a/apps/lfx-pcc/e2e/homepage-robust.spec.ts b/apps/lfx-pcc/e2e/homepage-robust.spec.ts index 121f47bb..51682639 100644 --- a/apps/lfx-pcc/e2e/homepage-robust.spec.ts +++ b/apps/lfx-pcc/e2e/homepage-robust.spec.ts @@ -3,8 +3,12 @@ import { expect, test } from '@playwright/test'; +import { ApiMockHelper } from './helpers/api-mock.helper'; + test.describe('Homepage - Robust Tests', () => { test.beforeEach(async ({ page }) => { + await ApiMockHelper.setupProjectSlugMock(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); // Verify we're authenticated and on the homepage diff --git a/apps/lfx-pcc/e2e/homepage.spec.ts b/apps/lfx-pcc/e2e/homepage.spec.ts index e73cc963..50dc5336 100644 --- a/apps/lfx-pcc/e2e/homepage.spec.ts +++ b/apps/lfx-pcc/e2e/homepage.spec.ts @@ -7,6 +7,9 @@ import { ApiMockHelper } from './helpers/api-mock.helper'; test.describe('Homepage', () => { test.beforeEach(async ({ page }) => { + // Setup API mocks before navigation + await ApiMockHelper.setupProjectSlugMock(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); // Verify we're authenticated and on the homepage @@ -114,9 +117,6 @@ test.describe('Homepage', () => { }); test('should filter projects when searching', async ({ page }) => { - // Setup API mocks before navigation - await ApiMockHelper.setupProjectSlugMock(page); - // Wait for project cards to appear await expect(page.getByTestId('project-card').first()).toBeVisible({ timeout: 10000 }); @@ -149,9 +149,6 @@ test.describe('Homepage', () => { }); test('should clear search and show all projects', async ({ page }) => { - // Setup API mocks before navigation - await ApiMockHelper.setupProjectSlugMock(page); - // Wait for project cards to appear await expect(page.getByTestId('project-card').first()).toBeVisible({ timeout: 10000 }); @@ -175,9 +172,6 @@ test.describe('Homepage', () => { }); test('should navigate to project detail when clicking a project card', async ({ page }) => { - // Setup API mocks before navigation - await ApiMockHelper.setupProjectSlugMock(page); - // Wait for project cards to appear await expect(page.getByTestId('project-card').first()).toBeVisible({ timeout: 10000 }); diff --git a/apps/lfx-pcc/e2e/project-dashboard.spec.ts b/apps/lfx-pcc/e2e/project-dashboard.spec.ts index a7d6e31d..e6384c7d 100644 --- a/apps/lfx-pcc/e2e/project-dashboard.spec.ts +++ b/apps/lfx-pcc/e2e/project-dashboard.spec.ts @@ -73,7 +73,6 @@ test.describe('Project Dashboard', () => { }); test('should display all navigation tabs', async ({ page }) => { - console.log(await page.getByTestId('menu-item').allInnerTexts()); await expect(page.getByTestId('menu-item').filter({ hasText: 'Dashboard' })).toBeVisible(); await expect(page.getByTestId('menu-item').filter({ hasText: 'Meetings' })).toBeVisible(); await expect(page.getByTestId('menu-item').filter({ hasText: 'Committees' })).toBeVisible(); @@ -93,16 +92,6 @@ test.describe('Project Dashboard', () => { const projectImage = page.locator('img').first(); await expect(projectImage).toBeVisible(); }); - - test('should display project summary counts', async ({ page }) => { - // Look for summary cards in the upper section of the page - await expect(page.locator('span').filter({ hasText: 'Meetings' }).first()).toBeVisible(); - await expect(page.locator('span').filter({ hasText: 'Committees' }).first()).toBeVisible(); - await expect(page.locator('span').filter({ hasText: 'Mailing Lists' }).first()).toBeVisible(); - - // Check for count values (they should be visible as numbers) - await expect(page.getByText(/^\d+$/).first()).toBeVisible(); - }); }); test.describe('Metrics Cards', () => { @@ -191,16 +180,16 @@ test.describe('Project Dashboard', () => { }); test('should display all quick action items', async ({ page }) => { - await expect(page.getByRole('menuitem', { name: 'Schedule Meeting' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Create Meeting' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Create Committee' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'View All Committees' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'View Calendar' })).toBeVisible(); }); test('should have working links in quick actions', async ({ page }) => { - // Schedule Meeting should link to meetings page - const scheduleMeetingLink = page.getByRole('link', { name: 'Schedule Meeting' }); - await expect(scheduleMeetingLink).toHaveAttribute('href', /\/meetings$/); + // Create Meeting should link to meetings page + const createMeetingLink = page.getByRole('link', { name: 'Create Meeting' }); + await expect(createMeetingLink).toHaveAttribute('href', /\/meetings\/create$/); // View All Committees should link to committees page const viewCommitteesLink = page.getByRole('link', { name: 'View All Committees' }); @@ -216,7 +205,7 @@ test.describe('Project Dashboard', () => { const quickActionsSection = page.getByText('Quick Actions').locator('..'); // Check that menu items are present and interactive - await expect(quickActionsSection.getByRole('menuitem', { name: 'Schedule Meeting' })).toBeVisible(); + await expect(quickActionsSection.getByRole('menuitem', { name: 'Create Meeting' })).toBeVisible(); await expect(quickActionsSection.getByRole('menuitem', { name: 'Create Committee' })).toBeVisible(); await expect(quickActionsSection.getByRole('menuitem', { name: 'View All Committees' })).toBeVisible(); await expect(quickActionsSection.getByRole('menuitem', { name: 'View Calendar' })).toBeVisible(); diff --git a/apps/lfx-pcc/src/app/app.component.scss b/apps/lfx-pcc/src/app/app.component.scss index 87651355..e955a7f2 100644 --- a/apps/lfx-pcc/src/app/app.component.scss +++ b/apps/lfx-pcc/src/app/app.component.scss @@ -55,6 +55,10 @@ } } + .p-textarea { + field-sizing: content; + } + .pill { @apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full transition-colors text-gray-600 border border-gray-200 hover:bg-gray-50; } diff --git a/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html b/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html index a6fcc1e3..6524fde2 100644 --- a/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html +++ b/apps/lfx-pcc/src/app/layouts/project-layout/project-layout.component.html @@ -33,25 +33,11 @@

@if (projectDescription(); as description) { -

+

{{ description }}

} - - @if (metrics().length > 0) { -
- @for (metric of metrics(); track metric.label) { -
-
- - {{ metric.label }} -
-
{{ metric.value }}
-
- } -
- } diff --git a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html index d208bcd1..91af91e4 100644 --- a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html +++ b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html @@ -200,11 +200,11 @@

Upcoming Meetings

No Upcoming Meetings

There are no upcoming meetings scheduled.

diff --git a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts index acb8dcc1..232337b9 100644 --- a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts @@ -541,9 +541,9 @@ export class ProjectComponent { private initializeQuickActionMenuItems(): MenuItem[] { return [ { - label: 'Schedule Meeting', + label: 'Create Meeting', icon: 'fa-light fa-calendar-plus text-sm', - routerLink: ['meetings'], + routerLink: ['meetings/create'], }, { label: 'Create Committee', diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html index 9a4ce3f7..6cb347af 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html @@ -108,9 +108,7 @@

AI Agenda Generator

control="agenda" id="meeting-agenda" placeholder="Enter meeting agenda and key discussion points" - [rows]="6" - [autoResize]="true" - styleClass="w-full" + styleClass="w-full min-h-32" data-testid="meeting-details-agenda-textarea">

A clear agenda helps participants prepare and keeps discussions focused

@if (form().get('agenda')?.errors?.['required'] && form().get('agenda')?.touched) { diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts index 60b20292..1e4fdb8f 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts @@ -12,10 +12,14 @@ import { SelectComponent } from '@components/select/select.component'; import { TextareaComponent } from '@components/textarea/textarea.component'; import { TimePickerComponent } from '@components/time-picker/time-picker.component'; import { ToggleComponent } from '@components/toggle/toggle.component'; +import { GenerateAgendaRequest, MeetingTemplate } from '@lfx-pcc/shared'; import { TIMEZONES } from '@lfx-pcc/shared/constants'; -import { MeetingType } from '@lfx-pcc/shared/enums'; -import { MeetingTemplate } from '@lfx-pcc/shared'; +import { MeetingService } from '@services/meeting.service'; +import { ProjectService } from '@services/project.service'; +import { MessageService } from 'primeng/api'; import { TooltipModule } from 'primeng/tooltip'; +import { finalize, take, tap } from 'rxjs'; + import { AgendaTemplateSelectorComponent } from '../agenda-template-selector/agenda-template-selector.component'; @Component({ @@ -37,6 +41,10 @@ import { AgendaTemplateSelectorComponent } from '../agenda-template-selector/age templateUrl: './meeting-details.component.html', }) export class MeetingDetailsComponent implements OnInit { + private readonly projectService = inject(ProjectService); + private readonly meetingService = inject(MeetingService); + private readonly messageService = inject(MessageService); + // Form group input from parent public readonly form = input.required(); @@ -141,20 +149,65 @@ export class MeetingDetailsComponent implements OnInit { } public async generateAiAgenda(): Promise { - const promptValue = this.form().get('aiPrompt')?.value; - if (!promptValue?.trim()) return; - - this.isGeneratingAgenda.set(true); + const context = this.form().get('aiPrompt')?.value; + const currentProject = this.projectService.project(); + const form = this.form(); + const topic = form.get('topic')?.value; + const meetingType = form.get('meeting_type')?.value; + + if (!currentProject || !topic || !meetingType || !context) { + this.messageService.add({ + severity: 'warn', + summary: 'Missing Information', + detail: 'Please fill in the meeting title, type, and prompt before generating an agenda.', + }); + return; + } - // Simulate API call delay - await new Promise((resolve) => setTimeout(resolve, 2500)); + const request: GenerateAgendaRequest = { + meetingType, + title: topic, + projectName: currentProject.name, + context, + }; - const meetingType = this.form().get('meeting_type')?.value || MeetingType.OTHER; - const generatedAgenda = this.getMockAgenda(meetingType, promptValue); + this.isGeneratingAgenda.set(true); - this.form().get('agenda')?.setValue(generatedAgenda); - this.isGeneratingAgenda.set(false); - this.hideAiAgendaHelper(); + this.meetingService + .generateAgenda(request) + .pipe( + take(1), + tap({ + next: (response) => { + // Set the generated agenda in the form + this.form().get('agenda')?.setValue(response.agenda); + + // Set the AI-estimated duration + this.setAiEstimatedDuration(response.estimatedDuration); + + this.messageService.add({ + severity: 'success', + summary: 'Agenda Generated', + detail: 'AI has successfully generated a meeting agenda.', + }); + }, + error: (error) => { + console.error('Failed to generate agenda:', error); + this.messageService.add({ + severity: 'error', + summary: 'Generation Failed', + detail: 'Failed to generate agenda. Please try again.', + }); + }, + complete: () => { + this.hideAiAgendaHelper(); + }, + }), + finalize(() => { + this.isGeneratingAgenda.set(false); + }) + ) + .subscribe(); } // Template selector public methods @@ -235,125 +288,18 @@ export class MeetingDetailsComponent implements OnInit { return { weekOfMonth, isLastWeek }; } - private getMockAgenda(meetingType: string, prompt: string): string { - const mockAgendas: Record = { - [MeetingType.BOARD]: `**Meeting Objective**: ${prompt} - -**Agenda Items**: -1. **Opening & Welcome** (5 min) - - Roll call and attendance - - Review of previous meeting minutes - -2. **Strategic Discussion** (25 min) - - ${prompt} - - Financial overview and budget considerations - - Key performance indicators review - -3. **Decision Items** (15 min) - - Action items requiring board approval - - Risk assessment and mitigation strategies - -4. **Next Steps & Closing** (5 min) - - Assignment of action items - - Next meeting date confirmation`, - - [MeetingType.TECHNICAL]: `**Development Focus**: ${prompt} - -**Technical Agenda**: -1. **System Status Review** (10 min) - - Current sprint progress - - Infrastructure health check - -2. **Core Discussion** (30 min) - - ${prompt} - - Technical implementation approach - - Architecture considerations and trade-offs - -3. **Code Review & Quality** (15 min) - - Recent pull requests and code changes - - Testing coverage and quality metrics - -4. **Planning & Blockers** (5 min) - - Upcoming milestones - - Technical blockers and dependencies`, - - [MeetingType.MAINTAINERS]: `**Community Focus**: ${prompt} - -**Maintainers Sync Agenda**: -1. **Community Updates** (10 min) - - Recent contributor activity - - Community feedback highlights - -2. **Project Discussion** (25 min) - - ${prompt} - - Release planning and roadmap updates - - Contributor onboarding improvements - -3. **Issue Triage** (20 min) - - High-priority issues review - - Feature requests evaluation - -4. **Action Planning** (5 min) - - Task assignments and next steps`, - - [MeetingType.MARKETING]: `**Marketing Initiative**: ${prompt} - -**Marketing Meeting Agenda**: -1. **Performance Review** (10 min) - - Current campaign metrics - - Community growth statistics - -2. **Strategic Focus** (25 min) - - ${prompt} - - Brand positioning and messaging - - Content strategy alignment - -3. **Campaign Planning** (20 min) - - Upcoming marketing initiatives - - Budget allocation and resources - -4. **Collaboration & Next Steps** (5 min) - - Cross-team coordination - - Action item assignments`, - - [MeetingType.LEGAL]: `**Legal Review**: ${prompt} - -**Legal Meeting Agenda**: -1. **Compliance Overview** (10 min) - - Current legal standing - - Recent regulatory changes - -2. **Focus Discussion** (30 min) - - ${prompt} - - Legal risk assessment - - Policy and procedure updates - -3. **Documentation Review** (15 min) - - Contract updates and amendments - - Terms of service modifications - -4. **Action Items** (5 min) - - Legal task assignments - - Timeline for deliverables`, - - [MeetingType.OTHER]: `**Meeting Purpose**: ${prompt} - -**General Meeting Agenda**: -1. **Welcome & Introductions** (10 min) - - Participant introductions - - Meeting objectives overview - -2. **Main Discussion** (35 min) - - ${prompt} - - Open discussion and brainstorming - - Key points and considerations - -3. **Summary & Next Steps** (15 min) - - Key takeaways summary - - Action item assignments - - Follow-up meeting planning`, - }; + private setAiEstimatedDuration(estimatedDuration: number): void { + // Check if the estimated duration matches one of our standard options + const standardDuration = this.durationOptions.find((option) => typeof option.value === 'number' && option.value === estimatedDuration); - return mockAgendas[meetingType] || mockAgendas[MeetingType.OTHER]; + if (standardDuration) { + // Use standard duration option + this.form().get('duration')?.setValue(estimatedDuration); + this.form().get('customDuration')?.setValue(null); + } else { + // Use custom duration + this.form().get('duration')?.setValue('custom'); + this.form().get('customDuration')?.setValue(estimatedDuration); + } } } 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 deleted file mode 100644 index 21cecbbd..00000000 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html +++ /dev/null @@ -1,380 +0,0 @@ - - - -
- -
- -
- - -

Meeting topic is required

-
- - -
- - -
- - -
- - -

- Meeting type is required -

-
-
- - -
-
- -
- - -

- Start date is required -

-
- - -
- - -

- Start time is required -

-
- - -
- - -

Duration is required

-
-
- - -

- Meeting must be scheduled in the future -

- - - @if (this.form().get('duration')?.value === 'custom') { -
- - -

- Custom duration is required -

-

- Custom duration must be greater than 5 minutes -

-

- Custom duration must be less than 480 minutes -

-
- } - - -
- - -

Timezone is required

-
- - - @if (!isEditingSingleOccurrence()) { -
-
- - -
- -
- } - - -
-
- - -
- -
-
- - - @if (!isEditingSingleOccurrence()) { -
-

Meeting Settings

- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - - @if (form().get('recording_enabled')?.value === true) { -
-
- - -
- - -
-
- - -
- -
- - -
- } -
- - -
-
- - -
- - - @if (form().get('zoom_ai_enabled')?.value === true) { -
- - -
- -
-
- - -
- -
- } -
-
- } - - @if (!isEditing()) { -
-
- - -
- - -
Drag and drop files to here to upload.
-
-
- - - @if (pendingAttachments().length > 0) { -
-

Selected Files:

- @for (attachment of pendingAttachments(); track attachment.id) { -
-
- @if (attachment.uploading) { - - } @else if (attachment.uploadError) { - - } @else { - - } - {{ attachment.fileName }} - ({{ attachment.fileSize / 1024 / 1024 | number: '1.1-1' }}MB) -
-
- @if (attachment.uploading) { - Uploading... - } @else if (attachment.uploadError) { - {{ attachment.uploadError }} - } @else { - Ready - } - -
-
- } -
- } -
- } - - -
- - -
-
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.scss b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.scss deleted file mode 100644 index e1f3c95d..00000000 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -// Meeting form specific styles -.meeting-form { - // Component-specific styles can be added here if needed -} 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 deleted file mode 100644 index 8adda593..00000000 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.ts +++ /dev/null @@ -1,644 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { FileUploadComponent } from '@app/shared/components/file-upload/file-upload.component'; -import { ButtonComponent } from '@components/button/button.component'; -import { CalendarComponent } from '@components/calendar/calendar.component'; -import { InputTextComponent } from '@components/input-text/input-text.component'; -import { SelectComponent } from '@components/select/select.component'; -import { TextareaComponent } from '@components/textarea/textarea.component'; -import { TimePickerComponent } from '@components/time-picker/time-picker.component'; -import { ToggleComponent } from '@components/toggle/toggle.component'; -import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_MB, TIMEZONES } from '@lfx-pcc/shared/constants'; -import { MeetingType, MeetingVisibility, RecurrenceType } from '@lfx-pcc/shared/enums'; -import { CreateMeetingRequest, MeetingAttachment, MeetingRecurrence, PendingAttachment, UpdateMeetingRequest } from '@lfx-pcc/shared/interfaces'; -import { combineDateTime, getUserTimezone } from '@lfx-pcc/shared/utils'; -import { - customDurationValidator, - futureDateTimeValidator, - meetingAgendaValidator, - meetingTopicValidator, - timeFormatValidator, -} from '@lfx-pcc/shared/validators'; -import { MeetingService } from '@services/meeting.service'; -import { ProjectService } from '@services/project.service'; -import { MessageService } from 'primeng/api'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { TooltipModule } from 'primeng/tooltip'; -import { forkJoin, Observable, of, take, tap } from 'rxjs'; - -@Component({ - selector: 'lfx-meeting-form', - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - ButtonComponent, - CalendarComponent, - InputTextComponent, - SelectComponent, - TextareaComponent, - TimePickerComponent, - ToggleComponent, - TooltipModule, - FileUploadComponent, - ], - templateUrl: './meeting-form.component.html', - 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); - private readonly projectService = inject(ProjectService); - private readonly messageService = inject(MessageService); - - // Loading state for form submissions - public submitting = signal(false); - public pendingAttachments = signal([]); - - // Create form group internally - public form = signal(this.createMeetingFormGroup()); - public loading = signal(false); - - public isEditing = signal(this.config.data?.isEditing || false); - public meetingId = signal(this.config.data?.meetingId); - public meeting = signal(this.config.data?.meeting); - public editType = signal(this.config.data?.editType || 'single'); - public isEditingSingleOccurrence = computed(() => this.editType() === 'single' && !!this.meeting()?.recurrence); - - // Duration options for the select dropdown - public durationOptions = [ - { label: '15 minutes', value: 15 }, - { label: '30 minutes', value: 30 }, - { label: '60 minutes', value: 60 }, - { label: '90 minutes', value: 90 }, - { label: '120 minutes', value: 120 }, - { label: 'Custom...', value: 'custom' }, - ]; - - // Meeting type options (using shared enum) - public meetingTypeOptions = [ - { label: 'Board', value: MeetingType.BOARD }, - { label: 'Maintainers', value: MeetingType.MAINTAINERS }, - { label: 'Marketing', value: MeetingType.MARKETING }, - { label: 'Technical', value: MeetingType.TECHNICAL }, - { label: 'Legal', value: MeetingType.LEGAL }, - { label: 'Other', value: MeetingType.OTHER }, - { label: 'None', value: MeetingType.NONE }, - ]; - - // Timezone options from shared constants - public timezoneOptions = TIMEZONES.map((tz) => ({ - label: `${tz.label} (${tz.offset})`, - value: tz.value, - })); - - // AI Summary Access options - public aiSummaryAccessOptions = [ - { label: 'PCC', value: 'PCC' }, - { label: 'PCC & Individuals', value: 'PCC & Individuals' }, - ]; - - // Recording Access options - public recordingAccessOptions = [ - { label: 'Members', value: 'Members' }, - { label: 'Public', value: 'Public' }, - { label: 'Restricted', value: 'Restricted' }, - ]; - - // Recurrence options (computed dynamically based on selected date) - public recurrenceOptions = signal([ - { label: 'Does not repeat', value: 'none' }, - { label: 'Daily', value: 'daily' }, - { label: 'Weekly on Monday', value: 'weekly' }, // Will be updated dynamically - { label: 'Monthly on the 1st Monday', value: 'monthly_nth' }, // Will be updated dynamically - { label: 'Monthly on the last Monday', value: 'monthly_last' }, // Will be updated dynamically - { label: 'Every weekday', value: 'weekdays' }, - ]); - - // Minimum date (yesterday) - public minDate = signal(this.getYesterday()); - - public constructor() { - // Initialize form with data when component is created - this.initializeForm(); - } - - // Public methods - public onSubmit(): void { - // Mark all form controls as touched and dirty to show validation errors - Object.keys(this.form().controls).forEach((key) => { - const control = this.form().get(key); - control?.markAsTouched(); - control?.markAsDirty(); - }); - - if (this.form().invalid) { - return; - } - - const project = this.projectService.project(); - if (!project) { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Project information is required to create a meeting.', - }); - return; - } - - this.submitting.set(true); - const formValue = this.form().value; - - // Process duration value with safety check for NaN - const duration = formValue.duration === 'custom' ? Number(formValue.customDuration) : formValue.duration; - - // Safety check to prevent NaN duration - if (isNaN(duration) || duration <= 0) { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Please enter a valid duration.', - }); - this.submitting.set(false); - return; - } - - // Combine date and time for start_time with timezone awareness - const startDateTime = combineDateTime(formValue.startDate, formValue.startTime, formValue.timezone); - - // Generate recurrence object if needed - const recurrenceObject = this.generateRecurrenceObject(formValue.recurrence, formValue.startDate); - - // Create meeting data - const baseMeetingData = { - project_uid: project.uid, - topic: formValue.topic, - agenda: formValue.agenda || '', - start_time: startDateTime, - duration: duration, - timezone: formValue.timezone, - meeting_type: formValue.meeting_type || 'None', - early_join_time: formValue.early_join_time || 10, - visibility: formValue.show_in_public_calendar ? MeetingVisibility.PUBLIC : MeetingVisibility.PRIVATE, - restricted: formValue.restricted || false, - recording_enabled: formValue.recording_enabled || false, - transcripts_enabled: formValue.transcripts_enabled || false, - youtube_enabled: formValue.youtube_enabled || false, - zoom_ai_enabled: formValue.zoom_ai_enabled || false, - require_ai_summary_approval: formValue.require_ai_summary_approval || false, - ai_summary_access: formValue.ai_summary_access || 'PCC', - recording_access: formValue.recording_access || 'Members', - recurrence: recurrenceObject, - }; - - const operation = this.isEditing() - ? this.meetingService.updateMeeting(this.meetingId()!, baseMeetingData as UpdateMeetingRequest, this.editType()) - : this.meetingService.createMeeting(baseMeetingData as CreateMeetingRequest); - - operation.subscribe({ - next: (meeting) => { - // If we have pending attachments and this is a new meeting, save them to the database - if (!this.isEditing() && this.pendingAttachments().length > 0) { - this.savePendingAttachments(meeting.id).subscribe({ - next: () => { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: `Meeting created successfully with ${this.pendingAttachments().length} attachment(s)`, - }); - this.dialogRef.close(meeting); - }, - 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.dialogRef.close(meeting); - }, - }); - } else { - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: `Meeting ${this.isEditing() ? 'updated' : 'created'} successfully`, - }); - this.dialogRef.close(meeting); - } - }, - error: (error) => { - console.error('Error saving meeting:', error); - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: `Failed to ${this.isEditing() ? 'update' : 'create'} meeting. Please try again.`, - }); - this.submitting.set(false); - }, - }); - } - - public onCancel(): void { - this.dialogRef.close(); - } - - public onFileSelect(event: any): void { - // Handle different possible event structures - 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 if (event.target && event.target.files) { - files = Array.from(event.target.files); - } else { - console.error('Could not extract files from event:', event); - return; - } - - if (!files || files.length === 0) { - return; - } - - // Validate each file before processing - files.forEach((file) => { - const validationError = this.validateFile(file); - if (validationError) { - // Show validation error to user - this.messageService.add({ - severity: 'error', - summary: 'File Upload Error', - detail: validationError, - life: 5000, - }); - return; - } - - const pendingAttachment: PendingAttachment = { - id: crypto.randomUUID(), - fileName: file.name, - fileUrl: '', - fileSize: file.size, - mimeType: file.type, - uploading: true, - }; - - // Add to pending list with uploading status - 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({ - next: (result) => { - // Update the pending attachment with the uploaded URL - this.pendingAttachments.update((current) => - current.map((pa) => (pa.id === pendingAttachment.id ? { ...pa, fileUrl: result.url, uploading: false } : pa)) - ); - }, - error: (error) => { - // Update the pending attachment with error status - 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); - }, - }); - }); - } - - public removePendingAttachment(attachmentId: string): void { - this.pendingAttachments.update((current) => current.filter((pa) => pa.id !== attachmentId)); - } - - 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 (!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}.`; - } - - // 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 - } - - 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), - tap(() => { - // Clear pending attachments after successful save - this.pendingAttachments.set([]); - }), - tap(() => undefined) // Transform to void - ); - } - - // Private methods - - private createMeetingFormGroup(): FormGroup { - const defaultDateTime = this.getDefaultStartDateTime(); - - return new FormGroup( - { - // Basic info (using exact database field names) - topic: new FormControl('', [Validators.required, meetingTopicValidator()]), - agenda: new FormControl('', [meetingAgendaValidator()]), - meeting_type: new FormControl(''), - - // Date/Time fields (helper fields for form, will be combined into start_time) - startDate: new FormControl(defaultDateTime.date, [Validators.required]), - startTime: new FormControl(defaultDateTime.time, [Validators.required, timeFormatValidator()]), - duration: new FormControl(60, [Validators.required]), - customDuration: new FormControl('', [customDurationValidator()]), - timezone: new FormControl(getUserTimezone(), [Validators.required]), - early_join_time: new FormControl(10, [Validators.min(10), Validators.max(60)]), - - // Meeting settings (using exact database field names) - show_in_public_calendar: new FormControl(false), - restricted: new FormControl(false), - recording_enabled: new FormControl(false), - transcripts_enabled: new FormControl(false), - youtube_enabled: new FormControl(false), - zoom_ai_enabled: new FormControl(false), - require_ai_summary_approval: new FormControl(false), - ai_summary_access: new FormControl('PCC'), - - // Recurrence settings - recurrence: new FormControl('none'), - - // Recording access - recording_access: new FormControl('Members'), - - // Attachments - attachments: new FormControl([]), - }, - { validators: futureDateTimeValidator() } - ); - } - - private getYesterday(): Date { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); - return yesterday; - } - - private getDefaultStartDateTime(): { date: Date; time: string } { - const now = new Date(); - // Add 1 hour to current time - now.setHours(now.getHours() + 1); - - // Round up to next 15 minutes - const minutes = now.getMinutes(); - const roundedMinutes = Math.ceil(minutes / 15) * 15; - now.setMinutes(roundedMinutes); - now.setSeconds(0); - now.setMilliseconds(0); - - // If rounding pushed us to next hour, adjust accordingly - if (roundedMinutes === 60) { - now.setHours(now.getHours() + 1); - now.setMinutes(0); - } - - // Format time to 12-hour format (HH:MM AM/PM) - const hours = now.getHours(); - const mins = now.getMinutes(); - const period = hours >= 12 ? 'PM' : 'AM'; - let displayHours = hours > 12 ? hours - 12 : hours; - if (displayHours === 0) { - displayHours = 12; - } - const timeString = `${displayHours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')} ${period}`; - - return { - date: new Date(now), - time: timeString, - }; - } - - private updateRecurrenceOptions(date: Date): void { - const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const dayName = dayNames[date.getDay()]; - - // Calculate which occurrence of the day in the month (1st, 2nd, 3rd, 4th, or last) - const { weekOfMonth, isLastWeek } = this.getWeekOfMonth(date); - const ordinals = ['', '1st', '2nd', '3rd', '4th']; - const ordinal = ordinals[weekOfMonth] || `${weekOfMonth}th`; - - const options = [ - { label: 'Does not repeat', value: 'none' }, - { label: 'Daily', value: 'daily' }, - { label: `Weekly on ${dayName}`, value: 'weekly' }, - { label: 'Every weekday', value: 'weekdays' }, - ]; - - // If this is the last occurrence, show "Monthly on the last [day]" instead of "Monthly on the Nth [day]" - if (isLastWeek) { - options.splice(3, 0, { label: `Monthly on the last ${dayName}`, value: 'monthly_last' }); - } else { - options.splice(3, 0, { label: `Monthly on the ${ordinal} ${dayName}`, value: 'monthly_nth' }); - } - - this.recurrenceOptions.set(options); - } - - private getWeekOfMonth(date: Date): { weekOfMonth: number; isLastWeek: boolean } { - // Find the first occurrence of this day of week in the month - const targetDayOfWeek = date.getDay(); - let firstOccurrence = 1; - while (new Date(date.getFullYear(), date.getMonth(), firstOccurrence).getDay() !== targetDayOfWeek) { - firstOccurrence++; - } - - // Calculate which week this date is in - const weekOfMonth = Math.floor((date.getDate() - firstOccurrence) / 7) + 1; - - // Check if this is the last occurrence of this day in the month - const nextWeekDate = new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000); - const isLastWeek = nextWeekDate.getMonth() !== date.getMonth(); - - return { weekOfMonth, isLastWeek }; - } - - private generateRecurrenceObject(recurrenceType: string, startDate: Date): MeetingRecurrence | undefined { - if (recurrenceType === 'none') { - return undefined; - } - - const dayOfWeek = startDate.getDay() + 1; // Zoom API uses 1-7 (Sunday=1) - const { weekOfMonth } = this.getWeekOfMonth(startDate); - - switch (recurrenceType) { - case 'daily': - return { - type: RecurrenceType.DAILY, - repeat_interval: 1, - }; - - case 'weekly': - return { - type: RecurrenceType.WEEKLY, - repeat_interval: 1, - weekly_days: dayOfWeek.toString(), - }; - - case 'monthly_nth': - return { - type: RecurrenceType.MONTHLY, - repeat_interval: 1, - monthly_week: weekOfMonth, - monthly_week_day: dayOfWeek, - }; - - case 'monthly_last': - return { - type: RecurrenceType.MONTHLY, - repeat_interval: 1, - monthly_week: -1, - monthly_week_day: dayOfWeek, - }; - - case 'weekdays': - return { - type: RecurrenceType.WEEKLY, - repeat_interval: 1, - weekly_days: '2,3,4,5,6', // Monday through Friday - }; - - default: - return undefined; - } - } - - private initializeForm(): void { - if (this.isEditing() && this.meeting()) { - const meeting = this.meeting()!; - - // Parse start_time to separate date and time - let startDate = null; - let startTime = ''; - - if (meeting.start_time) { - const date = new Date(meeting.start_time); - startDate = date; - - // Update recurrence options based on the meeting date - this.updateRecurrenceOptions(date); - - // Convert to 12-hour format for display - const hours = date.getHours(); - const minutes = date.getMinutes(); - const period = hours >= 12 ? 'PM' : 'AM'; - let displayHours = hours > 12 ? hours - 12 : hours; - if (displayHours === 0) { - displayHours = 12; - } - startTime = `${displayHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')} ${period}`; - } - - // Map recurrence object back to form value - let recurrenceValue = 'none'; - if (meeting.recurrence) { - const rec = meeting.recurrence; - if (rec.type === RecurrenceType.DAILY) { - recurrenceValue = 'daily'; - } else if (rec.type === RecurrenceType.WEEKLY) { - recurrenceValue = rec.weekly_days === '2,3,4,5,6' ? 'weekdays' : 'weekly'; - } else if (rec.type === RecurrenceType.MONTHLY) { - recurrenceValue = rec.monthly_week === -1 ? 'monthly_last' : 'monthly_nth'; - } - } - - this.form().patchValue({ - topic: meeting.topic || '', - agenda: meeting.agenda || '', - meeting_type: meeting.meeting_type || 'None', - startDate: startDate, - startTime: startTime, - duration: meeting.duration || 60, - timezone: meeting.timezone || getUserTimezone(), - early_join_time: meeting.early_join_time || 10, - show_in_public_calendar: meeting.visibility === MeetingVisibility.PUBLIC, - restricted: meeting.restricted ?? false, - recording_enabled: meeting.recording_enabled || false, - transcripts_enabled: meeting.transcripts_enabled || false, - youtube_enabled: meeting.youtube_enabled || false, - zoom_ai_enabled: meeting.zoom_ai_enabled || false, - require_ai_summary_approval: meeting.require_ai_summary_approval ?? false, - ai_summary_access: meeting.ai_summary_access ?? 'PCC', - recording_access: meeting.recording_access ?? 'Members', - recurrence: recurrenceValue, - }); - } else { - // For new meetings, update recurrence options based on default date - const defaultDateTime = this.getDefaultStartDateTime(); - this.updateRecurrenceOptions(defaultDateTime.date); - } - - // Add custom duration validator when duration is 'custom' - this.form() - .get('duration') - ?.valueChanges.pipe(takeUntilDestroyed()) - .subscribe((value) => { - const customDurationControl = this.form().get('customDuration'); - if (value === 'custom') { - customDurationControl?.setValidators([Validators.required, customDurationValidator()]); - } else { - customDurationControl?.clearValidators(); - } - customDurationControl?.updateValueAndValidity(); - }); - - // Update recurrence options when start date changes - this.form() - .get('startDate') - ?.valueChanges.pipe(takeUntilDestroyed()) - .subscribe((date) => { - if (date) { - this.updateRecurrenceOptions(date); - // Reset recurrence selection to 'none' when date changes - this.form().get('recurrence')?.setValue('none'); - } - }); - } -} diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts index 1b1acc14..92a33cd5 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.ts @@ -500,9 +500,9 @@ export class MeetingManageComponent { // Step 3: Platform & Features meetingTool: new FormControl(DEFAULT_MEETING_TOOL, [Validators.required]), recording_enabled: new FormControl(false), - transcripts_enabled: new FormControl(false), - youtube_enabled: new FormControl(false), - zoom_ai_enabled: new FormControl(false), + transcripts_enabled: new FormControl({ value: false, disabled: true }), + youtube_enabled: new FormControl({ value: false, disabled: true }), + zoom_ai_enabled: new FormControl({ value: false, disabled: true }), require_ai_summary_approval: new FormControl(false), ai_summary_access: new FormControl(DEFAULT_AI_SUMMARY_ACCESS), recording_access: new FormControl(DEFAULT_RECORDING_ACCESS), @@ -535,23 +535,19 @@ export class MeetingManageComponent { const form = this.form(); const meetingType = form.get('meeting_type')?.value; const startDate = form.get('startDate')?.value; + const project = this.projectService.project(); - // Only auto-generate if we have meeting type and the title is empty + // Only auto-generate if we have meeting type, start date, and the title is empty const currentTitle = form.get('topic')?.value; - if (meetingType && (!currentTitle || currentTitle.trim() === '')) { - const formattedDate = startDate - ? new Date(startDate).toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - year: 'numeric', - }) - : new Date().toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - year: 'numeric', - }); + if (meetingType && startDate && (!currentTitle || currentTitle.trim() === '')) { + const formattedDate = new Date(startDate).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + }); - const generatedTitle = `${meetingType} Meeting - ${formattedDate}`; + const projectSlug = project?.slug?.toUpperCase() || ''; + const generatedTitle = `${projectSlug} ${meetingType} Meeting - ${formattedDate}`; form.get('topic')?.setValue(generatedTitle); } } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.html index 8c5e5e1b..8ed5507b 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.html @@ -9,39 +9,18 @@

Meeting Platform & Feature -
-

Meeting Platform *

- -
- @for (platform of platformOptions; track platform.value) { -
- @if (!platform.available) { -
- Coming Soon -
- } +
+ -
-
- -
-
-

{{ platform.label }}

-

{{ platform.description }}

-
-
-
- } -
+ + @if (form().get('meetingTool')?.errors?.['required'] && form().get('meetingTool')?.touched) {

Please select a meeting platform

diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.ts index 7d3df46d..83397d44 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-platform-features/meeting-platform-features.component.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, input } from '@angular/core'; +import { Component, DestroyRef, inject, input, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { SelectComponent } from '@components/select/select.component'; import { ToggleComponent } from '@components/toggle/toggle.component'; @@ -15,7 +16,8 @@ import { TooltipModule } from 'primeng/tooltip'; imports: [CommonModule, ReactiveFormsModule, SelectComponent, ToggleComponent, TooltipModule], templateUrl: './meeting-platform-features.component.html', }) -export class MeetingPlatformFeaturesComponent { +export class MeetingPlatformFeaturesComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); // Form group input from parent public readonly form = input.required(); @@ -25,11 +27,39 @@ export class MeetingPlatformFeaturesComponent { public readonly recordingAccessOptions = RECORDING_ACCESS_OPTIONS; public readonly aiSummaryAccessOptions = AI_SUMMARY_ACCESS_OPTIONS; - public selectPlatform(platform: string): void { - const platformOption = this.platformOptions.find((p) => p.value === platform); - if (platformOption?.available) { - this.form().get('meetingTool')?.setValue(platform); - } + // Transform platforms into dropdown options (only available platforms) + public readonly platformDropdownOptions = MEETING_PLATFORMS.map((platform) => ({ + label: platform.available ? platform.label : `${platform.label} (Coming Soon)`, + value: platform.value, + icon: platform.icon, + description: platform.description, + disabled: !platform.available, + })); + + public ngOnInit(): void { + // Watch for recording_enabled changes to disable dependent features + this.form() + .get('recording_enabled') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((recordingEnabled: boolean) => { + const dependentControls = ['transcripts_enabled', 'zoom_ai_enabled', 'youtube_enabled']; + + dependentControls.forEach((controlName) => { + const control = this.form().get(controlName); + if (control) { + if (!recordingEnabled) { + // Disable and reset dependent features when recording is disabled + control.setValue(false); + control.disable(); + } else { + // Re-enable dependent features when recording is enabled + control.enable(); + } + + control.updateValueAndValidity(); + } + }); + }); } public toggleFeature(featureKey: string, enabled: boolean): void { diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts index 73326ab5..516b88bd 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts @@ -261,7 +261,7 @@ export class MeetingDashboardComponent { const project = this.project(); return [ { - label: 'Schedule Meeting', + label: 'Create Meeting', icon: 'fa-light fa-calendar-plus text-sm', routerLink: project ? `/project/${project.slug}/meetings/create` : '#', }, diff --git a/apps/lfx-pcc/src/app/shared/components/textarea/textarea.component.ts b/apps/lfx-pcc/src/app/shared/components/textarea/textarea.component.ts index 360deea8..f5b31441 100644 --- a/apps/lfx-pcc/src/app/shared/components/textarea/textarea.component.ts +++ b/apps/lfx-pcc/src/app/shared/components/textarea/textarea.component.ts @@ -21,7 +21,7 @@ export class TextareaComponent { public id = input(); public readonly = input(false); public styleClass = input(); - public autoResize = input(true); + public autoResize = input(false); public maxlength = input(); public dataTest = input(); } diff --git a/apps/lfx-pcc/src/app/shared/services/meeting.service.ts b/apps/lfx-pcc/src/app/shared/services/meeting.service.ts index eb9879d8..14eb1d4f 100644 --- a/apps/lfx-pcc/src/app/shared/services/meeting.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/meeting.service.ts @@ -3,7 +3,16 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { CreateMeetingRequest, Meeting, MeetingAttachment, MeetingParticipant, UpdateMeetingRequest, UploadFileResponse } from '@lfx-pcc/shared/interfaces'; +import { + CreateMeetingRequest, + GenerateAgendaRequest, + GenerateAgendaResponse, + Meeting, + MeetingAttachment, + MeetingParticipant, + UpdateMeetingRequest, + UploadFileResponse, +} from '@lfx-pcc/shared/interfaces'; import { catchError, defer, Observable, of, switchMap, take, tap, throwError } from 'rxjs'; @Injectable({ @@ -245,6 +254,16 @@ export class MeetingService { ); } + public generateAgenda(request: GenerateAgendaRequest): Observable { + return this.http.post('/api/meetings/generate-agenda', request).pipe( + take(1), + catchError((error) => { + console.error('Failed to generate meeting agenda:', error); + throw error; + }) + ); + } + private readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/lfx-pcc/src/app/shared/services/project.service.ts b/apps/lfx-pcc/src/app/shared/services/project.service.ts index 070460ac..c3c6f103 100644 --- a/apps/lfx-pcc/src/app/shared/services/project.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/project.service.ts @@ -17,8 +17,7 @@ export class ProjectService { public getProjects(params?: HttpParams): Observable { return this.http.get('/api/projects', { params }).pipe( - catchError((error) => { - console.error('Failed to load projects:', error); + catchError(() => { return of([]); }) ); @@ -26,8 +25,7 @@ export class ProjectService { public getProject(slug: string): Observable { return this.http.get(`/api/projects/${slug}`).pipe( - catchError((error) => { - console.error(`Failed to load project ${slug}:`, error); + catchError(() => { return of(null); }), tap((project) => { diff --git a/apps/lfx-pcc/src/index.html b/apps/lfx-pcc/src/index.html index d1915d4c..ba247772 100644 --- a/apps/lfx-pcc/src/index.html +++ b/apps/lfx-pcc/src/index.html @@ -10,6 +10,16 @@ + diff --git a/apps/lfx-pcc/src/server/routes/meetings.ts b/apps/lfx-pcc/src/server/routes/meetings.ts index 887a9c8f..83c32c67 100644 --- a/apps/lfx-pcc/src/server/routes/meetings.ts +++ b/apps/lfx-pcc/src/server/routes/meetings.ts @@ -4,11 +4,13 @@ import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES, sanitizeFilename } from '@lfx-pcc/shared'; import { NextFunction, Request, Response, Router } from 'express'; +import { AiService } from '../services/ai.service'; import { SupabaseService } from '../services/supabase.service'; const router = Router(); const supabaseService = new SupabaseService(); +const aiService = new AiService(); router.get('/', async (req: Request, res: Response, next: NextFunction) => { const startTime = Date.now(); @@ -762,4 +764,62 @@ router.get('/:id/attachments', async (req: Request, res: Response, next: NextFun } }); +// AI agenda generation endpoint +router.post('/generate-agenda', async (req: Request, res: Response, next: NextFunction) => { + const startTime = Date.now(); + + req.log.info( + { + operation: 'generate_agenda', + meeting_type: req.body['meetingType'], + has_context: !!req.body['context'], + }, + 'Starting AI agenda generation request' + ); + + try { + const { meetingType, title, projectName, context } = req.body; + + // Validate required fields + if (!meetingType || !title || !projectName) { + return res.status(400).json({ + error: 'Missing required fields: meetingType, title, and projectName are required', + }); + } + + const response = await aiService.generateMeetingAgenda({ + meetingType, + title, + projectName, + context, + }); + + const duration = Date.now() - startTime; + + req.log.info( + { + operation: 'generate_agenda', + duration, + estimated_duration: response.estimatedDuration, + status_code: 200, + }, + 'Successfully generated meeting agenda' + ); + + return res.json(response); + } catch (error) { + const duration = Date.now() - startTime; + req.log.error( + { + error: error instanceof Error ? error.message : error, + operation: 'generate_agenda', + duration, + meeting_type: req.body['meetingType'], + }, + 'Failed to generate meeting agenda' + ); + return next(error); + } +}); + export default router; diff --git a/apps/lfx-pcc/src/server/services/ai.service.ts b/apps/lfx-pcc/src/server/services/ai.service.ts new file mode 100644 index 00000000..1b7f4968 --- /dev/null +++ b/apps/lfx-pcc/src/server/services/ai.service.ts @@ -0,0 +1,191 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { AI_AGENDA_SYSTEM_PROMPT, AI_MODEL, AI_REQUEST_CONFIG, DURATION_ESTIMATION } from '@lfx-pcc/shared/constants'; +import { MeetingType } from '@lfx-pcc/shared/enums'; +import { GenerateAgendaRequest, GenerateAgendaResponse, OpenAIChatRequest, OpenAIChatResponse } from '@lfx-pcc/shared/interfaces'; + +import { serverLogger } from '../server'; + +export class AiService { + private readonly aiProxyUrl: string; + private readonly model = AI_MODEL; + private readonly aiKey = process.env['AI_API_KEY'] || 'sk-proj-1234567890'; + + public constructor() { + this.aiProxyUrl = process.env['AI_PROXY_URL'] || 'https://api.openai.com/v1/chat/completions'; + if (!this.aiProxyUrl) { + throw new Error('AI_PROXY_URL environment variable is required'); + } + + if (!this.aiKey) { + throw new Error('AI_API_KEY environment variable is required'); + } + } + + public async generateMeetingAgenda(request: GenerateAgendaRequest): Promise { + try { + serverLogger.info('Generating meeting agenda', { + meetingType: request.meetingType, + title: request.title, + hasContext: !!request.context, + projectName: request.projectName, + }); + + const prompt = this.buildPrompt(request); + const chatRequest: OpenAIChatRequest = { + model: this.model, + messages: [ + { + role: 'system', + content: AI_AGENDA_SYSTEM_PROMPT, + }, + { + role: 'user', + content: prompt, + }, + ], + max_tokens: AI_REQUEST_CONFIG.MAX_TOKENS, + temperature: AI_REQUEST_CONFIG.TEMPERATURE, + response_format: { + type: 'json_schema', + json_schema: { + name: 'meeting_agenda', + description: 'Generated meeting agenda with estimated duration', + schema: { + type: 'object', + properties: { + agenda: { + type: 'string', + description: 'Well-structured meeting agenda with time allocations and clear objectives', + }, + duration: { + type: 'number', + description: 'Total estimated meeting duration in minutes', + }, + }, + required: ['agenda', 'duration'], + additionalProperties: false, + }, + }, + strict: true, + }, + }; + + const response = await this.makeAiRequest(chatRequest); + const result = this.extractAgendaAndDuration(response); + + serverLogger.info('Successfully generated meeting agenda', { + estimatedDuration: result.estimatedDuration, + }); + + return result; + } catch (error) { + serverLogger.error('Failed to generate meeting agenda', { error }); + throw new Error('Failed to generate meeting agenda'); + } + } + + private buildPrompt(request: GenerateAgendaRequest): string { + let prompt = `Generate a meeting agenda for a ${this.getMeetingTypeDescription(request.meetingType)} meeting`; + prompt += ` titled "${request.title}" for the ${request.projectName} project.`; + + if (request.context) { + prompt += ` Additional context: ${request.context}`; + } + + prompt += '\n\nPlease create a professional, well-structured agenda that includes appropriate time allocations and clear objectives for each item.'; + + return prompt; + } + + private getMeetingTypeDescription(meetingType: MeetingType): string { + switch (meetingType) { + case MeetingType.BOARD: + return 'board governance'; + case MeetingType.MAINTAINERS: + return 'maintainers/technical steering committee'; + case MeetingType.MARKETING: + return 'marketing and community outreach'; + case MeetingType.TECHNICAL: + return 'technical working group'; + case MeetingType.LEGAL: + return 'legal and compliance'; + case MeetingType.OTHER: + return 'project team'; + case MeetingType.NONE: + return 'general project'; + default: + return 'project team'; + } + } + + private async makeAiRequest(request: OpenAIChatRequest): Promise { + const response = await fetch(this.aiProxyUrl, { + method: 'POST', + headers: { + ['Content-Type']: 'application/json', + ['Authorization']: `Bearer ${this.aiKey}`, + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`AI request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + return response.json(); + } + + private extractAgendaAndDuration(response: OpenAIChatResponse): GenerateAgendaResponse { + if (!response.choices || response.choices.length === 0) { + throw new Error('No agenda generated'); + } + + const content = response.choices[0].message.content; + + if (!content || content.trim().length === 0) { + throw new Error('Empty agenda generated'); + } + + try { + // Parse the JSON response + const parsed = JSON.parse(content.trim()); + + if (!parsed.agenda || typeof parsed.agenda !== 'string') { + throw new Error('Invalid agenda format in response'); + } + + if (!parsed.duration || typeof parsed.duration !== 'number') { + throw new Error('Invalid duration format in response'); + } + + // Cap duration between minimum and maximum limits + const cappedDuration = Math.max(DURATION_ESTIMATION.MINIMUM_DURATION, Math.min(parsed.duration, DURATION_ESTIMATION.MAXIMUM_DURATION)); + + return { + agenda: parsed.agenda.trim(), + estimatedDuration: cappedDuration, + }; + } catch (parseError) { + serverLogger.warn('Failed to parse JSON response, falling back to text extraction', { + content: content.substring(0, 100), + error: parseError, + }); + + // Fallback to treating the entire content as agenda with heuristic duration + const lines = content.split('\n').filter((line) => line.trim().length > 0); + const estimatedItems = lines.filter((line) => line.match(/^[#\-*\d]/)).length; + const fallbackDuration = DURATION_ESTIMATION.BASE_DURATION + estimatedItems * DURATION_ESTIMATION.TIME_PER_ITEM; + + // Cap fallback duration between minimum and maximum limits + const cappedFallbackDuration = Math.max(DURATION_ESTIMATION.MINIMUM_DURATION, Math.min(fallbackDuration, DURATION_ESTIMATION.MAXIMUM_DURATION)); + + return { + agenda: content.trim(), + estimatedDuration: cappedFallbackDuration, + }; + } + } +} diff --git a/docs/architecture/backend/README.md b/docs/architecture/backend/README.md index a600e5a9..bcfb1bbc 100644 --- a/docs/architecture/backend/README.md +++ b/docs/architecture/backend/README.md @@ -41,6 +41,20 @@ Request → Controller → Service → Microservice/Data Layer - **CRUD Operations**: Create, Read, Update, Delete with ETag concurrency control - **Query Service Integration**: Integration with LFX Query Service microservice +#### AI Integration Service + +- **AI Service**: Claude Sonnet integration for meeting agenda generation +- **LiteLLM Proxy**: OpenAI-compatible API proxy for AI model access +- **JSON Schema Validation**: Strict response validation with fallback parsing +- **Meeting API Integration**: Protected endpoints for AI-powered features + +#### NATS Messaging Service + +- **NATS Service**: High-performance inter-service messaging integration +- **Project Slug Resolution**: Real-time project lookup via request-reply pattern +- **Lazy Connection Management**: On-demand connection with automatic reconnection +- **Kubernetes Service Discovery**: Native cluster DNS integration for NATS access + #### Authentication & Session Management - **Auth0 Integration**: OpenID Connect with session-based authentication @@ -61,6 +75,14 @@ Understand Auth0 integration, JWT handling, and user session management. Explore Winston logging configuration, structured logging, and monitoring strategies. +### [AI Service](./ai-service.md) + +Learn about AI integration, Claude Sonnet model configuration, and meeting agenda generation. + +### [NATS Integration](./nats-integration.md) + +Understand NATS messaging integration, project slug resolution, and inter-service communication. + ### [Deployment](../../deployment.md) Discover PM2 configuration, production deployment, and server management. @@ -81,6 +103,7 @@ Discover PM2 configuration, production deployment, and server management. - **Structured Logging**: Pino with request correlation, timing, and sensitive data redaction - **Process Management**: PM2 for production deployment with health monitoring - **Health Monitoring**: Built-in health check endpoints with detailed system status +- **AI Integration**: Claude Sonnet model integration via LiteLLM proxy for intelligent features ### Development & Quality @@ -221,7 +244,11 @@ apps/lfx-pcc/src/server/ │ ├── committee.service.ts │ ├── etag.service.ts │ ├── api-client.service.ts -│ └── microservice-proxy.service.ts +│ ├── microservice-proxy.service.ts +│ ├── ai.service.ts +│ ├── nats.service.ts +│ ├── project.service.ts +│ └── supabase.service.ts ├── helpers/ # Utility classes │ ├── logger.ts # Standardized logging │ ├── responder.ts # Response formatting diff --git a/docs/architecture/backend/ai-service.md b/docs/architecture/backend/ai-service.md new file mode 100644 index 00000000..291acdf1 --- /dev/null +++ b/docs/architecture/backend/ai-service.md @@ -0,0 +1,340 @@ +# AI Service Integration + +## 🤖 Overview + +The LFX PCC AI Service provides intelligent meeting agenda generation using **Claude Sonnet 4** through a **LiteLLM proxy**. The service integrates with the meeting creation workflow to automatically generate professional, structured meeting agendas based on meeting type, context, and project information. + +## 🏗 Architecture + +### Service Architecture + +```text +Frontend Request → Meeting API → AI Service → LiteLLM Proxy → Claude Sonnet 4 + ↓ ↓ ↓ ↓ + Angular Express.js Business OpenAI-Compatible + Service Controller Logic Proxy +``` + +### Core Components + +- **AI Service** (`/server/services/ai.service.ts`): Core business logic for AI integration +- **Meeting API** (`/server/routes/meetings.ts`): HTTP endpoints for AI-powered features +- **LiteLLM Proxy**: OpenAI-compatible proxy for Claude Sonnet model access +- **Shared Interfaces** (`@lfx-pcc/shared`): Type-safe request/response contracts + +## 🔧 Implementation Details + +### AI Service Configuration + +```typescript +export class AiService { + private readonly aiProxyUrl: string; + private readonly model = AI_MODEL; // 'us.anthropic.claude-sonnet-4-20250514-v1:0' + private readonly aiKey = process.env['AI_API_KEY'] || ''; + + public constructor() { + this.aiProxyUrl = process.env['AI_PROXY_URL'] || ''; + if (!this.aiProxyUrl || !this.aiKey) { + throw new Error('AI configuration environment variables are required'); + } + } +} +``` + +### Request/Response Schema + +#### Generate Agenda Request + +```typescript +export interface GenerateAgendaRequest { + meetingType: MeetingType; // Meeting type enum (BOARD, TECHNICAL, etc.) + title: string; // Meeting title/topic + projectName: string; // Project name for context + context?: string; // Additional context from user +} +``` + +#### Generate Agenda Response + +```typescript +export interface GenerateAgendaResponse { + agenda: string; // Generated meeting agenda content + estimatedDuration: number; // Estimated meeting duration in minutes +} +``` + +### JSON Schema Validation + +The service uses strict JSON schema validation to ensure reliable AI responses: + +```typescript +response_format: { + type: 'json_schema', + json_schema: { + name: 'meeting_agenda', + description: 'Generated meeting agenda with estimated duration', + schema: { + type: 'object', + properties: { + agenda: { + type: 'string', + description: 'Well-structured meeting agenda with time allocations' + }, + duration: { + type: 'number', + description: 'Total estimated meeting duration in minutes' + } + }, + required: ['agenda', 'duration'], + additionalProperties: false + } + }, + strict: true +} +``` + +## 🚀 API Endpoints + +### Generate Meeting Agenda + +**Endpoint**: `POST /api/meetings/generate-agenda` + +**Authentication**: Required (Bearer token) + +**Request Body**: + +```json +{ + "meetingType": "TECHNICAL", + "title": "Q1 Architecture Review", + "projectName": "LFX Platform", + "context": "Review microservices architecture and discuss scaling plans" +} +``` + +**Response**: + +```json +{ + "agenda": "**Meeting Objective**: Q1 Architecture Review\n\n**Agenda Items**:\n1. **System Overview** (10 min)...", + "estimatedDuration": 60 +} +``` + +**Error Responses**: + +- `400 Bad Request`: Missing required fields +- `401 Unauthorized`: Invalid or missing authentication +- `500 Internal Server Error`: AI service failure + +## 🔐 Security & Authentication + +### API Protection + +```typescript +// Bearer token middleware applied to all API routes +app.use('/api', extractBearerToken); + +// Protected meeting agenda endpoint +router.post('/generate-agenda', async (req: Request, res: Response, next: NextFunction) => { + // Validates authentication before processing +}); +``` + +### Data Sanitization + +- **Request Logging**: Sensitive data automatically redacted from logs +- **Input Validation**: All user inputs validated before AI processing +- **Response Filtering**: AI responses validated against strict schema + +## 🛠 Configuration + +### Environment Variables + +```bash +# AI Service Configuration +AI_PROXY_URL={{https://lite-llm-url}}/chat/completions +AI_API_KEY=your-ai-api-key +``` + +### Model Configuration + +```typescript +// AI Constants (/packages/shared/src/constants/ai.constants.ts) +export const AI_MODEL = 'us.anthropic.claude-sonnet-4-20250514-v1:0'; + +export const AI_REQUEST_CONFIG = { + MAX_TOKENS: 4000, + TEMPERATURE: 0.3, +}; + +export const DURATION_ESTIMATION = { + BASE_DURATION: 30, // Base meeting duration in minutes + TIME_PER_ITEM: 5, // Additional time per agenda item + MINIMUM_DURATION: 15, // Minimum meeting duration +}; +``` + +### System Prompt + +```typescript +export const AI_AGENDA_SYSTEM_PROMPT = `You are an expert meeting facilitator for open source projects. +Generate professional, well-structured meeting agendas that include: + +1. Clear objectives and expected outcomes +2. Time allocations for each agenda item +3. Appropriate agenda items based on meeting type +4. Consideration for open source project governance + +You must respond with a valid JSON object containing: +- agenda: A detailed meeting agenda with markdown formatting +- duration: Total estimated duration in minutes (15-240 range) + +Focus on transparency, collaboration, and effective time management.`; +``` + +## 🔄 Integration Workflow + +### Frontend Integration + +```typescript +// Meeting Form Component +public async generateAiAgenda(): Promise { + const request: GenerateAgendaRequest = { + meetingType: this.form().get('meeting_type')?.value, + title: this.form().get('topic')?.value, + projectName: this.projectService.project()?.name, + context: this.form().get('aiPrompt')?.value + }; + + this.meetingService.generateAgenda(request) + .subscribe(response => { + // Set generated agenda and duration + this.form().get('agenda')?.setValue(response.agenda); + this.setAiEstimatedDuration(response.estimatedDuration); + }); +} +``` + +### Backend Processing + +```typescript +// AI Service Implementation +public async generateMeetingAgenda(request: GenerateAgendaRequest): Promise { + const prompt = this.buildPrompt(request); + const chatRequest: OpenAIChatRequest = { + model: this.model, + messages: [ + { role: 'system', content: AI_AGENDA_SYSTEM_PROMPT }, + { role: 'user', content: prompt } + ], + response_format: { /* JSON schema */ }, + max_tokens: AI_REQUEST_CONFIG.MAX_TOKENS, + temperature: AI_REQUEST_CONFIG.TEMPERATURE + }; + + const response = await this.makeAiRequest(chatRequest); + return this.extractAgendaAndDuration(response); +} +``` + +## 🔍 Error Handling & Fallbacks + +### Response Parsing Strategy + +1. **Primary**: JSON schema with strict validation +2. **Fallback**: Parse JSON manually if schema fails +3. **Emergency**: Extract plain text with heuristic duration estimation + +```typescript +private extractAgendaAndDuration(response: OpenAIChatResponse): GenerateAgendaResponse { + try { + // Primary: Parse strict JSON schema response + const parsed = JSON.parse(content.trim()); + return { + agenda: parsed.agenda.trim(), + estimatedDuration: parsed.duration + }; + } catch (parseError) { + // Fallback: Extract text with estimated duration + const lines = content.split('\n').filter(line => line.trim().length > 0); + const estimatedItems = lines.filter(line => line.match(/^[#\-*\d]/)).length; + const fallbackDuration = DURATION_ESTIMATION.BASE_DURATION + + estimatedItems * DURATION_ESTIMATION.TIME_PER_ITEM; + + return { + agenda: content.trim(), + estimatedDuration: Math.max(DURATION_ESTIMATION.MINIMUM_DURATION, fallbackDuration) + }; + } +} +``` + +### Logging & Monitoring + +```typescript +// Request logging with context +serverLogger.info('Generating meeting agenda', { + meetingType: request.meetingType, + title: request.title, + hasContext: !!request.context, + projectName: request.projectName, +}); + +// Success logging with metrics +serverLogger.info('Successfully generated meeting agenda', { + estimatedDuration: result.estimatedDuration, +}); + +// Error logging with details +serverLogger.error('Failed to generate meeting agenda', { error }); +``` + +## 🎯 Best Practices + +### Development Guidelines + +1. **Input Validation**: Always validate requests before AI processing +2. **Error Handling**: Implement comprehensive error handling with fallbacks +3. **Logging**: Log all AI operations with appropriate detail levels +4. **Rate Limiting**: Consider implementing rate limiting for AI endpoints +5. **Caching**: Cache responses for identical requests to reduce API costs + +### Security Considerations + +1. **Authentication**: All AI endpoints require valid authentication +2. **Input Sanitization**: Sanitize user inputs to prevent prompt injection +3. **Response Validation**: Validate AI responses against expected schemas +4. **API Key Management**: Secure storage and rotation of AI API keys +5. **Audit Logging**: Log all AI requests for audit and debugging purposes + +## 📊 Performance & Monitoring + +### Key Metrics + +- **Response Time**: AI request/response latency +- **Success Rate**: Percentage of successful agenda generations +- **Error Rate**: Rate of AI service failures and fallbacks +- **Token Usage**: API token consumption for cost monitoring + +### Health Monitoring + +```typescript +// Health check integration +app.get('/api/health', (req, res) => { + const healthStatus = { + ai_service: { + configured: !!process.env['AI_PROXY_URL'] && !!process.env['AI_API_KEY'], + model: AI_MODEL, + }, + }; + res.json(healthStatus); +}); +``` + +## 🔗 Related Documentation + +- [Backend Architecture Overview](./README.md) +- [Meeting API Routes](../../CLAUDE.md#api-routes) +- [Shared Interfaces](../shared/package-architecture.md) +- [Environment Configuration](../../deployment.md#environment-variables) diff --git a/docs/architecture/backend/nats-integration.md b/docs/architecture/backend/nats-integration.md new file mode 100644 index 00000000..a80597b4 --- /dev/null +++ b/docs/architecture/backend/nats-integration.md @@ -0,0 +1,404 @@ +# NATS Integration + +## 🚀 Overview + +The LFX PCC application integrates with **NATS** (Neural Autonomic Transport System) for high-performance inter-service messaging within the LFX microservices ecosystem. NATS provides lightweight, publish-subscribe, and request-reply communication patterns for distributed systems. + +## 🏗 Architecture + +### NATS Integration Pattern + +```text +LFX PCC ←→ NATS Server ←→ LFX Microservices + ↓ ↓ ↓ + Client Message Project + Requests Broker Services +``` + +### Core Components + +- **NATS Service** (`/server/services/nats.service.ts`): Core NATS client implementation +- **Project Service Integration**: Uses NATS for project slug resolution +- **Lazy Connection Management**: On-demand connection with automatic reconnection +- **Request-Reply Pattern**: Synchronous communication with timeout handling + +## 🔧 Implementation Details + +### NATS Service Configuration + +```typescript +export class NatsService { + private connection: NatsConnection | null = null; + private connectionPromise: Promise | null = null; + private codec = StringCodec(); + + public constructor() { + // Lazy initialization - no immediate connection + } +} +``` + +### Connection Management + +#### Lazy Connection Strategy + +```typescript +private async ensureConnection(): Promise { + // Return existing connection if valid + if (this.connection && !this.connection.isClosed()) { + return this.connection; + } + + // If already connecting, wait for that connection + if (this.connectionPromise) { + return this.connectionPromise; + } + + // Create new connection with thread safety + this.connectionPromise = this.createConnection(); + + try { + this.connection = await this.connectionPromise; + return this.connection; + } finally { + this.connectionPromise = null; + } +} +``` + +#### Connection Configuration + +```typescript +private async createConnection(): Promise { + const natsUrl = process.env['NATS_URL'] || NATS_CONFIG.DEFAULT_SERVER_URL; + + const connection = await connect({ + servers: [natsUrl], + timeout: NATS_CONFIG.CONNECTION_TIMEOUT, // 5000ms + }); + + return connection; +} +``` + +## 📡 Message Subjects and Patterns + +### Defined Subjects + +```typescript +export enum NatsSubjects { + PROJECT_SLUG_TO_UID = 'lfx.projects-api.slug_to_uid', +} +``` + +### Request-Reply Pattern + +```typescript +public async getProjectIdBySlug(slug: string): Promise { + const connection = await this.ensureConnection(); + + const response = await connection.request( + NatsSubjects.PROJECT_SLUG_TO_UID, + this.codec.encode(slug), + { timeout: NATS_CONFIG.REQUEST_TIMEOUT } // 5000ms + ); + + const projectId = this.codec.decode(response.data); + + return { + projectId: projectId.trim(), + slug, + exists: projectId.trim() !== '' + }; +} +``` + +## 🔗 Service Integration + +### Project Service Integration + +```typescript +export class ProjectService { + private natsService: NatsService; + + public constructor() { + this.natsService = new NatsService(); + } + + public async getProjectBySlug(req: Request, slug: string): Promise { + // Use NATS to resolve project slug to ID + const { projectId, exists } = await this.natsService.getProjectIdBySlug(slug); + + if (!exists) { + throw createApiError({ + message: 'Project not found', + status: 404, + code: 'PROJECT_NOT_FOUND', + }); + } + + // Use resolved ID to fetch project details + return this.getProjectById(req, projectId); + } +} +``` + +## ⚙️ Configuration + +### Environment Variables + +```bash +# NATS Configuration +# Internal k8s service DNS for NATS cluster +NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222 +``` + +### NATS Configuration Constants + +```typescript +export const NATS_CONFIG = { + /** + * Default NATS server URL for Kubernetes cluster + */ + DEFAULT_SERVER_URL: 'nats://lfx-platform-nats.lfx.svc.cluster.local:4222', + + /** + * Connection timeout in milliseconds + */ + CONNECTION_TIMEOUT: 5000, + + /** + * Request timeout in milliseconds + */ + REQUEST_TIMEOUT: 5000, +} as const; +``` + +## 🔐 Security and Error Handling + +### Connection Security + +- **Kubernetes Service DNS**: Uses internal cluster DNS for secure communication +- **Network Policies**: Security enforced at Kubernetes network level +- **Connection Pooling**: Single connection per service instance +- **Service Mesh Integration**: Compatible with Istio/Linkerd if deployed + +### Error Handling Strategy + +```typescript +try { + const response = await connection.request(subject, data, { timeout }); + return this.processResponse(response); +} catch (error) { + // Handle timeout and no responder errors gracefully + if (error.message.includes('timeout') || error.message.includes('503')) { + serverLogger.info({ slug }, 'Project slug not found via NATS'); + return { exists: false, projectId: '', slug }; + } + + // Re-throw connection and other critical errors + throw error; +} +``` + +### Graceful Shutdown + +```typescript +public async shutdown(): Promise { + if (this.connection && !this.connection.isClosed()) { + serverLogger.info('Shutting down NATS connection'); + + try { + await this.connection.drain(); // Graceful shutdown + serverLogger.info('NATS connection closed successfully'); + } catch (error) { + serverLogger.error({ error }, 'Error during NATS shutdown'); + } + } + this.connection = null; +} +``` + +## 📊 Monitoring and Logging + +### Connection Monitoring + +```typescript +public isConnected(): boolean { + return this.connection !== null && !this.connection.isClosed(); +} +``` + +### Request Logging + +```typescript +// Success logging +serverLogger.info({ slug, project_id: projectId }, 'Successfully resolved project slug to ID'); + +// Error logging +serverLogger.error({ error, slug }, 'Failed to resolve project slug via NATS'); + +// Connection logging +serverLogger.info({ url: natsUrl }, 'Connecting to NATS server on demand'); +``` + +### Health Check Integration + +```typescript +// Health endpoint includes NATS status +app.get('/api/health', (req, res) => { + const healthStatus = { + nats: { + connected: natsService.isConnected(), + url: process.env['NATS_URL'] || NATS_CONFIG.DEFAULT_SERVER_URL, + }, + }; + res.json(healthStatus); +}); +``` + +## 🔧 Development and Troubleshooting + +### Local Development Setup + +Since NATS runs in the local Kubernetes cluster, the application connects directly using the Kubernetes service DNS: + +```bash +# Environment configuration for local development +NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222 +``` + +### Kubernetes Service Discovery + +The application leverages Kubernetes service discovery: + +- **Service Name**: `lfx-platform-nats` +- **Namespace**: `lfx` +- **Port**: `4222` +- **Full DNS**: `lfx-platform-nats.lfx.svc.cluster.local:4222` + +### Common Issues and Solutions + +#### 1. DNS Resolution Issues + +```text +Error: getaddrinfo ENOTFOUND lfx-platform-nats.lfx.svc.cluster.local +Cause: Kubernetes DNS not accessible or service not running +Solution: Verify NATS service is deployed and accessible in cluster +``` + +#### 2. Connection Timeout + +```text +Error: Failed to connect to NATS server +Cause: NATS server not responding or network issues +Solution: Check NATS pod status and network policies +``` + +#### 3. Request Timeout + +```text +Error: Request timeout on NATS subject +Cause: No service responding to the subject or high latency +Solution: Verify target microservice deployment and health +``` + +### Debugging Commands + +```bash +# Check NATS pod status +kubectl get pods -n lfx -l app=nats + +# Check NATS service +kubectl get svc -n lfx lfx-platform-nats + +# Check NATS logs +kubectl logs -n lfx deployment/lfx-platform-nats + +# Test NATS connectivity from within cluster +kubectl run nats-test --rm -i --tty --image=natsio/nats-box -- nats pub test "hello" +``` + +## 🎯 Best Practices + +### Performance Optimization + +1. **Lazy Connection**: Connect only when needed to reduce startup time +2. **Connection Reuse**: Single connection per service instance +3. **Request Timeouts**: Always use timeouts to prevent hanging requests +4. **Graceful Shutdown**: Properly drain connections on service shutdown + +### Error Handling + +1. **Timeout Handling**: Treat timeouts as "not found" for optional data +2. **Circuit Breaking**: Implement circuit breaker for degraded service scenarios +3. **Fallback Strategies**: Provide alternative data sources when NATS is unavailable +4. **Retry Logic**: Consider exponential backoff for transient failures + +### Code Quality + +1. **Type Safety**: Use TypeScript interfaces for message payloads +2. **Subject Constants**: Define subjects as enums to prevent typos +3. **Error Logging**: Log all NATS operations with appropriate context +4. **Unit Testing**: Mock NATS connections for comprehensive testing + +## 📈 Performance Metrics + +### Key Metrics to Monitor + +- **Connection Status**: Track connection health and reconnections +- **Request Latency**: Monitor request-reply response times +- **Error Rate**: Track timeout and connection failures +- **Message Volume**: Monitor message throughput and patterns + +### Request Performance Logging + +```typescript +// Request timing and metrics +const startTime = Date.now(); +try { + const response = await connection.request(subject, data, { timeout }); + const duration = Date.now() - startTime; + + serverLogger.info( + { + subject, + duration, + success: true, + slug, + }, + 'NATS request completed successfully' + ); + + return response; +} catch (error) { + const duration = Date.now() - startTime; + + serverLogger.error( + { + subject, + duration, + success: false, + error: error.message, + slug, + }, + 'NATS request failed' + ); + + throw error; +} +``` + +## 🔗 Related Documentation + +- [Backend Architecture Overview](./README.md) +- [Project Service Integration](../../CLAUDE.md#backend-stack) +- [Environment Configuration](../../deployment.md#environment-variables) +- [Microservice Proxy Service](./README.md#microservice-integration) + +## 📚 External Resources + +- [NATS.io Documentation](https://docs.nats.io/) +- [NATS TypeScript Client](https://github.com/nats-io/nats.js) +- [NATS Request-Reply Pattern](https://docs.nats.io/nats-concepts/reqreply) +- [Kubernetes Service Discovery](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/) diff --git a/docs/architecture/backend/ssr-server.md b/docs/architecture/backend/ssr-server.md index b2875df3..828aa444 100644 --- a/docs/architecture/backend/ssr-server.md +++ b/docs/architecture/backend/ssr-server.md @@ -24,6 +24,7 @@ import { extractBearerToken } from './middleware/auth-token.middleware'; import { apiErrorHandler } from './middleware/error-handler.middleware'; import { tokenRefreshMiddleware } from './middleware/token-refresh.middleware'; import projectsRouter from './routes/projects'; +import meetingsRouter from './routes/meetings'; dotenv.config(); @@ -106,6 +107,7 @@ app.use('/api', extractBearerToken); // Mount API routes before Angular SSR app.use('/api/projects', projectsRouter); +app.use('/api/meetings', meetingsRouter); // Add API error handler middleware app.use('/api/*', apiErrorHandler); @@ -271,8 +273,11 @@ src/server/ │ ├── auth-token.middleware.ts # Bearer token extraction │ ├── error-handler.middleware.ts # API error handling │ └── token-refresh.middleware.ts # Auth0 token refresh -└── routes/ - └── projects.ts # Project API routes +├── routes/ +│ ├── projects.ts # Project API routes +│ └── meetings.ts # Meeting API routes (including AI endpoints) +└── services/ + └── ai.service.ts # AI integration service ``` This Express.js configuration provides a robust foundation for serving Angular 19 SSR applications with proper security, performance, and development features. diff --git a/docs/deployment.md b/docs/deployment.md index 70a5cc62..85eb5c7f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -189,24 +189,46 @@ The application uses GitHub Actions for automated deployment to AWS ECS: ```bash # Application Configuration NODE_ENV=production -PORT=4200 -PM2=true -PCC_BASE_URL=https://pcc.lfx.dev -# Auth0 Authentication -PCC_AUTH0_CLIENT_ID=production-client-id -PCC_AUTH0_CLIENT_SECRET=production-client-secret -PCC_AUTH0_ISSUER_BASE_URL=https://linuxfoundation.auth0.com -PCC_AUTH0_AUDIENCE=https://api.lfx.dev -PCC_AUTH0_SECRET=production-secret +# Environment Configuration +ENV=development +PCC_BASE_URL=http://localhost:4200 +LOG_LEVEL=info + +# Auth0 Authentication Configuration +# Get these values from your Auth0 dashboard +PCC_AUTH0_CLIENT_ID=your-auth0-client-id +PCC_AUTH0_CLIENT_SECRET=your-auth0-client-secret +PCC_AUTH0_ISSUER_BASE_URL=https://auth.k8s.orb.local +PCC_AUTH0_AUDIENCE=http://lfx-api.k8s.orb.local/ +PCC_AUTH0_SECRET=sufficiently-long-string # Microservice Configuration -QUERY_SERVICE_URL=https://api.lfx.dev/query/resources -QUERY_SERVICE_TOKEN=production-jwt-token +# URL and JWT token for the query service +LFX_V2_SERVICE=http://lfx-api.k8s.orb.local -# Database Configuration (Supabase) +# Supabase Database Configuration +# Get these from your Supabase project settings SUPABASE_URL=https://your-project.supabase.co -POSTGRES_API_KEY=production-supabase-key +POSTGRES_API_KEY=your-supabase-anon-key +SUPABASE_STORAGE_BUCKET=your-supabase-bucket-name + +# NATS Configuration +# Internal k8s service DNS for NATS cluster +NATS_URL=nats://lfx-platform-nats.lfx.svc.cluster.local:4222 + +# AI Service Configuration +# OpenAI-compatible proxy for meeting agenda generation +AI_PROXY_URL=https://litellm.tools.lfx.dev/chat/completions +AI_API_KEY=your-ai-api-key + +# E2E Test Configuration (Optional) +# Test user credentials for automated testing +TEST_USERNAME=your-test-username +TEST_PASSWORD=your-test-password + +# LOCAL ONLY FOR AUTHELIA +NODE_TLS_REJECT_UNAUTHORIZED=0 ``` ## 🔧 Production Setup Steps diff --git a/packages/shared/src/constants/ai.constants.ts b/packages/shared/src/constants/ai.constants.ts new file mode 100644 index 00000000..8aa48dfb --- /dev/null +++ b/packages/shared/src/constants/ai.constants.ts @@ -0,0 +1,45 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * System prompt for AI meeting agenda generation + */ +export const AI_AGENDA_SYSTEM_PROMPT = `You are an expert meeting facilitator and agenda creator for open source software projects and organizations. Your role is to generate well-structured, comprehensive meeting agendas that promote productive discussions and clear outcomes. + +Key principles: +- Create agendas that are time-boxed and actionable +- Include clear objectives for each agenda item +- Structure items logically from administrative to strategic topics +- Ensure adequate time for discussion and decision-making +- Follow best practices for meeting facilitation + +You must respond with a valid JSON object in this exact format: +{ + "agenda": "string containing the agenda with clear section headers, time allocations, and action-oriented language", + "duration": "number representing the total estimated duration in minutes" +} + +The agenda should be well-structured plain text with time allocations for each item. The duration should be the sum of all time allocations plus any buffer time needed. Do not include any text outside the JSON object.`; + +/** + * AI model configuration + */ +export const AI_MODEL = 'us.anthropic.claude-sonnet-4-20250514-v1:0'; + +/** + * AI request configuration + */ +export const AI_REQUEST_CONFIG = { + MAX_TOKENS: 4000, + TEMPERATURE: 0.7, +}; + +/** + * Duration estimation configuration + */ +export const DURATION_ESTIMATION = { + BASE_DURATION: 15, // Opening/closing time in minutes + TIME_PER_ITEM: 10, // Average time per agenda item in minutes + MINIMUM_DURATION: 30, // Minimum meeting duration in minutes + MAXIMUM_DURATION: 240, // Maximum meeting duration in minutes (4 hours) +}; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 209359ac..4dc413cb 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +export * from './ai.constants'; export * from './api.constants'; export * from './colors.constants'; export * from './committees.constants'; diff --git a/packages/shared/src/interfaces/ai.interface.ts b/packages/shared/src/interfaces/ai.interface.ts new file mode 100644 index 00000000..7da897e6 --- /dev/null +++ b/packages/shared/src/interfaces/ai.interface.ts @@ -0,0 +1,82 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { MeetingType } from '../enums'; + +/** + * Request interface for AI agenda generation + */ +export interface GenerateAgendaRequest { + /** Type of meeting for agenda generation */ + meetingType: MeetingType; + /** Meeting title */ + title: string; + /** Name of the project for contextualized agenda */ + projectName: string; + /** Additional context or specific requirements */ + context?: string; +} + +/** + * Response interface for AI agenda generation + */ +export interface GenerateAgendaResponse { + /** Generated agenda content in markdown format */ + agenda: string; + /** AI-estimated duration in minutes (30-240 range) */ + estimatedDuration: number; +} + +/** + * OpenAI chat message interface + */ +export interface OpenAIChatMessage { + /** Role of the message sender */ + role: 'system' | 'user' | 'assistant'; + /** Content of the message */ + content: string; +} + +/** + * OpenAI chat completion request interface + */ +export interface OpenAIChatRequest { + /** Model identifier */ + model: string; + /** Array of chat messages */ + messages: OpenAIChatMessage[]; + /** Maximum tokens to generate */ + max_tokens?: number; + /** Sampling temperature for response variability */ + temperature?: number; + /** Response format specification */ + response_format?: { + /** Type of response format */ + type: 'text' | 'json_object' | 'json_schema'; + /** JSON schema definition when type is json_schema */ + json_schema?: { + /** Schema name */ + name: string; + /** Schema description */ + description?: string; + /** JSON schema definition */ + schema: Record; + }; + /** Strict mode for JSON schema validation */ + strict?: boolean; + }; +} + +/** + * OpenAI chat completion response interface + */ +export interface OpenAIChatResponse { + /** Array of response choices */ + choices: Array<{ + /** Generated message */ + message: { + /** Content of the generated message */ + content: string; + }; + }>; +} diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index a947a95e..e12a3c92 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -42,3 +42,6 @@ export * from './dashboard.interface'; // NATS interfaces export * from './nats.interface'; + +// AI interfaces +export * from './ai.interface'; diff --git a/packages/shared/src/utils/date-time.utils.ts b/packages/shared/src/utils/date-time.utils.ts index eda0f00f..257f36b1 100644 --- a/packages/shared/src/utils/date-time.utils.ts +++ b/packages/shared/src/utils/date-time.utils.ts @@ -2,18 +2,19 @@ // SPDX-License-Identifier: MIT import { fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { RecurrenceType } from '../enums'; -import { MeetingRecurrence } from '../interfaces'; + import { - TIME_ROUNDING_MINUTES, - WEEKDAY_CODES, + DAYS_IN_WEEK, DEFAULT_REPEAT_INTERVAL, MINUTES_IN_HOUR, - DAYS_IN_WEEK, MS_IN_DAY, + TIME_ROUNDING_MINUTES, + TimezoneOption, TIMEZONES, - type TimezoneOption, + WEEKDAY_CODES, } from '../constants'; +import { RecurrenceType } from '../enums'; +import { MeetingRecurrence } from '../interfaces'; // ============================================================================ // Date Formatting and Parsing Utilities @@ -100,12 +101,12 @@ export function combineDateTime(date: Date, time: string, timezone?: string): st // ============================================================================ /** - * Gets default start date and time (1 hour from now, rounded to next 15 minutes) + * Gets default start date and time (1 week from now, rounded to next 15 minutes) */ export function getDefaultStartDateTime(): { date: Date; time: string } { const now = new Date(); // Add 1 hour to current time - now.setHours(now.getHours() + 1); + now.setDate(now.getDate() + 7); // Round up to next 15 minutes const minutes = now.getMinutes();