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
+67 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common";
import { createHmac } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
export interface AuditLogEntry {
@@ -12,21 +13,86 @@ export interface AuditLogEntry {
userAgent?: string;
}
/**
* AuditLogService — M-5
* Adds HMAC-SHA256 integrity hash to every log entry so tampering can be detected.
* Hash is stored in metadata.integrity.
* Secret: env var AUDIT_HMAC_SECRET (falls back to a default for dev).
*/
@Injectable()
export class AuditLogService {
private readonly logger = new Logger(AuditLogService.name);
private readonly hmacSecret = process.env.AUDIT_HMAC_SECRET ?? "moraworld-audit-hmac-secret-change-in-prod";
constructor(private prisma: PrismaService) {}
/**
* Compute HMAC-SHA256 of the canonical log payload.
* Canonical form: JSON.stringify of { tenantId, userId, action, resource, resourceId, createdAt }
*/
private computeIntegrity(entry: AuditLogEntry, createdAt: Date): string {
const canonical = JSON.stringify({
tenantId: entry.tenantId ?? null,
userId: entry.userId ?? null,
action: entry.action,
resource: entry.resource ?? null,
resourceId: entry.resourceId ?? null,
createdAt: createdAt.toISOString(),
});
return createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
}
async log(entry: AuditLogEntry): Promise<void> {
try {
await this.prisma.client.auditLog.create({ data: entry });
const createdAt = new Date();
const integrity = this.computeIntegrity(entry, createdAt);
await this.prisma.client.auditLog.create({
data: {
...entry,
metadata: {
...(entry.metadata ?? {}),
integrity, // HMAC-SHA256 of canonical payload
},
createdAt,
},
});
} catch (e: unknown) {
// Never let audit log failure break main flow
this.logger.error("AuditLog write failed", (e as Error).message);
}
}
/**
* Verify the integrity hash of a stored audit log entry.
* Returns true if the hash matches, false if the record was tampered with.
*/
verify(entry: {
tenantId?: string | null;
userId?: string | null;
action: string;
resource?: string | null;
resourceId?: string | null;
createdAt: Date;
metadata?: any;
}): boolean {
const stored = entry.metadata?.integrity;
if (!stored) return false;
const canonical = JSON.stringify({
tenantId: entry.tenantId ?? null,
userId: entry.userId ?? null,
action: entry.action,
resource: entry.resource ?? null,
resourceId: entry.resourceId ?? null,
createdAt: entry.createdAt instanceof Date
? entry.createdAt.toISOString()
: new Date(entry.createdAt).toISOString(),
});
const expected = createHmac("sha256", this.hmacSecret).update(canonical).digest("hex");
return expected === stored;
}
async findAll(filters: {
tenantId?: string;
userId?: string;
@@ -10,11 +10,18 @@ export const INTEGRATION_CATALOG = [
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
// ── Notificaciones ────────────────────────────────────────
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
// ── WhatsApp Business Cloud API (Meta) — C-4 ─────────────
{ key: "whatsapp_api_token", label: "WhatsApp Business — API Token (Meta)", group: "notifications", required: false },
{ key: "whatsapp_phone_number_id", label: "WhatsApp Business — Phone Number ID", group: "notifications", required: false },
// ── Twilio SMS — C-3 ─────────────────────────────────────
{ key: "sms_account_sid", label: "Twilio — Account SID", group: "notifications", required: false },
{ key: "sms_auth_token", label: "Twilio — Auth Token", group: "notifications", required: false },
{ key: "sms_from_number", label: "Twilio — From Number (E.164)", group: "notifications", required: false },
// ── Aduana / SENAE ────────────────────────────────────────
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
+4
View File
@@ -1,6 +1,7 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { IoAdapter } from "@nestjs/platform-socket.io";
import { join } from "path";
import { AppModule } from "./app.module";
@@ -16,6 +17,9 @@ async function bootstrap() {
credentials: true,
});
// Socket.io adapter for WebSocket gateway (C-6)
app.useWebSocketAdapter(new IoAdapter(app));
app.setGlobalPrefix("api");
// Serve uploaded files (photos, invoices) as static assets
@@ -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 },
+11
View File
@@ -14,6 +14,17 @@ export class CreatePackageDto {
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
}
/** Cliente registra su propia compra — el userId viene del JWT (doc §09 paso 5) */
export class RegisterPackageDto {
@IsString() description!: string;
@IsOptional() @IsString() store?: string;
@IsOptional() @IsString() vendorTracking?: string;
@IsOptional() @IsString() productUrl?: string;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) declaredValue?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() declaredWeightLb?: number;
@IsOptional() @IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"]) senaeCategory?: string;
}
export class UpdateStatusDto {
@IsEnum([
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
+8 -1
View File
@@ -7,7 +7,7 @@ import { memoryStorage } from "multer";
import { extname } from "path";
import { PackagesService } from "./packages.service";
import { StorageService } from "../storage/storage.service";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@@ -45,6 +45,13 @@ export class PackagesController {
return this.svc.create(dto, user.id, user.tenantId);
}
/** Cliente registra su propia compra (doc §09 paso 5) */
@Post("register")
@Roles("CLIENTE")
selfRegister(@Body() dto: RegisterPackageDto, @CurrentUser() user: any): Promise<any> {
return this.svc.selfRegister(dto, user.id, user.tenantId);
}
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(
+2 -1
View File
@@ -5,9 +5,10 @@ import { PrismaModule } from "../prisma/prisma.module";
import { ConfigModule } from "@nestjs/config";
import { NotificationsModule } from "../notifications/notifications.module";
import { StorageModule } from "../storage/storage.module";
import { SenaeModule } from "../senae/senae.module";
@Module({
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule],
imports: [PrismaModule, ConfigModule, NotificationsModule, StorageModule, SenaeModule],
providers: [PackagesService],
controllers: [PackagesController],
exports: [PackagesService],
+61 -28
View File
@@ -1,14 +1,16 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationsService } from "../notifications/notifications.service";
import { SenaeService } from "../senae/senae.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
@Injectable()
export class PackagesService {
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
private senae: SenaeService,
) {}
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
@@ -87,6 +89,29 @@ export class PackagesService {
return pkg;
}
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
const updated = await this.prisma.client.package.update({
where: { id },
data: { status: dto.status as any },
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: dto.status as any,
createdBy: operatorId,
note: dto.note,
},
});
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return updated;
}
/** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking
* y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */
private async tryLinkPreAlert(
@@ -115,28 +140,41 @@ export class PackagesService {
}
}
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
/** Cliente registra su propia compra — doc §09 pasos 5-6 */
async selfRegister(dto: RegisterPackageDto, userId: string, tenantId: string): Promise<any> {
const trackingId = generateTrackingId();
const updated = await this.prisma.client.package.update({
where: { id },
data: { status: dto.status as any },
const pkg = await this.prisma.client.package.create({
data: {
trackingId,
tenantId,
userId,
description: dto.description,
store: dto.store,
vendorTracking: dto.vendorTracking,
productUrl: dto.productUrl,
declaredValue: dto.declaredValue ?? 0,
declaredWeight: dto.declaredWeightLb ?? null,
senaeCategory: dto.senaeCategory as any ?? null,
status: "REGISTRADO",
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: dto.status as any,
createdBy: operatorId,
note: dto.note,
packageId: pkg.id,
status: "REGISTRADO",
createdBy: userId,
note: "Compra registrada por el cliente",
},
});
// Notify user on status change
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
await this.tryLinkPreAlert(pkg.id, userId, tenantId, dto.vendorTracking);
return updated;
// Notify the user of registration
this.notifications.notifyStatusChange(pkg, { id: userId }).catch(() => {});
return pkg;
}
/**
@@ -201,7 +239,7 @@ export class PackagesService {
}
/**
* SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA.
* SENAE declaration (doc §11): call SenaeService (real or stub), update status to DECLARACION_ADUANERA.
*/
async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
@@ -210,10 +248,13 @@ export class PackagesService {
throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración.");
}
// Stub: In prod this would call SENAE SOAP/REST WebService
// Generate a plausible authorization number
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
const declarationId = `DSI-${pkg.trackingId}`;
// Call real SENAE service (falls back to stub if credentials not set — C-1)
const { authNumber, declarationId, message } = await this.senae.submitDSI(
pkg,
pkg.tenantId,
dto.category,
dto.agentNotes,
);
const updated = await this.prisma.client.package.update({
where: { id },
@@ -234,17 +275,9 @@ export class PackagesService {
},
});
const result = {
...updated,
declarationId,
authNumber,
message: "Declaración simplificada (DSI) enviada y aprobada por la SENAE (stub).",
};
// Notify user of customs clearance
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return result;
return { ...updated, declarationId, authNumber, message };
}
/**
+3 -9
View File
@@ -1,6 +1,7 @@
import { Controller, Post, Body, UseGuards } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("products")
@UseGuards(JwtAuthGuard)
@@ -8,14 +9,7 @@ export class ProductsController {
constructor(private svc: ProductsService) {}
@Post("scan")
scanUrl(@Body("url") url: string): Promise<{
name: string;
price: number;
weightLb: number;
imageUrl: string;
store: string;
url: string;
}> {
return this.svc.scanUrl(url);
scanUrl(@Body("url") url: string, @CurrentUser() user: any): Promise<any> {
return this.svc.scanUrl(url, user?.tenantId);
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProductsService } from "./products.service";
import { ProductsController } from "./products.controller";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [IntegrationsModule],
providers: [ProductsService],
controllers: [ProductsController],
exports: [ProductsService],
+128 -12
View File
@@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { IntegrationsService } from "../integrations/integrations.service";
interface ProductScanResult {
name: string;
@@ -7,35 +8,150 @@ interface ProductScanResult {
imageUrl: string;
store: string;
url: string;
asin?: string;
brand?: string;
isStub: boolean;
}
/**
* ProductsService — C-2
* Scans a product URL. If Amazon SP-API credentials are configured (amazon_client_id,
* amazon_client_secret, amazon_refresh_token), uses the Catalog Items API to get real data.
* Falls back to stub if not configured or on error.
*
* SP-API docs: https://developer-docs.amazon.com/sp-api/docs/catalog-items-api-v2022-04-01
*/
@Injectable()
export class ProductsService {
/**
* Stub: In production this calls Amazon SP-API or a scraping service.
* For now it extracts basic info from the URL and returns plausible mock data.
*/
async scanUrl(url: string): Promise<ProductScanResult> {
const store = this.detectStore(url);
private readonly logger = new Logger(ProductsService.name);
// SP-API LWA token cache: { accessToken, expiresAt }
private lwaCache: { accessToken: string; expiresAt: number; tenantId: string } | null = null;
// Extract ASIN from Amazon URL if present
constructor(private integrations: IntegrationsService) {}
async scanUrl(url: string, tenantId?: string): Promise<ProductScanResult> {
const store = this.detectStore(url);
const asinMatch = url.match(/\/dp\/([A-Z0-9]{10})/);
const asin = asinMatch ? asinMatch[1] : null;
// Stub response — in prod: call Amazon SP-API Catalog Items API
// ── Amazon SP-API call if configured ─────────────────────────────────────
if (asin && store === "Amazon" && tenantId) {
try {
const result = await this.callSpApi(asin, tenantId);
if (result) return { ...result, store, url, isStub: false };
} catch (err: any) {
this.logger.warn(`[SP-API] Failed for ASIN ${asin}: ${err.message}`);
}
}
// ── Stub fallback ─────────────────────────────────────────────────────────
if (asin) {
this.logger.warn(`[SP-API STUB] ASIN ${asin} — configure amazon_client_id, amazon_client_secret, amazon_refresh_token en Integraciones`);
}
return {
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
price: 29.99,
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
price: 29.99,
weightLb: 1.5,
imageUrl: "https://placehold.co/200x200?text=Product",
store,
url,
asin: asin ?? undefined,
isStub: true,
};
}
/**
* Exchange LWA refresh token for a SP-API access token (cached 55 min).
*/
private async getLwaToken(clientId: string, clientSecret: string, refreshToken: string, tenantId: string): Promise<string> {
const now = Date.now();
if (this.lwaCache && this.lwaCache.tenantId === tenantId && this.lwaCache.expiresAt > now + 60_000) {
return this.lwaCache.accessToken;
}
const res = await fetch("https://api.amazon.com/auth/o2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.text().catch(() => `HTTP ${res.status}`);
throw new Error(`LWA token error: ${err}`);
}
const data = await res.json();
this.lwaCache = {
tenantId,
accessToken: data.access_token,
expiresAt: now + (data.expires_in ?? 3600) * 1000,
};
return data.access_token;
}
/**
* Call SP-API Catalog Items v2022-04-01 for a single ASIN.
*/
private async callSpApi(asin: string, tenantId: string): Promise<Omit<ProductScanResult, "store" | "url" | "isStub"> | null> {
const clientId = await this.integrations.getValue(tenantId, "amazon_client_id");
const clientSecret = await this.integrations.getValue(tenantId, "amazon_client_secret");
const refreshToken = await this.integrations.getValue(tenantId, "amazon_refresh_token");
if (!clientId || !clientSecret || !refreshToken) return null;
const accessToken = await this.getLwaToken(clientId, clientSecret, refreshToken, tenantId);
// SP-API Catalog Items endpoint (NA marketplace)
const marketplaceId = "ATVPDKIKX0DER"; // US
const endpoint = `https://sellingpartnerapi-na.amazon.com/catalog/2022-04-01/items/${asin}?marketplaceIds=${marketplaceId}&includedData=summaries,images,dimensions`;
const res = await fetch(endpoint, {
headers: {
"x-amz-access-token": accessToken,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const err = await res.text().catch(() => `HTTP ${res.status}`);
throw new Error(`SP-API error: ${err}`);
}
const data = await res.json();
const summary = data?.summaries?.[0];
const image = data?.images?.[0]?.images?.[0];
const dims = data?.dimensions?.[0]?.package;
// Convert kg to lb if available
let weightLb = 1.5;
if (dims?.weight?.value && dims.weight.unit === "KILOGRAMS") {
weightLb = Number(dims.weight.value) * 2.20462;
} else if (dims?.weight?.value && dims.weight.unit === "POUNDS") {
weightLb = Number(dims.weight.value);
}
this.logger.log(`[SP-API] Fetched item: ASIN ${asin}${summary?.itemName ?? "unknown"}`);
return {
name: summary?.itemName ?? `Amazon ASIN ${asin}`,
price: 0, // Catalog Items API does not return price; use Pricing API separately
weightLb: Math.round(weightLb * 100) / 100,
imageUrl: image?.link ?? "https://placehold.co/200x200?text=Amazon",
asin,
brand: summary?.brand ?? undefined,
};
}
private detectStore(url: string): string {
if (url.includes("amazon.")) return "Amazon";
if (url.includes("ebay.")) return "eBay";
if (url.includes("ebay.")) return "eBay";
if (url.includes("walmart.")) return "Walmart";
if (url.includes("target.")) return "Target";
if (url.includes("bestbuy.")) return "Best Buy";
+10
View File
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { SenaeService } from "./senae.service";
import { IntegrationsModule } from "../integrations/integrations.module";
@Module({
imports: [IntegrationsModule],
providers: [SenaeService],
exports: [SenaeService],
})
export class SenaeModule {}
+93
View File
@@ -0,0 +1,93 @@
import { Injectable, Logger } from "@nestjs/common";
import { IntegrationsService } from "../integrations/integrations.service";
export interface SenaeDeclarationResult {
authNumber: string;
declarationId: string;
isStub: boolean;
message: string;
}
/**
* SenaeService — C-1
* Envía la Declaración Simplificada de Importación (DSI) a la SENAE.
* Si las credenciales de integración están configuradas, hace la llamada real.
* Si no, genera un número de stub para entorno de desarrollo.
*
* Docs: §11, §18 — SENAE WebService integration
*/
@Injectable()
export class SenaeService {
private readonly logger = new Logger(SenaeService.name);
constructor(private integrations: IntegrationsService) {}
async submitDSI(pkg: any, tenantId: string, category: string, agentNotes?: string): Promise<SenaeDeclarationResult> {
const endpoint = await this.integrations.getValue(tenantId, "senae_endpoint");
const apiKey = await this.integrations.getValue(tenantId, "senae_api_key");
const ruc = await this.integrations.getValue(tenantId, "senae_ruc");
const agentCode = await this.integrations.getValue(tenantId, "senae_agent_code");
const declarationId = `DSI-${pkg.trackingId}`;
// ── Real SENAE WebService call ────────────────────────────────────────────
if (endpoint && apiKey && ruc) {
try {
const payload = {
declaracion: {
tipo: "DSI",
rucDeclarante: ruc,
codigoAgente: agentCode ?? null,
trackingInterno: pkg.trackingId,
descripcion: pkg.description,
valorDeclarado: Number(pkg.declaredValue ?? 0),
pesoKg: Number(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592,
categoria: category,
fechaEnvio: new Date().toISOString(),
notas: agentNotes ?? null,
},
};
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"X-Agent-Code": agentCode ?? "",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const errText = await res.text().catch(() => `HTTP ${res.status}`);
this.logger.error(`[SENAE] DSI submit failed (${res.status}): ${errText}`);
// Fall through to stub on error
} else {
const data = await res.json();
const authNumber = data?.autorizacion ?? data?.authNumber ?? data?.numeroAutorizacion;
if (authNumber) {
this.logger.log(`[SENAE] DSI aprobada — Auth: ${authNumber} — Pkg: ${pkg.trackingId}`);
return { authNumber, declarationId, isStub: false, message: `DSI aprobada por SENAE. Autorización: ${authNumber}` };
}
this.logger.warn(`[SENAE] Respuesta inesperada: ${JSON.stringify(data).slice(0, 200)}`);
}
} catch (err: any) {
this.logger.error(`[SENAE] Exception: ${err.message}`);
// Fall through to stub
}
} else {
this.logger.warn(`[SENAE] Credenciales no configuradas para tenant ${tenantId} — usando stub`);
}
// ── Stub fallback (dev / sin credenciales) ────────────────────────────────
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
this.logger.warn(`[SENAE STUB] Auth: ${authNumber} — configure senae_endpoint, senae_api_key, senae_ruc en Integraciones`);
return {
authNumber,
declarationId,
isStub: true,
message: `DSI generada (STUB — sin conexión real SENAE). Auth: ${authNumber}. Configure las claves SENAE en Admin → Integraciones.`,
};
}
}