feat: add public instructor profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 54s
CI / Build and publish Docker images (push) Successful in 26s

This commit is contained in:
Cauê Faleiros
2026-09-08 10:50:35 -03:00
parent 07fbc7d009
commit 7b2d25aabb
12 changed files with 372 additions and 7 deletions

View File

@@ -11,6 +11,7 @@ import { learningRoutes } from './routes/learning.js';
import { adminRoutes } from './routes/admin.js';
import { mediaRoutes } from './routes/media.js';
import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
import { profileRoutes } from './routes/profiles.js';
export function buildApp() {
const app = Fastify({ logger: true });
@@ -62,6 +63,7 @@ export function buildApp() {
app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(profileRoutes, { prefix: '/api/v1/profiles' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });

View File

@@ -0,0 +1,123 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import { courseSelect } from './courses.js';
const instructorParamsSchema = z.object({ instructorId: z.string().uuid() });
const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional()
.transform((value) => value || null);
const profileUpdateSchema = z.object({
name: z.string().trim().min(2).max(120),
avatarImageUrl: optionalUrl,
headline: z.string().trim().max(180),
bio: z.string().trim().max(3_000),
websiteUrl: optionalUrl,
linkedinUrl: optionalUrl,
instagramUrl: optionalUrl,
youtubeUrl: optionalUrl,
isPublic: z.boolean().optional(),
});
type ProfileRow = {
id: string;
email?: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
};
const profileColumns = `
id,
display_name as name,
role,
avatar_image_url as "avatarImageUrl",
profile_headline as headline,
profile_bio as bio,
website_url as "websiteUrl",
linkedin_url as "linkedinUrl",
instagram_url as "instagramUrl",
youtube_url as "youtubeUrl",
profile_is_public as "isPublic"`;
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
export const profileRoutes: FastifyPluginAsync = async (app) => {
app.get('/me', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}, email from users where id = $1`,
[request.user.id],
);
return { data: result.rows[0] };
});
app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => {
const input = profileUpdateSchema.parse(request.body);
const isInstructor = request.user.role === 'instructor' || request.user.role === 'admin';
const result = await pool.query<ProfileRow>(
`update users set
display_name = $2,
avatar_image_url = case when $3::boolean then $4 else avatar_image_url end,
profile_headline = case when $3::boolean then $5 else profile_headline end,
profile_bio = case when $3::boolean then $6 else profile_bio end,
website_url = case when $3::boolean then $7 else website_url end,
linkedin_url = case when $3::boolean then $8 else linkedin_url end,
instagram_url = case when $3::boolean then $9 else instagram_url end,
youtube_url = case when $3::boolean then $10 else youtube_url end,
profile_is_public = case when $3::boolean then coalesce($11, profile_is_public) else profile_is_public end,
updated_at = now()
where id = $1
returning ${profileColumns}, email`,
[
request.user.id,
input.name,
isInstructor,
input.avatarImageUrl,
input.headline,
input.bio,
input.websiteUrl,
input.linkedinUrl,
input.instagramUrl,
input.youtubeUrl,
input.isPublic,
],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Profile not found' });
await recordAudit({ actorId: request.user.id, action: 'profile.updated', subjectType: 'user', subjectId: request.user.id, metadata: { public: profile.isPublic }, ipAddress: request.ip });
return { data: profile };
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
const result = await pool.query<ProfileRow>(
`select ${profileColumns}
from users
where id = $1 and profile_is_public and role in ('instructor', 'admin')`,
[instructorId],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Instructor profile not found' });
const courses = await pool.query(
`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`,
[instructorId],
);
return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } };
});
};