feat: payments module, notification templates, WA float, route fixes

- Add PaymentsModule: POST /payments/intent, POST /:id/confirm, GET /payments, GET /payments/package/:id, GET /payments/track/:trackingId
- Add Payment model to Prisma schema (PaymentStatus enum, Payment table)
- Add NotificationTemplate model + NotificationsController (GET/PUT /notification-templates, POST /notification-templates/seed)
- Update NotificationsService: DB-backed templates with variable interpolation {{trackingId}} {{firstName}} {{status}} {{suiteCode}}
- Fix /bodega/paquetes: replace 11 wrong status strings with correct §08 enum values
- Fix /admin/reportes: replace EN_CAMINO_A_ECUADOR with correct §08 statuses, rewrite report page with proper KPIs and bar charts
- Fix /portal/mis-paquetes: correct §08 statuses, add 'Pagar envío' button for VERIFICADO/DECLARACION_ADUANERA packages
- Add WhatsApp float component (_components/whatsapp-float.tsx, 2 contacts: NJ ops + Cuenca aduana)
- Add /casillero/calculadora and /casillero/registro redirects (§20)
- Add /portal/pago payment page with cost breakdown (§09/§14/§15)
- Add /admin/notificaciones page: view/edit/toggle templates per event×channel
- Admin nav: add Notificaciones link
- api.ts: add notificationTemplates.* and payments.* client methods
- schema.prisma v0.4: PaymentStatus enum, Payment model, NotificationTemplate model
- db push applied to remote DB (46.202.93.92)
- All builds pass (API nest build + Next.js build)
This commit is contained in:
Lizandro Guarnizo
2026-06-01 17:09:05 -05:00
parent b2b292c50a
commit 5565eef554
20 changed files with 1326 additions and 97 deletions
@@ -0,0 +1,46 @@
import {
Controller,
Get,
Put,
Post,
Body,
Param,
UseGuards,
Request,
} from "@nestjs/common";
import { JwtAuthGuard } from "../auth/guards/auth.guard";
import { NotificationsService } from "./notifications.service";
class UpdateTemplateDto {
body!: string;
subject?: string;
isActive?: boolean;
}
@Controller("notification-templates")
@UseGuards(JwtAuthGuard)
export class NotificationsController {
constructor(private readonly svc: NotificationsService) {}
/** GET /notification-templates — lista plantillas del tenant */
@Get()
list(@Request() req: any) {
return this.svc.getTemplates(req.user.tenantId);
}
/** PUT /notification-templates/:id — actualiza asunto/cuerpo/estado */
@Put(":id")
update(
@Param("id") id: string,
@Body() dto: UpdateTemplateDto,
@Request() req: any,
) {
return this.svc.updateTemplate(id, req.user.tenantId, dto.body, dto.subject, dto.isActive);
}
/** POST /notification-templates/seed — crea plantillas por defecto (idempotente) */
@Post("seed")
seed(@Request() req: any) {
return this.svc.seedDefaultTemplates(req.user.tenantId);
}
}
@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsService } from "./notifications.service";
import { NotificationsController } from "./notifications.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
@@ -1,50 +1,138 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
const DEFAULT_SUBJECTS: Record<string, string> = {
REGISTRADO: "Tu paquete fue registrado — {{trackingId}}",
EN_TRANSITO_BODEGA: "Tu paquete está en camino a NJ — {{trackingId}}",
RECIBIDO_BODEGA: "Tu paquete llegó a bodega NJ — {{trackingId}}",
EN_VERIFICACION: "Tu paquete está siendo verificado — {{trackingId}}",
VERIFICADO: "Tu paquete fue verificado — {{trackingId}}",
DECLARACION_ADUANERA: "Declaración aduanera aprobada — {{trackingId}}",
EN_TRANSITO_ECUADOR: "Tu paquete viaja hacia Ecuador — {{trackingId}}",
EN_ADUANA_ECUADOR: "Tu paquete está en aduana Ecuador — {{trackingId}}",
LISTO_ENTREGA: "Tu paquete está listo para entrega — {{trackingId}}",
ENTREGADO: "Tu paquete fue entregado — {{trackingId}}",
INCIDENCIA: "Incidencia en tu paquete — {{trackingId}}",
};
const DEFAULT_BODIES: Record<string, string> = {
REGISTRADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue registrado en el sistema. Te notificaremos cada avance.",
EN_TRANSITO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia nuestra bodega en New Jersey.",
RECIBIDO_BODEGA: "Hola {{firstName}}, tu paquete {{trackingId}} llegó a nuestra bodega en NJ. Estamos procesándolo.",
EN_VERIFICACION: "Hola {{firstName}}, tu paquete {{trackingId}} está siendo verificado por nuestro equipo.",
VERIFICADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue verificado. El cobro final fue aplicado.",
DECLARACION_ADUANERA: "Hola {{firstName}}, la declaración aduanera SENAE de tu paquete {{trackingId}} fue aprobada.",
EN_TRANSITO_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en tránsito hacia Ecuador. ¡Ya viene en camino!",
EN_ADUANA_ECUADOR: "Hola {{firstName}}, tu paquete {{trackingId}} está en inspección aduanera en Ecuador.",
LISTO_ENTREGA: "Hola {{firstName}}, tu paquete {{trackingId}} está listo para ser retirado o entregado.",
ENTREGADO: "Hola {{firstName}}, tu paquete {{trackingId}} fue entregado exitosamente. ¡Gracias por confiar en Moraworld Imports!",
INCIDENCIA: "Hola {{firstName}}, hay una incidencia con tu paquete {{trackingId}}. Nuestro equipo te contactará pronto.",
};
/** Sustituye variables {{key}} en una plantilla */
function interpolate(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(private prisma: PrismaService) {}
/** Called whenever a package status changes. Creates Notification records and stubs dispatch. */
// ─── Gestión de plantillas ────────────────────────────────
async getTemplates(tenantId: string) {
return this.prisma.client.notificationTemplate.findMany({
where: { tenantId },
orderBy: [{ event: "asc" }, { channel: "asc" }],
});
}
async updateTemplate(id: string, tenantId: string, body: string, subject?: string, isActive?: boolean) {
const tpl = await this.prisma.client.notificationTemplate.findUnique({ where: { id } });
if (!tpl || tpl.tenantId !== tenantId) throw new NotFoundException("Plantilla no encontrada");
return this.prisma.client.notificationTemplate.update({
where: { id },
data: { body, subject: subject ?? tpl.subject, isActive: isActive ?? tpl.isActive, updatedAt: new Date() },
});
}
/** Crea las plantillas por defecto para un tenant (upsert — idempotente). */
async seedDefaultTemplates(tenantId: string) {
const events = Object.keys(DEFAULT_BODIES);
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
const ops = [];
for (const event of events) {
for (const channel of channels) {
ops.push(
this.prisma.client.notificationTemplate.upsert({
where: { tenantId_event_channel: { tenantId, event, channel } },
create: {
tenantId,
event,
channel,
subject: channel === "EMAIL" ? DEFAULT_SUBJECTS[event] : undefined,
body: DEFAULT_BODIES[event],
isActive: true,
},
update: {}, // no sobreescribir si ya existe
})
);
}
}
await Promise.all(ops);
return { seeded: ops.length };
}
// ─── Envío de notificaciones ─────────────────────────────
/** Called whenever a package status changes. */
async notifyStatusChange(pkg: any, user: any): Promise<void> {
const statusLabels: Record<string, string> = {
REGISTRADO: "fue registrado en el sistema",
EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ",
RECIBIDO_BODEGA: "fue recibido en la bodega de NJ",
EN_VERIFICACION: "está siendo verificado en bodega",
VERIFICADO: "fue verificado. El cobro final fue aplicado.",
DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)",
EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador",
EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador",
LISTO_ENTREGA: "está listo para entrega",
ENTREGADO: "fue entregado exitosamente",
INCIDENCIA: "tiene una incidencia reportada",
const vars: Record<string, string> = {
trackingId: pkg.trackingId ?? "",
firstName: user?.firstName ?? "Cliente",
status: pkg.status ?? "",
suiteCode: user?.suite?.code ?? "",
};
const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`;
const body = `Tu paquete ${pkg.trackingId} ${label}.`;
const subject = `Estado de tu paquete: ${pkg.trackingId}`;
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
for (const channel of channels) {
try {
await this.prisma.client.notification.create({
// 1. Buscar plantilla en DB
const tpl = await this.prisma.client.notificationTemplate.findUnique({
where: { tenantId_event_channel: { tenantId: pkg.tenantId, event: pkg.status, channel } },
});
const active = tpl ? tpl.isActive : true;
if (!active) continue;
const subject = interpolate(
tpl?.subject ?? DEFAULT_SUBJECTS[pkg.status] ?? `Estado de tu paquete: ${pkg.trackingId}`,
vars
);
const bodyText = interpolate(
tpl?.body ?? DEFAULT_BODIES[pkg.status] ?? `Tu paquete ${pkg.trackingId} cambió a ${pkg.status}.`,
vars
);
const record = await this.prisma.client.notification.create({
data: {
packageId: pkg.id,
userId: pkg.userId,
userId: pkg.userId,
channel,
status: "PENDIENTE",
status: "PENDIENTE",
subject,
body,
body: bodyText,
},
});
// STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc.
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`);
// Mark as sent (stub — in prod this would be async)
await this.prisma.client.notification.updateMany({
where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" },
// STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc.
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`);
await this.prisma.client.notification.update({
where: { id: record.id },
data: { status: "ENVIADO", sentAt: new Date() },
});
} catch (e: unknown) {
@@ -53,7 +141,7 @@ export class NotificationsService {
}
}
async findByUser(userId: string, limit = 20): Promise<any[]> {
async findByUser(userId: string, limit = 20) {
return this.prisma.client.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
@@ -61,7 +149,7 @@ export class NotificationsService {
});
}
async findByPackage(packageId: string): Promise<any[]> {
async findByPackage(packageId: string) {
return this.prisma.client.notification.findMany({
where: { packageId },
orderBy: { createdAt: "desc" },