diff --git a/App.tsx b/App.tsx index ed52806..9ddb440 100644 --- a/App.tsx +++ b/App.tsx @@ -10,6 +10,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import { ToastProvider } from './context/ToastContext'; import { Course, UserRole } from './types'; import { SuperAdmin } from './pages/SuperAdmin'; +import { AccessTokenPage } from './pages/AccessTokenPage'; // Protected Route Component const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { @@ -90,6 +91,8 @@ function AppContent() { } /> + } /> + } /> {/* Catch all redirect */} } /> diff --git a/pages/AccessTokenPage.tsx b/pages/AccessTokenPage.tsx new file mode 100644 index 0000000..0a7cc26 --- /dev/null +++ b/pages/AccessTokenPage.tsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { authApi, saveSession } from '../services/api'; + +export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }) => { + const location = useLocation(); + const navigate = useNavigate(); + const token = new URLSearchParams(location.search).get('token') || ''; + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const submit = async (event: React.FormEvent) => { + event.preventDefault(); setSaving(true); setError(''); + try { + if (mode === 'invite') { + const session = await authApi.acceptInvitation(token, name, password); + saveSession(session); navigate(session.user.role === 'admin' ? '/admin' : session.user.role === 'instructor' ? '/gerenciar' : '/'); + } else { await authApi.resetPassword(token, password); navigate('/'); } + } catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); } + finally { setSaving(false); } + }; + return

{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}

{mode === 'invite' && setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />} setPassword(event.target.value)} placeholder="Nova senha (mínimo 12 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error &&

{error}

}
; +}; diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index a1442c5..5c1d56d 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -27,9 +27,10 @@ import { } from 'lucide-react'; import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types'; import { useToast } from '../context/ToastContext'; -import { getManagedCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db'; +import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db'; import { CATEGORIES } from '../constants'; import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; +import { instructorApi, InstructorAnalytics } from '../services/api'; // 1. Create/Edit Course Modal const CourseModal: React.FC<{ @@ -525,6 +526,7 @@ export const ManageCourses: React.FC = () => { const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]); const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(''); + const [analytics, setAnalytics] = useState(null); const { showToast } = useToast(); @@ -532,6 +534,8 @@ export const ManageCourses: React.FC = () => { setLoading(true); const data = await getManagedCourses(); setCourses(data); + const analyticsResponse = await instructorApi.analytics(); + setAnalytics(analyticsResponse.data); // Load all comments const commentsAccumulator: {comment: Comment, courseTitle: string}[] = []; @@ -578,12 +582,7 @@ export const ManageCourses: React.FC = () => { const target = allComments.find(c => c.comment.id === commentId); if (target) { - const updatedComment: Comment = { - ...target.comment, - reply: replyText, - replyDate: new Date().toLocaleDateString('pt-BR') - }; - await saveComment(updatedComment); + await replyToComment(commentId, replyText); showToast('Resposta enviada com sucesso!', 'success'); setReplyingTo(null); setReplyText(''); @@ -591,8 +590,17 @@ export const ManageCourses: React.FC = () => { } }; + const handleModerateComment = async (commentId: string) => { + if (!window.confirm('Remover este comentário permanentemente?')) return; + await moderateComment(commentId); + showToast('Comentário removido.', 'success'); + loadData(); + }; + // Metrics - const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0); + const totalLearners = analytics?.learners ?? 0; + const completedLessons = analytics?.completedLessons ?? 0; + const totalQuestions = analytics?.comments ?? allComments.length; const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0); const totalMaterials = courses.reduce((acc, c) => { const globalCount = c.attachments?.length || 0; @@ -631,7 +639,7 @@ export const ManageCourses: React.FC = () => { {/* Metrics Row */} -
+
Total de Cursos

{courses.length}

