Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/database/migrations/0021_course_reports.sql
Original file line number Diff line number Diff line change
@@ -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");
19 changes: 19 additions & 0 deletions src/database/migrations/0022_sessions.sql
Original file line number Diff line number Diff line change
@@ -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");
73 changes: 56 additions & 17 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
);

Expand Down
6 changes: 6 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:";

Expand Down Expand Up @@ -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");
Expand Down
33 changes: 33 additions & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
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();
34 changes: 34 additions & 0 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
verifySchema,
refreshSchema,
logoutSchema,
sessionIdParamsSchema,
} from "./auth.types.js";

export async function authRoutes(app: FastifyInstance): Promise<void> {
Expand Down Expand Up @@ -118,4 +119,37 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
(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)
);
}
5 changes: 5 additions & 0 deletions src/modules/auth/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof challengeSchema>;
export type VerifyBody = z.infer<typeof verifySchema>;
export type RefreshBody = z.infer<typeof refreshSchema>;
export type LogoutBody = z.infer<typeof logoutSchema>;
export type SessionIdParams = z.infer<typeof sessionIdParamsSchema>;

export interface ChallengeResponse {
challenge: string;
Expand Down
93 changes: 93 additions & 0 deletions src/modules/auth/session.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<SessionSummary[]> {
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<void> {
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();
16 changes: 16 additions & 0 deletions src/modules/courses/admin-course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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).
Expand Down
Loading