diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts index 8df682a26..f1f272e94 100644 --- a/apps/lfx-one/src/app/app.routes.ts +++ b/apps/lfx-one/src/app/app.routes.ts @@ -50,11 +50,91 @@ export const routes: Routes = [ data: { lens: 'project' }, loadComponent: () => import('./modules/dashboards/dashboard.component').then((m) => m.DashboardComponent), }, - // Org Lens dashboard (placeholder — reuses DashboardComponent for now) { - path: 'org', + path: 'org/overview', data: { lens: 'org' }, - loadComponent: () => import('./modules/dashboards/dashboard.component').then((m) => m.DashboardComponent), + loadComponent: () => import('./modules/dashboards/org/org-overview/org-overview.component').then((m) => m.OrgOverviewComponent), + }, + { + path: 'org/memberships', + data: { lens: 'org', title: 'Memberships', description: 'Active memberships and tier history.', icon: 'fa-light fa-display' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/projects', + data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/roi', + data: { lens: 'org', title: 'ROI', description: 'Return on investment across your memberships and engagement.', icon: 'fa-light fa-chart-line-up' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/governance', + data: { lens: 'org', title: 'Governance', description: 'Board seats and governance participation.', icon: 'fa-light fa-layer-group' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/people', + data: { lens: 'org', title: 'People', description: 'Employees and contributors associated with your organization.', icon: 'fa-light fa-users' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/contributions', + data: { + lens: 'org', + title: 'Code Contributions', + description: "Open-source contributions from your organization's contributors.", + icon: 'fa-light fa-code', + }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/events', + data: { lens: 'org', title: 'Events', description: 'Events your organization is sponsoring or attending.', icon: 'fa-light fa-calendar' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/training', + data: { + lens: 'org', + title: 'Training & Certification', + description: 'Training enrollments and certifications across your organization.', + icon: 'fa-light fa-graduation-cap', + }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/meetings', + data: { lens: 'org', title: 'Meetings', description: 'Meetings your organization is participating in.', icon: 'fa-light fa-video' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/groups', + data: { lens: 'org', title: 'Groups', description: 'Committees your organization participates in.', icon: 'fa-light fa-users-rectangle' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org/profile', + data: { lens: 'org', title: 'Profile', description: 'Public-facing details about your organization.', icon: 'fa-light fa-file' }, + loadComponent: () => + import('./modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component').then((m) => m.OrgPlaceholderPageComponent), + }, + { + path: 'org', + redirectTo: 'org/overview', + pathMatch: 'full', }, // Foundation Lens — feature routes (lens-tagged so deep links restore the foundation lens) { diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html index 5e25090de..7624ed909 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html @@ -33,6 +33,7 @@ [items]="sidebarItems()" [showMeSelector]="activeLens() === 'me'" [showProjectSelector]="activeLens() === 'project' || activeLens() === 'foundation'" + [showOrgSelector]="activeLens() === 'org'" [(selectorPanelOpen)]="selectorPanelOpen"> @@ -64,6 +65,7 @@ [items]="sidebarItems()" [showMeSelector]="activeLens() === 'me'" [showProjectSelector]="activeLens() === 'project' || activeLens() === 'foundation'" + [showOrgSelector]="activeLens() === 'org'" [(selectorPanelOpen)]="selectorPanelOpen" [mobile]="true"> diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts index f8b84b0c7..b8555e54d 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts @@ -335,70 +335,84 @@ export class MainLayoutComponent { }, ]; - // --- Org Lens Items --- private readonly orgLensItems: SidebarMenuItem[] = [ { - label: 'Overview', + label: 'Org Overview', icon: 'fa-light fa-grid-2', - routerLink: '/org', + routerLink: '/org/overview', }, { - label: 'Portfolio', + label: 'Org Foundations', isSection: true, expanded: true, items: [ { - label: 'Key Projects', - icon: 'fa-light fa-diagram-project', + label: 'Memberships', + icon: 'fa-light fa-display', + routerLink: '/org/memberships', + }, + { + label: 'Projects', + icon: 'fa-light fa-folder', routerLink: '/org/projects', }, { - label: 'Code Contributions', - icon: 'fa-light fa-code', - routerLink: '/org/code', + label: 'ROI', + icon: 'fa-light fa-chart-line-up', + routerLink: '/org/roi', + }, + { + label: 'Governance', + icon: 'fa-light fa-layer-group', + routerLink: '/org/governance', }, ], }, { - label: 'Membership', + label: 'Org Engagement', isSection: true, expanded: true, items: [ { - label: 'Membership', - icon: 'fa-light fa-id-card', - routerLink: '/org/membership', + label: 'People', + icon: 'fa-light fa-users', + routerLink: '/org/people', }, { - label: 'Benefits', - icon: 'fa-light fa-gift', - routerLink: '/org/benefits', + label: 'Code Contributions', + icon: 'fa-light fa-code', + routerLink: '/org/contributions', }, - ], - }, - { - label: 'Administration', - isSection: true, - expanded: true, - items: [ { - label: COMMITTEE_LABEL.plural, - icon: 'fa-light fa-users-rectangle', - routerLink: '/org/groups', + label: 'Events', + icon: 'fa-light fa-calendar', + routerLink: '/org/events', }, { - label: 'CLA Management', - icon: 'fa-light fa-file-signature', - routerLink: '/org/cla', + label: 'Training & Certification', + icon: 'fa-light fa-graduation-cap', + routerLink: '/org/training', }, { - label: 'Access & Permissions', - icon: 'fa-light fa-key', - routerLink: '/org/permissions', + label: 'Meetings', + icon: 'fa-light fa-video', + routerLink: '/org/meetings', }, { - label: 'Org Profile', - icon: 'fa-light fa-building', + label: COMMITTEE_LABEL.plural, + icon: 'fa-light fa-users-rectangle', + routerLink: '/org/groups', + }, + ], + }, + { + label: 'Org Admin', + isSection: true, + expanded: true, + items: [ + { + label: 'Profile', + icon: 'fa-light fa-file', routerLink: '/org/profile', }, ], diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html new file mode 100644 index 000000000..caf8fea9a --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.html @@ -0,0 +1,106 @@ + + + + + +
+ + + @if (row().foundationLogoUrl) { + + } @else { +
+ {{ initials() }} +
+ } + +
+ {{ row().foundationName }} +
+ @if (showProjectsInvolved()) { + {{ projectsInvolvedText() }} + } + + + {{ subtitleText() }} + +
+
+
+ + + + + {{ row().badges.orgRole }} + + + + + @if (row().badges.votingStatus === '—') { + + } @else { + + {{ row().badges.votingStatus }} + + } + + + + @if (row().badges.governanceParticipation === '—') { + + } @else { + + {{ row().badges.governanceParticipation }} + + } + + diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss new file mode 100644 index 000000000..48bd97bfa --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.scss @@ -0,0 +1,11 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Host element disappears from the box tree so the inner `` is a direct +// child of the parent `` (required for HTML table layout). This is +// the canonical Angular alternative to attribute-selector row components, +// which our eslint rules disallow (component selectors must be kebab-case +// elements per `@angular-eslint/component-selector`). +:host { + display: contents; +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts new file mode 100644 index 000000000..2c1246de1 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundation-row.component.ts @@ -0,0 +1,178 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, input, output } from '@angular/core'; +import { Router } from '@angular/router'; +import { TooltipModule } from 'primeng/tooltip'; + +import type { GovernanceParticipationBucket, OrgLensFoundationRow, OrgRoleBadge, VotingStatusBadge } from '@lfx-one/shared/interfaces'; + +import { foundationInitials, foundationLogoSquareClasses } from '../helpers/foundation-logo.helper'; +import { tierRibbonClasses } from '../helpers/tier-ribbon.helper'; + +/** + * Presentational foundation row. Renders the four-cell main `` inside + * its template: Name (logo + name + tier-ribbon subtitle + chevron), + * Org Role, Voting Status, Governance Participation. + * + * Hosting strategy: the component declares `:host { display: contents }` in + * SCSS so the `` element disappears from the box tree + * and the inner `` becomes a direct child of `` — required for + * HTML table layout. This is the Angular-canonical alternative to + * attribute-selector row components, which our `@angular-eslint/component-selector` + * rule disallows (must be kebab-case element selector). + * + * Inline-detail (expansion) data is rendered by the parent component in a + * sibling `` because expansion can grow/shrink independently of the + * presentational row. + * + * Behaviour-level concerns owned by this component: + * - Row-body click navigates to /org/memberships (no slug parameter — the + * slug-aware Memberships detail page is a follow-on feature; the + * current Memberships route is a placeholder page) for member / + * non_member rows; outside_lf is a no-op. When the slug-aware page + * lands, restore the slug argument here AND register + * `path: 'org/memberships/:foundationSlug'` in app.routes.ts. + * - Chevron click toggles expansion via the `toggle` output and stops + * propagation so the row-body click does NOT also fire. + * - Keyboard activation (Enter / Space) mirrors the click handler. + */ +@Component({ + selector: 'lfx-foundation-row', + imports: [TooltipModule], + templateUrl: './foundation-row.component.html', + styleUrls: ['./foundation-row.component.scss'], +}) +export class FoundationRowComponent { + public readonly row = input.required(); + public readonly expanded = input(false); + public readonly index = input(0); + + public readonly toggle = output(); + public readonly rowClick = output<{ foundationName: string; isMember: boolean }>(); + public readonly caretToggle = output<{ foundationName: string; expanded: boolean }>(); + + private readonly router = inject(Router); + + protected readonly logoSquareClasses = computed(() => foundationLogoSquareClasses(this.row().foundationId)); + protected readonly initials = computed(() => foundationInitials(this.row().foundationName)); + protected readonly ribbonClasses = computed(() => tierRibbonClasses(this.row().membershipTierClass, this.row().rowKind)); + + /** + * Subtitle pill text. Binds to the canonical `membershipTierClass` + * (NOT `membershipTierDisplayName`) so the badge renders the canonical + * tier label. + */ + protected readonly subtitleText = computed(() => { + const r = this.row(); + if (r.rowKind === 'outside_lf') return 'Outside LF'; + if (r.rowKind === 'non_member') return 'Non-member'; + const tier = r.membershipTierClass ?? 'Member'; + return `${tier} Member`; + }); + + protected readonly projectsInvolvedText = computed(() => { + const count = this.row().projectCount; + return `${count} project${count === 1 ? '' : 's'} involved`; + }); + + /** + * The "N projects involved · " prefix renders on every row regardless of + * `rowKind` (member / non_member / outside_lf). The visual design shows + * the prefix for non-member and Outside-LF rows too (e.g. "8 projects + * involved · Outside LF"), so the gate is kept as a signal but always + * resolves to true. Left as a computed so it can be tightened later + * without touching the template. + */ + protected readonly showProjectsInvolved = computed(() => true); + + protected readonly chevronAriaLabel = computed(() => { + const action = this.expanded() ? 'Collapse' : 'Expand'; + return `${action} ${this.row().foundationName}`; + }); + + protected readonly governanceTooltip = computed(() => { + const pct = this.row().badges.governanceAttendancePct; + if (pct == null) return ''; + const pctInt = Math.round(pct * 100); + return `${pctInt}% meeting attendance across governance, working groups, and project meetings`; + }); + + protected readonly governanceHeaderTooltip = + 'Average meeting attendance across governance, working groups, and project meetings. <33% Inactive · 33–66% Partial · >66% Active.'; + + protected readonly testIdSlug = computed(() => this.row().foundationSlug || this.row().foundationId); + + protected readonly trRole = computed(() => (this.row().rowKind === 'outside_lf' ? null : 'button')); + protected readonly trTabIndex = computed(() => (this.row().rowKind === 'outside_lf' ? null : 0)); + protected readonly trClasses = computed(() => { + const base = 'border-b border-gray-200 hover:bg-gray-50 transition-colors'; + return this.row().rowKind === 'outside_lf' ? `${base} cursor-default` : `${base} cursor-pointer`; + }); + + public onRowClick(event: MouseEvent): void { + // Defensive: if the click landed inside a real interactive child + // (chevron button, future inline link, etc.), let that child handle + // it instead of firing the row navigation. We deliberately do NOT + // match `[role="button"]` here because the row's own carries + // `role="button"` and a `closest()` query would always match the row + // itself — silently swallowing every row click. The chevron button + // already calls `event.stopPropagation()`, so this branch is mainly + // a safety net for future inner affordances. + const target = event.target as HTMLElement | null; + if (target && target.closest('button, a')) { + return; + } + const r = this.row(); + if (r.rowKind === 'outside_lf') { + return; + } + this.rowClick.emit({ foundationName: r.foundationName, isMember: r.rowKind === 'member' }); + void this.router.navigate(['/org/memberships']); + } + + public onRowKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + const r = this.row(); + if (r.rowKind === 'outside_lf') return; + event.preventDefault(); + this.rowClick.emit({ foundationName: r.foundationName, isMember: r.rowKind === 'member' }); + void this.router.navigate(['/org/memberships']); + } + + public onChevronClick(event: Event): void { + event.stopPropagation(); + const nextExpanded = !this.expanded(); + this.toggle.emit(); + this.caretToggle.emit({ foundationName: this.row().foundationName, expanded: nextExpanded }); + } + + /** Org Role badge palette via Tailwind utilities → lfxColors. */ + protected orgRoleBadgeClasses(badge: OrgRoleBadge): string { + if (badge === 'Director' || badge === 'Member') { + return 'bg-blue-100 text-blue-700 border-blue-200'; + } + return 'bg-white text-gray-600 border-gray-300'; + } + + protected votingStatusBadgeClasses(badge: VotingStatusBadge): string { + if (badge === 'Voting' || badge === 'Observer') { + return 'bg-blue-100 text-blue-700 border-blue-200'; + } + return 'text-gray-400'; + } + + protected governanceBadgeClasses(bucket: GovernanceParticipationBucket): string { + switch (bucket) { + case 'Active': + return 'bg-emerald-100 text-emerald-700'; + case 'Partial': + return 'bg-amber-100 text-amber-700'; + case 'Inactive': + return 'bg-gray-100 text-gray-500'; + case '—': + default: + return 'text-gray-400'; + } + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html new file mode 100644 index 000000000..9b7b1d6ea --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.html @@ -0,0 +1,60 @@ + + + +
+
+ +
+ + + +
+
{{ foundationsTotal() }}
+
Foundations
+
{{ foundationsDetail() }}
+
+
+ + +
+ + + +
+
{{ projectsTotal() }}
+
Projects
+
{{ projectsDetail() }}
+
+
+ + +
+ + + +
+
{{ governanceTotal() }}
+
Governance Roles
+
{{ governanceDetail() }}
+
+
+ + +
+ + + +
+
{{ meetingsTotal() }}
+
Meetings This Week
+
{{ meetingsDetail() }}
+
+
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts new file mode 100644 index 000000000..c68c219ba --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/components/foundations-stat-strip.component.ts @@ -0,0 +1,117 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, input } from '@angular/core'; + +import type { MembershipTierClass, OrgLensFoundationsStatStrip } from '@lfx-one/shared/interfaces'; + +/** + * Canonical 13-class tier order. Used to order the foundations tile's + * detail line. Zero-bucket entries are suppressed at render time. + */ +const TIER_ORDER: MembershipTierClass[] = [ + 'Platinum', + 'Premier', + 'Founding', + 'Strategic', + 'Gold', + 'Steering', + 'Silver', + 'General', + 'Associate', + 'End User', + 'Academic', + 'Contributor', + 'Other', +]; + +/** + * 4-tile stat strip presented above the foundations table: + * - Foundations + * - Projects (Leading / Contributing / Participating / Silent) + * - Governance Roles (Board members / Committee members) + * - Meetings This Week (Board / Technical / Marketing / WG / Other) + * + * Every colour resolves through `lfxColors`. Zero-bucket entries are + * suppressed. When a tile's total is zero, the big number still renders + * as `0` (so users see the truthful count, not a "data missing" hint); + * only the detail line collapses to a single em-dash placeholder. + */ +@Component({ + selector: 'lfx-foundations-stat-strip', + imports: [], + templateUrl: './foundations-stat-strip.component.html', +}) +export class FoundationsStatStripComponent { + public readonly strip = input.required(); + + /** + * Detail-line placeholder. Any empty `parts` array — every sub-bucket + * of the tile is zero — collapses to a single em-dash so the tile + * never renders a list of zeros. + */ + private static readonly emptyDetail = '—'; + + /** + * Foundations tile — ordered by the canonical 13-class ladder, zero + * suppressed. Design format: "Platinum (2), Gold (1), …". + */ + protected readonly foundationsDetail = computed(() => { + const breakdown = this.strip().foundations.breakdown; + const parts: string[] = []; + for (const tier of TIER_ORDER) { + const count = breakdown[tier]; + if (count && count > 0) { + parts.push(`${tier} (${count})`); + } + } + return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail; + }); + + /** + * Projects tile — Leading / Contributing / Participating / Silent. + * Design format: "Leading (3), Contributing (1), …". + */ + protected readonly projectsDetail = computed(() => { + const p = this.strip().projects; + const parts: string[] = []; + if (p.leading > 0) parts.push(`Leading (${p.leading})`); + if (p.contributing > 0) parts.push(`Contributing (${p.contributing})`); + if (p.participating > 0) parts.push(`Participating (${p.participating})`); + if (p.silent > 0) parts.push(`Silent (${p.silent})`); + return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail; + }); + + /** + * Governance roles tile — design format: + * "Board members (B) Committee members (C)" (space-separated, plural + * always, matching the `mfp-governance-sub` renderer in the design). + */ + protected readonly governanceDetail = computed(() => { + const g = this.strip().governanceRoles; + const parts: string[] = []; + if (g.boardMembers > 0) parts.push(`Board members (${g.boardMembers})`); + if (g.committeeMembers > 0) parts.push(`Committee members (${g.committeeMembers})`); + return parts.length > 0 ? parts.join('\u00A0\u00A0\u00A0') : FoundationsStatStripComponent.emptyDetail; + }); + + /** + * Meetings this week tile — 5-way category split. Design format: + * "Board (3), Technical (6), Working Group (10), Other (5)". + */ + protected readonly meetingsDetail = computed(() => { + const m = this.strip().meetingsThisWeek; + const parts: string[] = []; + if (m.board > 0) parts.push(`Board (${m.board})`); + if (m.technical > 0) parts.push(`Technical (${m.technical})`); + if (m.marketing > 0) parts.push(`Marketing (${m.marketing})`); + if (m.workingGroup > 0) parts.push(`Working Group (${m.workingGroup})`); + if (m.other > 0) parts.push(`Other (${m.other})`); + return parts.length > 0 ? parts.join(', ') : FoundationsStatStripComponent.emptyDetail; + }); + + protected readonly foundationsTotal = computed(() => this.strip().foundations.total); + protected readonly projectsTotal = computed(() => this.strip().projects.total); + protected readonly governanceTotal = computed(() => this.strip().governanceRoles.total); + protected readonly meetingsTotal = computed(() => this.strip().meetingsThisWeek.total); +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts new file mode 100644 index 000000000..9a47d9629 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/foundation-logo.helper.ts @@ -0,0 +1,54 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Foundation-logo letter-square fallback palette. + * + * Deterministic `djb2(foundation_id) % 5` map into the chromatic LFX + * families [blue, emerald, amber, red, violet]. Excludes `gray` so the + * letter square never blends into the Outside-LF umbrella row's gray + * pill. Same `foundation_id` always maps to the same colour across + * renders / sort changes / org switches — stable identity cue, not a + * row-position cue. + * + * Each family renders as `bg-{family}-600 text-white` for high-contrast + * initials. Class strings are full literals so Tailwind JIT picks them + * up from source. + */ + +const SQUARE_PALETTE = ['bg-blue-600 text-white', 'bg-emerald-600 text-white', 'bg-amber-600 text-white', 'bg-red-600 text-white', 'bg-violet-600 text-white']; + +/** Classic djb2 string hash (Daniel J. Bernstein). Returns unsigned 32-bit. */ +function djb2(input: string): number { + let hash = 5381; + for (let i = 0; i < input.length; i += 1) { + // hash * 33 ^ char + hash = ((hash << 5) + hash) ^ input.charCodeAt(i); + } + return hash >>> 0; +} + +export function foundationLogoSquareClasses(foundationId: string): string { + const index = djb2(foundationId) % SQUARE_PALETTE.length; + return SQUARE_PALETTE[index]; +} + +/** + * Foundation initials. First letter of each capital-led word, max 2 chars. + * Falls back to the first 2 characters of the name uppercased if the + * name has no capital-led words (e.g., lowercase slug fallback). + */ +export function foundationInitials(name: string): string { + if (!name) return '?'; + + const capitalWords = name.match(/[A-Z][A-Za-z0-9]*/g); + if (capitalWords && capitalWords.length > 0) { + const initials = capitalWords + .slice(0, 2) + .map((word) => word.charAt(0)) + .join(''); + return initials.toUpperCase(); + } + + return name.slice(0, 2).toUpperCase(); +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts new file mode 100644 index 000000000..a2225c316 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/helpers/tier-ribbon.helper.ts @@ -0,0 +1,50 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { MembershipTierClass, OrgLensRowKind } from '@lfx-one/shared/interfaces'; + +/** + * Tier-ribbon pill palette. + * + * Rank-banded mapping into 4 LFX semantic families: + * - Top-4 (premium): Platinum / Premier / Founding / Strategic → violet + * - Mid (sponsor): Gold / Steering / Silver → amber + * - Standard: General / Associate → blue + * - Free / observer: End User / Academic / Contributor / Other → gray + * - Non-member LF row: → amber outline + * - Outside LF row: → gray + * + * Every class string resolves through `lfxColors` via the Tailwind + * theme extension (apps/lfx-one/tailwind.config.js wires `theme.extend + * .colors = lfxColors`). NO raw hex; NO non-LFX palettes. + */ +export function tierRibbonClasses(tierClass: MembershipTierClass | null | undefined, rowKind: OrgLensRowKind): string { + if (rowKind === 'outside_lf') { + return 'bg-gray-100 text-gray-600'; + } + + if (rowKind === 'non_member') { + return 'bg-amber-50 text-amber-700 border border-amber-200'; + } + + switch (tierClass) { + case 'Platinum': + case 'Premier': + case 'Founding': + case 'Strategic': + return 'bg-violet-100 text-violet-700'; + case 'Gold': + case 'Steering': + case 'Silver': + return 'bg-amber-100 text-amber-700'; + case 'General': + case 'Associate': + return 'bg-blue-100 text-blue-700'; + case 'End User': + case 'Academic': + case 'Contributor': + case 'Other': + default: + return 'bg-gray-100 text-gray-600'; + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html new file mode 100644 index 000000000..d9f1c0594 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html @@ -0,0 +1,142 @@ + + + +
+ +
+

+ + {{ companyName() }}'s Foundations and Projects +

+ View all on Memberships page › +
+ + @if (loading()) { + +
+
+ @for (i of [1, 2, 3, 4]; track i) { +
+ + + +
+ } +
+
+ @for (i of [1, 2, 3, 4, 5, 6]; track i) { + + } +
+
+ } @else if (error()) { + +
+

Couldn't load Foundations and Projects.

+ +
+ } @else { + + + + @if (isEmpty()) { + +

No foundations or projects yet.

+ } @else { + +
+ + + + + + + + + + + @for (row of rows(); track row.foundationId; let i = $index) { + + @if (isExpanded(row.foundationId)) { + + + + } + } + +
FoundationOrg RoleVoting StatusGovernance Participation
+ @if (row.projects.length === 0) { +

No projects from this foundation are currently active for your org.

+ } @else { +
+ Projects Involved ({{ row.projects.length }}) +
+ + + + + + + + + + + + @for (project of row.projects; track project.projectId) { + + + + + + + + } + +
ProjectMaintainersContributorsCollaboratorsCommits
+ + + {{ project.projectName }} + + {{ project.maintainers.toLocaleString() }}{{ project.contributors.toLocaleString() }}{{ project.collaborators.toLocaleString() }}{{ project.commits.toLocaleString() }}
+ } +
+
+ } + } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss new file mode 100644 index 000000000..f4ef3d296 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.scss @@ -0,0 +1,9 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Layout intentionally Tailwind-utility-driven. +// This file is a placeholder so the @Component { styleUrls } reference +// resolves cleanly; per-element styles live in the template. +:host { + display: block; +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts new file mode 100644 index 000000000..1854ae1fa --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.ts @@ -0,0 +1,214 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; +import { TableModule } from 'primeng/table'; +import { SkeletonModule } from 'primeng/skeleton'; +import { AccountContextService } from '@services/account-context.service'; +import { OrgLensFoundationsService } from '@services/org-lens-foundations.service'; +import { PlausibleService } from '@services/plausible.service'; +import { catchError, combineLatest, map, of, startWith, switchMap, tap } from 'rxjs'; + +import type { OrgLensFoundationRow, OrgLensFoundationsAndProjectsResponse } from '@lfx-one/shared/interfaces'; + +import { FoundationRowComponent } from './components/foundation-row.component'; +import { FoundationsStatStripComponent } from './components/foundations-stat-strip.component'; + +interface SectionState { + status: 'loading' | 'error' | 'ready' | 'empty'; + data: OrgLensFoundationsAndProjectsResponse | null; +} + +const EMPTY_RESPONSE: OrgLensFoundationsAndProjectsResponse = { + accountId: '', + accountName: '', + statStrip: { + foundations: { total: 0, breakdown: {} }, + projects: { total: 0, leading: 0, contributing: 0, participating: 0, silent: 0 }, + governanceRoles: { total: 0, boardMembers: 0, committeeMembers: 0 }, + meetingsThisWeek: { total: 0, board: 0, technical: 0, marketing: 0, workingGroup: 0, other: 0 }, + }, + rows: [], +}; + +const INITIAL_STATE: SectionState = { status: 'loading', data: null }; + +/** + * Parent component for the Org Lens "Foundations and Projects" section. + * + * Owns: + * - Data fetch keyed on the selected org id (re-fetch on switch). + * - Loading / error / ready / empty status signal. + * - Row expansion state cleared on org switch. + * - Retry trigger. + * - First-render telemetry per org (`overview_view` Plausible event). + * + * Renders: + * - Section header with org name + "View all on Memberships page ›" link. + * - 4-tile stat strip (FoundationsStatStripComponent). + * - Foundations table with per-row FoundationRowComponent + inline-detail expansion. + * - Loading skeletons / retry affordance / empty caption. + */ +@Component({ + selector: 'lfx-org-overview-foundations-and-projects', + imports: [FoundationRowComponent, FoundationsStatStripComponent, RouterLink, SkeletonModule, TableModule], + templateUrl: './org-overview-foundations-and-projects.component.html', + styleUrls: ['./org-overview-foundations-and-projects.component.scss'], +}) +export class OrgOverviewFoundationsAndProjectsComponent { + private readonly accountContextService = inject(AccountContextService); + private readonly foundationsService = inject(OrgLensFoundationsService); + private readonly plausibleService = inject(PlausibleService); + private readonly router = inject(Router); + + protected readonly companyName = computed(() => this.accountContextService.selectedAccount().accountName || 'Your Organization'); + + private readonly retryTrigger = signal(0); + + private readonly accountId$ = toObservable(this.accountContextService.selectedAccount).pipe(map((account) => account.accountId)); + private readonly retryTrigger$ = toObservable(this.retryTrigger); + + /** + * Combined stream: re-fetches when the selected account changes OR + * when the retry trigger ticks. `combineLatest` ensures both feed in; + * `startWith` primes the retry stream so the first emission fires on + * mount. + */ + private readonly state = toSignal( + combineLatest([this.accountId$, this.retryTrigger$.pipe(startWith(0))]).pipe( + switchMap(([accountId]) => { + if (!accountId) { + return of({ status: 'empty', data: EMPTY_RESPONSE }); + } + return this.foundationsService.getFoundationsAndProjects(accountId).pipe( + map((data) => ({ + status: data.rows.length === 0 ? 'empty' : 'ready', + data, + })), + startWith({ status: 'loading', data: null }), + catchError(() => of({ status: 'error', data: null })), + tap((s) => { + if (s.status === 'ready' || s.status === 'empty') { + this.emitOverviewViewOnce(accountId); + } + }) + ); + }) + ), + { initialValue: INITIAL_STATE } + ); + + protected readonly loading = computed(() => this.state().status === 'loading'); + protected readonly error = computed(() => this.state().status === 'error'); + protected readonly statStrip = computed(() => (this.state().data ?? EMPTY_RESPONSE).statStrip); + protected readonly rows = computed(() => (this.state().data ?? EMPTY_RESPONSE).rows); + protected readonly isEmpty = computed(() => this.state().status === 'empty'); + + // Per-row expansion state. Cleared whenever the selected account + // changes so previous-org expansions never leak into a new org. + private readonly expansionState = signal>({}); + + /** Telemetry de-dupe: emit `overview_view` once per org per session. */ + private readonly viewedOrgs = new Set(); + + public constructor() { + effect(() => { + // Re-read selectedAccount so this effect re-runs on every change. + this.accountContextService.selectedAccount(); + this.expansionState.set({}); + }); + } + + protected isExpanded(foundationId: string): boolean { + return this.expansionState()[foundationId] === true; + } + + protected toggleExpansion(foundationId: string): void { + this.expansionState.update((state) => { + const next = { ...state }; + if (next[foundationId]) { + delete next[foundationId]; + } else { + next[foundationId] = true; + } + return next; + }); + } + + protected retry(): void { + this.retryTrigger.update((n) => n + 1); + } + + protected onRowClick(payload: { foundationName: string; isMember: boolean }): void { + const orgId = this.accountContextService.selectedAccount().accountId; + this.plausibleService.trackEvent('mfp_row_click', { + orgId, + foundationName: payload.foundationName, + isMember: payload.isMember, + }); + } + + protected onCaretToggle(payload: { foundationName: string; expanded: boolean }): void { + const orgId = this.accountContextService.selectedAccount().accountId; + this.plausibleService.trackEvent('mfp_caret_toggle', { + orgId, + foundationName: payload.foundationName, + expanded: payload.expanded, + }); + } + + protected onProjectClick(payload: { projectId: string; projectName: string }): void { + const orgId = this.accountContextService.selectedAccount().accountId; + this.plausibleService.trackEvent('mfp_project_row_click', { + orgId, + projectId: payload.projectId, + projectName: payload.projectName, + }); + } + + protected influenceDotClasses(influence: OrgLensFoundationRow['projects'][number]['influence']): string { + switch (influence) { + case 'Leading': + return 'bg-emerald-700'; + case 'Contributing': + return 'bg-blue-500'; + case 'Participating': + return 'bg-amber-500'; + case 'Silent': + default: + return 'bg-gray-400'; + } + } + + protected projectSlugTestId(slug: string | null | undefined, projectId: string): string { + return slug && slug.length > 0 ? slug : projectId; + } + + protected onProjectRowClick(project: OrgLensFoundationRow['projects'][number]): void { + // LF project-row clicks route to `/org/projects` (no slug, no + // ProjectContext mutation, no lens switch) until the slug-aware + // per-project drilldown destination lands as a follow-on feature. + // Keeping the user inside the Org Lens is intentional — switching + // them out via `lensService.setLens('project')` on a row click was + // jarring. Non-LF rows (under the Outside-LF umbrella) remain + // intentional no-ops (category mismatch with /org/projects). + if (!project.isLfProject) return; + this.onProjectClick({ projectId: project.projectId, projectName: project.projectName }); + void this.router.navigate(['/org/projects']); + } + + protected onProjectRowKeydown(event: KeyboardEvent, project: OrgLensFoundationRow['projects'][number]): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (!project.isLfProject) return; + event.preventDefault(); + this.onProjectRowClick(project); + } + + private emitOverviewViewOnce(accountId: string): void { + if (this.viewedOrgs.has(accountId)) return; + this.viewedOrgs.add(accountId); + this.plausibleService.trackEvent('overview_view', { orgId: accountId }); + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html new file mode 100644 index 000000000..6f254b6d5 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.html @@ -0,0 +1,68 @@ + + + +
+ +
+
+

{{ accountName() }}'s Involvement

+ {{ subtitleText() }} +
+
+ +
+ + +
+
+ +
+ +
+ +
+
+ @for (metric of primaryMetrics(); track metric.testId) { + + } +
+ + + + + + +
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss new file mode 100644 index 000000000..121c97271 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.scss @@ -0,0 +1,11 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + + &::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts new file mode 100644 index 000000000..9499f9cbf --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-involvement/org-overview-involvement.component.ts @@ -0,0 +1,492 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { FilterPillsComponent } from '@components/filter-pills/filter-pills.component'; +import { MetricCardComponent } from '@components/metric-card/metric-card.component'; +import { BASE_BAR_CHART_OPTIONS, BASE_LINE_CHART_OPTIONS, lfxColors, ORG_INVOLVEMENT_METRICS } from '@lfx-one/shared/constants'; +import { hexToRgba } from '@lfx-one/shared/utils'; +import { AccountContextService } from '@services/account-context.service'; +import { OrgInvolvementAnalyticsService } from '@services/org-involvement-analytics.service'; +import { ScrollShadowDirective } from '@shared/directives/scroll-shadow.directive'; +import { catchError, map, of, switchMap, tap } from 'rxjs'; + +import type { + OrgInvolvementCertifiedEmployeesMonthlyResponse, + OrgInvolvementContributorsMonthlyResponse, + OrgInvolvementEventAttendanceMonthlyResponse, + OrgFoundationCoverageResponse, + OrgInvolvementMaintainersMonthlyResponse, + OrgTrainingEnrollmentsResponse, +} from '@lfx-one/shared/interfaces/org-involvement.interface'; +import type { DashboardMetricCard, FilterPillOption } from '@lfx-one/shared/interfaces'; +import type { ChartOptions, ChartType } from 'chart.js'; + +@Component({ + selector: 'lfx-org-overview-involvement', + imports: [FilterPillsComponent, MetricCardComponent, ScrollShadowDirective], + templateUrl: './org-overview-involvement.component.html', + styleUrl: './org-overview-involvement.component.scss', +}) +export class OrgOverviewInvolvementComponent { + public readonly scrollShadowDirective = viewChild(ScrollShadowDirective); + + private readonly analyticsService = inject(OrgInvolvementAnalyticsService); + private readonly accountContextService = inject(AccountContextService); + + private readonly maintainersLoading = signal(true); + private readonly contributorsLoading = signal(true); + private readonly certifiedEmployeesLoading = signal(true); + private readonly trainingEnrollmentsLoading = signal(true); + private readonly eventsLoading = signal(true); + private readonly coverageLoading = signal(true); + + private readonly selectedAccountId$ = toObservable(this.accountContextService.selectedAccount).pipe(map((account) => account.accountId)); + + private readonly coverageData = this.initializeCoverageData(); + private readonly maintainersData = this.initializeMaintainersData(); + private readonly contributorsData = this.initializeContributorsData(); + private readonly certifiedEmployeesData = this.initializeCertifiedEmployeesData(); + private readonly trainingEnrollmentsData = this.initializeTrainingEnrollmentsData(); + private readonly eventAttendanceMonthlyData = this.initializeEventAttendanceMonthlyData(); + + public readonly selectedFilter = signal('all'); + public readonly accountName = computed(() => this.accountContextService.selectedAccount().accountName || 'Organization'); + + public readonly subtitleText = computed(() => { + const coverage = this.coverageData(); + if (this.coverageLoading()) { + return ''; + } + return coverage.foundationCount > 0 ? `across ${coverage.foundationCount} LF foundations` : 'No engagement yet'; + }); + + public readonly filterOptions: FilterPillOption[] = [ + { id: 'all', label: 'All' }, + { id: 'contributions', label: 'Contribution' }, + { id: 'events', label: 'Event' }, + { id: 'education', label: 'Education' }, + ]; + + private readonly activeContributorsCard = this.initializeActiveContributorsCard(); + private readonly maintainersCard = this.initializeMaintainersCard(); + private readonly eventAttendeesCard = this.initializeEventAttendeesCard(); + private readonly eventSpeakersCard = this.initializeEventSpeakersCard(); + private readonly certifiedEmployeesCard = this.initializeCertifiedEmployeesCard(); + private readonly trainingEnrollmentsCard = this.initializeTrainingEnrollmentsCard(); + + public readonly primaryMetrics = this.initializePrimaryMetrics(); + + public handleFilterChange(filter: string): void { + this.selectedFilter.set(filter); + } + + private getMetricConfig(title: string): DashboardMetricCard { + return ORG_INVOLVEMENT_METRICS.find((m) => m.title === title)!; + } + + private initializeActiveContributorsCard() { + return computed(() => this.transformActiveContributors(this.contributorsData(), this.getMetricConfig('Active Contributors'))); + } + + private initializeMaintainersCard() { + return computed(() => this.transformMaintainers(this.maintainersData(), this.getMetricConfig('Maintainers'))); + } + + private initializeEventAttendeesCard() { + return computed(() => this.transformEventAttendees(this.eventAttendanceMonthlyData(), this.getMetricConfig('Event Attendees'))); + } + + private initializeEventSpeakersCard() { + return computed(() => this.transformEventSpeakers(this.eventAttendanceMonthlyData(), this.getMetricConfig('Event Speakers'))); + } + + private initializeCertifiedEmployeesCard() { + return computed(() => this.transformCertifiedEmployees(this.certifiedEmployeesData(), this.getMetricConfig('Certified Employees'))); + } + + private initializeTrainingEnrollmentsCard() { + return computed(() => this.transformTrainingEnrollments(this.trainingEnrollmentsData(), this.getMetricConfig('Training Enrollments'))); + } + + private initializePrimaryMetrics() { + return computed(() => { + const filter = this.selectedFilter(); + + const allCards = [ + { card: this.activeContributorsCard(), category: 'contributions' }, + { card: this.maintainersCard(), category: 'contributions' }, + { card: this.eventAttendeesCard(), category: 'events' }, + { card: this.eventSpeakersCard(), category: 'events' }, + { card: this.certifiedEmployeesCard(), category: 'education' }, + { card: this.trainingEnrollmentsCard(), category: 'education' }, + ]; + + if (filter === 'all') { + return allCards.map((item) => item.card); + } + + return allCards.filter((item) => item.category === filter).map((item) => item.card); + }); + } + + private initializeCoverageData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.coverageLoading.set(true); + return this.analyticsService.getFoundationCoverage(accountId).pipe( + tap(() => this.coverageLoading.set(false)), + catchError(() => { + this.coverageLoading.set(false); + return of({ accountId: '', foundationCount: 0, foundations: [] } as OrgFoundationCoverageResponse); + }) + ); + }) + ), + { initialValue: { accountId: '', foundationCount: 0, foundations: [] } as OrgFoundationCoverageResponse } + ); + } + + private initializeMaintainersData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.maintainersLoading.set(true); + return this.analyticsService.getMaintainersMonthly(accountId).pipe( + tap(() => this.maintainersLoading.set(false)), + catchError(() => { + this.maintainersLoading.set(false); + return of({ + accountId: '', + accountName: '', + totalMaintainersYearly: 0, + totalProjectsYearly: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementMaintainersMonthlyResponse); + }) + ); + }) + ), + { + initialValue: { + accountId: '', + accountName: '', + totalMaintainersYearly: 0, + totalProjectsYearly: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementMaintainersMonthlyResponse, + } + ); + } + + private initializeContributorsData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.contributorsLoading.set(true); + return this.analyticsService.getContributorsMonthly(accountId).pipe( + tap(() => this.contributorsLoading.set(false)), + catchError(() => { + this.contributorsLoading.set(false); + return of({ + accountId: '', + totalActiveContributors: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementContributorsMonthlyResponse); + }) + ); + }) + ), + { + initialValue: { + accountId: '', + totalActiveContributors: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementContributorsMonthlyResponse, + } + ); + } + + private initializeCertifiedEmployeesData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.certifiedEmployeesLoading.set(true); + return this.analyticsService.getCertifiedEmployeesMonthly(accountId).pipe( + tap(() => this.certifiedEmployeesLoading.set(false)), + catchError(() => { + this.certifiedEmployeesLoading.set(false); + return of({ + accountId: '', + totalCertifications: 0, + totalCertifiedEmployees: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementCertifiedEmployeesMonthlyResponse); + }) + ); + }) + ), + { + initialValue: { + accountId: '', + totalCertifications: 0, + totalCertifiedEmployees: 0, + monthlyData: [], + monthlyLabels: [], + } as OrgInvolvementCertifiedEmployeesMonthlyResponse, + } + ); + } + + private initializeTrainingEnrollmentsData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.trainingEnrollmentsLoading.set(true); + return this.analyticsService.getTrainingEnrollments(accountId).pipe( + tap(() => this.trainingEnrollmentsLoading.set(false)), + catchError(() => { + this.trainingEnrollmentsLoading.set(false); + return of({ accountId: '', totalEnrollments: 0, dailyData: [] } as OrgTrainingEnrollmentsResponse); + }) + ); + }) + ), + { initialValue: { accountId: '', totalEnrollments: 0, dailyData: [] } as OrgTrainingEnrollmentsResponse } + ); + } + + private initializeEventAttendanceMonthlyData() { + return toSignal( + this.selectedAccountId$.pipe( + switchMap((accountId) => { + this.eventsLoading.set(true); + return this.analyticsService.getEventAttendanceMonthly(accountId).pipe( + tap(() => this.eventsLoading.set(false)), + catchError(() => { + this.eventsLoading.set(false); + return of({ + accountId: '', + accountName: '', + totalAttended: 0, + totalSpeakers: 0, + attendeesMonthlyData: [], + speakersMonthlyData: [], + monthlyLabels: [], + } as OrgInvolvementEventAttendanceMonthlyResponse); + }) + ); + }) + ), + { + initialValue: { + accountId: '', + accountName: '', + totalAttended: 0, + totalSpeakers: 0, + attendeesMonthlyData: [], + speakersMonthlyData: [], + monthlyLabels: [], + } as OrgInvolvementEventAttendanceMonthlyResponse, + } + ); + } + + private transformActiveContributors(data: OrgInvolvementContributorsMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard { + return { + ...metric, + loading: this.contributorsLoading(), + value: data.totalActiveContributors.toString(), + subtitle: 'Employees actively contributing to projects', + chartOptions: this.createBarChartOptions('Active contributors'), + chartData: + data.monthlyData.length > 0 + ? { + labels: data.monthlyLabels, + datasets: [ + { + data: data.monthlyData, + borderColor: lfxColors.blue[500], + backgroundColor: hexToRgba(lfxColors.blue[500], 0.5), + borderWidth: 0, + borderRadius: 4, + }, + ], + } + : metric.chartData, + }; + } + + private transformMaintainers(data: OrgInvolvementMaintainersMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard { + const projectLabel = data.totalProjectsYearly === 1 ? 'project' : 'projects'; + return { + ...metric, + loading: this.maintainersLoading(), + value: data.totalMaintainersYearly.toString(), + subtitle: `Employees stewarding ${projectLabel} across foundations`, + chartOptions: this.createBarChartOptions('Maintainers'), + chartData: + data.monthlyData.length > 0 + ? { + labels: data.monthlyLabels, + datasets: [ + { + data: data.monthlyData, + borderColor: lfxColors.blue[500], + backgroundColor: hexToRgba(lfxColors.blue[500], 0.5), + borderWidth: 0, + borderRadius: 4, + }, + ], + } + : metric.chartData, + }; + } + + private transformEventAttendees(data: OrgInvolvementEventAttendanceMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard { + return { + ...metric, + loading: this.eventsLoading(), + value: data.totalAttended.toString(), + subtitle: 'Employees at foundation events', + chartOptions: this.createLineChartOptions('Event attendees'), + chartData: + data.attendeesMonthlyData.length > 0 + ? { + labels: data.monthlyLabels, + datasets: [ + { + data: data.attendeesMonthlyData, + borderColor: lfxColors.emerald[500], + backgroundColor: hexToRgba(lfxColors.emerald[500], 0.1), + fill: true, + tension: 0, + borderWidth: 2, + pointRadius: 0, + }, + ], + } + : metric.chartData, + }; + } + + private transformEventSpeakers(data: OrgInvolvementEventAttendanceMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard { + return { + ...metric, + loading: this.eventsLoading(), + value: data.totalSpeakers.toString(), + subtitle: 'Employee speakers at events', + chartOptions: this.createLineChartOptions('Event speakers'), + chartData: + data.speakersMonthlyData.length > 0 + ? { + labels: data.monthlyLabels, + datasets: [ + { + data: data.speakersMonthlyData, + borderColor: lfxColors.amber[500], + backgroundColor: hexToRgba(lfxColors.amber[500], 0.1), + fill: true, + tension: 0, + borderWidth: 2, + pointRadius: 0, + }, + ], + } + : metric.chartData, + }; + } + + private transformCertifiedEmployees(data: OrgInvolvementCertifiedEmployeesMonthlyResponse, metric: DashboardMetricCard): DashboardMetricCard { + return { + ...metric, + loading: this.certifiedEmployeesLoading(), + value: `${data.totalCertifiedEmployees} employees`, + subtitle: `${data.totalCertifications} total certifications`, + chartOptions: this.createLineChartOptions('Certifications'), + chartData: + data.monthlyData.length > 0 + ? { + labels: data.monthlyLabels, + datasets: [ + { + data: data.monthlyData, + borderColor: lfxColors.violet[500], + backgroundColor: hexToRgba(lfxColors.violet[500], 0.1), + fill: true, + tension: 0, + borderWidth: 2, + pointRadius: 0, + }, + ], + } + : metric.chartData, + }; + } + + private transformTrainingEnrollments(data: OrgTrainingEnrollmentsResponse, metric: DashboardMetricCard): DashboardMetricCard { + return { + ...metric, + loading: this.trainingEnrollmentsLoading(), + value: data.totalEnrollments.toString(), + subtitle: 'Training courses enrolled this year', + chartOptions: this.createLineChartOptions('Training enrollments'), + chartData: { + labels: data.dailyData.map((row) => { + const date = new Date(row.date); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }), + datasets: [ + { + data: data.dailyData.map((row) => row.cumulativeCount), + borderColor: lfxColors.blue[500], + backgroundColor: hexToRgba(lfxColors.blue[500], 0.1), + fill: true, + tension: 0, + borderWidth: 2, + pointRadius: 0, + }, + ], + }, + }; + } + + private createBarChartOptions(label: string): ChartOptions { + return { + ...BASE_BAR_CHART_OPTIONS, + plugins: { + ...BASE_BAR_CHART_OPTIONS.plugins, + tooltip: { + ...(BASE_BAR_CHART_OPTIONS.plugins?.tooltip ?? {}), + callbacks: { + title: (context) => context[0]?.label ?? '', + label: (context) => `${label}: ${context.parsed.y ?? 0}`, + }, + }, + }, + }; + } + + private createLineChartOptions(label: string): ChartOptions { + return { + ...BASE_LINE_CHART_OPTIONS, + plugins: { + ...BASE_LINE_CHART_OPTIONS.plugins, + tooltip: { + ...(BASE_LINE_CHART_OPTIONS.plugins?.tooltip ?? {}), + callbacks: { + title: (context) => context[0]?.label ?? '', + label: (context) => `${label}: ${context.parsed.y ?? 0}`, + }, + }, + }, + }; + } +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html new file mode 100644 index 000000000..04692bf09 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.html @@ -0,0 +1,15 @@ + + + +
+
+

{{ title() }}

+

{{ description() }}

+
+ + +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts new file mode 100644 index 000000000..e55245993 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-placeholder-page/org-placeholder-page.component.ts @@ -0,0 +1,28 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Data } from '@angular/router'; +import { EmptyStateComponent } from '@components/empty-state/empty-state.component'; + +interface OrgPlaceholderRouteData { + title?: string; + description?: string; + icon?: string; +} + +@Component({ + selector: 'lfx-org-placeholder-page', + imports: [EmptyStateComponent], + templateUrl: './org-placeholder-page.component.html', +}) +export class OrgPlaceholderPageComponent { + private readonly route = inject(ActivatedRoute); + + private readonly routeData: Signal = toSignal(this.route.data, { initialValue: {} as Data }); + + protected readonly title = computed(() => (this.routeData() as OrgPlaceholderRouteData).title ?? 'Coming Soon'); + protected readonly description = computed(() => (this.routeData() as OrgPlaceholderRouteData).description ?? 'This view is in development.'); + protected readonly icon = computed(() => (this.routeData() as OrgPlaceholderRouteData).icon ?? 'fa-light fa-screwdriver-wrench'); +} diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html new file mode 100644 index 000000000..94bab15b0 --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.html @@ -0,0 +1,69 @@ + + + +
+
+

{{ companyName() }} Overview

+ @if (tierLabel(); as tier) { + + } +
+ + + @defer (on viewport) { + + } @placeholder { +
+
+
+ + +
+
+ + +
+
+
+ @for (i of [1, 2, 3]; track i) { +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ } +
+
+ } + + + @defer (on viewport) { + + } @placeholder { +
+
+ + +
+
+ @for (i of [1, 2, 3, 4]; track i) { +
+ + + +
+ } +
+
+ } +
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts new file mode 100644 index 000000000..4e693f24a --- /dev/null +++ b/apps/lfx-one/src/app/modules/dashboards/org/org-overview/org-overview.component.ts @@ -0,0 +1,25 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, Signal } from '@angular/core'; +import { TagComponent } from '@components/tag/tag.component'; +import { SkeletonModule } from 'primeng/skeleton'; +import { AccountContextService } from '@services/account-context.service'; + +import { OrgOverviewFoundationsAndProjectsComponent } from '../components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component'; +import { OrgOverviewInvolvementComponent } from '../components/org-overview-involvement/org-overview-involvement.component'; + +@Component({ + selector: 'lfx-org-overview', + imports: [TagComponent, SkeletonModule, OrgOverviewInvolvementComponent, OrgOverviewFoundationsAndProjectsComponent], + templateUrl: './org-overview.component.html', +}) +export class OrgOverviewComponent { + private readonly accountContextService = inject(AccountContextService); + + protected readonly selectedAccount = this.accountContextService.selectedAccount; + + protected readonly companyName: Signal = computed(() => this.selectedAccount().accountName || 'Your Organization'); + + protected readonly tierLabel: Signal = computed(() => this.selectedAccount().membershipTier ?? null); +} diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html new file mode 100644 index 000000000..dbddd7083 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.html @@ -0,0 +1,80 @@ + + + + + + +
+
+ + +
+ +
+ @for (account of visibleAccounts(); track account.accountId) { + + } @empty { +
No organizations found
+ } +
+
+
diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss new file mode 100644 index 000000000..90e919e7d --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.scss @@ -0,0 +1,29 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +:host { + display: block; + width: 100%; +} + +.sidebar-org-name { + @apply font-medium leading-5 relative text-base text-gray-900 tracking-tight truncate w-full text-left; +} + +::ng-deep .org-selector-panel.p-popover { + &::before, + &::after { + display: none !important; + } + + @media (min-width: 1024px) { + // stylelint-disable-next-line declaration-no-important + position: fixed !important; + left: 344px !important; + top: 4px !important; + + &.org-selector-panel--with-banner { + top: 52px !important; + } + } +} diff --git a/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts new file mode 100644 index 000000000..d101a3888 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts @@ -0,0 +1,71 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, model, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { Account } from '@lfx-one/shared/interfaces'; +import { AccountContextService } from '@services/account-context.service'; +import { UserService } from '@services/user.service'; +import { AutoFocus } from 'primeng/autofocus'; +import { InputTextModule } from 'primeng/inputtext'; +import { Popover, PopoverModule } from 'primeng/popover'; + +@Component({ + selector: 'lfx-org-selector', + imports: [ReactiveFormsModule, PopoverModule, InputTextModule, AutoFocus], + templateUrl: './org-selector.component.html', + styleUrl: './org-selector.component.scss', +}) +export class OrgSelectorComponent { + private readonly accountContextService = inject(AccountContextService); + private readonly userService = inject(UserService); + + public readonly isPanelOpen = model(false); + + protected readonly searchControl = new FormControl('', { nonNullable: true }); + private readonly searchTerm: Signal = computed(() => (this.searchValue() ?? '').trim().toLowerCase()); + private readonly searchValue = toSignal(this.searchControl.valueChanges, { initialValue: '' }); + + protected readonly panelStyleClass = computed(() => + this.userService.impersonating() ? 'org-selector-panel org-selector-panel--with-banner' : 'org-selector-panel' + ); + + protected readonly selectedAccount: Signal = this.accountContextService.selectedAccount; + protected readonly availableAccounts: Signal = this.accountContextService.availableAccounts; + + protected readonly displayName: Signal = computed(() => this.selectedAccount().accountName || 'Select Organization'); + + protected readonly displayLogo: Signal = computed(() => this.selectedAccount().logoUrl ?? ''); + + protected readonly visibleAccounts: Signal = computed(() => { + const term = this.searchTerm(); + const accounts = this.availableAccounts(); + if (!term) { + return accounts; + } + return accounts.filter((account) => account.accountName.toLowerCase().includes(term)); + }); + + protected selectItem(account: Account, popover: Popover): void { + this.accountContextService.setAccount(account); + popover.hide(); + } + + protected togglePanel(event: Event, popover: Popover): void { + popover.toggle(event); + } + + protected onPopoverShow(): void { + this.isPanelOpen.set(true); + } + + protected onPopoverHide(): void { + this.isPanelOpen.set(false); + this.searchControl.setValue('', { emitEvent: true }); + } + + protected isSelected(account: Account): boolean { + return account.accountId === this.selectedAccount().accountId; + } +} diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html index c6db941ef..355a41031 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.html @@ -28,6 +28,12 @@ } + @if (showOrgSelector()) { +
+ +
+ } +
diff --git a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts index c02016153..911d8400f 100644 --- a/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts +++ b/apps/lfx-one/src/app/shared/components/sidebar/sidebar.component.ts @@ -6,6 +6,7 @@ import { Component, computed, inject, input, model, Signal } from '@angular/core import { RouterModule } from '@angular/router'; import { AvatarComponent } from '@components/avatar/avatar.component'; import { BadgeComponent } from '@components/badge/badge.component'; +import { OrgSelectorComponent } from '@components/org-selector/org-selector.component'; import { ProjectSelectorComponent } from '@components/project-selector/project-selector.component'; import { environment } from '@environments/environment'; import { PERSONA_OPTIONS, PERSONA_PRIORITY } from '@lfx-one/shared/constants'; @@ -27,7 +28,7 @@ const PERSONA_ICONS: Partial> = { @Component({ selector: 'lfx-sidebar', - imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, ProjectSelectorComponent, SkeletonModule], + imports: [NgClass, NgTemplateOutlet, RouterModule, AvatarComponent, BadgeComponent, OrgSelectorComponent, ProjectSelectorComponent, SkeletonModule], templateUrl: './sidebar.component.html', styleUrl: './sidebar.component.scss', }) @@ -43,6 +44,7 @@ export class SidebarComponent { public readonly collapsed = input(false); public readonly styleClass = input(''); public readonly showProjectSelector = input(false); + public readonly showOrgSelector = input(false); public readonly showMeSelector = input(false); public readonly mobile = input(false); public readonly selectorPanelOpen = model(false); @@ -100,7 +102,7 @@ export class SidebarComponent { private initLensLoaded(): Signal { return computed(() => { - if (this.isOrgLens()) return false; + if (this.isOrgLens()) return true; const lens = this.navLens(); if (!lens) return true; return this.navigationService.loaded(lens)(); 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 6af5053e7..b0a651967 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 @@ -2,120 +2,194 @@ // SPDX-License-Identifier: MIT import { computed, inject, Injectable, Signal, signal, WritableSignal } from '@angular/core'; -import { ACCOUNT_COOKIE_KEY, ACCOUNTS, DEFAULT_ACCOUNT } from '@lfx-one/shared/constants'; -import { Account } from '@lfx-one/shared/interfaces'; +import { ACCOUNT_COOKIE_KEY } from '@lfx-one/shared/constants'; +import { Account, OrgLensAccountContextResponse } from '@lfx-one/shared/interfaces'; import { SsrCookieService } from 'ngx-cookie-service-ssr'; +import { AnalyticsService } from './analytics.service'; import { CookieRegistryService } from './cookie-registry.service'; +const PLACEHOLDER_ACCOUNT: Account = { + accountId: '', + accountName: '', + accountSlug: '', + membershipTier: '', +}; + @Injectable({ providedIn: 'root', }) export class AccountContextService { private readonly cookieService = inject(SsrCookieService); private readonly cookieRegistry = inject(CookieRegistryService); + private readonly analyticsService = inject(AnalyticsService); private readonly storageKey = ACCOUNT_COOKIE_KEY; /** - * User's organizations from committee memberships (filtered from ACCOUNTS) - * If empty, falls back to all available accounts + * Seed organizations from the persona service — the accounts the + * current user is authorised to see. Display attributes (slug, logo, + * tier) are resolved from Snowflake via getOrgLensAccountContext. */ private readonly userOrganizations: WritableSignal = signal([]); - /** - * Whether user organizations have been initialized from auth context - */ private readonly initialized: WritableSignal = signal(false); /** - * The currently selected account + * Snowflake-resolved Account records keyed by accountId. Flat — the + * UI renders one entry per account regardless of any Salesforce + * conglomerate hierarchy that may exist behind the scenes. */ + private readonly liveAccounts: WritableSignal> = signal(new Map()); + public readonly selectedAccount: WritableSignal; /** - * Returns available accounts for the user - * Merges detected organizations into the predefined ACCOUNTS list so that - * organizations not in the hardcoded list are still available for selection + * Accounts visible in the org-selector — one row per persona-authorised + * account, enriched with Snowflake display attributes once available, + * falling back to bare seed records while live data is loading so the + * selector is never empty between bootstrap and the first response. */ public readonly availableAccounts: Signal = computed(() => { - const detected = this.userOrganizations(); - if (!this.initialized() || detected.length === 0) { - return ACCOUNTS; + const seeds = this.userOrganizations(); + const live = this.liveAccounts(); + + if (live.size === 0) { + return seeds; } - // 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; + const seen = new Set(); + const result: Account[] = []; + for (const seed of seeds) { + if (seen.has(seed.accountId)) { + continue; + } + seen.add(seed.accountId); + result.push(live.get(seed.accountId) ?? seed); + } + return result; }); public constructor() { const stored = this.loadFromStorage(); - this.selectedAccount = signal(stored || DEFAULT_ACCOUNT); + this.selectedAccount = signal(stored ?? PLACEHOLDER_ACCOUNT); } /** - * Initialize user organizations from auth context (SSR state transfer) - * Called during app initialization with organizations matched from committee memberships + * Initialize user organizations from auth context (SSR state transfer). + * + * Sets the seed list from the persona service, then fetches + * /api/analytics/org-lens-account-context to enrich each seed with + * its Snowflake display attributes (slug, logo, cdev mapping, tier). + * Selection is reconciled against the seeds and re-enriched once the + * live data arrives. */ public initializeUserOrganizations(organizations: Account[]): void { this.initialized.set(true); - this.userOrganizations.set(organizations ?? []); - - if (organizations && organizations.length > 0) { - // Validate stored selection against the user's detected organizations. - // A stored selection from a prior session (or a leaked impersonator cookie) - // must match one of the currently detected orgs; otherwise fall back to - // the first detected org so the selector reflects the active context. - // Resolve to the canonical Account from organizations so we never trust - // cookie-supplied fields (e.g. accountName) beyond the validated accountId. - const stored = this.loadFromStorage(); - const matchedOrganization = stored ? (organizations.find((org) => org.accountId === stored.accountId) ?? null) : null; + const seeds = organizations ?? []; + this.userOrganizations.set(seeds); - if (matchedOrganization) { - this.selectedAccount.set(matchedOrganization); + if (seeds.length > 0) { + const stored = this.loadFromStorage(); + const matchedSeed = stored ? (seeds.find((seed) => seed.accountId === stored.accountId) ?? null) : null; + if (matchedSeed) { + this.setAccount(matchedSeed); } else { - this.setAccount(organizations[0]); + this.setAccount(seeds[0]); } } + + this.refreshFromSnowflake(seeds.map((seed) => seed.accountId)); } - /** - * Set the selected account and persist to storage - */ public setAccount(account: Account): void { - this.selectedAccount.set(account); - this.persistToStorage(account); + const live = this.liveAccounts().get(account.accountId); + const next = live ?? account; + this.selectedAccount.set(next); + this.persistToStorage(next); } - /** - * Get the currently selected account ID - */ public getAccountId(): string { return this.selectedAccount().accountId; } + private refreshFromSnowflake(accountIds: string[]): void { + const ids = [...new Set(accountIds.filter((id) => !!id))]; + if (ids.length === 0) { + return; + } + + this.analyticsService.getOrgLensAccountContext(ids).subscribe((rows) => { + if (rows.length === 0) { + return; + } + const live = this.buildLiveAccounts(rows); + this.liveAccounts.set(live); + + const current = this.selectedAccount(); + const liveCurrent = live.get(current.accountId); + if (liveCurrent) { + this.selectedAccount.set(liveCurrent); + } else if (!current.accountId) { + const firstSeed = this.userOrganizations()[0]; + if (firstSeed) { + const liveSeed = live.get(firstSeed.accountId) ?? firstSeed; + this.selectedAccount.set(liveSeed); + this.persistToStorage(liveSeed); + } + } + }); + } + + private buildLiveAccounts(rows: OrgLensAccountContextResponse[]): Map { + const accounts = new Map(); + for (const row of rows) { + const account = this.toAccount(row); + accounts.set(account.accountId, account); + } + return accounts; + } + + private toAccount(row: OrgLensAccountContextResponse): Account { + return { + accountId: row.accountId, + accountName: row.accountName, + accountSlug: row.accountSlug ?? '', + logoUrl: row.logoUrl ?? undefined, + cdevOrgId: row.cdevOrgId ?? undefined, + membershipTier: row.membershipTierDisplayName ?? '', + }; + } + private persistToStorage(account: Account): void { - // Store in cookie (SSR-compatible) this.cookieService.set(this.storageKey, JSON.stringify(account), { - expires: 30, // 30 days + expires: 30, path: '/', sameSite: 'Lax', secure: process.env['NODE_ENV'] === 'production', }); - // Register cookie for tracking this.cookieRegistry.registerCookie(this.storageKey); } private loadFromStorage(): Account | null { try { const stored = this.cookieService.get(this.storageKey); - if (stored) { - return JSON.parse(stored) as Account; + if (!stored) { + return null; } + const parsed = JSON.parse(stored) as Account; + if (!this.isValidAccountId(parsed?.accountId)) { + return null; + } + return parsed; } catch { - // Invalid data in cookie, ignore + return null; } - return null; + } + + // Salesforce account ids are 15- or 18-char alphanumeric strings. + // Reject anything else so a tampered cookie can't seed selectedAccount with + // an invalid id before persona init reconciles. + private isValidAccountId(id: unknown): id is string { + return typeof id === 'string' && /^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$/.test(id); } } diff --git a/apps/lfx-one/src/app/shared/services/analytics.service.ts b/apps/lfx-one/src/app/shared/services/analytics.service.ts index 9d5069fed..c3cf937d9 100644 --- a/apps/lfx-one/src/app/shared/services/analytics.service.ts +++ b/apps/lfx-one/src/app/shared/services/analytics.service.ts @@ -4,6 +4,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { + OrgLensAccountContextResponse, ActiveWeeksStreakResponse, CertifiedEmployeesResponse, CodeCommitsDailyResponse, @@ -206,6 +207,23 @@ export class AnalyticsService { ); } + /** + * Bootstrap the Org Lens account context for the user's persona-authorised + * Salesforce accounts. Returns a single denormalised row per account_id + * with display attributes, Crowd.dev mapping, and the highest active + * corporate membership tier — drives both the org-selector dropdown and + * the header badge. + */ + public getOrgLensAccountContext(accountIds: string[]): Observable { + if (accountIds.length === 0) { + return of([]); + } + const params = { accountIds: accountIds.join(',') }; + return this.http + .get('/api/analytics/org-lens-account-context', { params }) + .pipe(catchError(() => of([] as OrgLensAccountContextResponse[]))); + } + /** * Get certified employees data for an organization with monthly trend * @param accountId - Required account ID to filter by specific organization diff --git a/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts b/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts new file mode 100644 index 000000000..fbcdc5863 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-involvement-analytics.service.ts @@ -0,0 +1,108 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { catchError, Observable, of } from 'rxjs'; + +import type { + OrgFoundationCoverageResponse, + OrgInvolvementCertifiedEmployeesMonthlyResponse, + OrgInvolvementContributorsMonthlyResponse, + OrgInvolvementEventAttendanceMonthlyResponse, + OrgInvolvementMaintainersMonthlyResponse, + OrgTrainingEnrollmentsResponse, +} from '@lfx-one/shared/interfaces/org-involvement.interface'; + +@Injectable({ + providedIn: 'root', +}) +export class OrgInvolvementAnalyticsService { + private readonly http = inject(HttpClient); + + public getFoundationCoverage(accountId: string): Observable { + return this.http.get('/api/analytics/org-foundation-coverage', { params: { accountId } }).pipe( + catchError(() => + of({ + accountId: '', + foundationCount: 0, + foundations: [], + }) + ) + ); + } + + public getContributorsMonthly(accountId: string): Observable { + return this.http.get('/api/analytics/org-involvement-contributors-monthly', { params: { accountId } }).pipe( + catchError(() => + of({ + accountId: '', + totalActiveContributors: 0, + monthlyData: [], + monthlyLabels: [], + }) + ) + ); + } + + public getMaintainersMonthly(accountId: string): Observable { + return this.http.get('/api/analytics/org-involvement-maintainers-monthly', { params: { accountId } }).pipe( + catchError(() => + of({ + accountId: '', + accountName: '', + totalMaintainersYearly: 0, + totalProjectsYearly: 0, + monthlyData: [], + monthlyLabels: [], + }) + ) + ); + } + + public getEventAttendanceMonthly(accountId: string): Observable { + return this.http + .get('/api/analytics/org-involvement-event-attendance-monthly', { params: { accountId } }) + .pipe( + catchError(() => + of({ + accountId: '', + accountName: '', + totalAttended: 0, + totalSpeakers: 0, + attendeesMonthlyData: [], + speakersMonthlyData: [], + monthlyLabels: [], + }) + ) + ); + } + + public getCertifiedEmployeesMonthly(accountId: string): Observable { + return this.http + .get('/api/analytics/org-involvement-certified-employees-monthly', { params: { accountId } }) + .pipe( + catchError(() => + of({ + accountId: '', + totalCertifications: 0, + totalCertifiedEmployees: 0, + monthlyData: [], + monthlyLabels: [], + }) + ) + ); + } + + public getTrainingEnrollments(accountId: string): Observable { + return this.http.get('/api/analytics/org-involvement-training-enrollments', { params: { accountId } }).pipe( + catchError(() => + of({ + accountId: '', + totalEnrollments: 0, + dailyData: [], + }) + ) + ); + } +} diff --git a/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts b/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts new file mode 100644 index 000000000..dbcb55648 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/org-lens-foundations.service.ts @@ -0,0 +1,23 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import type { OrgLensFoundationsAndProjectsResponse } from '@lfx-one/shared/interfaces'; + +/** + * Client-side proxy for GET /api/orgs/:accountId/lens/foundations-and-projects. + * Backs the Org Lens "Foundations and Projects" section on /org/overview. + */ +@Injectable({ + providedIn: 'root', +}) +export class OrgLensFoundationsService { + private readonly http = inject(HttpClient); + + public getFoundationsAndProjects(accountId: string): Observable { + return this.http.get(`/api/orgs/${encodeURIComponent(accountId)}/lens/foundations-and-projects`); + } +} diff --git a/apps/lfx-one/src/server/controllers/analytics.controller.ts b/apps/lfx-one/src/server/controllers/analytics.controller.ts index 9dffe5864..ab50cb610 100644 --- a/apps/lfx-one/src/server/controllers/analytics.controller.ts +++ b/apps/lfx-one/src/server/controllers/analytics.controller.ts @@ -6,6 +6,7 @@ import { NextFunction, Request, Response } from 'express'; import { AuthenticationError, ServiceValidationError } from '../errors'; import { assertHealthMetricsRange, getStringQueryParam, parseEntityType } from '../helpers/validation.helper'; import { logger } from '../services/logger.service'; +import { OrgInvolvementService } from '../services/org-involvement.service'; import { OrganizationService } from '../services/organization.service'; import { ProjectService } from '../services/project.service'; import { UserService } from '../services/user.service'; @@ -24,11 +25,13 @@ const NAME_MAX_LENGTH = 200; export class AnalyticsController { private readonly userService: UserService; private readonly organizationService: OrganizationService; + private readonly orgInvolvementService: OrgInvolvementService; private readonly projectService: ProjectService; public constructor() { this.userService = new UserService(); this.organizationService = new OrganizationService(); + this.orgInvolvementService = new OrgInvolvementService(); this.projectService = new ProjectService(); } @@ -1751,6 +1754,196 @@ export class AnalyticsController { } } + // Organization Involvement Endpoints (cross-foundation, accountId only) + + /** + * GET /api/analytics/org-foundation-coverage + * Get foundation coverage for an organization across all LF foundations + * Query params: accountId (required) + */ + public async orgFoundationCoverage(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_foundation_coverage'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_foundation_coverage', + }); + } + + const response = await this.orgInvolvementService.getFoundationCoverage(accountId); + + logger.success(req, 'get_org_foundation_coverage', startTime, { + account_id: accountId, + foundation_count: response.foundationCount, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /api/analytics/org-involvement-contributors-monthly + * Get cross-foundation active contributors monthly for an organization + * Query params: accountId (required) + */ + public async orgContributorsMonthly(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_involvement_contributors_monthly'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_involvement_contributors_monthly', + }); + } + + const response = await this.orgInvolvementService.getContributorsMonthly(accountId); + + logger.success(req, 'get_org_involvement_contributors_monthly', startTime, { + account_id: accountId, + total_active_contributors: response.totalActiveContributors, + monthly_data_points: response.monthlyData.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /api/analytics/org-involvement-maintainers-monthly + * Get cross-foundation maintainers monthly for an organization + * Query params: accountId (required) + */ + public async orgMaintainersMonthly(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_involvement_maintainers_monthly'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_involvement_maintainers_monthly', + }); + } + + const response = await this.orgInvolvementService.getMaintainersMonthly(accountId); + + logger.success(req, 'get_org_involvement_maintainers_monthly', startTime, { + account_id: accountId, + total_maintainers: response.totalMaintainersYearly, + total_projects: response.totalProjectsYearly, + monthly_data_points: response.monthlyData.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /api/analytics/org-involvement-event-attendance-monthly + * Get cross-foundation event attendance monthly for an organization + * Query params: accountId (required) + */ + public async orgEventAttendanceMonthly(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_involvement_event_attendance_monthly'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_involvement_event_attendance_monthly', + }); + } + + const response = await this.orgInvolvementService.getEventAttendanceMonthly(accountId); + + logger.success(req, 'get_org_involvement_event_attendance_monthly', startTime, { + account_id: accountId, + total_attended: response.totalAttended, + total_speakers: response.totalSpeakers, + monthly_data_points: response.monthlyLabels.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /api/analytics/org-involvement-certified-employees-monthly + * Get cross-foundation certified employees monthly for an organization + * Query params: accountId (required) + */ + public async orgCertifiedEmployeesMonthly(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_involvement_certified_employees_monthly'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_involvement_certified_employees_monthly', + }); + } + + const response = await this.orgInvolvementService.getCertifiedEmployeesMonthly(accountId); + + logger.success(req, 'get_org_involvement_certified_employees_monthly', startTime, { + account_id: accountId, + total_certifications: response.totalCertifications, + total_certified_employees: response.totalCertifiedEmployees, + monthly_data_points: response.monthlyData.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /api/analytics/org-involvement-training-enrollments + * Get cross-foundation training enrollments YTD for an organization + * Query params: accountId (required) + */ + public async orgTrainingEnrollments(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_involvement_training_enrollments'); + + try { + const accountId = getStringQueryParam(req, 'accountId'); + + if (!accountId) { + throw ServiceValidationError.forField('accountId', 'accountId query parameter is required', { + operation: 'get_org_involvement_training_enrollments', + }); + } + + const response = await this.orgInvolvementService.getTrainingEnrollments(accountId); + + logger.success(req, 'get_org_involvement_training_enrollments', startTime, { + account_id: accountId, + total_enrollments: response.totalEnrollments, + daily_data_points: response.dailyData.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + // Marketing Analytics Endpoints // All marketing endpoints (Web Activities, Email CTR, Social Reach, Social Media, and the // ED dashboard KPIs) use `foundationSlug` — underlying Snowflake Platinum views key on a @@ -2602,6 +2795,31 @@ export class AnalyticsController { } } + /** + * GET /api/analytics/org-lens-account-context + * Resolve LFX One Org Lens display context for a set of Salesforce + * accounts — one denormalised row per account_id with cdev mapping + * and highest active corporate membership tier. + * Query params: accountIds (required) - Comma-separated Salesforce account IDs (max 50) + */ + public async getOrgLensAccountContext(req: Request, res: Response, next: NextFunction): Promise { + const startTime = logger.startOperation(req, 'get_org_lens_account_context'); + + try { + const accountIds = this.parseAccountIdsParam(req, 'get_org_lens_account_context'); + const response = await this.organizationService.getOrgLensAccountContext(accountIds); + + logger.success(req, 'get_org_lens_account_context', startTime, { + requested_count: accountIds.length, + resolved_count: response.length, + }); + + res.json(response); + } catch (error) { + next(error); + } + } + /** * Parse and validate a comma-separated slugs query parameter. * @throws ServiceValidationError if the parameter is missing, empty, exceeds max count, or has invalid format @@ -2646,4 +2864,43 @@ export class AnalyticsController { return slugs; } + + /** + * Parse and validate the `accountIds` query parameter (comma-separated + * Salesforce account IDs). De-duplicates, enforces a 50-id ceiling, and + * checks each id matches the Salesforce 15/18-char alphanumeric format. + */ + private parseAccountIdsParam(req: Request, operation: string): string[] { + const raw = getStringQueryParam(req, 'accountIds'); + if (!raw) { + throw ServiceValidationError.forField('accountIds', 'accountIds query parameter is required', { operation }); + } + + const ids = [ + ...new Set( + raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + ), + ]; + + if (ids.length === 0) { + throw ServiceValidationError.forField('accountIds', 'At least one accountId is required', { operation }); + } + + const MAX_ACCOUNT_IDS = 50; + if (ids.length > MAX_ACCOUNT_IDS) { + throw ServiceValidationError.forField('accountIds', `Maximum of ${MAX_ACCOUNT_IDS} accountIds allowed per request`, { operation }); + } + + const SALESFORCE_ID_PATTERN = /^[A-Za-z0-9]{15,18}$/; + for (const id of ids) { + if (!SALESFORCE_ID_PATTERN.test(id)) { + throw ServiceValidationError.forField('accountIds', `Invalid Salesforce accountId format: ${id}`, { operation }); + } + } + + return ids; + } } diff --git a/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts b/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts new file mode 100644 index 000000000..f91e05d45 --- /dev/null +++ b/apps/lfx-one/src/server/controllers/org-lens-foundations.controller.ts @@ -0,0 +1,65 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { NextFunction, Request, Response } from 'express'; + +import { ServiceValidationError } from '../errors'; +import { logger } from '../services/logger.service'; +import { OrgLensFoundationsService } from '../services/org-lens-foundations.service'; + +/** Salesforce account ID pattern (15 or 18 chars, starts with 001). */ +const ACCOUNT_ID_PATTERN = /^001[A-Za-z0-9]{12,15}$/; + +/** + * Controller for the Org Lens Foundations and Projects section. + * Owns the HTTP boundary (validation, lifecycle logging, error + * propagation) for the OrgLensFoundationsService. + */ +export class OrgLensFoundationsController { + private readonly service: OrgLensFoundationsService; + + public constructor() { + this.service = new OrgLensFoundationsService(); + } + + /** + * GET /api/orgs/:accountId/lens/foundations-and-projects + */ + public async getFoundationsAndProjects(req: Request, res: Response, next: NextFunction): Promise { + const accountId = req.params['accountId']; + const startTime = logger.startOperation(req, 'get_org_lens_foundations_and_projects', { + account_id: accountId, + }); + + try { + if (!accountId || typeof accountId !== 'string') { + throw ServiceValidationError.forField('accountId', 'accountId path parameter is required', { + operation: 'get_org_lens_foundations_and_projects', + }); + } + + if (!ACCOUNT_ID_PATTERN.test(accountId)) { + throw ServiceValidationError.forField('accountId', 'Invalid Salesforce accountId format', { + operation: 'get_org_lens_foundations_and_projects', + }); + } + + const response = await this.service.getFoundationsAndProjects(accountId); + + const projectCountTotal = response.rows.reduce((sum, row) => sum + row.projects.length, 0); + + logger.success(req, 'get_org_lens_foundations_and_projects', startTime, { + account_id: accountId, + row_count: response.rows.length, + project_count_total: projectCountTotal, + }); + + // No PII or tokens in logs. account_id is already a Salesforce + // opaque ID, safe. + res.setHeader('Cache-Control', 'no-store'); + res.json(response); + } catch (error) { + next(error); + } + } +} diff --git a/apps/lfx-one/src/server/routes/analytics.route.ts b/apps/lfx-one/src/server/routes/analytics.route.ts index ace758fc1..b2ff79a22 100644 --- a/apps/lfx-one/src/server/routes/analytics.route.ts +++ b/apps/lfx-one/src/server/routes/analytics.route.ts @@ -129,6 +129,14 @@ router.get('/org-training-enrollments-distribution', (req, res, next) => analyti router.get('/org-certified-employees-monthly', (req, res, next) => analyticsController.getOrgCertifiedEmployeesMonthly(req, res, next)); router.get('/org-certified-employees-distribution', (req, res, next) => analyticsController.getOrgCertifiedEmployeesDistribution(req, res, next)); +// Organization involvement endpoints (cross-foundation, accountId only — org overview page) +router.get('/org-foundation-coverage', (req, res, next) => analyticsController.orgFoundationCoverage(req, res, next)); +router.get('/org-involvement-contributors-monthly', (req, res, next) => analyticsController.orgContributorsMonthly(req, res, next)); +router.get('/org-involvement-maintainers-monthly', (req, res, next) => analyticsController.orgMaintainersMonthly(req, res, next)); +router.get('/org-involvement-event-attendance-monthly', (req, res, next) => analyticsController.orgEventAttendanceMonthly(req, res, next)); +router.get('/org-involvement-certified-employees-monthly', (req, res, next) => analyticsController.orgCertifiedEmployeesMonthly(req, res, next)); +router.get('/org-involvement-training-enrollments', (req, res, next) => analyticsController.orgTrainingEnrollments(req, res, next)); + // Web activities summary endpoint (marketing dashboard) router.get('/web-activities-summary', (req, res, next) => analyticsController.getWebActivitiesSummary(req, res, next)); @@ -181,4 +189,7 @@ router.get('/marketing-attribution', (req, res, next) => analyticsController.get // Multi-foundation summary endpoint (multi-foundation dashboard) router.get('/multi-foundation-summary', (req, res, next) => analyticsController.getMultiFoundationSummary(req, res, next)); +// Org Lens — bootstrap account context (display attrs + cdev mapping + tier) +router.get('/org-lens-account-context', (req, res, next) => analyticsController.getOrgLensAccountContext(req, res, next)); + export default router; diff --git a/apps/lfx-one/src/server/routes/orgs.route.ts b/apps/lfx-one/src/server/routes/orgs.route.ts new file mode 100644 index 000000000..762b54ba5 --- /dev/null +++ b/apps/lfx-one/src/server/routes/orgs.route.ts @@ -0,0 +1,15 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Router } from 'express'; + +import { OrgLensFoundationsController } from '../controllers/org-lens-foundations.controller'; + +const router = Router(); + +const orgLensFoundationsController = new OrgLensFoundationsController(); + +// GET /api/orgs/:accountId/lens/foundations-and-projects +router.get('/:accountId/lens/foundations-and-projects', (req, res, next) => orgLensFoundationsController.getFoundationsAndProjects(req, res, next)); + +export default router; diff --git a/apps/lfx-one/src/server/server.ts b/apps/lfx-one/src/server/server.ts index f7a693454..a88488d45 100644 --- a/apps/lfx-one/src/server/server.ts +++ b/apps/lfx-one/src/server/server.ts @@ -30,6 +30,7 @@ import mailingListsRouter from './routes/mailing-lists.route'; import meetingsRouter from './routes/meetings.route'; import navigationRouter from './routes/navigation.route'; import organizationsRouter from './routes/organizations.route'; +import orgsRouter from './routes/orgs.route'; import pastMeetingsRouter from './routes/past-meetings.route'; import personaRouter from './routes/persona.route'; import profileRouter from './routes/profile.route'; @@ -178,6 +179,7 @@ app.use('/api/committees', committeesRouter); app.use('/api/mailing-lists', mailingListsRouter); app.use('/api/meetings', meetingsRouter); app.use('/api/organizations', organizationsRouter); +app.use('/api/orgs', orgsRouter); app.use('/api/past-meetings', pastMeetingsRouter); app.use('/api/profile', profileRouter); app.use('/api/search', searchRouter); diff --git a/apps/lfx-one/src/server/services/org-involvement.service.ts b/apps/lfx-one/src/server/services/org-involvement.service.ts new file mode 100644 index 000000000..0510eb196 --- /dev/null +++ b/apps/lfx-one/src/server/services/org-involvement.service.ts @@ -0,0 +1,277 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { + OrgInvolvementCertifiedEmployeesMonthlyResponse, + OrgInvolvementContributorsMonthlyResponse, + OrgInvolvementEventAttendanceMonthlyResponse, + OrgFoundationCoverageResponse, + OrgInvolvementMaintainersMonthlyResponse, + OrgTrainingEnrollmentsResponse, +} from '@lfx-one/shared'; + +import { ResourceNotFoundError } from '../errors'; +import { SnowflakeService } from './snowflake.service'; + +interface FoundationCoverageRow { + ACCOUNT_ID: string; + FOUNDATION_ID: string; + FOUNDATION_SLUG: string; + FOUNDATION_NAME: string; + FOUNDATION_COUNT: number; +} + +interface ContributorsMonthlyRow { + ACCOUNT_ID: string; + ACCOUNT_NAME: string; + MONTH_START_DATE: Date; + UNIQUE_CONTRIBUTORS: number; + TOTAL_ACTIVE_CONTRIBUTORS: number; +} + +interface MaintainersMonthlyRow { + ACCOUNT_ID: string; + ACCOUNT_NAME: string; + METRIC_MONTH: Date; + ACTIVE_MAINTAINERS: number; + ACTIVE_PROJECTS: number; + TOTAL_MAINTAINERS_YEARLY: number; + TOTAL_PROJECTS_YEARLY: number; +} + +interface EventAttendanceMonthlyRow { + ACCOUNT_ID: string; + ACCOUNT_NAME: string; + MONTH_START_DATE: Date; + REGISTRATION_COUNT: number; + ATTENDED_COUNT: number; + SPEAKER_COUNT: number; + TOTAL_REGISTRATIONS: number; + TOTAL_ATTENDED: number; + TOTAL_SPEAKERS: number; +} + +interface CertifiedEmployeesMonthlyRow { + ACCOUNT_ID: string; + MONTH_START_DATE: Date; + MONTHLY_CERTIFICATIONS: number; + MONTHLY_CERTIFIED_EMPLOYEES: number; + TOTAL_CERTIFICATIONS: number; + TOTAL_CERTIFIED_EMPLOYEES: number; +} + +interface TrainingEnrollmentRow { + ACCOUNT_ID: string; + ENROLLMENT_DATE: string; + DAILY_COUNT: number; + CUMULATIVE_COUNT: number; + TOTAL_ENROLLMENTS: number; +} + +/** + * Service for cross-foundation organization involvement analytics. + * Queries the org_* platinum tables (account-level, no foundation filter). + */ +export class OrgInvolvementService { + private snowflakeService: SnowflakeService; + + public constructor() { + this.snowflakeService = SnowflakeService.getInstance(); + } + + public async getFoundationCoverage(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + FOUNDATION_ID, + FOUNDATION_SLUG, + FOUNDATION_NAME, + FOUNDATION_COUNT + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_FOUNDATION_COVERAGE + WHERE ACCOUNT_ID = ? + ORDER BY FOUNDATION_NAME ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + throw new ResourceNotFoundError('Foundation coverage data', accountId, { + operation: 'get_foundation_coverage', + }); + } + + return { + accountId: result.rows[0].ACCOUNT_ID, + foundationCount: result.rows[0].FOUNDATION_COUNT || 0, + foundations: result.rows.map((row) => ({ + foundationId: row.FOUNDATION_ID, + foundationSlug: row.FOUNDATION_SLUG, + foundationName: row.FOUNDATION_NAME, + })), + }; + } + + public async getContributorsMonthly(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + MONTH_START_DATE, + UNIQUE_CONTRIBUTORS, + TOTAL_ACTIVE_CONTRIBUTORS + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_CONTRIBUTORS_MONTHLY + WHERE ACCOUNT_ID = ? + ORDER BY MONTH_START_DATE ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + throw new ResourceNotFoundError('Contributors monthly data', accountId, { + operation: 'get_contributors_monthly', + }); + } + + const firstRow = result.rows[0]; + + return { + accountId: firstRow.ACCOUNT_ID, + totalActiveContributors: firstRow.TOTAL_ACTIVE_CONTRIBUTORS || 0, + monthlyData: result.rows.map((row) => row.UNIQUE_CONTRIBUTORS || 0), + monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + }; + } + + public async getMaintainersMonthly(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + ACCOUNT_NAME, + METRIC_MONTH, + ACTIVE_MAINTAINERS, + ACTIVE_PROJECTS, + TOTAL_MAINTAINERS_YEARLY, + TOTAL_PROJECTS_YEARLY + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_MAINTAINERS_MONTHLY + WHERE ACCOUNT_ID = ? + ORDER BY METRIC_MONTH ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + throw new ResourceNotFoundError('Maintainers monthly data', accountId, { + operation: 'get_maintainers_monthly', + }); + } + + const firstRow = result.rows[0]; + + return { + accountId: firstRow.ACCOUNT_ID, + accountName: firstRow.ACCOUNT_NAME, + totalMaintainersYearly: firstRow.TOTAL_MAINTAINERS_YEARLY || 0, + totalProjectsYearly: firstRow.TOTAL_PROJECTS_YEARLY || 0, + monthlyData: result.rows.map((row) => row.ACTIVE_MAINTAINERS || 0), + monthlyLabels: result.rows.map((row) => row.METRIC_MONTH.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + }; + } + + public async getEventAttendanceMonthly(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + ACCOUNT_NAME, + MONTH_START_DATE, + REGISTRATION_COUNT, + ATTENDED_COUNT, + SPEAKER_COUNT, + TOTAL_REGISTRATIONS, + TOTAL_ATTENDED, + TOTAL_SPEAKERS + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_EVENT_ATTENDANCE_MONTHLY + WHERE ACCOUNT_ID = ? + ORDER BY MONTH_START_DATE ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + throw new ResourceNotFoundError('Event attendance monthly data', accountId, { + operation: 'get_event_attendance_monthly', + }); + } + + const firstRow = result.rows[0]; + + return { + accountId: firstRow.ACCOUNT_ID, + accountName: firstRow.ACCOUNT_NAME, + totalAttended: firstRow.TOTAL_ATTENDED || 0, + totalSpeakers: firstRow.TOTAL_SPEAKERS || 0, + attendeesMonthlyData: result.rows.map((row) => row.ATTENDED_COUNT || 0), + speakersMonthlyData: result.rows.map((row) => row.SPEAKER_COUNT || 0), + monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + }; + } + + public async getCertifiedEmployeesMonthly(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + MONTH_START_DATE, + MONTHLY_CERTIFICATIONS, + MONTHLY_CERTIFIED_EMPLOYEES, + TOTAL_CERTIFICATIONS, + TOTAL_CERTIFIED_EMPLOYEES + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_CERTIFIED_EMPLOYEES_MONTHLY + WHERE ACCOUNT_ID = ? + ORDER BY MONTH_START_DATE ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + throw new ResourceNotFoundError('Certified employees monthly data', accountId, { + operation: 'get_certified_employees_monthly', + }); + } + + const firstRow = result.rows[0]; + + return { + accountId: firstRow.ACCOUNT_ID, + totalCertifications: firstRow.TOTAL_CERTIFICATIONS || 0, + totalCertifiedEmployees: firstRow.TOTAL_CERTIFIED_EMPLOYEES || 0, + monthlyData: result.rows.map((row) => row.MONTHLY_CERTIFICATIONS || 0), + monthlyLabels: result.rows.map((row) => row.MONTH_START_DATE.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })), + }; + } + + public async getTrainingEnrollments(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + ENROLLMENT_DATE, + DAILY_COUNT, + CUMULATIVE_COUNT, + TOTAL_ENROLLMENTS + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_TRAINING_ENROLLMENTS + WHERE ACCOUNT_ID = ? + ORDER BY ENROLLMENT_DATE ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + const totalEnrollments = result.rows.length > 0 ? result.rows[result.rows.length - 1].TOTAL_ENROLLMENTS || 0 : 0; + + return { + accountId, + totalEnrollments, + dailyData: result.rows.map((row) => ({ + date: row.ENROLLMENT_DATE, + count: row.DAILY_COUNT, + cumulativeCount: row.CUMULATIVE_COUNT, + })), + }; + } +} diff --git a/apps/lfx-one/src/server/services/org-lens-foundations.service.ts b/apps/lfx-one/src/server/services/org-lens-foundations.service.ts new file mode 100644 index 000000000..d13205842 --- /dev/null +++ b/apps/lfx-one/src/server/services/org-lens-foundations.service.ts @@ -0,0 +1,301 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { + GovernanceParticipationBucket, + MembershipTierClass, + OrgLensFoundationProject, + OrgLensFoundationRow, + OrgLensFoundationsAndProjectsResponse, + OrgLensFoundationsStatStrip, + OrgLensRowKind, + OrgRoleBadge, + ProjectInfluenceBucket, + VotingStatusBadge, +} from '@lfx-one/shared/interfaces'; + +import { SnowflakeService } from './snowflake.service'; + +/** + * Raw row shape returned by the joined query (foundations rollup LEFT + * JOIN per-project detail on (account_id, foundation_id)). One row per + * (account_id, foundation_id, project_id); foundations with zero + * involved projects emit a single row with all PROJECT_* columns null. + * Sentinel foundation_id '__outside_lf__' identifies the Outside-LF + * umbrella row. + * + * Kept private to this service — only cross-layer types live in + * @lfx-one/shared. + */ +interface RawRow { + ACCOUNT_ID: string; + ACCOUNT_NAME: string; + FOUNDATION_ID: string; + FOUNDATION_SLUG: string | null; + FOUNDATION_NAME: string; + FOUNDATION_LOGO_URL: string | null; + IS_MEMBER: boolean; + IS_OUTSIDE_LF: boolean; + MEMBERSHIP_TIER_CLASS: MembershipTierClass | null; + MEMBERSHIP_TIER_DISPLAY_NAME: string | null; + TIER_RANK: number | null; + PROJECT_COUNT_LF: number; + INFLUENCE_LEADING_COUNT: number; + INFLUENCE_CONTRIBUTING_COUNT: number; + INFLUENCE_PARTICIPATING_COUNT: number; + INFLUENCE_SILENT_COUNT: number; + BOARD_MEMBER_SEAT_COUNT: number; + COMMITTEE_MEMBER_SEAT_COUNT: number; + MEETINGS_THIS_WEEK_TOTAL: number; + MEETINGS_THIS_WEEK_BOARD: number; + MEETINGS_THIS_WEEK_TECHNICAL: number; + MEETINGS_THIS_WEEK_MARKETING: number; + MEETINGS_THIS_WEEK_WORKING_GROUP: number; + MEETINGS_THIS_WEEK_OTHER: number; + GOVERNANCE_ATTENDANCE_PCT: number | null; + GOVERNANCE_PARTICIPATION_BUCKET: GovernanceParticipationBucket | null; + ORG_ROLE: OrgRoleBadge; + VOTING_STATUS: VotingStatusBadge; + ROW_KIND: OrgLensRowKind; + PROJECT_ID: string | null; + PROJECT_SLUG: string | null; + PROJECT_NAME: string | null; + PROJECT_IS_LF_PROJECT: boolean | null; + PROJECT_INFLUENCE_MAX_BUCKET: ProjectInfluenceBucket | null; + PROJECT_MAINTAINERS_COUNT: number | null; + PROJECT_CONTRIBUTORS_COUNT: number | null; + PROJECT_COLLABORATORS_COUNT: number | null; + PROJECT_COMMITS_COUNT: number | null; +} + +/** + * Service for the Org Lens "Foundations and Projects" section. + * + * Reads from two pre-aggregated upstream tables: + * - the foundations rollup — one row per (account_id, foundation_id) + * - the per-project detail — one row per (account_id, foundation_id, project_id) + * + * Single Snowflake query per render. + * Returns an empty envelope (200 + empty rows) for orgs with zero + * engagement — NEVER a 404. + */ +export class OrgLensFoundationsService { + private snowflakeService: SnowflakeService; + + public constructor() { + this.snowflakeService = SnowflakeService.getInstance(); + } + + public async getFoundationsAndProjects(accountId: string): Promise { + // Single LEFT JOIN against the two pre-aggregated rollups. ORDER BY is + // CASE-guarded so `project_count_lf` only sorts non-member LF rows and + // NEVER member rows (which sort by tier_rank then foundation_name). + // Inner projects array is sorted by commits DESC. + const query = ` + SELECT + f.ACCOUNT_ID, + f.ACCOUNT_NAME, + f.FOUNDATION_ID, + f.FOUNDATION_SLUG, + f.FOUNDATION_NAME, + f.FOUNDATION_LOGO_URL, + f.IS_MEMBER, + f.IS_OUTSIDE_LF, + f.MEMBERSHIP_TIER_CLASS, + f.MEMBERSHIP_TIER_DISPLAY_NAME, + f.TIER_RANK, + f.PROJECT_COUNT_LF, + f.INFLUENCE_LEADING_COUNT, + f.INFLUENCE_CONTRIBUTING_COUNT, + f.INFLUENCE_PARTICIPATING_COUNT, + f.INFLUENCE_SILENT_COUNT, + f.BOARD_MEMBER_SEAT_COUNT, + f.COMMITTEE_MEMBER_SEAT_COUNT, + f.MEETINGS_THIS_WEEK_TOTAL, + f.MEETINGS_THIS_WEEK_BOARD, + f.MEETINGS_THIS_WEEK_TECHNICAL, + f.MEETINGS_THIS_WEEK_MARKETING, + f.MEETINGS_THIS_WEEK_WORKING_GROUP, + f.MEETINGS_THIS_WEEK_OTHER, + f.GOVERNANCE_ATTENDANCE_PCT, + f.GOVERNANCE_PARTICIPATION_BUCKET, + f.ORG_ROLE, + f.VOTING_STATUS, + f.ROW_KIND, + p.PROJECT_ID AS PROJECT_ID, + p.PROJECT_SLUG AS PROJECT_SLUG, + p.PROJECT_NAME AS PROJECT_NAME, + p.IS_LF_PROJECT AS PROJECT_IS_LF_PROJECT, + p.INFLUENCE_MAX_BUCKET AS PROJECT_INFLUENCE_MAX_BUCKET, + p.MAINTAINERS_COUNT AS PROJECT_MAINTAINERS_COUNT, + p.CONTRIBUTORS_COUNT AS PROJECT_CONTRIBUTORS_COUNT, + p.COLLABORATORS_COUNT AS PROJECT_COLLABORATORS_COUNT, + p.COMMITS_COUNT AS PROJECT_COMMITS_COUNT + FROM ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_FOUNDATIONS_AND_PROJECTS f + LEFT JOIN ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_FOUNDATION_PROJECTS_DETAIL p + ON p.ACCOUNT_ID = f.ACCOUNT_ID + AND p.FOUNDATION_ID = f.FOUNDATION_ID + WHERE f.ACCOUNT_ID = ? + ORDER BY + CASE WHEN f.IS_OUTSIDE_LF THEN 2 WHEN f.IS_MEMBER THEN 0 ELSE 1 END, + CASE WHEN f.IS_MEMBER THEN f.TIER_RANK END ASC NULLS LAST, + CASE WHEN NOT f.IS_MEMBER AND NOT f.IS_OUTSIDE_LF THEN f.PROJECT_COUNT_LF END DESC NULLS LAST, + f.FOUNDATION_NAME ASC, + p.COMMITS_COUNT DESC NULLS LAST + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + + if (result.rows.length === 0) { + return this.emptyResponse(accountId); + } + + return this.shapeResponse(accountId, result.rows); + } + + private shapeResponse(accountId: string, rawRows: RawRow[]): OrgLensFoundationsAndProjectsResponse { + const accountName = rawRows[0].ACCOUNT_NAME ?? 'Unknown Account'; + + // Group rows by foundation_id; the first raw row per foundation_id + // carries the foundation-level columns. SQL ORDER BY preserves the + // foundation sort + commits DESC inside each foundation. + const rowsByFoundation = new Map(); + + // Stat-strip running totals. + const tierBreakdown: Partial> = {}; + let foundationsTotal = 0; + let projectsLeading = 0; + let projectsContributing = 0; + let projectsParticipating = 0; + let projectsSilent = 0; + let boardMembers = 0; + let committeeMembers = 0; + let mtwBoard = 0; + let mtwTechnical = 0; + let mtwMarketing = 0; + let mtwWorkingGroup = 0; + let mtwOther = 0; + + for (const raw of rawRows) { + let row = rowsByFoundation.get(raw.FOUNDATION_ID); + if (!row) { + // Normalize the Outside-LF umbrella row's slug at the wire boundary. + // dbt emits FOUNDATION_ID='__outside_lf__' and FOUNDATION_SLUG also + // equal to '__outside_lf__' (literal sentinel string, NOT null) for + // the umbrella row. The wire value MUST be the kebab-case slug + // 'outside-lf'. We force this for any row_kind='outside_lf' + // regardless of what dbt sends — the sentinel is an internal + // implementation detail and MUST NOT leak into testids, telemetry + // payloads, or future routing keys. + const foundationSlug = raw.ROW_KIND === 'outside_lf' ? 'outside-lf' : (raw.FOUNDATION_SLUG ?? raw.FOUNDATION_ID); + row = { + foundationId: raw.FOUNDATION_ID, + foundationSlug, + foundationName: raw.FOUNDATION_NAME, + foundationLogoUrl: raw.FOUNDATION_LOGO_URL, + rowKind: raw.ROW_KIND, + membershipTierClass: raw.MEMBERSHIP_TIER_CLASS, + membershipTierDisplayName: raw.MEMBERSHIP_TIER_DISPLAY_NAME, + projectCount: raw.PROJECT_COUNT_LF ?? 0, + badges: { + orgRole: raw.ORG_ROLE, + votingStatus: raw.VOTING_STATUS, + // Outside LF emits NULL bucket → render em-dash. + governanceParticipation: raw.GOVERNANCE_PARTICIPATION_BUCKET ?? '—', + governanceAttendancePct: raw.GOVERNANCE_ATTENDANCE_PCT, + }, + projects: [], + }; + rowsByFoundation.set(raw.FOUNDATION_ID, row); + + // Foundations tile: count member foundations only. + if (row.rowKind === 'member' && row.membershipTierClass) { + foundationsTotal += 1; + tierBreakdown[row.membershipTierClass] = (tierBreakdown[row.membershipTierClass] ?? 0) + 1; + } + + // Governance + meetings tiles: LF foundations only. + // Outside-LF emits zero from dbt, but skip to be explicit. + if (row.rowKind !== 'outside_lf') { + boardMembers += raw.BOARD_MEMBER_SEAT_COUNT ?? 0; + committeeMembers += raw.COMMITTEE_MEMBER_SEAT_COUNT ?? 0; + mtwBoard += raw.MEETINGS_THIS_WEEK_BOARD ?? 0; + mtwTechnical += raw.MEETINGS_THIS_WEEK_TECHNICAL ?? 0; + mtwMarketing += raw.MEETINGS_THIS_WEEK_MARKETING ?? 0; + mtwWorkingGroup += raw.MEETINGS_THIS_WEEK_WORKING_GROUP ?? 0; + mtwOther += raw.MEETINGS_THIS_WEEK_OTHER ?? 0; + } + } + + if (raw.PROJECT_ID && raw.PROJECT_INFLUENCE_MAX_BUCKET) { + const project: OrgLensFoundationProject = { + projectId: raw.PROJECT_ID, + projectSlug: raw.PROJECT_SLUG ?? raw.PROJECT_ID, + projectName: raw.PROJECT_NAME ?? raw.PROJECT_ID, + isLfProject: raw.PROJECT_IS_LF_PROJECT === true, + influence: raw.PROJECT_INFLUENCE_MAX_BUCKET, + maintainers: raw.PROJECT_MAINTAINERS_COUNT ?? 0, + contributors: raw.PROJECT_CONTRIBUTORS_COUNT ?? 0, + collaborators: raw.PROJECT_COLLABORATORS_COUNT ?? 0, + commits: raw.PROJECT_COMMITS_COUNT ?? 0, + }; + row.projects.push(project); + + // Projects tile: count every project on every row (including + // Outside-LF) per the influence bucket. + switch (project.influence) { + case 'Leading': + projectsLeading += 1; + break; + case 'Contributing': + projectsContributing += 1; + break; + case 'Participating': + projectsParticipating += 1; + break; + case 'Silent': + projectsSilent += 1; + break; + } + } + } + + const rows = Array.from(rowsByFoundation.values()); + + const statStrip: OrgLensFoundationsStatStrip = { + foundations: { total: foundationsTotal, breakdown: tierBreakdown }, + projects: { + total: projectsLeading + projectsContributing + projectsParticipating + projectsSilent, + leading: projectsLeading, + contributing: projectsContributing, + participating: projectsParticipating, + silent: projectsSilent, + }, + governanceRoles: { total: boardMembers + committeeMembers, boardMembers, committeeMembers }, + meetingsThisWeek: { + total: mtwBoard + mtwTechnical + mtwMarketing + mtwWorkingGroup + mtwOther, + board: mtwBoard, + technical: mtwTechnical, + marketing: mtwMarketing, + workingGroup: mtwWorkingGroup, + other: mtwOther, + }, + }; + + return { accountId, accountName, statStrip, rows }; + } + + private emptyResponse(accountId: string): OrgLensFoundationsAndProjectsResponse { + return { + accountId, + accountName: '', + statStrip: { + foundations: { total: 0, breakdown: {} }, + projects: { total: 0, leading: 0, contributing: 0, participating: 0, silent: 0 }, + governanceRoles: { total: 0, boardMembers: 0, committeeMembers: 0 }, + meetingsThisWeek: { total: 0, board: 0, technical: 0, marketing: 0, workingGroup: 0, other: 0 }, + }, + rows: [], + }; + } +} diff --git a/apps/lfx-one/src/server/services/organization.service.ts b/apps/lfx-one/src/server/services/organization.service.ts index 49dac8f49..c68a1620c 100644 --- a/apps/lfx-one/src/server/services/organization.service.ts +++ b/apps/lfx-one/src/server/services/organization.service.ts @@ -6,6 +6,8 @@ import { CertifiedEmployeesMonthlyRow, CertifiedEmployeesResponse, + OrgLensAccountContextResponse, + OrgLensAccountContextRow, FoundationCompanyBusFactorResponse, FoundationCompanyBusFactorRow, MembershipTierResponse, @@ -870,4 +872,62 @@ export class OrganizationService { return { programs }; } + + /** + * Resolve LFX One Org Lens account context for a set of Salesforce + * accounts (typically the user's persona-authorised organizations). + * + * Single-table read against + * platinum_lfx_one_org_lens_account_context — one denormalised + * platinum row per account_id with display attributes, Crowd.dev + * mapping, and the highest active corporate membership tier + * pre-joined inside dbt. No application-layer joins, no conglomerate + * expansion (the UI renders a flat list). + */ + public async getOrgLensAccountContext(accountIds: string[]): Promise { + if (accountIds.length === 0) { + return []; + } + + const placeholders = accountIds.map(() => '?').join(','); + const query = ` + SELECT + ACCOUNT_ID, + ACCOUNT_NAME, + ACCOUNT_SLUG, + LOGO_URL, + CDEV_ORG_ID, + CDEV_ORG_NAME, + CDEV_ORG_LOGO, + IS_MEMBER, + MEMBER_ACCOUNT_TYPE, + MEMBERSHIP_ID, + MEMBERSHIP_PROJECT_ID, + MEMBERSHIP_PROJECT_NAME, + MEMBERSHIP_TIER_DISPLAY_NAME, + MEMBERSHIP_TIER_CLASS + FROM ANALYTICS_DEV.LF_AOPEYEMI_PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT + WHERE ACCOUNT_ID IN (${placeholders}) + ORDER BY ACCOUNT_NAME + `; + + const result = await this.snowflakeService.execute(query, accountIds); + + return result.rows.map((row) => ({ + accountId: row.ACCOUNT_ID, + accountName: row.ACCOUNT_NAME, + accountSlug: row.ACCOUNT_SLUG, + logoUrl: row.LOGO_URL, + cdevOrgId: row.CDEV_ORG_ID, + cdevOrgName: row.CDEV_ORG_NAME, + cdevOrgLogo: row.CDEV_ORG_LOGO, + isMember: row.IS_MEMBER, + memberAccountType: row.MEMBER_ACCOUNT_TYPE, + membershipId: row.MEMBERSHIP_ID, + membershipProjectId: row.MEMBERSHIP_PROJECT_ID, + membershipProjectName: row.MEMBERSHIP_PROJECT_NAME, + membershipTierDisplayName: row.MEMBERSHIP_TIER_DISPLAY_NAME, + membershipTierClass: row.MEMBERSHIP_TIER_CLASS, + })); + } } 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 fbc3e4c58..bae823417 100644 --- a/apps/lfx-one/src/server/services/persona-detection.service.ts +++ b/apps/lfx-one/src/server/services/persona-detection.service.ts @@ -4,6 +4,7 @@ import { AFFILIATED_PROJECT_UIDS_CACHE_TTL_MS, DETECTION_SOURCE_MAP, + ORG_LENS_DEMO_SEED_ACCOUNTS, PERSONA_PRIORITY, PERSONAS_CACHE_TTL_MS, ROOT_PROJECT_SLUG, @@ -203,7 +204,7 @@ export class PersonaDetectionService { personaProjects: {}, personas: ['contributor'], projects: [], - organizations: [], + organizations: this.withDemoSeedFallback(req, []), error: detectionResponse.error.message, }; } @@ -215,7 +216,7 @@ export class PersonaDetectionService { personaProjects: {}, personas: ['contributor'], projects: [], - organizations: [], + organizations: this.withDemoSeedFallback(req, []), error: null, }; } @@ -235,11 +236,36 @@ export class PersonaDetectionService { personaProjects, personas, projects, - organizations: this.extractOrganizations(req, projects), + organizations: this.withDemoSeedFallback(req, this.extractOrganizations(req, projects)), error: null, }; } + /** + * Org Lens demo seed fallback. + * + * Until the upstream persona service returns user-scoped organizations + * directly (blocked on persona-service work), the only path that + * populates `organizations` today is `extractOrganizations`, which + * scrapes `board_member` detection extras. Users without board + * memberships — i.e. most engineering accounts on the dev cluster — + * would see an empty selector. + * + * Returns the demo seed (`ORG_LENS_DEMO_SEED_ACCOUNTS`) only when the + * real extraction produced nothing, so real board members still see + * their actual orgs first. Replace this fallback with a live source + * once the persona contract delivers organizations. + */ + private withDemoSeedFallback(req: Request, accounts: Account[]): Account[] { + if (accounts.length > 0) { + return accounts; + } + logger.debug(req, 'extract_organizations', 'No detected orgs — returning Org Lens demo seed', { + seed_count: ORG_LENS_DEMO_SEED_ACCOUNTS.length, + }); + return ORG_LENS_DEMO_SEED_ACCOUNTS.map((account) => ({ ...account })); + } + private async fetchAndResolveAffiliatedSlugs(req: Request, username: string, email: string): Promise { const detectionResponse = await this.fetchPersonaDetections(req, username, email); diff --git a/packages/shared/src/constants/accounts.constants.ts b/packages/shared/src/constants/accounts.constants.ts index 51062d35b..2b88bb341 100644 --- a/packages/shared/src/constants/accounts.constants.ts +++ b/packages/shared/src/constants/accounts.constants.ts @@ -6,37 +6,24 @@ import type { Account } from '../interfaces/account.interface'; export const ACCOUNT_COOKIE_KEY = 'lfx-selected-account'; /** - * Available accounts for board member dashboard - * @description Predefined list of organizations with their account IDs + * Demo seed for the Org Lens feature branch. Used as a fallback in + * PersonaDetectionService when the upstream persona service doesn't yet + * return user-scoped { accountId, cdevOrgId } pairs (blocked on upstream + * persona-service work). Only identifier + display name live here — + * tier, slug, and logo are intentionally resolved from Snowflake via + * getOrgLensAccountContext, so the demo exercises the real enrichment + * path end-to-end. + * + * Remove (or replace with a live source) once the persona service + * delivers organizations directly. */ -export const ACCOUNTS: Account[] = [ - { accountId: '0012M00002kMtLDQA0', accountName: 'Axcelis Technologies' }, - { accountId: '0014100000TdzqcAAB', accountName: 'Credit Suisse' }, - { accountId: '0012M00002GDRWBQA5', accountName: 'Ericsson Software Technology' }, - { accountId: '0014100000TdzJHAAZ', accountName: 'Fujitsu Limited' }, - { accountId: '0014100000TdzJQAAZ', accountName: 'GitLab Inc.' }, - { accountId: '0014100000Te02DAAR', accountName: 'Google LLC' }, - { accountId: '0014100000TdzABAAZ', accountName: 'Huawei Technologies Co., Ltd' }, - { accountId: '0014100000TdzA7AAJ', accountName: 'Intel Corporation' }, - { accountId: '0014100000TdzwSAAR', accountName: 'Meta Platforms, Inc.' }, - { accountId: '0014100000Te0OKAAZ', accountName: 'Microsoft Corporation' }, - { accountId: '0014100000Te0QfAAJ', accountName: 'NEC Corporation' }, - { accountId: '0014100000Te2MfAAJ', accountName: 'Oracle America Inc.' }, - { accountId: 'lflowfQUSzqglPDJtp', accountName: 'Panasonic Corporation' }, - { accountId: '0014100000Te2PpAAJ', accountName: 'Qualcomm, Inc.' }, +export const ORG_LENS_DEMO_SEED_ACCOUNTS: Account[] = [ + { accountId: '0012M00002ND5yOQAT', accountName: 'Toyota Motor Corporation' }, + { accountId: '0014100000kgVoqAAE', accountName: 'International Business Machines Corporation' }, { accountId: '0014100000Te2QjAAJ', accountName: 'Red Hat, Inc.' }, - { accountId: '0012M00002KB7YYQA1', accountName: 'Redpoint Ventures' }, - { accountId: '0014100000Te0bvAAB', accountName: 'Renesas Electronics Corporation' }, - { accountId: '0014100000Te0dPAAR', accountName: 'Samsung Electronics Co. Ltd.' }, - { accountId: '0014100000Te0jpAAB', accountName: 'Sony Group Corporation' }, - { accountId: '0014100000Te2ovAAB', accountName: 'The Linux Foundation' }, + { accountId: '0014100000TdzYmAAJ', accountName: 'Apptio' }, + { accountId: '0012M00002ZLGHsQAP', accountName: 'IBM Watson Health' }, + { accountId: '0012M000027Enn8QAC', accountName: 'Nordcloud, an IBM Company' }, + { accountId: '0012M00002F2mObQAJ', accountName: 'Stackwatch Inc' }, + { accountId: '0014100000Te2qeAAB', accountName: 'Turbonomic, an IBM Company' }, ]; - -/** - * Default account for board member dashboard - * @description The Linux Foundation is used as the default selection - */ -export const DEFAULT_ACCOUNT: Account = { - accountId: '0014100000Te2ovAAB', - accountName: 'The Linux Foundation', -}; diff --git a/packages/shared/src/constants/dashboard-metrics.constants.ts b/packages/shared/src/constants/dashboard-metrics.constants.ts index e5d83c451..69e3d4140 100644 --- a/packages/shared/src/constants/dashboard-metrics.constants.ts +++ b/packages/shared/src/constants/dashboard-metrics.constants.ts @@ -318,6 +318,59 @@ export const PRIMARY_INVOLVEMENT_METRICS: DashboardMetricCard[] = [ }, ]; +// ============================================ +// Org Overview Involvement Metrics (cross-foundation, non-clickable) +// ============================================ + +/** + * Engagement card configuration for the /org/overview involvement section. + * 6 cards aggregated across all LF foundations. Cards are NOT clickable — no drawerType. + */ +export const ORG_INVOLVEMENT_METRICS: DashboardMetricCard[] = [ + { + title: 'Active Contributors', + icon: 'fa-light fa-code', + chartType: 'bar', + category: 'contributors', + testId: 'org-overview-involvement-card-active-contributors', + }, + { + title: 'Maintainers', + icon: 'fa-light fa-user-check', + chartType: 'bar', + category: 'contributors', + testId: 'org-overview-involvement-card-maintainers', + }, + { + title: 'Event Attendees', + icon: 'fa-light fa-user-group', + chartType: 'line', + category: 'events', + testId: 'org-overview-involvement-card-event-attendees', + }, + { + title: 'Event Speakers', + icon: 'fa-light fa-award-simple', + chartType: 'line', + category: 'events', + testId: 'org-overview-involvement-card-event-speakers', + }, + { + title: 'Certified Employees', + icon: 'fa-light fa-graduation-cap', + chartType: 'line', + category: 'education', + testId: 'org-overview-involvement-card-certified-employees', + }, + { + title: 'Training Enrollments', + icon: 'fa-light fa-graduation-cap', + chartType: 'line', + category: 'education', + testId: 'org-overview-involvement-card-training-enrollments', + }, +]; + // ============================================ // Marketing Overview Metrics (Executive Director) // ============================================ diff --git a/packages/shared/src/interfaces/account.interface.ts b/packages/shared/src/interfaces/account.interface.ts index 68c3b7dc7..4945b5812 100644 --- a/packages/shared/src/interfaces/account.interface.ts +++ b/packages/shared/src/interfaces/account.interface.ts @@ -2,12 +2,21 @@ // SPDX-License-Identifier: MIT /** - * Account entity representing an organization in the system - * @description Maps account ID to organization name for board member dashboard + * Account entity representing an organization in the LFX One Org Lens. + * Used by the org-selector and any header/badge that needs the org's + * display attributes. */ export interface Account { - /** Unique account identifier */ + /** Salesforce account_id — primary join key */ accountId: string; /** Organization display name */ accountName: string; + /** Crowd.dev organization id — secondary identifier, mapped from accountId in Snowflake */ + cdevOrgId?: string; + /** URL-friendly slug derived from the account name */ + accountSlug?: string; + /** Logo URL for the organization */ + logoUrl?: string; + /** Highest active corporate membership tier display name (e.g. "Platinum Membership"). NULL/empty → no badge. */ + membershipTier?: string; } diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 7d1ade301..9cdd30654 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -66,6 +66,9 @@ export * from './snowflake.interface'; // Account interfaces export * from './account.interface'; +// Org Lens (per-account TLF membership tier + cdev org mapping) interfaces +export * from './org-lens.interface'; + // Mailing list interfaces export * from './mailing-list.interface'; @@ -145,5 +148,8 @@ export * from './supabase.interface'; // Stat card interfaces export * from './stat-card.interface'; +// Org involvement interfaces (cross-foundation org overview) +export * from './org-involvement.interface'; + // Changelog interfaces export * from './changelog.interface'; diff --git a/packages/shared/src/interfaces/org-involvement.interface.ts b/packages/shared/src/interfaces/org-involvement.interface.ts new file mode 100644 index 000000000..01d9671db --- /dev/null +++ b/packages/shared/src/interfaces/org-involvement.interface.ts @@ -0,0 +1,90 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Foundation entry in the org foundation coverage response + */ +export interface OrgFoundationCoverageFoundation { + foundationId: string; + foundationSlug: string; + foundationName: string; +} + +/** + * GET /api/analytics/org-foundation-coverage + * How many LF foundations this organization is involved in + */ +export interface OrgFoundationCoverageResponse { + accountId: string; + foundationCount: number; + foundations: OrgFoundationCoverageFoundation[]; +} + +/** + * GET /api/analytics/org-contributors-monthly + * Cross-foundation active contributors aggregated monthly (12-month window) + */ +export interface OrgInvolvementContributorsMonthlyResponse { + accountId: string; + totalActiveContributors: number; + monthlyData: number[]; + monthlyLabels: string[]; +} + +/** + * GET /api/analytics/org-maintainers-monthly + * Cross-foundation maintainers aggregated monthly (12-month window) + */ +export interface OrgInvolvementMaintainersMonthlyResponse { + accountId: string; + accountName: string; + totalMaintainersYearly: number; + totalProjectsYearly: number; + monthlyData: number[]; + monthlyLabels: string[]; +} + +/** + * GET /api/analytics/org-event-attendance-monthly + * Cross-foundation event attendance aggregated monthly (12-month window) + */ +export interface OrgInvolvementEventAttendanceMonthlyResponse { + accountId: string; + accountName: string; + totalAttended: number; + totalSpeakers: number; + attendeesMonthlyData: number[]; + speakersMonthlyData: number[]; + monthlyLabels: string[]; +} + +/** + * GET /api/analytics/org-certified-employees-monthly + * Cross-foundation certified employees aggregated monthly (12-month window) + */ +export interface OrgInvolvementCertifiedEmployeesMonthlyResponse { + accountId: string; + totalCertifications: number; + totalCertifiedEmployees: number; + monthlyData: number[]; + monthlyLabels: string[]; +} + +/** + * Daily data point for training enrollments + */ +export interface OrgTrainingEnrollmentDailyDataPoint { + date: string; + count: number; + cumulativeCount: number; +} + +/** + * GET /api/analytics/org-training-enrollments + * Cross-foundation training enrollments YTD (daily grain) + */ +export interface OrgTrainingEnrollmentsResponse { + accountId: string; + totalEnrollments: number; + dailyData: OrgTrainingEnrollmentDailyDataPoint[]; +} diff --git a/packages/shared/src/interfaces/org-lens.interface.ts b/packages/shared/src/interfaces/org-lens.interface.ts new file mode 100644 index 000000000..cde69e582 --- /dev/null +++ b/packages/shared/src/interfaces/org-lens.interface.ts @@ -0,0 +1,158 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * LFX Org Lens membership tier classes, in descending tier order. + * Drives any tier-based ranking or filtering on the client side. + * The set of valid values must stay in sync with the canonical tier + * classes emitted by the upstream membership-tier table. + * + * Rank: Platinum(1) > Premier(2) > Founding(3) > Strategic(4) > Gold(5) > + * Steering(6) > Silver(7) > General(8) > Associate(9) > End User(10) > + * Academic(11) > Contributor(12) > Other(13) + * + * Migration from the prior 10-class ladder to the current 13-class one: + * - Sponsor is REMOVED. Existing 'Associate Sponsor' display labels + * reclassify to Associate via the upstream LIKE '%Associate%' arm. + * - Steering DEMOTED from rank 3 to rank 6 (now below Gold). + * - Founding, Strategic, End User, Contributor are NEW canonical classes. + */ +export type MembershipTierClass = + | 'Platinum' + | 'Premier' + | 'Founding' + | 'Strategic' + | 'Gold' + | 'Steering' + | 'Silver' + | 'General' + | 'Associate' + | 'End User' + | 'Academic' + | 'Contributor' + | 'Other'; + +/** + * Raw row from ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT — the + * single denormalised platinum table that resolves a Salesforce + * account_id to the full Org Lens display context (account attributes, + * Crowd.dev mapping, highest active corporate membership tier). + */ +export interface OrgLensAccountContextRow { + ACCOUNT_ID: string; + ACCOUNT_NAME: string; + ACCOUNT_SLUG: string | null; + LOGO_URL: string | null; + CDEV_ORG_ID: string | null; + CDEV_ORG_NAME: string | null; + CDEV_ORG_LOGO: string | null; + IS_MEMBER: boolean; + MEMBER_ACCOUNT_TYPE: string | null; + MEMBERSHIP_ID: string | null; + MEMBERSHIP_PROJECT_ID: string | null; + MEMBERSHIP_PROJECT_NAME: string | null; + MEMBERSHIP_TIER_DISPLAY_NAME: string | null; + MEMBERSHIP_TIER_CLASS: MembershipTierClass | null; +} + +export interface OrgLensAccountContextResponse { + accountId: string; + accountName: string; + accountSlug: string | null; + logoUrl: string | null; + cdevOrgId: string | null; + cdevOrgName: string | null; + cdevOrgLogo: string | null; + isMember: boolean; + memberAccountType: string | null; + membershipId: string | null; + membershipProjectId: string | null; + membershipProjectName: string | null; + membershipTierDisplayName: string | null; + membershipTierClass: MembershipTierClass | null; +} + +// ───────────────────────────────────────────────────────────────────────── +// Foundations and Projects section +// ───────────────────────────────────────────────────────────────────────── + +export type OrgLensRowKind = 'member' | 'non_member' | 'outside_lf'; +export type OrgRoleBadge = 'Director' | 'Member' | 'Non-Member'; +export type VotingStatusBadge = 'Voting' | 'Observer' | '—'; +export type GovernanceParticipationBucket = 'Active' | 'Partial' | 'Inactive' | '—'; +export type ProjectInfluenceBucket = 'Leading' | 'Contributing' | 'Participating' | 'Silent'; + +/** + * One inline-detail-table row per project the org is involved with under a foundation. + * Sorted by `commits` DESC on the wire. + */ +export interface OrgLensFoundationProject { + projectId: string; + projectSlug: string; + projectName: string; + isLfProject: boolean; + influence: ProjectInfluenceBucket; + maintainers: number; + contributors: number; + collaborators: number; + commits: number; +} + +/** + * One row in the foundations table. Inline-detail data is pre-loaded + * inside `projects` so the caret toggle never triggers a fetch. + */ +export interface OrgLensFoundationRow { + foundationId: string; + foundationSlug: string; + foundationName: string; + foundationLogoUrl: string | null; + rowKind: OrgLensRowKind; + membershipTierClass: MembershipTierClass | null; + membershipTierDisplayName: string | null; + projectCount: number; + badges: { + orgRole: OrgRoleBadge; + votingStatus: VotingStatusBadge; + governanceParticipation: GovernanceParticipationBucket; + /** 0..1; null on outside-lf rows (no LF governance to participate in). */ + governanceAttendancePct: number | null; + }; + projects: OrgLensFoundationProject[]; +} + +export interface OrgLensFoundationsStatStrip { + foundations: { + total: number; + /** Zero buckets MUST be omitted on the wire. */ + breakdown: Partial>; + }; + projects: { + total: number; + leading: number; + contributing: number; + participating: number; + silent: number; + }; + governanceRoles: { + total: number; + boardMembers: number; + committeeMembers: number; + }; + meetingsThisWeek: { + total: number; + board: number; + technical: number; + marketing: number; + workingGroup: number; + other: number; + }; +} + +export interface OrgLensFoundationsAndProjectsResponse { + accountId: string; + accountName: string; + statStrip: OrgLensFoundationsStatStrip; + /** Render-ordered: member tier-ranked → non-member by project count → outside-lf last. */ + rows: OrgLensFoundationRow[]; +}