feat: persist learner progress and comments
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
126
server/src/routes/learning.ts
Normal file
126
server/src/routes/learning.ts
Normal 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] });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user