diff --git a/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts b/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts index 8c656787c..e2e671f7c 100644 --- a/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts +++ b/apps/lfx-one/src/app/modules/committees/components/upcoming-committee-meeting/upcoming-committee-meeting.component.ts @@ -6,10 +6,10 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; import { Meeting } from '@lfx-one/shared/interfaces'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; -import { MeetingService } from '@services/meeting.service'; +import { CommitteeService } from '@services/committee.service'; import { ProjectService } from '@services/project.service'; import { TooltipModule } from 'primeng/tooltip'; -import { filter, map, of } from 'rxjs'; +import { map, of } from 'rxjs'; @Component({ selector: 'lfx-upcoming-committee-meeting', @@ -17,8 +17,8 @@ import { filter, map, of } from 'rxjs'; templateUrl: './upcoming-committee-meeting.component.html', }) export class UpcomingCommitteeMeetingComponent implements OnInit { + private readonly committeeService = inject(CommitteeService); private readonly projectService = inject(ProjectService); - private readonly meetingService = inject(MeetingService); private readonly injector = inject(Injector); public readonly committeeId = input(null); @@ -37,9 +37,21 @@ export class UpcomingCommitteeMeetingComponent implements OnInit { } private initializeUpcomingMeeting(): Signal { - return toSignal(this.project() ? this.getNextUpcomingCommitteeMeeting(this.project()!.uid, this.committeeId()) : of(null), { - initialValue: null, - }); + const committeeId = this.committeeId(); + if (!committeeId) return toSignal(of(null), { initialValue: null }); + + return toSignal( + this.committeeService.getCommitteeMeetings(committeeId).pipe( + map((meetings: Meeting[]) => { + const now = new Date().getTime(); + const upcoming = meetings + .filter((m) => m.start_time && new Date(m.start_time).getTime() > now) + .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); + return upcoming[0] ?? null; + }) + ), + { initialValue: null } + ); } private initializeCommittees() { @@ -50,36 +62,4 @@ export class UpcomingCommitteeMeetingComponent implements OnInit { .join(', ') ?? '' ); } - - private getNextUpcomingCommitteeMeeting(uid: string, committeeId: string | null = null) { - return this.meetingService.getMeetingsByProject(uid).pipe( - filter((meetings: Meeting[]) => { - // Return only meetings that have a start time in the future and has a committee value regardless of the committee id - return ( - meetings.filter((meeting) => new Date(meeting.start_time).getTime() > new Date().getTime() && meeting.committees && meeting.committees?.length > 0) - .length > 0 - ); - }), - map((meetings: Meeting[]) => { - if (meetings.length > 0) { - if (committeeId) { - // Find the earliest upcoming meeting that has the committee id and return it - const committeeMeetings = meetings.filter( - (meeting) => - new Date(meeting.start_time).getTime() > new Date().getTime() && - meeting.committees && - meeting.committees?.length > 0 && - meeting.committees.some((c) => c.uid === committeeId) - ); - - return committeeMeetings.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())[0]; - } - - // Return the next upcoming meeting by date in the future - return meetings.filter((meeting) => new Date(meeting.start_time).getTime() > new Date().getTime())[0]; - } - return null; - }) - ); - } } diff --git a/apps/lfx-one/src/app/shared/services/committee.service.ts b/apps/lfx-one/src/app/shared/services/committee.service.ts index 1378a7714..7c9f3a0f9 100644 --- a/apps/lfx-one/src/app/shared/services/committee.service.ts +++ b/apps/lfx-one/src/app/shared/services/committee.service.ts @@ -3,7 +3,15 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { Committee, CommitteeMember, CreateCommitteeMemberRequest, MyCommittee, QueryServiceCountResponse } from '@lfx-one/shared/interfaces'; +import { + Committee, + CommitteeMember, + CreateCommitteeMemberRequest, + Meeting, + MyCommittee, + PaginatedResponse, + QueryServiceCountResponse, +} from '@lfx-one/shared/interfaces'; import { catchError, map, Observable, of, take, tap, throwError } from 'rxjs'; @Injectable({ @@ -79,6 +87,10 @@ export class CommitteeService { return this.http.delete(`/api/committees/${committeeId}/members/${memberId}`).pipe(take(1)); } + public getCommitteeMeetings(committeeId: string): Observable { + return this.http.get>(`/api/committees/${committeeId}/meetings`).pipe(map((response) => response.data)); + } + // ── Join / Leave Methods ────────────────────────────────────────────────── /** Self-join an open group */ diff --git a/apps/lfx-one/src/app/shared/services/survey.service.ts b/apps/lfx-one/src/app/shared/services/survey.service.ts index ad7ddadb5..f344d6b70 100644 --- a/apps/lfx-one/src/app/shared/services/survey.service.ts +++ b/apps/lfx-one/src/app/shared/services/survey.service.ts @@ -35,6 +35,10 @@ export class SurveyService { return this.getSurveys(params); } + public getSurveysByCommittee(committeeUid: string): Observable { + return this.http.get(`/api/committees/${committeeUid}/surveys`).pipe(catchError(() => of([]))); + } + public getSurvey(surveyUid: string, projectId?: string): Observable { let params = new HttpParams(); if (projectId) { diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts index 8ce14968b..71831d06f 100644 --- a/apps/lfx-one/src/server/controllers/committee.controller.ts +++ b/apps/lfx-one/src/server/controllers/committee.controller.ts @@ -7,12 +7,24 @@ import { NextFunction, Request, Response } from 'express'; import { ServiceValidationError } from '../errors'; import { logger } from '../services/logger.service'; import { CommitteeService } from '../services/committee.service'; +import { SurveyService } from '../services/survey.service'; /** * Controller for handling committee HTTP requests */ export class CommitteeController { private committeeService: CommitteeService = new CommitteeService(); + // Cross-domain: surveys are accessed via committee context for the surveys tab + private readonly surveyService = new SurveyService(); + + // ── Dashboard Sub-Resource Handlers (via factory) ───────────────────────── + + /** GET /committees/:id/meetings */ + public getCommitteeMeetings = this.subResourceHandler( + 'get_committee_meetings', + (req, id) => this.committeeService.getCommitteeMeetings(req, id, req.query as Record), + 'meeting_count' + ); /** * GET /committees @@ -521,4 +533,84 @@ export class CommitteeController { next(error); } } + + /** + * GET /committees/:id/surveys + * Manual handler (not using subResourceHandler) because surveys need custom query param passthrough for filtering by status + */ + public async getCommitteeSurveys(req: Request, res: Response, next: NextFunction): Promise { + const { id } = req.params; + + if (!id) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { + operation: 'get_committee_surveys', + service: 'committee_controller', + path: req.path, + }) + ); + return; + } + + const startTime = logger.startOperation(req, 'get_committee_surveys', { + committee_id: id, + query_params: logger.sanitize(req.query as Record), + }); + + try { + const surveys = await this.surveyService.getCommitteeSurveys(req, id, req.query as Record); + + logger.success(req, 'get_committee_surveys', startTime, { + committee_id: id, + survey_count: surveys.length, + }); + + res.json(surveys); + } catch (error) { + next(error); + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + /** + * Factory that produces a standard sub-resource handler. + * Validates the committee ID, starts an operation, calls the service, + * logs success, and delegates errors to Express error middleware. + */ + private subResourceHandler(operation: string, serviceFn: (req: Request, id: string) => Promise, countKey: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const committeeId = req.params['id']; + if (!committeeId) { + next( + ServiceValidationError.forField('id', 'Committee ID is required', { + operation, + service: 'committee_controller', + path: req.path, + }) + ); + return; + } + + const startTime = logger.startOperation(req, operation, { committee_id: committeeId }); + try { + const result = await serviceFn(req, committeeId); + let count: number | boolean; + if (Array.isArray(result)) { + count = result.length; + } else if (result && typeof result === 'object' && 'data' in result && Array.isArray((result as { data: unknown[] }).data)) { + count = (result as { data: unknown[] }).data.length; + } else { + count = !!result; + } + logger.success(req, operation, startTime, { + committee_id: committeeId, + [countKey]: count, + }); + res.json(result); + } catch (error) { + next(error); + } + }; + } } diff --git a/apps/lfx-one/src/server/routes/committees.route.ts b/apps/lfx-one/src/server/routes/committees.route.ts index 2887494be..70c2ee115 100644 --- a/apps/lfx-one/src/server/routes/committees.route.ts +++ b/apps/lfx-one/src/server/routes/committees.route.ts @@ -25,8 +25,13 @@ router.post('/:id/members', (req, res, next) => committeeController.createCommit router.put('/:id/members/:memberId', (req, res, next) => committeeController.updateCommitteeMember(req, res, next)); router.delete('/:id/members/:memberId', (req, res, next) => committeeController.deleteCommitteeMember(req, res, next)); +// Meeting routes +router.get('/:id/meetings', (req, res, next) => committeeController.getCommitteeMeetings(req, res, next)); + // ── Join / Leave routes ──────────────────────────────────────────────────── router.post('/:id/join', (req, res, next) => committeeController.joinCommittee(req, res, next)); router.delete('/:id/leave', (req, res, next) => committeeController.leaveCommittee(req, res, next)); +// Survey routes +router.get('/:id/surveys', (req, res, next) => committeeController.getCommitteeSurveys(req, res, next)); export default router; diff --git a/apps/lfx-one/src/server/services/committee.service.ts b/apps/lfx-one/src/server/services/committee.service.ts index d975e873f..cce808bcc 100644 --- a/apps/lfx-one/src/server/services/committee.service.ts +++ b/apps/lfx-one/src/server/services/committee.service.ts @@ -8,6 +8,8 @@ import { CommitteeSettingsData, CommitteeUpdateData, CreateCommitteeMemberRequest, + Meeting, + PaginatedResponse, MyCommittee, QueryServiceCountResponse, QueryServiceResponse, @@ -17,11 +19,15 @@ import { Request } from 'express'; import { getUsernameFromAuth } from '../utils/auth-helper'; import { ResourceNotFoundError } from '../errors'; -import { logger } from '../services/logger.service'; +import { logger } from './logger.service'; import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; +// MeetingService is dynamically imported to avoid circular dependency. +// Use import('...') type to reference the class without a static import. +type MeetingServiceType = InstanceType; + /** * Service for handling committee business logic */ @@ -29,6 +35,8 @@ export class CommitteeService { private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; + // Promise-based lazy initializer to avoid concurrent imports creating duplicate instances + private meetingServicePromise?: Promise; public constructor() { this.accessCheckService = new AccessCheckService(); @@ -139,11 +147,12 @@ export class CommitteeService { */ public async updateCommittee(req: Request, committeeId: string, data: CommitteeUpdateData): Promise { // Extract settings and channel fields from core committee data + // mailing_list/chat_channel are stripped because upstream PUT does not accept them + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { business_email_required, is_audit_enabled, show_meeting_attendees, member_visibility, mailing_list, chat_channel, ...committeeData } = data; const hasSettingsUpdate = business_email_required !== undefined || is_audit_enabled !== undefined || show_meeting_attendees !== undefined || member_visibility !== undefined; - const hasChannelsUpdate = mailing_list !== undefined || chat_channel !== undefined; const hasCoreUpdate = Object.keys(committeeData).length > 0; let updatedCommittee: Committee; @@ -166,30 +175,7 @@ export class CommitteeService { updatedCommittee = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'GET'); } - // Step 3: Update channels via PATCH (mailing_list/chat_channel are not accepted by PUT) - if (hasChannelsUpdate) { - try { - const channelsPayload: Record = {}; - if (mailing_list !== undefined) channelsPayload['mailing_list'] = mailing_list; - if (chat_channel !== undefined) channelsPayload['chat_channel'] = chat_channel; - - logger.debug(req, 'update_committee_channels', 'Updating committee channels via PATCH', { - committee_uid: committeeId, - fields: Object.keys(channelsPayload), - }); - - const patched = await this.microserviceProxy.proxyRequest(req, 'LFX_V2_SERVICE', `/committees/${committeeId}`, 'PATCH', {}, channelsPayload); - - updatedCommittee = { ...updatedCommittee, ...patched }; - } catch (error) { - logger.warning(req, 'update_committee_channels', 'PATCH failed for channels, returning current committee data', { - committee_uid: committeeId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - // Step 5: Update settings if provided + // Step 3: Update settings if provided if (hasSettingsUpdate) { try { await this.updateCommitteeSettings(req, committeeId, { @@ -390,6 +376,51 @@ export class CommitteeService { return userMemberships; } + /** + * Fetches meetings associated with a committee. + */ + public async getCommitteeMeetings(req: Request, committeeId: string, query: Record = {}): Promise> { + try { + // Whitelist allowed query params to prevent unexpected parameters from reaching downstream + const allowedParams = ['page_size', 'page_token', 'order_by', 'committee_uid']; + const sanitizedQuery: Record = {}; + for (const key of allowedParams) { + if (query[key] !== undefined) sanitizedQuery[key] = String(query[key]); + } + + const params = { + ...sanitizedQuery, + committee_uid: committeeId, + }; + + logger.debug(req, 'get_committee_meetings', 'Fetching meetings for committee', { + committee_uid: committeeId, + }); + + // Lazy import to avoid circular dependency — Promise ensures only one instance is created + if (!this.meetingServicePromise) { + this.meetingServicePromise = import('./meeting.service').then((m) => new m.MeetingService()); + } + const meetingService = await this.meetingServicePromise; + const result = await meetingService.getMeetings(req, params); + + logger.debug(req, 'get_committee_meetings', 'Fetched committee meetings', { + committee_uid: committeeId, + count: result.data.length, + }); + + return result; + } catch (error) { + // Reset cached promise so a transient import failure doesn't stick forever + this.meetingServicePromise = undefined; + logger.warning(req, 'get_committee_meetings', 'Failed to fetch committee meetings, returning empty', { + committee_uid: committeeId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return { data: [], page_token: undefined }; + } + } + // ── My Committees ───────────────────────────────────────────────────────── public async getMyCommittees(req: Request, projectUid?: string): Promise { diff --git a/apps/lfx-one/src/server/services/survey.service.ts b/apps/lfx-one/src/server/services/survey.service.ts index 89195c2d9..1c38c41d4 100644 --- a/apps/lfx-one/src/server/services/survey.service.ts +++ b/apps/lfx-one/src/server/services/survey.service.ts @@ -89,6 +89,40 @@ export class SurveyService { return survey; } + /** + * Fetches surveys for a specific committee by committee_uid + */ + public async getCommitteeSurveys(req: Request, committeeId: string, query: Record = {}): Promise { + logger.debug(req, 'get_committee_surveys', 'Fetching surveys for committee', { + committee_uid: committeeId, + }); + + // Whitelist allowed query params to prevent unexpected parameters from reaching downstream + const allowedParams = ['page_size', 'page_token', 'order_by', 'status']; + const sanitizedQuery: Record = {}; + for (const key of allowedParams) { + if (query[key] !== undefined) sanitizedQuery[key] = String(query[key]); + } + + // Use tags parameter for server-side filtering — committee_uid is not a supported query param + const params = { + ...sanitizedQuery, + type: 'survey', + tags: `committee_uid:${committeeId}`, + }; + + const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); + + const surveys: Survey[] = (resources ?? []).map((resource) => resource.data); + + logger.debug(req, 'get_committee_surveys', 'Completed committee survey fetch', { + committee_uid: committeeId, + count: surveys.length, + }); + + return surveys; + } + /** * Deletes a survey using ETag for concurrency control */ diff --git a/packages/shared/src/interfaces/committee-application.interface.ts b/packages/shared/src/interfaces/committee-application.interface.ts deleted file mode 100644 index 3ef9536ee..000000000 --- a/packages/shared/src/interfaces/committee-application.interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -/** - * Status of a committee join application - */ -export type CommitteeJoinApplicationStatus = 'pending' | 'approved' | 'rejected'; - -/** - * Represents a join application for a committee - */ -export interface CommitteeJoinApplication { - uid: string; - committee_uid: string; - applicant_email: string; - applicant_name?: string; - applicant_uid?: string; - status: CommitteeJoinApplicationStatus; - reason?: string; - created_at: string; - updated_at?: string; -} - -/** - * Request payload to create a committee join application - */ -export interface CreateCommitteeJoinApplicationRequest { - reason?: string; -} diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 9739d17bc..c755e13ca 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -86,8 +86,5 @@ export * from './filter.interface'; // Lens interfaces export * from './lens.interface'; -// Committee application interfaces -export * from './committee-application.interface'; - // Public committee interfaces export * from './public-committee.interface';