diff --git a/apps/lfx-one/src/server/services/org-contributions.service.ts b/apps/lfx-one/src/server/services/org-contributions.service.ts index 3c2ba7170..9de254c35 100644 --- a/apps/lfx-one/src/server/services/org-contributions.service.ts +++ b/apps/lfx-one/src/server/services/org-contributions.service.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { ContributionSource, ContributionsCommitSortColumn, @@ -17,6 +18,7 @@ import type { } from '@lfx-one/shared/interfaces'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; const PLATINUM_TABLE = 'ANALYTICS.PLATINUM_LFX_ONE.ORG_CODE_CONTRIBUTIONS'; @@ -90,19 +92,19 @@ export class OrgContributionsService { const repoPagination = viewAwarePagination(query, 'repositories'); const commitPagination = viewAwarePagination(query, 'commits'); - const [kpiResult, repoResult, commitResult, projectOptions, employeeOptions] = await Promise.all([ - this.fetchKpis(accountId, scope, kpiSearch), - this.fetchRepositories(accountId, scope, repoSearch, query, repoPagination), - this.fetchCommits(accountId, scope, commitSearch, query, commitPagination), - this.fetchProjectOptions(accountId, query.dateRange), - this.fetchEmployeeOptions(accountId, query.dateRange), - ]); - - const kpis = mapKpis(kpiResult.rows[0]); - const totalRecords = repoResult.rows.length > 0 ? repoResult.rows[0].TOTAL_RECORDS : 0; - const repositories = query.view === 'repositories' ? repoResult.rows.map(mapRepoRow) : []; - const commitsTotalRecords = commitResult.rows.length > 0 ? commitResult.rows[0].TOTAL_RECORDS : 0; - const commits = query.view === 'commits' ? commitResult.rows.map(mapCommitRow) : []; + const raw = await withOrgCache( + accountId, + `contributions:${contributionsSignature(query)}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchContributionsRaw(accountId, query, scope, kpiSearch, repoSearch, commitSearch, repoPagination, commitPagination), + isContributionsRaw + ); + + const kpis = mapKpis(raw.kpiRows[0]); + const totalRecords = raw.repoRows.length > 0 ? raw.repoRows[0].TOTAL_RECORDS : 0; + const repositories = query.view === 'repositories' ? raw.repoRows.map(mapRepoRow) : []; + const commitsTotalRecords = raw.commitRows.length > 0 ? raw.commitRows[0].TOTAL_RECORDS : 0; + const commits = query.view === 'commits' ? raw.commitRows.map(mapCommitRow) : []; return { accountId, @@ -110,13 +112,46 @@ export class OrgContributionsService { kpis, repositories, commits, - projectOptions, - employeeOptions, + projectOptions: raw.projectOptionRows.map(mapProjectOption), + employeeOptions: raw.employeeOptionRows.map(mapEmployeeOption), totalRecords, commitsTotalRecords, }; } + private async fetchContributionsRaw( + accountId: string, + query: OrgContributionsQuery, + scope: ScopeFilters, + kpiSearch: SearchFilter, + repoSearch: SearchFilter, + commitSearch: SearchFilter, + repoPagination: ViewAwarePagination, + commitPagination: ViewAwarePagination + ): Promise<{ + kpiRows: ContributionsKpiRow[]; + repoRows: ContributionsRepoRow[]; + commitRows: ContributionsCommitRow[]; + projectOptionRows: ContributionsProjectOptionRow[]; + employeeOptionRows: ContributionsEmployeeOptionRow[]; + }> { + const [kpiResult, repoResult, commitResult, projectResult, employeeResult] = await Promise.all([ + this.fetchKpis(accountId, scope, kpiSearch), + this.fetchRepositories(accountId, scope, repoSearch, query, repoPagination), + this.fetchCommits(accountId, scope, commitSearch, query, commitPagination), + this.fetchProjectOptionRows(accountId, query.dateRange), + this.fetchEmployeeOptionRows(accountId, query.dateRange), + ]); + + return { + kpiRows: kpiResult.rows, + repoRows: repoResult.rows, + commitRows: commitResult.rows, + projectOptionRows: projectResult.rows, + employeeOptionRows: employeeResult.rows, + }; + } + private async fetchKpis(accountId: string, scope: ScopeFilters, kpiSearch: SearchFilter): Promise<{ rows: ContributionsKpiRow[] }> { const sql = ` SELECT @@ -235,7 +270,7 @@ export class OrgContributionsService { return this.snowflakeService.execute(sql, [accountId, ...scope.binds, ...commitSearch.binds]); } - private async fetchProjectOptions(accountId: string, dateRange: ContributionsDateRange): Promise { + private async fetchProjectOptionRows(accountId: string, dateRange: ContributionsDateRange): Promise<{ rows: ContributionsProjectOptionRow[] }> { const datePredicate = dateRangePredicate(dateRange); const sql = ` SELECT @@ -251,17 +286,10 @@ export class OrgContributionsService { GROUP BY project_id ORDER BY commits DESC, project_name ASC `; - const result = await this.snowflakeService.execute(sql, [accountId]); - return result.rows.map((row) => ({ - slug: row.PROJECT_SLUG ?? row.PROJECT_ID, - projectId: row.PROJECT_ID, - name: row.PROJECT_NAME ?? row.PROJECT_ID, - commits: row.COMMITS ?? 0, - parentSlug: row.PARENT_SLUG, - })); + return this.snowflakeService.execute(sql, [accountId]); } - private async fetchEmployeeOptions(accountId: string, dateRange: ContributionsDateRange): Promise { + private async fetchEmployeeOptionRows(accountId: string, dateRange: ContributionsDateRange): Promise<{ rows: ContributionsEmployeeOptionRow[] }> { const datePredicate = dateRangePredicate(dateRange); const sql = ` SELECT @@ -275,12 +303,7 @@ export class OrgContributionsService { GROUP BY member_id ORDER BY commits DESC, member_display_name ASC `; - const result = await this.snowflakeService.execute(sql, [accountId]); - return result.rows.map((row) => ({ - id: row.MEMBER_ID, - displayName: row.MEMBER_DISPLAY_NAME ?? row.MEMBER_ID, - commits: row.COMMITS ?? 0, - })); + return this.snowflakeService.execute(sql, [accountId]); } } @@ -415,6 +438,65 @@ function mapKpis(row: ContributionsKpiRow | undefined): OrgContributionsKpis { }; } +function mapProjectOption(row: ContributionsProjectOptionRow): OrgContributionProjectOption { + return { + slug: row.PROJECT_SLUG ?? row.PROJECT_ID, + projectId: row.PROJECT_ID, + name: row.PROJECT_NAME ?? row.PROJECT_ID, + commits: row.COMMITS ?? 0, + parentSlug: row.PARENT_SLUG, + }; +} + +function mapEmployeeOption(row: ContributionsEmployeeOptionRow): OrgContributionEmployeeOption { + return { + id: row.MEMBER_ID, + displayName: row.MEMBER_DISPLAY_NAME ?? row.MEMBER_ID, + commits: row.COMMITS ?? 0, + }; +} + +/** Deterministic, key-safe cache-key suffix covering every query field that changes the SQL (filter arrays sorted so member order never fragments the key); base64url keeps it to `[A-Za-z0-9_-]`. */ +function contributionsSignature(query: OrgContributionsQuery): string { + const parts = [ + query.dateRange, + query.view, + query.search, + query.sort, + query.dir, + query.commitSort, + query.commitDir, + query.page, + query.size, + [...query.projects].sort().join(','), + [...query.employees].sort().join(','), + ]; + return Buffer.from(JSON.stringify(parts), 'utf8').toString('base64url'); +} + +function isContributionsRaw(value: unknown): boolean { + const v = value as { kpiRows?: unknown; repoRows?: unknown; commitRows?: unknown; projectOptionRows?: unknown; employeeOptionRows?: unknown } | null; + return ( + !!v && + isRowArray(v.kpiRows, 'PROJECTS_WITH_ACTIVITY', 'REPOSITORIES', 'COMMITS') && + isRowArray(v.repoRows, 'REPOSITORY_URL') && + isRowArray(v.commitRows, 'COMMIT_ID') && + isRowArray(v.projectOptionRows, 'PROJECT_ID') && + isRowArray(v.employeeOptionRows, 'MEMBER_ID') + ); +} + +/** Array guard that validates every element so a single corrupt/legacy row degrades the whole entry to a cache miss: each element must be a non-null object carrying all required contract keys. */ +function isRowArray(value: unknown, ...requiredKeys: string[]): boolean { + return ( + Array.isArray(value) && + value.every((row) => { + if (row === null || typeof row !== 'object' || Array.isArray(row)) return false; + return requiredKeys.every((key) => key in (row as Record)); + }) + ); +} + function mapRepoRow(row: ContributionsRepoRow): OrgContributionRepoRow { return { repositoryId: row.REPOSITORY_URL, diff --git a/apps/lfx-one/src/server/services/org-involvement.service.ts b/apps/lfx-one/src/server/services/org-involvement.service.ts index 56653add8..aa9baa196 100644 --- a/apps/lfx-one/src/server/services/org-involvement.service.ts +++ b/apps/lfx-one/src/server/services/org-involvement.service.ts @@ -9,8 +9,10 @@ import { OrgInvolvementMaintainersMonthlyResponse, OrgTrainingEnrollmentsResponse, } from '@lfx-one/shared'; +import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; const formatMonthLabel = (date: Date): string => date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); @@ -83,6 +85,71 @@ export class OrgInvolvementService { * an empty state instead of treating the call as an error. */ public async getFoundationCoverage(accountId: string): Promise { + return withOrgCache( + accountId, + 'coverage', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchFoundationCoverage(accountId), + OrgInvolvementService.hasAccountId + ); + } + + public async getContributorsMonthly(accountId: string): Promise { + return withOrgCache( + accountId, + 'contributors', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchContributorsMonthly(accountId), + OrgInvolvementService.hasAccountId + ); + } + + public async getMaintainersMonthly(accountId: string): Promise { + return withOrgCache( + accountId, + 'maintainers', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchMaintainersMonthly(accountId), + OrgInvolvementService.hasAccountId + ); + } + + public async getEventAttendanceMonthly(accountId: string): Promise { + return withOrgCache( + accountId, + 'events', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventAttendanceMonthly(accountId), + OrgInvolvementService.hasAccountId + ); + } + + public async getCertifiedEmployeesMonthly(accountId: string): Promise { + return withOrgCache( + accountId, + 'certs', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchCertifiedEmployeesMonthly(accountId), + OrgInvolvementService.hasAccountId + ); + } + + public async getTrainingEnrollments(accountId: string): Promise { + return withOrgCache( + accountId, + 'training', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchTrainingEnrollments(accountId), + OrgInvolvementService.hasAccountId + ); + } + + // Rejects a corrupt/legacy entry (degrade to a miss); every response in this service carries `accountId`. + private static hasAccountId(value: unknown): boolean { + return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof (value as { accountId?: unknown }).accountId === 'string'; + } + + private async fetchFoundationCoverage(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -112,7 +179,7 @@ export class OrgInvolvementService { }; } - public async getContributorsMonthly(accountId: string): Promise { + private async fetchContributorsMonthly(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -140,7 +207,7 @@ export class OrgInvolvementService { }; } - public async getMaintainersMonthly(accountId: string): Promise { + private async fetchMaintainersMonthly(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -173,7 +240,7 @@ export class OrgInvolvementService { }; } - public async getEventAttendanceMonthly(accountId: string): Promise { + private async fetchEventAttendanceMonthly(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -217,7 +284,7 @@ export class OrgInvolvementService { }; } - public async getCertifiedEmployeesMonthly(accountId: string): Promise { + private async fetchCertifiedEmployeesMonthly(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -248,7 +315,7 @@ export class OrgInvolvementService { }; } - public async getTrainingEnrollments(accountId: string): Promise { + private async fetchTrainingEnrollments(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, diff --git a/apps/lfx-one/src/server/services/org-lens-access.service.ts b/apps/lfx-one/src/server/services/org-lens-access.service.ts index a764bc911..504953a1b 100644 --- a/apps/lfx-one/src/server/services/org-lens-access.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-access.service.ts @@ -3,7 +3,7 @@ // Generated with [Cursor](https://cursor.com) -import { ORG_ACCESS_ROLE_RELATION } from '@lfx-one/shared/constants'; +import { ORG_ACCESS_ROLE_RELATION, VALKEY_CACHE } from '@lfx-one/shared/constants'; import { MemberServiceB2bOrgSettings, MemberServiceOrgUser, @@ -21,6 +21,50 @@ import { logger } from './logger.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; import { OrgLensKeyContactsService } from './org-lens-key-contacts.service'; import { OrgRoleGrantsService } from './org-role-grants.service'; +import { invalidatePerUserCache, withPerUserCache } from './valkey.service'; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Each user row is served straight from cache to the client, so validate its element shape against the wire contract. */ +function isAccessUser(value: unknown): boolean { + const u = value as Partial; + return ( + isObject(value) && + typeof u.email === 'string' && + typeof u.name === 'string' && + typeof u.initials === 'string' && + (u.avatarUrl === null || typeof u.avatarUrl === 'string') && + (u.jobTitle === null || typeof u.jobTitle === 'string') && + (u.role === 'admin' || u.role === 'viewer') && + (u.inviteStatus === 'pending' || u.inviteStatus === 'accepted') && + typeof u.isPending === 'boolean' + ); +} + +function isAccessSummary(value: unknown): boolean { + const s = value as Partial; + return isObject(value) && typeof s.totalUsers === 'number' && typeof s.administrators === 'number' && typeof s.viewers === 'number'; +} + +/** Rejects a corrupt/legacy access-list entry (degrades to a miss) by validating every user element and the numeric summary fields against the wire contract. */ +function isAccessListResponse(value: unknown): boolean { + const v = value as Partial; + return ( + isObject(value) && + typeof v.orgUid === 'string' && + Array.isArray(v.users) && + v.users.every(isAccessUser) && + isAccessSummary(v.summary) && + typeof v.canManage === 'boolean' + ); +} + +/** Rejects a corrupt/legacy principals entry whose elements don't match the user wire shape (degrades to a miss before the directory merge reads user fields). */ +function isPrincipalArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isAccessUser); +} // Spec 025 — Org Lens Access read/write against member-service settings. // Reads via GET /b2b_orgs/{uid}/settings. Writes use the PER-PRINCIPAL endpoints @@ -48,12 +92,22 @@ export class OrgLensAccessService { * role-grants lookup on the post-write refresh. */ public async listAccessUsers(req: Request, orgUid: string, knownCanManage?: boolean): Promise { - const [settings, canManage] = await Promise.all([ - this.fetchSettings(req, orgUid), - knownCanManage === undefined ? this.resolveCanManage(req, orgUid) : Promise.resolve(knownCanManage), - ]); - const users = await this.enrichJobTitles(req, orgUid, this.mapPrincipals(settings)); - return { orgUid, users, summary: this.buildSummary(users), canManage }; + // A write refresh passes knownCanManage and must reflect the just-written state — bypass the cache + // (compute directly, no read/write). The caller's own earlier-read entries are dropped by the write + // path's invalidateCallerCaches; any other caller's per-user entry ages out within the TTL. + if (knownCanManage !== undefined) { + return this.computeAccessList(req, orgUid, knownCanManage); + } + const username = getEffectiveUsername(req) ?? ''; + // `:list` / `:principals` suffixes keep this read and getAccessPrincipals from colliding on the shared key. + return withPerUserCache( + `${VALKEY_CACHE.ORG_ACCESS_LIST_NAMESPACE}:list`, + username, + orgUid, + VALKEY_CACHE.ORG_LENS_PERUSER_TTL_SECONDS, + () => this.computeAccessList(req, orgUid, undefined), + isAccessListResponse + ); } /** @@ -62,8 +116,15 @@ export class OrgLensAccessService { * Access tab — the directory orchestrator owns its own merge + enrichment. */ public async getAccessPrincipals(req: Request, orgUid: string): Promise { - const settings = await this.fetchSettings(req, orgUid); - return this.mapPrincipals(settings); + const username = getEffectiveUsername(req) ?? ''; + return withPerUserCache( + `${VALKEY_CACHE.ORG_ACCESS_LIST_NAMESPACE}:principals`, + username, + orgUid, + VALKEY_CACHE.ORG_LENS_PERUSER_TTL_SECONDS, + async () => this.mapPrincipals(await this.fetchSettings(req, orgUid)), + isPrincipalArray + ); } /** Add Users — invite a NEW principal via the per-principal POST endpoint; returns the refreshed list. */ @@ -77,6 +138,7 @@ export class OrgLensAccessService { ...(cleanName ? { name: cleanName } : {}), }; await this.microserviceProxy.proxyRequest(req, 'LFX_V2_MEMBER_SERVICE', `/b2b_orgs/${encodeURIComponent(orgUid)}/settings/users`, 'POST', undefined, body); + await this.invalidateCallerCaches(req, orgUid); // canManage was just asserted true above — reuse it to skip a second role-grants lookup. return this.listAccessUsers(req, orgUid, true); } @@ -93,6 +155,7 @@ export class OrgLensAccessService { undefined, { invited_as: ORG_ACCESS_ROLE_RELATION[role] } ); + await this.invalidateCallerCaches(req, orgUid); // canManage was just asserted true above — reuse it to skip a second role-grants lookup. return this.listAccessUsers(req, orgUid, true); } @@ -107,12 +170,37 @@ export class OrgLensAccessService { `/b2b_orgs/${encodeURIComponent(orgUid)}/settings/users/${encodeURIComponent(target)}`, 'DELETE' ); + await this.invalidateCallerCaches(req, orgUid); // canManage was just asserted true above — reuse it to skip a second role-grants lookup. return this.listAccessUsers(req, orgUid, true); } // ── base helpers ───────────────────────────────────────────────────────────── + /** + * Drop the acting caller's own cached access views after a write so their next read (page reload or the + * directory `:principals` merge) reflects the mutation immediately rather than serving the pre-write list + * for up to the TTL. The write response itself already bypasses the cache; this closes the read-after-write + * gap for the caller. Best-effort — a failed delete just leaves the entry to age out within the TTL — and + * scoped to the caller, so another admin's per-user entry still ages out on its own. + */ + private async invalidateCallerCaches(req: Request, orgUid: string): Promise { + const username = getEffectiveUsername(req) ?? ''; + await Promise.all([ + invalidatePerUserCache(`${VALKEY_CACHE.ORG_ACCESS_LIST_NAMESPACE}:list`, username, orgUid), + invalidatePerUserCache(`${VALKEY_CACHE.ORG_ACCESS_LIST_NAMESPACE}:principals`, username, orgUid), + ]); + } + + private async computeAccessList(req: Request, orgUid: string, knownCanManage: boolean | undefined): Promise { + const [settings, canManage] = await Promise.all([ + this.fetchSettings(req, orgUid), + knownCanManage === undefined ? this.resolveCanManage(req, orgUid) : Promise.resolve(knownCanManage), + ]); + const users = await this.enrichJobTitles(req, orgUid, this.mapPrincipals(settings)); + return { orgUid, users, summary: this.buildSummary(users), canManage }; + } + /** Authoritative settings read (member-service source of record). */ private async fetchSettings(req: Request, orgUid: string): Promise { const settings = await this.microserviceProxy.proxyRequest( diff --git a/apps/lfx-one/src/server/services/org-lens-board-committee.service.ts b/apps/lfx-one/src/server/services/org-lens-board-committee.service.ts index 314e1faf1..23aa3fa15 100644 --- a/apps/lfx-one/src/server/services/org-lens-board-committee.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-board-committee.service.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { isBoardCategory } from '@lfx-one/shared/constants'; +import { isBoardCategory, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { BoardSeat, CommitteeSeat, @@ -17,11 +17,13 @@ import type { import { isFilterSafeIdentifier } from '@lfx-one/shared/utils'; import { Request } from 'express'; +import { getEffectiveUsername } from '../utils/auth-helper'; import { logger } from './logger.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; import { OrgLensKeyContactsService } from './org-lens-key-contacts.service'; import { OrgLensMembershipsService } from './org-lens-memberships.service'; import { ProjectService } from './project.service'; +import { withPerUserCache } from './valkey.service'; /** * Picker roster bound (FR-006 typeahead): cap the org-wide seat drain so opening the Reassign modal @@ -154,9 +156,17 @@ export class OrgLensBoardCommitteeService { return { accountId, foundationId, seat }; } - /** Spec 027: org-wide seat drain (no project filter) for the People → Committee tab. Reuses the private drain + its 200-page fail-closed safety cap so the org-wide roster never duplicates the pagination logic. */ + /** Org-wide seat drain (no project filter) for the People Committee/Board tabs and the directory picker, cached per caller + org so the single full-roster drain is shared across consumers; only the full, non-truncated drain is cached here — the bounded picker and project-scoped `getSeats` paths bypass this cache so a truncated/differently-scoped result is never served as the full roster. */ public async fetchAllOrgSeats(req: Request, orgUid: string): Promise { - return this.fetchOrgSeats(req, orgUid); + const username = getEffectiveUsername(req) ?? ''; + return withPerUserCache( + VALKEY_CACHE.ORG_SEATS_NAMESPACE, + username, + orgUid, + VALKEY_CACHE.ORG_LENS_PERUSER_TTL_SECONDS, + () => this.fetchOrgSeats(req, orgUid), + isOrgSeatArray + ); } /** Resolve the membership's project family (foundation root + descendants) for seat scoping (spec 026 T007a): SFID → slug (getFoundationSlug) → uid (getProjectIdBySlug) → family (getFoundationProjectUids); `undefined` when unresolvable so callers return an EMPTY list, never the org-wide roster. */ @@ -347,3 +357,8 @@ export class OrgLensBoardCommitteeService { }; } } + +/** Rejects a corrupt/legacy seat entry whose elements aren't non-null objects (degrades to a miss before seat fields are read). */ +function isOrgSeatArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => el !== null && typeof el === 'object' && !Array.isArray(el)); +} diff --git a/apps/lfx-one/src/server/services/org-lens-events.service.ts b/apps/lfx-one/src/server/services/org-lens-events.service.ts index 6c2c73916..9fbf1b4d9 100644 --- a/apps/lfx-one/src/server/services/org-lens-events.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-events.service.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { GetOrgEventsOptions, OrgEvent, @@ -20,6 +21,7 @@ import type { Request } from 'express'; import { logger } from './logger.service'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Service for org-lens event list endpoints — reads org event footprint from platinum ORG_EVENTS. */ export class OrgLensEventsService { @@ -90,9 +92,15 @@ export class OrgLensEventsService { const binds: string[] = [accountId]; if (searchQuery) binds.push(`%${searchQuery}%`); - let result; + let rows: OrgEventRow[]; try { - result = await this.snowflakeService.execute(sql, binds); + rows = await withOrgCache( + accountId, + `events:${paramSignature([isPast, status ?? null, searchQuery ?? null, pageSize, offset, sortColumn, sortOrder])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventListRows(sql, binds), + isEventRowArray + ); } catch (error) { logger.warning(req, 'get_org_lens_events', 'Snowflake query failed, returning empty events', { error: error instanceof Error ? error.message : String(error), @@ -101,8 +109,8 @@ export class OrgLensEventsService { return { data: [], total: 0, pageSize, offset }; } - const total = result.rows.length > 0 ? result.rows[0].TOTAL_RECORDS : 0; - const data = result.rows.map((row) => this.mapRowToOrgEvent(row)); + const total = rows.length > 0 ? rows[0].TOTAL_RECORDS : 0; + const data = rows.map((row) => this.mapRowToOrgEvent(row)); logger.debug(req, 'get_org_lens_events', 'Fetched org events', { count: data.length, total }); @@ -122,9 +130,15 @@ export class OrgLensEventsService { WHERE oe.ACCOUNT_ID = ? `; - let result; + let rows: OrgEventsSummaryRow[]; try { - result = await this.snowflakeService.execute(sql, [accountId]); + rows = await withOrgCache( + accountId, + 'events-summary', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventsSummaryRows(accountId, sql), + isObjectRowArray + ); } catch (error) { logger.warning(req, 'get_org_lens_events_summary', 'Snowflake query failed, returning zero counts', { error: error instanceof Error ? error.message : String(error), @@ -133,7 +147,7 @@ export class OrgLensEventsService { return { totalEvents: 0, pastEvents: 0, upcomingEvents: 0 }; } - const row = result.rows[0]; + const row = rows[0]; const summary: OrgEventsSummary = { totalEvents: row?.TOTAL_EVENTS ?? 0, pastEvents: row?.PAST_EVENTS ?? 0, @@ -172,9 +186,15 @@ export class OrgLensEventsService { binds.push(`%${searchQuery}%`, `%${searchQuery}%`); } - let result; + let rows: OrgEventAttendeesDrawerRow[]; try { - result = await this.snowflakeService.execute(sql, binds); + rows = await withOrgCache( + accountId, + `event-attendees:${paramSignature([eventId, searchQuery ?? null])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventAttendeeRows(sql, binds), + isContactRowArray + ); } catch (error) { logger.warning(req, 'get_event_attendees', 'Snowflake query failed, returning empty attendees', { error: error instanceof Error ? error.message : String(error), @@ -184,8 +204,8 @@ export class OrgLensEventsService { return { eventId, eventName: '', total: 0, data: [] }; } - const eventName = result.rows[0]?.EVENT_NAME ?? ''; - const data: OrgEventAttendee[] = result.rows.map((row) => ({ + const eventName = rows[0]?.EVENT_NAME ?? ''; + const data: OrgEventAttendee[] = rows.map((row) => ({ contactId: row.CONTACT_ID, name: row.NAME ?? row.CONTACT_ID, jobTitle: row.JOB_TITLE ?? null, @@ -223,9 +243,15 @@ export class OrgLensEventsService { binds.push(`%${searchQuery}%`, `%${searchQuery}%`); } - let result; + let rows: OrgEventSpeakersDrawerRow[]; try { - result = await this.snowflakeService.execute(sql, binds); + rows = await withOrgCache( + accountId, + `event-speakers:${paramSignature([eventId, searchQuery ?? null])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventSpeakerRows(sql, binds), + isContactRowArray + ); } catch (error) { logger.warning(req, 'get_event_speakers', 'Snowflake query failed, returning empty speakers', { error: error instanceof Error ? error.message : String(error), @@ -235,8 +261,8 @@ export class OrgLensEventsService { return { eventId, eventName: '', acceptedCount: 0, submittedCount: 0, data: [] }; } - const eventName = result.rows[0]?.EVENT_NAME ?? ''; - const data: OrgEventSpeaker[] = result.rows.map((row) => ({ + const eventName = rows[0]?.EVENT_NAME ?? ''; + const data: OrgEventSpeaker[] = rows.map((row) => ({ contactId: row.CONTACT_ID, name: row.NAME ?? row.CONTACT_ID, jobTitle: row.JOB_TITLE ?? null, @@ -251,6 +277,26 @@ export class OrgLensEventsService { return { eventId, eventName, acceptedCount, submittedCount, data }; } + private async fetchEventListRows(sql: string, binds: string[]): Promise { + const result = await this.snowflakeService.execute(sql, binds); + return result.rows; + } + + private async fetchEventsSummaryRows(accountId: string, sql: string): Promise { + const result = await this.snowflakeService.execute(sql, [accountId]); + return result.rows; + } + + private async fetchEventAttendeeRows(sql: string, binds: string[]): Promise { + const result = await this.snowflakeService.execute(sql, binds); + return result.rows; + } + + private async fetchEventSpeakerRows(sql: string, binds: string[]): Promise { + const result = await this.snowflakeService.execute(sql, binds); + return result.rows; + } + private mapRowToOrgEvent(row: OrgEventRow): OrgEvent { return { eventId: row.EVENT_ID, @@ -272,3 +318,27 @@ export class OrgLensEventsService { }; } } + +/** Deterministic, key-safe sub-resource suffix for the result-changing query params (base64url → only `[A-Za-z0-9_-]`). */ +function paramSignature(parts: readonly (string | number | boolean | null)[]): string { + return Buffer.from(JSON.stringify(parts), 'utf8').toString('base64url'); +} + +function isObjectRow(el: unknown): el is Record { + return el !== null && typeof el === 'object' && !Array.isArray(el); +} + +/** Summary rows expose no contract key replayed verbatim to the client (every field is read null-safe), so an object shape suffices. */ +function isObjectRowArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isObjectRow); +} + +/** Event-list rows reach the client through `mapRowToOrgEvent`, which reads `EVENT_ID` with no fallback — validate the contract key so a poisoned `[{}]` entry degrades to a miss. */ +function isEventRowArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => isObjectRow(el) && typeof el['EVENT_ID'] === 'string'); +} + +/** Attendee/speaker rows map `contactId: row.CONTACT_ID` with no fallback — validate the contract key to the same depth as the people/access guards. */ +function isContactRowArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => isObjectRow(el) && typeof el['CONTACT_ID'] === 'string'); +} 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 index f5a0f53b5..2809b22ff 100644 --- a/apps/lfx-one/src/server/services/org-lens-foundations.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-foundations.service.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { GovernanceParticipationBucket, MembershipTierClass, @@ -15,6 +16,7 @@ import type { } from '@lfx-one/shared/interfaces'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** * Raw row shape returned by the joined query (foundations rollup LEFT @@ -87,6 +89,27 @@ export class OrgLensFoundationsService { } public async getFoundationsAndProjects(accountId: string): Promise { + return withOrgCache( + accountId, + 'foundations', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchFoundationsAndProjects(accountId), + OrgLensFoundationsService.isFoundationsResponse + ); + } + + // Rejects a corrupt/legacy entry (degrade to a miss) before it reaches shapeResponse consumers. + private static isFoundationsResponse(value: unknown): value is OrgLensFoundationsAndProjectsResponse { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as OrgLensFoundationsAndProjectsResponse).accountId === 'string' && + Array.isArray((value as OrgLensFoundationsAndProjectsResponse).rows) + ); + } + + private async fetchFoundationsAndProjects(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). diff --git a/apps/lfx-one/src/server/services/org-lens-memberships.service.ts b/apps/lfx-one/src/server/services/org-lens-memberships.service.ts index 27694352a..f97ac5733 100644 --- a/apps/lfx-one/src/server/services/org-lens-memberships.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-memberships.service.ts @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { OrgActiveMembership, OrgActiveMembershipsResponse, @@ -16,6 +17,7 @@ import { Request } from 'express'; import { logger } from './logger.service'; import { OrgLensKeyContactsService } from './org-lens-key-contacts.service'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; // Spec 024: key contacts are sourced live from the query-service indexer (real-time), replacing the // spec-015 mock fixture. Only the foundation header is still derived from the Snowflake summary. @@ -72,40 +74,26 @@ export class OrgLensMembershipsService { } public async getActiveMemberships(accountId: string, search?: string, tier?: string, renewal?: string): Promise { - const query = ` - SELECT - ACCOUNT_ID, - FOUNDATION_ID, - FOUNDATION_NAME, - FOUNDATION_SLUG, - FOUNDATION_LOGO_URL, - MEMBERSHIP_TIER_DISPLAY_NAME, - TIER_START_DATE, - TIER_END_DATE, - FIRST_MEMBERSHIP_STARTED_AT, - BOARD_MEMBER_SEAT_COUNT, - COMMITTEE_MEMBER_SEAT_COUNT, - ORG_PROJECTS_COUNT, - PROJECT_COUNT, - MEMBER_COUNT, - IS_RENEWING_WITHIN_90_DAYS - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_MEMBERSHIPS_SUMMARY - WHERE ACCOUNT_ID = ? - ORDER BY FOUNDATION_NAME ASC - `; - - const result = await this.snowflakeService.execute(query, [accountId]); - - if (result.rows.length === 0) { + // Cache the raw per-org Snowflake rows so the in-JS search/tier/renewal filtering below stays out of + // the key (best hit rate); all callers share the same cached rows. + const rows = await withOrgCache( + accountId, + 'memberships-active', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchActiveMembershipRows(accountId), + OrgLensMembershipsService.isRawRowArray + ); + + if (rows.length === 0) { return { accountId, summary: { activeMemberships: 0, renewingWithin90Days: 0, governanceRoles: 0 }, memberships: [] }; } - const allMemberships = result.rows.map((raw) => this.shapeRow(raw)); + const allMemberships = rows.map((raw) => this.shapeRow(raw)); const summary = { activeMemberships: allMemberships.length, - renewingWithin90Days: result.rows.filter((r) => r.IS_RENEWING_WITHIN_90_DAYS).length, - governanceRoles: result.rows.reduce((sum, r) => sum + r.BOARD_MEMBER_SEAT_COUNT + r.COMMITTEE_MEMBER_SEAT_COUNT, 0), + renewingWithin90Days: rows.filter((r) => r.IS_RENEWING_WITHIN_90_DAYS).length, + governanceRoles: rows.reduce((sum, r) => sum + r.BOARD_MEMBER_SEAT_COUNT + r.COMMITTEE_MEMBER_SEAT_COUNT, 0), }; let filtered = allMemberships; @@ -133,29 +121,19 @@ export class OrgLensMembershipsService { } public async getExpiredMemberships(accountId: string, search?: string): Promise { - const query = ` - SELECT - FOUNDATION_ID, - FOUNDATION_NAME, - FOUNDATION_SLUG, - FOUNDATION_LOGO_URL, - MEMBERSHIP_TIER_DISPLAY_NAME, - TIER_START_DATE, - TIER_END_DATE, - EXPIRATION_DATE, - ACTION_TYPE - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_EXPIRED_MEMBERSHIPS - WHERE ACCOUNT_ID = ? - ORDER BY FOUNDATION_NAME ASC - `; - - const result = await this.snowflakeService.execute(query, [accountId]); - - if (result.rows.length === 0) { + const rows = await withOrgCache( + accountId, + 'memberships-expired', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchExpiredMembershipRows(accountId), + OrgLensMembershipsService.isRawRowArray + ); + + if (rows.length === 0) { return { accountId, memberships: [] }; } - let memberships: OrgExpiredMembership[] = result.rows.map((raw) => ({ + let memberships: OrgExpiredMembership[] = rows.map((raw) => ({ foundationId: raw.FOUNDATION_ID, foundationName: raw.FOUNDATION_NAME, foundationSlug: raw.FOUNDATION_SLUG ?? '', @@ -176,29 +154,19 @@ export class OrgLensMembershipsService { } public async getDiscoverOpportunities(accountId: string): Promise { - const query = ` - SELECT - FOUNDATION_ID, - FOUNDATION_NAME, - FOUNDATION_SLUG, - FOUNDATION_LOGO_URL, - PROJECT_TYPE, - SUGGESTED_TIER, - CONTRIBUTORS_COUNT, - CONTRIBUTION_COUNT, - RELEVANT_PROJECTS - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_DISCOVER_MEMBERSHIPS - WHERE ACCOUNT_ID = ? - ORDER BY CONTRIBUTION_COUNT DESC - `; - - const result = await this.snowflakeService.execute(query, [accountId]); - - if (result.rows.length === 0) { + const rows = await withOrgCache( + accountId, + 'memberships-discover', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchDiscoverOpportunityRows(accountId), + OrgLensMembershipsService.isRawRowArray + ); + + if (rows.length === 0) { return { accountId, opportunities: [] }; } - const opportunities: OrgDiscoverOpportunity[] = result.rows.map((raw) => ({ + const opportunities: OrgDiscoverOpportunity[] = rows.map((raw) => ({ foundationId: raw.FOUNDATION_ID, foundationName: raw.FOUNDATION_NAME, foundationSlug: raw.FOUNDATION_SLUG ?? '', @@ -276,6 +244,84 @@ export class OrgLensMembershipsService { return row ? row.foundationSlug : null; } + // Rejects a corrupt/legacy raw-row entry (degrade to a miss) before mapping/filtering runs over it; an + // empty array is a legitimate cacheable result. Every cached row in this service carries FOUNDATION_ID. + private static isRawRowArray(value: unknown): boolean { + return ( + Array.isArray(value) && + value.every((el) => el !== null && typeof el === 'object' && !Array.isArray(el) && typeof (el as { FOUNDATION_ID?: unknown }).FOUNDATION_ID === 'string') + ); + } + + private async fetchActiveMembershipRows(accountId: string): Promise { + const query = ` + SELECT + ACCOUNT_ID, + FOUNDATION_ID, + FOUNDATION_NAME, + FOUNDATION_SLUG, + FOUNDATION_LOGO_URL, + MEMBERSHIP_TIER_DISPLAY_NAME, + TIER_START_DATE, + TIER_END_DATE, + FIRST_MEMBERSHIP_STARTED_AT, + BOARD_MEMBER_SEAT_COUNT, + COMMITTEE_MEMBER_SEAT_COUNT, + ORG_PROJECTS_COUNT, + PROJECT_COUNT, + MEMBER_COUNT, + IS_RENEWING_WITHIN_90_DAYS + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_MEMBERSHIPS_SUMMARY + WHERE ACCOUNT_ID = ? + ORDER BY FOUNDATION_NAME ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + return result.rows; + } + + private async fetchExpiredMembershipRows(accountId: string): Promise { + const query = ` + SELECT + FOUNDATION_ID, + FOUNDATION_NAME, + FOUNDATION_SLUG, + FOUNDATION_LOGO_URL, + MEMBERSHIP_TIER_DISPLAY_NAME, + TIER_START_DATE, + TIER_END_DATE, + EXPIRATION_DATE, + ACTION_TYPE + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_EXPIRED_MEMBERSHIPS + WHERE ACCOUNT_ID = ? + ORDER BY FOUNDATION_NAME ASC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + return result.rows; + } + + private async fetchDiscoverOpportunityRows(accountId: string): Promise { + const query = ` + SELECT + FOUNDATION_ID, + FOUNDATION_NAME, + FOUNDATION_SLUG, + FOUNDATION_LOGO_URL, + PROJECT_TYPE, + SUGGESTED_TIER, + CONTRIBUTORS_COUNT, + CONTRIBUTION_COUNT, + RELEVANT_PROJECTS + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_DISCOVER_MEMBERSHIPS + WHERE ACCOUNT_ID = ? + ORDER BY CONTRIBUTION_COUNT DESC + `; + + const result = await this.snowflakeService.execute(query, [accountId]); + return result.rows; + } + private shapeRow(raw: RawMembershipRow): OrgActiveMembership { return { foundationId: raw.FOUNDATION_ID, diff --git a/apps/lfx-one/src/server/services/org-lens-people.service.ts b/apps/lfx-one/src/server/services/org-lens-people.service.ts index a05a2dd8f..8f6e249ac 100644 --- a/apps/lfx-one/src/server/services/org-lens-people.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-people.service.ts @@ -1,13 +1,12 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { EMPTY_ORG_ALL_EMPLOYEE_STATS } from '@lfx-one/shared/constants'; +import { EMPTY_ORG_ALL_EMPLOYEE_STATS, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { OrgAllEmployeeCodeContribution, OrgAllEmployeeCommitteeMembership, OrgAllEmployeeDetail, OrgAllEmployeeEvent, - OrgAllEmployeeFoundationOption, OrgAllEmployeeRow, OrgAllEmployeeStats, OrgAllEmployeeTraining, @@ -16,9 +15,10 @@ import type { OrgAllEmployeesResponse, OrgPersonSource, } from '@lfx-one/shared/interfaces'; -import { splitDisplayName } from '@lfx-one/shared/utils'; +import { isFilterSafeIdentifier, splitDisplayName } from '@lfx-one/shared/utils'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Per-(account, person) row from PLATINUM_LFX_ONE.ORG_PEOPLE_ALL. */ interface OrgPeopleAllRow { @@ -38,6 +38,9 @@ interface OrgPeopleAllRow { COURSES_COUNT: number; } +/** Roster row including the raw ENGAGED_FOUNDATION_IDS column (Snowflake ARRAY may arrive as a JSON string or a parsed array). */ +type OrgPeopleAllRowRaw = OrgPeopleAllRow & { ENGAGED_FOUNDATION_IDS: string | string[] | null }; + /** One-row aggregate from PLATINUM_LFX_ONE.ORG_PEOPLE_ALL_STATS. */ interface OrgPeopleStatsRow { ACCOUNT_ID: string; @@ -107,30 +110,27 @@ export class OrgLensPeopleService { this.snowflakeService = SnowflakeService.getInstance(); } - /** Bundled rows + stats + foundations payload; three Snowflake queries in parallel. */ + /** Bundled rows + stats + foundations payload; three Snowflake queries in parallel, served through the shared per-org cache. */ public async getAllEmployees(accountId: string): Promise { - const [rows, stats, foundations] = await Promise.all([ - this.fetchAllEmployeeRows(accountId), - this.fetchAllEmployeeStats(accountId), - this.fetchFoundationOptions(accountId), - ]); + const raw = await withOrgCache( + accountId, + 'people-all', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchAllEmployeesRaw(accountId), + isAllEmployeesRaw + ); return { accountId, - rows, - stats, - foundations, + rows: raw.rowsRaw.map((row) => this.mapEmployeeRow(row)), + stats: this.mapStats(raw.statsRaw), + foundations: raw.foundationRaw.map((row) => ({ foundationId: row.FOUNDATION_ID, foundationName: row.FOUNDATION_NAME })), }; } - /** Chevron-expansion detail for one person within an account; four Snowflake queries in parallel. */ + /** Chevron-expansion detail for one person within an account; four Snowflake queries in parallel, served through the shared per-org cache. */ public async getEmployeeDetail(accountId: string, personKey: string): Promise { - const [committeeRows, codeRows, eventRows, trainingRows] = await Promise.all([ - this.fetchCommitteeMembershipRows(accountId, personKey), - this.fetchCodeContributionRows(accountId, personKey), - this.fetchEventRows(accountId, personKey), - this.fetchTrainingRows(accountId, personKey), - ]); + const { committeeRows, codeRows, eventRows, trainingRows } = await this.fetchEmployeeDetailRaw(accountId, personKey); const memberships = committeeRows.map((row) => this.mapCommitteeRow(row)); const boardSeats = memberships.filter((m) => m.isBoard); @@ -166,8 +166,11 @@ export class OrgLensPeopleService { }; } - private async fetchAllEmployeeRows(accountId: string): Promise { - const query = ` + /** Three parallel Snowflake reads returning raw rows; mapping happens after the cache read. */ + private async fetchAllEmployeesRaw( + accountId: string + ): Promise<{ rowsRaw: OrgPeopleAllRowRaw[]; statsRaw: OrgPeopleStatsRow[]; foundationRaw: FoundationOptionRow[] }> { + const rowsQuery = ` SELECT ACCOUNT_ID, PERSON_KEY, @@ -189,35 +192,7 @@ export class OrgLensPeopleService { ORDER BY NAME ASC NULLS LAST `; - const result = await this.snowflakeService.execute(query, [accountId]); - - return result.rows.map((row) => { - const name = cleanDisplayName(row.NAME, row.EMAIL); - const [firstName, lastName] = splitDisplayName(name); - return { - personKey: row.PERSON_KEY, - lfid: row.LFID, - cdpMemberId: row.CDP_MEMBER_ID, - name, - firstName, - lastName, - title: row.TITLE, - email: row.EMAIL, - avatarUrl: row.PHOTO ?? null, - sources: ['snowflake'] as OrgPersonSource[], - seatsCount: row.SEATS_COUNT ?? 0, - boardSeatsCount: row.BOARD_SEATS_COUNT ?? 0, - committeeSeatsCount: row.COMMITTEE_SEATS_COUNT ?? 0, - commitsCount: row.COMMITS_COUNT ?? 0, - eventsCount: row.EVENTS_COUNT ?? 0, - coursesCount: row.COURSES_COUNT ?? 0, - engagedFoundationIds: this.parseFoundationIdArray(row.ENGAGED_FOUNDATION_IDS), - }; - }); - } - - private async fetchAllEmployeeStats(accountId: string): Promise { - const query = ` + const statsQuery = ` SELECT ACCOUNT_ID, ACTIVE_IN_OSS, @@ -229,25 +204,8 @@ export class OrgLensPeopleService { WHERE ACCOUNT_ID = ? `; - const result = await this.snowflakeService.execute(query, [accountId]); - - if (result.rows.length === 0) { - return EMPTY_ORG_ALL_EMPLOYEE_STATS; - } - - const row = result.rows[0]; - return { - activeInOss: row.ACTIVE_IN_OSS ?? 0, - inGovernance: row.IN_GOVERNANCE ?? 0, - codeContributors: row.CODE_CONTRIBUTORS ?? 0, - eventAttendees: row.EVENT_ATTENDEES ?? 0, - trainees: row.TRAINEES ?? 0, - }; - } - - /** Distinct (foundation_id, foundation_name) pairs across the four detail tables; keeps the BFF confined to PLATINUM_LFX_ONE. */ - private async fetchFoundationOptions(accountId: string): Promise { - const query = ` + // Distinct (foundation_id, foundation_name) pairs across the four detail tables; keeps the BFF confined to PLATINUM_LFX_ONE. + const foundationQuery = ` WITH pairs AS ( SELECT DISTINCT FOUNDATION_ID, FOUNDATION_NAME FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_COMMITTEE_MEMBERSHIP @@ -270,12 +228,83 @@ export class OrgLensPeopleService { ORDER BY FOUNDATION_NAME ASC `; - const result = await this.snowflakeService.execute(query, [accountId, accountId, accountId, accountId]); + const [rowsResult, statsResult, foundationResult] = await Promise.all([ + this.snowflakeService.execute(rowsQuery, [accountId]), + this.snowflakeService.execute(statsQuery, [accountId]), + this.snowflakeService.execute(foundationQuery, [accountId, accountId, accountId, accountId]), + ]); - return result.rows.map((row) => ({ - foundationId: row.FOUNDATION_ID, - foundationName: row.FOUNDATION_NAME, - })); + return { rowsRaw: rowsResult.rows, statsRaw: statsResult.rows, foundationRaw: foundationResult.rows }; + } + + private mapEmployeeRow(row: OrgPeopleAllRowRaw): OrgAllEmployeeRow { + const name = cleanDisplayName(row.NAME, row.EMAIL); + const [firstName, lastName] = splitDisplayName(name); + return { + personKey: row.PERSON_KEY, + lfid: row.LFID, + cdpMemberId: row.CDP_MEMBER_ID, + name, + firstName, + lastName, + title: row.TITLE, + email: row.EMAIL, + avatarUrl: row.PHOTO ?? null, + sources: ['snowflake'] as OrgPersonSource[], + seatsCount: row.SEATS_COUNT ?? 0, + boardSeatsCount: row.BOARD_SEATS_COUNT ?? 0, + committeeSeatsCount: row.COMMITTEE_SEATS_COUNT ?? 0, + commitsCount: row.COMMITS_COUNT ?? 0, + eventsCount: row.EVENTS_COUNT ?? 0, + coursesCount: row.COURSES_COUNT ?? 0, + engagedFoundationIds: this.parseFoundationIdArray(row.ENGAGED_FOUNDATION_IDS), + }; + } + + private mapStats(rows: OrgPeopleStatsRow[]): OrgAllEmployeeStats { + if (rows.length === 0) { + return EMPTY_ORG_ALL_EMPLOYEE_STATS; + } + + const row = rows[0]; + return { + activeInOss: row.ACTIVE_IN_OSS ?? 0, + inGovernance: row.IN_GOVERNANCE ?? 0, + codeContributors: row.CODE_CONTRIBUTORS ?? 0, + eventAttendees: row.EVENT_ATTENDEES ?? 0, + trainees: row.TRAINEES ?? 0, + }; + } + + /** Cached per-org detail bundle (four raw row arrays); a non-filter-safe personKey bypasses the shared cache to keep the key namespace intact. */ + private async fetchEmployeeDetailRaw( + accountId: string, + personKey: string + ): Promise<{ committeeRows: CommitteeMembershipRow[]; codeRows: CodeContributionRow[]; eventRows: EventRow[]; trainingRows: TrainingRow[] }> { + if (!isFilterSafeIdentifier(personKey)) { + return this.runEmployeeDetailFetch(accountId, personKey); + } + + return withOrgCache( + accountId, + `people-detail:${personKey}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.runEmployeeDetailFetch(accountId, personKey), + isEmployeeDetailRaw + ); + } + + private async runEmployeeDetailFetch( + accountId: string, + personKey: string + ): Promise<{ committeeRows: CommitteeMembershipRow[]; codeRows: CodeContributionRow[]; eventRows: EventRow[]; trainingRows: TrainingRow[] }> { + const [committeeRows, codeRows, eventRows, trainingRows] = await Promise.all([ + this.fetchCommitteeMembershipRows(accountId, personKey), + this.fetchCodeContributionRows(accountId, personKey), + this.fetchEventRows(accountId, personKey), + this.fetchTrainingRows(accountId, personKey), + ]); + return { committeeRows, codeRows, eventRows, trainingRows }; } private async fetchCommitteeMembershipRows(accountId: string, personKey: string): Promise { @@ -430,6 +459,16 @@ function cleanDisplayName(rawName: string | null, email: string | null): string return (email ?? '').trim() || 'Unknown member'; } +function isAllEmployeesRaw(value: unknown): boolean { + const v = value as { rowsRaw?: unknown; statsRaw?: unknown; foundationRaw?: unknown } | null; + return !!v && Array.isArray(v.rowsRaw) && Array.isArray(v.statsRaw) && Array.isArray(v.foundationRaw); +} + +function isEmployeeDetailRaw(value: unknown): boolean { + const v = value as { committeeRows?: unknown; codeRows?: unknown; eventRows?: unknown; trainingRows?: unknown } | null; + return !!v && Array.isArray(v.committeeRows) && Array.isArray(v.codeRows) && Array.isArray(v.eventRows) && Array.isArray(v.trainingRows); +} + /** Narrow upstream free-text voting status to the three badges; unknown values collapse to 'Non-voting'. */ function mapVotingStatus(raw: string | null): OrgAllEmployeeVotingStatus { if (!raw) return 'Non-voting'; diff --git a/apps/lfx-one/src/server/services/org-lens-training.service.ts b/apps/lfx-one/src/server/services/org-lens-training.service.ts index f6fe2db8a..6147c5422 100644 --- a/apps/lfx-one/src/server/services/org-lens-training.service.ts +++ b/apps/lfx-one/src/server/services/org-lens-training.service.ts @@ -3,7 +3,7 @@ // Generated with [Claude Code](https://claude.ai/code) -import { CERTIFICATION_PRODUCT_TYPE, MAX_ORG_CERT_EMPLOYEES, MAX_ORG_TRAINING_EMPLOYEES } from '@lfx-one/shared/constants'; +import { CERTIFICATION_PRODUCT_TYPE, MAX_ORG_CERT_EMPLOYEES, MAX_ORG_TRAINING_EMPLOYEES, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { GetOrgCertificationsOptions, GetOrgTrainingsOptions, @@ -22,6 +22,7 @@ import type { Request } from 'express'; import { logger } from './logger.service'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; interface OrgTrainingStatsRow { CERTIFIED_EMPLOYEES: number; @@ -61,13 +62,6 @@ interface OrgRosterEmployeeRow { TOTAL_MATCHES: number; } -interface OrgRosterEmployeesResult { - courseId: string; - courseName: string; - total: number; - data: readonly OrgCertEmployee[]; -} - /** TI catalog dimension scoped to an org's engaged course IDs (avoids full TI catalog scan). */ const scopedCourseCatalogDimCte = (orgCourseIdsSql: string): string => ` org_course_ids AS ( @@ -99,49 +93,16 @@ export class OrgLensTrainingService { private readonly snowflakeService = SnowflakeService.getInstance(); public async getTrainingStats(accountId: string): Promise { - const certSql = ` - WITH ${scopedCourseCatalogDimCte(` - SELECT DISTINCT COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) AS COURSE_ID - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING t - WHERE t.ACCOUNT_ID = ? - AND COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) IS NOT NULL - `)}, - scoped AS ( - SELECT - t.PERSON_KEY, - t.STATUS, - d.PRODUCT_TYPE - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING t - INNER JOIN course_dim d - ON d.COURSE_ID = COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) - WHERE t.ACCOUNT_ID = ? - AND d.PRODUCT_TYPE = '${CERTIFICATION_PRODUCT_TYPE}' - ) - SELECT - COUNT(DISTINCT CASE WHEN STATUS = 'Certified' THEN PERSON_KEY END) AS CERTIFIED_EMPLOYEES, - COUNT_IF(STATUS = 'Certified') AS CERTIFICATIONS_EARNED, - 0 AS EMPLOYEES_IN_TRAINING, - 0 AS TRAINING_COURSES_ENROLLED - FROM scoped - `; - - const trainingSql = ` - SELECT - 0 AS CERTIFIED_EMPLOYEES, - 0 AS CERTIFICATIONS_EARNED, - COUNT(DISTINCT CASE WHEN TRAINING_STATUS = 'InProgress' THEN PERSON_KEY END) AS EMPLOYEES_IN_TRAINING, - COUNT(CASE WHEN TRAINING_STATUS = 'InProgress' THEN 1 END) AS TRAINING_COURSES_ENROLLED - FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING_COURSES - WHERE ACCOUNT_ID = ? - `; - - const [certResult, trainingResult] = await Promise.all([ - this.snowflakeService.execute(certSql, [accountId, accountId]), - this.snowflakeService.execute(trainingSql, [accountId]), - ]); + const raw = await withOrgCache( + accountId, + 'training-stats', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchTrainingStatsRows(accountId), + isTrainingStatsRaw + ); - const cert = certResult.rows[0]; - const training = trainingResult.rows[0]; + const cert = raw.certRows[0]; + const training = raw.trainingRows[0]; return { certifiedEmployees: cert?.CERTIFIED_EMPLOYEES ?? 0, @@ -200,17 +161,25 @@ export class OrgLensTrainingService { ) `; - return this.fetchPagedCourseRows(req, 'get_org_certifications', filteredCte, { + const raw = await withOrgCache( accountId, - searchQuery, - level, - pageSize, - offset, - sortField, - sortOrder, - selectColumns: 'COURSE_ID, COURSE_NAME, FOUNDATION_NAME, LEVEL, LOGO_URL, CERTIFIED_COUNT, IN_PROGRESS_COUNT', - mapRow: (row) => this.mapRowToOrgCertification(row), - }); + `certifications:${paramSignature([searchQuery ?? null, level, pageSize, offset, sortField, sortOrder])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => + this.fetchPagedCourseRows(req, 'get_org_certifications', filteredCte, { + accountId, + searchQuery, + level, + pageSize, + offset, + sortField, + sortOrder, + selectColumns: 'COURSE_ID, COURSE_NAME, FOUNDATION_NAME, LEVEL, LOGO_URL, CERTIFIED_COUNT, IN_PROGRESS_COUNT', + }), + isPagedCourseRaw + ); + + return { data: raw.rows.map((row) => this.mapRowToOrgCertification(row)), total: raw.total, pageSize, offset }; } public async getOrgTrainings(req: Request, accountId: string, options: GetOrgTrainingsOptions): Promise { @@ -258,17 +227,25 @@ export class OrgLensTrainingService { ) `; - return this.fetchPagedCourseRows(req, 'get_org_trainings', filteredCte, { + const raw = await withOrgCache( accountId, - searchQuery, - level, - pageSize, - offset, - sortField, - sortOrder, - selectColumns: 'COURSE_ID, COURSE_NAME, FOUNDATION_NAME, LEVEL, IN_PROGRESS_COUNT, COMPLETED_COUNT', - mapRow: (row) => this.mapRowToOrgTraining(row), - }); + `trainings:${paramSignature([searchQuery ?? null, level, pageSize, offset, sortField, sortOrder])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => + this.fetchPagedCourseRows(req, 'get_org_trainings', filteredCte, { + accountId, + searchQuery, + level, + pageSize, + offset, + sortField, + sortOrder, + selectColumns: 'COURSE_ID, COURSE_NAME, FOUNDATION_NAME, LEVEL, IN_PROGRESS_COUNT, COMPLETED_COUNT', + }), + isPagedCourseRaw + ); + + return { data: raw.rows.map((row) => this.mapRowToOrgTraining(row)), total: raw.total, pageSize, offset }; } public async getCertificationEmployees( @@ -310,16 +287,20 @@ export class OrgLensTrainingService { LIMIT ${MAX_ORG_CERT_EMPLOYEES} `; - const roster = await this.fetchRosterEmployees(req, 'get_certification_employees', sql, [courseId, accountId, courseId], searchQuery, { - courseId, - }); + const rows = await withOrgCache( + accountId, + `certification-employees:${paramSignature([courseId, status, searchQuery ?? null])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchRosterRows(req, 'get_certification_employees', sql, [courseId, accountId, courseId], searchQuery, { courseId }), + isRosterRowArray + ); return { - courseId: roster.courseId, - certificationName: roster.courseName, + courseId, + certificationName: rows[0]?.COURSE_NAME ?? '', status, - total: roster.total, - data: roster.data, + total: rows[0]?.TOTAL_MATCHES ?? 0, + data: mapRosterRows(rows), }; } @@ -358,20 +339,69 @@ export class OrgLensTrainingService { LIMIT ${MAX_ORG_TRAINING_EMPLOYEES} `; - const roster = await this.fetchRosterEmployees(req, 'get_training_employees', sql, [accountId, courseId, trainingStatus], searchQuery, { - courseId, - }); + const rows = await withOrgCache( + accountId, + `training-employees:${paramSignature([courseId, status, searchQuery ?? null])}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchRosterRows(req, 'get_training_employees', sql, [accountId, courseId, trainingStatus], searchQuery, { courseId }), + isRosterRowArray + ); return { - courseId: roster.courseId, - trainingName: roster.courseName, + courseId, + trainingName: rows[0]?.COURSE_NAME ?? '', status, - total: roster.total, - data: roster.data, + total: rows[0]?.TOTAL_MATCHES ?? 0, + data: mapRosterRows(rows), }; } - private async fetchPagedCourseRows( + private async fetchTrainingStatsRows(accountId: string): Promise<{ certRows: OrgTrainingStatsRow[]; trainingRows: OrgTrainingStatsRow[] }> { + const certSql = ` + WITH ${scopedCourseCatalogDimCte(` + SELECT DISTINCT COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) AS COURSE_ID + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING t + WHERE t.ACCOUNT_ID = ? + AND COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) IS NOT NULL + `)}, + scoped AS ( + SELECT + t.PERSON_KEY, + t.STATUS, + d.PRODUCT_TYPE + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING t + INNER JOIN course_dim d + ON d.COURSE_ID = COALESCE(t.COURSE_ID, t.COURSE_OR_CERT_ID) + WHERE t.ACCOUNT_ID = ? + AND d.PRODUCT_TYPE = '${CERTIFICATION_PRODUCT_TYPE}' + ) + SELECT + COUNT(DISTINCT CASE WHEN STATUS = 'Certified' THEN PERSON_KEY END) AS CERTIFIED_EMPLOYEES, + COUNT_IF(STATUS = 'Certified') AS CERTIFICATIONS_EARNED, + 0 AS EMPLOYEES_IN_TRAINING, + 0 AS TRAINING_COURSES_ENROLLED + FROM scoped + `; + + const trainingSql = ` + SELECT + 0 AS CERTIFIED_EMPLOYEES, + 0 AS CERTIFICATIONS_EARNED, + COUNT(DISTINCT CASE WHEN TRAINING_STATUS = 'InProgress' THEN PERSON_KEY END) AS EMPLOYEES_IN_TRAINING, + COUNT(CASE WHEN TRAINING_STATUS = 'InProgress' THEN 1 END) AS TRAINING_COURSES_ENROLLED + FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_PEOPLE_TRAINING_COURSES + WHERE ACCOUNT_ID = ? + `; + + const [certResult, trainingResult] = await Promise.all([ + this.snowflakeService.execute(certSql, [accountId, accountId]), + this.snowflakeService.execute(trainingSql, [accountId]), + ]); + + return { certRows: certResult.rows, trainingRows: trainingResult.rows }; + } + + private async fetchPagedCourseRows( req: Request, operation: string, filteredCte: string, @@ -384,10 +414,9 @@ export class OrgLensTrainingService { sortField: string; sortOrder: 'ASC' | 'DESC'; selectColumns: string; - mapRow: (row: TRow) => TItem; } - ): Promise<{ data: TItem[]; total: number; pageSize: number; offset: number }> { - const { accountId, searchQuery, level, pageSize, offset, sortField, sortOrder, selectColumns, mapRow } = options; + ): Promise<{ rows: TRow[]; total: number }> { + const { accountId, searchQuery, level, pageSize, offset, sortField, sortOrder, selectColumns } = options; const countSql = `${filteredCte} SELECT COUNT(*) AS TOTAL_RECORDS FROM filtered`; // sortField/sortOrder are validated against allow-lists in the controller before reaching @@ -410,41 +439,28 @@ export class OrgLensTrainingService { ]); const total = countResult.rows[0]?.TOTAL_RECORDS ?? 0; - const data = pageResult.rows.map((row) => mapRow(row)); - logger.debug(req, operation, 'Fetched paged course rows', { count: data.length, total }); + logger.debug(req, operation, 'Fetched paged course rows', { count: pageResult.rows.length, total }); - return { data, total, pageSize, offset }; + return { rows: pageResult.rows, total }; } - private async fetchRosterEmployees( + private async fetchRosterRows( req: Request, operation: string, sql: string, binds: string[], searchQuery: string | undefined, meta: { courseId: string } - ): Promise { + ): Promise { const queryBinds = [...binds]; if (searchQuery) queryBinds.push(`%${searchQuery}%`); const result = await this.snowflakeService.execute(sql, queryBinds); - const courseName = result.rows[0]?.COURSE_NAME ?? ''; - const total = result.rows[0]?.TOTAL_MATCHES ?? 0; - const data: OrgCertEmployee[] = result.rows.map((row) => ({ - contactId: row.CONTACT_ID, - name: row.NAME ?? row.CONTACT_ID, - jobTitle: row.JOB_TITLE ?? null, - })); - logger.debug(req, operation, 'Fetched roster employees', { count: data.length, total, course_id: meta.courseId }); + logger.debug(req, operation, 'Fetched roster employees', { count: result.rows.length, course_id: meta.courseId }); - return { - courseId: meta.courseId, - courseName, - total, - data, - }; + return result.rows; } private mapRowToOrgCertification(row: OrgCertificationRow): OrgCertification { @@ -471,3 +487,30 @@ export class OrgLensTrainingService { }; } } + +/** Deterministic, key-safe sub-resource suffix for the result-changing query params (base64url → only `[A-Za-z0-9_-]`). */ +function paramSignature(parts: readonly (string | number | boolean | null)[]): string { + return Buffer.from(JSON.stringify(parts), 'utf8').toString('base64url'); +} + +function mapRosterRows(rows: readonly OrgRosterEmployeeRow[]): OrgCertEmployee[] { + return rows.map((row) => ({ + contactId: row.CONTACT_ID, + name: row.NAME ?? row.CONTACT_ID, + jobTitle: row.JOB_TITLE ?? null, + })); +} + +function isTrainingStatsRaw(value: unknown): boolean { + const v = value as { certRows?: unknown; trainingRows?: unknown } | null; + return !!v && Array.isArray(v.certRows) && Array.isArray(v.trainingRows); +} + +function isPagedCourseRaw(value: unknown): boolean { + const v = value as { rows?: unknown; total?: unknown } | null; + return !!v && Array.isArray(v.rows) && typeof v.total === 'number'; +} + +function isRosterRowArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => el !== null && typeof el === 'object' && !Array.isArray(el)); +} diff --git a/apps/lfx-one/src/server/services/org-people-contributors.service.ts b/apps/lfx-one/src/server/services/org-people-contributors.service.ts index fde551c33..41dc6f5ee 100644 --- a/apps/lfx-one/src/server/services/org-people-contributors.service.ts +++ b/apps/lfx-one/src/server/services/org-people-contributors.service.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { EMPTY_ORG_CONTRIBUTORS_RESPONSE } from '@lfx-one/shared/constants'; +import { EMPTY_ORG_CONTRIBUTORS_RESPONSE, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { ContributorPersonProjectRow, OrgContributorFoundationOption, @@ -15,6 +15,7 @@ import type { import { toIsoDate } from '../helpers/date-format.helper'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Contributors tab data access — single bundled GET, time-window aggregated server-side per Item 2 A1 lock. */ export class OrgPeopleContributorsService { @@ -30,7 +31,13 @@ export class OrgPeopleContributorsService { return { ...EMPTY_ORG_CONTRIBUTORS_RESPONSE, timeRange }; } - const rows = await this.fetchPersonProjectRows(accountId, timeRange); + const rows = await withOrgCache( + accountId, + `people-contributors:${timeRange}`, + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchPersonProjectRows(accountId, timeRange), + isObjectRowArray + ); return buildResponse(accountId, timeRange, rows); } @@ -69,6 +76,10 @@ export class OrgPeopleContributorsService { } } +function isObjectRowArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => el !== null && typeof el === 'object' && !Array.isArray(el)); +} + /** Snowflake date-cutoff SQL fragment (inline, not bound) so the planner can fold it; null for 'all' (dbt caps at 3yr rolling). */ function timeRangeCutoffSnowflake(timeRange: OrgContributorTimeRange): string | null { switch (timeRange) { diff --git a/apps/lfx-one/src/server/services/org-people-directory.service.ts b/apps/lfx-one/src/server/services/org-people-directory.service.ts index c445639f9..c77786d2f 100644 --- a/apps/lfx-one/src/server/services/org-people-directory.service.ts +++ b/apps/lfx-one/src/server/services/org-people-directory.service.ts @@ -1,12 +1,13 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { EMPTY_ORG_ALL_EMPLOYEES_RESPONSE } from '@lfx-one/shared/constants'; +import { EMPTY_ORG_ALL_EMPLOYEES_RESPONSE, VALKEY_CACHE } from '@lfx-one/shared/constants'; import { isBoardCategory } from '@lfx-one/shared/constants'; import type { CommitteeServiceOrgSeat, KeyContactEmployee, OrgAccessUser, + OrgAllEmployeeFoundationOption, OrgAllEmployeeRow, OrgAllEmployeeStats, OrgAllEmployeesResponse, @@ -21,16 +22,64 @@ import { OrgLensAccessService } from './org-lens-access.service'; import { OrgLensBoardCommitteeService } from './org-lens-board-committee.service'; import { OrgLensKeyContactsService } from './org-lens-key-contacts.service'; import { OrgLensPeopleService } from './org-lens-people.service'; +import { withPerUserCache } from './valkey.service'; -/** TTL for the merged directory cache. The merge fans out to 4 upstreams (Snowflake + committee + query-service + member-service), so a short window collapses the burst of calls a tab + its pickers make on open without serving stale rosters. */ -const DIRECTORY_CACHE_TTL_MS = 30_000; +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isStringArray(value: unknown): boolean { + return Array.isArray(value) && value.every((el) => typeof el === 'string'); +} + +/** Every row must match the wire shape: the cached value is replayed straight to the client, so a corrupt element would otherwise crash on `sources` spreading or `name.localeCompare`. */ +function isAllEmployeeRow(value: unknown): boolean { + const r = value as Partial; + return ( + isObject(value) && + typeof r.personKey === 'string' && + typeof r.name === 'string' && + (r.email === null || typeof r.email === 'string') && + isStringArray(r.sources) && + isStringArray(r.engagedFoundationIds) && + typeof r.seatsCount === 'number' && + typeof r.boardSeatsCount === 'number' && + typeof r.committeeSeatsCount === 'number' && + typeof r.commitsCount === 'number' && + typeof r.eventsCount === 'number' && + typeof r.coursesCount === 'number' + ); +} + +function isFoundationOption(value: unknown): boolean { + const f = value as Partial; + return isObject(value) && typeof f.foundationId === 'string' && typeof f.foundationName === 'string'; +} -/** Hard cap on cache entries so the singleton can't grow unbounded across every (org x caller) pair over the server's lifetime; oldest-first eviction mirrors OrgMembershipResolverService. */ -const DIRECTORY_CACHE_MAX_ENTRIES = 2_000; +function isAllEmployeeStats(value: unknown): boolean { + const s = value as Partial; + return ( + isObject(value) && + typeof s.activeInOss === 'number' && + typeof s.inGovernance === 'number' && + typeof s.codeContributors === 'number' && + typeof s.eventAttendees === 'number' && + typeof s.trainees === 'number' + ); +} -interface DirectoryCacheEntry { - at: number; - value: OrgAllEmployeesResponse; +/** Rejects a corrupt/legacy merged-roster entry (degrades to a miss) by validating every row, foundation, and stat field against the wire contract. */ +function isAllEmployeesResponse(value: unknown): boolean { + const v = value as Partial; + return ( + isObject(value) && + typeof v.accountId === 'string' && + Array.isArray(v.rows) && + v.rows.every(isAllEmployeeRow) && + Array.isArray(v.foundations) && + v.foundations.every(isFoundationOption) && + isAllEmployeeStats(v.stats) + ); } /** @@ -49,9 +98,6 @@ export class OrgPeopleDirectoryService { private readonly keyContactsService: OrgLensKeyContactsService; private readonly accessService: OrgLensAccessService; - // Per-org short-TTL cache for the merged result. In-memory + small, mirroring OrgMembershipResolverService. - private readonly cache = new Map(); - public constructor() { this.peopleService = new OrgLensPeopleService(); this.boardCommitteeService = new OrgLensBoardCommitteeService(); @@ -59,21 +105,23 @@ export class OrgPeopleDirectoryService { this.accessService = new OrgLensAccessService(); } - /** Merged stored + live roster for the account. Cached for a short window keyed by account + caller. */ + /** Merged stored + live roster, served through the per-caller shared cache: the merge folds in request-scoped permission-filtered reads (committee seats, FGA-filtered key contacts, the caller's access view), so keying by caller + org stops one caller's roster from being replayed to another within the TTL. */ public async getLive(req: Request, accountId: string): Promise { - // Key by caller too: the merge folds in request-scoped reads (committee seats via the caller's token, - // FGA-filtered key contacts, the caller's access view), so a plain accountId key could replay one - // caller's permission-filtered roster to a different caller within the TTL. - const cacheKey = this.cacheKey(req, accountId); - const cached = this.cache.get(cacheKey); - if (cached && Date.now() - cached.at < DIRECTORY_CACHE_TTL_MS) { - return cached.value; - } + const username = getEffectiveUsername(req) ?? ''; + return withPerUserCache( + VALKEY_CACHE.ORG_PEOPLE_DIRECTORY_NAMESPACE, + username, + accountId, + VALKEY_CACHE.ORG_LENS_PERUSER_TTL_SECONDS, + () => this.computeLive(req, accountId), + isAllEmployeesResponse + ); + } + private async computeLive(req: Request, accountId: string): Promise { // `fetchAllOrgSeats` is the full cross-foundation seat drain — heavier than the picker's bounded read — - // because this roster also backs the All Employees tab, which needs the complete set. The short TTL above - // plus the caller-side `shareReplay` collapse the burst so the drain runs at most once per org per window - // (shared by the tab and every picker), not per keystroke. + // because this roster also backs the All Employees tab, which needs the complete set. Each source is + // fetched with `Promise.allSettled` so a single upstream outage degrades the roster gracefully. const [snowflake, seats, keyContacts, access] = await Promise.allSettled([ this.peopleService.getAllEmployees(accountId), this.boardCommitteeService.fetchAllOrgSeats(req, accountId), @@ -89,18 +137,7 @@ export class OrgPeopleDirectoryService { }); } - const merged = this.merge(req, accountId, base, seats, keyContacts, access); - if (!this.cache.has(cacheKey) && this.cache.size >= DIRECTORY_CACHE_MAX_ENTRIES) { - const oldest = this.cache.keys().next(); - if (!oldest.done) this.cache.delete(oldest.value); - } - this.cache.set(cacheKey, { at: Date.now(), value: merged }); - return merged; - } - - /** Cache key scoped to the org and the caller identity so permission-filtered reads aren't shared across callers. */ - private cacheKey(req: Request, accountId: string): string { - return `${accountId}:${getEffectiveUsername(req) ?? 'anonymous'}`; + return this.merge(req, accountId, base, seats, keyContacts, access); } /** Seed the snowflake rows by lowercased email (no-email rows pass through), then fold each live source in. */ diff --git a/apps/lfx-one/src/server/services/org-people-event-attendees.service.ts b/apps/lfx-one/src/server/services/org-people-event-attendees.service.ts index 679085e11..b650ecfee 100644 --- a/apps/lfx-one/src/server/services/org-people-event-attendees.service.ts +++ b/apps/lfx-one/src/server/services/org-people-event-attendees.service.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { EMPTY_ORG_EVENT_ATTENDEES_RESPONSE } from '@lfx-one/shared/constants'; +import { EMPTY_ORG_EVENT_ATTENDEES_RESPONSE, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { EventAttendeeEventOptionRow, EventAttendeeFoundationOptionRow, @@ -17,6 +17,7 @@ import { normalizeToUrl } from '@lfx-one/shared/utils'; import { toIsoDate } from '../helpers/date-format.helper'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Event Attendees tab data access — single bundled GET that backs the filter trio, four stat cards, main row, and expanded event-grain sub-table client-side. */ export class OrgPeopleEventAttendeesService { @@ -32,12 +33,13 @@ export class OrgPeopleEventAttendeesService { return { ...EMPTY_ORG_EVENT_ATTENDEES_RESPONSE }; } - const [attendeeRows, detailRows, foundationRows, eventRows] = await Promise.all([ - this.fetchAttendeeRows(accountId), - this.fetchDetailRows(accountId), - this.fetchFoundationOptions(accountId), - this.fetchEventOptions(accountId), - ]); + const { attendeeRows, detailRows, foundationRows, eventRows } = await withOrgCache( + accountId, + 'people-event-attendees', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchEventAttendeesRaw(accountId), + isEventAttendeesRaw + ); const attendees: OrgEventAttendeeRow[] = attendeeRows.map((row) => ({ personKey: row.PERSON_KEY, @@ -84,6 +86,21 @@ export class OrgPeopleEventAttendeesService { }; } + private async fetchEventAttendeesRaw(accountId: string): Promise<{ + attendeeRows: OrgPeopleAllEventAttendeeRow[]; + detailRows: OrgPeopleEventRow[]; + foundationRows: EventAttendeeFoundationOptionRow[]; + eventRows: EventAttendeeEventOptionRow[]; + }> { + const [attendeeRows, detailRows, foundationRows, eventRows] = await Promise.all([ + this.fetchAttendeeRows(accountId), + this.fetchDetailRows(accountId), + this.fetchFoundationOptions(accountId), + this.fetchEventOptions(accountId), + ]); + return { attendeeRows, detailRows, foundationRows, eventRows }; + } + private async fetchAttendeeRows(accountId: string): Promise { const query = ` SELECT @@ -153,3 +170,8 @@ export class OrgPeopleEventAttendeesService { return result.rows; } } + +function isEventAttendeesRaw(value: unknown): boolean { + const v = value as { attendeeRows?: unknown; detailRows?: unknown; foundationRows?: unknown; eventRows?: unknown } | null; + return !!v && Array.isArray(v.attendeeRows) && Array.isArray(v.detailRows) && Array.isArray(v.foundationRows) && Array.isArray(v.eventRows); +} diff --git a/apps/lfx-one/src/server/services/org-people-key-contacts.service.ts b/apps/lfx-one/src/server/services/org-people-key-contacts.service.ts index 8601c8f39..f14ceeb92 100644 --- a/apps/lfx-one/src/server/services/org-people-key-contacts.service.ts +++ b/apps/lfx-one/src/server/services/org-people-key-contacts.service.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { ORG_KEY_CONTACT_REQUIRED_ROLES } from '@lfx-one/shared/constants'; +import { ORG_KEY_CONTACT_REQUIRED_ROLES, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { KeyContactIndexedDoc, OrgKeyContactAssignment, @@ -13,7 +13,44 @@ import type { import { Request } from 'express'; import { fetchAllQueryResources } from '../helpers/query-service.helper'; +import { getEffectiveUsername } from '../utils/auth-helper'; import { MicroserviceProxyService } from './microservice-proxy.service'; +import { withPerUserCache } from './valkey.service'; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Each assignment is rendered straight from cache, so validate every required string key before accepting. */ +function isKeyContactAssignment(value: unknown): boolean { + const a = value as Partial; + return ( + isObject(value) && + typeof a.contactUid === 'string' && + typeof a.membershipUid === 'string' && + typeof a.email === 'string' && + typeof a.firstName === 'string' && + typeof a.lastName === 'string' && + typeof a.displayName === 'string' && + (a.title === null || typeof a.title === 'string') && + typeof a.role === 'string' && + typeof a.foundationSlug === 'string' && + (a.foundationName === null || typeof a.foundationName === 'string') + ); +} + +function isKeyContactsStats(value: unknown): boolean { + const s = value as Partial; + return ( + isObject(value) && typeof s.individualCount === 'number' && typeof s.foundationsCovered === 'number' && typeof s.unfilledRequiredRoleCount === 'number' + ); +} + +/** Rejects a corrupt/legacy entry (degrades to a miss) by validating every assignment element and the numeric stat fields against the wire contract. */ +function isKeyContactsResponse(value: unknown): boolean { + const v = value as Partial; + return isObject(value) && Array.isArray(v.assignments) && v.assignments.every(isKeyContactAssignment) && isKeyContactsStats(v.stats); +} /** Org Lens — People → Key Contacts tab. V1 is org-wide and read-only; membership-scoped reads + writes live in OrgLensKeyContactsService (spec 024). */ export class OrgPeopleKeyContactsService { @@ -28,8 +65,20 @@ export class OrgPeopleKeyContactsService { this.microserviceProxy = new MicroserviceProxyService(); } - /** Bundled GET — joins active key_contact rows to their project_membership and computes the filter-independent stat strip (FR-004). Caller passes b2b_org UUID directly because the upstream b2b_org index has no indexed sfid field. */ + /** Bundled GET — joins active key_contact rows to their project_membership and computes the filter-independent stat strip. Caller passes b2b_org UUID directly because the upstream b2b_org index has no indexed sfid field. Served through the per-caller shared cache; only successful reads are cached. */ public async getKeyContacts(req: Request, orgUid: string): Promise { + const username = getEffectiveUsername(req) ?? ''; + return withPerUserCache( + VALKEY_CACHE.ORG_PEOPLE_KC_NAMESPACE, + username, + orgUid, + VALKEY_CACHE.ORG_LENS_PERUSER_TTL_SECONDS, + () => this.computeKeyContacts(req, orgUid), + isKeyContactsResponse + ); + } + + private async computeKeyContacts(req: Request, orgUid: string): Promise { const tags = `b2b_org_uid:${orgUid}`; // failOnPartial: true on both fetches — stats are computed off the full active dataset, so a dropped page would silently produce wrong counts and missing-join filters. const [contacts, memberships] = await Promise.all([ diff --git a/apps/lfx-one/src/server/services/org-people-trainees.service.ts b/apps/lfx-one/src/server/services/org-people-trainees.service.ts index 7fda077b0..6c30ed929 100644 --- a/apps/lfx-one/src/server/services/org-people-trainees.service.ts +++ b/apps/lfx-one/src/server/services/org-people-trainees.service.ts @@ -1,7 +1,7 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { EMPTY_ORG_TRAINEES_RESPONSE } from '@lfx-one/shared/constants'; +import { EMPTY_ORG_TRAINEES_RESPONSE, VALKEY_CACHE } from '@lfx-one/shared/constants'; import type { OrgPeopleAllTraineeRow, OrgPeopleTrainingRow, @@ -15,6 +15,7 @@ import type { } from '@lfx-one/shared/interfaces'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Trainees tab data access — single bundled GET that backs the filter trio, four stat cards, main row, and lazy expanded section client-side. */ export class OrgPeopleTraineesService { @@ -30,12 +31,13 @@ export class OrgPeopleTraineesService { return { ...EMPTY_ORG_TRAINEES_RESPONSE }; } - const [traineeRows, detailRows, foundationRows, courseRows] = await Promise.all([ - this.fetchTraineeRows(accountId), - this.fetchDetailRows(accountId), - this.fetchFoundationOptions(accountId), - this.fetchCourseOptions(accountId), - ]); + const { traineeRows, detailRows, foundationRows, courseRows } = await withOrgCache( + accountId, + 'people-trainees', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchTraineesRaw(accountId), + isTraineesRaw + ); const trainees: OrgTraineeRow[] = traineeRows.map((row) => ({ personKey: row.PERSON_KEY, @@ -86,6 +88,21 @@ export class OrgPeopleTraineesService { }; } + private async fetchTraineesRaw(accountId: string): Promise<{ + traineeRows: OrgPeopleAllTraineeRow[]; + detailRows: OrgPeopleTrainingRow[]; + foundationRows: TraineeFoundationOptionRow[]; + courseRows: TraineeCourseOptionRow[]; + }> { + const [traineeRows, detailRows, foundationRows, courseRows] = await Promise.all([ + this.fetchTraineeRows(accountId), + this.fetchDetailRows(accountId), + this.fetchFoundationOptions(accountId), + this.fetchCourseOptions(accountId), + ]); + return { traineeRows, detailRows, foundationRows, courseRows }; + } + private async fetchTraineeRows(accountId: string): Promise { const query = ` SELECT @@ -149,6 +166,11 @@ export class OrgPeopleTraineesService { } } +function isTraineesRaw(value: unknown): boolean { + const v = value as { traineeRows?: unknown; detailRows?: unknown; foundationRows?: unknown; courseRows?: unknown } | null; + return !!v && Array.isArray(v.traineeRows) && Array.isArray(v.detailRows) && Array.isArray(v.foundationRows) && Array.isArray(v.courseRows); +} + /** Normalize Snowflake `Date | string | null` to a full ISO string, or null when missing / unparseable; preserves time-of-day so client-side time-window predicates and tiebreaker chains stay precise. */ function toIsoTimestamp(value: Date | string | null | undefined): string | null { if (!value) return null; diff --git a/apps/lfx-one/src/server/services/organization.service.ts b/apps/lfx-one/src/server/services/organization.service.ts index 663e30c03..1d6fadc33 100644 --- a/apps/lfx-one/src/server/services/organization.service.ts +++ b/apps/lfx-one/src/server/services/organization.service.ts @@ -42,12 +42,14 @@ import { TrainingEnrollmentDailyRow, TrainingEnrollmentsResponse, } from '@lfx-one/shared'; +import { ORG_LENS_ACCOUNT_CONTEXT_FETCH_CONCURRENCY, VALKEY_CACHE } from '@lfx-one/shared/constants'; import { Request } from 'express'; import { ResourceNotFoundError } from '../errors'; import { logger } from './logger.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; import { SnowflakeService } from './snowflake.service'; +import { withOrgCache } from './valkey.service'; /** Raw row shape for ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT — server-only, mirrors Snowflake column names. */ interface OrgLensAccountContextRow { @@ -891,13 +893,55 @@ export class OrganizationService { return { programs }; } - /** Resolve Org Lens display context for the given accountIds — one denormalised row per account_id, pre-joined in dbt. */ + /** Resolve Org Lens display context per accountId — one denormalised row per account_id (pre-joined in dbt), each cached per org (identical bytes for every caller, refreshed on the daily batch) and assembled sorted by account name. */ public async getOrgLensAccountContext(accountIds: string[]): Promise { - if (accountIds.length === 0) { + // Dedupe upfront so duplicate ids don't fan out to duplicate rows (the prior `IN (...)` query collapsed them). + const uniqueAccountIds = Array.from(new Set(accountIds)); + if (uniqueAccountIds.length === 0) { return []; } - const placeholders = accountIds.map(() => '?').join(','); + // Each account is an independently-cached Snowflake read; a cursor-driven pool caps cold-cache + // concurrency so a many-account bootstrap can't exhaust the shared Snowflake connection pool. + const perAccount: OrgLensAccountContextResponse[][] = new Array(uniqueAccountIds.length); + let cursor = 0; + const worker = async (): Promise => { + while (cursor < uniqueAccountIds.length) { + const index = cursor++; + const accountId = uniqueAccountIds[index]; + perAccount[index] = await withOrgCache( + accountId, + 'account-context', + VALKEY_CACHE.ORG_LENS_SNOWFLAKE_TTL_SECONDS, + () => this.fetchOrgLensAccountContext(accountId), + OrganizationService.isAccountContextArray + ); + } + }; + + const poolSize = Math.min(ORG_LENS_ACCOUNT_CONTEXT_FETCH_CONCURRENCY, uniqueAccountIds.length); + await Promise.all(Array.from({ length: poolSize }, () => worker())); + + return perAccount.flat().sort((a, b) => (a.accountName ?? '').localeCompare(b.accountName ?? '')); + } + + // Rejects a corrupt/legacy entry (degrade to a miss). An empty array is a legitimate cacheable result. + private static isAccountContextArray(value: unknown): value is OrgLensAccountContextResponse[] { + return ( + Array.isArray(value) && + value.every( + (el) => + el !== null && + typeof el === 'object' && + !Array.isArray(el) && + typeof (el as OrgLensAccountContextResponse).accountId === 'string' && + typeof (el as OrgLensAccountContextResponse).accountName === 'string' + ) + ); + } + + // Per-account Snowflake read (0 or 1 rows) — the cache fetcher behind getOrgLensAccountContext. + private async fetchOrgLensAccountContext(accountId: string): Promise { const query = ` SELECT ACCOUNT_ID, @@ -915,11 +959,10 @@ export class OrganizationService { MEMBERSHIP_TIER_DISPLAY_NAME, MEMBERSHIP_TIER_CLASS FROM ANALYTICS.PLATINUM_LFX_ONE.ORG_LENS_ACCOUNT_CONTEXT - WHERE ACCOUNT_ID IN (${placeholders}) - ORDER BY ACCOUNT_NAME + WHERE ACCOUNT_ID = ? `; - const result = await this.snowflakeService.execute(query, accountIds); + const result = await this.snowflakeService.execute(query, [accountId]); return result.rows.map((row) => ({ accountId: row.ACCOUNT_ID, diff --git a/apps/lfx-one/src/server/services/valkey.service.ts b/apps/lfx-one/src/server/services/valkey.service.ts index 997e32465..57cac9705 100644 --- a/apps/lfx-one/src/server/services/valkey.service.ts +++ b/apps/lfx-one/src/server/services/valkey.service.ts @@ -3,6 +3,7 @@ import { VALKEY_CACHE } from '@lfx-one/shared/constants'; import { CachePort } from '@lfx-one/shared/interfaces'; +import { isFilterSafeIdentifier, isFilterSafeUsername } from '@lfx-one/shared/utils'; import Redis from 'ioredis'; import { addShutdownHook } from '../utils/shutdown'; @@ -100,6 +101,16 @@ export class ValkeyService implements CachePort { } } + /** Best-effort invalidation. A null key (fail-closed) or disabled cache is a no-op; a fault just leaves the entry to age out via TTL. Never throws. */ + public async del(key: string | null): Promise { + if (key === null || !this.client) return; + try { + await this.withTimeout(this.client.del(key)); + } catch (err) { + logger.warning(undefined, 'valkey_del', 'Cache delete failed — entry will age out via TTL', { err, cache_key: ValkeyService.redactKey(key) }); + } + } + public async withCache(key: string | null, ttlSeconds: number, fetcher: () => Promise, accept?: (value: unknown) => boolean): Promise { // Fail-closed (no principal-bound key) or disabled cache → direct fetch, no read/write. if (key === null || !this.client) { @@ -184,6 +195,52 @@ export function cacheKeyNamespace(): string { return (process.env['VALKEY_KEY_NAMESPACE'] ?? '').replace(/[^A-Za-z0-9._-]/g, '-'); } +/** Joins the app prefix with the optional deployment namespace segment, matching the existing adopters. */ +function keyPrefix(): string { + const ns = cacheKeyNamespace(); + return ns ? `${VALKEY_CACHE.APP_PREFIX}:${ns}` : VALKEY_CACHE.APP_PREFIX; +} + +/** Per-org Snowflake-namespace cache key (account id + caller-chosen sub-resource); null (fail-closed → direct fetch) when the account id isn't filter-safe, so it can't corrupt the `:`-delimited key. */ +export function buildOrgCacheKey(accountId: string, subResource: string): string | null { + if (!isFilterSafeIdentifier(accountId)) return null; + return `${keyPrefix()}:${VALKEY_CACHE.ORG_LENS_SNOWFLAKE_NAMESPACE}:${accountId}:${subResource}`; +} + +/** Per-user cache key (caller username + org uid under a caller-chosen namespace); null (fail-closed → direct fetch) when the username or org uid isn't filter-safe, keeping cache identity aligned with the authz principal and the `:`-delimited key uncorruptible. */ +export function buildPerUserOrgKey(namespace: string, username: string, orgUid: string): string | null { + if (!isFilterSafeUsername(username) || !isFilterSafeIdentifier(orgUid)) return null; + return `${keyPrefix()}:${namespace}:${username}:${orgUid}`; +} + +/** Read-through helper for the per-org Snowflake-backed namespace; a null key (unsafe account id) fetches directly. */ +export function withOrgCache( + accountId: string, + subResource: string, + ttlSeconds: number, + fetcher: () => Promise, + accept?: (value: unknown) => boolean +): Promise { + return valkeyService.withCache(buildOrgCacheKey(accountId, subResource), ttlSeconds, fetcher, accept); +} + +/** Best-effort invalidation of a per-user org key (e.g. after a write so the caller's own next read is fresh); an unsafe identity yields a null key → no-op. */ +export function invalidatePerUserCache(namespace: string, username: string, orgUid: string): Promise { + return valkeyService.del(buildPerUserOrgKey(namespace, username, orgUid)); +} + +/** Read-through helper for a per-user org namespace; a null key (unsafe username) fetches directly. */ +export function withPerUserCache( + namespace: string, + username: string, + orgUid: string, + ttlSeconds: number, + fetcher: () => Promise, + accept?: (value: unknown) => boolean +): Promise { + return valkeyService.withCache(buildPerUserOrgKey(namespace, username, orgUid), ttlSeconds, fetcher, accept); +} + /** Shared accessor — forwards to the current singleton so resetInstance() is always honored (no stale binding). */ export const valkeyService: ValkeyService = new Proxy({} as ValkeyService, { get: (_target, prop: string | symbol, receiver) => { diff --git a/packages/shared/src/constants/org-selector.constants.ts b/packages/shared/src/constants/org-selector.constants.ts index 6375be9cc..6bda20d87 100644 --- a/packages/shared/src/constants/org-selector.constants.ts +++ b/packages/shared/src/constants/org-selector.constants.ts @@ -15,5 +15,8 @@ export const ORG_CASCADING_CHILDREN_PER_PARENT_HARD_CAP = 500; /** Max concurrent query-service pagination loops when fetching cascading children, to avoid bursting hundreds of in-flight requests. */ export const ORG_CASCADING_CHILDREN_FETCH_CONCURRENCY = 8; +/** Max concurrent per-account Snowflake reads when warming the Org Lens account-context cache, so a many-account bootstrap can't exhaust the Snowflake connection pool. */ +export const ORG_LENS_ACCOUNT_CONTEXT_FETCH_CONCURRENCY = 8; + /** Short TTL for the per-username access-aware org-universe memo — keeps typeahead requests off query-service/NATS while staying fresh enough for grant changes. */ export const ORG_ACCESS_AWARE_CACHE_TTL_MS = 30 * 1000; diff --git a/packages/shared/src/constants/valkey-cache.constants.ts b/packages/shared/src/constants/valkey-cache.constants.ts index cfbe22fc2..509d5d88f 100644 --- a/packages/shared/src/constants/valkey-cache.constants.ts +++ b/packages/shared/src/constants/valkey-cache.constants.ts @@ -12,9 +12,30 @@ export const VALKEY_CACHE = { /** Domain + schema-version segment for the org access / role-grants cache. */ ORG_ACCESS_NAMESPACE: 'org-access:v1', + /** Domain + schema-version segment for the per-org Snowflake-backed Org Lens cache (shared across callers). */ + ORG_LENS_SNOWFLAKE_NAMESPACE: 'org-lens-sf:v1', + + /** Domain + schema-version segment for the per-user org seats cache. */ + ORG_SEATS_NAMESPACE: 'org-seats:v1', + + /** Domain + schema-version segment for the per-user org People key-contacts cache. */ + ORG_PEOPLE_KC_NAMESPACE: 'org-people-kc:v1', + + /** Domain + schema-version segment for the per-user org access-list cache. */ + ORG_ACCESS_LIST_NAMESPACE: 'org-access-list:v1', + + /** Domain + schema-version segment for the per-user org People directory cache. */ + ORG_PEOPLE_DIRECTORY_NAMESPACE: 'org-people-dir:v1', + /** Default freshness window for membership entries (carried over from the prior 30_000 ms memo). */ ORG_MEMBERSHIP_TTL_SECONDS: 30, + /** Freshness window for the per-org Snowflake-backed Org Lens cache (1 hour). */ + ORG_LENS_SNOWFLAKE_TTL_SECONDS: 3600, + + /** Freshness window for the per-user Org Lens caches (seats, key-contacts, access-list, people directory). */ + ORG_LENS_PERUSER_TTL_SECONDS: 30, + /** Per-op cap; a slower cache resolves to a miss so the request fetches directly (well below the ~30s upstream timeout). */ OP_TIMEOUT_MS: 250, diff --git a/packages/shared/src/interfaces/valkey-cache.interface.ts b/packages/shared/src/interfaces/valkey-cache.interface.ts index 86b6f2453..3475a9f76 100644 --- a/packages/shared/src/interfaces/valkey-cache.interface.ts +++ b/packages/shared/src/interfaces/valkey-cache.interface.ts @@ -18,6 +18,9 @@ export interface CachePort { /** Best-effort write with a TTL in seconds. Returns whether it persisted. Never throws. */ setJson(key: string, value: unknown, ttlSeconds: number): Promise; + /** Best-effort invalidation. A null key (fail-closed) or disabled cache is a no-op; faults are swallowed. Never throws. */ + del(key: string | null): Promise; + /** Read-through helper; `key === null` (or disabled cache) runs `fetcher()` directly (fail-closed); `accept` rejects a malformed cached value as a miss. Cache faults are swallowed, but errors from `fetcher()` propagate to the caller. */ withCache(key: string | null, ttlSeconds: number, fetcher: () => Promise, accept?: (value: unknown) => boolean): Promise; }