feat: C-8/C-1/C-2/C-3/C-4/C-6/M-1/M-5/M-6 — WebSocket gateway, SENAE real, SP-API, Twilio SMS, WhatsApp Business, soporte portal, HMAC audit, reportes CSV

This commit is contained in:
Lizandro Guarnizo
2026-06-01 21:28:42 -05:00
parent a5842278fb
commit 84c1fdec54
27 changed files with 1743 additions and 122 deletions
@@ -0,0 +1,101 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { Logger } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
/**
* NotificationsGateway — C-6
* Real-time push of package status changes to connected portal clients.
*
* Connection flow:
* 1. Client connects with `auth: { token: "<jwt>" }` in socket options.
* 2. Gateway verifies JWT → places socket in room `user:<userId>`.
* 3. On package status change, NotificationsService calls `emitStatusChange()`.
* 4. All sockets in that user room receive `package:status` event.
*
* Client (Next.js portal):
* const socket = io("http://localhost:3001", { auth: { token: localStorage.getItem("mw_access") } });
* socket.on("package:status", (data) => { ... });
*/
@WebSocketGateway({
cors: {
origin: (process.env.CORS_ORIGINS ?? "http://localhost:3000").split(",").map(o => o.trim()),
credentials: true,
},
namespace: "/ws",
transports: ["websocket", "polling"],
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server!: Server;
private readonly logger = new Logger(NotificationsGateway.name);
constructor(private jwtService: JwtService) {}
async handleConnection(client: Socket) {
try {
const token =
(client.handshake.auth as any)?.token ??
client.handshake.headers?.authorization?.replace("Bearer ", "");
if (!token) {
this.logger.warn(`[WS] Client ${client.id} rejected — no token`);
client.disconnect(true);
return;
}
const secret = process.env.JWT_SECRET ?? "changeme";
const payload = this.jwtService.verify(token, { secret });
const userId: string = payload.sub;
// Join personal room so we can target by userId
await client.join(`user:${userId}`);
client.data.userId = userId;
client.data.tenantId = payload.tenantId;
this.logger.log(`[WS] Connected: ${client.id} → user:${userId}`);
} catch {
this.logger.warn(`[WS] Client ${client.id} rejected — invalid token`);
client.disconnect(true);
}
}
handleDisconnect(client: Socket) {
this.logger.log(`[WS] Disconnected: ${client.id}`);
}
/** Emitted by NotificationsService on every package status change */
emitStatusChange(userId: string, pkg: {
id: string;
trackingId: string;
status: string;
description?: string;
}) {
this.server.to(`user:${userId}`).emit("package:status", {
packageId: pkg.id,
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description ?? null,
at: new Date().toISOString(),
});
this.logger.log(`[WS] Emitted package:status to user:${userId}${pkg.trackingId}${pkg.status}`);
}
/** Broadcast to all sockets in a tenant room */
emitToTenant(tenantId: string, event: string, data: any) {
this.server.to(`tenant:${tenantId}`).emit(event, data);
}
/** Ping/pong — optional keep-alive */
@SubscribeMessage("ping")
handlePing(@ConnectedSocket() client: Socket, @MessageBody() _data: any) {
client.emit("pong", { at: new Date().toISOString() });
}
}
@@ -1,13 +1,22 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { NotificationsService } from "./notifications.service";
import { NotificationsGateway } from "./notifications.gateway";
import { NotificationsController, NotificationsUserController } from "./notifications.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [PrismaModule, IntegrationsModule],
imports: [
PrismaModule,
IntegrationsModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? "changeme",
signOptions: { expiresIn: "15m" },
}),
],
controllers: [NotificationsController, NotificationsUserController],
providers: [NotificationsService],
exports: [NotificationsService],
providers: [NotificationsService, NotificationsGateway],
exports: [NotificationsService, NotificationsGateway],
})
export class NotificationsModule {}
@@ -139,9 +139,10 @@ describe("NotificationsService", () => {
mockPrisma.client.notification.update.mockResolvedValue({});
});
it("crea notificaciones para los 3 canales por defecto", async () => {
it("crea notificaciones para los 3 canales activos por defecto (EMAIL, WHATSAPP, SMS)", async () => {
await service.notifyStatusChange(mockPackage, mockUser);
// EMAIL (FALLIDO sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
// EMAIL (FALLIDO sin API key), WHATSAPP (wa.me link), SMS (FALLIDO sin Twilio)
// PUSH se omite porque no hay plantilla activa y el fallback lo excluye
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
});
@@ -1,6 +1,7 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { Injectable, Logger, NotFoundException, Optional } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { IntegrationsService } from "../integrations/integrations.service";
import type { NotificationsGateway } from "./notifications.gateway";
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
const DEFAULT_SUBJECTS: Record<string, string> = {
@@ -36,11 +37,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) */
/** Normaliza número de teléfono para wa.me / WhatsApp (solo dígitos, con código de país) */
function normalizePhone(phone: string): string {
return phone.replace(/\D/g, "");
}
/** Añade código de país Ecuador si no tiene prefijo internacional */
function toE164Ecuador(phone: string): string {
const digits = phone.replace(/\D/g, "");
if (digits.startsWith("593")) return `+${digits}`;
if (digits.startsWith("0")) return `+593${digits.slice(1)}`;
return `+${digits}`;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
@@ -48,6 +57,7 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
private integrations: IntegrationsService,
@Optional() private gateway: NotificationsGateway | null,
) {}
// ─── Gestión de plantillas ────────────────────────────────
@@ -99,7 +109,7 @@ 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)
// Load full user data to get phone and name
let fullUser = user;
if (!user?.firstName || !user?.phone) {
try {
@@ -115,15 +125,14 @@ export class NotificationsService {
suiteCode: fullUser?.suite?.code ?? "",
};
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "SMS", "PUSH"];
for (const channel of channels) {
try {
// 1. Buscar plantilla en DB
const tpl = await this.prisma.client.notificationTemplate.findUnique({
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } },
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel: channel as any } },
});
const active = tpl ? tpl.isActive : true;
const active = tpl ? tpl.isActive : (channel !== "PUSH"); // PUSH requires explicit template
if (!active) continue;
const subject = interpolate(
@@ -144,19 +153,19 @@ export class NotificationsService {
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";
}
const result = await this.sendWhatsApp(pkg.tenantId, bodyText, fullUser);
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
finalBody = result.finalBody ?? bodyText;
errorMsg = result.error;
} else if (channel === "SMS") {
const result = await this.sendSms(pkg.tenantId, bodyText, fullUser);
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
errorMsg = result.error;
} else if (channel === "PUSH") {
// PUSH no implementado — marcar FALLIDO silenciosamente
notifStatus = "FALLIDO";
errorMsg = "PUSH no configurado";
}
@@ -165,7 +174,7 @@ export class NotificationsService {
data: {
packageId: pkg.id,
userId: pkg.userId,
channel,
channel: channel as any,
status: "PENDIENTE",
subject,
body: finalBody,
@@ -180,6 +189,18 @@ export class NotificationsService {
error: errorMsg ?? null,
},
});
// ── Real-time WebSocket push (C-6) ─────────────────────────────────
if (channel === "PUSH" && this.gateway) {
try {
this.gateway.emitStatusChange(pkg.userId, {
id: pkg.id,
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description,
});
} catch { /* non-blocking */ }
}
} catch (e: unknown) {
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
}
@@ -256,6 +277,125 @@ export class NotificationsService {
}
}
/**
* WhatsApp Business Cloud API (Meta) — C-4.
* Falls back to wa.me link if Business API credentials are not configured.
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api/messages
*/
private async sendWhatsApp(
tenantId: string,
bodyText: string,
toUser: any,
): Promise<{ ok: boolean; finalBody?: string; error?: string }> {
const phone = toUser?.phone ? toUser.phone : null;
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
const apiToken = await this.integrations.getValue(tenantId, "whatsapp_api_token");
const phoneNumId = await this.integrations.getValue(tenantId, "whatsapp_phone_number_id");
// ── Meta WhatsApp Cloud API ────────────────────────────────────────────
if (apiToken && phoneNumId) {
try {
const toE164 = toE164Ecuador(phone);
const res = await fetch(
`https://graph.facebook.com/v19.0/${phoneNumId}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiToken}`,
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: toE164.replace("+", ""),
type: "text",
text: { body: bodyText },
}),
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[WhatsApp Business] Send failed (${res.status}): ${errText}`);
return { ok: false, error: `WhatsApp API error ${res.status}` };
}
this.logger.log(`[WhatsApp Business] Message sent to ${toE164}`);
return { ok: true, finalBody: bodyText };
} catch (err: any) {
this.logger.error(`[WhatsApp Business] Exception: ${err.message}`);
// Fall through to wa.me
}
}
// ── wa.me fallback (no Business API credentials) ─────────────────────
const normalized = normalizePhone(phone);
const waLink = `https://wa.me/${normalized}?text=${encodeURIComponent(bodyText)}`;
this.logger.warn(`[WhatsApp] Using wa.me fallback for ${normalized} — configure whatsapp_api_token + whatsapp_phone_number_id en Integraciones`);
return { ok: true, finalBody: waLink };
}
/**
* Twilio SMS — C-3.
* Skips gracefully if credentials not configured.
* Docs: https://www.twilio.com/docs/messaging/api
*/
private async sendSms(
tenantId: string,
bodyText: string,
toUser: any,
): Promise<{ ok: boolean; error?: string }> {
const phone = toUser?.phone ? toUser.phone : null;
if (!phone) return { ok: false, error: "Sin número de teléfono registrado" };
const accountSid = await this.integrations.getValue(tenantId, "sms_account_sid");
const authToken = await this.integrations.getValue(tenantId, "sms_auth_token");
const fromNumber = await this.integrations.getValue(tenantId, "sms_from_number");
if (!accountSid || !authToken || !fromNumber) {
this.logger.warn(`[Twilio SMS] Credenciales no configuradas para tenant ${tenantId} — SMS omitido`);
return { ok: false, error: "Twilio no configurado" };
}
try {
const toE164 = toE164Ecuador(phone);
const basic = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
const body = new URLSearchParams({
From: fromNumber,
To: toE164,
Body: bodyText.substring(0, 1600), // Twilio max length
});
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${basic}`,
},
body,
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[Twilio] Send failed (${res.status}): ${errText}`);
return { ok: false, error: `Twilio error ${res.status}` };
}
const data = await res.json();
this.logger.log(`[Twilio] SMS sent to ${toE164} — SID: ${data.sid}`);
return { ok: true };
} catch (err: any) {
this.logger.error(`[Twilio] Exception: ${err.message}`);
return { ok: false, error: err.message };
}
}
async findByUser(userId: string, limit = 20) {
return this.prisma.client.notification.findMany({
where: { userId },