Skip to content
Merged
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
28 changes: 22 additions & 6 deletions frontend/src/app/(admin)/admin/organizations/[orgId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { OrganizationCourseDrawer } from "@/components/admin/organization-course
import {
createOrganizationCouponPoolAction,
rotateOrganizationCredentialFormAction,
setAllocationAllowedAction,
setAllocationLeaderboardAction,
setOrganizationCouponPoolActiveAction,
} from "@/server/actions/admin/organizations";
Expand Down Expand Up @@ -200,7 +201,7 @@ export default async function AdminOrganizationDetailPage({
<AdminTableHeaderCell>Course</AdminTableHeaderCell>
<AdminTableHeaderCell align="right">Used / max</AdminTableHeaderCell>
<AdminTableHeaderCell>Expires</AdminTableHeaderCell>
<AdminTableHeaderCell>Allowed</AdminTableHeaderCell>
<AdminTableHeaderCell>Assignable</AdminTableHeaderCell>
<AdminTableHeaderCell>Leaderboards</AdminTableHeaderCell>
</tr>
</AdminTableHead>
Expand Down Expand Up @@ -230,11 +231,26 @@ export default async function AdminOrganizationDetailPage({
{row.expiresAt ? new Date(row.expiresAt).toLocaleDateString() : "—"}
</AdminTableCell>
<AdminTableCell>
{row.isAllowed ? (
<Badge variant="secondary">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
<form action={setAllocationAllowedAction} className="flex items-center gap-2">
<input type="hidden" name="organizationId" value={org.id} />
<input type="hidden" name="courseId" value={row.course.id} />
<input type="hidden" name="enabled" value={row.isAllowed ? "false" : "true"} />
{row.isAllowed ? (
<Badge variant="secondary">Yes</Badge>
) : (
<Badge variant="destructive" title="The integration cannot see this course, so it cannot be assigned">
Not assignable
</Badge>
)}
<Button
type="submit"
size="sm"
variant="ghost"
aria-label={`${row.isAllowed ? "Turn off" : "Turn on"} integration enrollments for ${row.course.title}`}
>
{row.isAllowed ? "Turn off" : "Turn on"}
</Button>
</form>
</AdminTableCell>
<AdminTableCell>
<form action={setAllocationLeaderboardAction} className="flex items-center gap-2">
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/server/actions/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const organizationId = String(formData.get("organizationId") ?? "");
Expand Down
7 changes: 7 additions & 0 deletions server/src/controllers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
5 changes: 5 additions & 0 deletions server/src/routes/v1/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
40 changes: 40 additions & 0 deletions server/src/services/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const { count } = await prisma.organizationCourse.updateMany({
Expand Down
25 changes: 25 additions & 0 deletions server/tests/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
26 changes: 26 additions & 0 deletions server/tests/api/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` };

Expand Down
Loading