Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<lfx-select
[form]="form"
control="selectedAccountId"
[options]="availableAccounts"
[options]="availableAccounts()"
optionLabel="accountName"
optionValue="accountId"
[filter]="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonComponent } from '@components/button/button.component';
import { SelectComponent } from '@components/select/select.component';
import { ACCOUNTS, DEV_PERSONA_PRESETS } from '@lfx-one/shared/constants';
import { Account, DevPersonaPreset, isBoardScopedPersona } from '@lfx-one/shared/interfaces';
import { DEV_PERSONA_PRESETS } from '@lfx-one/shared/constants';
import { DevPersonaPreset, isBoardScopedPersona } from '@lfx-one/shared/interfaces';
import { AccountContextService } from '@services/account-context.service';
import { CookieRegistryService } from '@services/cookie-registry.service';
import { FeatureFlagService } from '@services/feature-flag.service';
Expand All @@ -34,8 +34,8 @@ export class DevToolbarComponent {
protected readonly showDevToolbar = this.featureFlagService.getBooleanFlag('dev-toolbar', true);
protected readonly showOrganizationSelector = this.featureFlagService.getBooleanFlag('organization-selector', true);

// Organization selector options
protected readonly availableAccounts = ACCOUNTS;
// Organization selector options — includes detected orgs not in the predefined list
protected readonly availableAccounts = this.accountContextService.availableAccounts;

// Dev persona presets for SelectButton
protected readonly personaPresets = DEV_PERSONA_PRESETS;
Expand Down Expand Up @@ -109,9 +109,9 @@ export class DevToolbarComponent {
.get('selectedAccountId')
?.valueChanges.pipe(takeUntilDestroyed())
.subscribe((value) => {
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);
}
});

Expand Down
16 changes: 10 additions & 6 deletions apps/lfx-one/src/app/shared/services/account-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Account[]> = 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;
Comment thread
asithade marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

public constructor() {
Expand Down
25 changes: 16 additions & 9 deletions apps/lfx-one/src/app/shared/services/persona.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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<PersonaType>;
public readonly allPersonas: WritableSignal<PersonaType[]>;
Expand Down Expand Up @@ -62,13 +65,11 @@ 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
afterNextRender(() => {
if (!stored || Object.keys(this.personaProjects()).length === 0) {
this.refreshFromApi();
}
this.refreshFromApi();
Comment thread
asithade marked this conversation as resolved.
});
}

Expand All @@ -83,12 +84,12 @@ 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 });
this.persistToCookie({ primary, all, multiProject, multiFoundation, organizations });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
Expand Down Expand Up @@ -118,7 +119,13 @@ 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);
}

// Forward detected organizations to account context service
if (response.organizations && response.organizations.length > 0) {
Comment thread
asithade marked this conversation as resolved.
Outdated
console.info('[PersonaService] Detected organizations:', response.organizations);
this.accountContextService.initializeUserOrganizations(response.organizations);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/lfx-one/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion apps/lfx-one/src/server/services/persona-detection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +67,7 @@ export class PersonaDetectionService {
projects: [],
multiProject: false,
multiFoundation: false,
organizations: [],
error: detectionResponse.error.message,
};
}
Expand All @@ -72,6 +81,7 @@ export class PersonaDetectionService {
projects: [],
multiProject: false,
multiFoundation: false,
organizations: [],
error: null,
};
}
Expand Down Expand Up @@ -105,6 +115,7 @@ export class PersonaDetectionService {
projects: enrichedProjects,
multiProject,
multiFoundation,
organizations: this.extractOrganizations(req, enrichedProjects),
error: null,
};
}
Expand All @@ -121,6 +132,21 @@ 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<string, unknown>) =>
Array.isArray(p['detections'])
? (p['detections'] as Record<string, unknown>[]).filter((d: Record<string, unknown>) => d['source'] === 'board_member')
: []
)
: [];
logger.debug(req, 'fetch_persona_detections', 'Raw NATS persona response', {
project_count: projectCount,
board_member_detections: boardDetections.length,
board_member_extras: boardDetections.map((d: Record<string, unknown>) => d['extra']),
});
Comment thread
asithade marked this conversation as resolved.