@@ -645,8 +653,16 @@ export const ManageCourses: React.FC = () => {

{totalMaterials}

- Visualizações Acumuladas -

{totalViews.toLocaleString('pt-BR')}

+ Alunos com Progresso +

{totalLearners.toLocaleString('pt-BR')}

+
+
+ Aulas Concluídas +

{completedLessons.toLocaleString('pt-BR')}

+
+
+ Dúvidas Recebidas +

{totalQuestions.toLocaleString('pt-BR')}

@@ -810,6 +826,7 @@ export const ManageCourses: React.FC = () => { )}
)} + )) )} diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index dcf36c3..c73cffa 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react'; -import { adminApi, ManagedUser } from '../services/api'; +import { adminApi, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { useToast } from '../context/ToastContext'; import { useNavigate } from 'react-router-dom'; @@ -19,12 +19,20 @@ export const SuperAdmin: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [updatingUserId, setUpdatingUserId] = useState(null); const [query, setQuery] = useState(''); + const [analytics, setAnalytics] = useState(null); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState<'student' | 'instructor'>('student'); + const [accessLink, setAccessLink] = useState(''); + const [selectedUser, setSelectedUser] = useState(null); + const [loadingDetailId, setLoadingDetailId] = useState(null); const loadUsers = async () => { setIsLoading(true); try { const response = await adminApi.listUsers(); setUsers(response.data); + const dashboard = await adminApi.dashboard(); + setAnalytics(dashboard.data); } catch { showToast('Não foi possível carregar os usuários.', 'error'); } finally { @@ -59,6 +67,28 @@ export const SuperAdmin: React.FC = () => { const activeUsers = users.filter((account) => account.isActive).length; const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length; + const createInvite = async (event: React.FormEvent) => { + event.preventDefault(); + try { + const response = await adminApi.invite(inviteEmail, inviteRole); + setAccessLink(response.data.inviteUrl); setInviteEmail(''); showToast('Link de convite criado.', 'success'); + } catch { showToast('Não foi possível criar o convite.', 'error'); } + }; + const resetPassword = async (account: ManagedUser) => { + try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); } + catch { showToast('Não foi possível criar o link.', 'error'); } + }; + const showUserDetail = async (account: ManagedUser) => { + setLoadingDetailId(account.id); + try { + const response = await adminApi.userDetail(account.id); + setSelectedUser(response.data); + } catch { + showToast('Não foi possível carregar os detalhes do usuário.', 'error'); + } finally { + setLoadingDetailId(null); + } + }; return (
@@ -86,12 +116,42 @@ export const SuperAdmin: React.FC = () => { -
- } label="Usuários cadastrados" value={users.length} /> - } label="Contas ativas" value={activeUsers} /> +
+ } label="Usuários cadastrados" value={analytics?.totalUsers ?? users.length} /> + } label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /> } label="Instrutores ativos" value={instructorUsers} /> + } label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /> + } label="Aulas concluídas" value={analytics?.completedLessons ?? 0} />
+
+

Convidar usuário

+
+ setInviteEmail(event.target.value)} placeholder="E-mail do convidado" className="flex-1 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white" /> + + +
+ {accessLink &&
} +
+ + {selectedUser && ( +
+
+
+

Detalhes do usuário

+

{selectedUser.name}

+

{selectedUser.email} · {roleLabel[selectedUser.role]}

+
+ +
+
+ + + +
+
+ )} +

Usuários

