feat(renovaciones): renewal notification emails over SES
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s

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:
2026-08-02 02:00:02 -07:00
parent 3125b52057
commit 87d8743251
29 changed files with 1450 additions and 82 deletions
+6
View File
@@ -33,3 +33,9 @@ COMPANY_EMAIL=
COMPANY_TAX_ID= COMPANY_TAX_ID=
COMPANY_WEBSITE= COMPANY_WEBSITE=
COMPANY_LOGO_PATH= COMPANY_LOGO_PATH=
SES_REGION=
SES_FROM=
SES_ACCESS_KEY=
SES_SECRET_KEY=
SES_CONFIGURATION_SET=
+4 -2
View File
@@ -12,20 +12,22 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.665.0", "@aws-sdk/client-s3": "^3.665.0",
"@aws-sdk/client-sesv2": "^3.1101.0",
"@jorgecuadros/database": "workspace:*", "@jorgecuadros/database": "workspace:*",
"@nestjs/common": "^10.4.4", "@nestjs/common": "^10.4.4",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.4", "@nestjs/core": "^10.4.4",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.4", "@nestjs/platform-express": "^10.4.4",
"@nestjs/schedule": "^4.1.2",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"pdfkit": "^0.15.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.15.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -34,11 +36,11 @@
"@nestjs/testing": "^10.4.4", "@nestjs/testing": "^10.4.4",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.18.0", "@types/express-session": "^1.18.0",
"@types/pdfkit": "^0.13.5",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^20.16.11", "@types/node": "^20.16.11",
"@types/passport": "^1.0.17", "@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/pdfkit": "^0.13.5",
"jest": "^29.7.0", "jest": "^29.7.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
+4
View File
@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
import { StorageModule } from "./storage/storage.module"; import { StorageModule } from "./storage/storage.module";
import { CommonModule } from "./common/common.module"; import { CommonModule } from "./common/common.module";
@@ -14,11 +15,13 @@ import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
import { BankModule } from "./bank/bank.module"; import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module"; import { OpsModule } from "./ops/ops.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
import { RenewalsModule } from "./renewals/renewals.module";
import { AppController } from "./app.controller"; import { AppController } from "./app.controller";
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
PrismaModule, PrismaModule,
StorageModule, StorageModule,
CommonModule, CommonModule,
@@ -33,6 +36,7 @@ import { AppController } from "./app.controller";
BankModule, BankModule,
OpsModule, OpsModule,
ReportsModule, ReportsModule,
RenewalsModule,
], ],
controllers: [AppController], controllers: [AppController],
}) })
+2
View File
@@ -26,6 +26,7 @@ export type Ability =
| "policy:delete" | "policy:delete"
| "policy:ingest" | "policy:ingest"
| "policy:ocr-review" | "policy:ocr-review"
| "renewal:send"
| "property:create" | "property:create"
| "property:update" | "property:update"
| "property:delete" | "property:delete"
@@ -52,6 +53,7 @@ export const ABILITY_MIN: Record<Ability, Role> = {
// upload + confirm, nothing reaches the books unconfirmed. // upload + confirm, nothing reaches the books unconfirmed.
"policy:ingest": "STAFF", "policy:ingest": "STAFF",
"policy:ocr-review": "STAFF", "policy:ocr-review": "STAFF",
"renewal:send": "MANAGER",
"property:create": "STAFF", "property:create": "STAFF",
"property:update": "STAFF", "property:update": "STAFF",
"property:delete": "MANAGER", "property:delete": "MANAGER",
@@ -29,6 +29,7 @@ export class CreateCustomerDto {
@IsOptional() @IsString() mobile?: string; @IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() fax?: string; @IsOptional() @IsString() fax?: string;
@IsOptional() @IsEmail() email?: string; @IsOptional() @IsEmail() email?: string;
@IsOptional() @IsBoolean() emailOptOut?: boolean;
@IsOptional() @IsString() notes?: string; @IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() identificationType?: string; @IsOptional() @IsString() identificationType?: string;
@IsOptional() @IsString() identificationNumber?: string; @IsOptional() @IsString() identificationNumber?: string;
@@ -22,6 +22,7 @@ export class UpdateCustomerDto {
@IsOptional() @IsString() mobile?: string; @IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() fax?: string; @IsOptional() @IsString() fax?: string;
@IsOptional() @IsEmail() email?: string; @IsOptional() @IsEmail() email?: string;
@IsOptional() @IsBoolean() emailOptOut?: boolean;
@IsOptional() @IsString() notes?: string; @IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() identificationType?: string; @IsOptional() @IsString() identificationType?: string;
@IsOptional() @IsString() identificationNumber?: string; @IsOptional() @IsString() identificationNumber?: string;
@@ -27,6 +27,7 @@ import {
type PolicyStatus, type PolicyStatus,
} from "./policies.service"; } from "./policies.service";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import { MarkRenewalNoticeDto } from "./renewal-notice.dto";
import { import {
BeneficiaryDto, BeneficiaryDto,
ClaimDto, ClaimDto,
@@ -145,6 +146,26 @@ export class PoliciesController {
return p; 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) --------------------- // --- children (all editing a policy => policy:update) ---------------------
@Post(":id/installments") @Post(":id/installments")
+29
View File
@@ -6,6 +6,7 @@ import { StorageService } from "../storage/storage.service";
import { extForUpload, type UploadedFileLike } from "../storage/upload-file"; import { extForUpload, type UploadedFileLike } from "../storage/upload-file";
import { toDate } from "../common/coerce"; import { toDate } from "../common/coerce";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import { MarkRenewalNoticeDto } from "./renewal-notice.dto";
import { import {
BeneficiaryDto, BeneficiaryDto,
ClaimDto, ClaimDto,
@@ -358,6 +359,34 @@ export class PoliciesService {
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); 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) { private async ensurePolicy(id: string) {
const found = await this.prisma.policy.findUnique({ const found = await this.prisma.policy.findUnique({
where: { id }, where: { id },
@@ -0,0 +1,28 @@
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;
}
@@ -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);
});
});
+102
View File
@@ -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("&lt;img");
});
});
+65
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 &amp; Asociados para revisar su renovación.</p><p>Atentamente,<br>Jorge Cuadros &amp; 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);
}
}
+10
View File
@@ -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"),
});
});
});
+248
View File
@@ -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 }),
},
});
}
}
+139
View File
@@ -0,0 +1,139 @@
import { Prisma } from "@jorgecuadros/database";
export function renewalLetterSelect(generation: number) {
return Prisma.validator<Prisma.PolicySelect>()({
id: true,
policyNumber: true,
policyTo: true,
netPremium: true,
policyFee: true,
total: true,
currency: true,
coveragesJson: true,
customer: {
select: {
name: true,
nameMissing: true,
email: true,
phone: true,
mobile: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
zipCode: true,
country: true,
},
},
policyType: { select: { name: true } },
insuranceProvider: { select: { name: true } },
vehicles: {
take: 1,
select: {
make: true,
model: true,
modelYear: true,
bodyType: true,
engineNumber: true,
licensePlate: true,
},
},
renewalNotices: {
where: { generation },
select: { sentAt: true, channel: true },
},
});
}
export type RenewalLetterPolicy = Prisma.PolicyGetPayload<{
select: ReturnType<typeof renewalLetterSelect>;
}>;
export interface RenewalLetterRow extends Record<string, unknown> {
__kind: "letter";
policyId: string;
policyNumber: string;
policyType: string;
customerName: string;
customerEmail: string | null;
customerPhone: string | null;
customerMobile: string | null;
customerAddress: string[];
provider: string;
policyTo: string;
netPremium: string | null;
policyFee: string | null;
total: string | null;
currency: string;
coverageDays: unknown;
cslLimit: unknown;
medicalCoverage: unknown;
propertyDamage: unknown;
perPersonLiability: unknown;
additionalService: unknown;
vehicle: {
make: string | null;
model: string | null;
modelYear: string | null;
bodyType: string | null;
engineNumber: string | null;
licensePlate: string | null;
} | null;
generation: number;
sentAt: string | null;
}
export function toRenewalLetterRow(
policy: RenewalLetterPolicy,
generation: number,
): RenewalLetterRow {
const notice = policy.renewalNotices[0];
const coverage = (policy.coveragesJson ?? {}) as Record<string, unknown>;
const address = [
policy.customer.addressLine1,
policy.customer.addressLine2,
[policy.customer.city, policy.customer.state, policy.customer.zipCode]
.filter(Boolean)
.join(", "),
policy.customer.country,
].filter((part): part is string => Boolean(part));
return {
__kind: "letter",
policyId: policy.id,
policyNumber: policy.policyNumber,
policyType: policy.policyType?.name ?? "—",
customerName: policy.customer.nameMissing ? "(sin nombre)" : policy.customer.name,
customerEmail: policy.customer.email,
customerPhone: policy.customer.phone,
customerMobile: policy.customer.mobile,
customerAddress: address,
provider: policy.insuranceProvider?.name ?? "—",
policyTo: policy.policyTo ? policy.policyTo.toISOString().slice(0, 10) : "—",
netPremium: policy.netPremium ? policy.netPremium.toFixed(2) : null,
policyFee: policy.policyFee ? policy.policyFee.toFixed(2) : null,
total: policy.total ? policy.total.toFixed(2) : null,
currency: policy.currency,
coverageDays: coverage.cobertura ?? null,
cslLimit: coverage.csl_limite ?? null,
medicalCoverage: coverage.gastos_medico ?? null,
propertyDamage: coverage.propiedades ?? null,
perPersonLiability: coverage.personas ?? null,
additionalService:
coverage.servicio_adicional ?? coverage.servicio_adiconal ?? null,
vehicle: policy.vehicles[0]
? {
make: policy.vehicles[0].make,
model: policy.vehicles[0].model,
modelYear: policy.vehicles[0].modelYear,
bodyType: policy.vehicles[0].bodyType,
engineNumber: policy.vehicles[0].engineNumber,
licensePlate: policy.vehicles[0].licensePlate,
}
: null,
generation,
sentAt: notice?.sentAt
? notice.sentAt.toISOString().slice(0, 10)
: null,
};
}
+9 -70
View File
@@ -21,6 +21,10 @@ import {
parseDate, parseDate,
type ReportDef, type ReportDef,
} from "./reports.types"; } from "./reports.types";
import {
renewalLetterSelect,
toRenewalLetterRow,
} from "./renewal-letter";
/* ------------------------------------------------------------------ helpers */ /* ------------------------------------------------------------------ helpers */
@@ -615,10 +619,7 @@ const vigente: ReportDef = {
* covers every carrier and tier instead of a clone per combination. * covers every carrier and tier instead of a clone per combination.
* *
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the * `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log * replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log.
* — but this report is read-only; marking a notice as sent is a separate
* mutation (not yet built) that would upsert `RenewalNotice` by
* `[policyId, generation]`.
*/ */
const avisoRenovacion: ReportDef = { const avisoRenovacion: ReportDef = {
slug: "aviso-renovacion", slug: "aviso-renovacion",
@@ -711,78 +712,16 @@ const avisoRenovacion: ReportDef = {
: {}), : {}),
}, },
orderBy: { policyTo: "asc" }, orderBy: { policyTo: "asc" },
select: { select: renewalLetterSelect(generation),
id: true,
policyNumber: true,
policyTo: true,
netPremium: true,
policyFee: true,
total: true,
currency: true,
coveragesJson: true,
customer: { select: { name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } },
vehicles: {
take: 1,
select: {
make: true,
model: true,
modelYear: true,
bodyType: true,
engineNumber: true,
licensePlate: true,
},
},
renewalNotices: {
where: { generation },
select: { sentAt: true, channel: true },
},
},
}); });
let totalPremium = new Prisma.Decimal(0); let totalPremium = new Prisma.Decimal(0);
let sentCount = 0; let sentCount = 0;
const out = rows.map((r) => { const out = rows.map((r) => {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
const notice = r.renewalNotices[0]; const letter = toRenewalLetterRow(r, generation);
if (notice?.sentAt) sentCount++; if (letter.sentAt) sentCount++;
// Legacy coverage columns not modeled as first-class Policy fields — return letter;
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
// effort (derived from the source schema, not yet verified against a
// live migrated DB) — confirm before relying on them in production.
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
return {
__kind: "letter",
policyId: r.id,
policyNumber: r.policyNumber,
customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—",
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
total: r.total ? r.total.toFixed(2) : null,
currency: r.currency,
coverageDays: cov.cobertura ?? null,
cslLimit: cov.csl_limite ?? null,
medicalCoverage: cov.gastos_medico ?? null,
propertyDamage: cov.propiedades ?? null,
perPersonLiability: cov.personas ?? null,
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
vehicle: r.vehicles[0]
? {
make: r.vehicles[0].make,
model: r.vehicles[0].model,
modelYear: r.vehicles[0].modelYear,
bodyType: r.vehicles[0].bodyType,
engineNumber: r.vehicles[0].engineNumber,
licensePlate: r.vehicles[0].licensePlate,
}
: null,
generation,
sentAt: notice?.sentAt
? notice.sentAt.toISOString().slice(0, 10)
: null,
};
}); });
return { return {
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { apiFetch } from "@/lib/api";
export interface RenewalLetter {
policyId: string;
policyNumber: string;
policyType: string;
customerName: string;
customerEmail: string | null;
provider: string;
policyTo: string;
netPremium: string | null;
total: string | null;
currency: string;
generation: number;
sentAt: string | null;
}
export interface RenewalSweepResult {
eligible: number;
sent: number;
skipped: number;
failed: number;
failures: { policyId: string; generation: number; error: string }[];
}
export interface RenewalMarkInput {
generation: number;
channel: "MAIL" | "EMAIL";
sentAt?: string;
notes?: string;
}
export default function RenovacionesPage() {
return (
<AppShell>
<Renovaciones />
</AppShell>
);
}
const GENERATION_LABEL: Record<number, string> = {
1: "Primer aviso (30 días antes)",
2: "Segundo aviso (15 días antes)",
3: "Tercer aviso (7 días después)",
};
const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = {
MAIL: "Impreso",
EMAIL: "Correo electrónico",
};
function Renovaciones() {
const allowed = useCan("renewal:send");
const [days, setDays] = useState(30);
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sweeping, setSweeping] = useState(false);
const refresh = useCallback(async () => {
setPendingError(null);
try {
const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`,
);
setPending(data);
} catch (e) {
setPendingError(
(e as Error)?.message ?? "No se pudo cargar la lista de avisos.",
);
setPending([]);
}
}, [days]);
useEffect(() => {
if (allowed) refresh();
}, [allowed, refresh]);
async function handleSweep() {
setActionError(null);
setNotice(null);
setSweeping(true);
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
});
setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).`,
);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
} finally {
setSweeping(false);
}
}
async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") {
setActionError(null);
setNotice(null);
try {
await apiFetch(`/policies/${letter.policyId}/renewal-notices`, {
method: "POST",
body: JSON.stringify({
generation: letter.generation,
channel,
} satisfies RenewalMarkInput),
});
setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`);
await refresh();
} catch (e) {
setActionError(
(e as Error)?.message ?? "No se pudo registrar el aviso.",
);
}
}
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Renovaciones</h1>
<div className="state-box state-error">
No tiene permisos para enviar avisos de renovación.
</div>
</div>
);
}
const counts = (pending ?? []).reduce<Record<number, number>>(
(acc, item) => ({
...acc,
[item.generation]: (acc[item.generation] ?? 0) + 1,
}),
{},
);
const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0);
return (
<>
<div className="page-head">
<p className="eyebrow">Renovaciones</p>
<h1 className="page-title">Avisos de renovación</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
El sistema ejecuta un barrido diario a las 06:00 hora local que
notifica a los clientes a 30, 15 y 7 días antes o después del
vencimiento de su póliza. Esta pantalla muestra qué avisos están
pendientes y permite ejecutarlo manualmente.
</p>
</div>
{actionError && <div className="state-box state-error">{actionError}</div>}
{notice && <div className="state-box">{notice}</div>}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div className="row-actions" style={{ justifyContent: "space-between" }}>
<div>
<h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={sweeping}
onClick={handleSweep}
>
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div className="field" style={{ maxWidth: 180, marginTop: 12 }}>
<span className="field-label">Ventana (días)</span>
<input
className="input"
type="number"
min={1}
max={365}
value={days}
onChange={(e) =>
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
}
/>
</div>
</div>
{pendingError && (
<div className="state-box state-error">{pendingError}</div>
)}
{!pendingError && grouped.length === 0 && (
<div className="empty-inline">
No hay avisos pendientes en esta ventana.
</div>
)}
{grouped.map((generation) => (
<section className="card" key={generation} style={{ padding: 20 }}>
<h2 className="section-title">{GENERATION_LABEL[generation]}</h2>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Póliza</th>
<th>Tipo</th>
<th>Aseguradora</th>
<th>Vence</th>
<th className="num">Prima</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{(pending ?? [])
.filter((item) => item.generation === generation)
.map((item) => (
<tr key={`${item.policyId}-${item.generation}`}>
<td>
<div>{item.customerName}</div>
<div className="muted small">
{item.customerEmail ?? "Sin correo"}
</div>
</td>
<td className="mono">{item.policyNumber}</td>
<td>{item.policyType}</td>
<td>{item.provider}</td>
<td>{formatDate(item.policyTo)}</td>
<td className="num">
{formatMoney(item.total ?? item.netPremium, item.currency)}
</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "EMAIL")}
disabled={!item.customerEmail}
>
Marcar EMAIL
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "MAIL")}
>
Marcar impreso
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</>
);
}
+1
View File
@@ -79,6 +79,7 @@ const NAV: NavEntry[] = [
ability: "bank:manage-accounts", ability: "bank:manage-accounts",
}, },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" }, { href: "/operaciones", label: "Operaciones", ability: "db:manage" },
], ],
}, },
+10
View File
@@ -30,6 +30,7 @@ type Values = {
mobile: string; mobile: string;
fax: string; fax: string;
email: string; email: string;
emailOptOut: boolean;
identificationType: string; identificationType: string;
identificationNumber: string; identificationNumber: string;
identificationExpiration: string; identificationExpiration: string;
@@ -54,6 +55,7 @@ function initial(c?: CustomerDetail): Values {
mobile: c?.mobile ?? "", mobile: c?.mobile ?? "",
fax: c?.fax ?? "", fax: c?.fax ?? "",
email: c?.email ?? "", email: c?.email ?? "",
emailOptOut: c?.emailOptOut ?? false,
identificationType: c?.identificationType ?? "", identificationType: c?.identificationType ?? "",
identificationNumber: c?.identificationNumber ?? "", identificationNumber: c?.identificationNumber ?? "",
identificationExpiration: toDateInput(c?.identificationExpiration), identificationExpiration: toDateInput(c?.identificationExpiration),
@@ -103,6 +105,7 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
mobile: s(v.mobile), mobile: s(v.mobile),
fax: s(v.fax), fax: s(v.fax),
email: s(v.email), email: s(v.email),
emailOptOut: v.emailOptOut,
identificationType: s(v.identificationType), identificationType: s(v.identificationType),
identificationNumber: s(v.identificationNumber), identificationNumber: s(v.identificationNumber),
identificationExpiration: s(v.identificationExpiration), identificationExpiration: s(v.identificationExpiration),
@@ -139,6 +142,13 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
<input className="input" type="email" value={v.email} <input className="input" type="email" value={v.email}
onChange={(e) => set("email", e.target.value)} /> onChange={(e) => set("email", e.target.value)} />
</Field> </Field>
<Field label="Notificaciones de renovación">
<label>
<input type="checkbox" checked={v.emailOptOut}
onChange={(e) => set("emailOptOut", e.target.checked)} />
{" "}No enviar correos
</label>
</Field>
<Field label="Teléfono"> <Field label="Teléfono">
<input className="input" value={v.phone} <input className="input" value={v.phone}
onChange={(e) => set("phone", e.target.value)} /> onChange={(e) => set("phone", e.target.value)} />
+1 -1
View File
@@ -108,7 +108,7 @@ export class ApiError extends Error {
} }
} }
async function apiFetch<T>( export async function apiFetch<T>(
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
+3
View File
@@ -14,6 +14,7 @@ export type Ability =
| "policy:delete" | "policy:delete"
| "policy:ingest" | "policy:ingest"
| "policy:ocr-review" | "policy:ocr-review"
| "renewal:send"
| "property:create" | "property:create"
| "property:update" | "property:update"
| "property:delete" | "property:delete"
@@ -963,6 +964,7 @@ export interface CustomerDetail {
mobile: string | null; mobile: string | null;
fax: string | null; fax: string | null;
email: string | null; email: string | null;
emailOptOut: boolean;
notes: string | null; notes: string | null;
identificationType: string | null; identificationType: string | null;
identificationNumber: string | null; identificationNumber: string | null;
@@ -993,6 +995,7 @@ export interface CustomerInput {
mobile?: string; mobile?: string;
fax?: string; fax?: string;
email?: string; email?: string;
emailOptOut?: boolean;
notes?: string; notes?: string;
identificationType?: string; identificationType?: string;
identificationNumber?: string; identificationNumber?: string;
+6
View File
@@ -32,3 +32,9 @@ S3_ENDPOINT=http://192.168.4.212:9000
S3_BUCKET=jorgecuadros-documents S3_BUCKET=jorgecuadros-documents
MINIO_ROOT_USER=jc_minio MINIO_ROOT_USER=jc_minio
MINIO_ROOT_PASSWORD=CHANGE_ME MINIO_ROOT_PASSWORD=CHANGE_ME
SES_REGION=
SES_FROM=
SES_ACCESS_KEY=
SES_SECRET_KEY=
SES_CONFIGURATION_SET=
+5
View File
@@ -55,6 +55,11 @@ services:
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
SES_REGION: ${SES_REGION:-}
SES_FROM: ${SES_FROM:-}
SES_ACCESS_KEY: ${SES_ACCESS_KEY:-}
SES_SECRET_KEY: ${SES_SECRET_KEY:-}
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-}
ports: ports:
- target: 3001 - target: 3001
published: ${API_PORT:-3001} published: ${API_PORT:-3001}
@@ -0,0 +1,15 @@
ALTER TABLE `customers`
ADD COLUMN `emailOptOut` BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE `renewal_notices`
ADD COLUMN `providerMessageId` VARCHAR(191) NULL;
CREATE TABLE `scheduled_job_states` (
`name` VARCHAR(191) NOT NULL,
`lockedUntil` DATETIME(3) NULL,
`lastSuccessfulAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`name`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+12
View File
@@ -99,6 +99,7 @@ model Customer {
mobile String? mobile String?
fax String? fax String?
email String? email String?
emailOptOut Boolean @default(false)
notes String? @db.Text notes String? @db.Text
identificationType String? identificationType String?
identificationNumber String? identificationNumber String?
@@ -238,6 +239,7 @@ model RenewalNotice {
channel RenewalNoticeChannel @default(MAIL) channel RenewalNoticeChannel @default(MAIL)
sentAt DateTime? sentAt DateTime?
sentById String? sentById String?
providerMessageId String?
notes String? @db.Text notes String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -953,3 +955,13 @@ model OpsJob {
@@index([startedAt]) @@index([startedAt])
@@map("ops_jobs") @@map("ops_jobs")
} }
model ScheduledJobState {
name String @id
lockedUntil DateTime?
lastSuccessfulAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("scheduled_job_states")
}
+288
View File
@@ -13,6 +13,9 @@ importers:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.665.0 specifier: ^3.665.0
version: 3.1093.0 version: 3.1093.0
'@aws-sdk/client-sesv2':
specifier: ^3.1101.0
version: 3.1101.0
'@jorgecuadros/database': '@jorgecuadros/database':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database version: link:../../packages/database
@@ -31,6 +34,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.4 specifier: ^10.4.4
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/schedule':
specifier: ^4.1.2
version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
argon2: argon2:
specifier: ^0.41.1 specifier: ^0.41.1
version: 0.41.1 version: 0.41.1
@@ -165,18 +171,38 @@ packages:
resolution: {integrity: sha512-7452vEdp/nihIBWijnmcTBujXEFfbs4F02wyBDGqmNr6pwyo5GmQorx0zQIVg8QGFLXiBvsWKXBhCdiBcxNnGA==} resolution: {integrity: sha512-7452vEdp/nihIBWijnmcTBujXEFfbs4F02wyBDGqmNr6pwyo5GmQorx0zQIVg8QGFLXiBvsWKXBhCdiBcxNnGA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-sesv2@3.1101.0':
resolution: {integrity: sha512-5n4COAW5u6T1gOz4t6RueAncImDjK0wdoQEXu1ABnRfLAoNa2dlouubKq3lHLliQdhhEo4so7Cn88ylprab0Ng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.976.0': '@aws-sdk/core@3.976.0':
resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.4':
resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.60': '@aws-sdk/credential-provider-env@3.972.60':
resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==} resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.65':
resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.62': '@aws-sdk/credential-provider-http@3.972.62':
resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==} resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.67':
resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.10':
resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.5': '@aws-sdk/credential-provider-ini@3.973.5':
resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==} resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -185,22 +211,42 @@ packages:
resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==} resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.72':
resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.71': '@aws-sdk/credential-provider-node@3.972.71':
resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==} resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.76':
resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.60': '@aws-sdk/credential-provider-process@3.972.60':
resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==} resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.65':
resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.4': '@aws-sdk/credential-provider-sso@3.973.4':
resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==} resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.9':
resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.66': '@aws-sdk/credential-provider-web-identity@3.972.66':
resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==} resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.71':
resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.65': '@aws-sdk/middleware-sdk-s3@3.972.65':
resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==} resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -209,14 +255,26 @@ packages:
resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==} resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.39':
resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.41': '@aws-sdk/signature-v4-multi-region@3.996.41':
resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.43':
resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1092.0': '@aws-sdk/token-providers@3.1092.0':
resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==} resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1100.0':
resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.2': '@aws-sdk/types@3.974.2':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -225,6 +283,10 @@ packages:
resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.37':
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0': '@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -580,6 +642,12 @@ packages:
'@nestjs/common': ^10.0.0 '@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0 '@nestjs/core': ^10.0.0
'@nestjs/schedule@4.1.2':
resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/schematics@10.2.3': '@nestjs/schematics@10.2.3':
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
peerDependencies: peerDependencies:
@@ -709,18 +777,38 @@ packages:
resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==} resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/core@3.31.1':
resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.12': '@smithy/credential-provider-imds@4.4.12':
resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==} resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.16':
resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.13':
resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.9': '@smithy/fetch-http-handler@5.6.9':
resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==} resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.13':
resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.9': '@smithy/node-http-handler@4.9.9':
resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==} resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.6.12':
resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.6.8': '@smithy/signature-v4@5.6.8':
resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==} resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -814,6 +902,9 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@@ -1349,6 +1440,9 @@ packages:
create-require@1.1.1: create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron@3.2.1:
resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2220,6 +2314,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
magic-string@0.30.8: magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3125,6 +3223,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
uuid@11.0.3:
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
hasBin: true
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
@@ -3308,6 +3410,18 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-sesv2@3.1101.0':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/credential-provider-node': 3.972.76
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/core@3.976.0': '@aws-sdk/core@3.976.0':
dependencies: dependencies:
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.2
@@ -3319,6 +3433,17 @@ snapshots:
bowser: 2.14.1 bowser: 2.14.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/core@3.977.4':
dependencies:
'@aws-sdk/types': 3.974.2
'@aws-sdk/xml-builder': 3.972.37
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.31.1
'@smithy/signature-v4': 5.6.12
'@smithy/types': 4.16.1
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.60': '@aws-sdk/credential-provider-env@3.972.60':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3327,6 +3452,14 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.65':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.62': '@aws-sdk/credential-provider-http@3.972.62':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3337,6 +3470,32 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.67':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.10':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/credential-provider-env': 3.972.65
'@aws-sdk/credential-provider-http': 3.972.67
'@aws-sdk/credential-provider-login': 3.972.72
'@aws-sdk/credential-provider-process': 3.972.65
'@aws-sdk/credential-provider-sso': 3.973.9
'@aws-sdk/credential-provider-web-identity': 3.972.71
'@aws-sdk/nested-clients': 3.997.39
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/credential-provider-imds': 4.4.16
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.5': '@aws-sdk/credential-provider-ini@3.973.5':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3362,6 +3521,15 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.72':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/nested-clients': 3.997.39
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.71': '@aws-sdk/credential-provider-node@3.972.71':
dependencies: dependencies:
'@aws-sdk/credential-provider-env': 3.972.60 '@aws-sdk/credential-provider-env': 3.972.60
@@ -3376,6 +3544,20 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.76':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.65
'@aws-sdk/credential-provider-http': 3.972.67
'@aws-sdk/credential-provider-ini': 3.973.10
'@aws-sdk/credential-provider-process': 3.972.65
'@aws-sdk/credential-provider-sso': 3.973.9
'@aws-sdk/credential-provider-web-identity': 3.972.71
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/credential-provider-imds': 4.4.16
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.60': '@aws-sdk/credential-provider-process@3.972.60':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3384,6 +3566,14 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.65':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.4': '@aws-sdk/credential-provider-sso@3.973.4':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3394,6 +3584,16 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.9':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/nested-clients': 3.997.39
'@aws-sdk/token-providers': 3.1100.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.66': '@aws-sdk/credential-provider-web-identity@3.972.66':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3403,6 +3603,15 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.71':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/nested-clients': 3.997.39
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.65': '@aws-sdk/middleware-sdk-s3@3.972.65':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3423,6 +3632,17 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.39':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.41': '@aws-sdk/signature-v4-multi-region@3.996.41':
dependencies: dependencies:
'@aws-sdk/types': 3.974.2 '@aws-sdk/types': 3.974.2
@@ -3430,6 +3650,13 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.43':
dependencies:
'@aws-sdk/types': 3.974.2
'@smithy/signature-v4': 5.6.12
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/token-providers@3.1092.0': '@aws-sdk/token-providers@3.1092.0':
dependencies: dependencies:
'@aws-sdk/core': 3.976.0 '@aws-sdk/core': 3.976.0
@@ -3439,6 +3666,15 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1100.0':
dependencies:
'@aws-sdk/core': 3.977.4
'@aws-sdk/nested-clients': 3.997.39
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/types@3.974.2': '@aws-sdk/types@3.974.2':
dependencies: dependencies:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
@@ -3449,6 +3685,11 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {} '@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
@@ -3974,6 +4215,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 3.2.1
uuid: 11.0.3
'@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)':
dependencies: dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -4075,24 +4323,53 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/core@3.31.1':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.12': '@smithy/credential-provider-imds@4.4.12':
dependencies: dependencies:
'@smithy/core': 3.29.7 '@smithy/core': 3.29.7
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.16':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.13':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.9': '@smithy/fetch-http-handler@5.6.9':
dependencies: dependencies:
'@smithy/core': 3.29.7 '@smithy/core': 3.29.7
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/node-http-handler@4.9.13':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/node-http-handler@4.9.9': '@smithy/node-http-handler@4.9.9':
dependencies: dependencies:
'@smithy/core': 3.29.7 '@smithy/core': 3.29.7
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@smithy/signature-v4@5.6.12':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/signature-v4@5.6.8': '@smithy/signature-v4@5.6.8':
dependencies: dependencies:
'@smithy/core': 3.29.7 '@smithy/core': 3.29.7
@@ -4215,6 +4492,8 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/luxon@3.4.2': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/node@14.18.63': {} '@types/node@14.18.63': {}
@@ -4842,6 +5121,11 @@ snapshots:
create-require@1.1.1: {} create-require@1.1.1: {}
cron@3.2.1:
dependencies:
'@types/luxon': 3.4.2
luxon: 3.5.0
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -5960,6 +6244,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
luxon@3.5.0: {}
magic-string@0.30.8: magic-string@0.30.8:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -6788,6 +7074,8 @@ snapshots:
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
uuid@11.0.3: {}
uuid@8.3.2: {} uuid@8.3.2: {}
v8-compile-cache-lib@3.0.1: {} v8-compile-cache-lib@3.0.1: {}