Add transactional emails: welcome, professional submission, status changes

- mail/mail.service.ts: MailService with HTML templates (welcome, submitted,
  approved, rejected, pending, deactivated). Silent if SMTP not configured.
- mail/mail.module.ts: global module so all services can inject MailService
- auth.service.ts: send welcome email on register()
- professionals.service.ts: notify admin on new submission; notify professional
  on approve/deny/deactivate/setPending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-29 11:01:45 -05:00
co-authored by Claude Sonnet 4.6
parent 0a67f5d80a
commit 27f4a2d7ec
5 changed files with 282 additions and 11 deletions
+2
View File
@@ -15,6 +15,7 @@ import { NotificationsModule } from './notifications/notifications.module';
import { SmsModule } from './sms/sms.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { VerifikModule } from './verifik/verifik.module';
import { MailModule } from './mail/mail.module';
@Module({
imports: [
@@ -34,6 +35,7 @@ import { VerifikModule } from './verifik/verifik.module';
SmsModule,
SuggestionsModule,
VerifikModule,
MailModule,
],
})
export class AppModule {}
+3
View File
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { MailService } from '../mail/mail.service';
@Injectable()
export class AuthService {
@@ -10,6 +11,7 @@ export class AuthService {
private prisma: PrismaService,
private jwt: JwtService,
private sms: SmsService,
private mail: MailService,
) {}
async register(email: string, password: string, name: string) {
@@ -21,6 +23,7 @@ export class AuthService {
data: { email, password_hash, name },
});
this.mail.sendWelcome(name, email).catch(() => {});
return this.generateToken(user);
}
+11
View File
@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';
import { PrismaModule } from '../prisma/prisma.module';
@Global()
@Module({
imports: [PrismaModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+248
View File
@@ -0,0 +1,248 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { PrismaService } from '../prisma/prisma.service';
const BRAND_COLOR = '#42A4EF';
const LOGO = 'https://prosapp.co/img/logo_prosapp.png';
const APP_URL = 'https://app.prosapp.co';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private prisma: PrismaService) {}
// ── transport ───────────────────────────────────────────────────────────────
private async createTransport(): Promise<{ transport: nodemailer.Transporter; from: string } | null> {
const dbConfig = await this.prisma.settings
.findUnique({ where: { key: 'smtp_config' } })
.then(r => r?.value as any)
.catch(() => null);
const host = dbConfig?.host || process.env.SMTP_HOST;
const port = parseInt(dbConfig?.port || process.env.SMTP_PORT || '587');
const user = dbConfig?.user || process.env.SMTP_USER;
const pass = dbConfig?.pass || process.env.SMTP_PASS;
const from = dbConfig?.from || process.env.EMAIL_FROM || user;
if (!host || !user || !pass) return null;
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return { transport, from: from || user };
}
// Silent send — never throws, logs errors
async tryMail(to: string, subject: string, html: string) {
const ctx = await this.createTransport();
if (!ctx) return; // SMTP not configured — silently skip
try {
await ctx.transport.sendMail({ from: `ProsApp <${ctx.from}>`, to, subject, html });
this.logger.log(`Mail enviado a ${to}: ${subject}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'sent' },
}).catch(() => {});
} catch (err) {
this.logger.warn(`Mail falló a ${to}: ${err}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'error', error: String(err) },
}).catch(() => {});
}
}
private async adminEmail(): Promise<string | null> {
const g = await this.prisma.settings.findUnique({ where: { key: 'global' } }).then(r => r?.value as any).catch(() => null);
if (g?.admin_email) return g.admin_email;
const smtp = await this.prisma.settings.findUnique({ where: { key: 'smtp_config' } }).then(r => r?.value as any).catch(() => null);
return smtp?.user || null;
}
// ── base template ────────────────────────────────────────────────────────────
private wrap(content: string, preheader = '') {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ProsApp</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
</head>
<body style="margin:0;padding:0;background:#F0F4F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
${preheader ? `<div style="display:none;max-height:0;overflow:hidden;color:#F0F4F8">${preheader}</div>` : ''}
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F4F8;padding:40px 16px">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,${BRAND_COLOR},#1565C0);border-radius:16px 16px 0 0;padding:32px 40px;text-align:center">
<img src="${LOGO}" height="36" alt="ProsApp" style="display:block;margin:0 auto 16px">
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:0.5px">ProsApp — Profesionales de salud</p>
</td></tr>
<!-- Body -->
<tr><td style="background:#ffffff;padding:40px;border-left:1px solid #E2E8F0;border-right:1px solid #E2E8F0">
${content}
</td></tr>
<!-- Footer -->
<tr><td style="background:#F8FAFC;border:1px solid #E2E8F0;border-top:0;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
<p style="margin:0;color:#94A3B8;font-size:12px">© ${new Date().getFullYear()} ProsApp · <a href="${APP_URL}" style="color:${BRAND_COLOR};text-decoration:none">app.prosapp.co</a></p>
<p style="margin:8px 0 0;color:#CBD5E1;font-size:11px">Si no esperabas este correo, puedes ignorarlo.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
private badge(text: string, color: string, bg: string) {
return `<span style="display:inline-block;padding:4px 12px;background:${bg};color:${color};border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.3px">${text}</span>`;
}
private btn(text: string, href: string) {
return `<a href="${href}" style="display:inline-block;padding:14px 32px;background:${BRAND_COLOR};color:#ffffff;border-radius:10px;font-size:15px;font-weight:600;text-decoration:none;margin-top:24px">${text}</a>`;
}
// ── templates ────────────────────────────────────────────────────────────────
async sendWelcome(name: string, email: string) {
const html = this.wrap(`
<h1 style="margin:0 0 8px;color:#1E293B;font-size:24px">¡Bienvenido a ProsApp, ${name}! 👋</h1>
<p style="margin:0 0 24px;color:#64748B;font-size:15px;line-height:1.6">
Nos alegra tenerte aquí. Ya puedes explorar profesionales de salud, agendar citas y mucho más.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F8FF;border:1px solid #BAE3FF;border-radius:12px;padding:20px 24px;margin-bottom:24px">
<tr>
<td style="padding:8px 0">
<p style="margin:0;color:#1E293B;font-size:14px">✅ &nbsp;<strong>Cuenta creada</strong> con correo <strong>${email}</strong></p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">📋 &nbsp;Completa tu perfil para una mejor experiencia</p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">🔍 &nbsp;Encuentra y agenda profesionales de salud</p>
</td>
</tr>
</table>
<div style="text-align:center">${this.btn('Ir a ProsApp', APP_URL)}</div>
`, `Bienvenido a ProsApp, ${name}`);
await this.tryMail(email, `¡Bienvenido a ProsApp, ${name}!`, html);
}
async sendProfessionalSubmitted(profName: string, profEmail: string, profession: string) {
const admin = await this.adminEmail();
if (!admin) return;
const html = this.wrap(`
<p style="margin:0 0 4px;color:#64748B;font-size:13px;text-transform:uppercase;letter-spacing:0.5px">Nueva solicitud pendiente</p>
<h1 style="margin:0 0 24px;color:#1E293B;font-size:22px">Solicitud de profesional recibida</h1>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #E2E8F0;border-radius:12px;overflow:hidden;margin-bottom:24px">
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;width:40%">Nombre</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500">${profName}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Correo</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;border-top:1px solid #E2E8F0">${profEmail}</td>
</tr>
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Profesión</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500;border-top:1px solid #E2E8F0">${profession || '—'}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Estado</td>
<td style="padding:16px 20px;border-top:1px solid #E2E8F0">${this.badge('En revisión', '#92400E', '#FEF3C7')}</td>
</tr>
</table>
<p style="margin:0 0 24px;color:#64748B;font-size:14px;line-height:1.6">
Revisa los documentos adjuntos y aprueba o rechaza la solicitud desde el panel de administración.
</p>
<div style="text-align:center">${this.btn('Revisar en el panel', 'https://admin.prosapp.co/professionals')}</div>
`, `${profName} ha enviado una solicitud de profesional`);
await this.tryMail(admin, `Nueva solicitud de profesional — ${profName}`, html);
}
async sendStatusChanged(
profName: string,
email: string,
status: 'approved' | 'rejected' | 'pending' | 'deactivated',
) {
const configs = {
approved: {
subject: '¡Tu solicitud fue aprobada! 🎉',
badge: this.badge('Aprobado', '#065F46', '#D1FAE5'),
title: '¡Felicidades, ya eres profesional en ProsApp!',
body: 'Tu solicitud fue revisada y <strong>aprobada</strong>. Ya puedes recibir clientes, gestionar tu agenda y ofrecer tus servicios de salud en la plataforma.',
cta: this.btn('Ver mi perfil profesional', APP_URL),
accent: '#10B981',
accentBg: '#D1FAE5',
icon: '✅',
},
rejected: {
subject: 'Actualización sobre tu solicitud de profesional',
badge: this.badge('No aprobada', '#991B1B', '#FEE2E2'),
title: 'Tu solicitud no fue aprobada',
body: 'Lamentablemente tu solicitud <strong>no fue aprobada</strong> en esta ocasión. Puedes corregir los documentos y volver a intentarlo desde la aplicación.',
cta: this.btn('Volver a intentarlo', APP_URL),
accent: '#EF4444',
accentBg: '#FEE2E2',
icon: '📋',
},
pending: {
subject: 'Tu solicitud está en revisión',
badge: this.badge('En revisión', '#92400E', '#FEF3C7'),
title: 'Tu solicitud volvió a revisión',
body: 'Un administrador ha puesto tu solicitud nuevamente <strong>en revisión</strong>. Te notificaremos cuando haya una actualización.',
cta: this.btn('Ver estado', APP_URL),
accent: '#F59E0B',
accentBg: '#FEF3C7',
icon: '🔍',
},
deactivated: {
subject: 'Tu cuenta profesional fue desactivada',
badge: this.badge('Desactivada', '#374151', '#F3F4F6'),
title: 'Tu cuenta profesional fue desactivada',
body: 'Tu perfil profesional ha sido <strong>desactivado</strong>. Si crees que es un error, comunícate con el soporte de ProsApp.',
cta: this.btn('Contactar soporte', APP_URL),
accent: '#6B7280',
accentBg: '#F3F4F6',
icon: '⚠️',
},
};
const c = configs[status];
const html = this.wrap(`
<div style="text-align:center;margin-bottom:28px">
<div style="display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;background:${c.accentBg};border-radius:50%;font-size:28px;margin-bottom:16px">${c.icon}</div>
<br>${c.badge}
</div>
<h1 style="margin:0 0 12px;color:#1E293B;font-size:22px;text-align:center">${c.title}</h1>
<p style="margin:0 0 28px;color:#64748B;font-size:15px;line-height:1.7;text-align:center">
Hola <strong>${profName}</strong>, ${c.body}
</p>
<div style="background:#F8FAFC;border-left:4px solid ${c.accent};border-radius:0 8px 8px 0;padding:16px 20px;margin-bottom:28px">
<p style="margin:0;color:#475569;font-size:13px;line-height:1.6">
Si tienes preguntas, escríbenos a través del soporte en la app o responde este correo.
</p>
</div>
<div style="text-align:center">${c.cta}</div>
`, c.title);
await this.tryMail(email, c.subject, html);
}
}
@@ -1,9 +1,10 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { MailService } from '../mail/mail.service';
@Injectable()
export class ProfessionalsService {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, private mail: MailService) {}
async findAllActive(page = 1, limit = 20, city?: string) {
const skip = (page - 1) * limit;
@@ -72,16 +73,20 @@ export class ProfessionalsService {
if (!existing) {
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
return this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } });
const prof = await this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } });
const u = await this.prisma.users.findUnique({ where: { id: userId }, select: { name: true, email: true } });
if (u?.email) this.mail.sendProfessionalSubmitted(u.name || 'Usuario', u.email, data.profession || '').catch(() => {});
return prof;
}
// Re-submitting after rejection reset (pro_state=0): move back to pending
const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { pro_state: true } });
const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { pro_state: true, name: true, email: true } });
if (user?.pro_state === 0) {
await this.prisma.$transaction([
this.prisma.professionals.update({ where: { user_id: userId }, data: { ...data, is_active: false } }),
this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } }),
]);
if (user.email) this.mail.sendProfessionalSubmitted(user.name || 'Usuario', user.email, data.profession || '').catch(() => {});
return this.prisma.professionals.findUnique({ where: { user_id: userId } });
}
@@ -155,15 +160,11 @@ export class ProfessionalsService {
if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([
this.prisma.professionals.update({
where: { id: professionalId },
data: { is_active: true },
}),
this.prisma.users.update({
where: { id: prof.user_id },
data: { pro_state: 2 },
}),
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: true } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 2 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'approved').catch(() => {});
return { message: 'Profesional aprobado' };
}
@@ -175,6 +176,8 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false, updated_at: new Date() } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'rejected').catch(() => {});
return { message: 'Solicitud rechazada' };
}
@@ -191,6 +194,8 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'deactivated').catch(() => {});
return { message: 'Profesional desactivado' };
}
@@ -201,6 +206,8 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 1 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'pending').catch(() => {});
return { message: 'Profesional puesto en revisión' };
}