feat(b2b): render the analytics dashboard scoped to a contract - #3773
feat(b2b): render the analytics dashboard scoped to a contract#3773blarghmatey wants to merge 4 commits into
Conversation
The dashboard was org-scoped at every layer while the MITx Online manager dashboard it mirrors is contract-scoped, so a manager arriving from a contract page landed on org-wide numbers. Adds a contract-nested route, client and query layer alongside the org ones -- the org endpoints are staying, so nothing currently deployed changes shape. AnalyticsContent takes an optional contractSlug and picks the scope once; every section below that choice is untouched, since both scopes return the identical envelope and row shapes. The slug in the route is not the id the API filters on. It resolves through the org's contracts list to MITx Online's contract id, the same lookup ContractAdminPage already does. A slug matching no contract of the org reads as "unavailable" rather than issuing a request with `undefined` in the path, which the API would answer 403 -- indistinguishable from a real denial. Three type corrections fall out of touching these files, all of them pre-existing drift against the API rather than consequences of this change: - contract_pk, courserun_pk and program_pk are typed `number` here but have been `str` in the API since ol-analytics-api#29. They are md5 surrogates, not integers. CoursePerformanceTable's grouping Map follows. - MonthlyEngagementTrend's three activity totals became nullable in ol-analytics-api#33 and are still typed non-null; its five cohort columns and ContentEngagementDepth's two were missing entirely. Depends on ol-analytics-api#34 and, beneath it, ol-data-platform#2559 -- neither route nor column exists until both land and the MVs rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
Pull request overview
Adds contract-scoped B2B analytics while retaining the existing organization dashboard.
Changes:
- Adds the contract analytics route and navigation.
- Adds contract-scoped API clients, query factories, types, and test utilities.
- Corrects analytics identifier and nullable metric types.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
frontends/main/src/common/urls.ts |
Defines the contract analytics URL. |
frontends/main/src/app/(site)/dashboard/organization/[orgSlug]/contract/[contractSlug]/analytics/page.tsx |
Adds the contract analytics page. |
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx |
Selects organization or contract queries. |
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx |
Tests contract-scoped rendering. |
frontends/main/src/app-pages/DashboardPage/Analytics/CoursePerformanceTable.tsx |
Uses string contract keys. |
frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx |
Links contracts to scoped analytics. |
frontends/api/src/analytics/types.ts |
Updates and extends analytics types. |
frontends/api/src/analytics/test-utils/urls.ts |
Adds contract endpoint URLs. |
frontends/api/src/analytics/test-utils/factories.ts |
Adds contract analytics fixtures. |
frontends/api/src/analytics/hooks/organizations/queries.ts |
Adds contract query keys and options. |
frontends/api/src/analytics/hooks/organizations/queries.test.ts |
Tests contract queries and keys. |
frontends/api/src/analytics/hooks/organizations/index.ts |
Exports contract analytics APIs and types. |
frontends/api/src/analytics/clients.ts |
Implements contract-scoped API clients. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
On a contract-scoped analytics page the header link was built from org.contracts[0], so a manager looking at the second contract's numbers was sent to the first contract's seat admin. It now prefers the resolved contract and falls back to the first only on the org-wide page, where there is no current contract. Also strengthens the negative test added with the contract routes: a bare `waitFor` around `not.toHaveBeenCalled` passes on its first tick, before the manager-org lookup resolves, so it would have held even if a contract request were issued a moment later. It now awaits the settled unavailable state before asserting. Adds the regression test the link bug did not have. Both caught by Copilot in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj
ahtesham-quraish
left a comment
There was a problem hiding this comment.
LGTM! Left few suggestions
ahtesham-quraish
left a comment
There was a problem hiding this comment.
Left few suggestions otherwise looks good
daniellefrappier18
left a comment
There was a problem hiding this comment.
I also reviewed this and don't have any other comments outside of what @ahtesham-quraish has already suggested.
…d messaging Per review on #3773: the `as unknown as SectionQueries` cast on the memoized query factories erased row types along with the query key, so a section wired to the wrong factory would compile silently. `eraseKey` now widens only the key; `eraseContractTrendRow` isolates the one section (engagement trend) where the contract-scoped factory legitimately returns a wider row type. Also splits the "contract slug doesn't resolve" case out of the org-level "analytics not available" notice -- the org is fine here, only the link is stale, so point the manager at organization-wide analytics instead of asking them to contact support about nothing they can fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2QDxf9qiStMtPdVzyepkj
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 10259317 | Triggered | Generic Password | eb61d24 | docker-compose.services.yml | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
| // On a contract-scoped page this must be the contract being viewed, not the | ||
| // org's first one, or "Manage seats" silently sends the manager to a | ||
| // different contract's admin page. | ||
| const manageSeatsSlug = contract?.slug ?? org.contracts[0]?.slug |
There was a problem hiding this comment.
The ?? fallback is correct on the org-wide route, but the unresolved-contract branch also renders header, so a manager on a stale or mistyped contract link gets "This contract link could not be found" with a "Manage seats" button directly above it
| const manageSeatsSlug = contract?.slug ?? org.contracts[0]?.slug | |
| const manageSeatsSlug = contractSlug ? contract?.slug : org.contracts[0]?.slug |

