feat: improve account management and welcome emails

This commit is contained in:
Cauê Faleiros
2026-09-08 11:47:33 -03:00
parent 7b2d25aabb
commit 03a6491989
8 changed files with 148 additions and 11 deletions

View File

@@ -19,6 +19,8 @@ const environmentSchema = z.object({
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
RESEND_API_KEY: optionalEnvironmentValue(z.string().min(20)),
EMAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
@@ -58,3 +60,7 @@ const bunnyStorageConfigurationValues = [
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.');
}
if (Boolean(config.RESEND_API_KEY) !== Boolean(config.EMAIL_FROM)) {
throw new Error('RESEND_API_KEY and EMAIL_FROM must be configured together.');
}

View File

@@ -11,12 +11,14 @@ const userParamsSchema = z.object({
});
const updateUserSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
email: z.string().email().transform((email) => email.toLowerCase()).optional(),
role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(),
}).refine((input) => 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',
});
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') });
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 categoryParamsSchema = z.object({ categoryId: z.string().uuid() });
const homeConfigurationSchema = z.object({
@@ -373,18 +375,61 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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' });
}
if (input.email) {
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' });
}
const result = await pool.query(
`update users
set role = coalesce($2::user_role, role),
is_active = coalesce($3, is_active)
set display_name = coalesce($2, display_name),
email = coalesce($3, email),
role = coalesce($4::user_role, role),
is_active = coalesce($5, is_active)
where id = $1
returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`,
[userId, input.role ?? null, input.isActive ?? null],
[userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
);
const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account };
});
app.delete('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
if (userId === request.user.id) return reply.code(400).send({ error: 'You cannot delete your own superadmin account' });
const client = await pool.connect();
try {
await client.query('begin');
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]) {
await client.query('rollback');
return reply.code(404).send({ error: 'User not found' });
}
if (account.rows[0].role === 'admin') {
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
if (Number(admins.rows[0].count) <= 1) {
await client.query('rollback');
return reply.code(400).send({ error: 'The last active superadmin cannot be deleted' });
}
}
// Courses remain available. Their ownership is transferred to the admin
// performing the deletion instead of deleting lesson and Bunny media.
await client.query('update courses set instructor_id = $2 where instructor_id = $1', [userId, request.user.id]);
await client.query('delete from comment_replies where author_id = $1', [userId]);
await client.query('delete from comments where author_id = $1', [userId]);
await client.query('delete from account_access_tokens where created_by = $1 or email = $2', [userId, account.rows[0].email]);
await client.query('delete from users where id = $1', [userId]);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'user.deleted', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email, role: account.rows[0].role }, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
};

View File

@@ -5,6 +5,7 @@ import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
import { config } from '../config.js';
import { sendWelcomeEmail } from '../services/email.js';
const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
@@ -130,6 +131,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
);
const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
try {
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
if (!sent && config.RESEND_API_KEY) request.log.warn({ email: user.email }, 'Welcome email was rejected by the provider');
} catch (error) {
request.log.error(error, 'Welcome email could not be sent');
}
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {

View File

@@ -0,0 +1,20 @@
import { config } from '../config.js';
type WelcomeRecipient = { name: string; email: string };
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return false;
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[character] || character));
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: config.EMAIL_FROM,
to: [recipient.email],
subject: 'Bem-vindo(a) ao Compor HUB',
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
}),
});
return response.ok;
}