Skip to content

feat: add B2B contract learner directory page - #3958

Merged
daniellefrappier18 merged 18 commits into
mainfrom
daniellef/feat-learners-analytics-dashboard
Sep 24, 2026
Merged

daniellefrappier18 merged 18 commits into
mainfrom
daniellef/feat-learners-analytics-dashboard

Conversation

@daniellefrappier18

@daniellefrappier18 daniellefrappier18 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

N/A — no mit-learn issue tracks this directly.

Description (What does it do?)

Adds a per-contract learner directory at /organization/:orgSlug/contract/:contractSlug/learners, linked from a new View learner analytics button on each contract's card in the org dashboard.

One row per learner per course run: name, course, status pill, search, status filter, CSV export, and four summary tiles (Enrollments / Not started / In progress / Completed).

This is additive to the aggregate analytics dashboard (AnalyticsContent), not a replacement.

This matches the shape of Ferdi's prototype at mit-learn-prototypes.vercel.app/b2b-stats/learners, but ships a deliberately smaller surface — only what a real backend field supports today. Compared to the prototype, this PR does not render:

  • Progress bars / lesson counts — no blockcompletion data-platform aggregation exists yet, and "what counts as a lesson" isn't decided.
  • Last activity dates — LearnerProgress.last_active_on is hard-coded null by the API today.
  • The "Needs attention" tile — no needs_attention field exists yet.
  • The module filter (the prototype's ?module= param) — ol-analytics-api doesn't implement filtering by courserun_readable_id; the param would be silently dropped, so wiring it up would look like it filters and wouldn't.
  • Row selection + bulk "Send reminder" — no endpoint can message an already-enrolled learner (MITx Online's remind mutation only covers an unredeemed seat code).
  • The prototype's progress-band statuses (1–24% / 25–49% / 50–99%) — replaced with an agreed status vocabulary (not started / in progress / completed / certificate / no consent given) that doesn't depend on the missing progress data.

Every disabled item above is fully built and wired, just commented out rather than deleted, so nothing fabricated ships while each stays one uncomment away from shipping. See ContractLearnersPage.tsx's file header and placeholders.ts for exactly what unblocks each one.

Screen Recording

Screen.Recording.2026-09-17.at.4.06.45.PM.mov

Screenshots

Desktop
Screenshot 2026-09-18 at 11 57 59 AM
Mobile
Screenshot 2026-09-18 at 11 58 18 AM

How can this be tested?

This assumes your are running Local dev for this branch runs on the Tilt/k3d stack (~/Desktop/work/ol-infrastructure), not docker compose.

Prerequisite: mitxonline manager access. This part is real and required — ContractLearnersPage won't resolve without a mitxonline user that has is_manager=True on some org with at least one contract.

1. Confirm the analytics-api stub is running. ContractLearnersPage's learner data comes from a local-dev-only stub (real ol-analytics-api reads dbt-materialized views out of StarRocks, which isn't realistic to run in k3d) — not from anything you set up in mitxonline. The stub ignores the org UUID/contract ID entirely and always returns the same 48 fixture learners, so you're not "generating" analytics data in step 2, just satisfying the real authorization check that gates the page.

It lives on a throwaway (not-for-merge) branch: mitodl/ol-infrastructure#5788 ("add analytics-api stub for testing B2B dashboard PRs", branch daniellef/analytics-api-stub), kept open just so it's easy to pull in for local testing like this. tilt_config.json's enabled_apps already lists "analytics-api", so once you've checked out that branch (or applied its Tiltfile app to yours), tilt up deploys it automatically.

kubectl get pods -n mit-learn -l app=analytics-api

If nothing's running, either check out daniellef/analytics-api-stub and re-run tilt up, or apply the manifest below directly (same source as that branch, just as plain k8s YAML instead of a Tilt-managed app):

analytics-api-stub.yaml (kubectl apply -f -)
apiVersion: v1
kind: ConfigMap
metadata:
  name: analytics-api-stub-src
  namespace: mit-learn
  labels:
    app: analytics-api
data:
  stub_app.py: |
    #!/usr/bin/env python3
    """Local-dev stub for `ol-analytics-api` (the B2B analytics gateway).

    The real service (mitodl/ol-analytics-api) is a FastAPI app that reads
    dbt-materialized views out of StarRocks. Neither StarRocks nor the data
    pipeline is realistic to run in the k3d local-dev stack, so this stub stands in
    for it: it serves the exact endpoints and response envelope the mit-learn
    frontend's b2b_dashboard client expects (see mit-learn
    `frontends/api/src/analytics/{clients,types}.ts`) with plausible fixture data,
    so the dashboard renders instead of reporting itself unavailable.

    Deliberately zero-dependency (stdlib `http.server` only) so the pod starts
    instantly with no PyPI/registry access and survives cluster restarts.

    Auth is handled entirely by APISIX in front of this service (the same
    `openid-connect` plugin the other authed routes use). This stub does not verify
    anything — it just echoes any `X-Userinfo` it receives for debugging and always
    returns data for whatever organization UUID is in the path. The real API keys
    every endpoint on the Keycloak organization UUID (`sso_organization_id`); the
    stub ignores which UUID it is and returns the same fixtures for all of them.
    """

    import json
    import os
    import re
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import parse_qs, urlparse

    PORT = int(os.environ.get("PORT", "8070"))

    # Path shape after APISIX strips the `/analytics/` prefix:
    #   /api/v1/analytics/organizations/<org_uuid>/<resource>
    #   /api/v1/analytics/organizations/<org_uuid>/contracts/<contract_id>/<resource>
    # The contract-scoped route was added later (mit-learn's org dashboard nests
    # analytics under a contract now); CONTRACT_PATH is tried first since its
    # extra path segment means ORG_PATH's anchored pattern never matches it.
    ORG_PATH = re.compile(
        r"^/api/v1/analytics/organizations/(?P<org>[^/]+)/(?P<resource>[^/?]+)/?$"
    )
    CONTRACT_PATH = re.compile(
        r"^/api/v1/analytics/organizations/(?P<org>[^/]+)/contracts/(?P<contract>[^/]+)/(?P<resource>[^/?]+)/?$"
    )

    # A fixed "last refresh" timestamp per section. Static so the stub is
    # deterministic (the runtime forbids wall-clock reads in some contexts, and a
    # frozen value keeps snapshot tests / screenshots stable).
    AS_OF = "2026-07-01T00:00:00Z"

    ORG_KEY = "b2b-org-localdev"
    ORG_NAME = "Local Dev Organization"

    # --- Fixtures: one list per resource, columns matching mit-learn types.ts. ---

    CONTRACT_UTILIZATION = [
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "1",
            "contract_id": "1",
            "b2b_contract_name": "FY26 Site License — Data Science Track",
            "b2b_contract_is_active": True,
            "b2b_contract_start_date": "2025-09-01",
            "b2b_contract_end_date": "2026-08-31",
            "seat_limit": 250,
            "b2b_contract_membership_type": "seat_limited",
            "seats_consumed": 187,
            "active_learners": 142,
            "learners_certified": 61,
            "seat_utilization_pct": 74.8,
            "completion_rate_pct": 42.9,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "2",
            "contract_id": "2",
            "b2b_contract_name": "FY26 Site License — Leadership Track",
            "b2b_contract_is_active": True,
            "b2b_contract_start_date": "2025-09-01",
            "b2b_contract_end_date": "2026-08-31",
            "seat_limit": 100,
            "b2b_contract_membership_type": "seat_limited",
            "seats_consumed": 54,
            "active_learners": 38,
            # k-anonymity floor: a small certified count is suppressed to null.
            "learners_certified": None,
            "seat_utilization_pct": 54.0,
            "completion_rate_pct": None,
        },
    ]

    ENROLLMENT_FUNNEL = [
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "1",
            "contract_id": "1",
            "b2b_contract_name": "FY26 Site License — Data Science Track",
            "courserun_pk": 101,
            "courserun_readable_id": "course-v1:MITx+14.310x+2026_Spring",
            "courserun_title": "Data Analysis for Social Scientists",
            "enrolled_learners": 96,
            "active_learners": 81,
            "passing_learners": 44,
            "certified_learners": 39,
            "active_rate_pct": 84.4,
            "completion_rate_pct": 45.8,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "1",
            "contract_id": "1",
            "b2b_contract_name": "FY26 Site License — Data Science Track",
            "courserun_pk": 102,
            "courserun_readable_id": "course-v1:MITx+6.86x+2026_Spring",
            "courserun_title": "Machine Learning with Python",
            "enrolled_learners": 91,
            "active_learners": 67,
            "passing_learners": 22,
            "certified_learners": 22,
            "active_rate_pct": 73.6,
            "completion_rate_pct": 24.2,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "2",
            "contract_id": "2",
            "b2b_contract_name": "FY26 Site License — Leadership Track",
            "courserun_pk": 201,
            "courserun_readable_id": "course-v1:MITx+15.031x+2026_Spring",
            "courserun_title": "Energy Economics and Policy",
            "enrolled_learners": 54,
            "active_learners": 38,
            "passing_learners": None,
            "certified_learners": None,
            "active_rate_pct": 70.4,
            "completion_rate_pct": None,
        },
    ]

    ENGAGEMENT_TREND = [
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "activity_year_and_month": ym,
            "monthly_active_learners": mal,
            "new_enrollments": newe,
            "certificates_earned": certs,
            "total_videos_watched": vids,
            "total_problems_attempted": probs,
            "total_chatbot_interactions": chats,
        }
        for (ym, mal, newe, certs, vids, probs, chats) in [
            ("2025-09", 41, 41, None, 512, 883, 120),
            ("2025-10", 88, 52, None, 1340, 2110, 402),
            ("2025-11", 121, 39, 12, 2015, 3488, 690),
            ("2025-12", 104, 18, 21, 1789, 2901, 548),
            ("2026-01", 133, 44, 17, 2450, 4102, 812),
            ("2026-02", 142, 22, 33, 2688, 4530, 905),
        ]
    ]

    PROGRAM_FUNNEL = [
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "1",
            "contract_id": "1",
            "b2b_contract_name": "FY26 Site License — Data Science Track",
            "program_pk": 10,
            "program_title": "Statistics and Data Science MicroMasters",
            "total_courses": 5,
            "enrolled_in_contract_courses": 96,
            "enrolled_via_program": 71,
            "program_course_completers": 33,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "contract_pk": "2",
            "contract_id": "2",
            "b2b_contract_name": "FY26 Site License — Leadership Track",
            "program_pk": 20,
            "program_title": "Sustainability Leadership Professional Certificate",
            "total_courses": 3,
            "enrolled_in_contract_courses": 54,
            "enrolled_via_program": None,
            "program_course_completers": None,
        },
    ]

    CONTENT_ENGAGEMENT = [
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "courserun_readable_id": "course-v1:MITx+14.310x+2026_Spring",
            "courserun_title": "Data Analysis for Social Scientists",
            "total_enrolled_learners": 96,
            "engaged_learners": 81,
            "engagement_rate_pct": 84.4,
            "total_videos_watched": 4120,
            "avg_videos_per_engaged_learner": 50.9,
            "total_problems_attempted": 6890,
            "avg_problems_per_engaged_learner": 85.1,
            "total_chatbot_interactions": 1830,
            "chatbot_users": 58,
            "chatbot_adoption_pct": 71.6,
            "certificates_earned": 39,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "courserun_readable_id": "course-v1:MITx+6.86x+2026_Spring",
            "courserun_title": "Machine Learning with Python",
            "total_enrolled_learners": 91,
            "engaged_learners": 67,
            "engagement_rate_pct": 73.6,
            "total_videos_watched": 3980,
            "avg_videos_per_engaged_learner": 59.4,
            "total_problems_attempted": 7210,
            "avg_problems_per_engaged_learner": 107.6,
            "total_chatbot_interactions": 2405,
            "chatbot_users": 51,
            "chatbot_adoption_pct": 76.1,
            "certificates_earned": 22,
        },
        {
            "organization_key": ORG_KEY,
            "organization_name": ORG_NAME,
            "courserun_readable_id": "course-v1:MITx+15.031x+2026_Spring",
            "courserun_title": "Energy Economics and Policy",
            "total_enrolled_learners": 54,
            "engaged_learners": None,
            "engagement_rate_pct": None,
            "total_videos_watched": None,
            "avg_videos_per_engaged_learner": None,
            "total_problems_attempted": None,
            "avg_problems_per_engaged_learner": None,
            "total_chatbot_interactions": None,
            "chatbot_users": None,
            "chatbot_adoption_pct": None,
            "certificates_earned": None,
        },
    ]

    RESOURCES = {
        "contract-utilization": CONTRACT_UTILIZATION,
        "enrollment-funnel": ENROLLMENT_FUNNEL,
        "engagement-trend": ENGAGEMENT_TREND,
        "program-funnel": PROGRAM_FUNNEL,
        "content-engagement": CONTENT_ENGAGEMENT,
    }

    # learner-progress is handled separately from RESOURCES: it is the one
    # individual-learner endpoint (see mit-learn's LearnerProgress type), so it
    # takes its own filters (search, completion_status, consent) and its own
    # envelope field (outcomes_withheld_count) rather than the aggregate
    # resources' shared offset/limit-only handling.
    #
    # Course runs match ENROLLMENT_FUNNEL's two contract-1 rows so the frontend's
    # module filter — sourced from enrollment-funnel — actually narrows this data.
    _LEARNER_COURSERUNS = [
        {
            "courserun_readable_id": "course-v1:MITx+14.310x+2026_Spring",
            "courserun_title": "Data Analysis for Social Scientists",
            "courserun_start_on": "2026-01-05T00:00:00Z",
            "courserun_end_on": "2026-05-15T00:00:00Z",
        },
        {
            "courserun_readable_id": "course-v1:MITx+6.86x+2026_Spring",
            "courserun_title": "Machine Learning with Python",
            "courserun_start_on": "2026-01-05T00:00:00Z",
            "courserun_end_on": "2026-05-15T00:00:00Z",
        },
    ]

    _LEARNER_FIRST_NAMES = [
        "Anton", "Hugo", "Jordan", "Marcus", "Sam", "Nina", "Rohan", "Tobias",
        "Zara", "Grace", "Amara", "Chiara", "Sofia", "Aisha", "Noah", "Yuki",
        "Anja", "Luca", "Priya", "Mateo", "Elena", "Kwame", "Ines", "Felix",
    ]
    _LEARNER_LAST_NAMES = [
        "Petrov", "Bernard", "Brooks", "Reid", "Okafor", "Lang", "Malik",
        "Nakamura", "Nwosu", "Bruno", "Rossi", "Rahman", "Weiss", "Tanaka",
        "Kowalski", "Gupta", "Moreau", "Silva", "Haddad", "Novak", "Adeyemi",
        "Cohen", "Dubois", "Park",
    ]
    _LEARNER_STATUSES = ["not_started", "in_progress", "passed", "certified"]


    def _build_learner_progress():
        """Deterministic so the stub's fixture is stable across pod restarts.

        Roughly one enrollment in nine has withheld consent (outcomes_shared
        False, every outcome field null) and one in twelve is deactivated, so
        both the consent banner and `include_inactive` have something to show
        without any per-request randomness.
        """
        rows = []
        for i, first in enumerate(_LEARNER_FIRST_NAMES):
            last = _LEARNER_LAST_NAMES[(i * 7) % len(_LEARNER_LAST_NAMES)]
            for run_index, run in enumerate(_LEARNER_COURSERUNS):
                n = i * len(_LEARNER_COURSERUNS) + run_index
                shared = n % 9 != 0
                status = _LEARNER_STATUSES[n % len(_LEARNER_STATUSES)]
                certified = status == "certified"
                rows.append(
                    {
                        "learner_id": f"kc-{1000 + n}",
                        "email": f"{first.lower()}.{last.lower()}@example.edu",
                        "full_name": f"{first} {last}",
                        "courserun_readable_id": run["courserun_readable_id"],
                        "courserun_title": run["courserun_title"],
                        "courserun_start_on": run["courserun_start_on"],
                        "courserun_end_on": run["courserun_end_on"],
                        "enrolled_on": "2026-01-12T00:00:00Z",
                        "enrollment_is_active": n % 12 != 0,
                        "enrollment_mode": "audit" if n % 4 == 0 else "verified",
                        "outcomes_shared": shared,
                        "completion_status": status if shared else None,
                        "is_passing": (status in ("passed", "certified")) if shared else None,
                        "grade": round((n % 10) / 10, 2) if shared else None,
                        "letter_grade": None,
                        "certificate_issued_on": "2026-06-01T00:00:00Z"
                        if shared and certified
                        else None,
                        "certificate_is_revoked": False if shared and certified else None,
                        # Null here too: real activity data doesn't exist yet
                        # upstream either (mitodl/ol-data-platform#2672).
                        "last_active_on": None,
                    }
                )
        return rows


    LEARNER_PROGRESS = _build_learner_progress()

    # Identity of "the" contract every contract-scoped request is treated as
    # viewing — the stub ignores which contract_id is actually in the path (same
    # policy as the org UUID) and always answers as contract "1".
    _VIEWED_CONTRACT = {
        "contract_pk": "1",
        "contract_id": "1",
        "b2b_contract_name": "FY26 Site License — Data Science Track",
    }


    def _rows_for(resource, contract_scoped):
        """Rows for `resource`, scoped to the org or to `_VIEWED_CONTRACT`.

        Org-scoped calls return every fixture row unfiltered (mixed contracts),
        matching the real org-level views. Contract-scoped calls narrow to just
        `_VIEWED_CONTRACT`: for the three views that already carry contract
        identity at every grain, that's a filter; `engagement-trend` and
        `content-engagement` have no contract-scoped fixture rows of their own
        (they're org x month / org x run in this stub), so the same rows are
        reused with the contract identity fields stamped on — matching
        `ContractMonthlyEngagementTrend`/`ContractContentEngagementDepth`, which
        are just the org row types plus `ContractIdentity`.
        """
        rows = RESOURCES.get(resource)
        if rows is None:
            return None
        if not contract_scoped:
            return rows
        if resource in ("engagement-trend", "content-engagement"):
            return [dict(row, **_VIEWED_CONTRACT) for row in rows]
        return [
            row for row in rows if row.get("contract_pk") == _VIEWED_CONTRACT["contract_pk"]
        ]


    class Handler(BaseHTTPRequestHandler):
        protocol_version = "HTTP/1.1"

        def _send_json(self, status, payload):
            body = json.dumps(payload).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        @staticmethod
        def _parse_offset_limit(qs):
            offset = int(qs.get("offset", ["0"])[0])
            limit_raw = qs.get("limit", [None])[0]
            limit = int(limit_raw) if limit_raw is not None else None
            return offset, limit

        def _handle_learner_progress(self, org, qs):
            """learner-progress: filter, sort, then page — in that order, so
            `total_count`/`outcomes_withheld_count` reflect the filtered set and
            not the fixture's full 48 rows."""
            search = (qs.get("search", [""])[0] or "").strip().lower()
            statuses = qs.get("completion_status", [])
            include_inactive = qs.get("include_inactive", ["false"])[0].lower() == "true"
            courserun = qs.get("courserun_readable_id", [None])[0]
            sort_key = qs.get("sort", ["full_name"])[0]
            descending = qs.get("descending", ["false"])[0].lower() == "true"

            rows = LEARNER_PROGRESS
            if not include_inactive:
                rows = [r for r in rows if r["enrollment_is_active"]]
            if courserun:
                rows = [r for r in rows if r["courserun_readable_id"] == courserun]
            if search:
                rows = [
                    r
                    for r in rows
                    if search in r["full_name"].lower() or search in r["email"].lower()
                ]
            if statuses:
                rows = [
                    r
                    for r in rows
                    if (r["completion_status"] in statuses)
                    or (r["completion_status"] is None and "unknown" in statuses)
                ]

            if sort_key in ("full_name", "email", "enrolled_on", "courserun_readable_id"):
                rows = sorted(rows, key=lambda r: r[sort_key], reverse=descending)

            total_count = len(rows)
            outcomes_withheld_count = sum(1 for r in rows if not r["outcomes_shared"])

            offset, limit = self._parse_offset_limit(qs)
            page = rows[offset:]
            if limit is not None:
                page = page[:limit]

            self._send_json(
                200,
                {
                    "organization_id": org,
                    "as_of": AS_OF,
                    "total_count": total_count,
                    "outcomes_withheld_count": outcomes_withheld_count,
                    "data": page,
                },
            )

        def do_GET(self):
            parsed = urlparse(self.path)
            path = parsed.path

            # Health / readiness probe.
            if path in ("/", "/health", "/healthz"):
                self._send_json(200, {"status": "ok", "service": "analytics-api-stub"})
                return

            match = CONTRACT_PATH.match(path)
            contract_scoped = match is not None
            if not match:
                match = ORG_PATH.match(path)
            if not match:
                self._send_json(404, {"detail": f"Not found: {path}"})
                return

            org = match.group("org")
            resource = match.group("resource")
            qs = parse_qs(parsed.query)

            if resource == "learner-progress":
                if not contract_scoped:
                    self._send_json(
                        404, {"detail": "learner-progress is contract-scoped only"}
                    )
                    return
                try:
                    self._handle_learner_progress(org, qs)
                except ValueError:
                    self._send_json(422, {"detail": "limit/offset must be integers"})
                return

            rows = _rows_for(resource, contract_scoped)
            if rows is None:
                self._send_json(
                    404,
                    {"detail": f"Unknown analytics resource: {resource}"},
                )
                return

            # Honor LIMIT/OFFSET paging the way the real API does.
            try:
                offset, limit = self._parse_offset_limit(qs)
            except ValueError:
                self._send_json(422, {"detail": "limit/offset must be integers"})
                return

            page = rows[offset:]
            if limit is not None:
                page = page[:limit]

            self._send_json(
                200,
                {
                    "organization_id": org,
                    "as_of": AS_OF,
                    # Total rows for this resource, ignoring limit/offset paging.
                    # The frontend renders `total_count.toLocaleString()`, so it
                    # must always be present (never null/undefined).
                    "total_count": len(rows),
                    "data": page,
                },
            )

        def log_message(self, fmt, *args):  # keep pod logs readable
            userinfo = self.headers.get("X-Userinfo", "-")
            print(
                "analytics-api-stub %s - %s"
                % (self.command + " " + self.path, "auth" if userinfo != "-" else "anon")
            )


    def main():
        server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
        print(f"analytics-api stub listening on :{PORT}")
        server.serve_forever()


    if __name__ == "__main__":
        main()
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: analytics-api
  namespace: mit-learn
  labels:
    app: analytics-api
    app.kubernetes.io/managed-by: tilt
