From 84c1fdec541f1ebaf0cada76d995df1240cfa184 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:28:42 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20C-8/C-1/C-2/C-3/C-4/C-6/M-1/M-5/M-6=20?= =?UTF-8?q?=E2=80=94=20WebSocket=20gateway,=20SENAE=20real,=20SP-API,=20Tw?= =?UTF-8?q?ilio=20SMS,=20WhatsApp=20Business,=20soporte=20portal,=20HMAC?= =?UTF-8?q?=20audit,=20reportes=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 3 + apps/api/src/audit-log/audit-log.service.ts | 68 +++++- .../src/integrations/integrations.service.ts | 17 +- apps/api/src/main.ts | 4 + .../notifications/notifications.gateway.ts | 101 ++++++++ .../src/notifications/notifications.module.ts | 15 +- .../notifications.service.spec.ts | 5 +- .../notifications/notifications.service.ts | 178 ++++++++++++-- apps/api/src/packages/dto/package.dto.ts | 11 + apps/api/src/packages/packages.controller.ts | 9 +- apps/api/src/packages/packages.module.ts | 3 +- apps/api/src/packages/packages.service.ts | 89 ++++--- apps/api/src/products/products.controller.ts | 12 +- apps/api/src/products/products.module.ts | 2 + apps/api/src/products/products.service.ts | 140 ++++++++++- apps/api/src/senae/senae.module.ts | 10 + apps/api/src/senae/senae.service.ts | 93 +++++++ apps/web/package.json | 3 +- apps/web/src/app/admin/reportes/page.tsx | 160 ++++++++++-- apps/web/src/app/portal/layout.tsx | 74 +++++- .../src/app/portal/registrar-compra/page.tsx | 177 ++++++++++++++ apps/web/src/app/soporte/clientes/page.tsx | 153 ++++++++++++ apps/web/src/app/soporte/layout.tsx | 76 ++++++ apps/web/src/app/soporte/page.tsx | 88 +++++++ apps/web/src/app/soporte/paquetes/page.tsx | 143 +++++++++++ apps/web/src/lib/api.ts | 1 + pnpm-lock.yaml | 230 +++++++++++++++++- 27 files changed, 1743 insertions(+), 122 deletions(-) create mode 100644 apps/api/src/notifications/notifications.gateway.ts create mode 100644 apps/api/src/senae/senae.module.ts create mode 100644 apps/api/src/senae/senae.service.ts create mode 100644 apps/web/src/app/portal/registrar-compra/page.tsx create mode 100644 apps/web/src/app/soporte/clientes/page.tsx create mode 100644 apps/web/src/app/soporte/layout.tsx create mode 100644 apps/web/src/app/soporte/page.tsx create mode 100644 apps/web/src/app/soporte/paquetes/page.tsx diff --git a/apps/api/package.json b/apps/api/package.json index cb8e3d1..ff2885c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,9 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.0", + "@nestjs/platform-socket.io": "^11.1.24", "@nestjs/throttler": "^6.5.0", + "@nestjs/websockets": "^11.1.24", "@types/multer": "^2.1.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", @@ -35,6 +37,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "socket.io": "^4.8.3", "stripe": "^22.2.0" }, "devDependencies": { diff --git a/apps/api/src/audit-log/audit-log.service.ts b/apps/api/src/audit-log/audit-log.service.ts index 4b6ffb0..5439fd7 100644 --- a/apps/api/src/audit-log/audit-log.service.ts +++ b/apps/api/src/audit-log/audit-log.service.ts @@ -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 { 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; diff --git a/apps/api/src/integrations/integrations.service.ts b/apps/api/src/integrations/integrations.service.ts index 15d219b..b86456a 100644 --- a/apps/api/src/integrations/integrations.service.ts +++ b/apps/api/src/integrations/integrations.service.ts @@ -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 }, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 47afbc8..5eada06 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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 diff --git a/apps/api/src/notifications/notifications.gateway.ts b/apps/api/src/notifications/notifications.gateway.ts new file mode 100644 index 0000000..ba8e90b --- /dev/null +++ b/apps/api/src/notifications/notifications.gateway.ts @@ -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: "" }` in socket options. + * 2. Gateway verifies JWT → places socket in room `user:`. + * 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() }); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index fe16390..5927bd2 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notifications.service.spec.ts b/apps/api/src/notifications/notifications.service.spec.ts index 46c5830..b98c61a 100644 --- a/apps/api/src/notifications/notifications.service.spec.ts +++ b/apps/api/src/notifications/notifications.service.spec.ts @@ -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); }); diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index 8cac820..295f3b4 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -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 = { @@ -36,11 +37,19 @@ function interpolate(tpl: string, vars: Record): 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 { - // 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 }, diff --git a/apps/api/src/packages/dto/package.dto.ts b/apps/api/src/packages/dto/package.dto.ts index 5abb287..7553936 100644 --- a/apps/api/src/packages/dto/package.dto.ts +++ b/apps/api/src/packages/dto/package.dto.ts @@ -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", diff --git a/apps/api/src/packages/packages.controller.ts b/apps/api/src/packages/packages.controller.ts index 80db8b4..7dcc295 100644 --- a/apps/api/src/packages/packages.controller.ts +++ b/apps/api/src/packages/packages.controller.ts @@ -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 { + return this.svc.selfRegister(dto, user.id, user.tenantId); + } + @Patch(":id/status") @Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN") updateStatus( diff --git a/apps/api/src/packages/packages.module.ts b/apps/api/src/packages/packages.module.ts index 8e42dec..80c7f38 100644 --- a/apps/api/src/packages/packages.module.ts +++ b/apps/api/src/packages/packages.module.ts @@ -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], diff --git a/apps/api/src/packages/packages.service.ts b/apps/api/src/packages/packages.service.ts index 83276af..ad00390 100644 --- a/apps/api/src/packages/packages.service.ts +++ b/apps/api/src/packages/packages.service.ts @@ -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 { @@ -87,6 +89,29 @@ export class PackagesService { return pkg; } + async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise { + 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 { - 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 { + 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 { 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 }; } /** diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index ad78308..ac5b71b 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -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 { + return this.svc.scanUrl(url, user?.tenantId); } } diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts index f4a59b6..caf9a7d 100644 --- a/apps/api/src/products/products.module.ts +++ b/apps/api/src/products/products.module.ts @@ -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], diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index 8004cdd..17b4ccd 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -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 { - 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 { + 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 { + 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 | 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"; diff --git a/apps/api/src/senae/senae.module.ts b/apps/api/src/senae/senae.module.ts new file mode 100644 index 0000000..824b2b2 --- /dev/null +++ b/apps/api/src/senae/senae.module.ts @@ -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 {} diff --git a/apps/api/src/senae/senae.service.ts b/apps/api/src/senae/senae.service.ts new file mode 100644 index 0000000..6dd9a75 --- /dev/null +++ b/apps/api/src/senae/senae.service.ts @@ -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 { + 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.`, + }; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 78d242a..899144f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,8 @@ "js-cookie": "^3.0.8", "next": "^15.3.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@types/js-cookie": "^3.0.6", diff --git a/apps/web/src/app/admin/reportes/page.tsx b/apps/web/src/app/admin/reportes/page.tsx index 73c4910..aae9ca8 100644 --- a/apps/web/src/app/admin/reportes/page.tsx +++ b/apps/web/src/app/admin/reportes/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { api } from "@/lib/api"; -// §08 — estados oficiales del ciclo de vida const STATUS_COLORS: Record = { REGISTRADO: "#6B7280", EN_TRANSITO_BODEGA: "#F59E0B", @@ -17,24 +16,73 @@ const STATUS_COLORS: Record = { INCIDENCIA: "#EF4444", }; +/** Convert array of objects to CSV string */ +function toCSV(rows: any[], columns: { key: string; label: string }[]): string { + const header = columns.map(c => `"${c.label}"`).join(","); + const body = rows.map(row => + columns.map(c => { + const val = row[c.key] ?? ""; + const str = String(val).replace(/"/g, '""'); + return `"${str}"`; + }).join(",") + ); + return [header, ...body].join("\r\n"); +} + +/** Trigger browser download of a CSV string */ +function downloadCSV(csv: string, filename: string) { + const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); // BOM for Excel + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// Default date range: last 30 days +function defaultFrom() { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().split("T")[0]; +} +function defaultTo() { + return new Date().toISOString().split("T")[0]; +} + export default function ReportesPage() { const [users, setUsers] = useState([]); const [packages, setPackages] = useState([]); const [loading, setLoading] = useState(true); + const [from, setFrom] = useState(defaultFrom()); + const [to, setTo] = useState(defaultTo()); - useEffect(() => { + const fetchData = () => { + setLoading(true); Promise.all([api.users.list(), api.packages.list()]) .then(([u, p]) => { setUsers(u); setPackages(p); }) .catch(() => {}) .finally(() => setLoading(false)); - }, []); + }; - if (loading) return
; + useEffect(() => { fetchData(); }, []); + + // Filter packages by date range + const filteredPackages = useMemo(() => { + const fromDate = from ? new Date(from + "T00:00:00") : null; + const toDate = to ? new Date(to + "T23:59:59") : null; + return packages.filter(p => { + const d = new Date(p.createdAt); + if (fromDate && d < fromDate) return false; + if (toDate && d > toDate) return false; + return true; + }); + }, [packages, from, to]); const byStatus: Record = {}; - packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; }); + filteredPackages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; }); - const totalDeclared = packages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0); + const totalDeclared = filteredPackages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0); const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0); const delivered = byStatus["ENTREGADO"] ?? 0; const incidents = byStatus["INCIDENCIA"] ?? 0; @@ -42,24 +90,83 @@ export default function ReportesPage() { const byRole: Record = {}; users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; }); + // ── CSV Export handlers ──────────────────────────────────────────────── + const exportPackagesCSV = () => { + const cols = [ + { key: "trackingId", label: "Tracking ID" }, + { key: "description", label: "Descripción" }, + { key: "store", label: "Tienda" }, + { key: "status", label: "Estado" }, + { key: "declaredValue", label: "Valor Declarado (USD)" }, + { key: "actualWeight", label: "Peso Real (lb)" }, + { key: "senaeCategory", label: "Categoría SENAE" }, + { key: "createdAt", label: "Fecha Registro" }, + ]; + const rows = filteredPackages.map(p => ({ + ...p, + createdAt: new Date(p.createdAt).toLocaleDateString("es-EC"), + })); + downloadCSV(toCSV(rows, cols), `paquetes_${from}_${to}.csv`); + }; + + const exportUsersCSV = () => { + const cols = [ + { key: "firstName", label: "Nombre" }, + { key: "lastName", label: "Apellido" }, + { key: "email", label: "Email" }, + { key: "phone", label: "Teléfono" }, + { key: "role", label: "Rol" }, + { key: "isActive", label: "Activo" }, + { key: "createdAt", label: "Fecha Registro" }, + ]; + const rows = users.map(u => ({ + ...u, + isActive: u.isActive ? "Sí" : "No", + createdAt: new Date(u.createdAt).toLocaleDateString("es-EC"), + })); + downloadCSV(toCSV(rows, cols), `usuarios_${new Date().toISOString().split("T")[0]}.csv`); + }; + + if (loading) return
; + return (
-
-

Reportes

-

Resumen operativo del sistema — ingresos, volumen y estado de envíos.

+
+
+

Reportes

+

Resumen operativo — ingresos, volumen y estado de envíos.

+
+ + {/* Date range + export */} +
+
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+ + +
{/* KPIs */}
{[ - { label: "Total paquetes", value: packages.length, color: "var(--primary)" }, - { label: "Entregados", value: delivered, color: "var(--green)" }, - { label: "En tránsito", value: inTransit, color: "var(--yellow)" }, - { label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" }, - { label: "Incidencias", value: incidents, color: "var(--red)" }, - { label: "Usuarios", value: users.length, color: "var(--primary)" }, - { label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" }, - { label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" }, + { label: "Paquetes en rango", value: filteredPackages.length, color: "var(--primary)" }, + { label: "Entregados", value: delivered, color: "var(--green)" }, + { label: "En tránsito", value: inTransit, color: "var(--yellow)" }, + { label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC", { minimumFractionDigits:2, maximumFractionDigits:2 })}`, color: "var(--accent)" }, + { label: "Incidencias", value: incidents, color: "var(--red)" }, + { label: "Usuarios totales", value: users.length, color: "var(--primary)" }, + { label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" }, + { label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" }, ].map(s => (
{s.value}
@@ -71,11 +178,16 @@ export default function ReportesPage() {
{/* Paquetes por estado */}
-
Paquetes por estado (§08)
+
+ Paquetes por estado (§08) + + {from} → {to} + +
{Object.keys(STATUS_COLORS).map(status => { const count = byStatus[status] ?? 0; - const pct = packages.length ? Math.round((count / packages.length) * 100) : 0; + const pct = filteredPackages.length ? Math.round((count / filteredPackages.length) * 100) : 0; return (
@@ -91,7 +203,7 @@ export default function ReportesPage() {
); })} - {packages.length === 0 &&

Sin datos.

} + {filteredPackages.length === 0 &&

Sin datos en el rango seleccionado.

}
@@ -115,10 +227,10 @@ export default function ReportesPage() {
Rendimiento
{[ - ["Tasa de entrega", packages.length ? `${Math.round((delivered/packages.length)*100)}%` : "—"], - ["Tasa de incidencias", packages.length ? `${Math.round((incidents/packages.length)*100)}%` : "—"], + ["Tasa de entrega", filteredPackages.length ? `${Math.round((delivered/filteredPackages.length)*100)}%` : "—"], + ["Tasa de incidencias", filteredPackages.length ? `${Math.round((incidents/filteredPackages.length)*100)}%` : "—"], ["Pendiente declaración", byStatus["VERIFICADO"] ?? 0], - ["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)], + ["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)], ].map(([k, v]) => (
{k} diff --git a/apps/web/src/app/portal/layout.tsx b/apps/web/src/app/portal/layout.tsx index 0ef9d91..033061d 100644 --- a/apps/web/src/app/portal/layout.tsx +++ b/apps/web/src/app/portal/layout.tsx @@ -1,31 +1,67 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { getUser, clearAuth, getRefresh } from "@/lib/api"; +import { getUser, clearAuth, getToken } from "@/lib/api"; import { api } from "@/lib/api"; +const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "http://localhost:3001"; + const NAV = [ - { href: "/portal", icon: "◈", label: "Dashboard" }, - { href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" }, - { href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" }, - { href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" }, - { href: "/portal/consolidacion", icon: "🗃️", label: "Consolidar" }, - { href: "/portal/calculadora", icon: "🧮", label: "Calculadora" }, - { href: "/portal/perfil", icon: "👤", label: "Mi Perfil" }, + { href: "/portal", icon: "◈", label: "Dashboard" }, + { href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" }, + { href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" }, + { href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" }, + { href: "/portal/registrar-compra", icon: "🛍️", label: "Registrar Compra" }, + { href: "/portal/consolidacion", icon: "🗃️", label: "Consolidar" }, + { href: "/portal/calculadora", icon: "🧮", label: "Calculadora" }, + { href: "/portal/perfil", icon: "👤", label: "Mi Perfil" }, ]; export default function PortalLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [unread, setUnread] = useState(0); + const [toast, setToast] = useState<{ text: string; trackingId: string } | null>(null); + + // ── Real-time WS notifications (C-6) ──────────────────────────────────── + const connectWs = useCallback((token: string) => { + // Lazy-load socket.io-client only in browser + import("socket.io-client").then(({ io }) => { + const WS_NS = `${WS_URL}/ws`; + const socket = io(WS_NS, { + path: "/socket.io", + auth: { token }, + transports: ["websocket", "polling"], + reconnectionAttempts: 5, + }); + + socket.on("package:status", (data: any) => { + setUnread(n => n + 1); + setToast({ text: `Paquete ${data.trackingId} → ${data.status.replace(/_/g, " ")}`, trackingId: data.trackingId }); + // Auto-dismiss toast after 5 s + setTimeout(() => setToast(null), 5000); + }); + + socket.on("connect_error", (err: Error) => { + if (process.env.NODE_ENV === "development") { + console.debug("[WS] connect_error:", err.message); + } + }); + + return () => { socket.disconnect(); }; + }).catch(() => {/* socket.io-client not available — SSR or CDN issue */}); + }, []); useEffect(() => { const u = getUser(); if (!u) { router.replace("/login"); return; } setUser(u); - }, [router]); + + const token = getToken(); + if (token) connectWs(token); + }, [router, connectWs]); const handleLogout = async () => { try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {} @@ -37,6 +73,22 @@ export default function PortalLayout({ children }: { children: React.ReactNode } return (
+ {/* Toast (WS notification) */} + {toast && ( +
+ 🚀 + {toast.text} + +
+ )} + {/* Sidebar */}