diff --git a/frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx b/frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx index d4e14a05..b4e25166 100644 --- a/frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx +++ b/frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx @@ -19,6 +19,7 @@ import { OrganizationCourseDrawer } from "@/components/admin/organization-course import { createOrganizationCouponPoolAction, rotateOrganizationCredentialFormAction, + setAllocationAllowedAction, setAllocationLeaderboardAction, setOrganizationCouponPoolActiveAction, } from "@/server/actions/admin/organizations"; @@ -200,7 +201,7 @@ export default async function AdminOrganizationDetailPage({ Course Used / max Expires - Allowed + Assignable Leaderboards @@ -230,11 +231,26 @@ export default async function AdminOrganizationDetailPage({ {row.expiresAt ? new Date(row.expiresAt).toLocaleDateString() : "—"} - {row.isAllowed ? ( - Yes - ) : ( - No - )} +
+ + + + {row.isAllowed ? ( + Yes + ) : ( + + Not assignable + + )} + +
diff --git a/frontend/src/server/actions/admin/organizations.ts b/frontend/src/server/actions/admin/organizations.ts index e271a732..ecfd2987 100644 --- a/frontend/src/server/actions/admin/organizations.ts +++ b/frontend/src/server/actions/admin/organizations.ts @@ -83,6 +83,23 @@ export async function bulkUpsertOrganizationCourseAllocationsAction( }); } +/** + * Turns integration enrollments on or off for one allocated course. This is the + * flag the integration catalog filters on, so a course with it off cannot be + * assigned even though it sits in the organization's catalog. + */ +export async function setAllocationAllowedAction(formData: FormData): Promise { + const organizationId = String(formData.get("organizationId") ?? ""); + const courseId = String(formData.get("courseId") ?? ""); + if (!organizationId || !courseId) throw new Error("Missing organization or course."); + await adminCall( + `/api/v1/admin/organizations/${encodeURIComponent(organizationId)}/allocations/${encodeURIComponent(courseId)}/allowed`, + { method: "PATCH", json: { enabled: formData.get("enabled") === "true" } } + ); + revalidatePath(`/admin/organizations/${organizationId}`); + revalidatePath("/admin/organizations"); +} + /** Lets (or stops) an organization's learners joining a course's weekly leaderboards. */ export async function setAllocationLeaderboardAction(formData: FormData): Promise { const organizationId = String(formData.get("organizationId") ?? ""); diff --git a/server/src/controllers/admin.ts b/server/src/controllers/admin.ts index 653a90b0..4d346642 100644 --- a/server/src/controllers/admin.ts +++ b/server/src/controllers/admin.ts @@ -119,6 +119,13 @@ export async function bulkRejectCourseRequests(req: Request, res: Response) { ok(res, await courseRequests.bulkRejectCourseRequests(req.user!, ids)); } +// PATCH /api/v1/admin/organizations/:orgId/allocations/:courseId/allowed +export async function setAllocationAllowed(req: Request, res: Response) { + const { enabled } = req.body as { enabled: boolean }; + await organizations.setAllocationAllowed(req.user!, param(req, "orgId"), param(req, "courseId"), enabled); + noContent(res); +} + // PATCH /api/v1/admin/organizations/:orgId/allocations/:courseId/leaderboard export async function setAllocationLeaderboard(req: Request, res: Response) { const { enabled } = req.body as { enabled: boolean }; diff --git a/server/src/routes/v1/admin.ts b/server/src/routes/v1/admin.ts index 816bd060..b91935bf 100644 --- a/server/src/routes/v1/admin.ts +++ b/server/src/routes/v1/admin.ts @@ -95,6 +95,11 @@ router.post("/organizations", validateBody(createOrganizationBody), admin.create router.get("/organizations/:orgId", validateParams(orgIdParams), admin.getOrganization); router.post("/organizations/:orgId/credentials/rotate", validateParams(orgIdParams), admin.rotateCredential); router.put("/organizations/:orgId/allocations", validate({ params: orgIdParams, body: allocationsBody }), admin.upsertAllocations); +router.patch( + "/organizations/:orgId/allocations/:courseId/allowed", + validate({ params: allocationParams, body: instructorAccessBody }), + admin.setAllocationAllowed +); router.patch( "/organizations/:orgId/allocations/:courseId/leaderboard", validate({ params: allocationParams, body: instructorAccessBody }), diff --git a/server/src/services/admin/organizations.ts b/server/src/services/admin/organizations.ts index 319e00c9..16737c8a 100644 --- a/server/src/services/admin/organizations.ts +++ b/server/src/services/admin/organizations.ts @@ -248,6 +248,46 @@ export function approvePendingRequestsFor(tx: Prisma.TransactionClient, organiza }); } +/** + * Switches integration enrollments on or off for ONE allocation. + * + * `isAllowed` is the flag `/integrations/v1/courses` filters on, so an + * allocation with it off is invisible to the organisation's integration even + * though the catalog shows it in place with seats and no expiry. Until this + * existed the flag could only be set by the bulk form's single checkbox, which + * applies to every selected course at once — so one save with it unticked left + * a whole catalog unassignable, with no way back but re-selecting everything. + * + * Audited like a bulk allocation change, because it grants or withdraws access. + */ +export async function setAllocationAllowed( + actor: AdminActor, + orgId: string, + courseId: string, + isAllowed: boolean +): Promise { + const allocation = await prisma.organizationCourse.findUnique({ + where: { organizationId_courseId: { organizationId: orgId, courseId } }, + select: { organization: { select: { name: true } }, course: { select: { title: true } } }, + }); + if (!allocation) throw new NotFoundError("Allocation not found"); + + await prisma.$transaction(async (tx) => { + await tx.organizationCourse.update({ + where: { organizationId_courseId: { organizationId: orgId, courseId } }, + data: { isAllowed }, + }); + await recordAdminAction(tx, { + actor, + action: ADMIN_AUDIT_ACTIONS.ORGANIZATION_ALLOCATE, + targetType: "Organization", + targetId: orgId, + summary: `${isAllowed ? "Enabled" : "Disabled"} integration enrollments for ${allocation.course.title} at ${allocation.organization.name}`, + metadata: { courseIds: [courseId], isAllowed }, + }); + }); +} + /** Lets (or stops) this organization's learners joining the course's weekly leaderboards. */ export async function setAllocationLeaderboard(orgId: string, courseId: string, enabled: boolean): Promise { const { count } = await prisma.organizationCourse.updateMany({ diff --git a/server/tests/api/admin.test.ts b/server/tests/api/admin.test.ts index e73474e6..b0868c8d 100644 --- a/server/tests/api/admin.test.ts +++ b/server/tests/api/admin.test.ts @@ -119,6 +119,31 @@ describe("organizations", () => { expect(bad.status).toBe(422); expect(bad.body.code).toBe("COURSE_NOT_PUBLISHED"); }); + + it("turns integration enrollments off and back on for a single allocation", async () => { + await request(app) + .put(`/api/v1/admin/organizations/${createdOrgId}/allocations`) + .set(admin()) + .send({ isAllowed: true, courses: [{ courseId: fx.course.id, maxEnrollments: 25 }] }); + + const url = `/api/v1/admin/organizations/${createdOrgId}/allocations/${fx.course.id}/allowed`; + const key = { organizationId_courseId: { organizationId: createdOrgId, courseId: fx.course.id } }; + + const off = await request(app).patch(url).set(admin()).send({ enabled: false }); + expect(off.status).toBe(204); + expect(await prisma.organizationCourse.findUnique({ where: key })).toMatchObject({ isAllowed: false, maxEnrollments: 25 }); + + const on = await request(app).patch(url).set(admin()).send({ enabled: true }); + expect(on.status).toBe(204); + expect(await prisma.organizationCourse.findUnique({ where: key })).toMatchObject({ isAllowed: true, maxEnrollments: 25 }); + + // An allocation that does not exist is a 404, not a silent no-op. + const missing = await request(app) + .patch(`/api/v1/admin/organizations/${createdOrgId}/allocations/${org.organization.id}/allowed`) + .set(admin()) + .send({ enabled: true }); + expect(missing.status).toBe(404); + }); }); describe("course requests", () => { diff --git a/server/tests/api/integrations.test.ts b/server/tests/api/integrations.test.ts index 559e627e..03f85066 100644 --- a/server/tests/api/integrations.test.ts +++ b/server/tests/api/integrations.test.ts @@ -48,6 +48,32 @@ describe("/integrations/v1 (public organisation API)", () => { expect(allocations.body.allocations[0]).toMatchObject({ course_id: fx.course.id, max_enrollments: 1, current_enrollments: 0 }); }); + // A catalog that looks complete but is switched off reads as "no courses" to + // the consumer, which is indistinguishable from "nothing allocated". The + // allocations endpoint is what tells the two apart. + it("hides a course from the catalog while it is not allowed, but still reports the allocation", async () => { + const key = { organizationId_courseId: { organizationId: org.organization.id, courseId: fx.course.id } }; + await prisma.organizationCourse.update({ where: key, data: { isAllowed: false } }); + try { + const courses = await request(app).get("/integrations/v1/courses").set(org.headers); + expect(courses.status).toBe(200); + expect(courses.body.courses).toEqual([]); + + const allocations = await request(app).get("/integrations/v1/allocations").set(org.headers); + expect(allocations.body.allocations).toHaveLength(1); + expect(allocations.body.allocations[0]).toMatchObject({ + course_id: fx.course.id, + is_allowed: false, + course_status: "PUBLISHED", + }); + } finally { + await prisma.organizationCourse.update({ where: key, data: { isAllowed: true } }); + } + + const restored = await request(app).get("/integrations/v1/courses").set(org.headers); + expect(restored.body.courses).toHaveLength(1); + }); + it("creates an enrollment once, consumes the slot, then reports it", async () => { const payload = { student_email: `new-${fx.tag}@test.local`, course_id: fx.course.id, external_assignment_id: `assign-${fx.tag}` };