Skip to content

feat(b2b): render the analytics dashboard scoped to a contract - #3773

Open
blarghmatey wants to merge 4 commits into
mainfrom
feat/contract-scoped-analytics
Open

feat(b2b): render the analytics dashboard scoped to a contract#3773
blarghmatey wants to merge 4 commits into
mainfrom
feat/contract-scoped-analytics

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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:

# PR What it adds Why it blocks the next
1 mitodl/ol-data-platform#2559 contract_id on four MVs; two new contract-grained MVs Without it the API's SELECT names a column that does not exist
2 mitodl/ol-analytics-api#34 /organizations/{org}/contracts/{contract}/… Without it every request from this PR 404s
3 this PR the dashboard that calls those routes

Between 1 and 2 there is a deploy step, not just a merge: the next b2b_analytics_starrocks_job run 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.

  • New route 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.
  • AnalyticsContent takes an optional contractSlug and picks the scope once. Everything below that choice is untouched — both scopes return the identical envelope and row shapes.
  • The "View analytics" button now links to the contract-scoped view, and the comment above it explaining why analytics was org-scoped is updated rather than left contradicting the code.

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's contract_pk (an md5 surrogate that must never reach a URL). The slug resolves through the org's contracts list — the same lookup ContractAdminPage already does — and 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.

How can this be tested?

yarn install && npx tsc --noEmit -p main/tsconfig.json — clean.

Tests: npx jest src/analytics in frontends/api (15 passed) and npx jest src/app-pages/DashboardPage in frontends/main (644 passed across 29 suites); src/app-pages/ContractAdminPage also passes (120) since the link changed. New cases:

  • five analyticsContractQueries cases asserting each hits the contract-nested path;
  • contract query keys nest under the org's (so invalidating an org drops its contracts) and differ per contract, and never collide with an org-scoped key;
  • the contract id is URL-encoded, not spliced raw;
  • AnalyticsContent requests 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;
  • a contract slug not in the org issues no /contracts/ request at all.

prettier --check and eslint clean 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.ts half-right, since the file is hand-written and this is the second time it has needed chasing:

  • contract_pk, courserun_pk and program_pk were typed number but have been str in the API since ol-analytics-api#29 — they are md5 surrogates, not integers. CoursePerformanceTable's grouping Map<number, …> follows.
  • MonthlyEngagementTrend's three activity totals became nullable in ol-analytics-api#33 and were still typed non-null; its five cohort columns, and ContentEngagementDepth'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 and queryOptions is invariant in the key type, so the inferred union leaves useQuery unable 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 (erase in hooks/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 as tk-k-anonymity-suppress-complement-disclosure-near--8818e9.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj

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
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@blarghmatey
blarghmatey marked this pull request as ready for review August 14, 2026 23:11
@blarghmatey
blarghmatey requested a review from a team as a code owner August 14, 2026 23:11
Copilot AI balanced review requested due to automatic review settings August 14, 2026 23:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx
Comment thread frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx Outdated
Comment thread frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx
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
Comment thread frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx Outdated
Comment thread frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx

@ahtesham-quraish ahtesham-quraish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Left few suggestions

@ahtesham-quraish ahtesham-quraish self-assigned this Aug 18, 2026

@ahtesham-quraish ahtesham-quraish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few suggestions otherwise looks good

@daniellefrappier18 daniellefrappier18 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also reviewed this and don't have any other comments outside of what @ahtesham-quraish has already suggested.

blarghmatey and others added 2 commits August 20, 2026 18:16
…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

gitguardian Bot commented Aug 20, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
10259317 Triggered Generic Password eb61d24 docker-compose.services.yml View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. 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


🦉 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.

@blarghmatey

Copy link
Copy Markdown
Member Author

Addressed both review threads (fec3a1b): narrowed the query-key type erasure so section row types stay checked, and split the contract-not-found notice out from the org-not-configured message. Merged main (eb61d24) to clear stale openapi-diff drift unrelated to this PR. All checks green.

// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
const manageSeatsSlug = contract?.slug ?? org.contracts[0]?.slug
const manageSeatsSlug = contractSlug ? contract?.slug : org.contracts[0]?.slug

@daniellefrappier18 daniellefrappier18 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more minor thing, nothing on the contract-scoped page says which contract you are looking at.

Image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants