From 3efe033f06b9972ce9dd1cf1618de8c0f9fc98d2 Mon Sep 17 00:00:00 2001 From: sheryar-ahmed Date: Fri, 6 Nov 2026 23:56:22 +0500 Subject: [PATCH] fix(integrations): make an allocation's assignable flag toggleable per course Pathment's mentor course picker came back empty for the Pathment org while all 22 courses sat in its catalog. The integration itself was healthy: credentials verified, the right org resolved, and GET /integrations/v1/courses answered 200 {"courses":[]}. GET /integrations/v1/allocations showed why -- every row was PUBLISHED, unexpired and had 400 free seats, but carried isAllowed: false, which is the one condition listAllocatedCourses filters on. isAllowed could only be set by the bulk allocation form's single checkbox, which applies to every selected course at once, so one save with it unticked disabled a whole catalog silently. The table still showed those courses "In catalog" with their seats and expiry; the only hint was a quiet "No" in the Allowed column, with no control to change it. The leaderboard flag in the next column already had a per-row toggle. This one did not, so the only way back was re-selecting every course in the bulk form. Add PATCH /organizations/:orgId/allocations/:courseId/allowed and a per-row toggle in the admin table, mirroring the leaderboard control. The write is audited under organization.allocate, because it grants or withdraws access. Rename the column to Assignable and show a destructive "Not assignable" badge when the flag is off, so a disabled allocation reads as a fault rather than as a neutral status. Tests: the integrations suite now asserts that an un-allowed course drops out of /courses while /allocations still reports it, which is exactly what the consumer sees; the admin suite covers the toggle both ways and a 404 for an allocation that does not exist. --- .../admin/organizations/[orgId]/page.tsx | 28 ++++++++++--- .../src/server/actions/admin/organizations.ts | 17 ++++++++ server/src/controllers/admin.ts | 7 ++++ server/src/routes/v1/admin.ts | 5 +++ server/src/services/admin/organizations.ts | 40 +++++++++++++++++++ server/tests/api/admin.test.ts | 25 ++++++++++++ server/tests/api/integrations.test.ts | 26 ++++++++++++ 7 files changed, 142 insertions(+), 6 deletions(-) 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}` };