diff --git a/src/database/migrations/0020_courses_archived_at.sql b/src/database/migrations/0020_courses_archived_at.sql new file mode 100644 index 0000000..7241b58 --- /dev/null +++ b/src/database/migrations/0020_courses_archived_at.sql @@ -0,0 +1,7 @@ +-- Archive support for courses (#358). Null means the course is not +-- archived. Archiving sets isActive = false and archivedAt = now(), +-- hiding the course from public listings while preserving its data and +-- leaving existing enrollments/credentials untouched so enrolled users +-- can still access it. +ALTER TABLE "courses" + ADD COLUMN IF NOT EXISTS "archived_at" timestamptz; diff --git a/src/database/schema.ts b/src/database/schema.ts index 3ef8ee0..9089809 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -89,6 +89,11 @@ export const courses = pgTable( .notNull() .default([]), isActive: boolean("is_active").notNull().default(true), + // Set by CourseService.archiveCourse (#358). Null means the course is + // not archived. Once set, the course is hidden from public listings + // (isActive is also flipped to false) but its data, modules, and + // enrollments are preserved — enrolled users can still access it. + archivedAt: timestamp("archived_at", { withTimezone: true }), // 0–100 accessibility score for the course's authored content (#326), // recomputed on every create/update. Null until first written. Advisory // only — a low score never blocks saving the course. diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index d8b505a..f3f2e22 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -51,6 +51,22 @@ export class AdminCourseController { reply.send({ success: true, message: "Course deactivated" }); } + /** + * POST /api/admin/courses/:id/archive + * Archive a course (sets isActive = false, archivedAt = now()). Distinct + * from `remove`: archiving records when it happened so it can be told + * apart from other reasons a course might be inactive (#358). + */ + async archive( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + await courseService.archiveCourse(id); + + reply.send({ success: true, message: "Course archived" }); + } + /** * POST /api/admin/courses/:id/publish * Validate required content is present, then publish (isActive = true). diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 8c945e5..dd2f20a 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -134,6 +134,21 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { (request, reply) => adminCourseController.remove(request, reply) ); + app.post<{ Params: { id: string } }>( + "/:id/archive", + { + preHandler: [validate({ params: courseIdParamsSchema })], + schema: { + description: + "Archive a course: hides it from public listings while preserving data and enrolled users' access (admin only)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => adminCourseController.archive(request, reply) + ); + app.post<{ Params: { id: string } }>( "/:id/publish", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index b69c6f3..c72860d 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -1348,6 +1348,29 @@ export class CourseService { logger.info({ courseId }, "Course soft-deleted"); } + /** + * Archive a course (#358): sets isActive = false and archivedAt = now(). + * Unlike deleteCourse, this is a distinct, explicitly-tracked action — + * archivedAt records when and lets callers tell "archived" apart from + * any other reason a course might be inactive. Data, modules, and + * enrollments are preserved; enrolled users keep access. + */ + async archiveCourse(courseId: string): Promise { + const [course] = await db + .update(courses) + .set({ isActive: false, archivedAt: new Date() }) + .where(eq(courses.id, courseId)) + .returning(); + + if (!course) { + throw new NotFoundError("Course"); + } + + await this.invalidateCourseCaches(courseId); + await auditLog("course.archived", { courseId }); + logger.info({ courseId }, "Course archived"); + } + /** * Publish a course (set isActive = true) after validating it has the * content required to go live: a title, description, difficulty, at