feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing
- payments: real Stripe Checkout Session (redirect flow), confirmBySession, webhook handler - notifications: Zeptomail REST email, wa.me WhatsApp links, IntegrationsService injection - auth: changePassword, updateProfile, disableMfa (TOTP-verified) endpoints - packages: tryLinkPreAlert() auto-links on create (non-blocking) - integrations: catalog updated (zeptomail, stripe_webhook_secret; removed sendgrid/whatsapp-api) - web/portal/perfil: 3-tab layout (datos / contraseña / MFA) - web/portal/pago: Stripe redirect + ?success=1&session_id= callback, cancelled banner - web/admin/b2b: fix enum values (EN_COTIZACION, ACEPTADO) - web/carga-pesada: rich marketing landing (FCL/LCL, 7-step process, sectors, INEN callout) - tests: fix payments.service.spec (IntegrationsService+ConfigService mocks) - tests: fix notifications.service.spec (IntegrationsService mock, phone in mockUser) - all 104 tests passing, API + web builds clean
This commit is contained in:
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, IntegrationsModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
const mockTemplate = {
|
||||
id: "tpl-1",
|
||||
@@ -22,7 +23,15 @@ const mockPackage = {
|
||||
status: "REGISTRADO",
|
||||
};
|
||||
|
||||
const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } };
|
||||
// Include phone + email so notifyStatusChange skips the DB user lookup
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
firstName: "Juan",
|
||||
lastName: "Pérez",
|
||||
email: "juan@test.com",
|
||||
phone: "+5930987654321",
|
||||
suite: { code: "EC-00001" },
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
client: {
|
||||
@@ -38,18 +47,28 @@ const mockPrisma = {
|
||||
updateMany: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// IntegrationsService mock — getValue returns null so Zeptomail is skipped gracefully
|
||||
const mockIntegrations = {
|
||||
getValue: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
let service: NotificationsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockIntegrations.getValue.mockResolvedValue(null);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NotificationsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: IntegrationsService, useValue: mockIntegrations },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<NotificationsService>(NotificationsService);
|
||||
@@ -122,7 +141,8 @@ describe("NotificationsService", () => {
|
||||
|
||||
it("crea notificaciones para los 3 canales por defecto", async () => {
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH
|
||||
// EMAIL (FALLIDO – sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("interpola {{trackingId}} y {{firstName}} en el body", async () => {
|
||||
@@ -139,8 +159,7 @@ describe("NotificationsService", () => {
|
||||
it("no envía notificación si la plantilla está inactiva", async () => {
|
||||
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false });
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
// Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo)
|
||||
// El mock devuelve el template inactivo para todos
|
||||
// El mock devuelve el template inactivo para todos los canales
|
||||
expect(mockPrisma.client.notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
|
||||
const DEFAULT_SUBJECTS: Record<string, string> = {
|
||||
@@ -35,11 +36,19 @@ function interpolate(tpl: string, vars: Record<string, string>): string {
|
||||
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
||||
}
|
||||
|
||||
/** Normaliza número de teléfono para wa.me (solo dígitos, con código de país) */
|
||||
function normalizePhone(phone: string): string {
|
||||
return phone.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
) {}
|
||||
|
||||
// ─── Gestión de plantillas ────────────────────────────────
|
||||
|
||||
@@ -90,11 +99,20 @@ export class NotificationsService {
|
||||
|
||||
/** Called whenever a package status changes. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
// Load full user data to get phone and name (needed for wa.me)
|
||||
let fullUser = user;
|
||||
if (!user?.firstName || !user?.phone) {
|
||||
try {
|
||||
fullUser = await this.prisma.client.user.findUnique({ where: { id: user.id } }) ?? user;
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
trackingId: pkg.trackingId ?? "",
|
||||
firstName: user?.firstName ?? "Cliente",
|
||||
firstName: fullUser?.firstName ?? "Cliente",
|
||||
lastName: fullUser?.lastName ?? "",
|
||||
status: pkg.status ?? "",
|
||||
suiteCode: user?.suite?.code ?? "",
|
||||
suiteCode: fullUser?.suite?.code ?? "",
|
||||
};
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
@@ -117,6 +135,32 @@ export class NotificationsService {
|
||||
vars
|
||||
);
|
||||
|
||||
let finalBody = bodyText;
|
||||
let notifStatus: "ENVIADO" | "FALLIDO" | "PENDIENTE" = "PENDIENTE";
|
||||
let errorMsg: string | undefined;
|
||||
|
||||
// ── Dispatch por canal ──────────────────────────────
|
||||
if (channel === "EMAIL") {
|
||||
const result = await this.sendZeptomail(pkg.tenantId, subject, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
errorMsg = result.error;
|
||||
} else if (channel === "WHATSAPP") {
|
||||
// wa.me link con mensaje pre-cargado (sin Business API)
|
||||
const phone = fullUser?.phone ? normalizePhone(fullUser.phone) : null;
|
||||
if (phone) {
|
||||
finalBody = `https://wa.me/${phone}?text=${encodeURIComponent(bodyText)}`;
|
||||
notifStatus = "ENVIADO";
|
||||
} else {
|
||||
// No phone — skip WhatsApp
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "Sin número de teléfono registrado";
|
||||
}
|
||||
} else if (channel === "PUSH") {
|
||||
// PUSH no implementado — marcar FALLIDO silenciosamente
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "PUSH no configurado";
|
||||
}
|
||||
|
||||
const record = await this.prisma.client.notification.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
@@ -124,16 +168,17 @@ export class NotificationsService {
|
||||
channel,
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body: bodyText,
|
||||
body: finalBody,
|
||||
},
|
||||
});
|
||||
|
||||
// STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc.
|
||||
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`);
|
||||
|
||||
await this.prisma.client.notification.update({
|
||||
where: { id: record.id },
|
||||
data: { status: "ENVIADO", sentAt: new Date() },
|
||||
data: {
|
||||
status: notifStatus,
|
||||
sentAt: notifStatus === "ENVIADO" ? new Date() : null,
|
||||
error: errorMsg ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
|
||||
@@ -141,6 +186,76 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un email via Zeptomail REST API.
|
||||
* Docs: https://www.zoho.com/zeptomail/help/api/email-sending.html
|
||||
*/
|
||||
private async sendZeptomail(
|
||||
tenantId: string,
|
||||
subject: string,
|
||||
bodyText: string,
|
||||
toUser: any,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const apiKey = await this.integrations.getValue(tenantId, "zeptomail_api_key");
|
||||
const fromAddr = await this.integrations.getValue(tenantId, "email_from") ?? "noreply@moraworld.com";
|
||||
const fromName = await this.integrations.getValue(tenantId, "email_from_name") ?? "Moraworld Imports";
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn(`[ZEPTOMAIL] No API key configured for tenant ${tenantId}. Email not sent.`);
|
||||
return { ok: false, error: "Zeptomail API key no configurada" };
|
||||
}
|
||||
|
||||
const toEmail = toUser?.email;
|
||||
if (!toEmail) return { ok: false, error: "Sin email del destinatario" };
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||
<div style="background:#001F5B;padding:20px;border-radius:8px 8px 0 0">
|
||||
<h2 style="color:#fff;margin:0;font-size:1.2rem">Moraworld<span style="color:#FF6B00">.</span>Imports</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #e5e7eb;border-top:none">
|
||||
<p style="font-size:1rem;color:#111827;margin:0 0 16px">${bodyText.replace(/\n/g, "<br>")}</p>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0">
|
||||
<p style="font-size:.75rem;color:#6b7280;margin:0">Moraworld Imports S.A.S. · Mora Global Import LLC</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const payload = {
|
||||
from: { address: fromAddr, name: fromName },
|
||||
to: [{
|
||||
email_address: {
|
||||
address: toEmail,
|
||||
name: `${toUser?.firstName ?? ""} ${toUser?.lastName ?? ""}`.trim() || toEmail,
|
||||
},
|
||||
}],
|
||||
subject,
|
||||
htmlbody: htmlBody,
|
||||
};
|
||||
|
||||
const res = await fetch("https://api.zeptomail.com/v1.1/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Zoho-enczapikey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[ZEPTOMAIL] Send failed (${res.status}): ${errText}`);
|
||||
return { ok: false, error: `Zeptomail error ${res.status}: ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
this.logger.log(`[ZEPTOMAIL] Email sent to ${toEmail} — subject: ${subject}`);
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[ZEPTOMAIL] Exception: ${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
|
||||
Reference in New Issue
Block a user