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:
co-authored by
Claude Sonnet 4.6
parent
0a67f5d80a
commit
27f4a2d7ec
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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">✅ <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">📋 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">🔍 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' };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user