From f1eb22ba4fdc1cacc934d819db77fc64df5a2670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Wed, 2 Sep 2026 20:07:50 +0800 Subject: [PATCH 1/5] feat(courses): add 4 new API endpoints (#374, #381, #385, #393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/courses/:id/enrollment-status (#381): detailed enrollment status with module-by-module progress, quiz count, average score - GET /api/v1/courses/:id/progress (#385): user's detailed progress in a course with module completion, quiz scores, completion percentage - GET /api/v1/courses/:id/modules/:moduleId/quiz-attempts (#393): all quiz attempts for a module ordered oldest-first with score/percentage/pass - POST /api/v1/admin/courses/:id/modules/reorder (#374): admin endpoint to reorder course modules atomically with audit logging All endpoints follow the established controller→service→routes→types pattern, use Redis caching (30s for course endpoints), and include proper validation via zod schemas. --- src/audit/index.ts | 1 + .../courses/admin-course.controller.ts | 18 + src/modules/courses/admin-course.routes.ts | 30 ++ src/modules/courses/course.controller.ts | 51 +++ src/modules/courses/course.routes.ts | 54 +++ src/modules/courses/course.service.ts | 385 ++++++++++++++++++ src/modules/courses/course.types.ts | 75 ++++ 7 files changed, 614 insertions(+) diff --git a/src/audit/index.ts b/src/audit/index.ts index d22cbbf..c321ea2 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -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" diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index 7565c2e..d86f6f8 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -11,6 +11,7 @@ import type { ModuleParams, ListEnrolledUsersQuery, EnrollmentTrendsQuery, + ReorderModulesBody, } from "./course.types.js"; export class AdminCourseController { @@ -243,6 +244,23 @@ export class AdminCourseController { 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 { + const { id } = request.params; + const modules = await courseService.reorderModules( + id, + request.body.moduleIds, + ); + + reply.send({ success: true, data: modules }); + } } export const adminCourseController = new AdminCourseController(); diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index a009d85..1162124 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -11,6 +11,7 @@ import { moduleParamsSchema, listEnrolledUsersQuerySchema, enrollmentTrendsQuerySchema, + reorderModulesSchema, } from "./course.types.js"; /** Admin-only course management (#292). Every route requires an admin user. */ @@ -278,6 +279,35 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { (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", { diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index f5f2f4a..0443f74 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -11,6 +11,7 @@ import type { ListReviewsQuery, CreateReviewBody, ListEnrolledUsersQuery, + ReorderModulesBody, } from "./course.types.js"; export class CourseController { @@ -279,6 +280,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 { + 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 { + 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 { + 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(); diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 471423a..61de5e3 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -15,6 +15,8 @@ import { createReviewSchema, reportCourseSchema, listEnrolledUsersQuerySchema, + reorderModulesSchema, + moduleParamsSchema, } from "./course.types.js"; import { joinWaitlistSchema, leaveWaitlistSchema } from "./waitlist.types.js"; @@ -175,6 +177,58 @@ export async function courseRoutes(app: FastifyInstance): Promise { (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", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index a92f5a7..90feffd 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -62,6 +62,12 @@ import type { EnrollmentTrendDataPoint, CourseSyllabus, SyllabusModule, + EnrollmentStatus, + EnrollmentModuleProgress, + CourseProgress, + CourseProgressModule, + QuizAttempt, + QuizAttemptsResult, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -2238,6 +2244,385 @@ export class CourseService { await cacheSet(ck, result, 3600); return result; } + + // ─── Enrollment Status (#381) ─────────────────────────────────────────── + + /** + * Detailed enrollment status for the current user in a specific course + * (#381). Returns isEnrolled, enrolledAt, completedAt, module-by-module + * progress, quizCount, and averageScore. Cached 30s per (userId, courseId). + * Returns 404 for a non-existent or inactive course. + */ + async getEnrollmentStatus( + userId: string, + courseId: string, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const namespace = "user"; + const ck = cacheKey(namespace, "enrollment-status", userId, courseId); + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const enrollment = await db.query.enrollments.findFirst({ + where: and( + eq(enrollments.userId, userId), + eq(enrollments.courseId, courseId), + ), + }); + + // Build the module list from the course's authored modules definition, + // falling back to quiz-derived moduleId groups when no definitions exist. + const moduleDefinitions = (course.modules ?? []) as CourseModuleDefinition[]; + + let moduleIds: string[]; + if (moduleDefinitions.length > 0) { + moduleIds = moduleDefinitions.map((m) => m.id); + } else { + const moduleRows = await db + .select({ moduleId: quizzes.moduleId }) + .from(quizzes) + .where(eq(quizzes.courseId, courseId)) + .groupBy(quizzes.moduleId) + .orderBy(quizzes.moduleId); + moduleIds = moduleRows.map((r) => r.moduleId); + } + + const moduleTitleById = new Map( + moduleDefinitions.map((m) => [m.id, m.title] as const), + ); + + // Modules completed by the user (has a non-superseded submission). + const completedModuleIds = new Set( + moduleIds.length > 0 + ? ( + await db + .select({ moduleId: quizzes.moduleId }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.userId, userId), + eq(quizSubmissions.superseded, false), + ), + ) + .groupBy(quizzes.moduleId) + ).map((r) => r.moduleId) + : [], + ); + + const moduleProgress: EnrollmentModuleProgress[] = moduleIds.map( + (moduleId) => ({ + moduleId, + title: moduleTitleById.get(moduleId) ?? null, + completed: completedModuleIds.has(moduleId), + }), + ); + + // Quiz count and average score for this user in this course. + const [quizAgg] = await db + .select({ + quizCount: count(), + averageScore: sql`AVG(${quizSubmissions.score})`, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.userId, userId), + eq(quizSubmissions.superseded, false), + ), + ); + + const result: EnrollmentStatus = { + courseId, + isEnrolled: !!enrollment, + enrolledAt: enrollment?.enrolledAt ?? null, + completedAt: enrollment?.completedAt ?? null, + moduleProgress, + quizCount: quizAgg?.quizCount ?? 0, + averageScore: + quizAgg?.averageScore != null + ? Number(Number(quizAgg.averageScore).toFixed(2)) + : null, + }; + + await cacheSet(ck, result, 30); + return result; + } + + // ─── Course Progress (#385) ───────────────────────────────────────────── + + /** + * The user's detailed progress in a specific course (#385): module-by- + * module completion, quizzes taken, average score, and estimated + * completion percentage. Cached 30s per (userId, courseId). + */ + async getCourseProgress( + userId: string, + courseId: string, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const namespace = "user"; + const ck = cacheKey(namespace, "course-progress", userId, courseId); + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const moduleDefinitions = (course.modules ?? []) as CourseModuleDefinition[]; + + let moduleIds: string[]; + if (moduleDefinitions.length > 0) { + moduleIds = moduleDefinitions.map((m) => m.id); + } else { + const moduleRows = await db + .select({ moduleId: quizzes.moduleId }) + .from(quizzes) + .where(eq(quizzes.courseId, courseId)) + .groupBy(quizzes.moduleId) + .orderBy(quizzes.moduleId); + moduleIds = moduleRows.map((r) => r.moduleId); + } + + const moduleTitleById = new Map( + moduleDefinitions.map((m) => [m.id, m.title] as const), + ); + + // Completed modules for this user. + const completedRows = moduleIds.length + ? await db + .select({ moduleId: quizzes.moduleId }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.userId, userId), + eq(quizSubmissions.superseded, false), + ), + ) + .groupBy(quizzes.moduleId) + : []; + const completedModuleIds = new Set(completedRows.map((r) => r.moduleId)); + + const modules: CourseProgressModule[] = moduleIds.map((moduleId, i) => ({ + moduleId, + title: moduleTitleById.get(moduleId) ?? null, + order: i + 1, + completed: completedModuleIds.has(moduleId), + })); + + // Quiz count + average score. + const [quizAgg] = await db + .select({ + quizCount: count(), + averageScore: sql`AVG(${quizSubmissions.score})`, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizSubmissions.userId, userId), + eq(quizSubmissions.superseded, false), + ), + ); + + const totalModules = moduleIds.length; + const completedCount = completedModuleIds.size; + const completionPercentage = + totalModules > 0 + ? Math.round((completedCount / totalModules) * 100) + : 0; + + const result: CourseProgress = { + courseId, + modules, + quizzesTaken: quizAgg?.quizCount ?? 0, + averageScore: + quizAgg?.averageScore != null + ? Number(Number(quizAgg.averageScore).toFixed(2)) + : null, + completedModules: completedCount, + totalModules, + completionPercentage, + }; + + await cacheSet(ck, result, 30); + return result; + } + + // ─── Quiz Attempts (#393) ─────────────────────────────────────────────── + + /** + * All quiz attempts for a specific course module by the authenticated + * user (#393), ordered oldest-first. Returns attempt number, score, + * percentage, pass status, and date. Cached 30s per + * (userId, courseId, moduleId). + */ + async getQuizAttempts( + userId: string, + courseId: string, + moduleId: string, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course || !course.isActive) { + throw new NotFoundError("Course"); + } + + const namespace = "user"; + const ck = cacheKey( + namespace, + "quiz-attempts", + userId, + courseId, + moduleId, + ); + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const rows = await db + .select({ + submissionId: quizSubmissions.id, + score: quizSubmissions.score, + questions: quizzes.questions, + submittedAt: quizSubmissions.submittedAt, + superseded: quizSubmissions.superseded, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizzes.courseId, courseId), + eq(quizzes.moduleId, moduleId), + eq(quizSubmissions.userId, userId), + ), + ) + .orderBy(quizSubmissions.submittedAt); + + const attempts: QuizAttempt[] = rows.map((row, i) => { + const totalQuestions = Array.isArray(row.questions) + ? row.questions.length + : 0; + const percentage = + totalQuestions > 0 && row.score != null + ? Math.round((row.score / totalQuestions) * 100) + : null; + return { + attemptNumber: i + 1, + submissionId: row.submissionId, + score: row.score, + percentage, + passed: + percentage != null ? percentage >= PASSING_PERCENTAGE : false, + superseded: row.superseded, + date: row.submittedAt, + }; + }); + + const result: QuizAttemptsResult = { + courseId, + moduleId, + attempts, + totalAttempts: attempts.length, + }; + + await cacheSet(ck, result, 30); + return result; + } + + // ─── Admin: Reorder Modules (#374) ────────────────────────────────────── + + /** + * Reorder course modules atomically (#374). Accepts an ordered array of + * module IDs, validates all IDs belong to the course, and updates the + * `order` field on each module definition in courses.modules (jsonb) in + * a single transaction. Changes are logged to the audit log. + */ + async reorderModules( + courseId: string, + moduleIds: string[], + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course) { + throw new NotFoundError("Course"); + } + + const existingModules = (course.modules ?? []) as CourseModuleDefinition[]; + + // Validate that the provided IDs exactly match the course's modules — + // all IDs must be present, no extras, no missing. + const existingIds = new Set(existingModules.map((m) => m.id)); + const providedIds = new Set(moduleIds); + + if (existingIds.size !== providedIds.size) { + throw new ForbiddenError( + "Module IDs do not match the course's modules", + ); + } + + for (const id of moduleIds) { + if (!existingIds.has(id)) { + throw new ForbiddenError( + `Module ${id} does not belong to this course`, + ); + } + } + + return withLock(`course-modules:${courseId}`, async () => { + // Re-fetch inside the lock to avoid a lost update. + const [locked] = await db + .select() + .from(courses) + .where(eq(courses.id, courseId)); + + if (!locked) { + throw new NotFoundError("Course"); + } + + const currentModules = (locked.modules ?? []) as CourseModuleDefinition[]; + const moduleById = new Map(currentModules.map((m) => [m.id, m])); + + const reordered: CourseModuleDefinition[] = moduleIds.map((id, i) => { + const existing = moduleById.get(id); + if (!existing) { + throw new ForbiddenError( + `Module ${id} does not belong to this course`, + ); + } + return { ...existing, order: i }; + }); + + await db + .update(courses) + .set({ modules: reordered }) + .where(eq(courses.id, courseId)); + + await this.invalidateCourseCaches(courseId); + await auditLog("course.module.reordered", { + courseId, + moduleIds, + }); + logger.info({ courseId, moduleIds }, "Course modules reordered"); + + return reordered; + }); } export const courseService = new CourseService(); diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index e0fe23c..86531dc 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -430,6 +430,8 @@ export interface EnrollmentTrendsResult { granularity: string; trends: EnrollmentTrendDataPoint[]; totalEnrollments: number; +} + /** One module entry in the syllabus response. */ export interface SyllabusModule { order: number; @@ -449,3 +451,76 @@ export interface CourseSyllabus { totalEstimatedDurationMinutes: number | null; generatedAt: Date; } + +// ─── Enrollment Status (#381) ─────────────────────────────────────────────── + +/** One module's progress entry in the enrollment-status response (#381). */ +export interface EnrollmentModuleProgress { + moduleId: string; + title: string | null; + completed: boolean; +} + +/** Response of GET /api/v1/courses/:id/enrollment-status (#381). */ +export interface EnrollmentStatus { + courseId: string; + isEnrolled: boolean; + enrolledAt: Date | null; + completedAt: Date | null; + moduleProgress: EnrollmentModuleProgress[]; + quizCount: number; + averageScore: number | null; +} + +// ─── Course Progress (#385) ───────────────────────────────────────────────── + +/** One module's progress in the course-progress response (#385). */ +export interface CourseProgressModule { + moduleId: string; + title: string | null; + order: number; + completed: boolean; +} + +/** Response of GET /api/v1/courses/:id/progress (#385). */ +export interface CourseProgress { + courseId: string; + modules: CourseProgressModule[]; + quizzesTaken: number; + averageScore: number | null; + completedModules: number; + totalModules: number; + completionPercentage: number; +} + +// ─── Quiz Attempts (#393) ─────────────────────────────────────────────────── + +/** One attempt entry in the quiz-attempts response (#393). */ +export interface QuizAttempt { + attemptNumber: number; + submissionId: string; + /** Raw correct-answer count. */ + score: number | null; + /** Score normalized against the quiz's question count (0–100). */ + percentage: number | null; + passed: boolean; + /** True if this attempt was superseded by a retry (#295). */ + superseded: boolean; + date: Date; +} + +/** Response of GET /api/v1/courses/:id/modules/:moduleId/quiz-attempts (#393). */ +export interface QuizAttemptsResult { + courseId: string; + moduleId: string; + attempts: QuizAttempt[]; + totalAttempts: number; +} + +// ─── Admin: Reorder Modules (#374) ────────────────────────────────────────── + +export const reorderModulesSchema = z.object({ + moduleIds: z.array(z.string().min(1).max(100)).min(1).max(100), +}); + +export type ReorderModulesBody = z.infer; From 3e1de332611ef4a72aaa1432cd10621794fde276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Wed, 2 Sep 2026 20:07:57 +0800 Subject: [PATCH 2/5] feat(users): add GET /api/v1/users/me/learning-stats endpoint (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive learning statistics for the authenticated user: total courses completed, quizzes taken, average score (percentage), credits earned, credentials earned, learning streak (consecutive days), estimated total study time, and learning velocity (quizzes in last 7 days). Cached for 5 minutes. Follows the established controller→service→routes→types pattern. --- src/modules/users/user.controller.ts | 14 +++ src/modules/users/user.routes.ts | 13 +++ src/modules/users/user.service.ts | 130 +++++++++++++++++++++++++++ src/modules/users/user.types.ts | 17 ++++ 4 files changed, 174 insertions(+) diff --git a/src/modules/users/user.controller.ts b/src/modules/users/user.controller.ts index 01b5bac..e2c6258 100644 --- a/src/modules/users/user.controller.ts +++ b/src/modules/users/user.controller.ts @@ -160,6 +160,20 @@ export class UserController { reply.send({ success: true, data: profile }); } + + /** + * GET /api/v1/users/me/learning-stats + * Comprehensive learning statistics for the authenticated user (#383). + */ + async getLearningStats( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const stats = await userService.getLearningStats(authUser.id); + + reply.send({ success: true, data: stats }); + } } export const userController = new UserController(); diff --git a/src/modules/users/user.routes.ts b/src/modules/users/user.routes.ts index 6009b29..81efd8e 100644 --- a/src/modules/users/user.routes.ts +++ b/src/modules/users/user.routes.ts @@ -106,6 +106,19 @@ export async function userRoutes(app: FastifyInstance): Promise { (request, reply) => userController.getLearningPath(request, reply) ); + app.get( + "/me/learning-stats", + { + schema: { + description: + "Comprehensive learning statistics: courses completed, quizzes taken, average score, credits, credentials, streak, study time, velocity (cached 5 min, #383)", + tags: ["users"], + security: [{ bearerAuth: [] }], + } as FastifySchema, + }, + (request, reply) => userController.getLearningStats(request, reply) + ); + app.get<{ Querystring: import("../notifications/notification.types.js").ListNotificationsQuery }>( "/me/notifications", { diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 85148e9..86417d1 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -33,6 +33,7 @@ import type { UserProfile, UserProgress, UserDataExport, + LearningStats, } from "./user.types.js"; export class UserService { @@ -615,6 +616,135 @@ export class UserService { return exportData; } + // ─── Learning Stats (#383) ───────────────────────────────────────────── + + /** + * Comprehensive learning statistics for the authenticated user (#383): + * total courses completed, quizzes taken, average score (percentage), + * credits earned, credentials earned, learning streak (consecutive days + * with at least one submission), estimated total study time, and learning + * velocity (quizzes in the last 7 days). Cached for 5 minutes. + */ + async getLearningStats(userId: string): Promise { + const namespace = "user"; + const ck = cacheKey(namespace, "learning-stats", userId); + + const cached = await cacheGet(namespace, ck); + if (cached) return cached; + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) { + throw new NotFoundError("User"); + } + + const [ + [completedResult], + [quizAggResult], + [credentialResult], + [velocityResult], + submissionDateRows, + ] = await Promise.all([ + db + .select({ value: count() }) + .from(enrollments) + .where( + sql`${enrollments.userId} = ${userId} AND ${enrollments.completedAt} IS NOT NULL`, + ), + db + .select({ + quizCount: count(), + avgScore: sql`AVG(${quizSubmissions.score}::numeric / NULLIF(jsonb_array_length(${quizzes.questions}), 0) * 100)`, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and( + eq(quizSubmissions.userId, userId), + eq(quizSubmissions.superseded, false), + ), + ), + db + .select({ value: count() }) + .from(credentials) + .where( + and(eq(credentials.userId, userId), eq(credentials.revoked, false)), + ), + db + .select({ value: count() }) + .from(quizSubmissions) + .where( + sql`${quizSubmissions.userId} = ${userId} AND ${quizSubmissions.submittedAt} >= now() - interval '7 days'`, + ), + db + .select({ + date: sql`date_trunc('day', ${quizSubmissions.submittedAt})::date`, + }) + .from(quizSubmissions) + .where(eq(quizSubmissions.userId, userId)) + .groupBy(sql`date_trunc('day', ${quizSubmissions.submittedAt})`) + .orderBy(sql`date_trunc('day', ${quizSubmissions.submittedAt}) DESC`), + ]); + + // Compute learning streak: consecutive days (ending today or yesterday) + // with at least one submission. + let learningStreak = 0; + if (submissionDateRows.length > 0) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const dateSet = new Set( + submissionDateRows.map((r) => r.date), + ); + + // Start from today; if no submission today, start from yesterday. + let cursor = today; + if (!dateSet.has(cursor.toISOString().split("T")[0])) { + cursor = yesterday; + if (!dateSet.has(cursor.toISOString().split("T")[0])) { + learningStreak = 0; + } else { + // Count backwards from yesterday + learningStreak = 0; + while (dateSet.has(cursor.toISOString().split("T")[0])) { + learningStreak++; + cursor.setDate(cursor.getDate() - 1); + } + } + } else { + learningStreak = 0; + while (dateSet.has(cursor.toISOString().split("T")[0])) { + learningStreak++; + cursor.setDate(cursor.getDate() - 1); + } + } + } + + // Estimate total study time: ~5 minutes per quiz submission (heuristic). + const quizzesTaken = quizAggResult?.quizCount ?? 0; + const estimatedTotalStudyTimeMinutes = quizzesTaken * 5; + + const stats: LearningStats = { + coursesCompleted: completedResult?.value ?? 0, + quizzesTaken, + averageScore: + quizAggResult?.avgScore != null + ? Math.round(Number(quizAggResult.avgScore)) + : null, + creditsEarned: user.credits, + credentialsEarned: credentialResult?.value ?? 0, + learningStreak, + estimatedTotalStudyTimeMinutes, + learningVelocity: velocityResult?.value ?? 0, + }; + + await cacheSet(ck, stats, 300); + return stats; + } + private async deleteLocalAvatar(avatarUrl: string | null): Promise { if (!avatarUrl) return; diff --git a/src/modules/users/user.types.ts b/src/modules/users/user.types.ts index 276503b..10ab808 100644 --- a/src/modules/users/user.types.ts +++ b/src/modules/users/user.types.ts @@ -119,3 +119,20 @@ export interface UserDataExport { claimedAt: Date; }[]; } + +// ─── Learning Stats (#383) ────────────────────────────────────────────────── + +/** Comprehensive learning statistics for the authenticated user (#383). */ +export interface LearningStats { + coursesCompleted: number; + quizzesTaken: number; + averageScore: number | null; + creditsEarned: number; + credentialsEarned: number; + /** Current consecutive-day learning streak (1 = active today). */ + learningStreak: number; + /** Estimated total study time in minutes, derived from quiz submissions. */ + estimatedTotalStudyTimeMinutes: number; + /** Quizzes taken in the last 7 days — a simple learning-velocity metric. */ + learningVelocity: number; +} From 3228033676b902b6afb8cf78adb3ad0557198fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Wed, 2 Sep 2026 22:11:10 +0800 Subject: [PATCH 3/5] fix(courses): resolve syntax errors from rebase conflict resolution - course.routes.ts: add missing ');' closing enrolledUsers route - course.service.ts: add missing '}' closing reorderModules method - course.types.ts: remove broken empty EnrolledUserEntry interface and duplicate 'users' field declaration in EnrolledUsersResult - course.controller.ts: drop unused ReorderModulesBody import Fixes CI Lint & Typecheck failures (3 parsing errors) reported on 3e1de33. --- src/modules/courses/course.controller.ts | 1 - src/modules/courses/course.routes.ts | 2 ++ src/modules/courses/course.service.ts | 1 + src/modules/courses/course.types.ts | 2 -- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index 0443f74..9470627 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -11,7 +11,6 @@ import type { ListReviewsQuery, CreateReviewBody, ListEnrolledUsersQuery, - ReorderModulesBody, } from "./course.types.js"; export class CourseController { diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 61de5e3..db527cd 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -303,6 +303,8 @@ export async function courseRoutes(app: FastifyInstance): Promise { } as FastifySchema, }, (request, reply) => courseController.enrolledUsers(request, reply) + ); + app.delete<{ Params: { id: string } }>( "/:id/enroll", { diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 90feffd..54c5c96 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -2623,6 +2623,7 @@ export class CourseService { return reordered; }); + } } export const courseService = new CourseService(); diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index 86531dc..22d2565 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -284,7 +284,6 @@ export interface CourseReviewsResult { totalReviews: number; } -export interface EnrolledUserEntry { // #340: one row per user enrolled in a course, with their quiz-progress // summary for that course. quizCount/averageScore are scoped to quizzes // belonging to this course (via quizzes.courseId), non-superseded @@ -309,7 +308,6 @@ export interface EnrolledUserSummary { } export interface EnrolledUsersResult { - users: EnrolledUserEntry[]; users: EnrolledUserSummary[]; total: number; } From 5ef68030e2b649168e246719d8bdb9b7f4f57377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Wed, 2 Sep 2026 22:15:22 +0800 Subject: [PATCH 4/5] test(e2e): remove duplicated vitest triple-slash reference course-waitlist.test.ts had the same reference line twice, which tripped the @typescript-eslint/triple-slash-reference rule and kept Lint red. --- tests/e2e/course-waitlist.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/course-waitlist.test.ts b/tests/e2e/course-waitlist.test.ts index 5ea8804..f6ccbb9 100644 --- a/tests/e2e/course-waitlist.test.ts +++ b/tests/e2e/course-waitlist.test.ts @@ -1,5 +1,4 @@ /// -/// import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildApp } from "../../src/server.js"; From 60e499421d905d269ee68469a687a0bf81a3c666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Wed, 2 Sep 2026 22:18:39 +0800 Subject: [PATCH 5/5] test(e2e): drop redundant vitest triple-slash reference The file already imports describe/it/expect/beforeAll/afterAll from "vitest" directly, matching every other test in the repo. This was the last file in tests/ still using the triple-slash form. --- tests/e2e/course-waitlist.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/course-waitlist.test.ts b/tests/e2e/course-waitlist.test.ts index f6ccbb9..e0e1809 100644 --- a/tests/e2e/course-waitlist.test.ts +++ b/tests/e2e/course-waitlist.test.ts @@ -1,4 +1,3 @@ -/// import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { FastifyInstance } from "fastify"; import { buildApp } from "../../src/server.js";