feat: let students customize public profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 1m2s
CI / Build and publish Docker images (push) Successful in 23s

This commit is contained in:
Cauê Faleiros
2026-09-08 14:46:16 -03:00
parent ad736758a7
commit 54f598cd99
7 changed files with 54 additions and 38 deletions

View File

@@ -97,6 +97,7 @@ function AppContent() {
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} /> <Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} /> <Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} /> <Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/perfis/:profileId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} /> <Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} /> <Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react'; import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Course } from '../types'; import { Course } from '../types';
import { getInstructorProfile, InstructorProfileData } from '../services/db'; import { getPublicProfile, InstructorProfileData } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage'; import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard'; import { CourseCard } from '../components/CourseCard';
@@ -14,18 +14,21 @@ const networks = [
] as const; ] as const;
export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => { export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { instructorId } = useParams<{ instructorId: string }>(); const { instructorId, profileId } = useParams<{ instructorId?: string; profileId?: string }>();
const publicProfileId = instructorId || profileId;
const [profile, setProfile] = useState<InstructorProfileData | null>(null); const [profile, setProfile] = useState<InstructorProfileData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading'); const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading');
useEffect(() => { useEffect(() => {
if (!instructorId) return; if (!publicProfileId) return;
setState('loading'); setState('loading');
getInstructorProfile(instructorId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing')); getPublicProfile(publicProfileId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [instructorId]); }, [publicProfileId]);
if (state === 'loading') return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>; if (state === 'loading') return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>; if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>;
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Instrutor</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className="mx-auto grid max-w-6xl gap-12 px-6 py-12 sm:px-8 lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]"><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || 'Este instrutor ainda não adicionou uma apresentação.'}</p></aside><section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section></div></main>; const isInstructor = profile.role === 'instructor' || profile.role === 'admin';
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">{isInstructor ? 'Instrutor' : 'Aluno'}</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className={`mx-auto max-w-6xl gap-12 px-6 py-12 sm:px-8 ${isInstructor ? 'grid lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]' : 'max-w-3xl'}`}><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || `${isInstructor ? 'Este instrutor' : 'Este aluno'} ainda não adicionou uma apresentação.`}</p></aside>{isInstructor && <section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section>}</div></main>;
}; };

View File

@@ -33,8 +33,6 @@ export const ProfilePage: React.FC = () => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const canPublishProfile = user?.role === 'professor' || user?.role === 'superadmin';
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
profileApi.me() profileApi.me()
@@ -102,9 +100,9 @@ export const ProfilePage: React.FC = () => {
<div> <div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p> <p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1> <h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1>
<p className="mt-2 max-w-xl text-sm text-gray-400">{canPublishProfile ? 'Apresente sua experiência e conecte os alunos aos seus cursos.' : 'Mantenha seus dados pessoais atualizados.'}</p> <p className="mt-2 max-w-xl text-sm text-gray-400">Apresente quem você é, compartilhe seus links e escolha a visibilidade do seu perfil.</p>
</div> </div>
{canPublishProfile && profile.isPublic && <button onClick={() => navigate(`/instrutores/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>} {profile.isPublic && <button onClick={() => navigate(`/perfis/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
</div> </div>
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30"> <form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
@@ -112,9 +110,9 @@ export const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-5 sm:flex-row sm:items-center"> <div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl"> <div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl">
{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>} {profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>}
{canPublishProfile && <button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>} <button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>
</div> </div>
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p>{canPublishProfile && <p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis no seu perfil público.</p>}</div> <div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p><p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis quando seu perfil estiver público.</p></div>
</div> </div>
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} /> <input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} />
</section> </section>
@@ -122,13 +120,11 @@ export const ProfilePage: React.FC = () => {
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]"> <div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
<section className="space-y-5"> <section className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label> <label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label>
{canPublishProfile && <> <label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label> <label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sobre você, seus interesses ou sua experiência." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sua experiência e o que os alunos podem esperar aprender com você." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
</>}
</section> </section>
{canPublishProfile && <section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que alunos vejam sua apresentação e seus cursos publicados.</span></span></label></section>} <section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que outras pessoas vejam sua apresentação e seus links. Perfis de instrutores também exibem seus cursos publicados.</span></span></label></section>
</div> </div>
<div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div> <div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div>
</form> </form>

View File

@@ -72,6 +72,7 @@ async function assertVideoAccess(videoId: string, user: { id: string; role: stri
export const mediaRoutes: FastifyPluginAsync = async (app) => { export const mediaRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) }; const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
const coverAccess = { preHandler: app.authenticate };
app.get('/bunny/config', manageAccess, async () => ({ app.get('/bunny/config', manageAccess, async () => ({
data: { data: {
@@ -80,14 +81,14 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
}, },
})); }));
app.get('/covers/config', manageAccess, async () => ({ app.get('/covers/config', coverAccess, async () => ({
data: { data: {
configured: isBunnyStorageConfigured(), configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024, maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
}, },
})); }));
app.post('/covers', manageAccess, async (request, reply) => { app.post('/covers', coverAccess, async (request, reply) => {
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase(); const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
const body = request.body; const body = request.body;
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de imagem.' }); if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de imagem.' });

View File

@@ -4,6 +4,7 @@ import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { courseSelect } from './courses.js'; import { courseSelect } from './courses.js';
const profileParamsSchema = z.object({ profileId: z.string().uuid() });
const instructorParamsSchema = z.object({ instructorId: z.string().uuid() }); const instructorParamsSchema = z.object({ instructorId: z.string().uuid() });
const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional() const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional()
@@ -68,25 +69,23 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => { app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => {
const input = profileUpdateSchema.parse(request.body); const input = profileUpdateSchema.parse(request.body);
const isInstructor = request.user.role === 'instructor' || request.user.role === 'admin';
const result = await pool.query<ProfileRow>( const result = await pool.query<ProfileRow>(
`update users set `update users set
display_name = $2, display_name = $2,
avatar_image_url = case when $3::boolean then $4 else avatar_image_url end, avatar_image_url = $3,
profile_headline = case when $3::boolean then $5 else profile_headline end, profile_headline = $4,
profile_bio = case when $3::boolean then $6 else profile_bio end, profile_bio = $5,
website_url = case when $3::boolean then $7 else website_url end, website_url = $6,
linkedin_url = case when $3::boolean then $8 else linkedin_url end, linkedin_url = $7,
instagram_url = case when $3::boolean then $9 else instagram_url end, instagram_url = $8,
youtube_url = case when $3::boolean then $10 else youtube_url end, youtube_url = $9,
profile_is_public = case when $3::boolean then coalesce($11, profile_is_public) else profile_is_public end, profile_is_public = coalesce($10, profile_is_public),
updated_at = now() updated_at = now()
where id = $1 where id = $1
returning ${profileColumns}, email`, returning ${profileColumns}, email`,
[ [
request.user.id, request.user.id,
input.name, input.name,
isInstructor,
input.avatarImageUrl, input.avatarImageUrl,
input.headline, input.headline,
input.bio, input.bio,
@@ -103,21 +102,30 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
return { data: profile }; return { data: profile };
}); });
app.get('/instructors/:instructorId', async (request, reply) => { const getPublicProfile = async (profileId: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }, instructorOnly = false) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
const result = await pool.query<ProfileRow>( const result = await pool.query<ProfileRow>(
`select ${profileColumns} `select ${profileColumns}
from users from users
where id = $1 and profile_is_public and role in ('instructor', 'admin')`, where id = $1 and profile_is_public${instructorOnly ? " and role in ('instructor', 'admin')" : ''}`,
[instructorId], [profileId],
); );
const profile = result.rows[0]; const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Perfil de instrutor não encontrado.' }); if (!profile) return reply.code(404).send({ error: instructorOnly ? 'Perfil de instrutor não encontrado.' : 'Perfil não encontrado ou não está público.' });
const courses = await pool.query( const canPublishCourses = profile.role === 'instructor' || profile.role === 'admin';
`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`, const courses = canPublishCourses
[instructorId], ? await pool.query(`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`, [profileId])
); : { rows: [] };
return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } }; return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } };
};
app.get('/:profileId', async (request, reply) => {
const { profileId } = profileParamsSchema.parse(request.params);
return getPublicProfile(profileId, reply);
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
return getPublicProfile(instructorId, reply, true);
}); });
}; };

View File

@@ -209,6 +209,7 @@ export const adminApi = {
export const profileApi = { export const profileApi = {
async me() { return apiRequest<{ data: Profile }>('/profiles/me'); }, async me() { return apiRequest<{ data: Profile }>('/profiles/me'); },
async update(input: ProfileInput) { return apiRequest<{ data: Profile }>('/profiles/me', { method: 'PATCH', body: JSON.stringify(input) }); }, async update(input: ProfileInput) { return apiRequest<{ data: Profile }>('/profiles/me', { method: 'PATCH', body: JSON.stringify(input) }); },
async public(profileId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/${profileId}`); },
async instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); }, async instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); },
}; };

View File

@@ -210,6 +210,7 @@ export const getCourses = async (): Promise<Course[]> => {
export type InstructorProfileData = { export type InstructorProfileData = {
id: string; id: string;
name: string; name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null; avatarImageUrl: string | null;
headline: string; headline: string;
bio: string; bio: string;
@@ -225,6 +226,11 @@ export const getInstructorProfile = async (instructorId: string): Promise<Instru
return { ...response.data, courses: response.data.courses.map(toCourse) }; return { ...response.data, courses: response.data.courses.map(toCourse) };
}; };
export const getPublicProfile = async (profileId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/${profileId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => { export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths'); const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
return response.data; return response.data;