// Validate response shape — normalize malformed fields to prevent downstream crashes
return {
projects: Array.isArray(parsed?.projects)
Expand Down Expand Up @@ -278,4 +304,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<string>();
const accounts: Account[] = [];

for (const project of projects) {
for (const detection of project.detections) {
if (detection.source === 'board_member') {
logger.debug(req, 'extract_organizations', 'Processing board_member detection', {
project_uid: project.projectUid,
project_slug: project.projectSlug,
has_extra: !!detection.extra,
extra_keys: detection.extra ? Object.keys(detection.extra) : [],
organization_raw: detection.extra?.['organization'],
});

if (detection.extra) {
const org = detection.extra['organization'] as { id: string; name: string } | undefined;
if (org?.id && !seen.has(org.id)) {
Comment thread
asithade marked this conversation as resolved.
Outdated
seen.add(org.id);
accounts.push({ accountId: org.id, accountName: org.name });
Comment thread
asithade marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
}
}

logger.debug(req, 'extract_organizations', 'Organization extraction complete', {
total_accounts: accounts.length,
accounts: accounts.map((a) => ({ id: a.accountId, name: a.accountName })),
});

return accounts;
}
}
50 changes: 0 additions & 50 deletions apps/lfx-one/src/server/utils/organization-matcher.ts

This file was deleted.

5 changes: 4 additions & 1 deletion apps/lfx-one/src/server/utils/persona-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function resolveFromCookie(req: Request, cookieHeader: string): SsrPersonaResult
return {
persona: parsed.primary,
personas: parsed.all,
organizations: parsed.organizations ?? [],
};
}
}
Expand All @@ -69,6 +70,7 @@ async function resolveFromNats(req: Request, res: Response): Promise<SsrPersonaR

const persona = personaResult.personas.length > 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) {
Expand All @@ -77,6 +79,7 @@ async function resolveFromNats(req: Request, res: Response): Promise<SsrPersonaR
all: personas,
multiProject: personaResult.multiProject,
multiFoundation: personaResult.multiFoundation,
organizations,
Comment thread
asithade marked this conversation as resolved.
};
res.cookie(PERSONA_COOKIE_KEY, JSON.stringify(cookieState), {
maxAge: 30 * 24 * 60 * 60 * 1000,
Expand All @@ -92,7 +95,7 @@ async function resolveFromNats(req: Request, res: Response): Promise<SsrPersonaR
personas,
});

return { persona, personas };
return { persona, personas, organizations };
} catch (error) {
logger.warning(req, 'ssr_persona', 'Persona detection failed during SSR, defaulting to contributor', {
error: error instanceof Error ? error.message : 'Unknown error',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import type { Account } from './account.interface';
import type { PersonaType } from './persona.interface';

/**
Expand Down Expand Up @@ -86,6 +87,8 @@ export interface PersonaApiResponse {
multiProject: boolean;
/** Whether the user has access to projects under multiple foundations */
multiFoundation: boolean;
/** Unique organizations extracted from board member committee detections */
organizations: Account[];
/** Error message if the persona detection failed */
error: string | null;
}
Expand All @@ -96,4 +99,6 @@ export interface PersonaApiResponse {
export interface SsrPersonaResult {
persona: PersonaType;
personas: PersonaType[];
/** User's organizations from board member detections (optional — empty for non-board personas) */
organizations?: Account[];
}
4 changes: 4 additions & 0 deletions packages/shared/src/interfaces/persona.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import type { Account } from './account.interface';

/**
* Available persona types for UI customization
* @description Defines the different user personas that can be selected
Expand Down Expand Up @@ -39,6 +41,8 @@ export interface PersistedPersonaState {
multiProject?: boolean;
/** Whether the user has access to multiple foundations */
multiFoundation?: boolean;
/** User's organizations from board member detections */
organizations?: Account[];
}

/**
Expand Down
Loading