feat: expand admin and instructor tools
This commit is contained in:
15
server/migrations/004_account_access_tokens.sql
Normal file
15
server/migrations/004_account_access_tokens.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
create type account_token_purpose as enum ('invitation', 'password_reset');
|
||||
|
||||
create table account_access_tokens (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null,
|
||||
role user_role not null default 'student',
|
||||
purpose account_token_purpose not null,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
created_by uuid not null references users(id),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index account_access_tokens_lookup_index on account_access_tokens (token_hash) where used_at is null;
|
||||
@@ -15,7 +15,7 @@ export function buildApp() {
|
||||
|
||||
app.register(cors, {
|
||||
origin: config.FRONTEND_ORIGIN,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
|
||||
4
server/src/auth/account-tokens.ts
Normal file
4
server/src/auth/account-tokens.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export const createRawToken = () => randomBytes(32).toString('base64url');
|
||||
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const userParamsSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
@@ -12,6 +14,7 @@ const updateUserSchema = z.object({
|
||||
}).refine((input) => input.role !== undefined || input.isActive !== undefined, {
|
||||
message: 'Provide at least one field to update',
|
||||
});
|
||||
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') });
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||
@@ -26,6 +29,56 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/dashboard', adminAccess, async () => {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
(select count(*)::int from users) as "totalUsers",
|
||||
(select count(*)::int from users where is_active) as "activeUsers",
|
||||
(select count(*)::int from courses where status = 'published') as "publishedCourses",
|
||||
(select count(*)::int from lesson_progress where completed_at is not null) as "completedLessons",
|
||||
(select count(*)::int from comments) as "comments"`,
|
||||
);
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.get('/users/:userId', adminAccess, async (request, reply) => {
|
||||
const { userId } = userParamsSchema.parse(request.params);
|
||||
const result = await pool.query(
|
||||
`select u.id, u.email, u.display_name as name, u.role, u.is_active as "isActive", u.created_at as "createdAt",
|
||||
(select count(*)::int from lesson_progress lp where lp.user_id = u.id and lp.completed_at is not null) as "completedLessons",
|
||||
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
|
||||
from users u where u.id = $1`, [userId],
|
||||
);
|
||||
if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' });
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.post('/invitations', adminAccess, async (request, reply) => {
|
||||
const input = invitationSchema.parse(request.body);
|
||||
const existing = await pool.query('select 1 from users where email = $1', [input.email]);
|
||||
if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' });
|
||||
const rawToken = createRawToken();
|
||||
await pool.query(
|
||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||
values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`,
|
||||
[input.email, input.role, hashToken(rawToken), request.user.id],
|
||||
);
|
||||
return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } });
|
||||
});
|
||||
|
||||
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
|
||||
const { userId } = userParamsSchema.parse(request.params);
|
||||
const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]);
|
||||
if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' });
|
||||
const rawToken = createRawToken();
|
||||
await pool.query(
|
||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||
values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`,
|
||||
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id],
|
||||
);
|
||||
return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } };
|
||||
});
|
||||
|
||||
app.patch('/users/:userId', adminAccess, async (request, reply) => {
|
||||
const { userId } = userParamsSchema.parse(request.params);
|
||||
const input = updateUserSchema.parse(request.body);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||
import { hashToken } from '../auth/account-tokens.js';
|
||||
import type { AuthUser } from '../auth/plugin.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
@@ -12,6 +13,7 @@ const credentialsSchema = z.object({
|
||||
const registerSchema = credentialsSchema.extend({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
});
|
||||
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() });
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
@@ -30,6 +32,60 @@ const serializeUser = (user: UserRow): AuthUser => ({
|
||||
});
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/accept-invitation', async (request, reply) => {
|
||||
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const token = await client.query<{ email: string; role: AuthUser['role'] }>(
|
||||
`update account_access_tokens set used_at = now()
|
||||
where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now()
|
||||
returning email, role`, [hashToken(input.token)],
|
||||
);
|
||||
if (!token.rows[0]) {
|
||||
await client.query('rollback');
|
||||
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
|
||||
}
|
||||
const result = await client.query<UserRow>(
|
||||
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
|
||||
returning id, email, display_name, role, password_hash, is_active`,
|
||||
[token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role],
|
||||
);
|
||||
await client.query('commit');
|
||||
const user = serializeUser(result.rows[0]);
|
||||
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
|
||||
} catch {
|
||||
await client.query('rollback');
|
||||
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/reset-password', async (request, reply) => {
|
||||
const input = tokenPasswordSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const token = await client.query<{ email: string }>(
|
||||
`update account_access_tokens set used_at = now()
|
||||
where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now()
|
||||
returning email`, [hashToken(input.token)],
|
||||
);
|
||||
if (!token.rows[0]) {
|
||||
await client.query('rollback');
|
||||
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
|
||||
}
|
||||
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
|
||||
await client.query('commit');
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
app.post('/register', async (request, reply) => {
|
||||
const input = registerSchema.parse(request.body);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
@@ -19,6 +19,8 @@ const commentSchema = z.object({
|
||||
lessonId: z.string().uuid().nullable().optional(),
|
||||
text: z.string().trim().min(1).max(4000),
|
||||
});
|
||||
const commentParamsSchema = z.object({ commentId: z.string().uuid() });
|
||||
const replySchema = z.object({ text: z.string().trim().min(1).max(4000) });
|
||||
|
||||
const ensurePublishedCourse = async (courseId: string) => {
|
||||
const result = await pool.query(
|
||||
@@ -29,6 +31,16 @@ const ensurePublishedCourse = async (courseId: string) => {
|
||||
};
|
||||
|
||||
export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
|
||||
const result = await pool.query<{ instructor_id: string }>(
|
||||
`select c.instructor_id
|
||||
from comments cm join courses c on c.id = cm.course_id
|
||||
where cm.id = $1`,
|
||||
[commentId],
|
||||
);
|
||||
const comment = result.rows[0];
|
||||
return Boolean(comment && (user.role === 'admin' || comment.instructor_id === user.id));
|
||||
};
|
||||
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
if (!(await ensurePublishedCourse(courseId))) {
|
||||
@@ -123,4 +135,24 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
return reply.code(201).send({ data: result.rows[0] });
|
||||
});
|
||||
|
||||
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||
const { commentId } = commentParamsSchema.parse(request.params);
|
||||
const input = replySchema.parse(request.body);
|
||||
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' });
|
||||
await pool.query(
|
||||
`insert into comment_replies (comment_id, author_id, body)
|
||||
values ($1, $2, $3)
|
||||
on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`,
|
||||
[commentId, request.user.id, input.text],
|
||||
);
|
||||
return { data: { id: commentId } };
|
||||
});
|
||||
|
||||
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||
const { commentId } = commentParamsSchema.parse(request.params);
|
||||
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' });
|
||||
await pool.query('delete from comments where id = $1', [commentId]);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ const assetSchema = z.object({
|
||||
});
|
||||
|
||||
const lessonSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
title: z.string().trim().min(1).max(255),
|
||||
description: z.string().max(5000).default(''),
|
||||
durationSeconds: z.number().int().nonnegative().nullable().optional(),
|
||||
@@ -79,20 +80,52 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les
|
||||
}
|
||||
|
||||
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
|
||||
// This replacement strategy is safe before student progress exists. The next
|
||||
// iteration will switch to per-lesson updates to preserve historical progress.
|
||||
await client.query('delete from lessons where course_id = $1', [courseId]);
|
||||
const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
|
||||
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
||||
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
|
||||
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
|
||||
if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.');
|
||||
|
||||
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
|
||||
if (removedLessonIds.length > 0) {
|
||||
const usage = await client.query<{ id: string }>(
|
||||
`select l.id
|
||||
from lessons l
|
||||
where l.id = any($1::uuid[])
|
||||
and (exists (select 1 from lesson_progress lp where lp.lesson_id = l.id)
|
||||
or exists (select 1 from comments c where c.lesson_id = l.id))`,
|
||||
[removedLessonIds],
|
||||
);
|
||||
if (usage.rowCount) {
|
||||
throw new Error('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
|
||||
}
|
||||
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
|
||||
}
|
||||
|
||||
// Move existing positions away first so drag/reordering cannot violate the
|
||||
// unique (course_id, position) constraint while updates are applied.
|
||||
await client.query('update lessons set position = position + 10000 where course_id = $1', [courseId]);
|
||||
await client.query('delete from assets where course_id = $1', [courseId]);
|
||||
|
||||
await insertAssets(client, { courseId }, input.assets);
|
||||
for (const [index, lesson] of input.lessons.entries()) {
|
||||
const lessonResult = await client.query<{ id: string }>(
|
||||
const lessonId = lesson.id ?? (await client.query<{ id: string }>(
|
||||
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id`,
|
||||
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
|
||||
);
|
||||
const lessonId = lessonResult.rows[0].id;
|
||||
)).rows[0].id;
|
||||
|
||||
if (lesson.id) {
|
||||
await client.query(
|
||||
`update lessons
|
||||
set title = $2, description = $3, position = $4, duration_seconds = $5, access_level = $6
|
||||
where id = $1`,
|
||||
[lessonId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
|
||||
);
|
||||
await client.query('delete from lesson_media where lesson_id = $1', [lessonId]);
|
||||
await client.query('delete from assets where lesson_id = $1', [lessonId]);
|
||||
}
|
||||
|
||||
for (const media of lesson.media) {
|
||||
await client.query(
|
||||
@@ -127,6 +160,25 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/analytics', manageAccess, async (request) => {
|
||||
const isAdmin = request.user.role === 'admin';
|
||||
const result = await pool.query(
|
||||
`select
|
||||
count(distinct c.id)::int as "courses",
|
||||
count(distinct l.id)::int as "lessons",
|
||||
count(distinct lp.user_id)::int as "learners",
|
||||
count(distinct lp.lesson_id) filter (where lp.completed_at is not null)::int as "completedLessons",
|
||||
count(distinct cm.id)::int as "comments"
|
||||
from courses c
|
||||
left join lessons l on l.course_id = c.id
|
||||
left join lesson_progress lp on lp.lesson_id = l.id
|
||||
left join comments cm on cm.course_id = c.id
|
||||
where c.status <> 'archived' and ($1::boolean or c.instructor_id = $2)`,
|
||||
[isAdmin, request.user.id],
|
||||
);
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.post('/', manageAccess, async (request, reply) => {
|
||||
const input = courseSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
|
||||
Reference in New Issue
Block a user