Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
29 changes: 17 additions & 12 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;
});

public constructor() {
Expand All @@ -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]);
}
}
Expand Down
47 changes: 38 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 All @@ -42,6 +45,9 @@ export class PersonaService {
/** Full enriched projects from persona detection — source of truth for sidebar hierarchy */
public readonly detectedProjects: WritableSignal<EnrichedPersonaProject[]>;

/** Last known organizations from persona detection — preserved across persona switches */
private readonly lastKnownOrganizations: WritableSignal<Account[]> = signal<Account[]>([]);

public readonly isBoardScoped: Signal<boolean>;

/** Whether the user holds any board-scoped persona (board-member, executive-director) */
Expand All @@ -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();
});
}

Expand All @@ -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() });
}

/**
Expand Down Expand Up @@ -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);
}
});
}
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
71 changes: 70 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,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<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_has_org: boardDetections.map((d: Record<string, unknown>) => {
const extra = d['extra'] as Record<string, unknown> | undefined;
const org = extra?.['organization'] as Record<string, unknown> | undefined;
return { has_org: !!org, org_id: org?.['id'], org_name: org?.['name'] };
}),
});

// Validate response shape — normalize malformed fields to prevent downstream crashes
return {
projects: Array.isArray(parsed?.projects)
Expand Down Expand Up @@ -278,4 +308,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' && 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;
}
}
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,
};
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
Loading
Loading