feat: SMTP config en DB, historial mensajes, JWT 90d, email OTP con Prisma
- schema.prisma: modelo message_logs (channel, recipient, body, status, error) - email-otp.service.ts: lee config SMTP desde DB (settings.smtp_config), registra log en message_logs - sms.service.ts: registra log en message_logs tras cada envío (éxito y error) - auth.module.ts: agrega PrismaModule, JWT expira en 90d - settings.controller.ts: GET/PATCH /settings/smtp + GET /settings/message-logs - settings.service.ts: método getMessageLogs con paginación y filtro por canal - settings.module.ts: importa AuthModule + EmailOtpService Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a9b5e413e3
commit
522adc3b74
@@ -192,6 +192,16 @@ model suggestions {
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
model message_logs {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
channel String @db.VarChar(10) // 'sms' | 'email'
|
||||
recipient String @db.VarChar(255)
|
||||
body String
|
||||
status String @db.VarChar(20) // 'sent' | 'error'
|
||||
error String?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
model specializations {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
professional_id String @db.Uuid
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { EmailOtpService } from './email-otp.service';
|
||||
|
||||
@Module({
|
||||
@@ -16,10 +17,11 @@ import { EmailOtpService } from './email-otp.service';
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET') || 'dev-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
signOptions: { expiresIn: '90d' },
|
||||
}),
|
||||
}),
|
||||
SmsModule,
|
||||
PrismaModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, EmailOtpService],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutos
|
||||
|
||||
@@ -8,15 +9,21 @@ export class EmailOtpService {
|
||||
private readonly logger = new Logger(EmailOtpService.name);
|
||||
private readonly otps = new Map<string, { code: string; expiresAt: number }>();
|
||||
|
||||
private createTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587');
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async createTransport() {
|
||||
// Prioridad: DB > env vars
|
||||
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;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new BadRequestException(
|
||||
'Correo no configurado. Añade SMTP_HOST, SMTP_USER y SMTP_PASS en las variables de entorno.',
|
||||
'Correo no configurado. Configure el SMTP en Ajustes > Email.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,31 +35,55 @@ export class EmailOtpService {
|
||||
});
|
||||
}
|
||||
|
||||
async getSmtpConfig() {
|
||||
return this.prisma.settings.findUnique({ where: { key: 'smtp_config' } })
|
||||
.then(r => (r?.value as any) ?? null);
|
||||
}
|
||||
|
||||
async saveSmtpConfig(config: { host: string; port: string; user: string; pass: string; from?: string }) {
|
||||
await this.prisma.settings.upsert({
|
||||
where: { key: 'smtp_config' },
|
||||
create: { key: 'smtp_config', value: config as any },
|
||||
update: { value: config as any },
|
||||
});
|
||||
}
|
||||
|
||||
async sendOtp(email: string): Promise<void> {
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
this.otps.set(email.toLowerCase(), { code, expiresAt: Date.now() + OTP_TTL_MS });
|
||||
|
||||
const from = process.env.EMAIL_FROM || process.env.SMTP_USER;
|
||||
const transporter = this.createTransport();
|
||||
const dbConfig = await this.prisma.settings.findUnique({ where: { key: 'smtp_config' } })
|
||||
.then(r => r?.value as any).catch(() => null);
|
||||
const from = dbConfig?.from || process.env.EMAIL_FROM || process.env.SMTP_USER;
|
||||
const transporter = await this.createTransport();
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `ProsApp <${from}>`,
|
||||
to: email,
|
||||
subject: 'Código de verificación ProsApp',
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto;padding:32px">
|
||||
<img src="https://prosapp.co/img/logo_prosapp.png" height="40" style="margin-bottom:24px">
|
||||
<h2 style="color:#1e293b;margin:0 0 8px">Verifica tu correo</h2>
|
||||
<p style="color:#64748b;margin:0 0 24px">Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.</p>
|
||||
<div style="background:#f0f8ff;border:1px solid #bae3ff;border-radius:12px;padding:24px;text-align:center">
|
||||
<span style="font-size:36px;font-weight:700;letter-spacing:10px;color:#42A4EF">${code}</span>
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `ProsApp <${from}>`,
|
||||
to: email,
|
||||
subject: 'Código de verificación ProsApp',
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto;padding:32px">
|
||||
<img src="https://prosapp.co/img/logo_prosapp.png" height="40" style="margin-bottom:24px">
|
||||
<h2 style="color:#1e293b;margin:0 0 8px">Verifica tu correo</h2>
|
||||
<p style="color:#64748b;margin:0 0 24px">Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.</p>
|
||||
<div style="background:#f0f8ff;border:1px solid #bae3ff;border-radius:12px;padding:24px;text-align:center">
|
||||
<span style="font-size:36px;font-weight:700;letter-spacing:10px;color:#42A4EF">${code}</span>
|
||||
</div>
|
||||
<p style="color:#94a3b8;font-size:12px;margin-top:24px">Si no solicitaste esto, ignora este correo.</p>
|
||||
</div>
|
||||
<p style="color:#94a3b8;font-size:12px;margin-top:24px">Si no solicitaste esto, ignora este correo.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
this.logger.log(`Email OTP enviado a ${email}`);
|
||||
`,
|
||||
});
|
||||
this.logger.log(`Email OTP enviado a ${email}`);
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'email', recipient: email, body: `OTP: ${code}`, status: 'sent' },
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'email', recipient: email, body: 'OTP', status: 'error', error: String(err) },
|
||||
}).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
verifyOtp(email: string, code: string): boolean {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards, Param, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { EmailOtpService } from '../auth/email-otp.service';
|
||||
|
||||
@ApiTags('Settings')
|
||||
@Controller('settings')
|
||||
export class SettingsController {
|
||||
constructor(private settings: SettingsService) {}
|
||||
constructor(private settings: SettingsService, private emailOtp: EmailOtpService) {}
|
||||
|
||||
@Get()
|
||||
getGlobal() { return this.settings.getGlobal(); }
|
||||
@@ -29,6 +30,27 @@ export class SettingsController {
|
||||
return this.settings.updatePolicy(key, body.content);
|
||||
}
|
||||
|
||||
// SMTP config
|
||||
@Get('smtp')
|
||||
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||
async getSmtp() {
|
||||
const cfg = await this.emailOtp.getSmtpConfig();
|
||||
return cfg ?? {};
|
||||
}
|
||||
|
||||
@Patch('smtp')
|
||||
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||
saveSmtp(@Body() body: { host: string; port: string; user: string; pass: string; from?: string }) {
|
||||
return this.emailOtp.saveSmtpConfig(body);
|
||||
}
|
||||
|
||||
// Message logs
|
||||
@Get('message-logs')
|
||||
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||
getMessageLogs(@Query('page') page = '1', @Query('limit') limit = '50', @Query('channel') channel?: string) {
|
||||
return this.settings.getMessageLogs(+page, +limit, channel);
|
||||
}
|
||||
|
||||
// Maps key
|
||||
@Get('maps-key')
|
||||
async getMapsKey() {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { EmailOtpService } from '../auth/email-otp.service';
|
||||
|
||||
@Module({
|
||||
providers: [SettingsService],
|
||||
imports: [AuthModule],
|
||||
providers: [SettingsService, EmailOtpService],
|
||||
controllers: [SettingsController],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
|
||||
@@ -54,4 +54,19 @@ export class SettingsService {
|
||||
update: { value: { api_key } },
|
||||
});
|
||||
}
|
||||
|
||||
async getMessageLogs(page = 1, limit = 50, channel?: string) {
|
||||
const where = channel ? { channel } : {};
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.message_logs.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.message_logs.count({ where }),
|
||||
]);
|
||||
return { data, meta: { page, limit, total, pages: Math.ceil(total / limit) } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ export class SmsService {
|
||||
});
|
||||
} catch (networkErr) {
|
||||
this.logger.error(`Error de red al conectar con proveedor SMS: ${networkErr}`);
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'sms', recipient: numero, body: mensaje, status: 'error', error: String(networkErr) },
|
||||
}).catch(() => {});
|
||||
throw new BadRequestException('No se pudo conectar con el proveedor de SMS. Intenta de nuevo.');
|
||||
}
|
||||
|
||||
@@ -56,6 +59,9 @@ export class SmsService {
|
||||
|
||||
const result = await res.json().catch(() => ({ ok: true }));
|
||||
this.logger.log(`SMS enviado a ${normalizedNumero}`);
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'sms', recipient: normalizedNumero, body: mensaje, status: 'sent' },
|
||||
}).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user