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
1 change: 1 addition & 0 deletions src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type AuditEvent =
| "course.module.created"
| "course.module.updated"
| "course.module.deleted"
| "course.module.reordered"
| "quiz.feedback.submitted"
| "announcement.created"
| "announcement.updated"
Expand Down
18 changes: 18 additions & 0 deletions src/modules/courses/admin-course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ModuleParams,
ListEnrolledUsersQuery,
EnrollmentTrendsQuery,
ReorderModulesBody,
} from "./course.types.js";

export class AdminCourseController {
Expand Down Expand Up @@ -111,7 +112,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
await courseService.archiveCourse(id);

Check failure on line 115 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'archiveCourse' does not exist on type 'CourseService'.

reply.send({ success: true, message: "Course archived" });
}
Expand All @@ -125,7 +126,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const course = await courseService.publishCourse(id);

Check failure on line 129 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'publishCourse' does not exist on type 'CourseService'.

reply.send({ success: true, data: course });
}
Expand All @@ -139,7 +140,7 @@
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const course = await courseService.duplicateCourse(id);

Check failure on line 143 in src/modules/courses/admin-course.controller.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Property 'duplicateCourse' does not exist on type 'CourseService'. Did you mean 'updateCourse'?

reply.status(201).send({ success: true, data: course });
}
Expand Down Expand Up @@ -243,6 +244,23 @@

reply.send({ success: true, data: result });
}

/**
* POST /api/v1/admin/courses/:id/modules/reorder
* Reorder course modules atomically (#374).
*/
async reorderModules(
request: FastifyRequest<{ Params: CourseIdParams; Body: ReorderModulesBody }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const modules = await courseService.reorderModules(
id,
request.body.moduleIds,
);

reply.send({ success: true, data: modules });
}
}

export const adminCourseController = new AdminCourseController();
30 changes: 30 additions & 0 deletions src/modules/courses/admin-course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
moduleParamsSchema,
listEnrolledUsersQuerySchema,
enrollmentTrendsQuerySchema,
reorderModulesSchema,
} from "./course.types.js";

/** Admin-only course management (#292). Every route requires an admin user. */
Expand Down Expand Up @@ -278,6 +279,35 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise<void> {
(request, reply) => adminCourseController.removeModule(request, reply)
);

app.post<{ Params: { id: string }; Body: import("./course.types.js").ReorderModulesBody }>(
"/:id/modules/reorder",
{
preHandler: [
validate({ params: courseIdParamsSchema, body: reorderModulesSchema }),
],
schema: {
description:
"Reorder course modules atomically — accepts an ordered array of module IDs (admin only, #374)",
tags: ["admin", "courses"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
body: {
type: "object",
required: ["moduleIds"],
properties: {
moduleIds: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 100 },
minItems: 1,
maxItems: 100,
},
},
},
} as FastifySchema,
},
(request, reply) => adminCourseController.reorderModules(request, reply)
);

app.get<{ Params: { id: string } }>(
"/:id/analytics",
{
Expand Down
50 changes: 50 additions & 0 deletions src/modules/courses/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,56 @@ export class CourseController {

reply.send({ success: true, data: syllabus });
}

/**
* GET /api/v1/courses/:id/enrollment-status
* Detailed enrollment status for the authenticated user in a specific
* course (#381).
*/
async enrollmentStatus(
request: FastifyRequest<{ Params: CourseIdParams }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const { authUser } = request as AuthenticatedRequest;
const status = await courseService.getEnrollmentStatus(authUser.id, id);

reply.send({ success: true, data: status });
}

/**
* GET /api/v1/courses/:id/progress
* The user's detailed progress in a specific course (#385).
*/
async progress(
request: FastifyRequest<{ Params: CourseIdParams }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const { authUser } = request as AuthenticatedRequest;
const progress = await courseService.getCourseProgress(authUser.id, id);

reply.send({ success: true, data: progress });
}

/**
* GET /api/v1/courses/:id/modules/:moduleId/quiz-attempts
* All quiz attempts for a course module by the authenticated user (#393).
*/
async quizAttempts(
request: FastifyRequest<{ Params: { id: string; moduleId: string } }>,
reply: FastifyReply
): Promise<void> {
const { id, moduleId } = request.params;
const { authUser } = request as AuthenticatedRequest;
const result = await courseService.getQuizAttempts(
authUser.id,
id,
moduleId,
);

reply.send({ success: true, data: result });
}
}

export const courseController = new CourseController();
56 changes: 56 additions & 0 deletions src/modules/courses/course.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { FastifyInstance, FastifySchema } from "fastify";
import { courseController } from "./course.controller.js";
import { authGuard, adminGuard, optionalAuth } from "../../middleware/auth.js";

Check failure on line 3 in src/modules/courses/course.routes.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Duplicate identifier 'optionalAuth'.

Check failure on line 3 in src/modules/courses/course.routes.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Duplicate identifier 'authGuard'.
import { waitlistController } from "./waitlist.controller.js";
import { authGuard, optionalAuth } from "../../middleware/auth.js";

Check failure on line 5 in src/modules/courses/course.routes.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Duplicate identifier 'authGuard'.
import { validate } from "../../middleware/validation.js";
import {
listCoursesSchema,
Expand All @@ -15,6 +15,8 @@
createReviewSchema,
reportCourseSchema,
listEnrolledUsersQuerySchema,
reorderModulesSchema,
moduleParamsSchema,
} from "./course.types.js";
import { joinWaitlistSchema, leaveWaitlistSchema } from "./waitlist.types.js";

Expand Down Expand Up @@ -175,6 +177,58 @@
(request, reply) => courseController.modules(request, reply)
);

app.get<{ Params: { id: string } }>(
"/:id/enrollment-status",
{
preHandler: [authGuard, validate({ params: courseIdParamsSchema })],
schema: {
description:
"Detailed enrollment status for the authenticated user in a specific course (#381)",
tags: ["courses"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
} as FastifySchema,
},
(request, reply) => courseController.enrollmentStatus(request, reply)
);

app.get<{ Params: { id: string } }>(
"/:id/progress",
{
preHandler: [authGuard, validate({ params: courseIdParamsSchema })],
schema: {
description:
"The authenticated user's detailed progress in a specific course (#385)",
tags: ["courses"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
} as FastifySchema,
},
(request, reply) => courseController.progress(request, reply)
);

app.get<{ Params: { id: string; moduleId: string } }>(
"/:id/modules/:moduleId/quiz-attempts",
{
preHandler: [authGuard, validate({ params: moduleParamsSchema })],
schema: {
description:
"All quiz attempts for a specific course module by the authenticated user, ordered oldest-first (#393)",
tags: ["courses"],
security: [{ bearerAuth: [] }],
params: {
type: "object",
required: ["id", "moduleId"],
properties: {
id: { type: "string", format: "uuid" },
moduleId: { type: "string", minLength: 1, maxLength: 100 },
},
},
} as FastifySchema,
},
(request, reply) => courseController.quizAttempts(request, reply)
);

app.post<{ Params: { id: string }; Querystring: import("./course.types.js").EnrollCourseQuery }>(
"/:id/enroll",
{
Expand Down Expand Up @@ -249,6 +303,8 @@
} as FastifySchema,
},
(request, reply) => courseController.enrolledUsers(request, reply)
);

app.delete<{ Params: { id: string } }>(
"/:id/enroll",
{
Expand Down
Loading
Loading