diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1bacc83..f40d83b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 4cc7124..987ecb1 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -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('JWT_SECRET') || 'dev-secret', - signOptions: { expiresIn: '7d' }, + signOptions: { expiresIn: '90d' }, }), }), SmsModule, + PrismaModule, ], providers: [AuthService, JwtStrategy, EmailOtpService], controllers: [AuthController], diff --git a/backend/src/auth/email-otp.service.ts b/backend/src/auth/email-otp.service.ts index b933121..0d07a8a 100644 --- a/backend/src/auth/email-otp.service.ts +++ b/backend/src/auth/email-otp.service.ts @@ -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(); - 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 { 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: ` -
- -

Verifica tu correo

-

Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.

-
- ${code} + try { + await transporter.sendMail({ + from: `ProsApp <${from}>`, + to: email, + subject: 'Código de verificación ProsApp', + html: ` +
+ +

Verifica tu correo

+

Usa este código para vincular tu correo a ProsApp. Válido por 10 minutos.

+
+ ${code} +
+

Si no solicitaste esto, ignora este correo.

-

Si no solicitaste esto, ignora este correo.

-
- `, - }); - - 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 { diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index 4207b91..6fc7c95 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -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() { diff --git a/backend/src/settings/settings.module.ts b/backend/src/settings/settings.module.ts index a289b96..82dbb6b 100644 --- a/backend/src/settings/settings.module.ts +++ b/backend/src/settings/settings.module.ts @@ -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 {} diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts index c013f20..03a4747 100644 --- a/backend/src/settings/settings.service.ts +++ b/backend/src/settings/settings.service.ts @@ -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) } }; + } } diff --git a/backend/src/sms/sms.service.ts b/backend/src/sms/sms.service.ts index af97e38..af11b04 100644 --- a/backend/src/sms/sms.service.ts +++ b/backend/src/sms/sms.service.ts @@ -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; }