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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AuditEvent =
| "course.published"
| "course.duplicated"
| "course.reviewed"
| "course.imported"
| "course.archived"
| "course.enrollment_dropped"
| "course.waitlist.joined"
Expand All @@ -31,6 +32,9 @@ type AuditEvent =
| "course.module.created"
| "course.module.updated"
| "course.module.deleted"
| "announcement.created"
| "announcement.updated"
| "announcement.deleted";
| "webhook.created"
| "webhook.updated"
| "webhook.deleted"
Expand Down Expand Up @@ -62,6 +66,9 @@ interface AuditFields {
changes?: string[];
rating?: number;
sourceCourseId?: string;
moduleCount?: number;
announcementId?: string;
priority?: string;
}

export async function auditLog(event: AuditEvent, fields: AuditFields): Promise<void> {
Expand Down
5 changes: 5 additions & 0 deletions src/database/migrations/0020_courses_prerequisites.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
-- 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;
-- Prerequisite course IDs for a course (#369). Admin-configurable, informational
-- only — enrolling never checks this list. Empty array means no prerequisites.
ALTER TABLE "courses"
Expand Down
13 changes: 13 additions & 0 deletions src/database/migrations/0021_create_announcements.sql
Original file line number Diff line number Diff line change
@@ -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);
40 changes: 40 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export const courses = pgTable(
.notNull()
.default([]),
isActive: boolean("is_active").notNull().default(true),
// 0–100 accessibility score for the course's authored content (#326),
// recomputed on every create/update. Null until first written. Advisory
// only — a low score never blocks saving the course.
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<string[]>()
.notNull()
.default([]),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand Down Expand Up @@ -326,6 +337,35 @@ export const webhookAttempts = 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",
Expand Down
16 changes: 16 additions & 0 deletions src/modules/admin/dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const stats = await dashboardService.getStats();

reply.send({ success: true, data: stats });
}
}

export const dashboardController = new DashboardController();
22 changes: 22 additions & 0 deletions src/modules/admin/dashboard.routes.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
);
}
153 changes: 153 additions & 0 deletions src/modules/admin/dashboard.service.ts
Original file line number Diff line number Diff line change
@@ -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<AdminDashboardStats> {
const namespace = "admin";
const cacheKeyString = cacheKey(namespace, "dashboard-stats");

const cached = await cacheGet<AdminDashboardStats>(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<number>`COUNT(${quizSubmissions.score})`,
})
.from(quizSubmissions),
db
.select({ value: count() })
.from(credentials)
.where(eq(credentials.revoked, false)),
db
.select({
value: sql<number>`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();
31 changes: 31 additions & 0 deletions src/modules/admin/dashboard.types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading