feat: improve account management and welcome emails
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
20
server/src/services/email.ts
Normal file
20
server/src/services/email.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
Reference in New Issue
Block a user