feat: improve learning and platform operations
This commit is contained in:
13
server/migrations/005_audit_log.sql
Normal file
13
server/migrations/005_audit_log.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
create table audit_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
actor_id uuid references users(id) on delete set null,
|
||||
action text not null check (char_length(action) between 1 and 120),
|
||||
subject_type text not null check (char_length(subject_type) between 1 and 80),
|
||||
subject_id uuid,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
ip_address inet,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index audit_logs_created_index on audit_logs (created_at desc);
|
||||
create index audit_logs_actor_index on audit_logs (actor_id, created_at desc);
|
||||
18
server/src/audit.ts
Normal file
18
server/src/audit.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pool } from './db/pool.js';
|
||||
|
||||
type AuditInput = {
|
||||
actorId: string;
|
||||
action: string;
|
||||
subjectType: string;
|
||||
subjectId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
};
|
||||
|
||||
export const recordAudit = async (input: AuditInput) => {
|
||||
await pool.query(
|
||||
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
|
||||
values ($1, $2, $3, $4::uuid, $5::jsonb, $6::inet)`,
|
||||
[input.actorId, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,8 @@ const environmentSchema = z.object({
|
||||
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
|
||||
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
|
||||
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
|
||||
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
|
||||
});
|
||||
|
||||
export const config = environmentSchema.parse(process.env);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||
import { config } from '../config.js';
|
||||
import { recordAudit } from '../audit.js';
|
||||
|
||||
const userParamsSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
@@ -41,6 +42,18 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.get('/audit-log', adminAccess, async () => {
|
||||
const result = await pool.query(
|
||||
`select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId",
|
||||
a.metadata, a.created_at as "createdAt", coalesce(u.display_name, 'Sistema') as "actorName"
|
||||
from audit_logs a
|
||||
left join users u on u.id = a.actor_id
|
||||
order by a.created_at desc
|
||||
limit 50`,
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/users/:userId', adminAccess, async (request, reply) => {
|
||||
const { userId } = userParamsSchema.parse(request.params);
|
||||
const result = await pool.query(
|
||||
@@ -63,6 +76,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`,
|
||||
[input.email, input.role, hashToken(rawToken), request.user.id],
|
||||
);
|
||||
await recordAudit({ actorId: request.user.id, action: 'invitation.created', subjectType: 'invitation', metadata: { email: input.email, role: input.role }, ipAddress: request.ip });
|
||||
return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } });
|
||||
});
|
||||
|
||||
@@ -76,6 +90,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
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],
|
||||
);
|
||||
await recordAudit({ actorId: request.user.id, action: 'password_reset.created', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email }, ipAddress: request.ip });
|
||||
return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } };
|
||||
});
|
||||
|
||||
@@ -97,6 +112,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
const account = result.rows[0];
|
||||
if (!account) return reply.code(404).send({ error: 'User not found' });
|
||||
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
|
||||
return { data: account };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email().transform((email) => email.toLowerCase()),
|
||||
@@ -32,7 +33,27 @@ const serializeUser = (user: UserRow): AuthUser => ({
|
||||
});
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const publicAttempts = new Map<string, number[]>();
|
||||
const allowPublicAuthAttempt = (ip: string) => {
|
||||
const now = Date.now();
|
||||
const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000;
|
||||
const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed);
|
||||
if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) {
|
||||
publicAttempts.set(ip, attempts);
|
||||
return false;
|
||||
}
|
||||
attempts.push(now);
|
||||
publicAttempts.set(ip, attempts);
|
||||
return true;
|
||||
};
|
||||
const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
||||
if (allowPublicAuthAttempt(ip)) return false;
|
||||
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
|
||||
return true;
|
||||
};
|
||||
|
||||
app.post('/accept-invitation', async (request, reply) => {
|
||||
if (rejectIfRateLimited(request.ip, reply)) return;
|
||||
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -63,6 +84,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
});
|
||||
|
||||
app.post('/reset-password', async (request, reply) => {
|
||||
if (rejectIfRateLimited(request.ip, reply)) return;
|
||||
const input = tokenPasswordSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -87,6 +109,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
});
|
||||
app.post('/register', async (request, reply) => {
|
||||
if (rejectIfRateLimited(request.ip, reply)) return;
|
||||
const input = registerSchema.parse(request.body);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
@@ -109,6 +132,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
});
|
||||
|
||||
app.post('/login', async (request, reply) => {
|
||||
if (rejectIfRateLimited(request.ip, reply)) return;
|
||||
const input = credentialsSchema.parse(request.body);
|
||||
const result = await pool.query<UserRow>(
|
||||
`select id, email, display_name, role, password_hash, is_active from users where email = $1`,
|
||||
|
||||
@@ -100,6 +100,29 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
|
||||
});
|
||||
|
||||
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
|
||||
const result = await pool.query(
|
||||
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
|
||||
from (
|
||||
${courseSelect()}
|
||||
where c.status = 'published'
|
||||
) base
|
||||
left join lateral (
|
||||
select
|
||||
case when count(l.id) = 0 then 0
|
||||
else floor(100.0 * count(l.id) filter (where lp.completed_at is not null) / count(l.id))::int end as progress,
|
||||
max(lp.updated_at) as "lastActivity"
|
||||
from lessons l
|
||||
left join lesson_progress lp on lp.lesson_id = l.id and lp.user_id = $1
|
||||
where l.course_id = base.id
|
||||
) progress on true
|
||||
where progress."lastActivity" is not null
|
||||
order by progress."lastActivity" desc`,
|
||||
[request.user.id],
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/:courseId', async (request, reply) => {
|
||||
const authenticated = await hasSession(request);
|
||||
const { courseId } = paramsSchema.parse(request.params);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { recordAudit } from '../audit.js';
|
||||
|
||||
const courseParamsSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
@@ -11,9 +12,9 @@ const lessonParamsSchema = z.object({
|
||||
});
|
||||
|
||||
const completionSchema = z.object({
|
||||
completed: z.boolean(),
|
||||
completed: z.boolean().optional(),
|
||||
watchedSeconds: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Provide progress or completion state' });
|
||||
|
||||
const commentSchema = z.object({
|
||||
lessonId: z.string().uuid().nullable().optional(),
|
||||
@@ -30,6 +31,12 @@ const ensurePublishedCourse = async (courseId: string) => {
|
||||
return result.rowCount === 1;
|
||||
};
|
||||
|
||||
const canViewCourseComments = async (courseId: string, user: { id: string; role: string }) => {
|
||||
const result = await pool.query<{ status: string; instructor_id: string }>('select status, instructor_id from courses where id = $1', [courseId]);
|
||||
const course = result.rows[0];
|
||||
return Boolean(course && (course.status === 'published' || user.role === 'admin' || course.instructor_id === user.id));
|
||||
};
|
||||
|
||||
export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
|
||||
const result = await pool.query<{ instructor_id: string }>(
|
||||
@@ -76,20 +83,22 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const result = await pool.query(
|
||||
`insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at)
|
||||
values ($1, $2, $3, case when $4 then now() else null end)
|
||||
values ($1, $2, $3, case when $4 is true then now() else null end)
|
||||
on conflict (lesson_id, user_id) do update set
|
||||
watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds),
|
||||
completed_at = case when $4 then coalesce(lesson_progress.completed_at, now()) else null end
|
||||
completed_at = case when $4 is true then coalesce(lesson_progress.completed_at, now())
|
||||
when $4 is false then null
|
||||
else lesson_progress.completed_at end
|
||||
returning lesson_id as "lessonId", watched_seconds as "watchedSeconds",
|
||||
completed_at as "completedAt", updated_at as "updatedAt"`,
|
||||
[lessonId, request.user.id, input.watchedSeconds || 0, input.completed],
|
||||
[lessonId, request.user.id, input.watchedSeconds || 0, input.completed ?? null],
|
||||
);
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
if (!(await ensurePublishedCourse(courseId))) {
|
||||
if (!(await canViewCourseComments(courseId, request.user))) {
|
||||
return reply.code(404).send({ error: 'Course not found' });
|
||||
}
|
||||
|
||||
@@ -146,6 +155,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`,
|
||||
[commentId, request.user.id, input.text],
|
||||
);
|
||||
await recordAudit({ actorId: request.user.id, action: 'comment.replied', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
|
||||
return { data: { id: commentId } };
|
||||
});
|
||||
|
||||
@@ -153,6 +163,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
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]);
|
||||
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { courseSelect } from './courses.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { recordAudit } from '../audit.js';
|
||||
|
||||
const mediaSchema = z.object({
|
||||
provider: z.string().trim().min(1).max(80),
|
||||
@@ -46,6 +47,8 @@ const courseSchema = z.object({
|
||||
const paramsSchema = z.object({ courseId: z.string().uuid() });
|
||||
type CourseInput = z.infer<typeof courseSchema>;
|
||||
|
||||
class CourseContentConflict extends Error {}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
@@ -97,7 +100,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
||||
[removedLessonIds],
|
||||
);
|
||||
if (usage.rowCount) {
|
||||
throw new Error('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
|
||||
throw new CourseContentConflict('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]);
|
||||
}
|
||||
@@ -193,9 +196,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
await replaceCourseContents(client, course.rows[0].id, input);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: course.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
return reply.code(201).send({ data: { id: course.rows[0].id } });
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
@@ -221,9 +226,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
await replaceCourseContents(client, courseId, input);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: courseId, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
return { data: { id: courseId } };
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
@@ -235,6 +242,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
const accessError = await assertCanManageCourse(courseId, request.user);
|
||||
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
||||
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
|
||||
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user