feat(renovaciones): renewal notification emails over SES
INSURANCE_FEATURES_SPEC §1. The office printed and mailed renewal letters from the legacy CONTROL <ramo> RENEW[2/3] paper log; 91% of policyholders have an email on file, so send the notice instead and keep the paper log as the fallback. A daily cron (06:00 America/Tijuana) sweeps three generations off policyTo — 30 and 15 days before expiry, 7 days after — sends each through SES, and upserts RenewalNotice by [policyId, generation] so a policy is never notified twice for the same milestone. RenewalNotice now records providerMessageId, so a later bounce or complaint webhook can be traced back to the row that sent it. - customers.emailOptOut excludes a customer from every sweep; editable from the customer form - scheduled_job_states holds the sweep's lock and last successful run; the window is widened to cover days the job did not run, so a weekend outage does not silently drop a generation - SES unconfigured is not an error outside production — messages are logged and skipped, so dev and CI never send - /renovaciones (renewal:send, MANAGER+) lists what is pending per generation, runs the sweep by hand, and marks a notice sent by mail for the customers with no email - POST /policies/:id/renewal-notices records that manual mark - the aviso-renovacion report and the emails now share one projection (reports/renewal-letter.ts) instead of two copies of the mapping
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MailService } from "./mail.service";
|
||||
|
||||
function config(values: Record<string, string>): ConfigService {
|
||||
return { get: (key: string) => values[key] } as ConfigService;
|
||||
}
|
||||
|
||||
describe("MailService", () => {
|
||||
it("uses a no-op provider when SES is absent in development", async () => {
|
||||
const service = new MailService(config({ NODE_ENV: "development" }));
|
||||
|
||||
expect(service.available).toBe(true);
|
||||
await expect(
|
||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
||||
).resolves.toEqual({ providerId: expect.stringMatching(/^dev-/) });
|
||||
});
|
||||
|
||||
it("keeps production bootable but refuses sends when SES is absent", async () => {
|
||||
const service = new MailService(config({ NODE_ENV: "production" }));
|
||||
|
||||
expect(service.available).toBe(false);
|
||||
await expect(
|
||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
SendEmailCommand,
|
||||
SESv2Client,
|
||||
} from "@aws-sdk/client-sesv2";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface MailProvider {
|
||||
send(message: MailMessage): Promise<{ providerId: string }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MailService implements MailProvider {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly client: SESv2Client | null;
|
||||
private readonly from: string | null;
|
||||
private readonly configurationSet: string | undefined;
|
||||
private readonly developmentNoop: boolean;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const region = config.get<string>("SES_REGION");
|
||||
const from = config.get<string>("SES_FROM");
|
||||
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
||||
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
||||
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET") || undefined;
|
||||
this.developmentNoop =
|
||||
config.get<string>("NODE_ENV") !== "production" &&
|
||||
!region &&
|
||||
!from &&
|
||||
!accessKeyId &&
|
||||
!secretAccessKey;
|
||||
|
||||
if (this.developmentNoop) {
|
||||
this.logger.warn("SES no configurado; los correos se registrarán sin enviarse.");
|
||||
this.client = null;
|
||||
this.from = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!region || !from || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn("SES no configurado; las notificaciones por correo están deshabilitadas.");
|
||||
this.client = null;
|
||||
this.from = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new SESv2Client({
|
||||
region,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return this.developmentNoop || (this.client !== null && this.from !== null);
|
||||
}
|
||||
|
||||
async send(message: MailMessage): Promise<{ providerId: string }> {
|
||||
if (this.developmentNoop) {
|
||||
const providerId = `dev-${randomUUID()}`;
|
||||
this.logger.log(`Correo de renovación simulado (${providerId}).`);
|
||||
return { providerId };
|
||||
}
|
||||
|
||||
if (!this.client || !this.from) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.client.send(
|
||||
new SendEmailCommand({
|
||||
FromEmailAddress: this.from,
|
||||
Destination: { ToAddresses: [message.to] },
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: message.subject, Charset: "UTF-8" },
|
||||
Body: { Html: { Data: message.html, Charset: "UTF-8" } },
|
||||
},
|
||||
},
|
||||
ConfigurationSetName: this.configurationSet,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.MessageId) {
|
||||
throw new Error("SES no devolvió identificador de mensaje.");
|
||||
}
|
||||
|
||||
return { providerId: result.MessageId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
function letter(overrides: Partial<RenewalLetterRow> = {}): RenewalLetterRow {
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: "policy-1",
|
||||
policyNumber: "POL-123",
|
||||
policyType: "AUTO",
|
||||
customerName: "Ana Pérez",
|
||||
customerEmail: "ana@example.com",
|
||||
customerPhone: "664-111-2222",
|
||||
customerMobile: null,
|
||||
customerAddress: ["Calle Uno 123", "Tijuana, BC, 22000"],
|
||||
provider: "Aseguradora Uno",
|
||||
policyTo: "2026-09-01",
|
||||
netPremium: "1200.00",
|
||||
policyFee: null,
|
||||
total: "1392.00",
|
||||
currency: "MXN",
|
||||
coverageDays: null,
|
||||
cslLimit: null,
|
||||
medicalCoverage: null,
|
||||
propertyDamage: null,
|
||||
perPersonLiability: null,
|
||||
additionalService: null,
|
||||
vehicle: null,
|
||||
generation: 1,
|
||||
sentAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderRenewalEmail", () => {
|
||||
it("includes policy, premium, expiration, type, and customer information", () => {
|
||||
const result = renderRenewalEmail(letter());
|
||||
|
||||
expect(result.subject).toContain("POL-123");
|
||||
expect(result.html).toContain("primer aviso");
|
||||
expect(result.html).toContain("AUTO");
|
||||
expect(result.html).toContain("01/09/2026");
|
||||
expect(result.html).toContain("1,392.00");
|
||||
expect(result.html).toContain("Ana Pérez");
|
||||
expect(result.html).toContain("ana@example.com");
|
||||
expect(result.html).toContain("664-111-2222");
|
||||
expect(result.html).toContain("Calle Uno 123");
|
||||
});
|
||||
|
||||
it("uses overdue wording for generation three", () => {
|
||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||
|
||||
expect(result.subject).toContain("Póliza vencida");
|
||||
expect(result.html).toContain("está vencida");
|
||||
});
|
||||
|
||||
it("escapes customer-provided HTML", () => {
|
||||
const result = renderRenewalEmail(
|
||||
letter({ customerName: '<img src=x onerror="alert(1)">' }),
|
||||
);
|
||||
|
||||
expect(result.html).not.toContain("<img");
|
||||
expect(result.html).toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||
|
||||
const GENERATION_TEXT: Record<number, string> = {
|
||||
1: "Le enviamos el primer aviso para renovar su póliza.",
|
||||
2: "Le enviamos el segundo aviso para renovar su póliza.",
|
||||
3: "Le informamos que su póliza está vencida.",
|
||||
};
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function displayDate(value: string): string {
|
||||
if (value === "—") return value;
|
||||
const [year, month, day] = value.split("-");
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
function money(value: string | null, currency: string): string {
|
||||
if (!value) return "No disponible";
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Number(value));
|
||||
}
|
||||
|
||||
function row(label: string, value: string): string {
|
||||
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||
}
|
||||
|
||||
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||
subject: string;
|
||||
html: string;
|
||||
} {
|
||||
const expired = letter.generation === 3;
|
||||
const subject = expired
|
||||
? `Póliza vencida: ${letter.policyNumber}`
|
||||
: `Aviso de renovación: póliza ${letter.policyNumber}`;
|
||||
const phone = letter.customerMobile ?? letter.customerPhone ?? "No disponible";
|
||||
const address = letter.customerAddress.join(", ") || "No disponible";
|
||||
const premium = letter.total ?? letter.netPremium;
|
||||
|
||||
const details = [
|
||||
row("Número de póliza", letter.policyNumber),
|
||||
row("Tipo de póliza", letter.policyType),
|
||||
row("Aseguradora", letter.provider),
|
||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||
row("Prima", money(premium, letter.currency)),
|
||||
row("Cliente", letter.customerName),
|
||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||
row("Teléfono", phone),
|
||||
row("Dirección", address),
|
||||
].join("");
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: `<div style="font-family:Arial,sans-serif;color:#222;line-height:1.5"><p>Estimado(a) ${escapeHtml(letter.customerName)}:</p><p>${escapeHtml(GENERATION_TEXT[letter.generation] ?? "Le enviamos un aviso sobre la renovación de su póliza.")}</p><table style="border-collapse:collapse;width:100%;max-width:680px">${details}</table><p>Por favor, comuníquese con Jorge Cuadros & Asociados para revisar su renovación.</p><p>Atentamente,<br>Jorge Cuadros & Asociados</p></div>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("renewals")
|
||||
export class RenewalsController {
|
||||
constructor(private readonly renewals: RenewalsService) {}
|
||||
|
||||
@Get("pending")
|
||||
pending(@Query("days") days?: string) {
|
||||
return this.renewals.pending(
|
||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("sweep")
|
||||
@RequireAbility("renewal:send")
|
||||
sweep(@Req() req: Request) {
|
||||
return this.renewals.sweep((req.user as { id: string }).id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RenewalsController } from "./renewals.controller";
|
||||
import { MailService } from "./mail.service";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
@Module({
|
||||
controllers: [RenewalsController],
|
||||
providers: [MailService, RenewalsService],
|
||||
})
|
||||
export class RenewalsModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
addUtcDays,
|
||||
dateInTimeZone,
|
||||
renewalWindow,
|
||||
RENEWAL_CADENCE,
|
||||
} from "./renewals.service";
|
||||
|
||||
describe("renewal scheduling dates", () => {
|
||||
it("uses the America/Tijuana calendar date", () => {
|
||||
expect(dateInTimeZone(new Date("2026-08-01T05:00:00.000Z"))).toEqual(
|
||||
new Date("2026-07-31T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps generations to 30 days, 15 days, and 7 days overdue", () => {
|
||||
const today = new Date("2026-08-01T00:00:00.000Z");
|
||||
|
||||
expect(
|
||||
RENEWAL_CADENCE.map(({ generation, offsetDays }) => ({
|
||||
generation,
|
||||
target: addUtcDays(today, offsetDays).toISOString().slice(0, 10),
|
||||
})),
|
||||
).toEqual([
|
||||
{ generation: 1, target: "2026-08-31" },
|
||||
{ generation: 2, target: "2026-08-16" },
|
||||
{ generation: 3, target: "2026-07-25" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses an inclusive catch-up window after a missed run", () => {
|
||||
const window = renewalWindow(
|
||||
new Date("2026-08-10T00:00:00.000Z"),
|
||||
30,
|
||||
new Date("2026-08-07T18:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(window).toEqual({
|
||||
from: new Date("2026-09-07T00:00:00.000Z"),
|
||||
to: new Date("2026-09-09T00:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "../reports/renewal-letter";
|
||||
import { MailService } from "./mail.service";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
export const RENEWAL_CADENCE = [
|
||||
{ generation: 1, offsetDays: 30 },
|
||||
{ generation: 2, offsetDays: 15 },
|
||||
{ generation: 3, offsetDays: -7 },
|
||||
] as const;
|
||||
|
||||
const JOB_NAME = "renewal-email-sweep";
|
||||
const TIME_ZONE = "America/Tijuana";
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value);
|
||||
return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
|
||||
}
|
||||
|
||||
export function addUtcDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function renewalWindow(
|
||||
today: Date,
|
||||
offsetDays: number,
|
||||
lastSuccessfulAt?: Date | null,
|
||||
): { from: Date; to: Date } {
|
||||
const to = addUtcDays(today, offsetDays);
|
||||
if (!lastSuccessfulAt) return { from: to, to };
|
||||
const previousDay = dateInTimeZone(lastSuccessfulAt);
|
||||
if (previousDay >= today) return { from: to, to };
|
||||
return { from: addUtcDays(previousDay, offsetDays + 1), to };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RenewalsService {
|
||||
private readonly logger = new Logger(RenewalsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Cron("0 6 * * *", { timeZone: TIME_ZONE })
|
||||
async scheduledSweep(): Promise<void> {
|
||||
try {
|
||||
await this.sweep();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async pending(days = 30) {
|
||||
const today = dateInTimeZone(new Date());
|
||||
const state = await this.prisma.scheduledJobState.findUnique({
|
||||
where: { name: JOB_NAME },
|
||||
select: { lastSuccessfulAt: true },
|
||||
});
|
||||
const cadence = RENEWAL_CADENCE.filter(
|
||||
(item) => item.offsetDays < 0 || item.offsetDays <= days,
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
cadence.map(async (item) => ({
|
||||
generation: item.generation,
|
||||
rows: await this.findCandidates(
|
||||
item,
|
||||
today,
|
||||
state?.lastSuccessfulAt ?? null,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return groups.flatMap(({ generation, rows }) =>
|
||||
rows
|
||||
.filter((policy) => Boolean(policy.customer.email?.trim()))
|
||||
.map((policy) => toRenewalLetterRow(policy, generation)),
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(userId?: string) {
|
||||
const now = new Date();
|
||||
const state = await this.acquireLock(now);
|
||||
|
||||
try {
|
||||
if (!this.mail.available) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const today = dateInTimeZone(now);
|
||||
let eligible = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
const failures: Array<{ policyId: string; generation: number; error: string }> = [];
|
||||
|
||||
for (const cadence of RENEWAL_CADENCE) {
|
||||
const policies = await this.findCandidates(
|
||||
cadence,
|
||||
today,
|
||||
state.lastSuccessfulAt,
|
||||
);
|
||||
eligible += policies.length;
|
||||
|
||||
for (const policy of policies) {
|
||||
const to = policy.customer.email?.trim();
|
||||
if (!to) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const letter = toRenewalLetterRow(policy, cadence.generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const result = await this.mail.send({ to, ...message });
|
||||
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.providerId,
|
||||
},
|
||||
update: {
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.providerId,
|
||||
},
|
||||
});
|
||||
sent++;
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
providerMessageId: result.providerId,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = { eligible, sent, skipped, failed: failures.length, failures };
|
||||
await this.releaseLock(failures.length === 0 ? now : null);
|
||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.releaseLock(null);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private findCandidates(
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
lastSuccessfulAt: Date | null,
|
||||
) {
|
||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||
return this.prisma.policy.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
policyTo: { gte: window.from, lte: window.to },
|
||||
customer: {
|
||||
archivedAt: null,
|
||||
emailOptOut: false,
|
||||
email: { not: "" },
|
||||
},
|
||||
renewalNotices: {
|
||||
none: { generation: cadence.generation, sentAt: { not: null } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
|
||||
select: renewalLetterSelect(cadence.generation),
|
||||
});
|
||||
}
|
||||
|
||||
private async acquireLock(now: Date) {
|
||||
await this.prisma.scheduledJobState.upsert({
|
||||
where: { name: JOB_NAME },
|
||||
create: { name: JOB_NAME },
|
||||
update: { updatedAt: now },
|
||||
});
|
||||
|
||||
const acquired = await this.prisma.scheduledJobState.updateMany({
|
||||
where: {
|
||||
name: JOB_NAME,
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||
},
|
||||
data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
if (acquired.count !== 1) {
|
||||
throw new ConflictException(
|
||||
"Ya hay un barrido de renovaciones en curso.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.scheduledJobState.findUniqueOrThrow({
|
||||
where: { name: JOB_NAME },
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
|
||||
await this.prisma.scheduledJobState.update({
|
||||
where: { name: JOB_NAME },
|
||||
data: {
|
||||
lockedUntil: null,
|
||||
...(lastSuccessfulAt && { lastSuccessfulAt }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user