Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME=csrftoken
NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/recommendation_agent/
NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/syllabus_agent/

# OL Analytics API (ol-analytics-api). Auth is cookie-based via APISIX — the
# frontend attaches no token. Leave unset when there is no analytics API to talk
# to: the org analytics dashboard then reports itself unavailable rather than
# loading data. Access to the route itself is gated by the
# b2b-analytics-dashboard PostHog flag, not by this variable.
NEXT_PUBLIC_ANALYTICS_API_BASE_URL=${ANALYTICS_API_BASE_URL}

NEXT_PUBLIC_MITX_ONLINE_BASE_URL=${MITX_ONLINE_BASE_URL}
NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=http://mitxonline.odl.local:8065/
NEXT_PUBLIC_VERSION="local-dev"
Expand Down
9 changes: 9 additions & 0 deletions env/frontend.local.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID=""
# in the README. Requires learn-ai running locally.
# NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=http://open.odl.local:8065/ai/http/recommendation_agent/
# NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=http://open.odl.local:8065/ai/http/syllabus_agent/

# ol-analytics-api integration: supplies the data for the B2B org analytics
# dashboard at /dashboard/organization/<org>/analytics. Whether that route is
# reachable at all is controlled by the b2b-analytics-dashboard PostHog flag,
# not by this variable: with the flag on and this unset the page still renders,
# reporting "Analytics is not available in this environment".
# Point at the APISIX route rather than the API directly — the API expects the
# JWT that APISIX mints from the session cookie.
# NEXT_PUBLIC_ANALYTICS_API_BASE_URL=http://open.odl.local:8065/analytics/
5 changes: 4 additions & 1 deletion frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"./test-utils/mockAxios": "./src/test-utils/mockAxios.ts",
"./test-utils": "./src/test-utils/index.ts",
"./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts",
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts"
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts",
"./analytics-hooks/*": "./src/analytics/hooks/*/index.ts",
"./analytics-types": "./src/analytics/types.ts",
"./analytics-test-utils": "./src/analytics/test-utils/index.ts"
},
"peerDependencies": {
"react": "^19.2.1"
Expand Down
14 changes: 14 additions & 0 deletions frontends/api/src/analytics/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createConfigurableAxios } from "../configurableAxios"

/**
* Axios instance for the OL Analytics API (`ol-analytics-api`).
*
* Auth is entirely cookie-based: the analytics API sits behind APISIX, which
* resolves the session cookie into the JWT the API expects. The browser sends
* the cookie because of `withCredentials`, so nothing here attaches a token.
*/
export const analyticsAxiosClient = createConfigurableAxios(
"mit-learn.api.axios.analytics",
)

export default analyticsAxiosClient.instance
107 changes: 107 additions & 0 deletions frontends/api/src/analytics/clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { AxiosResponse } from "axios"
import axiosInstance from "./axios"
import type {
AnalyticsPageParams,
ContentEngagementDepth,
ContractUtilization,
EnrollmentCompletionFunnel,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
ProgramFunnel,
} from "./types"

/**
* The b2b_dashboard tenant is mounted at this path by `ol-analytics-api`'s
* root ASGI app (`main.py`'s tenant registry). The configured axios `baseURL`
* is the API host, so every path here is relative to it.
*/
const B2B_DASHBOARD_ROOT = "/api/v1/analytics"

/**
* `organizationId` is the **Keycloak organization UUID** (`sso_organization_id`),
* not the org slug and not MITx Online's numeric org id. It is the only
* identifier that is stable across the JWT, MITx Online and StarRocks, so the
* analytics API keys every org endpoint on it — see mitodl/ol-analytics-api#13.
*/
const orgRoot = (organizationId: string) =>
`${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent(organizationId)}`

const getOrgResource = <RowT>(
organizationId: string,
resource: string,
page: AnalyticsPageParams | undefined,
signal: AbortSignal | undefined,
): Promise<AxiosResponse<OrgAnalyticsResponse<RowT>>> =>
axiosInstance.get<OrgAnalyticsResponse<RowT>>(
`${orgRoot(organizationId)}/${resource}`,
{ params: page, signal },
)

