feat(renovaciones): send renewal notices from the list, drop manual marking
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:
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number, string> = {
|
||||
@@ -47,11 +50,6 @@ const GENERATION_LABEL: Record<number, string> = {
|
||||
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<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [sweeping, setSweeping] = useState(false);
|
||||
/** `policyId-generation` of the row currently being sent, if any. */
|
||||
const [sendingKey, setSendingKey] = useState<string | null>(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<RenewalSendResult>("/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() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleMark(item, "EMAIL")}
|
||||
disabled={!item.customerEmail}
|
||||
onClick={() => handleSend(item)}
|
||||
disabled={
|
||||
!item.customerEmail ||
|
||||
sweeping ||
|
||||
sendingKey !== null
|
||||
}
|
||||
>
|
||||
Marcar EMAIL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleMark(item, "MAIL")}
|
||||
>
|
||||
Marcar impreso
|
||||
{sendingKey ===
|
||||
`${item.policyId}-${item.generation}`
|
||||
? "Enviando…"
|
||||
: "Enviar aviso"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user