@@ -116,6 +176,7 @@ export const SuperAdmin: React.FC = () => { Perfil Status Cadastro + Ações @@ -151,6 +212,12 @@ export const SuperAdmin: React.FC = () => { {new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))} + +
+ + +
+ ); })} @@ -169,3 +236,10 @@ const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }
{value}
); + +const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); diff --git a/server/migrations/004_account_access_tokens.sql b/server/migrations/004_account_access_tokens.sql new file mode 100644 index 0000000..a4feed3 --- /dev/null +++ b/server/migrations/004_account_access_tokens.sql @@ -0,0 +1,15 @@ +create type account_token_purpose as enum ('invitation', 'password_reset'); + +create table account_access_tokens ( + id uuid primary key default gen_random_uuid(), + email text not null, + role user_role not null default 'student', + purpose account_token_purpose not null, + token_hash text not null unique, + expires_at timestamptz not null, + used_at timestamptz, + created_by uuid not null references users(id), + created_at timestamptz not null default now() +); + +create index account_access_tokens_lookup_index on account_access_tokens (token_hash) where used_at is null; diff --git a/server/src/app.ts b/server/src/app.ts index e3fde6a..8730000 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,7 +15,7 @@ export function buildApp() { app.register(cors, { origin: config.FRONTEND_ORIGIN, - methods: ['GET', 'POST', 'PATCH', 'DELETE'], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], }); app.setErrorHandler((error, _request, reply) => { diff --git a/server/src/auth/account-tokens.ts b/server/src/auth/account-tokens.ts new file mode 100644 index 0000000..fc3eea4 --- /dev/null +++ b/server/src/auth/account-tokens.ts @@ -0,0 +1,4 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export const createRawToken = () => randomBytes(32).toString('base64url'); +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 14f8398..8acc883 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,6 +1,8 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { pool } from '../db/pool.js'; +import { createRawToken, hashToken } from '../auth/account-tokens.js'; +import { config } from '../config.js'; const userParamsSchema = z.object({ userId: z.string().uuid(), @@ -12,6 +14,7 @@ const updateUserSchema = z.object({ }).refine((input) => input.role !== undefined || input.isActive !== undefined, { message: 'Provide at least one field to update', }); +const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') }); export const adminRoutes: FastifyPluginAsync = async (app) => { const adminAccess = { preHandler: app.requireRoles(['admin']) }; @@ -26,6 +29,56 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { return { data: result.rows }; }); + app.get('/dashboard', adminAccess, async () => { + const result = await pool.query( + `select + (select count(*)::int from users) as "totalUsers", + (select count(*)::int from users where is_active) as "activeUsers", + (select count(*)::int from courses where status = 'published') as "publishedCourses", + (select count(*)::int from lesson_progress where completed_at is not null) as "completedLessons", + (select count(*)::int from comments) as "comments"`, + ); + return { data: result.rows[0] }; + }); + + app.get('/users/:userId', adminAccess, async (request, reply) => { + const { userId } = userParamsSchema.parse(request.params); + const result = await pool.query( + `select u.id, u.email, u.display_name as name, u.role, u.is_active as "isActive", u.created_at as "createdAt", + (select count(*)::int from lesson_progress lp where lp.user_id = u.id and lp.completed_at is not null) as "completedLessons", + (select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt" + from users u where u.id = $1`, [userId], + ); + if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' }); + return { data: result.rows[0] }; + }); + + app.post('/invitations', adminAccess, async (request, reply) => { + const input = invitationSchema.parse(request.body); + const existing = await pool.query('select 1 from users where email = $1', [input.email]); + if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' }); + const rawToken = createRawToken(); + await pool.query( + `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) + values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`, + [input.email, input.role, hashToken(rawToken), request.user.id], + ); + return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } }); + }); + + app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => { + const { userId } = userParamsSchema.parse(request.params); + const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]); + if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' }); + const rawToken = createRawToken(); + await pool.query( + `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) + values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`, + [account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id], + ); + return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } }; + }); + app.patch('/users/:userId', adminAccess, async (request, reply) => { const { userId } = userParamsSchema.parse(request.params); const input = updateUserSchema.parse(request.body); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 571e455..e8708ba 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { hashPassword, verifyPassword } from '../auth/passwords.js'; +import { hashToken } from '../auth/account-tokens.js'; import type { AuthUser } from '../auth/plugin.js'; import { pool } from '../db/pool.js'; @@ -12,6 +13,7 @@ const credentialsSchema = z.object({ const registerSchema = credentialsSchema.extend({ name: z.string().trim().min(2).max(120), }); +const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() }); type UserRow = { id: string; @@ -30,6 +32,60 @@ const serializeUser = (user: UserRow): AuthUser => ({ }); export const authRoutes: FastifyPluginAsync = async (app) => { + app.post('/accept-invitation', async (request, reply) => { + const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body); + const client = await pool.connect(); + try { + await client.query('begin'); + const token = await client.query<{ email: string; role: AuthUser['role'] }>( + `update account_access_tokens set used_at = now() + where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now() + returning email, role`, [hashToken(input.token)], + ); + if (!token.rows[0]) { + await client.query('rollback'); + return reply.code(400).send({ error: 'This invitation is invalid or expired' }); + } + const result = await client.query( + `insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4) + returning id, email, display_name, role, password_hash, is_active`, + [token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role], + ); + await client.query('commit'); + const user = serializeUser(result.rows[0]); + return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user }); + } catch { + await client.query('rollback'); + return reply.code(409).send({ error: 'This invitation email already has an account' }); + } finally { + client.release(); + } + }); + + app.post('/reset-password', async (request, reply) => { + const input = tokenPasswordSchema.parse(request.body); + const client = await pool.connect(); + try { + await client.query('begin'); + const token = await client.query<{ email: string }>( + `update account_access_tokens set used_at = now() + where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now() + returning email`, [hashToken(input.token)], + ); + if (!token.rows[0]) { + await client.query('rollback'); + return reply.code(400).send({ error: 'This reset link is invalid or expired' }); + } + await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]); + await client.query('commit'); + return reply.code(204).send(); + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } + }); app.post('/register', async (request, reply) => { const input = registerSchema.parse(request.body); const passwordHash = await hashPassword(input.password); diff --git a/server/src/routes/learning.ts b/server/src/routes/learning.ts index fad5b9f..47a9d7a 100644 --- a/server/src/routes/learning.ts +++ b/server/src/routes/learning.ts @@ -19,6 +19,8 @@ const commentSchema = z.object({ lessonId: z.string().uuid().nullable().optional(), text: z.string().trim().min(1).max(4000), }); +const commentParamsSchema = z.object({ commentId: z.string().uuid() }); +const replySchema = z.object({ text: z.string().trim().min(1).max(4000) }); const ensurePublishedCourse = async (courseId: string) => { const result = await pool.query( @@ -29,6 +31,16 @@ const ensurePublishedCourse = async (courseId: string) => { }; export const learningRoutes: FastifyPluginAsync = async (app) => { + const canModerateComment = async (commentId: string, user: { id: string; role: string }) => { + const result = await pool.query<{ instructor_id: string }>( + `select c.instructor_id + from comments cm join courses c on c.id = cm.course_id + where cm.id = $1`, + [commentId], + ); + const comment = result.rows[0]; + return Boolean(comment && (user.role === 'admin' || comment.instructor_id === user.id)); + }; app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => { const { courseId } = courseParamsSchema.parse(request.params); if (!(await ensurePublishedCourse(courseId))) { @@ -123,4 +135,24 @@ export const learningRoutes: FastifyPluginAsync = async (app) => { ); return reply.code(201).send({ data: result.rows[0] }); }); + + app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => { + const { commentId } = commentParamsSchema.parse(request.params); + const input = replySchema.parse(request.body); + if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' }); + await pool.query( + `insert into comment_replies (comment_id, author_id, body) + values ($1, $2, $3) + on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`, + [commentId, request.user.id, input.text], + ); + return { data: { id: commentId } }; + }); + + app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => { + const { commentId } = commentParamsSchema.parse(request.params); + if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' }); + await pool.query('delete from comments where id = $1', [commentId]); + return reply.code(204).send(); + }); }; diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts index 3ceab5f..a138b07 100644 --- a/server/src/routes/manage-courses.ts +++ b/server/src/routes/manage-courses.ts @@ -23,6 +23,7 @@ const assetSchema = z.object({ }); const lessonSchema = z.object({ + id: z.string().uuid().optional(), title: z.string().trim().min(1).max(255), description: z.string().max(5000).default(''), durationSeconds: z.number().int().nonnegative().nullable().optional(), @@ -79,20 +80,52 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les } async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) { - // This replacement strategy is safe before student progress exists. The next - // iteration will switch to per-lesson updates to preserve historical progress. - await client.query('delete from lessons where course_id = $1', [courseId]); + const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]); + const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id)); + const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : [])); + const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId)); + if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.'); + + const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId)); + if (removedLessonIds.length > 0) { + const usage = await client.query<{ id: string }>( + `select l.id + from lessons l + where l.id = any($1::uuid[]) + and (exists (select 1 from lesson_progress lp where lp.lesson_id = l.id) + or exists (select 1 from comments c where c.lesson_id = l.id))`, + [removedLessonIds], + ); + if (usage.rowCount) { + throw new Error('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.'); + } + await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]); + } + + // Move existing positions away first so drag/reordering cannot violate the + // unique (course_id, position) constraint while updates are applied. + await client.query('update lessons set position = position + 10000 where course_id = $1', [courseId]); await client.query('delete from assets where course_id = $1', [courseId]); await insertAssets(client, { courseId }, input.assets); for (const [index, lesson] of input.lessons.entries()) { - const lessonResult = await client.query<{ id: string }>( + const lessonId = lesson.id ?? (await client.query<{ id: string }>( `insert into lessons (course_id, title, description, position, duration_seconds, access_level) values ($1, $2, $3, $4, $5, $6) returning id`, [courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel], - ); - const lessonId = lessonResult.rows[0].id; + )).rows[0].id; + + if (lesson.id) { + await client.query( + `update lessons + set title = $2, description = $3, position = $4, duration_seconds = $5, access_level = $6 + where id = $1`, + [lessonId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel], + ); + await client.query('delete from lesson_media where lesson_id = $1', [lessonId]); + await client.query('delete from assets where lesson_id = $1', [lessonId]); + } for (const media of lesson.media) { await client.query( @@ -127,6 +160,25 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { return { data: result.rows }; }); + app.get('/analytics', manageAccess, async (request) => { + const isAdmin = request.user.role === 'admin'; + const result = await pool.query( + `select + count(distinct c.id)::int as "courses", + count(distinct l.id)::int as "lessons", + count(distinct lp.user_id)::int as "learners", + count(distinct lp.lesson_id) filter (where lp.completed_at is not null)::int as "completedLessons", + count(distinct cm.id)::int as "comments" + from courses c + left join lessons l on l.course_id = c.id + left join lesson_progress lp on lp.lesson_id = l.id + left join comments cm on cm.course_id = c.id + where c.status <> 'archived' and ($1::boolean or c.instructor_id = $2)`, + [isAdmin, request.user.id], + ); + return { data: result.rows[0] }; + }); + app.post('/', manageAccess, async (request, reply) => { const input = courseSchema.parse(request.body); const client = await pool.connect(); diff --git a/services/api.ts b/services/api.ts index 5ee7f51..edbb5dc 100644 --- a/services/api.ts +++ b/services/api.ts @@ -18,6 +18,12 @@ export interface ManagedUser { isActive: boolean; createdAt: string; } +export interface ManagedUserDetail extends ManagedUser { + completedLessons: number; + lastLearningAt: string | null; +} +export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; } +export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; } interface Session { token: string; @@ -75,9 +81,16 @@ export const authApi = { async me() { return apiRequest<{ user: ApiUser }>('/auth/me'); }, + async acceptInvitation(token: string, name: string, password: string) { + return apiRequest('/auth/accept-invitation', { method: 'POST', body: JSON.stringify({ token, name, password }) }); + }, + async resetPassword(token: string, password: string) { + return apiRequest('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) }); + }, }; export const adminApi = { + async dashboard() { return apiRequest<{ data: PlatformAnalytics }>('/admin/dashboard'); }, async listUsers() { return apiRequest<{ data: ManagedUser[] }>('/admin/users'); }, @@ -87,4 +100,15 @@ export const adminApi = { body: JSON.stringify(update), }); }, + async invite(email: string, role: 'student' | 'instructor') { + return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) }); + }, + async passwordReset(userId: string) { + return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' }); + }, + async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); }, +}; + +export const instructorApi = { + async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); }, }; diff --git a/services/db.ts b/services/db.ts index df4ef3d..0473426 100644 --- a/services/db.ts +++ b/services/db.ts @@ -101,6 +101,7 @@ const toCoursePayload = (course: Course) => ({ status: 'published' as const, assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), lessons: course.lessons.map((lesson) => ({ + id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined, title: lesson.title, description: lesson.description || '', durationSeconds: toSeconds(lesson.duration), @@ -182,6 +183,14 @@ export const saveComment = async (comment: Pick => { + await apiRequest(`/comments/${commentId}/reply`, { method: 'PUT', body: JSON.stringify({ text }) }); +}; + +export const moderateComment = async (commentId: string): Promise => { + await apiRequest(`/comments/${commentId}`, { method: 'DELETE' }); +}; + export const getCompletedLessonIds = async (courseId: string): Promise => { const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`); return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);