From c0cc0d2ac29cf4b8127f1d5a9ce9c09cdc1e333a Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sun, 2 Aug 2026 02:40:38 -0700 Subject: [PATCH] feat(renovaciones): send renewal notices from the list, drop manual marking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pólizas tab now sends. Each pending row gets an "Enviar aviso" button backed by POST /renewals/send, which renders, mails and records the notice through the same path the daily sweep uses — so a hand-sent letter is marked exactly like a swept one and drops off the pending list. Sending is now the only way a notice gets marked as sent. Remove the manual "Marcar impreso" / "Marcar EMAIL" buttons and the endpoint behind them (POST /policies/:id/renewal-notices, PoliciesService.markRenewalNotice, MarkRenewalNoticeDto): they wrote a sentAt with no mail behind it, which let the list claim a customer was notified when nothing was sent. sendOne refuses a generation that already has a sentAt (409) so a double click cannot mail the customer twice, and 400s when the customer has no email on file. Sweep and single send share the new deliver() helper. --- apps/api/src/policies/policies.controller.ts | 21 --- apps/api/src/policies/policies.service.ts | 28 ---- apps/api/src/policies/renewal-notice.dto.ts | 28 ---- apps/api/src/renewals/renewals.controller.ts | 28 ++++ apps/api/src/renewals/renewals.service.ts | 129 +++++++++++++----- .../src/components/NotificacionesPolizas.tsx | 62 +++++---- 6 files changed, 155 insertions(+), 141 deletions(-) delete mode 100644 apps/api/src/policies/renewal-notice.dto.ts diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index f47eb9e..434bba9 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -27,7 +27,6 @@ import { type PolicyStatus, } from "./policies.service"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; -import { MarkRenewalNoticeDto } from "./renewal-notice.dto"; import { BeneficiaryDto, ClaimDto, @@ -146,26 +145,6 @@ export class PoliciesController { return p; } - @Post(":id/renewal-notices") - @RequireAbility("renewal:send") - async markRenewalNotice( - @Param("id") id: string, - @Body() dto: MarkRenewalNoticeDto, - @Req() req: Request, - ) { - const notice = await this.policies.markRenewalNotice( - id, - dto, - this.actingId(req), - ); - void this.audit.log(this.actingId(req), "renewalNotice.markSent", { - policyId: id, - generation: dto.generation, - channel: dto.channel, - }); - return notice; - } - // --- children (all editing a policy => policy:update) --------------------- @Post(":id/installments") diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 3d937d5..6c404f2 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -6,7 +6,6 @@ import { StorageService } from "../storage/storage.service"; import { extForUpload, type UploadedFileLike } from "../storage/upload-file"; import { toDate } from "../common/coerce"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; -import { MarkRenewalNoticeDto } from "./renewal-notice.dto"; import { BeneficiaryDto, ClaimDto, @@ -359,33 +358,6 @@ export class PoliciesService { return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); } - async markRenewalNotice( - policyId: string, - dto: MarkRenewalNoticeDto, - sentById: string, - ) { - await this.ensurePolicy(policyId); - const sentAt = toDate(dto.sentAt) ?? new Date(); - return this.prisma.renewalNotice.upsert({ - where: { - policyId_generation: { policyId, generation: dto.generation }, - }, - create: { - policyId, - generation: dto.generation, - channel: dto.channel, - sentAt, - sentById, - notes: dto.notes, - }, - update: { - channel: dto.channel, - sentAt, - sentById, - notes: dto.notes, - }, - }); - } private async ensurePolicy(id: string) { const found = await this.prisma.policy.findUnique({ diff --git a/apps/api/src/policies/renewal-notice.dto.ts b/apps/api/src/policies/renewal-notice.dto.ts deleted file mode 100644 index 904c508..0000000 --- a/apps/api/src/policies/renewal-notice.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RenewalNoticeChannel } from "@jorgecuadros/database"; -import { - IsDateString, - IsEnum, - IsInt, - IsOptional, - IsString, - Max, - Min, -} from "class-validator"; - -export class MarkRenewalNoticeDto { - @IsInt() - @Min(1) - @Max(3) - generation!: number; - - @IsEnum(RenewalNoticeChannel) - channel!: RenewalNoticeChannel; - - @IsOptional() - @IsDateString() - sentAt?: string; - - @IsOptional() - @IsString() - notes?: string; -} diff --git a/apps/api/src/renewals/renewals.controller.ts b/apps/api/src/renewals/renewals.controller.ts index c8ec302..92e3b8d 100644 --- a/apps/api/src/renewals/renewals.controller.ts +++ b/apps/api/src/renewals/renewals.controller.ts @@ -1,17 +1,33 @@ import { + Body, Controller, Get, + HttpCode, Post, Query, Req, UseGuards, } from "@nestjs/common"; import { Request } from "express"; +import { Type } from "class-transformer"; +import { IsInt, IsString, Max, Min } from "class-validator"; import { AbilityGuard } from "../auth/ability.guard"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; import { RenewalsService } from "./renewals.service"; +class SendRenewalDto { + @IsString() + policyId!: string; + + /** 1 = 30 días antes, 2 = 15 días antes, 3 = 7 días después. */ + @Type(() => Number) + @IsInt() + @Min(1) + @Max(3) + generation!: number; +} + @UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("renewals") export class RenewalsController { @@ -29,4 +45,16 @@ export class RenewalsController { sweep(@Req() req: Request) { return this.renewals.sweep((req.user as { id: string }).id); } + + /** Send a single pending notice from the /notificaciones list. */ + @Post("send") + @RequireAbility("renewal:send") + @HttpCode(200) + send(@Body() dto: SendRenewalDto, @Req() req: Request) { + return this.renewals.sendOne( + dto.policyId, + dto.generation, + (req.user as { id: string }).id, + ); + } } diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts index 2e804bc..5353ae1 100644 --- a/apps/api/src/renewals/renewals.service.ts +++ b/apps/api/src/renewals/renewals.service.ts @@ -1,7 +1,9 @@ import { + BadRequestException, ConflictException, Injectable, Logger, + NotFoundException, ServiceUnavailableException, } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; @@ -9,6 +11,7 @@ import { AuditService } from "../common/audit.service"; import { MailService } from "../mail/mail.service"; import { PrismaService } from "../prisma/prisma.service"; import { + RenewalLetterPolicy, renewalLetterSelect, toRenewalLetterRow, } from "../reports/renewal-letter"; @@ -133,44 +136,8 @@ export class RenewalsService { } try { - const letter = toRenewalLetterRow(policy, cadence.generation); - const message = renderRenewalEmail(letter); - const result = await this.mail.send({ - to, - subject: message.subject, - html: message.html, - xTracking: "renewals", - }); - const sentAt = new Date(); - - await this.prisma.renewalNotice.upsert({ - where: { - policyId_generation: { - policyId: policy.id, - generation: cadence.generation, - }, - }, - create: { - policyId: policy.id, - generation: cadence.generation, - channel: "EMAIL", - sentAt, - sentById: userId, - providerMessageId: result.messageId, - }, - update: { - channel: "EMAIL", - sentAt, - sentById: userId, - providerMessageId: result.messageId, - }, - }); + await this.deliver(policy, cadence.generation, to, userId); sent++; - void this.audit.log(userId, "renewalNotice.send", { - policyId: policy.id, - generation: cadence.generation, - providerMessageId: result.messageId, - }); } catch (error) { failures.push({ policyId: policy.id, @@ -191,6 +158,94 @@ export class RenewalsService { } } + /** + * Send one pending renewal notice on demand, from the /notificaciones + * list. Same path the sweep takes — render, send, then record the notice — + * so a letter sent by hand is marked exactly like a swept one and drops + * off the pending list. Refuses a generation already sent so a double + * click can't mail the customer twice. + */ + async sendOne(policyId: string, generation: number, userId?: string) { + if (!this.mail.available) { + throw new ServiceUnavailableException( + "El servicio de correo no está configurado.", + ); + } + + const policy = await this.prisma.policy.findFirst({ + where: { id: policyId, archivedAt: null }, + select: renewalLetterSelect(generation), + }); + if (!policy) { + throw new NotFoundException("Póliza no encontrada."); + } + if (policy.renewalNotices.some((notice) => notice.sentAt)) { + throw new ConflictException("Este aviso ya fue enviado."); + } + const to = policy.customer.email?.trim(); + if (!to) { + throw new BadRequestException("El cliente no tiene correo registrado."); + } + + const { sentAt, providerMessageId } = await this.deliver( + policy, + generation, + to, + userId, + ); + return { + policyId, + generation, + to, + sentAt: sentAt.toISOString(), + providerMessageId, + }; + } + + /** Render + send + record one notice. Shared by the sweep and `sendOne`. */ + private async deliver( + policy: RenewalLetterPolicy, + generation: number, + to: string, + userId?: string, + ) { + const letter = toRenewalLetterRow(policy, generation); + const message = renderRenewalEmail(letter); + const result = await this.mail.send({ + to, + subject: message.subject, + html: message.html, + xTracking: "renewals", + }); + const sentAt = new Date(); + + await this.prisma.renewalNotice.upsert({ + where: { + policyId_generation: { policyId: policy.id, generation }, + }, + create: { + policyId: policy.id, + generation, + channel: "EMAIL", + sentAt, + sentById: userId, + providerMessageId: result.messageId, + }, + update: { + channel: "EMAIL", + sentAt, + sentById: userId, + providerMessageId: result.messageId, + }, + }); + void this.audit.log(userId, "renewalNotice.send", { + policyId: policy.id, + generation, + providerMessageId: result.messageId, + }); + return { sentAt, providerMessageId: result.messageId }; + } + private findCandidates( cadence: (typeof RENEWAL_CADENCE)[number], today: Date, diff --git a/apps/web/src/components/NotificacionesPolizas.tsx b/apps/web/src/components/NotificacionesPolizas.tsx index c3e4721..72b7418 100644 --- a/apps/web/src/components/NotificacionesPolizas.tsx +++ b/apps/web/src/components/NotificacionesPolizas.tsx @@ -7,8 +7,10 @@ import { apiFetch } from "@/lib/api"; /** * Renewal notices — the "Pólizas" half of /notificaciones. Shows which - * renewal letters are pending in a window and lets staff run the sweep by - * hand or mark a notice as delivered. Gated on `renewal:send`. + * renewal letters are pending in a window and lets staff send them, either + * one row at a time or as a whole sweep. Sending is what marks a notice as + * delivered — there is no manual "mark as sent", so the list can never claim + * a letter went out when no mail was ever sent. Gated on `renewal:send`. */ export interface RenewalLetter { @@ -34,11 +36,12 @@ export interface RenewalSweepResult { failures: { policyId: string; generation: number; error: string }[]; } -export interface RenewalMarkInput { +export interface RenewalSendResult { + policyId: string; generation: number; - channel: "MAIL" | "EMAIL"; - sentAt?: string; - notes?: string; + to: string; + sentAt: string; + providerMessageId?: string; } const GENERATION_LABEL: Record = { @@ -47,11 +50,6 @@ const GENERATION_LABEL: Record = { 3: "Tercer aviso (7 días después)", }; -const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = { - MAIL: "Impreso", - EMAIL: "Correo electrónico", -}; - export function NotificacionesPolizas() { const allowed = useCan("renewal:send"); const [days, setDays] = useState(30); @@ -60,6 +58,8 @@ export function NotificacionesPolizas() { const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); const [sweeping, setSweeping] = useState(false); + /** `policyId-generation` of the row currently being sent, if any. */ + const [sendingKey, setSendingKey] = useState(null); const refresh = useCallback(async () => { setPendingError(null); @@ -97,21 +97,29 @@ export function NotificacionesPolizas() { } } - async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") { + /** + * Send this one notice now. The API records it as sent on success, so the + * row leaves the pending list — that disappearance IS the "sent" signal, + * backed by the confirmation line above the table. + */ + async function handleSend(letter: RenewalLetter) { setActionError(null); setNotice(null); + setSendingKey(`${letter.policyId}-${letter.generation}`); try { - await apiFetch(`/policies/${letter.policyId}/renewal-notices`, { + const result = await apiFetch("/renewals/send", { method: "POST", body: JSON.stringify({ + policyId: letter.policyId, generation: letter.generation, - channel, - } satisfies RenewalMarkInput), + }), }); - setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`); + setNotice(`Aviso enviado a ${result.to}.`); await refresh(); } catch (e) { - setActionError((e as Error)?.message ?? "No se pudo registrar el aviso."); + setActionError((e as Error)?.message ?? "No se pudo enviar el aviso."); + } finally { + setSendingKey(null); } } @@ -224,17 +232,17 @@ export function NotificacionesPolizas() { -