feat: improve learning and platform operations
All checks were successful
CI / Validate frontend and API (push) Successful in 45s
CI / Build and publish Docker images (push) Successful in 24s

This commit is contained in:
Cauê Faleiros
2026-09-01 15:38:14 -03:00
parent 4736fb5208
commit 3d4c72e85c
20 changed files with 292 additions and 45 deletions

View 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
View 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],
);
};

View File

@@ -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);

View File

@@ -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 };
});
};

View File

@@ -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`,

View File

@@ -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);

View File

@@ -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();
});
};

View File

@@ -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();
});
};