feat(renovaciones): send renewal notices from the list, drop manual marking
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m45s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m24s

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.
This commit is contained in:
2026-08-02 02:40:38 -07:00
parent 53a5fe8076
commit c0cc0d2ac2
6 changed files with 155 additions and 141 deletions
@@ -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,
);
}
}
+92 -37
View File
@@ -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,