diff --git a/src/components/app/data/hooks/useCourseRedemptionEligibility.js b/src/components/app/data/hooks/useCourseRedemptionEligibility.js index 89e6ab42d5..b271bb5485 100644 --- a/src/components/app/data/hooks/useCourseRedemptionEligibility.js +++ b/src/components/app/data/hooks/useCourseRedemptionEligibility.js @@ -1,34 +1,48 @@ import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { isRunUnrestricted } from '../utils'; import useCourseMetadata from './useCourseMetadata'; import { queryCanRedeem } from '../queries'; import useEnterpriseCustomer from './useEnterpriseCustomer'; import useLateEnrollmentBufferDays from './useLateEnrollmentBufferDays'; +import useEnterpriseCustomerContainsContent from './useEnterpriseCustomerContainsContent'; export function transformCourseRedemptionEligibility({ courseMetadata, canRedeemData, courseRunKey, + restrictedRunsAllowed, }) { - const redeemabilityForActiveCourseRun = canRedeemData.find(r => r.contentKey === courseMetadata.activeCourseRun?.key); + // Begin by excluding restricted runs that should not be visible to the requester. + // This filtering does not control visibility of individual course runs, but + // it does serve as input to the determination of redemption eligiblity. + const unrestrictedCanRedeemData = canRedeemData.filter(r => isRunUnrestricted({ + restrictedRunsAllowed, + courseMetadata, + courseRunKey: r.contentKey, + catalogUuid: r.redeemableSubsidyAccessPolicy?.catalogUuid, + })); + const redeemabilityForActiveCourseRun = unrestrictedCanRedeemData.find( + r => r.contentKey === courseMetadata.activeCourseRun?.key, + ); const missingSubsidyAccessPolicyReason = redeemabilityForActiveCourseRun?.reasons[0]; const preferredSubsidyAccessPolicy = redeemabilityForActiveCourseRun?.redeemableSubsidyAccessPolicy; - const otherSubsidyAccessPolicy = canRedeemData.find( + const anyRedeemableSubsidyAccessPolicy = unrestrictedCanRedeemData.find( r => r.redeemableSubsidyAccessPolicy, )?.redeemableSubsidyAccessPolicy; const listPrice = redeemabilityForActiveCourseRun?.listPrice?.usd; const hasSuccessfulRedemption = courseRunKey - ? !!canRedeemData.find(r => r.contentKey === courseRunKey)?.hasSuccessfulRedemption - : canRedeemData.some(r => r.hasSuccessfulRedemption); + ? !!unrestrictedCanRedeemData.find(r => r.contentKey === courseRunKey)?.hasSuccessfulRedemption + : unrestrictedCanRedeemData.some(r => r.hasSuccessfulRedemption); // If there is a redeemable subsidy access policy for the active course run, use that. Otherwise, use any other // redeemable subsidy access policy for any of the content keys. - const redeemableSubsidyAccessPolicy = preferredSubsidyAccessPolicy || otherSubsidyAccessPolicy; + const redeemableSubsidyAccessPolicy = preferredSubsidyAccessPolicy || anyRedeemableSubsidyAccessPolicy; const isPolicyRedemptionEnabled = hasSuccessfulRedemption || !!redeemableSubsidyAccessPolicy; return { isPolicyRedemptionEnabled, - redeemabilityPerContentKey: canRedeemData, + redeemabilityPerContentKey: unrestrictedCanRedeemData, redeemableSubsidyAccessPolicy, missingSubsidyAccessPolicyReason, hasSuccessfulRedemption, @@ -45,6 +59,7 @@ export default function useCourseRedemptionEligibility(queryOptions = {}) { const { select, ...queryOptionsRest } = queryOptions; const { data: enterpriseCustomer } = useEnterpriseCustomer(); const { data: courseMetadata } = useCourseMetadata(); + const { data: { restrictedRunsAllowed } } = useEnterpriseCustomerContainsContent([courseMetadata.key]); const lateEnrollmentBufferDays = useLateEnrollmentBufferDays(); return useQuery({ @@ -55,6 +70,7 @@ export default function useCourseRedemptionEligibility(queryOptions = {}) { courseMetadata, canRedeemData: data, courseRunKey, + restrictedRunsAllowed, }); if (select) { return select({ diff --git a/src/components/app/data/hooks/useCourseRedemptionEligibility.test.jsx b/src/components/app/data/hooks/useCourseRedemptionEligibility.test.jsx index 1b5d7c5daa..c31acc4d26 100644 --- a/src/components/app/data/hooks/useCourseRedemptionEligibility.test.jsx +++ b/src/components/app/data/hooks/useCourseRedemptionEligibility.test.jsx @@ -7,12 +7,17 @@ import useEnterpriseCustomer from './useEnterpriseCustomer'; import { queryClient } from '../../../../utils/tests'; import { fetchCanRedeem } from '../services'; import useCourseMetadata from './useCourseMetadata'; -import { useCourseRedemptionEligibility, useLateEnrollmentBufferDays } from './index'; +import { + useCourseRedemptionEligibility, + useLateEnrollmentBufferDays, + useEnterpriseCustomerContainsContent, +} from './index'; import { transformCourseRedemptionEligibility } from './useCourseRedemptionEligibility'; jest.mock('./useEnterpriseCustomer'); jest.mock('./useCourseMetadata'); jest.mock('./useLateEnrollmentBufferDays'); +jest.mock('./useEnterpriseCustomerContainsContent'); jest.mock('../services', () => ({ ...jest.requireActual('../services'), fetchCanRedeem: jest.fn().mockResolvedValue(null), @@ -87,6 +92,7 @@ describe('useCourseRedemptionEligibility', () => { useParams.mockReturnValue({ courseRunKey: mockCourseRunKey }); useCourseMetadata.mockReturnValue({ data: mockCourseMetadata }); useLateEnrollmentBufferDays.mockReturnValue(undefined); + useEnterpriseCustomerContainsContent.mockReturnValue({ data: {} }); }); it('should handle resolved value correctly', async () => { const { result, waitForNextUpdate } = renderHook(() => useCourseRedemptionEligibility(), { wrapper: Wrapper }); diff --git a/src/components/app/data/services/course.js b/src/components/app/data/services/course.js index 72bf5c6ef2..f917dd6e01 100644 --- a/src/components/app/data/services/course.js +++ b/src/components/app/data/services/course.js @@ -16,6 +16,8 @@ import { findHighestLevelEntitlementSku, getActiveCourseRun } from '../utils'; export async function fetchCourseMetadata(courseKey, courseRunKey) { const contentMetadataUrl = `${getConfig().DISCOVERY_API_BASE_URL}/api/v1/courses/${courseKey}/`; const queryParams = new URLSearchParams(); + // Always include restricted/custom-b2b-enterprise runs in case one has been requested. + queryParams.append('include_restricted', 'custom-b2b-enterprise'); const url = `${contentMetadataUrl}?${queryParams.toString()}`; try { const response = await getAuthenticatedHttpClient().get(url); @@ -28,6 +30,8 @@ export async function fetchCourseMetadata(courseKey, courseRunKey) { transformedData.activeCourseRun = getActiveCourseRun(transformedData); transformedData.courseEntitlementProductSku = findHighestLevelEntitlementSku(transformedData.entitlements); + // If a specific courseRunKey is requested, and that courseRunKey belongs + // to the specified course, narrow the returned runs to just the one run. const courseRunKeys = transformedData.courseRuns.map(({ key }) => key); if (courseRunKey && courseRunKeys.includes(courseRunKey)) { transformedData.canonicalCourseRunKey = courseRunKey; @@ -47,8 +51,12 @@ export async function fetchCourseMetadata(courseKey, courseRunKey) { export async function fetchCourseRunMetadata(courseRunKey) { const courseRunMetadataUrl = `${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_runs/${courseRunKey}/`; + const queryParams = new URLSearchParams(); + // Always include restricted/custom-b2b-enterprise runs in case one has been requested. + queryParams.append('include_restricted', 'custom-b2b-enterprise'); + const url = `${courseRunMetadataUrl}?${queryParams.toString()}`; try { - const response = await getAuthenticatedHttpClient().get(courseRunMetadataUrl); + const response = await getAuthenticatedHttpClient().get(url); return camelCaseObject(response.data); } catch (error) { if (getErrorResponseStatusCode(error) !== 404) { diff --git a/src/components/app/data/utils.js b/src/components/app/data/utils.js index f71f237236..e88d9baf70 100644 --- a/src/components/app/data/utils.js +++ b/src/components/app/data/utils.js @@ -832,3 +832,21 @@ export function transformCourseMetadataByAllocatedCourseRunAssignments({ } return courseMetadata; } + +/* + * A centralized helper to tell us if a given run is unrestricted for the given catalog. + */ +export function isRunUnrestricted({ + restrictedRunsAllowed, + courseMetadata, + courseRunKey, + catalogUuid, +}) { + const courseRunMetadata = courseMetadata.availableCourseRuns.find(r => r.contentKey === courseRunKey); + if (courseRunMetadata?.restrictionType === 'custom-b2b-enterprise') { + // If the run is restricted for enterprise, make sure the catalog of interest explicitly allows it. + return restrictedRunsAllowed[courseMetadata.key][courseRunKey].includes(catalogUuid); + } + // Otherwise, only allow completely unrestricted runs. + return !courseRunMetadata?.restrictionType; +} diff --git a/src/components/course/course-header/CourseRunCards.jsx b/src/components/course/course-header/CourseRunCards.jsx index 6a188bbbb6..366e8557c8 100644 --- a/src/components/course/course-header/CourseRunCards.jsx +++ b/src/components/course/course-header/CourseRunCards.jsx @@ -10,6 +10,7 @@ import { useEnterpriseCourseEnrollments, useEnterpriseCustomerContainsContent, useUserEntitlements, + isRunUnrestricted, } from '../../app/data'; /** @@ -21,18 +22,30 @@ const CourseRunCards = () => { const { userSubsidyApplicableToCourse, missingUserSubsidyReason, + applicableCatalogUuid, } = useUserSubsidyApplicableToCourse(); + const { + data: { + catalogList, + restrictedRunsAllowed, + }, + } = useEnterpriseCustomerContainsContent([courseKey]); const { data: courseMetadata } = useCourseMetadata(); - const { data: { catalogList } } = useEnterpriseCustomerContainsContent([courseKey]); const { data: { enterpriseCourseEnrollments } } = useEnterpriseCourseEnrollments(); const { data: userEntitlements } = useUserEntitlements(); + const availableCourseRuns = courseMetadata.availableCourseRuns.filter(r => isRunUnrestricted({ + restrictedRunsAllowed, + courseMetadata, + courseRunKey: r.key, + applicableCatalogUuid, + })); return ( - {courseMetadata.availableCourseRuns.map((courseRun) => { + {availableCourseRuns.map((courseRun) => { const hasRedeemablePolicy = userSubsidyApplicableToCourse?.subsidyType === LEARNER_CREDIT_SUBSIDY_TYPE; // Render the newer `CourseRunCard` component when the user's subsidy, if any, is