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
7 changes: 7 additions & 0 deletions src/database/migrations/0020_courses_archived_at.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/modules/courses/admin-course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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).
Expand Down
15 changes: 15 additions & 0 deletions src/modules/courses/admin-course.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise<void> {
(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",
{
Expand Down
23 changes: 23 additions & 0 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down