What are the relevant tickets?
N/A — no GitHub issue. Tracked in the work graph as
tk-point-the-mit-learn-analytics-dashboard-at-the-c-d9d6e1. Part of the B2B self-serve analytics effort (mitodl/hq discussion #11845).This is the last of three PRs and cannot work alone. Draft until the other two land. All three must ship, in this order:
contract_idon four MVs; two new contract-grained MVsSELECTnames a column that does not exist/organizations/{org}/contracts/{contract}/…Between 1 and 2 there is a deploy step, not just a merge: the next
b2b_analytics_starrocks_jobrun has to rebuild the materialized views. That is automatic (ol-data-platform#2554's drift detection, verified in production 2026-08-14) but it is not instant — merging 1 and deploying 2 in the same window will fail.Description (What does it do?)
The dashboard was org-scoped at every layer while MITx Online's manager dashboard it mirrors is contract-scoped, so a manager clicking "View analytics" from a contract page landed on org-wide numbers.
dashboard/organization/[orgSlug]/contract/[contractSlug]/analytics, alongside the existing org-level one.analyticsContractsApi+analyticsContractQueries, alongside the org versions. The org endpoints are staying, so nothing currently deployed changes shape.AnalyticsContenttakes an optionalcontractSlugand picks the scope once. Everything below that choice is untouched — both scopes return the identical envelope and row shapes.The slug in the route is not the id the API filters on. Three identifiers are in play: the route's contract slug, MITx Online's
contract_id(ContractPage.page_ptr_id, what the API filters on), and the warehouse'scontract_pk(an md5 surrogate that must never reach a URL). The slug resolves through the org'scontractslist — the same lookupContractAdminPagealready does — and a slug matching no contract of the org reads as "unavailable" rather than issuing a request withundefinedin the path, which the API would answer 403, indistinguishable from a real denial.How can this be tested?
yarn install && npx tsc --noEmit -p main/tsconfig.json— clean.Tests:
npx jest src/analyticsinfrontends/api(15 passed) andnpx jest src/app-pages/DashboardPageinfrontends/main(644 passed across 29 suites);src/app-pages/ContractAdminPagealso passes (120) since the link changed. New cases:analyticsContractQueriescases asserting each hits the contract-nested path;AnalyticsContentrequests the contract-nested endpoints with the resolved id — only the contract URLs are mocked, so an org-scoped fallback would fail the render, which is the point of the test;/contracts/request at all.prettier --checkandeslintclean on every changed file; pre-commit hooks passed on commit.End-to-end against a real API is not possible until the two upstream PRs deploy.
Additional Context
Three type corrections fall out of touching these files. All are pre-existing drift against the API, not consequences of this change — I fixed them here rather than leaving
types.tshalf-right, since the file is hand-written and this is the second time it has needed chasing:contract_pk,courserun_pkandprogram_pkwere typednumberbut have beenstrin the API since ol-analytics-api#29 — they are md5 surrogates, not integers.CoursePerformanceTable's groupingMap<number, …>follows.MonthlyEngagementTrend's three activity totals became nullable in ol-analytics-api#33 and were still typed non-null; its five cohort columns, andContentEngagementDepth's two, were missing entirely.This closes the separately-tracked
tk-update-mit-learn-s-hand-written-analytics-types--5f2d5b, and is a standing argument for generating this file from the API's OpenAPI schema instead.One deliberate cast, in
AnalyticsContent. The org and contract query factories build keys of different lengths andqueryOptionsis invariant in the key type, so the inferred union leavesuseQueryunable to pick an overload. The scope selector erases the key while keeping the row type — the same trade this repo's own query tests already make (eraseinhooks/organizations/queries.test.ts). The key still comes from the query factories; nothing in the component reads it.Privacy note carried down from the API PR. With both grains published, a suppressed contract is recoverable as
org_total − (other contracts). 4 of 58 orgs already hold more than one contract, so this is reachable on release rather than hypothetical — tracked astk-k-anonymity-suppress-complement-disclosure-near--8818e9.🤖 Generated with Claude Code
https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj