diff --git a/apps/lfx-one/angular.json b/apps/lfx-one/angular.json index c04092e6..0bcd3da4 100644 --- a/apps/lfx-one/angular.json +++ b/apps/lfx-one/angular.json @@ -52,7 +52,16 @@ "ssr": { "entry": "src/server/server.ts" }, - "allowedCommonJsDependencies": ["@linuxfoundation/lfx-ui-core"], + "allowedCommonJsDependencies": [ + "@linuxfoundation/lfx-ui-core", + "combined-stream", + "mime-types", + "asynckit", + "hasown", + "es-set-tostringtag", + "safe-buffer", + "lodash.isempty" + ], "externalDependencies": ["snowflake-sdk"], "define": { "LAUNCHDARKLY_CLIENT_ID": "''" @@ -68,7 +77,7 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", + "maximumWarning": "6kB", "maximumError": "8kB" } ], @@ -92,7 +101,7 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", + "maximumWarning": "6kB", "maximumError": "8kB" } ], diff --git a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html index 3fb4c972..d6ccdc38 100644 --- a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.html @@ -37,7 +37,7 @@

{{ selectedFoundatio
- + diff --git a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.ts b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.ts index 617bd68c..9f283ed9 100644 --- a/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/board-member/board-member-dashboard.component.ts @@ -2,14 +2,16 @@ // SPDX-License-Identifier: MIT import { Component, computed, inject, Signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { Account } from '@lfx-one/shared/interfaces'; +import { Account, PendingActionItem } from '@lfx-one/shared/interfaces'; +import { catchError, of, switchMap } from 'rxjs'; import { SelectComponent } from '../../../shared/components/select/select.component'; import { AccountContextService } from '../../../shared/services/account-context.service'; import { FeatureFlagService } from '../../../shared/services/feature-flag.service'; import { ProjectContextService } from '../../../shared/services/project-context.service'; +import { ProjectService } from '../../../shared/services/project.service'; import { FoundationHealthComponent } from '../components/foundation-health/foundation-health.component'; import { MyMeetingsComponent } from '../components/my-meetings/my-meetings.component'; import { OrganizationInvolvementComponent } from '../components/organization-involvement/organization-involvement.component'; @@ -24,6 +26,7 @@ import { PendingActionsComponent } from '../components/pending-actions/pending-a export class BoardMemberDashboardComponent { private readonly accountContextService = inject(AccountContextService); private readonly projectContextService = inject(ProjectContextService); + private readonly projectService = inject(ProjectService); private readonly featureFlagService = inject(FeatureFlagService); public readonly form = new FormGroup({ @@ -32,11 +35,14 @@ export class BoardMemberDashboardComponent { public readonly availableAccounts: Signal = computed(() => this.accountContextService.availableAccounts); public readonly selectedFoundation = computed(() => this.projectContextService.selectedFoundation()); + public readonly selectedProject = computed(() => this.projectContextService.selectedProject() || this.projectContextService.selectedFoundation()); + public readonly boardMemberActions: Signal; // Feature flags protected readonly showOrganizationSelector = this.featureFlagService.getBooleanFlag('organization-selector', true); public constructor() { + // Handle account selection changes this.form .get('selectedAccountId') ?.valueChanges.pipe(takeUntilDestroyed()) @@ -46,5 +52,34 @@ export class BoardMemberDashboardComponent { this.accountContextService.setAccount(selectedAccount as Account); } }); + + // Initialize board member actions with reactive pattern + this.boardMemberActions = this.initializeBoardMemberActions(); + } + + private initializeBoardMemberActions(): Signal { + // Convert project signal to observable to react to changes (handles both project and foundation) + const project$ = toObservable(this.selectedProject); + + return toSignal( + project$.pipe( + switchMap((project) => { + // If no project/foundation selected, return empty array + if (!project?.slug) { + return of([]); + } + + // Fetch survey actions from API + return this.projectService.getPendingActionSurveys(project.slug).pipe( + catchError((error) => { + console.error('Failed to fetch survey actions:', error); + // Return empty array on error + return of([]); + }) + ); + }) + ), + { initialValue: [] } + ); } } diff --git a/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html b/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html index 06f2a762..efe9e8f0 100644 --- a/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html @@ -4,85 +4,94 @@
-

Pending Actions

- +

Pending Actions

-
- @for (item of pendingActions(); track item.text) { -
- -
-
-
- + @if (pendingActions().length > 0) { +
+ @for (item of pendingActions(); track $index) { +
+ +
+
+
+ +
+ + {{ item.type }} +
+ {{ item.badge }} + +
+ + +
+

- {{ item.type }} - + {{ item.text }} +

- - {{ item.badge }} - -
- -
-

- {{ item.text }} -

+ + @if (item.buttonLink) { + + {{ item.buttonText }} + + } @else { + + }
- - - -
- } -
+ } +
+ } @else { + +
+ No pending actions +
+ }
diff --git a/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.ts b/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.ts index fa0e5a7f..e84387d7 100644 --- a/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.ts @@ -2,46 +2,24 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, computed, inject, output } from '@angular/core'; -import { PersonaService } from '@app/shared/services/persona.service'; -import { ButtonComponent } from '@components/button/button.component'; -import { BOARD_MEMBER_ACTION_ITEMS, CORE_DEVELOPER_ACTION_ITEMS, MAINTAINER_ACTION_ITEMS } from '@lfx-one/shared/constants'; +import { Component, input, output } from '@angular/core'; import type { PendingActionItem } from '@lfx-one/shared/interfaces'; @Component({ selector: 'lfx-pending-actions', standalone: true, - imports: [CommonModule, ButtonComponent], + imports: [CommonModule], templateUrl: './pending-actions.component.html', styleUrl: './pending-actions.component.scss', }) export class PendingActionsComponent { - private readonly personaService = inject(PersonaService); - - public readonly actionClick = output(); - public readonly viewAll = output(); - /** - * Computed signal that returns action items based on the current persona + * Required input signal for pending action items */ - protected readonly pendingActions = computed(() => { - const persona = this.personaService.currentPersona(); + public readonly pendingActions = input.required(); - switch (persona) { - case 'maintainer': - return MAINTAINER_ACTION_ITEMS; - case 'board-member': - return BOARD_MEMBER_ACTION_ITEMS; - case 'core-developer': - default: - return CORE_DEVELOPER_ACTION_ITEMS; - } - }); - - public handleViewAll(): void { - this.viewAll.emit(); - } + public readonly actionClick = output(); protected handleActionClick(item: PendingActionItem): void { this.actionClick.emit(item); diff --git a/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.html b/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.html index c32e5951..49ee5b34 100644 --- a/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.html @@ -17,7 +17,7 @@

{{ selectedFoundatio
- + diff --git a/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.ts b/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.ts index 11f8e497..545e0d4e 100644 --- a/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/core-developer/core-developer-dashboard.component.ts @@ -1,8 +1,9 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { ProjectContextService } from '@app/shared/services/project-context.service'; +import { CORE_DEVELOPER_ACTION_ITEMS } from '@lfx-one/shared/constants'; import { MyMeetingsComponent } from '../components/my-meetings/my-meetings.component'; import { MyProjectsComponent } from '../components/my-projects/my-projects.component'; @@ -20,4 +21,5 @@ export class CoreDeveloperDashboardComponent { private readonly projectContextService = inject(ProjectContextService); public readonly selectedFoundation = computed(() => this.projectContextService.selectedFoundation()); + public readonly coreDevActions = signal(CORE_DEVELOPER_ACTION_ITEMS); } diff --git a/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.html b/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.html index 18386e7e..080dec43 100644 --- a/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.html +++ b/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.html @@ -17,7 +17,7 @@

{{ selectedProject()
- + diff --git a/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.ts b/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.ts index 7e568019..75a5b064 100644 --- a/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.ts +++ b/apps/lfx-one/src/app/modules/dashboards/maintainer/maintainer-dashboard.component.ts @@ -1,8 +1,9 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { ProjectContextService } from '@app/shared/services/project-context.service'; +import { MAINTAINER_ACTION_ITEMS } from '@lfx-one/shared/constants'; import { MyMeetingsComponent } from '../components/my-meetings/my-meetings.component'; import { MyProjectsComponent } from '../components/my-projects/my-projects.component'; @@ -20,4 +21,5 @@ export class MaintainerDashboardComponent { private readonly projectContextService = inject(ProjectContextService); public readonly selectedProject = computed(() => this.projectContextService.selectedFoundation() || this.projectContextService.selectedProject()); + public readonly maintainerActions = signal(MAINTAINER_ACTION_ITEMS); } diff --git a/apps/lfx-one/src/app/shared/components/header/header.component.ts b/apps/lfx-one/src/app/shared/components/header/header.component.ts index 94c68dc7..a552471d 100644 --- a/apps/lfx-one/src/app/shared/components/header/header.component.ts +++ b/apps/lfx-one/src/app/shared/components/header/header.component.ts @@ -9,7 +9,6 @@ import { Router, RouterModule } from '@angular/router'; import { AppService } from '@app/shared/services/app.service'; import { AvatarComponent } from '@components/avatar/avatar.component'; import { MenubarComponent } from '@components/menubar/menubar.component'; -import { PersonaSelectorComponent } from '@components/persona-selector/persona-selector.component'; import { CombinedProfile, Project } from '@lfx-one/shared/interfaces'; import { ProjectService } from '@services/project.service'; import { UserService } from '@services/user.service'; @@ -18,23 +17,12 @@ import { AutoCompleteCompleteEvent, AutoCompleteSelectEvent } from 'primeng/auto import { RippleModule } from 'primeng/ripple'; import { catchError, debounceTime, distinctUntilChanged, of, startWith, switchMap } from 'rxjs'; -import { AutocompleteComponent } from '../autocomplete/autocomplete.component'; import { MenuComponent } from '../menu/menu.component'; @Component({ selector: 'lfx-header', standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - MenubarComponent, - RippleModule, - RouterModule, - AvatarComponent, - MenuComponent, - AutocompleteComponent, - PersonaSelectorComponent, - ], + imports: [CommonModule, ReactiveFormsModule, MenubarComponent, RippleModule, RouterModule, AvatarComponent, MenuComponent], templateUrl: './header.component.html', styleUrl: './header.component.scss', schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts index fefe5cdb..2b70aa82 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts +++ b/apps/lfx-one/src/app/shared/components/meeting-card/meeting-card.component.ts @@ -252,9 +252,9 @@ export class MeetingCardComponent implements OnInit { const ref = this.dialogService.open(SummaryModalComponent, { header: 'Meeting Summary', width: '800px', - modal: true, - closable: true, - dismissableMask: true, + modal: false, + closable: false, + dismissableMask: false, data: { summaryContent: this.summaryContent(), summaryUid: this.summaryUid(), diff --git a/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html index d8ac3b78..40cbdaef 100644 --- a/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html +++ b/apps/lfx-one/src/app/shared/components/meeting-rsvp-details/meeting-rsvp-details.component.html @@ -45,14 +45,27 @@
- @if (acceptedCount() > 0) { -
- } - @if (!pastMeeting() && maybeCount() > 0) { -
- } - @if (!pastMeeting() && declinedCount() > 0) { -
+ @if (pastMeeting()) { + + @if (attendedCount() > 0) { +
+ } + @if (meetingRegistrantCount() - attendedCount() > 0) { +
+ } + } @else { + + @if (acceptedCount() > 0) { +
+ } + @if (maybeCount() > 0) { +
+ } + @if (declinedCount() > 0) { +
+ } }
diff --git a/apps/lfx-one/src/app/shared/services/project.service.ts b/apps/lfx-one/src/app/shared/services/project.service.ts index a064add8..81092afc 100644 --- a/apps/lfx-one/src/app/shared/services/project.service.ts +++ b/apps/lfx-one/src/app/shared/services/project.service.ts @@ -3,7 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { Project } from '@lfx-one/shared/interfaces'; +import { PendingActionItem, Project } from '@lfx-one/shared/interfaces'; import { BehaviorSubject, catchError, Observable, of, tap } from 'rxjs'; @Injectable({ @@ -47,4 +47,20 @@ export class ProjectService { }) ); } + + /** + * Get pending action surveys for the current user + * @param projectSlug - Project slug to filter surveys + * @returns Observable of pending action items with survey links + */ + public getPendingActionSurveys(projectSlug: string): Observable { + const params = new HttpParams().set('projectSlug', projectSlug); + + return this.http.get('/api/projects/pending-action-surveys', { params }).pipe( + catchError((error) => { + console.error('Failed to fetch pending action surveys:', error); + return of([]); + }) + ); + } } diff --git a/apps/lfx-one/src/server/controllers/project.controller.ts b/apps/lfx-one/src/server/controllers/project.controller.ts index 6dfcae43..ec9ba367 100644 --- a/apps/lfx-one/src/server/controllers/project.controller.ts +++ b/apps/lfx-one/src/server/controllers/project.controller.ts @@ -437,6 +437,59 @@ export class ProjectController { } } + /** + * GET /projects/pending-action-surveys - Get pending survey actions for the authenticated user + */ + public async getPendingActionSurveys(req: Request, res: Response, next: NextFunction): Promise { + const startTime = Logger.start(req, 'get_pending_action_surveys'); + + try { + // Extract user email from OIDC + const userEmail = req.oidc?.user?.['email']; + if (!userEmail) { + Logger.error(req, 'get_pending_action_surveys', startTime, new Error('User email not found in OIDC context')); + + const validationError = ServiceValidationError.forField('email', 'User email not found in authentication context', { + operation: 'get_pending_action_surveys', + service: 'project_controller', + path: req.path, + }); + + next(validationError); + return; + } + + // Extract projectSlug from query parameters + const projectSlug = req.query['projectSlug'] as string | undefined; + if (!projectSlug) { + Logger.error(req, 'get_pending_action_surveys', startTime, new Error('Missing projectSlug parameter')); + + const validationError = ServiceValidationError.forField('projectSlug', 'projectSlug query parameter is required', { + operation: 'get_pending_action_surveys', + service: 'project_controller', + path: req.path, + }); + + next(validationError); + return; + } + + // Get pending surveys from service + const pendingActions = await this.projectService.getPendingActionSurveys(userEmail, projectSlug); + + Logger.success(req, 'get_pending_action_surveys', startTime, { + email: userEmail, + project_slug: projectSlug, + survey_count: pendingActions.length, + }); + + res.json(pendingActions); + } catch (error) { + Logger.error(req, 'get_pending_action_surveys', startTime, error); + next(error); + } + } + private isUuid(slug: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slug); } diff --git a/apps/lfx-one/src/server/routes/projects.route.ts b/apps/lfx-one/src/server/routes/projects.route.ts index bf9054a7..bcf218a9 100644 --- a/apps/lfx-one/src/server/routes/projects.route.ts +++ b/apps/lfx-one/src/server/routes/projects.route.ts @@ -18,6 +18,8 @@ router.get('/', (req, res, next) => projectController.getProjects(req, res, next router.get('/search', (req, res, next) => projectController.searchProjects(req, res, next)); +router.get('/pending-action-surveys', (req, res, next) => projectController.getPendingActionSurveys(req, res, next)); + router.get('/:slug', (req, res, next) => projectController.getProjectBySlug(req, res, next)); router.get('/:uid/permissions', (req, res, next) => projectController.getProjectPermissions(req, res, next)); diff --git a/apps/lfx-one/src/server/services/project.service.ts b/apps/lfx-one/src/server/services/project.service.ts index b84e293a..42bad124 100644 --- a/apps/lfx-one/src/server/services/project.service.ts +++ b/apps/lfx-one/src/server/services/project.service.ts @@ -4,6 +4,8 @@ import { NATS_CONFIG } from '@lfx-one/shared/constants'; import { NatsSubjects } from '@lfx-one/shared/enums'; import { + PendingActionItem, + PendingSurveyRow, Project, ProjectIssuesResolutionAggregatedRow, ProjectIssuesResolutionResponse, @@ -723,6 +725,53 @@ export class ProjectService { }; } + /** + * Get pending survey actions for a user + * Queries for non-responded surveys and transforms them into PendingActionItem format + * @param email - User's email from OIDC authentication + * @param projectSlug - Project slug to filter surveys + * @returns Array of pending action items with survey links + */ + public async getPendingActionSurveys(email: string, projectSlug: string): Promise { + const query = ` + SELECT + SURVEY_TITLE, + SURVEY_CUTOFF_DATE, + PROJECT_NAME, + SURVEY_LINK + FROM ANALYTICS_DEV.DEV_ADESILVA_PLATINUM_LFX_ONE.MEMBER_DASHBOARD_PENDING_ACTION_SURVEYS + WHERE EMAIL = ? + AND PROJECT_SLUG = ? + AND SURVEY_CUTOFF_DATE > CURRENT_DATE() + AND RESPONSE_TYPE = 'non_response' + AND COMMITTEE_CATEGORY = 'Board' + ORDER BY SURVEY_CUTOFF_DATE ASC + `; + + const result = await this.snowflakeService.execute(query, [email, projectSlug]); + + // Transform database rows to PendingActionItem format + return result.rows.map((row) => { + // Format the cutoff date as a readable string + const cutoffDate = new Date(row.SURVEY_CUTOFF_DATE); + const formattedDate = cutoffDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return { + type: 'Submit Feedback', + badge: row.PROJECT_NAME, + text: `${row.SURVEY_TITLE} is due ${formattedDate}`, + icon: 'fa-regular fa-clipboard-list', + color: 'amber' as const, + buttonText: 'Submit Survey', + buttonLink: row.SURVEY_LINK, + }; + }); + } + /** * Get project ID by slug using NATS request-reply pattern * @private diff --git a/packages/shared/src/interfaces/components.interface.ts b/packages/shared/src/interfaces/components.interface.ts index 135651c3..9bc64389 100644 --- a/packages/shared/src/interfaces/components.interface.ts +++ b/packages/shared/src/interfaces/components.interface.ts @@ -354,6 +354,8 @@ export interface PendingActionItem { color: 'amber' | 'blue' | 'green' | 'purple'; /** Button text */ buttonText: string; + /** Button link */ + buttonLink?: string; } /** diff --git a/packages/shared/src/interfaces/project.interface.ts b/packages/shared/src/interfaces/project.interface.ts index fb7cc57d..3c47bcb4 100644 --- a/packages/shared/src/interfaces/project.interface.ts +++ b/packages/shared/src/interfaces/project.interface.ts @@ -172,3 +172,54 @@ export interface ProjectContext { /** URL-friendly project identifier */ slug: string; } + +/** + * Pending survey response from analytics database + * @description Survey data for member dashboard pending actions + */ +export interface PendingSurveyRow { + /** Unique survey identifier */ + SURVEY_ID: string; + /** Survey title/name */ + SURVEY_TITLE: string; + /** Survey status (e.g., sent) */ + SURVEY_STATUS: string; + /** Survey cohort date */ + SURVEY_COHORT_DATE: string; + /** Survey cutoff/due date */ + SURVEY_CUTOFF_DATE: string; + /** Committee identifier */ + COMMITTEE_ID: string; + /** Committee name */ + COMMITTEE_NAME: string; + /** Committee category (e.g., Board) */ + COMMITTEE_CATEGORY: string; + /** Project identifier */ + PROJECT_ID: string; + /** Project slug */ + PROJECT_SLUG: string; + /** Project name */ + PROJECT_NAME: string; + /** Response identifier */ + RESPONSE_ID: string; + /** Response date */ + RESPONSE_DATE: string; + /** Respondent first name */ + FIRST_NAME: string; + /** Respondent last name */ + LAST_NAME: string; + /** Respondent email */ + EMAIL: string; + /** Account identifier */ + ACCOUNT_ID: string; + /** Account name */ + ACCOUNT_NAME: string; + /** Organization identifier */ + ORGANIZATION_ID: string; + /** Organization name */ + ORGANIZATION_NAME: string; + /** Response type (e.g., non_response) */ + RESPONSE_TYPE: string; + /** Survey link URL */ + SURVEY_LINK: string; +}