feat: add public instructor profiles
This commit is contained in:
@@ -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' });
|
||||
|
||||
123
server/src/routes/profiles.ts
Normal file
123
server/src/routes/profiles.ts
Normal 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) } };
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user