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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user