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:
Lizandro Guarnizo
2026-06-01 20:24:35 -05:00
parent b2f03e654f
commit 98ab5a309c
19 changed files with 979 additions and 134 deletions
@@ -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 },