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