/**
* One method per org-scoped endpoint of the analytics API's b2b_dashboard
* tenant. Thin by design — paging and the response envelope are the only
* shared concerns, and both live in `getOrgResource`.
*/
const analyticsOrganizationsApi = {
contractUtilization: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ContractUtilization>(
organizationId,
"contract-utilization",
page,
signal,
),

enrollmentFunnel: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<EnrollmentCompletionFunnel>(
organizationId,
"enrollment-funnel",
page,
signal,
),

engagementTrend: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<MonthlyEngagementTrend>(
organizationId,
"engagement-trend",
page,
signal,
),

programFunnel: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ProgramFunnel>(
organizationId,
"program-funnel",
page,
signal,
),

contentEngagement: (
organizationId: string,
page?: AnalyticsPageParams,
signal?: AbortSignal,
) =>
getOrgResource<ContentEngagementDepth>(
organizationId,
"content-engagement",
page,
signal,
),
}

export { analyticsOrganizationsApi, B2B_DASHBOARD_ROOT }
14 changes: 14 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export {
analyticsOrganizationQueries,
analyticsOrganizationKeys,
} from "./queries"

export type {
AnalyticsPageParams,
ContentEngagementDepth,
ContractUtilization,
EnrollmentCompletionFunnel,
MonthlyEngagementTrend,
OrgAnalyticsResponse,
ProgramFunnel,
} from "../../types"
117 changes: 117 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { renderHook, waitFor } from "@testing-library/react"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { setupReactQueryTest } from "../../../hooks/test-utils"
import { setMockResponse, makeRequest } from "../../../test-utils"
import { factories, urls } from "../../test-utils"
import { analyticsOrganizationQueries } from "./queries"

const ORG_UUID = "3fa85f64-5717-4562-b3fc-2c963f66afa6"

/**
* Each factory returns options for its own row type, so a `test.each` table of
* them is a union that `useQuery` cannot resolve to a single overload. The
* table only needs "does this options object request that URL", so erase the
* row type here rather than splitting the table into five near-identical tests.
*/
type AnyQueryOptions = UseQueryOptions<
unknown,
Error,
unknown,
readonly unknown[]
>
const erase = (options: unknown) => options as AnyQueryOptions

describe("analyticsOrganizationQueries", () => {
test.each([
{
name: "contractUtilization",
query: () =>
erase(analyticsOrganizationQueries.contractUtilization(ORG_UUID)),
url: urls.organizations.contractUtilization(ORG_UUID),
response: factories.envelope([factories.contractUtilization()]),
},
{
name: "enrollmentFunnel",
query: () =>
erase(analyticsOrganizationQueries.enrollmentFunnel(ORG_UUID)),
url: urls.organizations.enrollmentFunnel(ORG_UUID),
response: factories.envelope([factories.enrollmentCompletionFunnel()]),
},
{
name: "engagementTrend",
query: () =>
erase(analyticsOrganizationQueries.engagementTrend(ORG_UUID)),
url: urls.organizations.engagementTrend(ORG_UUID),
response: factories.envelope([factories.monthlyEngagementTrend()]),
},
{
name: "programFunnel",
query: () => erase(analyticsOrganizationQueries.programFunnel(ORG_UUID)),
url: urls.organizations.programFunnel(ORG_UUID),
response: factories.envelope([factories.programFunnel()]),
},
{
name: "contentEngagement",
query: () =>
erase(analyticsOrganizationQueries.contentEngagement(ORG_UUID)),
url: urls.organizations.contentEngagement(ORG_UUID),
response: factories.envelope([factories.contentEngagementDepth()]),
},
])("$name requests its endpoint and returns the envelope", async (spec) => {
setMockResponse.get(spec.url, spec.response)
const { wrapper } = setupReactQueryTest()

const { result } = renderHook(() => useQuery(spec.query()), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(spec.response)
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "get", url: spec.url }),
)
})

