From 21c5df3bd8ec066a79daae811074823f8aaa4645 Mon Sep 17 00:00:00 2001 From: codemagician Date: Mon, 31 Aug 2026 17:28:44 +0100 Subject: [PATCH] feat: course prerequisites, announcements, admin dashboard, course import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #354 — GET /api/v1/courses/:id/prerequisites - courses.prerequisites: jsonb string[] column (migration 0020), admin-configurable via existing POST/PUT /admin/courses (self-reference filtered out on update) - CourseService.getCoursePrerequisites(): resolves configured prerequisite courses, annotates each with the caller's completion status (enrollments.completedAt IS NOT NULL), returns an overall met flag. Anonymous callers see every prerequisite as incomplete. #353 — POST/GET/PUT/DELETE /api/v1/admin/announcements + GET /api/v1/announcements - New announcements table (migration 0021): title, message, priority (normal/high/urgent), active, createdAt, expiresAt - New module (src/modules/announcements/): full admin CRUD (authGuard+adminGuard) plus a public GET that filters to active && (no expiry || expiry in the future) #367 — GET /api/v1/admin/dashboard - New module (src/modules/admin/dashboard.*): total users, new users (today/week/month), total enrollments, quiz completion rate (graded / total submissions), total credentials, total rewards claimed (SUM(rewardAmount) where claimed), plus week-over-week trend comparisons for new users and enrollments. Cached 5 minutes via the existing cache/index.ts helpers. Admin only. #366 — POST /api/v1/admin/courses/import - Accepts multipart/form-data with one JSON file part (reuses the already-registered @fastify/multipart), validates against a Zod schema (core course fields + an optional modules array), creates the course via the existing createCourse() path, then creates each module via the existing createModule() so IDs/locking/audit/cache-invalidation all match a manually-created module. Returns { courseId, modulesCreated }. Note: the issue's literal route was /admin/courses/:id/import, but its own scope/acceptance-criteria describe creating a *new* course from the file ("creates the course... returns the created course ID") — a target :id doesn't fit that, so implemented as the collection-level /admin/courses/import instead. Flagged for review below. Also included: a standalone build fix (see PR #444) this branch is based on top of, since course.routes.ts (one of the files #354/#366 touch) had a syntax error blocking the whole build. Verification: npm run build (tsc) is clean. Did not add new automated tests for these four endpoints given time constraints — flagging that honestly rather than claiming coverage that isn't there. Existing courses/admin test suites still reference the same service methods these changes extend (createCourse, createModule, toAdminCourse), so a schema/behavior regression there would likely surface on the existing suite even without new tests, but the new logic itself (prerequisite resolution, announcement active-filtering, dashboard aggregates, import parsing) has no dedicated coverage yet. Closes #354 Closes #353 Closes #367 Closes #366 --- src/audit/index.ts | 9 +- .../migrations/0020_courses_prerequisites.sql | 5 + .../migrations/0021_create_announcements.sql | 13 ++ src/database/schema.ts | 36 +++++ src/modules/admin/dashboard.controller.ts | 16 ++ src/modules/admin/dashboard.routes.ts | 22 +++ src/modules/admin/dashboard.service.ts | 153 ++++++++++++++++++ src/modules/admin/dashboard.types.ts | 31 ++++ .../admin-announcement.routes.ts | 103 ++++++++++++ .../announcements/announcement.controller.ts | 84 ++++++++++ .../announcements/announcement.routes.ts | 16 ++ .../announcements/announcement.service.ts | 121 ++++++++++++++ .../announcements/announcement.types.ts | 60 +++++++ .../courses/admin-course.controller.ts | 47 ++++++ src/modules/courses/admin-course.routes.ts | 14 ++ src/modules/courses/course.controller.ts | 16 ++ src/modules/courses/course.routes.ts | 14 ++ src/modules/courses/course.service.ts | 106 +++++++++++- src/modules/courses/course.types.ts | 58 +++++++ src/routes/v1/index.ts | 6 + 20 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 src/database/migrations/0020_courses_prerequisites.sql create mode 100644 src/database/migrations/0021_create_announcements.sql create mode 100644 src/modules/admin/dashboard.controller.ts create mode 100644 src/modules/admin/dashboard.routes.ts create mode 100644 src/modules/admin/dashboard.service.ts create mode 100644 src/modules/admin/dashboard.types.ts create mode 100644 src/modules/announcements/admin-announcement.routes.ts create mode 100644 src/modules/announcements/announcement.controller.ts create mode 100644 src/modules/announcements/announcement.routes.ts create mode 100644 src/modules/announcements/announcement.service.ts create mode 100644 src/modules/announcements/announcement.types.ts diff --git a/src/audit/index.ts b/src/audit/index.ts index 5124085..642f8e1 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -21,10 +21,14 @@ type AuditEvent = | "course.published" | "course.duplicated" | "course.reviewed" + | "course.imported" | "user.account_deleted" | "course.module.created" | "course.module.updated" - | "course.module.deleted"; + | "course.module.deleted" + | "announcement.created" + | "announcement.updated" + | "announcement.deleted"; interface AuditFields { userId?: string; @@ -46,6 +50,9 @@ interface AuditFields { storedContentHash?: string | null; rating?: number; sourceCourseId?: string; + moduleCount?: number; + announcementId?: string; + priority?: string; } export async function auditLog(event: AuditEvent, fields: AuditFields): Promise { diff --git a/src/database/migrations/0020_courses_prerequisites.sql b/src/database/migrations/0020_courses_prerequisites.sql new file mode 100644 index 0000000..020794b --- /dev/null +++ b/src/database/migrations/0020_courses_prerequisites.sql @@ -0,0 +1,5 @@ +-- Course IDs that should be completed before this one (#354). Purely +-- advisory — never enforced at enrollment time, only surfaced via +-- GET /api/v1/courses/:id/prerequisites. +ALTER TABLE "courses" + ADD COLUMN IF NOT EXISTS "prerequisites" jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/src/database/migrations/0021_create_announcements.sql b/src/database/migrations/0021_create_announcements.sql new file mode 100644 index 0000000..71d7343 --- /dev/null +++ b/src/database/migrations/0021_create_announcements.sql @@ -0,0 +1,13 @@ +-- Platform-wide announcements admins broadcast to all users (#353). +CREATE TABLE IF NOT EXISTS "announcements" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "title" varchar(255) NOT NULL, + "message" text NOT NULL, + "priority" varchar(20) NOT NULL DEFAULT 'normal', + "active" boolean NOT NULL DEFAULT true, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "expires_at" timestamp with time zone +); + +CREATE INDEX IF NOT EXISTS "idx_announcements_active_created" + ON "announcements" ("active", "created_at" DESC); diff --git a/src/database/schema.ts b/src/database/schema.ts index 3ef8ee0..503c63d 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -93,6 +93,13 @@ export const courses = pgTable( // recomputed on every create/update. Null until first written. Advisory // only — a low score never blocks saving the course. accessibilityScore: integer("accessibility_score"), + // Course IDs that should be completed before this one (#354). Purely + // advisory — CourseService.enroll() never enforces this, it's surfaced + // to the client as a warning via getCoursePrerequisites(). + prerequisites: jsonb("prerequisites") + .$type() + .notNull() + .default([]), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -354,6 +361,35 @@ export const notifications = pgTable( ] ); +// ─── Announcements ────────────────────────────────────────────────────────── + +// Platform-wide announcements admins broadcast to all users (#353) — +// maintenance windows, new features, policy changes. `active` is an +// explicit admin-controlled switch independent of `expiresAt`, so an +// announcement can be taken down early without waiting for its expiry. +export const announcements = pgTable( + "announcements", + { + id: uuid("id").primaryKey().defaultRandom(), + title: varchar("title", { length: 255 }).notNull(), + message: text("message").notNull(), + priority: varchar("priority", { length: 20 }).notNull().default("normal"), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + }, + (table) => [ + // Matches the public listing's access pattern (WHERE active = true AND + // (expires_at IS NULL OR expires_at > now()) ORDER BY created_at DESC). + index("idx_announcements_active_created").on( + table.active, + sql`${table.createdAt} DESC`, + ), + ] +); + // ─── Audit Logs ───────────────────────────────────────────────────────────── export const auditLogs = pgTable( "audit_logs", diff --git a/src/modules/admin/dashboard.controller.ts b/src/modules/admin/dashboard.controller.ts new file mode 100644 index 0000000..3d59979 --- /dev/null +++ b/src/modules/admin/dashboard.controller.ts @@ -0,0 +1,16 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { dashboardService } from "./dashboard.service.js"; + +export class DashboardController { + /** + * GET /api/v1/admin/dashboard + * Platform-wide statistics for the admin console (#367). + */ + async stats(_request: FastifyRequest, reply: FastifyReply): Promise { + const stats = await dashboardService.getStats(); + + reply.send({ success: true, data: stats }); + } +} + +export const dashboardController = new DashboardController(); diff --git a/src/modules/admin/dashboard.routes.ts b/src/modules/admin/dashboard.routes.ts new file mode 100644 index 0000000..8d0039c --- /dev/null +++ b/src/modules/admin/dashboard.routes.ts @@ -0,0 +1,22 @@ +import type { FastifyInstance, FastifySchema } from "fastify"; +import { dashboardController } from "./dashboard.controller.js"; +import { authGuard, adminGuard } from "../../middleware/auth.js"; + +/** Admin-only platform dashboard (#367). Every route requires an admin user. */ +export async function dashboardRoutes(app: FastifyInstance): Promise { + app.addHook("onRequest", authGuard); + app.addHook("preHandler", adminGuard); + + app.get( + "/", + { + schema: { + description: + "Platform-wide statistics: users, enrollments, quiz completion rate, credentials, rewards claimed, with week-over-week trends (cached 5 minutes, admin only)", + tags: ["admin", "dashboard"], + security: [{ bearerAuth: [] }], + } as FastifySchema, + }, + (request, reply) => dashboardController.stats(request, reply) + ); +} diff --git a/src/modules/admin/dashboard.service.ts b/src/modules/admin/dashboard.service.ts new file mode 100644 index 0000000..45e7b90 --- /dev/null +++ b/src/modules/admin/dashboard.service.ts @@ -0,0 +1,153 @@ +import { and, count, eq, gte, isNull, lt, sql } from "drizzle-orm"; +import { db } from "../../config/database.js"; +import { + users, + enrollments, + quizSubmissions, + credentials, +} from "../../database/schema.js"; +import { cacheGet, cacheSet, cacheKey } from "../../cache/index.js"; +import type { AdminDashboardStats, TrendMetric } from "./dashboard.types.js"; + +const DASHBOARD_CACHE_TTL_SECONDS = 300; +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; + +function startOfDay(now: Date): Date { + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function startOfMonth(now: Date): Date { + return new Date(now.getFullYear(), now.getMonth(), 1); +} + +function trend(current: number, previous: number): TrendMetric { + return { + current, + previous, + changePercent: + previous === 0 ? null : Number((((current - previous) / previous) * 100).toFixed(2)), + }; +} + +export class DashboardService { + /** + * Platform-wide stats for the admin console (#367): totals, new-user + * counts over rolling windows, quiz completion rate, total credentials + * and rewards claimed, plus week-over-week trend comparisons. Cached for + * 5 minutes since none of these need to be real-time and every query + * here is a full-table aggregate. + */ + async getStats(): Promise { + const namespace = "admin"; + const cacheKeyString = cacheKey(namespace, "dashboard-stats"); + + const cached = await cacheGet(namespace, cacheKeyString); + if (cached) return cached; + + const now = new Date(); + const dayStart = startOfDay(now); + const weekStart = new Date(now.getTime() - WEEK_MS); + const prevWeekStart = new Date(now.getTime() - 2 * WEEK_MS); + const monthStart = startOfMonth(now); + + const [ + [totalUsersResult], + [newTodayResult], + [newThisWeekResult], + [newPrevWeekResult], + [newThisMonthResult], + [totalEnrollmentsResult], + [enrollThisWeekResult], + [enrollPrevWeekResult], + [submissionStats], + [totalCredentialsResult], + [rewardsResult], + ] = await Promise.all([ + db.select({ value: count() }).from(users).where(isNull(users.deletedAt)), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, dayStart))), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, weekStart))), + db + .select({ value: count() }) + .from(users) + .where( + and( + isNull(users.deletedAt), + gte(users.createdAt, prevWeekStart), + lt(users.createdAt, weekStart), + ), + ), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, monthStart))), + db.select({ value: count() }).from(enrollments), + db + .select({ value: count() }) + .from(enrollments) + .where(gte(enrollments.enrolledAt, weekStart)), + db + .select({ value: count() }) + .from(enrollments) + .where( + and( + gte(enrollments.enrolledAt, prevWeekStart), + lt(enrollments.enrolledAt, weekStart), + ), + ), + db + .select({ + total: count(), + graded: sql`COUNT(${quizSubmissions.score})`, + }) + .from(quizSubmissions), + db + .select({ value: count() }) + .from(credentials) + .where(eq(credentials.revoked, false)), + db + .select({ + value: sql`COALESCE(SUM(${quizSubmissions.rewardAmount}), 0)`, + }) + .from(quizSubmissions) + .where(eq(quizSubmissions.rewardClaimed, true)), + ]); + + const totalSubmissions = submissionStats?.total ?? 0; + const gradedSubmissions = Number(submissionStats?.graded ?? 0); + + const stats: AdminDashboardStats = { + totalUsers: totalUsersResult?.value ?? 0, + newUsersToday: newTodayResult?.value ?? 0, + newUsersThisWeek: newThisWeekResult?.value ?? 0, + newUsersThisMonth: newThisMonthResult?.value ?? 0, + totalEnrollments: totalEnrollmentsResult?.value ?? 0, + quizCompletionRate: + totalSubmissions === 0 + ? 0 + : Number(((gradedSubmissions / totalSubmissions) * 100).toFixed(2)), + totalCredentials: totalCredentialsResult?.value ?? 0, + totalRewardsClaimed: Number(rewardsResult?.value ?? 0), + trends: { + newUsers: trend(newThisWeekResult?.value ?? 0, newPrevWeekResult?.value ?? 0), + enrollments: trend( + enrollThisWeekResult?.value ?? 0, + enrollPrevWeekResult?.value ?? 0, + ), + }, + generatedAt: now.toISOString(), + }; + + await cacheSet(cacheKeyString, stats, DASHBOARD_CACHE_TTL_SECONDS); + + return stats; + } +} + +export const dashboardService = new DashboardService(); diff --git a/src/modules/admin/dashboard.types.ts b/src/modules/admin/dashboard.types.ts new file mode 100644 index 0000000..0732064 --- /dev/null +++ b/src/modules/admin/dashboard.types.ts @@ -0,0 +1,31 @@ +// ─── Types ────────────────────────────────────────────────────────────────── + +/** A metric alongside its comparison to the same-length prior period, + * e.g. "this week" vs "the week before". `changePercent` is null when the + * prior period's value was 0 (a percentage change is undefined). */ +export interface TrendMetric { + current: number; + previous: number; + changePercent: number | null; +} + +export interface AdminDashboardStats { + totalUsers: number; + newUsersToday: number; + newUsersThisWeek: number; + newUsersThisMonth: number; + totalEnrollments: number; + /** Percentage (0–100) of quiz submissions that have been graded + * (score IS NOT NULL), rounded to 2dp. */ + quizCompletionRate: number; + totalCredentials: number; + /** Sum of `quizSubmissions.rewardAmount` across all claimed rewards. */ + totalRewardsClaimed: number; + trends: { + /** New user signups: this week vs. the week before. */ + newUsers: TrendMetric; + /** New enrollments: this week vs. the week before. */ + enrollments: TrendMetric; + }; + generatedAt: string; +} diff --git a/src/modules/announcements/admin-announcement.routes.ts b/src/modules/announcements/admin-announcement.routes.ts new file mode 100644 index 0000000..c1af3dd --- /dev/null +++ b/src/modules/announcements/admin-announcement.routes.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance, FastifySchema } from "fastify"; +import { announcementController } from "./announcement.controller.js"; +import { authGuard, adminGuard } from "../../middleware/auth.js"; +import { validate } from "../../middleware/validation.js"; +import { + createAnnouncementSchema, + updateAnnouncementSchema, + announcementIdParamsSchema, + listAnnouncementsAdminQuerySchema, +} from "./announcement.types.js"; + +/** Admin-only announcement management (#353). Every route requires an admin user. */ +export async function adminAnnouncementRoutes(app: FastifyInstance): Promise { + app.addHook("onRequest", authGuard); + app.addHook("preHandler", adminGuard); + + app.get<{ Querystring: import("./announcement.types.js").ListAnnouncementsAdminQuery }>( + "/", + { + preHandler: [validate({ querystring: listAnnouncementsAdminQuerySchema })], + schema: { + description: "List every announcement, paginated (admin only)", + tags: ["admin", "announcements"], + security: [{ bearerAuth: [] }], + querystring: { + type: "object", + properties: { + page: { type: "integer", minimum: 1, default: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + }, + }, + } as FastifySchema, + }, + (request, reply) => announcementController.listAll(request, reply) + ); + + app.post<{ Body: import("./announcement.types.js").CreateAnnouncementBody }>( + "/", + { + preHandler: [validate({ body: createAnnouncementSchema })], + schema: { + description: "Create a platform-wide announcement (admin only)", + tags: ["admin", "announcements"], + security: [{ bearerAuth: [] }], + body: { + type: "object", + required: ["title", "message"], + properties: { + title: { type: "string", minLength: 1, maxLength: 255 }, + message: { type: "string", minLength: 1 }, + priority: { type: "string", enum: ["normal", "high", "urgent"] }, + active: { type: "boolean" }, + expiresAt: { type: "string", format: "date-time" }, + }, + }, + } as FastifySchema, + }, + (request, reply) => announcementController.create(request, reply) + ); + + app.put<{ + Params: { id: string }; + Body: import("./announcement.types.js").UpdateAnnouncementBody; + }>( + "/:id", + { + preHandler: [ + validate({ params: announcementIdParamsSchema, body: updateAnnouncementSchema }), + ], + schema: { + description: "Update an announcement (admin only)", + tags: ["admin", "announcements"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + body: { + type: "object", + properties: { + title: { type: "string", minLength: 1, maxLength: 255 }, + message: { type: "string", minLength: 1 }, + priority: { type: "string", enum: ["normal", "high", "urgent"] }, + active: { type: "boolean" }, + expiresAt: { type: "string", format: "date-time", nullable: true }, + }, + }, + } as FastifySchema, + }, + (request, reply) => announcementController.update(request, reply) + ); + + app.delete<{ Params: { id: string } }>( + "/:id", + { + preHandler: [validate({ params: announcementIdParamsSchema })], + schema: { + description: "Delete an announcement (admin only)", + tags: ["admin", "announcements"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => announcementController.remove(request, reply) + ); +} diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts new file mode 100644 index 0000000..8cfe024 --- /dev/null +++ b/src/modules/announcements/announcement.controller.ts @@ -0,0 +1,84 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import { announcementService } from "./announcement.service.js"; +import type { + AnnouncementIdParams, + CreateAnnouncementBody, + UpdateAnnouncementBody, + ListAnnouncementsAdminQuery, +} from "./announcement.types.js"; + +export class AnnouncementController { + /** + * GET /api/v1/announcements + * Active, unexpired announcements — public, no auth required. + */ + async listActive(_request: FastifyRequest, reply: FastifyReply): Promise { + const items = await announcementService.listActive(); + + reply.send({ success: true, data: items }); + } + + /** + * GET /api/v1/admin/announcements + * Every announcement, paginated (admin only). + */ + async listAll( + request: FastifyRequest<{ Querystring: ListAnnouncementsAdminQuery }>, + reply: FastifyReply + ): Promise { + const page = await announcementService.listAll(request.query); + + reply.send({ + success: true, + data: page.announcements, + pagination: { + page: request.query.page, + limit: request.query.limit, + total: page.total, + }, + }); + } + + /** + * POST /api/v1/admin/announcements + * Create a platform-wide announcement (admin only). + */ + async create( + request: FastifyRequest<{ Body: CreateAnnouncementBody }>, + reply: FastifyReply + ): Promise { + const announcement = await announcementService.create(request.body); + + reply.status(201).send({ success: true, data: announcement }); + } + + /** + * PUT /api/v1/admin/announcements/:id + * Update an announcement (admin only). + */ + async update( + request: FastifyRequest<{ Params: AnnouncementIdParams; Body: UpdateAnnouncementBody }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const announcement = await announcementService.update(id, request.body); + + reply.send({ success: true, data: announcement }); + } + + /** + * DELETE /api/v1/admin/announcements/:id + * Delete an announcement (admin only). + */ + async remove( + request: FastifyRequest<{ Params: AnnouncementIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + await announcementService.remove(id); + + reply.status(204).send(); + } +} + +export const announcementController = new AnnouncementController(); diff --git a/src/modules/announcements/announcement.routes.ts b/src/modules/announcements/announcement.routes.ts new file mode 100644 index 0000000..75362dc --- /dev/null +++ b/src/modules/announcements/announcement.routes.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance, FastifySchema } from "fastify"; +import { announcementController } from "./announcement.controller.js"; + +/** Public announcements feed (#353) — no auth required. */ +export async function announcementRoutes(app: FastifyInstance): Promise { + app.get( + "/", + { + schema: { + description: "List active, unexpired platform announcements", + tags: ["announcements"], + } as FastifySchema, + }, + (request, reply) => announcementController.listActive(request, reply) + ); +} diff --git a/src/modules/announcements/announcement.service.ts b/src/modules/announcements/announcement.service.ts new file mode 100644 index 0000000..82f9354 --- /dev/null +++ b/src/modules/announcements/announcement.service.ts @@ -0,0 +1,121 @@ +import { and, count, desc, eq, gt, or, isNull } from "drizzle-orm"; +import { db } from "../../config/database.js"; +import { announcements } from "../../database/schema.js"; +import { NotFoundError } from "../../utils/errors.js"; +import { auditLog } from "../../audit/index.js"; +import { logger } from "../../utils/logger.js"; +import type { + Announcement, + AnnouncementsAdminPage, + CreateAnnouncementBody, + UpdateAnnouncementBody, + ListAnnouncementsAdminQuery, +} from "./announcement.types.js"; + +export class AnnouncementService { + private toAnnouncement(row: typeof announcements.$inferSelect): Announcement { + return { + id: row.id, + title: row.title, + message: row.message, + priority: row.priority as Announcement["priority"], + active: row.active, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + }; + } + + /** + * Active, unexpired announcements for the public feed (#353) — + * `active = true` and either no expiry or one still in the future. + */ + async listActive(): Promise { + const rows = await db + .select() + .from(announcements) + .where( + and( + eq(announcements.active, true), + or(isNull(announcements.expiresAt), gt(announcements.expiresAt, new Date())), + ), + ) + .orderBy(desc(announcements.createdAt)); + + return rows.map((row) => this.toAnnouncement(row)); + } + + /** Every announcement (active, inactive, and expired), paginated — for + * the admin console, which needs to see and manage the full set. */ + async listAll(query: ListAnnouncementsAdminQuery): Promise { + const offset = (query.page - 1) * query.limit; + + const [[totalResult], rows] = await Promise.all([ + db.select({ value: count() }).from(announcements), + db + .select() + .from(announcements) + .orderBy(desc(announcements.createdAt)) + .limit(query.limit) + .offset(offset), + ]); + + return { + announcements: rows.map((row) => this.toAnnouncement(row)), + total: totalResult?.value ?? 0, + }; + } + + async create(data: CreateAnnouncementBody): Promise { + const [row] = await db + .insert(announcements) + .values({ + title: data.title, + message: data.message, + priority: data.priority, + active: data.active, + expiresAt: data.expiresAt, + }) + .returning(); + + await auditLog("announcement.created", { + announcementId: row.id, + priority: row.priority, + }); + logger.info({ announcementId: row.id }, "Announcement created"); + + return this.toAnnouncement(row); + } + + async update(id: string, data: UpdateAnnouncementBody): Promise { + const [row] = await db + .update(announcements) + .set(data) + .where(eq(announcements.id, id)) + .returning(); + + if (!row) { + throw new NotFoundError("Announcement"); + } + + await auditLog("announcement.updated", { announcementId: id }); + logger.info({ announcementId: id }, "Announcement updated"); + + return this.toAnnouncement(row); + } + + async remove(id: string): Promise { + const [row] = await db + .delete(announcements) + .where(eq(announcements.id, id)) + .returning(); + + if (!row) { + throw new NotFoundError("Announcement"); + } + + await auditLog("announcement.deleted", { announcementId: id }); + logger.info({ announcementId: id }, "Announcement deleted"); + } +} + +export const announcementService = new AnnouncementService(); diff --git a/src/modules/announcements/announcement.types.ts b/src/modules/announcements/announcement.types.ts new file mode 100644 index 0000000..23074e2 --- /dev/null +++ b/src/modules/announcements/announcement.types.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +// ─── Request Schemas ──────────────────────────────────────────────────────── + +export const announcementPrioritySchema = z.enum(["normal", "high", "urgent"]); + +export const createAnnouncementSchema = z.object({ + title: z.string().min(1).max(255), + message: z.string().min(1), + priority: announcementPrioritySchema.default("normal"), + active: z.boolean().default(true), + expiresAt: z.coerce.date().optional(), +}); + +export const updateAnnouncementSchema = z + .object({ + title: z.string().min(1).max(255).optional(), + message: z.string().min(1).optional(), + priority: announcementPrioritySchema.optional(), + active: z.boolean().optional(), + // Explicit null clears an existing expiry; omitted leaves it unchanged. + expiresAt: z.coerce.date().nullable().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided", + }); + +export const announcementIdParamsSchema = z.object({ + id: z.string().uuid("Invalid announcement ID"), +}); + +export const listAnnouncementsAdminQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(50).default(20), +}); + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type AnnouncementPriority = z.infer; +export type CreateAnnouncementBody = z.infer; +export type UpdateAnnouncementBody = z.infer; +export type AnnouncementIdParams = z.infer; +export type ListAnnouncementsAdminQuery = z.infer< + typeof listAnnouncementsAdminQuerySchema +>; + +export interface Announcement { + id: string; + title: string; + message: string; + priority: AnnouncementPriority; + active: boolean; + createdAt: Date; + expiresAt: Date | null; +} + +export interface AnnouncementsAdminPage { + announcements: Announcement[]; + total: number; +} diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index d8b505a..b482cfd 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -1,5 +1,7 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { courseService } from "./course.service.js"; +import { ValidationError } from "../../utils/errors.js"; +import { importCourseSchema } from "./course.types.js"; import type { CourseIdParams, CreateCourseBody, @@ -23,6 +25,51 @@ export class AdminCourseController { reply.status(201).send({ success: true, data: course }); } + /** + * POST /api/v1/admin/courses/import + * Bulk-create a course (and its modules) from an uploaded JSON file (#366). + */ + async import( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + if (!request.isMultipart()) { + throw new ValidationError({ + file: ["Request must be multipart/form-data"], + }); + } + + const file = await request.file(); + if (!file) { + throw new ValidationError({ + file: ["A JSON file is required"], + }); + } + + const buffer = await file.toBuffer(); + let parsed: unknown; + try { + parsed = JSON.parse(buffer.toString("utf-8")); + } catch { + throw new ValidationError({ + file: ["File must contain valid JSON"], + }); + } + + const result = importCourseSchema.safeParse(parsed); + if (!result.success) { + throw new ValidationError({ + file: result.error.issues.map( + (issue) => `${issue.path.join(".")}: ${issue.message}`, + ), + }); + } + + const imported = await courseService.importCourse(result.data); + + reply.status(201).send({ success: true, data: imported }); + } + /** * PUT /api/admin/courses/:id * Update an existing course. diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 8c945e5..12c4b90 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -65,6 +65,20 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { (request, reply) => adminCourseController.create(request, reply) ); + app.post( + "/import", + { + schema: { + description: + "Bulk-create a course (and its modules) from an uploaded JSON file — multipart/form-data with a single file part (admin only) (#366)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + consumes: ["multipart/form-data"], + } as FastifySchema, + }, + (request, reply) => adminCourseController.import(request, reply) + ); + app.put<{ Params: { id: string }; Body: import("./course.types.js").UpdateCourseBody; diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index ddaedca..4ebfeb2 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -156,6 +156,22 @@ export class CourseController { reply.send({ success: true, data: modules }); } + /** + * GET /api/v1/courses/:id/prerequisites + * List a course's configured prerequisite courses, each annotated with + * whether the caller has completed it (#354). + */ + async prerequisites( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const userId = (request as AuthenticatedRequest).authUser?.id ?? null; + const result = await courseService.getCoursePrerequisites(id, userId); + + reply.send({ success: true, data: result }); + } + /** * GET /api/v1/courses/:id/leaderboard * Top performers for a course, ranked by average quiz score (#324). diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 56e8272..47b91a9 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -102,6 +102,20 @@ export async function courseRoutes(app: FastifyInstance): Promise { (request, reply) => courseController.resolveShare(request, reply) ); + app.get<{ Params: { id: string } }>( + "/:id/prerequisites", + { + preHandler: [optionalAuth, validate({ params: courseIdParamsSchema })], + schema: { + description: + "List a course's configured prerequisite courses, with the caller's completion status per prerequisite (#354)", + tags: ["courses"], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => courseController.prerequisites(request, reply) + ); + app.get<{ Params: { id: string } }>( "/:id/leaderboard", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index b69c6f3..b950a82 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -65,6 +65,10 @@ import type { CreateReviewBody, CourseReview, CourseReviewsResult, + CoursePrerequisiteEntry, + CoursePrerequisitesResult, + ImportCourseBody, + ImportCourseResult, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -336,6 +340,73 @@ export class CourseService { }; } + /** + * The prerequisite courses configured for `courseId`, each annotated with + * whether `userId` has completed it (#354). Purely advisory — never + * enforced at enroll() time, just surfaced here so a client can warn the + * user before they start a course they may not be ready for. + */ + async getCoursePrerequisites( + courseId: string, + userId: string | null, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const prerequisiteIds = course.prerequisites ?? []; + if (prerequisiteIds.length === 0) { + return { prerequisites: [], met: true }; + } + + const prereqCourses = await db + .select({ + id: courses.id, + title: courses.title, + difficulty: courses.difficulty, + }) + .from(courses) + .where(inArray(courses.id, prerequisiteIds)); + + let completedIds = new Set(); + if (userId) { + const completedRows = await db + .select({ courseId: enrollments.courseId }) + .from(enrollments) + .where( + and( + eq(enrollments.userId, userId), + inArray(enrollments.courseId, prerequisiteIds), + sql`${enrollments.completedAt} IS NOT NULL`, + ), + ); + completedIds = new Set(completedRows.map((r) => r.courseId)); + } + + // Preserve the configured order rather than the DB's arbitrary IN() + // ordering, and keep prerequisite IDs that reference a + // deleted/deactivated course out of the response entirely. + const byId = new Map(prereqCourses.map((c) => [c.id, c])); + const prerequisites: CoursePrerequisiteEntry[] = prerequisiteIds + .map((id) => byId.get(id)) + .filter((c): c is (typeof prereqCourses)[number] => !!c) + .map((c) => ({ + id: c.id, + title: c.title, + difficulty: c.difficulty, + completed: completedIds.has(c.id), + })); + + return { + prerequisites, + met: prerequisites.every((p) => p.completed), + }; + } + /** * List a course's modules in their original order, annotated with * whether the requesting user has completed each one (#286). Restricted @@ -1232,6 +1303,7 @@ export class CourseService { isActive: row.isActive, modules: (row.modules ?? []) as CourseModuleDefinition[], accessibilityScore: row.accessibilityScore, + prerequisites: row.prerequisites ?? [], createdAt: row.createdAt, }; } @@ -1279,6 +1351,7 @@ export class CourseService { courseModules: data.courseModules, contentHash: data.contentHash, accessibilityScore: accessibility.score, + prerequisites: data.prerequisites ?? [], }) .returning(); @@ -1292,13 +1365,44 @@ export class CourseService { return { ...this.toAdminCourse(course), accessibility }; } + /** + * Bulk course creation from an uploaded JSON file (#366). Creates the + * course exactly as createCourse() does, then creates each entry in + * `modules` as a real course module (via createModule(), so each gets a + * generated ID and the same validation/locking/cache-invalidation/audit + * behavior a manually-created module would). + */ + async importCourse(data: ImportCourseBody): Promise { + const course = await this.createCourse(data); + + for (const module of data.modules) { + await this.createModule(course.id, module); + } + + await auditLog("course.imported", { + courseId: course.id, + moduleCount: data.modules.length, + }); + logger.info( + { courseId: course.id, moduleCount: data.modules.length }, + "Course imported from JSON", + ); + + return { courseId: course.id, modulesCreated: data.modules.length }; + } + async updateCourse( courseId: string, data: UpdateCourseBody, ): Promise { + // A course can't be its own prerequisite. + const sanitized = data.prerequisites + ? { ...data, prerequisites: data.prerequisites.filter((id) => id !== courseId) } + : data; + const [updated] = await db .update(courses) - .set(data) + .set(sanitized) .where(eq(courses.id, courseId)) .returning(); diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 6726729..277232c 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -47,6 +47,10 @@ export const courseModuleSchema = z.object({ estimatedDurationMinutes: z.coerce.number().int().positive().max(1440).optional(), }); +// Course IDs required before this one (#354) — admin-configurable via +// create/update, self-references filtered out in the service layer. +const prerequisitesSchema = z.array(z.string().uuid()).max(20).default([]); + export const createCourseSchema = z.object({ title: z.string().min(1).max(255), description: z.string().min(1), @@ -54,6 +58,7 @@ export const createCourseSchema = z.object({ tags: z.array(z.string().min(1).max(50)).max(20).default([]), courseModules: z.array(courseModuleSchema).max(100).optional(), contentHash: z.string().max(64).optional(), + prerequisites: prerequisitesSchema.optional(), }); export const updateCourseSchema = z @@ -65,6 +70,7 @@ export const updateCourseSchema = z courseModules: z.array(courseModuleSchema).max(100).optional(), contentHash: z.string().max(64).optional(), isActive: z.boolean().optional(), + prerequisites: prerequisitesSchema.optional(), }) .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided", @@ -93,6 +99,39 @@ export const moduleParamsSchema = z.object({ moduleId: z.string().min(1).max(100), }); +// ─── Import Request Schema (#366) ─────────────────────────────────────────── + +// Bulk course creation from an uploaded JSON file — same core shape as +// createCourseSchema, plus an optional `modules` array (admin-defined +// module definitions, distinct from `courseModules`' quiz-linked metadata) +// created alongside the course in one call. +export const importCourseSchema = z.object({ + title: z.string().min(1).max(255), + description: z.string().min(1), + difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"), + tags: z.array(z.string().min(1).max(50)).max(20).default([]), + courseModules: z.array(courseModuleSchema).max(100).optional(), + contentHash: z.string().max(64).optional(), + prerequisites: prerequisitesSchema.optional(), + modules: z + .array( + z.object({ + title: z.string().min(1).max(255), + description: z.string().max(2000).default(""), + order: z.coerce.number().int().min(0).optional(), + }), + ) + .max(100) + .default([]), +}); + +export type ImportCourseBody = z.infer; + +export interface ImportCourseResult { + courseId: string; + modulesCreated: number; +} + // ─── Review Request Schemas ───────────────────────────────────────────────── export const listReviewsQuerySchema = z.object({ @@ -231,9 +270,28 @@ export interface AdminCourse { modules: CourseModuleDefinition[]; /** 0–100 accessibility score for the authored content (#326). */ accessibilityScore: number | null; + /** Course IDs that should be completed before this one (#354). */ + prerequisites: string[]; createdAt: Date; } +/** One row of GET /api/v1/courses/:id/prerequisites (#354). */ +export interface CoursePrerequisiteEntry { + id: string; + title: string; + difficulty: string; + /** True once the requesting user has completed this prerequisite + * (a non-null `enrollments.completedAt`). Always false for an anonymous + * caller. */ + completed: boolean; +} + +export interface CoursePrerequisitesResult { + prerequisites: CoursePrerequisiteEntry[]; + /** True iff every prerequisite is completed (or there are none). */ + met: boolean; +} + /** createCourse / updateCourse responses carry the freshly computed * accessibility report (#326) alongside the course so the admin UI can * surface warnings without a second request. */ diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index b0f043c..da7b5db 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -6,9 +6,12 @@ import { courseRoutes } from "../../modules/courses/course.routes.js"; import { adminCourseRoutes } from "../../modules/courses/admin-course.routes.js"; import { adminUsersRoutes } from "../../modules/admin/admin-users.routes.js"; import { auditRoutes } from "../../modules/admin/audit.routes.js"; +import { dashboardRoutes } from "../../modules/admin/dashboard.routes.js"; import { quizRoutes, quizPublicRoutes } from "../../modules/quizzes/quiz.routes.js"; import { rewardRoutes } from "../../modules/rewards/reward.routes.js"; import { credentialRoutes } from "../../modules/credentials/credential.routes.js"; +import { announcementRoutes } from "../../modules/announcements/announcement.routes.js"; +import { adminAnnouncementRoutes } from "../../modules/announcements/admin-announcement.routes.js"; export async function registerV1Routes(app: FastifyInstance) { await app.register(authRoutes, { prefix: "/auth" }); @@ -17,8 +20,11 @@ export async function registerV1Routes(app: FastifyInstance) { await app.register(adminCourseRoutes, { prefix: "/admin/courses" }); await app.register(adminUsersRoutes, { prefix: "/admin/users" }); await app.register(auditRoutes, { prefix: "/admin/audit-logs" }); + await app.register(dashboardRoutes, { prefix: "/admin/dashboard" }); await app.register(quizPublicRoutes, { prefix: "/quizzes" }); await app.register(quizRoutes, { prefix: "/quizzes" }); await app.register(rewardRoutes, { prefix: "/rewards" }); await app.register(credentialRoutes, { prefix: "/credentials" }); + await app.register(announcementRoutes, { prefix: "/announcements" }); + await app.register(adminAnnouncementRoutes, { prefix: "/admin/announcements" }); }