-
} 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} />
+
+
+ {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 }) => (
+
+);
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);