test("paging params reach the request and change the query key", async () => {
const page = { limit: 200, offset: 0 }
const url = urls.organizations.contractUtilization(ORG_UUID, page)
const response = factories.envelope([factories.contractUtilization()])
setMockResponse.get(url, response)
const { wrapper } = setupReactQueryTest()

const { result } = renderHook(
() =>
useQuery(
analyticsOrganizationQueries.contractUtilization(ORG_UUID, page),
),
{ wrapper },
)

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(makeRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: "get", url }),
)
expect(
analyticsOrganizationQueries.contractUtilization(ORG_UUID, page).queryKey,
).not.toEqual(
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey,
)
})

test("query keys are namespaced per org so one org cannot read another's cache", () => {
const [namespace, resource, orgId] =
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey
expect([namespace, resource, orgId]).toEqual([
"analytics",
"organizations",
ORG_UUID,
])
expect(
analyticsOrganizationQueries.contractUtilization("other").queryKey,
).not.toEqual(
analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey,
)
})

test("the org id is url-encoded rather than spliced into the path raw", () => {
expect(urls.organizations.contractUtilization("a/b")).toContain("a%2Fb")
})
})
99 changes: 99 additions & 0 deletions frontends/api/src/analytics/hooks/organizations/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { queryOptions } from "@tanstack/react-query"
import { analyticsOrganizationsApi } from "../../clients"
import type { AnalyticsPageParams } from "../../types"

/**
* `orgId` in every key is the Keycloak organization UUID — see
* `analyticsOrganizationsApi`. Keys are namespaced under "analytics" so the
* whole dashboard can be invalidated without touching mitxonline or learn
* queries.
*/
const analyticsOrganizationKeys = {
root: ["analytics", "organizations"] as const,
organization: (orgId: string) =>
[...analyticsOrganizationKeys.root, orgId] as const,
resource: (orgId: string, resource: string, page?: AnalyticsPageParams) =>
[...analyticsOrganizationKeys.organization(orgId), resource, page] as const,
}

/**
* The materialized views behind these endpoints refresh on a schedule measured
* in hours, so refetching on every window focus only costs StarRocks queries
* without ever changing what the manager sees. Freshness is communicated by the
* `as_of` in each response instead.
*/
const ANALYTICS_STALE_TIME = 5 * 60 * 1000

const analyticsOrganizationQueries = {
contractUtilization: (orgId: string, page?: AnalyticsPageParams) =>
queryOptions({
queryKey: analyticsOrganizationKeys.resource(
orgId,
"contract-utilization",
page,
),
staleTime: ANALYTICS_STALE_TIME,
queryFn: async ({ signal }) =>
analyticsOrganizationsApi
.contractUtilization(orgId, page, signal)
.then((res) => res.data),
}),

enrollmentFunnel: (orgId: string, page?: AnalyticsPageParams) =>
queryOptions({
queryKey: analyticsOrganizationKeys.resource(
orgId,
"enrollment-funnel",
page,
),
staleTime: ANALYTICS_STALE_TIME,
queryFn: async ({ signal }) =>
analyticsOrganizationsApi
.enrollmentFunnel(orgId, page, signal)
.then((res) => res.data),
}),

engagementTrend: (orgId: string, page?: AnalyticsPageParams) =>
queryOptions({
queryKey: analyticsOrganizationKeys.resource(
orgId,
"engagement-trend",
page,
),
staleTime: ANALYTICS_STALE_TIME,
queryFn: async ({ signal }) =>
analyticsOrganizationsApi
.engagementTrend(orgId, page, signal)
.then((res) => res.data),
}),

programFunnel: (orgId: string, page?: AnalyticsPageParams) =>
queryOptions({
queryKey: analyticsOrganizationKeys.resource(
orgId,
"program-funnel",
page,
),
staleTime: ANALYTICS_STALE_TIME,
queryFn: async ({ signal }) =>
analyticsOrganizationsApi
.programFunnel(orgId, page, signal)
.then((res) => res.data),
}),

contentEngagement: (orgId: string, page?: AnalyticsPageParams) =>
queryOptions({
queryKey: analyticsOrganizationKeys.resource(
orgId,
"content-engagement",
page,
),
staleTime: ANALYTICS_STALE_TIME,
queryFn: async ({ signal }) =>
analyticsOrganizationsApi
.contentEngagement(orgId, page, signal)
.then((res) => res.data),
}),
}

export { analyticsOrganizationQueries, analyticsOrganizationKeys }
Loading
Loading