diff --git a/src/database/migrations/0021_course_reports.sql b/src/database/migrations/0021_course_reports.sql new file mode 100644 index 0000000..55e2843 --- /dev/null +++ b/src/database/migrations/0021_course_reports.sql @@ -0,0 +1,21 @@ +-- User-submitted course reports for community moderation (inappropriate +-- content, outdated material, errors, etc). One report per user per course; +-- admins triage via `status`. +CREATE TABLE IF NOT EXISTS "course_reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "course_id" uuid NOT NULL REFERENCES "courses"("id") ON DELETE CASCADE, + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "reason" varchar(20) NOT NULL, + "description" text, + "status" varchar(20) NOT NULL DEFAULT 'pending', + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "chk_course_reports_reason" CHECK ("reason" IN ('inappropriate', 'outdated', 'error', 'other')), + CONSTRAINT "chk_course_reports_status" CHECK ("status" IN ('pending', 'reviewed', 'dismissed')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_course_reports_user_course" + ON "course_reports" ("user_id", "course_id"); +CREATE INDEX IF NOT EXISTS "idx_course_reports_course_id" + ON "course_reports" ("course_id"); +CREATE INDEX IF NOT EXISTS "idx_course_reports_status" + ON "course_reports" ("status"); diff --git a/src/database/migrations/0022_sessions.sql b/src/database/migrations/0022_sessions.sql new file mode 100644 index 0000000..3ea4ad5 --- /dev/null +++ b/src/database/migrations/0022_sessions.sql @@ -0,0 +1,19 @@ +-- Authenticated-session tracking: one row per distinct JWT (keyed by its +-- jti), upserted on every authGuard-protected request so `last_active` +-- stays current. Lets a user see where they're logged in and revoke a +-- session they don't recognize. +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "token_id" varchar(64) NOT NULL, + "device_info" text, + "ip_address" varchar(45), + "last_active" timestamp with time zone NOT NULL DEFAULT now(), + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "idx_sessions_token_id" + ON "sessions" ("token_id"); +CREATE INDEX IF NOT EXISTS "idx_sessions_user_revoked" + ON "sessions" ("user_id", "revoked_at"); diff --git a/src/database/schema.ts b/src/database/schema.ts index b42c246..2d71167 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -375,32 +375,71 @@ export const webhookAttempts = pgTable( ] ); -// ─── Announcements ────────────────────────────────────────────────────────── +// ─── Course Reports ───────────────────────────────────────────────────────── -// 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", +export const courseReports = pgTable( + "course_reports", { 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), + courseId: uuid("course_id") + .notNull() + .references(() => courses.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + reason: varchar("reason", { length: 20 }).notNull(), + description: text("description"), + status: varchar("status", { length: 20 }).notNull().default("pending"), 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`, + uniqueIndex("idx_course_reports_user_course").on( + table.userId, + table.courseId + ), + index("idx_course_reports_course_id").on(table.courseId), + index("idx_course_reports_status").on(table.status), + check( + "chk_course_reports_reason", + sql`${table.reason} IN ('inappropriate', 'outdated', 'error', 'other')` ), + check( + "chk_course_reports_status", + sql`${table.status} IN ('pending', 'reviewed', 'dismissed')` + ), + ] +); + +// ─── Sessions ─────────────────────────────────────────────────────────────── + +export const sessions = pgTable( + "sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // The JWT's `jti` claim. Unique so authGuard can upsert the same row on + // every request from the same token instead of inserting a new one. + tokenId: varchar("token_id", { length: 64 }).notNull(), + deviceInfo: text("device_info"), + ipAddress: varchar("ip_address", { length: 45 }), + lastActive: timestamp("last_active", { withTimezone: true }) + .notNull() + .defaultNow(), + // Set by SessionService.revokeSession. The session's jti is also added + // to the JWT denylist at the same time, so a revoked session's token + // stops working immediately rather than only once this row is checked. + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("idx_sessions_token_id").on(table.tokenId), + index("idx_sessions_user_revoked").on(table.userId, table.revokedAt), ] ); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 07661c4..7333721 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -5,6 +5,7 @@ import { users } from "../database/schema.js"; import { eq } from "drizzle-orm"; import { logger } from "../utils/logger.js"; import { redis } from "../config/redis.js"; +import { sessionService } from "../modules/auth/session.service.js"; const JWT_DENYLIST_PREFIX = "jwt:revoked:"; @@ -87,6 +88,11 @@ export async function authGuard( id: user.id, stellarAddress: user.stellarAddress, }; + + if (decoded.jti) { + const deviceInfo = request.headers["user-agent"] ?? null; + await sessionService.track(user.id, decoded.jti, deviceInfo, request.ip ?? null); + } } catch (err) { if (err instanceof UnauthorizedError) throw err; throw new UnauthorizedError("Invalid or expired token"); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index df6bb2a..3b48b6a 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -7,12 +7,15 @@ import { revokeRefreshToken, } from "./refresh-token.service.js"; import { revokeToken } from "../../middleware/auth.js"; +import type { AuthenticatedRequest } from "../../middleware/auth.js"; +import { sessionService } from "./session.service.js"; import { logger } from "../../utils/logger.js"; import type { ChallengeBody, VerifyBody, RefreshBody, LogoutBody, + SessionIdParams, } from "./auth.types.js"; const JWT_TTL_SECONDS = 24 * 60 * 60; // must match the expiresIn below @@ -145,6 +148,36 @@ export class AuthController { reply.send({ success: true, data: { message: "Logged out successfully" } }); } + + /** + * GET /api/v1/auth/sessions + * List the caller's active sessions with device info and last activity. + */ + async listSessions(request: FastifyRequest, reply: FastifyReply): Promise { + const { authUser } = request as AuthenticatedRequest; + const decoded = request.user as { jti?: string }; + + const sessions = await sessionService.listSessions(authUser.id, decoded?.jti); + + reply.send({ success: true, data: sessions }); + } + + /** + * DELETE /api/v1/auth/sessions/:sessionId + * Revoke one of the caller's sessions. Its token is blacklisted + * immediately, on top of the session row being marked revoked. + */ + async revokeSession( + request: FastifyRequest<{ Params: SessionIdParams }>, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const { sessionId } = request.params; + + await sessionService.revokeSession(authUser.id, sessionId); + + reply.send({ success: true, message: "Session revoked" }); + } } export const authController = new AuthController(); diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 9f7a364..e0f751e 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -8,6 +8,7 @@ import { verifySchema, refreshSchema, logoutSchema, + sessionIdParamsSchema, } from "./auth.types.js"; export async function authRoutes(app: FastifyInstance): Promise { @@ -118,4 +119,37 @@ export async function authRoutes(app: FastifyInstance): Promise { }, (request, reply) => authController.logout(request, reply) ); + + app.get( + "/sessions", + { + preHandler: [authGuard], + schema: { + description: + "List the caller's active sessions, with device info and last activity", + tags: ["auth"], + security: [{ bearerAuth: [] }], + } as FastifySchema, + }, + (request, reply) => authController.listSessions(request, reply) + ); + + app.delete<{ Params: import("./auth.types.js").SessionIdParams }>( + "/sessions/:sessionId", + { + preHandler: [authGuard, validate({ params: sessionIdParamsSchema })], + schema: { + description: + "Revoke one of the caller's sessions — its token is blacklisted immediately", + tags: ["auth"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + required: ["sessionId"], + properties: { sessionId: { type: "string", format: "uuid" } }, + }, + } as FastifySchema, + }, + (request, reply) => authController.revokeSession(request, reply) + ); } diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index b69fcc3..6905d4d 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -40,12 +40,17 @@ export const logoutSchema = z }) .optional(); +export const sessionIdParamsSchema = z.object({ + sessionId: z.string().uuid("Invalid session ID"), +}); + // ─── Types ────────────────────────────────────────────────────────────────── export type ChallengeBody = z.infer; export type VerifyBody = z.infer; export type RefreshBody = z.infer; export type LogoutBody = z.infer; +export type SessionIdParams = z.infer; export interface ChallengeResponse { challenge: string; diff --git a/src/modules/auth/session.service.ts b/src/modules/auth/session.service.ts new file mode 100644 index 0000000..6288a94 --- /dev/null +++ b/src/modules/auth/session.service.ts @@ -0,0 +1,93 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { db } from "../../config/database.js"; +import { sessions } from "../../database/schema.js"; +import { NotFoundError } from "../../utils/errors.js"; +import { revokeToken } from "../../middleware/auth.js"; +import { logger } from "../../utils/logger.js"; + +// Must cover the longest a session's JWT can still be valid, so a revoked +// session's token is blacklisted for at least as long as it could have +// otherwise been presented. Matches auth.controller.ts's ACCESS_TOKEN_EXPIRES_IN. +const JWT_TTL_SECONDS = 24 * 60 * 60; + +export interface SessionSummary { + id: string; + deviceInfo: string | null; + ipAddress: string | null; + lastActive: Date; + createdAt: Date; + /** True when this row corresponds to the token used for the current request. */ + current: boolean; +} + +export class SessionService { + /** + * Upserts the session row for the given token (jti). Called from + * authGuard on every authenticated request so `lastActive` stays current. + * Best-effort — a tracking failure must not fail the request itself. + */ + async track( + userId: string, + tokenId: string, + deviceInfo: string | null, + ipAddress: string | null, + ): Promise { + try { + await db + .insert(sessions) + .values({ userId, tokenId, deviceInfo, ipAddress }) + .onConflictDoUpdate({ + target: sessions.tokenId, + set: { lastActive: new Date(), deviceInfo, ipAddress }, + }); + } catch (err) { + logger.warn({ err, userId }, "Failed to track session"); + } + } + + /** Lists the caller's active (non-revoked) sessions, most recently active first. */ + async listSessions(userId: string, currentTokenId?: string): Promise { + const rows = await db + .select() + .from(sessions) + .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt))) + .orderBy(desc(sessions.lastActive)); + + return rows.map((row) => ({ + id: row.id, + deviceInfo: row.deviceInfo, + ipAddress: row.ipAddress, + lastActive: row.lastActive, + createdAt: row.createdAt, + current: row.tokenId === currentTokenId, + })); + } + + /** + * Revoke a single session owned by `userId`. Marks the row revoked and + * blacklists its token immediately (via the same Redis denylist authGuard + * checks), so the session cannot be used again even though its JWT + * hasn't naturally expired yet. + */ + async revokeSession(userId: string, sessionId: string): Promise { + const [session] = await db + .select() + .from(sessions) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))); + + if (!session || session.revokedAt) { + throw new NotFoundError("Session"); + } + + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + + await revokeToken(session.tokenId, JWT_TTL_SECONDS); + + logger.info({ userId, sessionId }, "Session revoked"); + } +} + +export const sessionService = new SessionService(); diff --git a/src/modules/courses/admin-course.controller.ts b/src/modules/courses/admin-course.controller.ts index 52dd850..0d895bb 100644 --- a/src/modules/courses/admin-course.controller.ts +++ b/src/modules/courses/admin-course.controller.ts @@ -185,6 +185,22 @@ export class AdminCourseController { reply.send({ success: true, message: "Module deleted" }); } + /** + * GET /api/v1/admin/courses/:id/analytics + * Detailed analytics: enrollment trends, completion rate, average quiz + * score, average time-to-complete, and modules learners struggle with + * most (cached 1 hour). + */ + async analytics( + request: FastifyRequest<{ Params: CourseIdParams }>, + reply: FastifyReply + ): Promise { + const { id } = request.params; + const analytics = await courseService.getCourseAnalytics(id); + + reply.send({ success: true, data: analytics }); + } + /** * GET /api/admin/courses/:id/enrolled-users * Paginated list of users enrolled in a course, with progress (#340). diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 9d14796..83dd31f 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -277,6 +277,21 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { (request, reply) => adminCourseController.removeModule(request, reply) ); + app.get<{ Params: { id: string } }>( + "/:id/analytics", + { + preHandler: [validate({ params: courseIdParamsSchema })], + schema: { + description: + "Detailed course analytics: enrollment trends (daily/weekly), completion rate, average time-to-complete, average quiz score, and modules with the lowest average score (admin only, cached 1 hour)", + tags: ["admin", "courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + } as FastifySchema, + }, + (request, reply) => adminCourseController.analytics(request, reply) + ); + app.get<{ Params: { id: string }; Querystring: import("./course.types.js").ListEnrolledUsersQuery; diff --git a/src/modules/courses/course.controller.ts b/src/modules/courses/course.controller.ts index 5fa5fbf..5031f67 100644 --- a/src/modules/courses/course.controller.ts +++ b/src/modules/courses/course.controller.ts @@ -5,6 +5,7 @@ import type { ListCoursesQuery, CourseIdParams, PopularCoursesQuery, + ReportCourseBody, EnrollCourseQuery, ShareCodeParams, ListReviewsQuery, @@ -153,33 +154,18 @@ export class CourseController { } /** - * GET /api/v1/courses/:id/prerequisites - * List a course's configured prerequisite courses, each annotated with - * whether the caller has completed it (#354). + * POST /api/v1/courses/:id/report + * Report a course for inappropriate content, errors, or other issues. */ - 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). - */ - async leaderboard( - request: FastifyRequest<{ Params: CourseIdParams }>, + async report( + request: FastifyRequest<{ Params: CourseIdParams; Body: ReportCourseBody }>, reply: FastifyReply ): Promise { const { id } = request.params; - const leaderboard = await courseService.getLeaderboard(id); + const { authUser } = request as AuthenticatedRequest; + const report = await courseService.reportCourse(authUser.id, id, request.body); - reply.send({ success: true, data: leaderboard }); + reply.status(201).send({ success: true, data: report }); } /** diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 368b0ec..87f582a 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -13,6 +13,7 @@ import { shareCodeParamsSchema, listReviewsQuerySchema, createReviewSchema, + reportCourseSchema, listEnrolledUsersQuerySchema, } from "./course.types.js"; import { joinWaitlistSchema, leaveWaitlistSchema } from "./waitlist.types.js"; @@ -326,6 +327,32 @@ export async function courseRoutes(app: FastifyInstance): Promise { (request, reply) => courseController.share(request, reply) ); + app.post<{ Params: { id: string }; Body: import("./course.types.js").ReportCourseBody }>( + "/:id/report", + { + preHandler: [ + authGuard, + validate({ params: courseIdParamsSchema, body: reportCourseSchema }), + ], + schema: { + description: + "Report a course for inappropriate content, outdated material, errors, or other issues (one report per user per course)", + tags: ["courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + body: { + type: "object", + required: ["reason"], + properties: { + reason: { type: "string", enum: ["inappropriate", "outdated", "error", "other"] }, + description: { type: "string", maxLength: 2000 }, + }, + }, + } as FastifySchema, + }, + (request, reply) => courseController.report(request, reply) + ); + // ─── Waitlist Endpoints ────────────────────────────────────────────────── app.post<{ Params: { id: string }; Body: import("./waitlist.types.js").JoinWaitlistBody }>( diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index b01cf29..49805b8 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -7,6 +7,8 @@ import { quizzes, quizSubmissions, users, + courseReports, + notifications, type CourseModuleDefinition, } from "../../database/schema.js"; import { config } from "../../config/index.js"; @@ -14,6 +16,7 @@ import { NotFoundError, ConflictError, ForbiddenError } from "../../utils/errors import { withLock } from "../../utils/lock.js"; import { logger } from "../../utils/logger.js"; import { getOnChainContentHash } from "../../stellar/progress-tracker.js"; +import { PASSING_PERCENTAGE } from "../quizzes/quiz.types.js"; import { auditLog } from "../../audit/index.js"; import { dispatchWebhook } from "../../services/webhook-dispatcher.js"; import { waitlistService } from "./waitlist.service.js"; @@ -49,6 +52,11 @@ import type { ImportCourseResult, ListEnrolledUsersQuery, EnrolledUsersResult, + ReportCourseBody, + CourseReportResult, + CourseAnalytics, + EnrollmentTrendPoint, + ModuleDifficulty, } from "./course.types.js"; const POPULAR_COURSES_TTL_SECONDS = 300; @@ -1795,6 +1803,222 @@ export class CourseService { await auditLog("course.module.deleted", { courseId, moduleId }); logger.info({ courseId, moduleId }, "Course module deleted"); } + + /** + * Detailed analytics for a course creator: enrollment trends, completion + * rate, average quiz score, average time-to-complete, and which modules + * learners struggle with most (lowest average quiz score). Cached for 1 + * hour — this aggregates across every enrollment/submission for the + * course, too expensive to recompute on every dashboard load. + */ + async getCourseAnalytics(courseId: string): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course) { + throw new NotFoundError("Course"); + } + + const namespace = "courses"; + const cacheKeyString = cacheKey(namespace, "analytics", courseId); + const cached = await cacheGet(namespace, cacheKeyString); + if (cached) return cached; + + const [dailyRows, weeklyRows, [totals], moduleRows] = await Promise.all([ + db + .select({ + date: sql`date_trunc('day', ${enrollments.enrolledAt})::date`, + count: count(), + }) + .from(enrollments) + .where( + and( + eq(enrollments.courseId, courseId), + sql`${enrollments.enrolledAt} >= now() - interval '30 days'`, + ), + ) + .groupBy(sql`date_trunc('day', ${enrollments.enrolledAt})`) + .orderBy(sql`date_trunc('day', ${enrollments.enrolledAt})`), + + db + .select({ + date: sql`date_trunc('week', ${enrollments.enrolledAt})::date`, + count: count(), + }) + .from(enrollments) + .where( + and( + eq(enrollments.courseId, courseId), + sql`${enrollments.enrolledAt} >= now() - interval '12 weeks'`, + ), + ) + .groupBy(sql`date_trunc('week', ${enrollments.enrolledAt})`) + .orderBy(sql`date_trunc('week', ${enrollments.enrolledAt})`), + + db + .select({ + totalEnrollments: count(), + completed: sql`COUNT(*) FILTER (WHERE ${enrollments.completedAt} IS NOT NULL)`.mapWith(Number), + avgCompletionHours: sql`AVG(EXTRACT(EPOCH FROM (${enrollments.completedAt} - ${enrollments.enrolledAt})) / 3600.0) FILTER (WHERE ${enrollments.completedAt} IS NOT NULL)`, + avgQuizScorePercent: sql`( + SELECT AVG(${quizSubmissions.score}::numeric / NULLIF(jsonb_array_length(${quizzes.questions}), 0) * 100) + FROM ${quizSubmissions} + INNER JOIN ${quizzes} ON ${quizzes.id} = ${quizSubmissions.quizId} + WHERE ${quizzes.courseId} = ${courseId} AND ${quizSubmissions.superseded} = false + )`, + }) + .from(enrollments) + .where(eq(enrollments.courseId, courseId)), + + db + .select({ + moduleId: quizzes.moduleId, + averageScorePercent: sql`AVG(${quizSubmissions.score}::numeric / NULLIF(jsonb_array_length(${quizzes.questions}), 0) * 100)`, + submissionCount: count(), + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .where( + and(eq(quizzes.courseId, courseId), eq(quizSubmissions.superseded, false)), + ) + .groupBy(quizzes.moduleId), + ]); + + const moduleDefinitions = (course.modules ?? []) as CourseModuleDefinition[]; + const moduleTitleById = new Map(moduleDefinitions.map((m) => [m.id, m.title])); + + const moduleDifficulty: ModuleDifficulty[] = moduleRows + .map((row) => { + const averageScore = + row.averageScorePercent !== null ? Math.round(Number(row.averageScorePercent)) : null; + return { + moduleId: row.moduleId, + title: moduleTitleById.get(row.moduleId) ?? null, + averageScore, + submissionCount: row.submissionCount, + difficult: averageScore !== null && averageScore < PASSING_PERCENTAGE, + }; + }) + .sort((a, b) => (a.averageScore ?? 0) - (b.averageScore ?? 0)); + + const toTrend = (rows: { date: string; count: number }[]): EnrollmentTrendPoint[] => + rows.map((row) => ({ date: row.date, count: row.count })); + + const totalEnrollments = totals?.totalEnrollments ?? 0; + const completed = totals?.completed ?? 0; + + const analytics: CourseAnalytics = { + courseId, + totalEnrollments, + completionRate: + totalEnrollments > 0 ? Math.round((completed / totalEnrollments) * 100) : 0, + averageTimeToCompleteHours: + totals?.avgCompletionHours !== null && totals?.avgCompletionHours !== undefined + ? Math.round(Number(totals.avgCompletionHours) * 10) / 10 + : null, + averageQuizScore: + totals?.avgQuizScorePercent !== null && totals?.avgQuizScorePercent !== undefined + ? Math.round(Number(totals.avgQuizScorePercent)) + : null, + enrollmentTrends: { + daily: toTrend(dailyRows), + weekly: toTrend(weeklyRows), + }, + moduleDifficulty, + generatedAt: new Date(), + }; + + await cacheSet(cacheKeyString, analytics, 3600); + + return analytics; + } + + /** + * Report a course for inappropriate content, errors, or other issues. + * One report per user per course — a repeat report from the same user + * for the same course is rejected rather than silently upserted, so the + * report count admins see stays a genuine distinct-reporter count. + */ + async reportCourse( + userId: string, + courseId: string, + body: ReportCourseBody, + ): Promise { + const course = await db.query.courses.findFirst({ + where: eq(courses.id, courseId), + }); + if (!course) { + throw new NotFoundError("Course"); + } + + const existing = await db.query.courseReports.findFirst({ + where: and( + eq(courseReports.courseId, courseId), + eq(courseReports.userId, userId), + ), + }); + if (existing) { + throw new ConflictError("You have already reported this course"); + } + + const [report] = await db + .insert(courseReports) + .values({ + courseId, + userId, + reason: body.reason, + description: body.description, + }) + .returning(); + + await this.notifyAdminsOfReport(course.title, courseId, report.id, body.reason); + + await auditLog("course.reported", { + courseId, + userId, + reportId: report.id, + reason: body.reason, + }); + logger.info({ courseId, userId, reportId: report.id }, "Course reported"); + + return { + id: report.id, + courseId: report.courseId, + reason: report.reason, + status: report.status, + createdAt: report.createdAt, + }; + } + + /** + * Notifies every admin (in-app, via the notifications table) that a new + * course report came in. Best-effort — a notification-insert failure + * must not fail the report submission itself. + */ + private async notifyAdminsOfReport( + courseTitle: string, + courseId: string, + reportId: string, + reason: string, + ): Promise { + try { + const admins = await db.query.users.findMany({ + where: eq(users.isAdmin, true), + }); + if (admins.length === 0) return; + + await db.insert(notifications).values( + admins.map((admin) => ({ + userId: admin.id, + type: "course_report", + title: "New course report", + message: `"${courseTitle}" was reported for ${reason} (report ${reportId}).`, + })), + ); + } catch (err) { + logger.warn({ err, courseId, reportId }, "Failed to notify admins of course report"); + } + } } export const courseService = new CourseService(); diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index b286e71..a49e0b2 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -147,6 +147,17 @@ export const listEnrolledUsersQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), }); +// ─── Report Request Schema ─────────────────────────────────────────────────── + +export const reportCourseSchema = z.object({ + reason: z.enum(["inappropriate", "outdated", "error", "other"]), + description: z + .string() + .max(2000) + .optional() + .transform((v) => (v ? sanitizeText(v) : v)), +}); + export const createReviewSchema = z.object({ rating: z.coerce.number().int().min(1).max(5), reviewText: z @@ -172,6 +183,7 @@ export type ModuleParams = z.infer; export type ListReviewsQuery = z.infer; export type ListEnrolledUsersQuery = z.infer; export type CreateReviewBody = z.infer; +export type ReportCourseBody = z.infer; export type ListEnrolledUsersQuery = z.infer; export interface CourseSummary { @@ -350,3 +362,50 @@ export interface AdminCourseWithAccessibility extends AdminCourse { } export type CourseModuleMetadata = z.infer; + +/** One bucket of GET /api/v1/admin/courses/:id/analytics's enrollment trend. */ +export interface EnrollmentTrendPoint { + /** ISO date (YYYY-MM-DD) — the start of the day/week bucket. */ + date: string; + count: number; +} + +/** Per-module quiz performance, used to flag modules learners struggle with + * most (lowest average score) — GET /api/v1/admin/courses/:id/analytics. */ +export interface ModuleDifficulty { + moduleId: string; + title: string | null; + averageScore: number | null; + submissionCount: number; + /** True when averageScore is below the quiz passing threshold. */ + difficult: boolean; +} + +/** Response of GET /api/v1/admin/courses/:id/analytics. */ +export interface CourseAnalytics { + courseId: string; + totalEnrollments: number; + /** Percentage (0-100) of enrollments with a non-null completedAt. */ + completionRate: number; + /** Mean hours between enrolledAt and completedAt, null with no completions. */ + averageTimeToCompleteHours: number | null; + /** Mean quiz score percentage across all non-superseded submissions for + * the course's quizzes, null with no submissions. */ + averageQuizScore: number | null; + enrollmentTrends: { + daily: EnrollmentTrendPoint[]; + weekly: EnrollmentTrendPoint[]; + }; + /** Modules ordered by average score ascending — lowest first. */ + moduleDifficulty: ModuleDifficulty[]; + generatedAt: Date; +} + +/** Response of POST /api/v1/courses/:id/report. */ +export interface CourseReportResult { + id: string; + courseId: string; + reason: string; + status: string; + createdAt: Date; +} diff --git a/src/modules/rewards/reward.controller.ts b/src/modules/rewards/reward.controller.ts index 68bf417..a059053 100644 --- a/src/modules/rewards/reward.controller.ts +++ b/src/modules/rewards/reward.controller.ts @@ -1,7 +1,11 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { rewardService } from "./reward.service.js"; import type { AuthenticatedRequest } from "../../middleware/auth.js"; -import type { ClaimRewardBody, GetHistoryQuery } from "./reward.types.js"; +import type { + ClaimRewardBody, + GetHistoryQuery, + GetTransactionsQuery, +} from "./reward.types.js"; import { checkIdempotency, storeIdempotentResponse, @@ -104,6 +108,30 @@ export class RewardController { }); } + /** + * GET /api/v1/rewards/transactions + * List the authenticated user's reward-related blockchain transactions, + * each with its on-chain verification status against Stellar Horizon. + */ + async transactions( + request: FastifyRequest<{ Querystring: GetTransactionsQuery }>, + reply: FastifyReply + ): Promise { + const { authUser } = request as AuthenticatedRequest; + const { page, limit } = request.query; + const { transactions, total } = await rewardService.getTransactions( + authUser.id, + page, + limit + ); + + reply.send({ + success: true, + data: transactions, + pagination: { page, limit, total }, + }); + } + /** * GET /api/rewards/leaderboard * Get the top earners by total credits. No authentication required. diff --git a/src/modules/rewards/reward.routes.ts b/src/modules/rewards/reward.routes.ts index 02c73e9..433980f 100644 --- a/src/modules/rewards/reward.routes.ts +++ b/src/modules/rewards/reward.routes.ts @@ -3,7 +3,12 @@ import { rewardController } from "./reward.controller.js"; import { authGuard, optionalAuth } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; import { claimRateLimit } from "../../middleware/rate-limit.js"; -import { claimRewardSchema, getHistorySchema, getLeaderboardSchema } from "./reward.types.js"; +import { + claimRewardSchema, + getHistorySchema, + getLeaderboardSchema, + getTransactionsSchema, +} from "./reward.types.js"; export async function rewardRoutes(app: FastifyInstance): Promise { // Leaderboard endpoint - no auth required, so we register it before the authGuard hook @@ -81,4 +86,25 @@ export async function rewardRoutes(app: FastifyInstance): Promise { }, (request, reply) => rewardController.history(request, reply) ); + + app.get<{ Querystring: import("./reward.types.js").GetTransactionsQuery }>( + "/transactions", + { + preHandler: [validate({ querystring: getTransactionsSchema })], + schema: { + description: + "List the caller's reward-related blockchain transactions with on-chain verification status (cached 30s)", + tags: ["rewards"], + 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) => rewardController.transactions(request, reply) + ); } diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 375cada..39bd4f5 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -23,7 +23,11 @@ import { logger } from "../../utils/logger.js"; import { enqueueReward } from "../../services/retry-queue.js"; import { dispatchWebhook } from "../../services/webhook-dispatcher.js"; import StellarSdk from "@stellar/stellar-sdk"; -import type { RewardClaimResult, RewardHistoryItem } from "./reward.types.js"; +import type { + RewardClaimResult, + RewardHistoryItem, + RewardTransaction, +} from "./reward.types.js"; import { PASSING_PERCENTAGE } from "../quizzes/quiz.types.js"; import { auditLog } from "../../audit/index.js"; import { @@ -475,6 +479,93 @@ export class RewardService { return result; } + /** + * Get all reward-related blockchain transactions for a user, each + * verified against Stellar Horizon so the caller doesn't have to trust + * the stored tx hash blindly. Paginated and cached for 30s — verification + * involves a live Horizon call per transaction, so a short cache keeps + * repeated page loads cheap without going stale for long. + */ + async getTransactions( + userId: string, + page: number, + limit: number, + ): Promise<{ transactions: RewardTransaction[]; total: number }> { + const namespace = "rewards"; + const cacheKeyString = cacheKey(namespace, "transactions", userId, page, limit); + + const cached = await cacheGet<{ transactions: RewardTransaction[]; total: number }>( + namespace, + cacheKeyString, + ); + if (cached) return cached; + + const offset = (page - 1) * limit; + const where = and( + eq(quizSubmissions.userId, userId), + sql`${quizSubmissions.txHash} IS NOT NULL`, + ); + + const [totalResult] = await db + .select({ value: sql`count(*)`.mapWith(Number) }) + .from(quizSubmissions) + .where(where); + + const rows = await db + .select({ + id: quizSubmissions.id, + txHash: quizSubmissions.txHash, + rewardAmount: quizSubmissions.rewardAmount, + submittedAt: quizSubmissions.submittedAt, + courseTitle: courses.title, + }) + .from(quizSubmissions) + .innerJoin(quizzes, eq(quizSubmissions.quizId, quizzes.id)) + .innerJoin(courses, eq(quizzes.courseId, courses.id)) + .where(where) + .orderBy(desc(quizSubmissions.submittedAt)) + .limit(limit) + .offset(offset); + + const transactions: RewardTransaction[] = await Promise.all( + rows.map(async (row) => { + const txHash = row.txHash as string; + + // A bad_seq retry marks the tx as pending indexer confirmation + // rather than a real hash — nothing to look up on Horizon yet. + if (txHash === "pending_indexer_confirmation") { + return { + id: row.id, + courseTitle: row.courseTitle, + amount: row.rewardAmount ?? REWARD_AMOUNT, + txHash, + status: "pending" as const, + blockHeight: null, + confirmationCount: null, + submittedAt: row.submittedAt, + }; + } + + const verification = await stellarClient.getHorizonTransaction(txHash); + return { + id: row.id, + courseTitle: row.courseTitle, + amount: row.rewardAmount ?? REWARD_AMOUNT, + txHash, + status: verification.status, + blockHeight: verification.ledger, + confirmationCount: verification.confirmations, + submittedAt: row.submittedAt, + }; + }), + ); + + const result = { transactions, total: totalResult?.value ?? 0 }; + await cacheSet(cacheKeyString, result, 30); + + return result; + } + /** * Get the top earners by total credits (leaderboard). * Excludes users with 0 credits, cached for 5 minutes. diff --git a/src/modules/rewards/reward.types.ts b/src/modules/rewards/reward.types.ts index f2cb7db..4e9dbce 100644 --- a/src/modules/rewards/reward.types.ts +++ b/src/modules/rewards/reward.types.ts @@ -16,11 +16,17 @@ export const getLeaderboardSchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(50), }); +export const getTransactionsSchema = 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 ClaimRewardBody = z.infer; export type GetHistoryQuery = z.infer; export type GetLeaderboardQuery = z.infer; +export type GetTransactionsQuery = z.infer; export interface RewardClaimResult { submissionId: string; @@ -49,3 +55,18 @@ export interface LeaderboardResponse { entries: LeaderboardEntry[]; generatedAt: Date; } + +/** One row of GET /api/v1/rewards/transactions — a reward-related on-chain + * transaction with its verification status against Stellar Horizon. + * "pending" covers both "not yet indexed" and "Horizon lookup failed" — + * callers should treat it as "not yet confirmed", not "known bad". */ +export interface RewardTransaction { + id: string; + courseTitle: string; + amount: number; + txHash: string; + status: "confirmed" | "pending" | "failed"; + blockHeight: number | null; + confirmationCount: number | null; + submittedAt: Date; +} diff --git a/src/stellar/client.ts b/src/stellar/client.ts index ebcb549..c314644 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -161,6 +161,61 @@ export class StellarClient { } } + /** + * Look up a submitted transaction on Horizon and report its on-chain + * verification status. Used by GET /api/v1/rewards/transactions so users + * can verify their reward transactions rather than trusting the stored + * tx hash alone. + * + * Ledger lookup (for confirmation count) is best-effort — if it fails the + * transaction's own confirmed/failed status is still returned with + * confirmations left null, rather than failing the whole verification. + */ + async getHorizonTransaction(txHash: string): Promise<{ + status: "confirmed" | "pending" | "failed"; + ledger: number | null; + confirmations: number | null; + }> { + logger.debug({ requestId: getRequestId(), txHash }, "Verifying Stellar transaction on Horizon"); + let tx: StellarSdk.Horizon.ServerApi.TransactionRecord; + try { + tx = await circuitBreakerExecute( + () => + stellarRetry.execute(() => + withTimeout(this.horizon.transactions().transaction(txHash).call(), READ_TIMEOUT_MS) + ), + "read" + ); + } catch (err: any) { + const status = err?.response?.status ?? err?.status; + if (status === 404) { + return { status: "pending", ledger: null, confirmations: null }; + } + logger.warn({ err, txHash }, "Horizon transaction lookup failed — reporting pending"); + return { status: "pending", ledger: null, confirmations: null }; + } + + if (!tx.successful) { + return { status: "failed", ledger: tx.ledger, confirmations: null }; + } + + let confirmations: number | null = null; + try { + const latestLedgers = await withTimeout( + this.horizon.ledgers().order("desc").limit(1).call(), + READ_TIMEOUT_MS + ); + const latestSequence = latestLedgers.records[0]?.sequence; + if (typeof latestSequence === "number") { + confirmations = Math.max(latestSequence - tx.ledger + 1, 0); + } + } catch (err) { + logger.warn({ err, txHash }, "Failed to fetch latest ledger for confirmation count"); + } + + return { status: "confirmed", ledger: tx.ledger, confirmations }; + } + /** Check Soroban RPC health by calling getLatestLedger. */ async checkSorobanHealth(): Promise { try {