feat: persist learner progress and comments
All checks were successful
CI / Validate frontend and API (push) Successful in 47s
CI / Build and publish Docker images (push) Successful in 39s

This commit is contained in:
Cauê Faleiros
2026-08-31 16:33:08 -03:00
parent 7842a757cd
commit 2dfca33758
10 changed files with 225 additions and 491 deletions

View File

@@ -7,6 +7,7 @@ import { pool } from './db/pool.js';
import { authRoutes } from './routes/auth.js';
import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
import { learningRoutes } from './routes/learning.js';
export function buildApp() {
const app = Fastify({ logger: true });
@@ -25,19 +26,21 @@ export function buildApp() {
return reply.code(500).send({ error: 'Internal server error' });
});
app.get('/api/v1/health', async () => ({ status: 'ok' }));
app.get('/api/v1/ready', async (_request, reply) => {
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
try {
await pool.query('select 1');
return { status: 'ready' };
return { status: 'ok', database: 'connected' };
} catch {
return reply.code(503).send({ status: 'unavailable' });
return reply.code(503).send({ status: 'unavailable', database: 'unavailable' });
}
});
};
app.get('/api/v1/health', checkDatabase);
app.get('/api/v1/ready', checkDatabase);
app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(learningRoutes, { prefix: '/api/v1' });
return app;
}

View File

@@ -0,0 +1,126 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
const courseParamsSchema = z.object({
courseId: z.string().uuid(),
});
const lessonParamsSchema = z.object({
lessonId: z.string().uuid(),
});
const completionSchema = z.object({
completed: z.boolean(),
watchedSeconds: z.coerce.number().int().min(0).optional(),
});
const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(),
text: z.string().trim().min(1).max(4000),
});
const ensurePublishedCourse = async (courseId: string) => {
const result = await pool.query(
"select id from courses where id = $1 and status = 'published'",
[courseId],
);
return result.rowCount === 1;
};
export const learningRoutes: FastifyPluginAsync = async (app) => {
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' });
}
const result = await pool.query(
`select lp.lesson_id as "lessonId", lp.watched_seconds as "watchedSeconds",
lp.completed_at as "completedAt", lp.updated_at as "updatedAt"
from lesson_progress lp
join lessons l on l.id = lp.lesson_id
where lp.user_id = $1 and l.course_id = $2
order by l.position`,
[request.user.id, courseId],
);
return { data: result.rows };
});
app.put('/lessons/:lessonId/progress', { preHandler: app.authenticate }, async (request, reply) => {
const { lessonId } = lessonParamsSchema.parse(request.params);
const input = completionSchema.parse(request.body);
const lesson = await pool.query(
`select l.id
from lessons l
join courses c on c.id = l.course_id
where l.id = $1 and c.status = 'published'`,
[lessonId],
);
if (lesson.rowCount !== 1) {
return reply.code(404).send({ error: 'Lesson not found' });
}
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)
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
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],
);
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))) {
return reply.code(404).send({ error: 'Course not found' });
}
const result = await pool.query(
`select c.id, c.course_id as "courseId", c.lesson_id as "lessonId", c.body as text,
c.created_at as "createdAt", u.display_name as "user",
cr.body as reply, cr.created_at as "replyCreatedAt"
from comments c
join users u on u.id = c.author_id
left join comment_replies cr on cr.comment_id = c.id
where c.course_id = $1
order by c.created_at desc`,
[courseId],
);
return { data: result.rows };
});
app.post('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params);
const input = commentSchema.parse(request.body);
if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' });
}
if (input.lessonId) {
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
if (lesson.rowCount !== 1) {
return reply.code(400).send({ error: 'Lesson does not belong to this course' });
}
}
const result = await pool.query(
`with inserted as (
insert into comments (course_id, lesson_id, author_id, body)
values ($1, $2, $3, $4)
returning id, course_id, lesson_id, body, created_at
)
select inserted.id, inserted.course_id as "courseId", inserted.lesson_id as "lessonId",
inserted.body as text, inserted.created_at as "createdAt", u.display_name as "user"
from inserted
join users u on u.id = $3`,
[courseId, input.lessonId || null, request.user.id, input.text],
);
return reply.code(201).send({ data: result.rows[0] });
});
};