diff --git a/apps/lfx-one/src/app/layouts/dev-toolbar/dev-toolbar.component.html b/apps/lfx-one/src/app/layouts/dev-toolbar/dev-toolbar.component.html index 0587cc60d..c432c60e9 100644 --- a/apps/lfx-one/src/app/layouts/dev-toolbar/dev-toolbar.component.html +++ b/apps/lfx-one/src/app/layouts/dev-toolbar/dev-toolbar.component.html @@ -46,7 +46,7 @@ { - const selectedAccount = ACCOUNTS.find((acc) => acc.accountId === value); + const selectedAccount = this.availableAccounts().find((acc) => acc.accountId === value); if (selectedAccount) { - this.accountContextService.setAccount(selectedAccount as Account); + this.accountContextService.setAccount(selectedAccount); } }); diff --git a/apps/lfx-one/src/app/shared/services/account-context.service.ts b/apps/lfx-one/src/app/shared/services/account-context.service.ts index b277c4aea..de4e30987 100644 --- a/apps/lfx-one/src/app/shared/services/account-context.service.ts +++ b/apps/lfx-one/src/app/shared/services/account-context.service.ts @@ -34,15 +34,19 @@ export class AccountContextService { /** * Returns available accounts for the user - * If user has specific organizations from committee memberships, returns only those - * Otherwise returns all predefined accounts + * Merges detected organizations into the predefined ACCOUNTS list so that + * organizations not in the hardcoded list are still available for selection */ public readonly availableAccounts: Signal = computed(() => { - const orgs = this.userOrganizations(); - if (this.initialized() && orgs.length > 0) { - return orgs; + const detected = this.userOrganizations(); + if (!this.initialized() || detected.length === 0) { + return ACCOUNTS; } - return ACCOUNTS; + + // Start with ACCOUNTS, then append any detected orgs not already in the list + const knownIds = new Set(ACCOUNTS.map((a) => a.accountId)); + const extras = detected.filter((d) => !knownIds.has(d.accountId)); + return extras.length > 0 ? [...ACCOUNTS, ...extras] : ACCOUNTS; }); public constructor() { @@ -55,18 +59,19 @@ export class AccountContextService { * Called during app initialization with organizations matched from committee memberships */ public initializeUserOrganizations(organizations: Account[]): void { - if (organizations && organizations.length > 0) { - this.userOrganizations.set(organizations); - this.initialized.set(true); + this.initialized.set(true); + this.userOrganizations.set(organizations ?? []); - // If stored account is not in user's organizations, select the first available + if (organizations && organizations.length > 0) { + // Validate stored selection against the full available set (ACCOUNTS + detected) const stored = this.loadFromStorage(); - const isStoredValid = stored && organizations.some((org) => org.accountId === stored.accountId); + const available = this.availableAccounts(); + const isStoredValid = stored && available.some((acc) => acc.accountId === stored.accountId); if (isStoredValid && stored) { this.selectedAccount.set(stored); } else { - // Select first organization and persist + // Select first detected organization and persist this.setAccount(organizations[0]); } } diff --git a/apps/lfx-one/src/app/shared/services/persona.service.ts b/apps/lfx-one/src/app/shared/services/persona.service.ts index 50e161fc1..813715174 100644 --- a/apps/lfx-one/src/app/shared/services/persona.service.ts +++ b/apps/lfx-one/src/app/shared/services/persona.service.ts @@ -5,6 +5,7 @@ import { HttpClient } from '@angular/common/http'; import { afterNextRender, computed, inject, Injectable, Signal, signal, WritableSignal } from '@angular/core'; import { PERSONA_COOKIE_KEY } from '@lfx-one/shared/constants'; import { + Account, EnrichedPersonaProject, isBoardScopedPersona, isProjectScopedPersona, @@ -17,6 +18,7 @@ import { import { SsrCookieService } from 'ngx-cookie-service-ssr'; import { catchError, of, take } from 'rxjs'; +import { AccountContextService } from './account-context.service'; import { CookieRegistryService } from './cookie-registry.service'; @Injectable({ @@ -26,6 +28,7 @@ export class PersonaService { private readonly http = inject(HttpClient); private readonly cookieService = inject(SsrCookieService); private readonly cookieRegistry = inject(CookieRegistryService); + private readonly accountContextService = inject(AccountContextService); public readonly currentPersona: WritableSignal; public readonly allPersonas: WritableSignal; @@ -42,6 +45,9 @@ export class PersonaService { /** Full enriched projects from persona detection — source of truth for sidebar hierarchy */ public readonly detectedProjects: WritableSignal; + /** Last known organizations from persona detection — preserved across persona switches */ + private readonly lastKnownOrganizations: WritableSignal = signal([]); + public readonly isBoardScoped: Signal; /** Whether the user holds any board-scoped persona (board-member, executive-director) */ @@ -62,13 +68,15 @@ export class PersonaService { this.hasBoardRole = this.initHasBoardRole(); this.hasProjectRole = this.initHasProjectRole(); - // Refresh persona data from API after hydration (browser only) - // Skip if cookie was loaded AND personaProjects is already populated; - // cookie only stores primary/all/multi* — personaProjects needs the API + // Always refresh persona data from API after hydration (browser only). + // Cookie provides initial SSR values; the API is the primary source of truth + // for personaProjects, detectedProjects, and organizations. + // Note: this is intentionally unconditional — the cookie cannot carry + // personaProjects or detectedProjects, so the API refresh is always needed. + // The per-user NATS load is bounded by the persona service's stale-while-revalidate + // cache (< 10 min = cached, 10 min–24 h = background refresh, > 24 h = sync fetch). afterNextRender(() => { - if (!stored || Object.keys(this.personaProjects()).length === 0) { - this.refreshFromApi(); - } + this.refreshFromApi(); }); } @@ -83,12 +91,15 @@ export class PersonaService { * Set the active persona and update state * Sets the primary persona, the full list of active personas, and access flags */ - public setPersonas(primary: PersonaType, all: PersonaType[], multiProject = false, multiFoundation = false): void { + public setPersonas(primary: PersonaType, all: PersonaType[], multiProject = false, multiFoundation = false, organizations?: Account[]): void { this.currentPersona.set(primary); this.allPersonas.set(all); this.multiProject.set(multiProject); this.multiFoundation.set(multiFoundation); - this.persistToCookie({ primary, all, multiProject, multiFoundation }); + if (organizations !== undefined) { + this.lastKnownOrganizations.set(organizations); + } + this.persistToCookie({ primary, all, multiProject, multiFoundation, organizations: this.lastKnownOrganizations() }); } /** @@ -118,7 +129,25 @@ export class PersonaService { // Update persona state if API returned data — reuse setPersonas() for // consistent side effects (board-scoped project clearing, cookie persistence) if (response.personas.length > 0) { - this.setPersonas(response.personas[0], response.personas, response.multiProject, response.multiFoundation); + this.setPersonas(response.personas[0], response.personas, response.multiProject, response.multiFoundation, response.organizations); + } else if (response.organizations) { + // Persist organizations even when no personas changed (edge case: board member without persona roles) + this.lastKnownOrganizations.set(response.organizations); + this.persistToCookie({ + primary: this.currentPersona(), + all: this.allPersonas(), + multiProject: this.multiProject(), + multiFoundation: this.multiFoundation(), + organizations: response.organizations, + }); + } + + // Always sync organizations to account context service (including empty arrays to clear stale state) + if (response.organizations) { + if (response.organizations.length > 0) { + console.info('[PersonaService] Detected organizations:', response.organizations); + } + this.accountContextService.initializeUserOrganizations(response.organizations); } }); } diff --git a/apps/lfx-one/src/server/server.ts b/apps/lfx-one/src/server/server.ts index b2a37668f..070ec6d69 100644 --- a/apps/lfx-one/src/server/server.ts +++ b/apps/lfx-one/src/server/server.ts @@ -243,6 +243,7 @@ app.use('/**', async (req: Request, res: Response, next: NextFunction) => { const personaResult = await resolvePersonaForSsr(req, res); auth.persona = personaResult.persona; auth.personas = personaResult.personas; + auth.organizations = personaResult.organizations ?? []; } // Build runtime config from environment variables diff --git a/apps/lfx-one/src/server/services/persona-detection.service.ts b/apps/lfx-one/src/server/services/persona-detection.service.ts index 8c9c3a640..7e632b3ac 100644 --- a/apps/lfx-one/src/server/services/persona-detection.service.ts +++ b/apps/lfx-one/src/server/services/persona-detection.service.ts @@ -2,7 +2,15 @@ // SPDX-License-Identifier: MIT import { NatsSubjects } from '@lfx-one/shared/enums'; -import { EnrichedPersonaProject, PersonaApiResponse, PersonaDetectionResponse, PersonaProject, PersonaType, Project } from '@lfx-one/shared/interfaces'; +import { + Account, + EnrichedPersonaProject, + PersonaApiResponse, + PersonaDetectionResponse, + PersonaProject, + PersonaType, + Project, +} from '@lfx-one/shared/interfaces'; import { Request } from 'express'; import { logger } from './logger.service'; @@ -137,6 +145,7 @@ export class PersonaDetectionService { projects: [], multiProject: false, multiFoundation: false, + organizations: [], error: detectionResponse.error.message, }; } @@ -150,6 +159,7 @@ export class PersonaDetectionService { projects: [], multiProject: false, multiFoundation: false, + organizations: [], error: null, }; } @@ -183,6 +193,7 @@ export class PersonaDetectionService { projects: enrichedProjects, multiProject, multiFoundation, + organizations: this.extractOrganizations(req, enrichedProjects), error: null, }; } @@ -233,6 +244,25 @@ export class PersonaDetectionService { const parsed = JSON.parse(decoded); + // Log raw NATS response for debugging detection extras (e.g. organization data) + const projectCount = Array.isArray(parsed?.projects) ? parsed.projects.length : 0; + const boardDetections = Array.isArray(parsed?.projects) + ? parsed.projects.flatMap((p: Record) => + Array.isArray(p['detections']) + ? (p['detections'] as Record[]).filter((d: Record) => d['source'] === 'board_member') + : [] + ) + : []; + logger.debug(req, 'fetch_persona_detections', 'Raw NATS persona response', { + project_count: projectCount, + board_member_detections: boardDetections.length, + board_member_has_org: boardDetections.map((d: Record) => { + const extra = d['extra'] as Record | undefined; + const org = extra?.['organization'] as Record | undefined; + return { has_org: !!org, org_id: org?.['id'], org_name: org?.['name'] }; + }), + }); + // Validate response shape — normalize malformed fields to prevent downstream crashes const normalized = { projects: Array.isArray(parsed?.projects) @@ -400,4 +430,43 @@ export class PersonaDetectionService { project.funding_model.includes('Membership') ); } + + /** + * Extract unique organizations from board_member detection extras + * The persona service includes organization data (Salesforce account ID, name, website) + * in board_member detection extras — map these to Account objects for UI consumption + */ + private extractOrganizations(req: Request, projects: EnrichedPersonaProject[]): Account[] { + const seen = new Set(); + const accounts: Account[] = []; + + for (const project of projects) { + for (const detection of project.detections) { + if (detection.source === 'board_member' && detection.extra) { + const org = detection.extra['organization'] as { id?: unknown; name?: unknown } | undefined; + const orgId = typeof org?.id === 'string' ? org.id : ''; + const orgName = typeof org?.name === 'string' ? org.name : ''; + + logger.debug(req, 'extract_organizations', 'Processing board_member detection', { + project_slug: project.projectSlug, + has_org: !!org, + org_id: orgId || null, + org_name: orgName || null, + }); + + if (orgId && orgName && !seen.has(orgId)) { + seen.add(orgId); + accounts.push({ accountId: orgId, accountName: orgName }); + } + } + } + } + + logger.debug(req, 'extract_organizations', 'Organization extraction complete', { + total_accounts: accounts.length, + accounts: accounts.map((a) => ({ id: a.accountId, name: a.accountName })), + }); + + return accounts; + } } diff --git a/apps/lfx-one/src/server/utils/organization-matcher.ts b/apps/lfx-one/src/server/utils/organization-matcher.ts deleted file mode 100644 index 4e567374b..000000000 --- a/apps/lfx-one/src/server/utils/organization-matcher.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { ACCOUNTS } from '@lfx-one/shared/constants'; - -import type { Account } from '@lfx-one/shared/interfaces'; - -/** - * Normalizes an organization name for comparison - * Removes common suffixes, converts to lowercase, and trims whitespace - */ -function normalizeOrgName(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/,?\s*(inc\.?|ltd\.?|llc\.?|corp\.?|corporation|limited|incorporated)$/i, '') - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * Matches organization names from committee memberships to the predefined ACCOUNTS list - * Uses fuzzy matching to handle variations in organization names - * - * @param organizationNames - Array of organization names from committee memberships - * @returns Array of matched Account objects from the ACCOUNTS constant - */ -export function matchOrganizationNamesToAccounts(organizationNames: string[]): Account[] { - if (!organizationNames || organizationNames.length === 0) { - return []; - } - - const matchedAccounts: Account[] = []; - const normalizedInputNames = organizationNames.map(normalizeOrgName); - - for (const account of ACCOUNTS) { - const normalizedAccountName = normalizeOrgName(account.accountName); - - // Check for exact match or if one contains the other - const isMatch = normalizedInputNames.some( - (inputName) => normalizedAccountName === inputName || normalizedAccountName.includes(inputName) || inputName.includes(normalizedAccountName) - ); - - if (isMatch) { - matchedAccounts.push(account); - } - } - - return matchedAccounts; -} diff --git a/apps/lfx-one/src/server/utils/persona-helper.ts b/apps/lfx-one/src/server/utils/persona-helper.ts index ca874aef1..857f97a4c 100644 --- a/apps/lfx-one/src/server/utils/persona-helper.ts +++ b/apps/lfx-one/src/server/utils/persona-helper.ts @@ -49,6 +49,7 @@ function resolveFromCookie(req: Request, cookieHeader: string): SsrPersonaResult return { persona: parsed.primary, personas: parsed.all, + organizations: parsed.organizations ?? [], }; } } @@ -69,6 +70,7 @@ async function resolveFromNats(req: Request, res: Response): Promise 0 ? personaResult.personas[0] : DEFAULT_PERSONA; const personas = personaResult.personas.length > 0 ? personaResult.personas : [DEFAULT_PERSONA]; + const organizations = personaResult.organizations ?? []; // Only cache when detection succeeded — don't pin user to contributor on transient NATS failure if (!personaResult.error) { @@ -77,6 +79,7 @@ async function resolveFromNats(req: Request, res: Response): Promise