spec:
  replicas: 1
  selector:
    matchLabels:
      app: analytics-api
  template:
    metadata:
      labels:
        app: analytics-api
        app.kubernetes.io/managed-by: tilt
    spec:
      containers:
        - name: analytics-api
          image: python:3.13-slim
          command: ["python", "/app/stub_app.py"]
          env:
            - name: PORT
              value: "8070"
          ports:
            - name: http
              containerPort: 8070
          livenessProbe:
            httpGet:
              path: /health
              port: 8070
            initialDelaySeconds: 5
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /health
              port: 8070
            initialDelaySeconds: 3
            periodSeconds: 10
          resources:
            requests:
              cpu: 25m
              memory: 32Mi
            limits:
              memory: 128Mi
          volumeMounts:
            - name: stub-src
              mountPath: /app
              readOnly: true
      volumes:
        - name: stub-src
          configMap:
            name: analytics-api-stub-src
---
apiVersion: v1
kind: Service
metadata:
  name: analytics-api
  namespace: mit-learn
  labels:
    app: analytics-api
    app.kubernetes.io/managed-by: tilt
spec:
  selector:
    app: analytics-api
  ports:
    - name: http
      port: 8070
      targetPort: http
---
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: analytics-route-prefix
  namespace: mit-learn
  labels:
    app.kubernetes.io/managed-by: tilt
