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

@@ -12,6 +12,8 @@ import { Course, UserRole } from './types';
import { SuperAdmin } from './pages/SuperAdmin'; import { SuperAdmin } from './pages/SuperAdmin';
import { AccessTokenPage } from './pages/AccessTokenPage'; import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning'; import { MyLearning } from './pages/MyLearning';
import { ProfilePage } from './pages/ProfilePage';
import { InstructorProfile } from './pages/InstructorProfile';
// Protected Route Component // Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
@@ -93,6 +95,8 @@ 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="/instrutores/:instructorId" 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,6 +2,7 @@ import React from 'react';
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
import { Course, SectionVariant } from '../types'; import { Course, SectionVariant } from '../types';
import { CourseCoverImage } from './CourseCoverImage'; import { CourseCoverImage } from './CourseCoverImage';
import { useNavigate } from 'react-router-dom';
interface CourseCardProps { interface CourseCardProps {
course: Course; course: Course;
@@ -16,6 +17,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick, onClick,
onOpenMaterials onOpenMaterials
}) => { }) => {
const navigate = useNavigate();
const widthClass = variant === 'landscape' const widthClass = variant === 'landscape'
? 'w-[320px] md:w-[420px]' ? 'w-[320px] md:w-[420px]'
: 'w-[200px] md:w-[260px]'; : 'w-[200px] md:w-[260px]';
@@ -99,7 +101,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
{course.instructor && ( {course.instructor && (
<> <>
<span className="w-1 h-1 rounded-[980px] bg-gray-600"></span> <span className="w-1 h-1 rounded-[980px] bg-gray-600"></span>
<span className="truncate">{course.instructor}</span> {course.instructorId ? <button onClick={(event) => { event.stopPropagation(); navigate(`/instrutores/${course.instructorId}`); }} className="truncate text-left transition hover:text-orange-400 hover:underline">{course.instructor}</button> : <span className="truncate">{course.instructor}</span>}
</> </>
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings } from 'lucide-react'; import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings, UserRound } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal'; import { SearchModal } from './SearchModal';
@@ -111,6 +111,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
<ChevronDown className={`h-4 w-4 transition-transform ${showAccountMenu ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-4 w-4 transition-transform ${showAccountMenu ? 'rotate-180' : ''}`} />
</button> </button>
{showAccountMenu && <div role="menu" className="absolute right-0 top-full mt-2 w-56 overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-1.5 shadow-2xl shadow-black/60"> {showAccountMenu && <div role="menu" className="absolute right-0 top-full mt-2 w-56 overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-1.5 shadow-2xl shadow-black/60">
<button role="menuitem" onClick={() => goTo('/perfil')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><UserRound className="h-4 w-4 text-orange-400" /> Meu perfil</button>
{user.role === 'student' && <button role="menuitem" onClick={() => goTo('/meu-aprendizado')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><BookOpenCheck className="h-4 w-4 text-orange-400" /> Meu aprendizado</button>} {user.role === 'student' && <button role="menuitem" onClick={() => goTo('/meu-aprendizado')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><BookOpenCheck className="h-4 w-4 text-orange-400" /> Meu aprendizado</button>}
{user.role === 'professor' && <button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Painel do instrutor</button>} {user.role === 'professor' && <button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Painel do instrutor</button>}
{user.role === 'superadmin' && <><button role="menuitem" onClick={() => goTo('/admin')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><ShieldCheck className="h-4 w-4 text-orange-400" /> Painel administrativo</button><button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Gerenciar cursos</button></>} {user.role === 'superadmin' && <><button role="menuitem" onClick={() => goTo('/admin')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><ShieldCheck className="h-4 w-4 text-orange-400" /> Painel administrativo</button><button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Gerenciar cursos</button></>}

View File

@@ -1,12 +1,13 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { User, UserRole } from '../types'; import { User, UserRole } from '../types';
import { authApi, clearSession, getSession, saveSession } from '../services/api'; import { ApiUser, authApi, clearSession, getSession, saveSession } from '../services/api';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (email: string, password: string) => Promise<User | null>; login: (email: string, password: string) => Promise<User | null>;
register: (name: string, email: string, password: string) => Promise<User | null>; register: (name: string, email: string, password: string) => Promise<User | null>;
logout: () => void; logout: () => void;
updateCurrentUser: (user: ApiUser) => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -67,8 +68,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
clearSession(); clearSession();
}; };
const updateCurrentUser = (apiUser: ApiUser) => {
const session = getSession();
if (session) saveSession({ ...session, user: apiUser });
setUser(toFrontendUser(apiUser));
};
return ( return (
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}> <AuthContext.Provider value={{ user, login, register, logout, updateCurrentUser, isLoading }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -0,0 +1,31 @@
import React, { useEffect, useState } from 'react';
import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Course } from '../types';
import { getInstructorProfile, InstructorProfileData } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard';
const networks = [
{ key: 'websiteUrl', label: 'Site', icon: Globe2 },
{ key: 'linkedinUrl', label: 'LinkedIn', icon: Linkedin },
{ key: 'instagramUrl', label: 'Instagram', icon: Instagram },
{ key: 'youtubeUrl', label: 'YouTube', icon: Youtube },
] as const;
export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { instructorId } = useParams<{ instructorId: string }>();
const [profile, setProfile] = useState<InstructorProfileData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading');
useEffect(() => {
if (!instructorId) return;
setState('loading');
getInstructorProfile(instructorId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [instructorId]);
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>;
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>;
};

139
pages/ProfilePage.tsx Normal file
View File

@@ -0,0 +1,139 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ExternalLink, Globe2, Instagram, Linkedin, Loader2, Save, UserRound, Youtube } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { Profile, ProfileInput, instructorApi, profileApi } from '../services/api';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom';
const emptyProfile = (user: { id: string; email: string; name: string; role: string }): Profile => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role === 'superadmin' ? 'admin' : user.role === 'professor' ? 'instructor' : 'student',
avatarImageUrl: null,
headline: '',
bio: '',
websiteUrl: null,
linkedinUrl: null,
instagramUrl: null,
youtubeUrl: null,
isPublic: true,
});
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
export const ProfilePage: React.FC = () => {
const { user, updateCurrentUser } = useAuth();
const { showToast } = useToast();
const navigate = useNavigate();
const fileInput = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const canPublishProfile = user?.role === 'professor' || user?.role === 'superadmin';
useEffect(() => {
if (!user) return;
profileApi.me()
.then((response) => setProfile(response.data))
.catch(() => {
setProfile(emptyProfile(user));
showToast('Não foi possível carregar todos os dados do perfil.', 'error');
})
.finally(() => setIsLoading(false));
}, [showToast, user]);
if (isLoading || !profile || !user) {
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>;
}
const update = <K extends keyof Profile>(key: K, value: Profile[K]) => setProfile((current) => current ? { ...current, [key]: value } : current);
const uploadAvatar = async (file?: File) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('Selecione uma imagem JPG, PNG ou WebP.', 'error');
return;
}
setIsUploading(true);
try {
const response = await instructorApi.uploadCover(file);
update('avatarImageUrl', response.data.coverImageUrl);
showToast('Foto enviada. Salve o perfil para publicar a alteração.', 'success');
} catch {
showToast('Não foi possível enviar a foto.', 'error');
} finally {
setIsUploading(false);
}
};
const save = async (event: React.FormEvent) => {
event.preventDefault();
const input: ProfileInput = {
name: profile.name,
avatarImageUrl: profile.avatarImageUrl,
headline: profile.headline,
bio: profile.bio,
websiteUrl: profile.websiteUrl,
linkedinUrl: profile.linkedinUrl,
instagramUrl: profile.instagramUrl,
youtubeUrl: profile.youtubeUrl,
isPublic: profile.isPublic,
};
setIsSaving(true);
try {
const response = await profileApi.update(input);
setProfile(response.data);
updateCurrentUser({ id: user.id, email: user.email, name: response.data.name, role: response.data.role });
showToast('Perfil salvo com sucesso.', 'success');
} catch {
showToast('Não foi possível salvar o perfil. Verifique os links informados.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<main className="mx-auto min-h-screen w-full max-w-5xl px-5 pb-20 pt-28 sm:px-8">
<div className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<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>
<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>
</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>}
</div>
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
<section className="border-b border-white/10 bg-gradient-to-r from-orange-500/15 via-zinc-950 to-zinc-950 p-6 sm:p-8">
<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">
{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>}
</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>
<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>
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
<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>
{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">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>
{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>}
</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>
</main>
);
};
const UrlField: React.FC<{ icon: React.ReactNode; label: string; value: string | null; placeholder: string; onChange: (value: string) => void }> = ({ icon, label, value, placeholder, onChange }) => <label className="block"><span className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400"><span className="text-orange-400 [&>svg]:h-4 [&>svg]:w-4">{icon}</span>{label}</span><input type="url" value={value || ''} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={inputClass} /></label>;

View File

@@ -0,0 +1,13 @@
alter table users
add column if not exists avatar_image_url text,
add column if not exists profile_headline text not null default '',
add column if not exists profile_bio text not null default '',
add column if not exists website_url text,
add column if not exists linkedin_url text,
add column if not exists instagram_url text,
add column if not exists youtube_url text,
add column if not exists profile_is_public boolean not null default true;
create index if not exists users_public_instructor_profiles_index
on users (id)
where profile_is_public and role in ('instructor', 'admin');

View File

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

View File

@@ -10,6 +10,23 @@ export interface ApiUser {
role: ApiRole; role: ApiRole;
} }
export interface Profile {
id: string;
email?: string;
name: string;
role: ApiRole;
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
}
export type ProfileInput = Omit<Profile, 'id' | 'email' | 'role'>;
export interface ManagedUser { export interface ManagedUser {
id: string; id: string;
email: string; email: string;
@@ -188,6 +205,12 @@ export const adminApi = {
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); }, async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
}; };
export const profileApi = {
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 instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); },
};
export const instructorApi = { export const instructorApi = {
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); }, async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); }, async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); },

View File

@@ -11,7 +11,7 @@ type ApiAsset = {
description: string; description: string;
}; };
type ApiCourse = { export type ApiCourse = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
@@ -23,7 +23,7 @@ type ApiCourse = {
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
progress?: number; progress?: number;
lastActivity?: string | null; lastActivity?: string | null;
instructor: { name: string }; instructor: { id: string; name: string };
lessons: Array<{ lessons: Array<{
id: string; id: string;
title: string; title: string;
@@ -108,7 +108,7 @@ const toCourseDuration = (lessons: ApiCourse['lessons']) => {
return lessons.length ? 'Processando' : '—'; return lessons.length ? 'Processando' : '—';
}; };
const toCourse = (course: ApiCourse): Course => ({ export const toCourse = (course: ApiCourse): Course => ({
id: course.id, id: course.id,
title: course.title, title: course.title,
description: course.description, description: course.description,
@@ -118,6 +118,7 @@ const toCourse = (course: ApiCourse): Course => ({
coverImagePositionX: course.coverImagePositionX ?? 50, coverImagePositionX: course.coverImagePositionX ?? 50,
coverImagePositionY: course.coverImagePositionY ?? 50, coverImagePositionY: course.coverImagePositionY ?? 50,
instructor: course.instructor.name, instructor: course.instructor.name,
instructorId: course.instructor.id,
status: course.status === 'draft' ? 'draft' : 'published', status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress, progress: course.progress,
lastActivity: course.lastActivity || undefined, lastActivity: course.lastActivity || undefined,
@@ -206,6 +207,24 @@ export const getCourses = async (): Promise<Course[]> => {
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export type InstructorProfileData = {
id: string;
name: string;
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
courses: Course[];
};
export const getInstructorProfile = async (instructorId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/instructors/${instructorId}`);
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;

View File

@@ -51,6 +51,7 @@ export interface Course {
duration: string; // Total duration or lesson count duration: string; // Total duration or lesson count
description: string; description: string;
instructor?: string; instructor?: string;
instructorId?: string;
instructorRole?: string; instructorRole?: string;
tips?: string[]; // Key takeaways / instructor tips tips?: string[]; // Key takeaways / instructor tips
attachments?: Attachment[]; // Downloadable materials across entire course attachments?: Attachment[]; // Downloadable materials across entire course