fix: localize validation and api messages
This commit is contained in:
@@ -25,6 +25,18 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
if (!email.trim() || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||||
|
setError('Informe um e-mail válido.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRegistering && name.trim().length < 2) {
|
||||||
|
setError('Informe seu nome.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('A senha deve ter pelo menos 8 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
if (isRegistering && role === 'professor') {
|
if (isRegistering && role === 'professor') {
|
||||||
@@ -83,11 +95,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form noValidate onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
|
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus />}
|
||||||
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
|
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus={!isRegistering} />
|
||||||
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
|
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
|
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const submit = async (event: React.FormEvent) => {
|
const submit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault(); setSaving(true); setError('');
|
event.preventDefault(); setError('');
|
||||||
|
if (mode === 'invite' && name.trim().length < 2) { setError('Informe seu nome.'); return; }
|
||||||
|
if (password.length < 8) { setError('A senha deve ter pelo menos 8 caracteres.'); return; }
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (mode === 'invite') {
|
if (mode === 'invite') {
|
||||||
const session = await authApi.acceptInvitation(token, name, password);
|
const session = await authApi.acceptInvitation(token, name, password);
|
||||||
@@ -20,5 +23,5 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
|
|||||||
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
|
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={8} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
|
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form noValidate onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ export function buildApp() {
|
|||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() });
|
return reply.code(400).send({ error: 'Dados inválidos.', details: error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
return reply.code(500).send({ error: 'Internal server error' });
|
return reply.code(500).send({ error: 'Erro interno no servidor.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
|
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
|||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch {
|
} catch {
|
||||||
reply.code(401).send({ error: 'Authentication required' });
|
reply.code(401).send({ error: 'Autenticação necessária.' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
const account = result.rows[0];
|
const account = result.rows[0];
|
||||||
if (!account) {
|
if (!account) {
|
||||||
reply.code(401).send({ error: 'This account is no longer active' });
|
reply.code(401).send({ error: 'Esta conta não está mais ativa.' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
|||||||
if (!(await verifyActiveUser(request, reply))) return;
|
if (!(await verifyActiveUser(request, reply))) return;
|
||||||
|
|
||||||
if (!roles.includes(request.user.role)) {
|
if (!roles.includes(request.user.role)) {
|
||||||
return reply.code(403).send({ error: 'Insufficient permissions' });
|
return reply.code(403).send({ error: 'Você não tem permissão para esta ação.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const environmentSchema = z.object({
|
|||||||
export const config = environmentSchema.parse(process.env);
|
export const config = environmentSchema.parse(process.env);
|
||||||
|
|
||||||
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
|
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
|
||||||
throw new Error('JWT_SECRET must be set to a unique value in production.');
|
throw new Error('JWT_SECRET deve ter um valor único em produção.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bunnyConfigurationValues = [
|
const bunnyConfigurationValues = [
|
||||||
@@ -50,7 +50,7 @@ const bunnyConfigurationValues = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
|
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
|
||||||
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY, and BUNNY_EMBED_TOKEN_KEY must be configured together.');
|
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY e BUNNY_EMBED_TOKEN_KEY devem ser configurados juntos.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bunnyStorageConfigurationValues = [
|
const bunnyStorageConfigurationValues = [
|
||||||
@@ -61,10 +61,10 @@ const bunnyStorageConfigurationValues = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
|
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
|
||||||
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
|
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT e BUNNY_STORAGE_CDN_HOST devem ser configurados juntos.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
|
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
|
||||||
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
|
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
|
||||||
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and MAIL_FROM must be configured together.');
|
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS e MAIL_FROM devem ser configurados juntos.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type CoverImage = { body: Buffer; contentType: string };
|
|||||||
|
|
||||||
function bunnyStorageConfiguration() {
|
function bunnyStorageConfiguration() {
|
||||||
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
||||||
throw new BunnyStorageConfigurationError('Bunny Storage is not configured. Ask an administrator to add the Storage Zone variables.');
|
throw new BunnyStorageConfigurationError('O Bunny Storage não está configurado. Peça a um administrador para adicionar as variáveis da Storage Zone.');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
zone: config.BUNNY_STORAGE_ZONE,
|
zone: config.BUNNY_STORAGE_ZONE,
|
||||||
@@ -30,7 +30,7 @@ function imageExtension(image: CoverImage) {
|
|||||||
if (contentType === 'image/png' && isPng) return 'png';
|
if (contentType === 'image/png' && isPng) return 'png';
|
||||||
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
|
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
|
||||||
if (contentType === 'image/webp' && isWebp) return 'webp';
|
if (contentType === 'image/webp' && isWebp) return 'webp';
|
||||||
throw new BunnyStorageRequestError('Only valid JPG, PNG, and WebP images are accepted.');
|
throw new BunnyStorageRequestError('Envie apenas imagens JPG, PNG ou WebP válidas.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadBunnyCover(image: CoverImage) {
|
export async function uploadBunnyCover(image: CoverImage) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class BunnyRequestError extends Error {}
|
|||||||
|
|
||||||
function getBunnyConfiguration() {
|
function getBunnyConfiguration() {
|
||||||
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
|
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
|
||||||
throw new BunnyConfigurationError('Bunny Stream is not configured. Ask an administrator to configure the Bunny environment variables.');
|
throw new BunnyConfigurationError('O Bunny Stream não está configurado. Peça a um administrador para configurar as variáveis de ambiente do Bunny.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const updateUserSchema = z.object({
|
|||||||
role: z.enum(['student', 'instructor', 'admin']).optional(),
|
role: z.enum(['student', 'instructor', 'admin']).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
}).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
|
}).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
|
||||||
message: 'Provide at least one field to update',
|
message: 'Informe pelo menos um campo para atualizar.',
|
||||||
});
|
});
|
||||||
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
|
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
|
||||||
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
|
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
|
||||||
@@ -34,8 +34,8 @@ const homeBannersSchema = z.object({
|
|||||||
title: z.string().trim().max(180).default(''),
|
title: z.string().trim().max(180).default(''),
|
||||||
description: z.string().trim().max(500).default(''),
|
description: z.string().trim().max(500).default(''),
|
||||||
}).superRefine((banner, context) => {
|
}).superRefine((banner, context) => {
|
||||||
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Course banners need a course' });
|
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners de curso precisam de um curso.' });
|
||||||
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Custom banners need an image' });
|
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners personalizados precisam de uma imagem.' });
|
||||||
})).max(10),
|
})).max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,14 +86,14 @@ const getLearningPaths = async () => {
|
|||||||
|
|
||||||
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
|
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
|
||||||
const courseIds = [...new Set(input.courseIds)];
|
const courseIds = [...new Set(input.courseIds)];
|
||||||
if (courseIds.length !== input.courseIds.length) throw new Error('A course can only appear once in a path');
|
if (courseIds.length !== input.courseIds.length) throw new Error('Um curso só pode aparecer uma vez em uma trilha.');
|
||||||
const courses = await client.query<{ id: string; status: string }>(
|
const courses = await client.query<{ id: string; status: string }>(
|
||||||
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
|
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
|
||||||
[courseIds],
|
[courseIds],
|
||||||
);
|
);
|
||||||
if (courses.rowCount !== courseIds.length) throw new Error('Every path course must exist and cannot be archived');
|
if (courses.rowCount !== courseIds.length) throw new Error('Todos os cursos da trilha devem existir e não podem estar arquivados.');
|
||||||
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
|
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
|
||||||
throw new Error('Publish every course in this path before publishing the path');
|
throw new Error('Publique todos os cursos desta trilha antes de publicá-la.');
|
||||||
}
|
}
|
||||||
return courseIds;
|
return courseIds;
|
||||||
};
|
};
|
||||||
@@ -150,7 +150,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
|
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
|
||||||
if (courseIds.length) {
|
if (courseIds.length) {
|
||||||
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
|
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
|
||||||
if (courses.rowCount !== courseIds.length) return { error: 'Course banners must reference published courses' };
|
if (courses.rowCount !== courseIds.length) return { error: 'Banners de curso devem referenciar cursos publicados.' };
|
||||||
}
|
}
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -214,7 +214,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
if (!path.rows[0]) {
|
if (!path.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(404).send({ error: 'Learning path not found' });
|
return reply.code(404).send({ error: 'Trilha não encontrada.' });
|
||||||
}
|
}
|
||||||
await writePathCourses(client, pathId, courseIds);
|
await writePathCourses(client, pathId, courseIds);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
@@ -233,7 +233,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
|
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
|
||||||
const { pathId } = learningPathParamsSchema.parse(request.params);
|
const { pathId } = learningPathParamsSchema.parse(request.params);
|
||||||
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
|
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
|
||||||
if (!result.rows[0]) return reply.code(404).send({ error: 'Learning path not found' });
|
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
|
||||||
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
@@ -260,7 +260,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
returning id, name, position, is_active as "isActive"`,
|
returning id, name, position, is_active as "isActive"`,
|
||||||
[categoryId, input.name, input.position ?? null, input.isActive ?? null],
|
[categoryId, input.name, input.position ?? null, input.isActive ?? null],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) return reply.code(404).send({ error: 'Category not found' });
|
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
|
||||||
await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip });
|
||||||
return { data: result.rows[0] };
|
return { data: result.rows[0] };
|
||||||
});
|
});
|
||||||
@@ -288,7 +288,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
|
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]);
|
const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]);
|
||||||
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Featured courses must exist and be published' });
|
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Os cursos em destaque devem existir e estar publicados.' });
|
||||||
}
|
}
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -336,14 +336,14 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
|
(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],
|
from users u where u.id = $1`, [userId],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' });
|
if (!result.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
|
||||||
return { data: result.rows[0] };
|
return { data: result.rows[0] };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/invitations', adminAccess, async (request, reply) => {
|
app.post('/invitations', adminAccess, async (request, reply) => {
|
||||||
const input = invitationSchema.parse(request.body);
|
const input = invitationSchema.parse(request.body);
|
||||||
const existing = await pool.query('select 1 from users where email = $1', [input.email]);
|
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' });
|
if (existing.rowCount) return reply.code(409).send({ error: 'Este e-mail já possui uma conta.' });
|
||||||
const rawToken = createRawToken();
|
const rawToken = createRawToken();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
@@ -357,7 +357,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
|
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
|
||||||
const { userId } = userParamsSchema.parse(request.params);
|
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]);
|
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' });
|
if (!account.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
|
||||||
const rawToken = createRawToken();
|
const rawToken = createRawToken();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
@@ -373,11 +373,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const input = updateUserSchema.parse(request.body);
|
const input = updateUserSchema.parse(request.body);
|
||||||
|
|
||||||
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
|
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
|
||||||
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' });
|
return reply.code(400).send({ error: 'Você não pode remover seu próprio acesso de superadmin.' });
|
||||||
}
|
}
|
||||||
if (input.email) {
|
if (input.email) {
|
||||||
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
|
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
|
||||||
if (duplicate.rowCount) return reply.code(409).send({ error: 'This e-mail is already used by another account' });
|
if (duplicate.rowCount) return reply.code(409).send({ error: 'Este e-mail já é usado por outra conta.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -391,14 +391,14 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
[userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
|
[userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
|
||||||
);
|
);
|
||||||
const account = result.rows[0];
|
const account = result.rows[0];
|
||||||
if (!account) return reply.code(404).send({ error: 'User not found' });
|
if (!account) return reply.code(404).send({ error: 'Usuário não encontrado.' });
|
||||||
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
|
||||||
return { data: account };
|
return { data: account };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/users/:userId', adminAccess, async (request, reply) => {
|
app.delete('/users/:userId', adminAccess, async (request, reply) => {
|
||||||
const { userId } = userParamsSchema.parse(request.params);
|
const { userId } = userParamsSchema.parse(request.params);
|
||||||
if (userId === request.user.id) return reply.code(400).send({ error: 'You cannot delete your own superadmin account' });
|
if (userId === request.user.id) return reply.code(400).send({ error: 'Você não pode excluir sua própria conta de superadmin.' });
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -406,13 +406,13 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
|
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
|
||||||
if (!account.rows[0]) {
|
if (!account.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(404).send({ error: 'User not found' });
|
return reply.code(404).send({ error: 'Usuário não encontrado.' });
|
||||||
}
|
}
|
||||||
if (account.rows[0].role === 'admin') {
|
if (account.rows[0].role === 'admin') {
|
||||||
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
|
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
|
||||||
if (Number(admins.rows[0].count) <= 1) {
|
if (Number(admins.rows[0].count) <= 1) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(400).send({ error: 'The last active superadmin cannot be deleted' });
|
return reply.code(400).send({ error: 'O último superadmin ativo não pode ser excluído.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Courses remain available. Their ownership is transferred to the admin
|
// Courses remain available. Their ownership is transferred to the admin
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
};
|
};
|
||||||
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
||||||
if (await allowPublicAuthAttempt(ip)) return false;
|
if (await allowPublicAuthAttempt(ip)) return false;
|
||||||
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
|
reply.code(429).send({ error: 'Muitas tentativas. Tente novamente mais tarde.' });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
if (!token.rows[0]) {
|
if (!token.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
|
return reply.code(400).send({ error: 'Este convite é inválido ou expirou.' });
|
||||||
}
|
}
|
||||||
const result = await client.query<UserRow>(
|
const result = await client.query<UserRow>(
|
||||||
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
|
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
|
||||||
@@ -86,7 +86,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
|
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
|
||||||
} catch {
|
} catch {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
return reply.code(409).send({ error: 'Este e-mail de convite já possui uma conta.' });
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
if (!token.rows[0]) {
|
if (!token.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
|
return reply.code(400).send({ error: 'Este link de redefinição é inválido ou expirou.' });
|
||||||
}
|
}
|
||||||
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
|
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
@@ -140,7 +140,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(201).send({ token, user });
|
return reply.code(201).send({ token, user });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
return reply.code(409).send({ error: 'An account with this email already exists' });
|
return reply.code(409).send({ error: 'Já existe uma conta com este e-mail.' });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const account = result.rows[0];
|
const account = result.rows[0];
|
||||||
|
|
||||||
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
|
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
|
||||||
return reply.code(401).send({ error: 'Invalid email or password' });
|
return reply.code(401).send({ error: 'E-mail ou senha inválidos.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = serializeUser(account);
|
const user = serializeUser(account);
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ const webhookSchema = z.object({
|
|||||||
|
|
||||||
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
|
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.post('/bunny', async (request, reply) => {
|
app.post('/bunny', async (request, reply) => {
|
||||||
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Bunny webhooks are not configured' });
|
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Os webhooks do Bunny não estão configurados.' });
|
||||||
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
|
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
|
||||||
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
|
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
|
||||||
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
||||||
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
|
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
|
||||||
return reply.code(401).send({ error: 'Invalid Bunny webhook signature' });
|
return reply.code(401).send({ error: 'Assinatura do webhook do Bunny inválida.' });
|
||||||
}
|
}
|
||||||
const input = webhookSchema.parse(request.body);
|
const input = webhookSchema.parse(request.body);
|
||||||
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
|
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
|
||||||
return reply.code(400).send({ error: 'Unexpected Bunny video library' });
|
return reply.code(400).send({ error: 'Biblioteca de vídeos Bunny inesperada.' });
|
||||||
}
|
}
|
||||||
const status = bunnyMediaStatus(input.Status);
|
const status = bunnyMediaStatus(input.Status);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(cover.body);
|
return reply.send(cover.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
|
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
|
||||||
return reply.code(404).send({ error: 'Cover image was not found' });
|
return reply.code(404).send({ error: 'Imagem de capa não encontrada.' });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const course = result.rows[0];
|
const course = result.rows[0];
|
||||||
|
|
||||||
if (!course) {
|
if (!course) {
|
||||||
return reply.code(404).send({ error: 'Course not found' });
|
return reply.code(404).send({ error: 'Curso não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: authenticated ? course : withoutProtectedMedia(course) };
|
return { data: authenticated ? course : withoutProtectedMedia(course) };
|
||||||
@@ -244,22 +244,22 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
[courseId, lessonId],
|
[courseId, lessonId],
|
||||||
);
|
);
|
||||||
const media = result.rows[0];
|
const media = result.rows[0];
|
||||||
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' });
|
if (!media) return reply.code(404).send({ error: 'Mídia da aula não encontrada.' });
|
||||||
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' });
|
if (media.status === 'processing') return reply.code(409).send({ error: 'Este vídeo ainda está sendo processado pelo Bunny Stream.' });
|
||||||
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' });
|
if (media.status === 'failed') return reply.code(409).send({ error: 'Este vídeo não pôde ser processado. Entre em contato com o instrutor.' });
|
||||||
|
|
||||||
if (media.provider === 'bunny') {
|
if (media.provider === 'bunny') {
|
||||||
try {
|
try {
|
||||||
const playback = signedBunnyEmbedUrl(media.external_id);
|
const playback = signedBunnyEmbedUrl(media.external_id);
|
||||||
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
|
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'Bunny playback is not configured yet.' });
|
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'A reprodução do Bunny ainda não está configurada.' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = media.playback_url || media.embed_url;
|
const source = media.playback_url || media.embed_url;
|
||||||
if (!source) return reply.code(409).send({ error: 'This lesson does not have a playable video URL.' });
|
if (!source) return reply.code(409).send({ error: 'Esta aula não possui um endereço de vídeo reproduzível.' });
|
||||||
return { data: { provider: media.provider, kind: 'video', source } };
|
return { data: { provider: media.provider, kind: 'video', source } };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const lessonParamsSchema = z.object({
|
|||||||
const completionSchema = z.object({
|
const completionSchema = z.object({
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
watchedSeconds: z.coerce.number().int().min(0).optional(),
|
watchedSeconds: z.coerce.number().int().min(0).optional(),
|
||||||
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Provide progress or completion state' });
|
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Informe o progresso ou o status de conclusão.' });
|
||||||
|
|
||||||
const commentSchema = z.object({
|
const commentSchema = z.object({
|
||||||
lessonId: z.string().uuid().nullable().optional(),
|
lessonId: z.string().uuid().nullable().optional(),
|
||||||
@@ -51,7 +51,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
||||||
const { courseId } = courseParamsSchema.parse(request.params);
|
const { courseId } = courseParamsSchema.parse(request.params);
|
||||||
if (!(await ensurePublishedCourse(courseId))) {
|
if (!(await ensurePublishedCourse(courseId))) {
|
||||||
return reply.code(404).send({ error: 'Course not found' });
|
return reply.code(404).send({ error: 'Curso não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -78,7 +78,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (lesson.rowCount !== 1) {
|
if (lesson.rowCount !== 1) {
|
||||||
return reply.code(404).send({ error: 'Lesson not found' });
|
return reply.code(404).send({ error: 'Aula não encontrada.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -99,7 +99,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
||||||
const { courseId } = courseParamsSchema.parse(request.params);
|
const { courseId } = courseParamsSchema.parse(request.params);
|
||||||
if (!(await canViewCourseComments(courseId, request.user))) {
|
if (!(await canViewCourseComments(courseId, request.user))) {
|
||||||
return reply.code(404).send({ error: 'Course not found' });
|
return reply.code(404).send({ error: 'Curso não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -120,13 +120,13 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { courseId } = courseParamsSchema.parse(request.params);
|
const { courseId } = courseParamsSchema.parse(request.params);
|
||||||
const input = commentSchema.parse(request.body);
|
const input = commentSchema.parse(request.body);
|
||||||
if (!(await ensurePublishedCourse(courseId))) {
|
if (!(await ensurePublishedCourse(courseId))) {
|
||||||
return reply.code(404).send({ error: 'Course not found' });
|
return reply.code(404).send({ error: 'Curso não encontrado.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.lessonId) {
|
if (input.lessonId) {
|
||||||
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
|
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
|
||||||
if (lesson.rowCount !== 1) {
|
if (lesson.rowCount !== 1) {
|
||||||
return reply.code(400).send({ error: 'Lesson does not belong to this course' });
|
return reply.code(400).send({ error: 'Esta aula não pertence ao curso.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||||
const { commentId } = commentParamsSchema.parse(request.params);
|
const { commentId } = commentParamsSchema.parse(request.params);
|
||||||
const input = replySchema.parse(request.body);
|
const input = replySchema.parse(request.body);
|
||||||
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' });
|
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode responder a este comentário.' });
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into comment_replies (comment_id, author_id, body)
|
`insert into comment_replies (comment_id, author_id, body)
|
||||||
values ($1, $2, $3)
|
values ($1, $2, $3)
|
||||||
@@ -161,7 +161,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||||
const { commentId } = commentParamsSchema.parse(request.params);
|
const { commentId } = commentParamsSchema.parse(request.params);
|
||||||
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' });
|
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode moderar este comentário.' });
|
||||||
await pool.query('delete from comments where id = $1', [commentId]);
|
await pool.query('delete from comments where id = $1', [commentId]);
|
||||||
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const lessonSchema = z.object({
|
|||||||
const coverImageUrlSchema = z.string().url().refine((value) => {
|
const coverImageUrlSchema = z.string().url().refine((value) => {
|
||||||
const protocol = new URL(value).protocol;
|
const protocol = new URL(value).protocol;
|
||||||
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
|
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
|
||||||
}, { message: 'Cover image URL must use HTTPS.' });
|
}, { message: 'A URL da imagem de capa deve usar HTTPS.' });
|
||||||
|
|
||||||
const courseSchema = z.object({
|
const courseSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(255),
|
title: z.string().trim().min(1).max(255),
|
||||||
@@ -94,7 +94,7 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les
|
|||||||
|
|
||||||
async function assertActiveCategory(category: string) {
|
async function assertActiveCategory(category: string) {
|
||||||
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
|
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
|
||||||
if (!result.rowCount) throw new CourseContentConflict('Select an active category created by the superadmin.');
|
if (!result.rowCount) throw new CourseContentConflict('Selecione uma trilha ativa criada pelo superadmin.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPublishable(input: CourseInput) {
|
function assertPublishable(input: CourseInput) {
|
||||||
@@ -103,7 +103,7 @@ function assertPublishable(input: CourseInput) {
|
|||||||
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
|
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
|
||||||
);
|
);
|
||||||
if (unplayableLesson) {
|
if (unplayableLesson) {
|
||||||
throw new CoursePublicationBlocked(`The course cannot be published while "${unplayableLesson.title}" has no ready video.`);
|
throw new CoursePublicationBlocked(`O curso não pode ser publicado enquanto “${unplayableLesson.title}” não tiver um vídeo pronto.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
|||||||
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
||||||
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
|
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
|
||||||
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
|
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
|
||||||
if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.');
|
if (unknownLessonId) throw new Error('Uma aula em edição não pertence a este curso.');
|
||||||
|
|
||||||
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
|
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
|
||||||
if (removedLessonIds.length > 0) {
|
if (removedLessonIds.length > 0) {
|
||||||
@@ -125,7 +125,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
|||||||
[removedLessonIds],
|
[removedLessonIds],
|
||||||
);
|
);
|
||||||
if (usage.rowCount) {
|
if (usage.rowCount) {
|
||||||
throw new CourseContentConflict('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
|
throw new CourseContentConflict('Uma aula com progresso ou comentários de alunos não pode ser removida. Mantenha-a ou arquive o curso.');
|
||||||
}
|
}
|
||||||
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
|
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
|
||||||
}
|
}
|
||||||
@@ -170,9 +170,9 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
|||||||
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
|
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
|
||||||
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
|
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
|
||||||
const course = result.rows[0];
|
const course = result.rows[0];
|
||||||
if (!course) return { error: 'Course not found', statusCode: 404 as const };
|
if (!course) return { error: 'Curso não encontrado.', statusCode: 404 as const };
|
||||||
if (user.role !== 'admin' && course.instructor_id !== user.id) {
|
if (user.role !== 'admin' && course.instructor_id !== user.id) {
|
||||||
return { error: 'You can only manage your own courses', statusCode: 403 as const };
|
return { error: 'Você só pode gerenciar seus próprios cursos.', statusCode: 403 as const };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
if (!original.rows[0]) {
|
if (!original.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(404).send({ error: 'Course not found' });
|
return reply.code(404).send({ error: 'Curso não encontrado.' });
|
||||||
}
|
}
|
||||||
const source = original.rows[0];
|
const source = original.rows[0];
|
||||||
const title = `${source.title} (cópia)`;
|
const title = `${source.title} (cópia)`;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
|||||||
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
||||||
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
||||||
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
|
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
|
||||||
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
|
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'O Bunny Stream não conseguiu concluir esta solicitação. Tente novamente.' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ async function assertVideoAccess(videoId: string, user: { id: string; role: stri
|
|||||||
[videoId],
|
[videoId],
|
||||||
);
|
);
|
||||||
if (legacy.rows[0]?.instructor_id === user.id) return;
|
if (legacy.rows[0]?.instructor_id === user.id) return;
|
||||||
throw new VideoUploadValidationError('You do not have access to this video.', 403);
|
throw new VideoUploadValidationError('Você não tem acesso a este vídeo.', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||||
@@ -90,8 +90,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post('/covers', manageAccess, async (request, reply) => {
|
app.post('/covers', manageAccess, 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: 'Send an image file as the request body.' });
|
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de imagem.' });
|
||||||
if (body.length === 0) return reply.code(400).send({ error: 'Choose an image to upload.' });
|
if (body.length === 0) return reply.code(400).send({ error: 'Escolha uma imagem para enviar.' });
|
||||||
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
|
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
|
||||||
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
|
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
const profile = result.rows[0];
|
const profile = result.rows[0];
|
||||||
if (!profile) return reply.code(404).send({ error: 'Profile not found' });
|
if (!profile) return reply.code(404).send({ error: 'Perfil não encontrado.' });
|
||||||
await recordAudit({ actorId: request.user.id, action: 'profile.updated', subjectType: 'user', subjectId: request.user.id, metadata: { public: profile.isPublic }, ipAddress: request.ip });
|
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 };
|
return { data: profile };
|
||||||
});
|
});
|
||||||
@@ -112,7 +112,7 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
[instructorId],
|
[instructorId],
|
||||||
);
|
);
|
||||||
const profile = result.rows[0];
|
const profile = result.rows[0];
|
||||||
if (!profile) return reply.code(404).send({ error: 'Instructor profile not found' });
|
if (!profile) return reply.code(404).send({ error: 'Perfil de instrutor não encontrado.' });
|
||||||
|
|
||||||
const courses = await pool.query(
|
const courses = await pool.query(
|
||||||
`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`,
|
`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ValidatedVideoStream extends Transform {
|
|||||||
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
|
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
|
||||||
if (this.header.length >= 12) {
|
if (this.header.length >= 12) {
|
||||||
if (!isRecognizedVideo(this.header)) {
|
if (!isRecognizedVideo(this.header)) {
|
||||||
callback(new VideoUploadValidationError('Only valid MP4, WebM, and MOV video files are accepted.', 415));
|
callback(new VideoUploadValidationError('Envie apenas arquivos de vídeo MP4, WebM ou MOV válidos.', 415));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.validated = true;
|
this.validated = true;
|
||||||
@@ -46,7 +46,7 @@ class ValidatedVideoStream extends Transform {
|
|||||||
|
|
||||||
override _flush(callback: (error?: Error | null) => void) {
|
override _flush(callback: (error?: Error | null) => void) {
|
||||||
if (!this.validated) {
|
if (!this.validated) {
|
||||||
callback(new VideoUploadValidationError('The uploaded file is not a valid video.', 415));
|
callback(new VideoUploadValidationError('O arquivo enviado não é um vídeo válido.', 415));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
@@ -56,7 +56,7 @@ class ValidatedVideoStream extends Transform {
|
|||||||
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
|
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
|
||||||
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
|
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
|
||||||
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
|
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
|
||||||
throw new VideoUploadValidationError('Only MP4, WebM, and MOV video uploads are accepted.', 415);
|
throw new VideoUploadValidationError('Envie apenas vídeos MP4, WebM ou MOV.', 415);
|
||||||
}
|
}
|
||||||
return input.pipe(new ValidatedVideoStream(maxBytes));
|
return input.pipe(new ValidatedVideoStream(maxBytes));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
|
|||||||
|
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(body.error || 'The request could not be completed', response.status);
|
throw new ApiError(body.error || 'Não foi possível concluir a solicitação.', response.status);
|
||||||
}
|
}
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ async function uploadRequest<T>(path: string, file: File): Promise<T> {
|
|||||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||||
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
|
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new ApiError(body.error || 'The video upload could not be completed', response.status);
|
if (!response.ok) throw new ApiError(body.error || 'Não foi possível concluir o envio do vídeo.', response.status);
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
|
|||||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||||
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
|
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new ApiError(body.error || 'The cover image could not be uploaded', response.status);
|
if (!response.ok) throw new ApiError(body.error || 'Não foi possível enviar a imagem de capa.', response.status);
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user