spec:
  ingressClassName: apache-apisix
  http:
    - name: passauth
      priority: 10
      match:
        hosts:
          - api.learn.mit.dev
        paths:
          - /analytics/*
      backends:
        - serviceName: analytics-api
          servicePort: 8070
      plugin_config_name: mitlearn-shared-plugins
      plugins:
        - name: proxy-rewrite
          enable: true
          config:
            regex_uri:
              - /analytics/(.*)
              - /$1
        - name: openid-connect
          enable: true
          secretRef: ol-mitlearn-oidc
          config:
            bearer_only: false
            discovery: http://keycloak-service.local-infra.svc.cluster.local:8080/realms/olapps/.well-known/openid-configuration
            introspection_endpoint_auth_method: client_secret_post
            logout_path: /logout/oidc
            post_logout_redirect_uri: https://api.learn.mit.dev/logout/
            realm: olapps
            redirect_uri: https://api.learn.mit.dev/login/ol-oidc/callback/
            scope: openid profile email ol-profile
            session:
              secret: local-dev-oidc-session-secret-32chars!
            ssl_verify: false
            unauth_action: pass
            use_jwks: true
  1. Enable the b2b-analytics-dashboard PostHog feature flag for your account, sign in at https://learn.mit.dev, open the org, and click View learner analytics on the contract's card (or go directly to /organization/pr-test-org/contract/<contract-slug>/learners).
  • Verify: the four summary tiles and table populate with the fixture's 48 learners
  • Verify: Search and the status filter narrow the table and resets to page 1
  • Export learners downloads a matching CSV
  • Verify: a learner without shared outcomes reads "No consent given"
  • Confirm nothing else renders: no Progress column, no Last activity column, no "Needs attention" tile, no module filter, no row checkboxes or "Send reminder" button.

Adds the per-contract learner directory at /organization/:orgSlug/contract/:contractSlug/learners,
listing one row per learner per course run with status, search, filtering, and CSV export backed
by the learner-progress analytics endpoint. Progress, last activity, and bulk "Send reminder" are
built but left disabled pending real backend fields and a reminder endpoint for enrolled learners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 17, 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).

@daniellefrappier18
daniellefrappier18 marked this pull request as ready for review September 18, 2026 14:22
@daniellefrappier18
daniellefrappier18 requested a review from a team as a code owner September 18, 2026 14:22
Copilot AI balanced review requested due to automatic review settings September 18, 2026 14:22

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.

🟡 Changes recommended

CSV security and several filtering, status, export, and error-state behaviors remain incorrect.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a feature-flagged, contract-scoped learner directory for B2B managers.

Changes:

  • Adds learner metrics, filtering, pagination, and CSV export.
  • Extends the analytics client with learner-progress APIs.
  • Links contract dashboards to the new route and adds tests.
File summaries
File Description
frontends/ol-components/src/index.ts Exports MUI checkbox utilities.
frontends/main/src/components/B2BTable/B2BTable.tsx Adds shared table and CSV helpers.
frontends/main/src/common/urls.ts Defines the learner-directory route.
frontends/main/src/common/errors.ts Adds authorization-response detection.
frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/page.tsx Registers the learner page.
frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/learners/layout.tsx Restricts route access.
frontends/main/src/app-pages/DashboardPage/ContractContent.tsx Links contract cards to learners.
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx Links analytics to learners.
frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx Tests the analytics link.
frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts Maps learner statuses for display.
frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.test.ts Tests status mapping.
frontends/main/src/app-pages/ContractLearnersPage/ProgressBar.tsx Adds a future progress indicator.
frontends/main/src/app-pages/ContractLearnersPage/placeholders.ts Defines disabled placeholder data.
frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx Renders learner table rows.
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Implements the learner directory.
frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx Tests directory behavior.
frontends/main/src/app-pages/ContractLearnersPage/columns.ts Defines table column sizing.
frontends/api/src/test-utils/mockAxios.ts Preserves parameter serialization in mocks.
frontends/api/src/analytics/types.ts Adds learner-progress types.
frontends/api/src/analytics/test-utils/urls.ts Adds learner-progress mock URLs.
frontends/api/src/analytics/test-utils/factories.ts Adds learner test factories.
frontends/api/src/analytics/hooks/organizations/queries.ts Adds learner-progress queries.
frontends/api/src/analytics/hooks/organizations/index.ts Exports learner analytics types.
frontends/api/src/analytics/clients.ts Adds the learner-progress API client.
Review details

Suppressed comments (3)

frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx:710

  • canQuery is also false when analytics is configured but this organization lacks sso_organization_id, so this message incorrectly blames the environment in that case. Match the sibling analytics page by distinguishing an unavailable organization from an unconfigured environment.
        {!canQuery ? (
          <Typography variant="body1">
            Learner analytics is not available in this environment.
          </Typography>

frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx:685

  • This link points to the contract-scoped analytics route, whose heading is Analytics · <contract name>; calling it “Program analytics” misidentifies the destination. Use “Contract analytics” (or simply “Analytics”) so the link purpose is accurate.
            Program analytics

frontends/main/src/app-pages/ContractLearnersPage/LearnerRow.tsx:227

  • This disabled block is not actually one-uncomment-away: needsAttention has no declaration or prop in LearnerRow, and the adjacent disabled cells likewise reference undeclared progress and lastActiveOn; the page's disabled module JSX also references undeclared moduleFilter/setModuleFilter. Uncommenting the documented blocks therefore does not compile, contrary to the PR description. Either add the missing scaffolding or describe these as partial placeholders rather than fully wired features.
          {/*
          {needsAttention ? (
            <NeedsAttention
              component="span"
              {...{ [PLACEHOLDER_ATTR]: "needs-attention" }}
            >
              Needs attention
            </NeedsAttention>
          ) : null}
  • Files reviewed: 24/24 changed files
  • Comments generated: 8
  • Review effort level: Balanced

💡 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/components/B2BTable/B2BTable.tsx
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/statusDisplay.ts Outdated
daniellefrappier18 and others added 6 commits September 18, 2026 10:34
Enhance CSV cell formatting to handle special characters and prevent formula injection.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…able

The CSV export wrote the raw completion_status enum instead of the
same display label LearnerRow shows on screen, so e.g. a verified
"passed" row read "Certificate" in the table but exported as "passed".
Export now reuses getDisplayStatus/DISPLAY_STATUS_LABEL.

Export also ignored canQuery, so in an environment without analytics
configured the button stayed clickable and surfaced a misleading
generic failure instead of doing nothing. handleExport and the
button's aria-disabled state now both gate on canQuery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexfigtree

Copy link
Copy Markdown
Contributor

Not necessarily worth addressing in this PR, but I ran this locally and noticed that "Completed" means two things on this dashboard: 1) the tile at the top (ContractLearnersPage.tsx:449-456) which queries completion_status: ["passed", "certified"], and 2) the filter option (in ContractLearnersPage.tsx:320-327) which maps "Completed" to "passed" only, treating it and "Certificate" as mutually exclusive. So selecting "Completed" in the filter shows fewer rows than the tile's count (see image below). This isn't a logic bug (both pieces work as coded), I think it's just something that a user could read as broken or confusing.
Screenshot 2026-09-21 at 3 32 05 PM

@alexfigtree

Copy link
Copy Markdown
Contributor

Also, regardless of the filter I use on this Dashboard, the Export Learners button always seems to produce a CSV with the total amount of learners, not the filtered list. handleExport (ContractLearnersPage.tsx:571-592) builds its own query params and never includes search/completion_status, unlike listParams (:391-402) which the table itself uses.

@daniellefrappier18

daniellefrappier18 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Not necessarily worth addressing in this PR, but I ran this locally and noticed that "Completed" means two things on this dashboard: 1) the tile at the top (ContractLearnersPage.tsx:449-456) which queries completion_status: ["passed", "certified"], and 2) the filter option (in ContractLearnersPage.tsx:320-327) which maps "Completed" to "passed" only, treating it and "Certificate" as mutually exclusive. So selecting "Completed" in the filter shows fewer rows than the tile's count (see image below). This isn't a logic bug (both pieces work as coded), I think it's just something that a user could read as broken or confusing. Screenshot 2026-09-21 at 3 32 05 PM

Totally fair feedback - I was following the prototype here https://learn.mit.dev/organization/test-university/contract/test-university-contract/learners but I can see how this could be confusing for the user. The table status is confusing so I'm going to stop collapsing passed+verified into Certificate in learner status. getDisplayStatus now maps completion_status directly — passed → "Completed", certified → "Certificate"

I like to tackle the tile mismatch in the next round as not to hold this up. @Ferdi two suggestions that come to mind is 1. break out certificate into it's own tile or 2. Add a information icon with a hover explaining that this has been rolled up into one value. Maybe changing the label to Completed/Certificate

@daniellefrappier18

Copy link
Copy Markdown
Contributor Author

Also, regardless of the filter I use on this Dashboard, the Export Learners button always seems to produce a CSV with the total amount of learners, not the filtered list. handleExport (ContractLearnersPage.tsx:571-592) builds its own query params and never includes search/completion_status, unlike listParams (:391-402) which the table itself uses.

This was intentional the thought was if they are exporting the CSV they will likely want to do the filtering in excel or whatever system they might be uploading this data to.

@alexfigtree

Copy link
Copy Markdown
Contributor

Also, regardless of the filter I use on this Dashboard, the Export Learners button always seems to produce a CSV with the total amount of learners, not the filtered list. handleExport (ContractLearnersPage.tsx:571-592) builds its own query params and never includes search/completion_status, unlike listParams (:391-402) which the table itself uses.

This was intentional the thought was if they are exporting the CSV they will likely want to do the filtering in excel or whatever system they might be uploading this data to.

Alright, in that case, if we don't intend to match the CSV to the filtered list, the checkbox in your PR (the one that says "Export learners downloads a matching CSV") should be updated to reflect this.

@blarghmatey

Copy link
Copy Markdown
Member

@daniellefrappier18 whenever I see an export to CSV interface, it makes me wonder what it is that we're solving for? It also raises a lot of questions around data governance, because as soon as that data is exported we're completely blind to it. Separately, we're adding an integration API for being able to retrieve the per-learner records, with a batched CSV export to S3 so I just want to make sure that we're not adding extra data export surfaces beyond what we want to support.

@blarghmatey blarghmatey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Read this against ol-analytics-api's b2b_dashboard tenant and the service's deployed Pulumi config. The shape is right, and the discipline about not shipping a field with nothing behind it is worth keeping. Two things below are wrong rather than incomplete; the rest are inline.

The summary tiles cost five requests per page load where the envelope should carry the counts. The API side is mitodl/ol-analytics-api#68, open now. Nothing here blocks on it landing, but the tile code should be written expecting to collapse to one request, and there is a behaviour decision in it for you (inline below).

Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.tsx Outdated
Comment thread frontends/api/src/analytics/types.ts Outdated
@daniellefrappier18

Copy link
Copy Markdown
Contributor Author

@daniellefrappier18 whenever I see an export to CSV interface, it makes me wonder what it is that we're solving for? It also raises a lot of questions around data governance, because as soon as that data is exported we're completely blind to it. Separately, we're adding an integration API for being able to retrieve the per-learner records, with a batched CSV export to S3 so I just want to make sure that we're not adding extra data export surfaces beyond what we want to support.

@blarghmatey Splitting this into replies.

  1. Why the button exists at all: I'll defer to @Ferdi on this one since it's a product call. It was in the prototype and matches what we already do on the manage-seats dashboard, so from my side it was following an existing pattern rather than introducing a new one.
  2. Redundant export surface: this wasn't meant to be a permanent second path. The client-side CSV here is a stand-in until the integration API's batched export to S3 lands. My assumption was that once that ships, this button becomes the door to it.

@blarghmatey

Copy link
Copy Markdown
Member

So, the integration API is gated behind a machine-to-machine auth path. I guess my question is to Ferdi about what the goal of a CSV export is from the dashboard and how it should be managed from an authorization perspective. Not a hard blocker from me, just something that I like to get clarity on because it often comes back to bite us in terms of performance issues and the potential for data exposure.

@daniellefrappier18

Copy link
Copy Markdown
Contributor Author

So, the integration API is gated behind a machine-to-machine auth path. I guess my question is to Ferdi about what the goal of a CSV export is from the dashboard and how it should be managed from an authorization perspective. Not a hard blocker from me, just something that I like to get clarity on because it often comes back to bite us in terms of performance issues and the potential for data exposure.

@Ferdi

@daniellefrappier18

Copy link
Copy Markdown
Contributor Author

@Ferdi After talking with @blarghmatey, I realized I was wrong about why he asked about the CSV export. A parallel effort already exposes this data to LLMs through the b2b_learner_records API, so a CSV export here would be redundant.

Should we remove this button and the underlying logic entirely?

@daniellefrappier18
daniellefrappier18 merged commit 848040d into main Sep 24, 2026
22 checks passed
@daniellefrappier18
daniellefrappier18 deleted the daniellef/feat-learners-analytics-dashboard branch September 24, 2026 14:06
@odlbot odlbot mentioned this pull request Sep 24, 2026
6 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants