From 27f4a2d7ecd14b1e723e10163301dc8d60857131 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:01:45 -0500 Subject: [PATCH] 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 --- backend/src/app.module.ts | 2 + backend/src/auth/auth.service.ts | 3 + backend/src/mail/mail.module.ts | 11 + backend/src/mail/mail.service.ts | 248 ++++++++++++++++++ .../professionals/professionals.service.ts | 29 +- 5 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 backend/src/mail/mail.module.ts create mode 100644 backend/src/mail/mail.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 28054f2..c7de354 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1790baf..a2281e2 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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); } diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts new file mode 100644 index 0000000..29d7cb8 --- /dev/null +++ b/backend/src/mail/mail.module.ts @@ -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 {} diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts new file mode 100644 index 0000000..081b98c --- /dev/null +++ b/backend/src/mail/mail.service.ts @@ -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 { + 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 ` + + + + + ProsApp + + + + ${preheader ? `
${preheader}
` : ''} + + +
+ + + + + + + + + + + +
+ ProsApp +

ProsApp — Profesionales de salud

+
+ ${content} +
+

© ${new Date().getFullYear()} ProsApp · app.prosapp.co

+

Si no esperabas este correo, puedes ignorarlo.

+
+
+ +`; + } + + private badge(text: string, color: string, bg: string) { + return `${text}`; + } + + private btn(text: string, href: string) { + return `${text}`; + } + + // ── templates ──────────────────────────────────────────────────────────────── + + async sendWelcome(name: string, email: string) { + const html = this.wrap(` +

¡Bienvenido a ProsApp, ${name}! 👋

+

+ Nos alegra tenerte aquí. Ya puedes explorar profesionales de salud, agendar citas y mucho más. +

+ + + + + + + + + + +
+

✅  Cuenta creada con correo ${email}

+
+

📋  Completa tu perfil para una mejor experiencia

+
+

🔍  Encuentra y agenda profesionales de salud

+
+
${this.btn('Ir a ProsApp', APP_URL)}
+ `, `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(` +

Nueva solicitud pendiente

+

Solicitud de profesional recibida

+ + + + + + + + + + + + + + + + + +
Nombre${profName}
Correo${profEmail}
Profesión${profession || '—'}
Estado${this.badge('En revisión', '#92400E', '#FEF3C7')}
+

+ Revisa los documentos adjuntos y aprueba o rechaza la solicitud desde el panel de administración. +

+
${this.btn('Revisar en el panel', 'https://admin.prosapp.co/professionals')}
+ `, `${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 aprobada. 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 no fue aprobada 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 en revisión. 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 desactivado. 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(` +
+
${c.icon}
+
${c.badge} +
+

${c.title}

+

+ Hola ${profName}, ${c.body} +

+
+

+ Si tienes preguntas, escríbenos a través del soporte en la app o responde este correo. +

+
+
${c.cta}
+ `, c.title); + + await this.tryMail(email, c.subject, html); + } +} diff --git a/backend/src/professionals/professionals.service.ts b/backend/src/professionals/professionals.service.ts index 7f15ff8..ba49be5 100644 --- a/backend/src/professionals/professionals.service.ts +++ b/backend/src/professionals/professionals.service.ts @@ -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' }; }