diff --git a/.env.example b/.env.example index efc9fbc..fe7184c 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ COMPANY_EMAIL= COMPANY_TAX_ID= COMPANY_WEBSITE= COMPANY_LOGO_PATH= + +SES_REGION= +SES_FROM= +SES_ACCESS_KEY= +SES_SECRET_KEY= +SES_CONFIGURATION_SET= diff --git a/apps/api/package.json b/apps/api/package.json index 8a6da82..e28c3ab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,20 +12,22 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.665.0", + "@aws-sdk/client-sesv2": "^3.1101.0", "@jorgecuadros/database": "workspace:*", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.4", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.4", + "@nestjs/schedule": "^4.1.2", "argon2": "^0.41.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "exceljs": "^4.4.0", "express-session": "^1.18.0", - "pdfkit": "^0.15.1", "passport": "^0.7.0", "passport-local": "^1.0.0", + "pdfkit": "^0.15.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -34,11 +36,11 @@ "@nestjs/testing": "^10.4.4", "@types/express": "^4.17.21", "@types/express-session": "^1.18.0", - "@types/pdfkit": "^0.13.5", "@types/jest": "^29.5.13", "@types/node": "^20.16.11", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", + "@types/pdfkit": "^0.13.5", "jest": "^29.7.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9c5698a..5afa8b8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; import { PrismaModule } from "./prisma/prisma.module"; import { StorageModule } from "./storage/storage.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 { OpsModule } from "./ops/ops.module"; import { ReportsModule } from "./reports/reports.module"; +import { RenewalsModule } from "./renewals/renewals.module"; import { AppController } from "./app.controller"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), PrismaModule, StorageModule, CommonModule, @@ -33,6 +36,7 @@ import { AppController } from "./app.controller"; BankModule, OpsModule, ReportsModule, + RenewalsModule, ], controllers: [AppController], }) diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts index 956eaa5..9e22172 100644 --- a/apps/api/src/auth/abilities.ts +++ b/apps/api/src/auth/abilities.ts @@ -26,6 +26,7 @@ export type Ability = | "policy:delete" | "policy:ingest" | "policy:ocr-review" + | "renewal:send" | "property:create" | "property:update" | "property:delete" @@ -52,6 +53,7 @@ export const ABILITY_MIN: Record = { // upload + confirm, nothing reaches the books unconfirmed. "policy:ingest": "STAFF", "policy:ocr-review": "STAFF", + "renewal:send": "MANAGER", "property:create": "STAFF", "property:update": "STAFF", "property:delete": "MANAGER", diff --git a/apps/api/src/customers/create-customer.dto.ts b/apps/api/src/customers/create-customer.dto.ts index e8e33f8..fbc349b 100644 --- a/apps/api/src/customers/create-customer.dto.ts +++ b/apps/api/src/customers/create-customer.dto.ts @@ -29,6 +29,7 @@ export class CreateCustomerDto { @IsOptional() @IsString() mobile?: string; @IsOptional() @IsString() fax?: string; @IsOptional() @IsEmail() email?: string; + @IsOptional() @IsBoolean() emailOptOut?: boolean; @IsOptional() @IsString() notes?: string; @IsOptional() @IsString() identificationType?: string; @IsOptional() @IsString() identificationNumber?: string; diff --git a/apps/api/src/customers/update-customer.dto.ts b/apps/api/src/customers/update-customer.dto.ts index 21af1c6..3d796b4 100644 --- a/apps/api/src/customers/update-customer.dto.ts +++ b/apps/api/src/customers/update-customer.dto.ts @@ -22,6 +22,7 @@ export class UpdateCustomerDto { @IsOptional() @IsString() mobile?: string; @IsOptional() @IsString() fax?: string; @IsOptional() @IsEmail() email?: string; + @IsOptional() @IsBoolean() emailOptOut?: boolean; @IsOptional() @IsString() notes?: string; @IsOptional() @IsString() identificationType?: string; @IsOptional() @IsString() identificationNumber?: string; diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 434bba9..f47eb9e 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -27,6 +27,7 @@ import { type PolicyStatus, } from "./policies.service"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { MarkRenewalNoticeDto } from "./renewal-notice.dto"; import { BeneficiaryDto, ClaimDto, @@ -145,6 +146,26 @@ export class PoliciesController { return p; } + @Post(":id/renewal-notices") + @RequireAbility("renewal:send") + async markRenewalNotice( + @Param("id") id: string, + @Body() dto: MarkRenewalNoticeDto, + @Req() req: Request, + ) { + const notice = await this.policies.markRenewalNotice( + id, + dto, + this.actingId(req), + ); + void this.audit.log(this.actingId(req), "renewalNotice.markSent", { + policyId: id, + generation: dto.generation, + channel: dto.channel, + }); + return notice; + } + // --- children (all editing a policy => policy:update) --------------------- @Post(":id/installments") diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 2877e99..3d937d5 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -6,6 +6,7 @@ import { StorageService } from "../storage/storage.service"; import { extForUpload, type UploadedFileLike } from "../storage/upload-file"; import { toDate } from "../common/coerce"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { MarkRenewalNoticeDto } from "./renewal-notice.dto"; import { BeneficiaryDto, ClaimDto, @@ -358,6 +359,34 @@ export class PoliciesService { return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); } + async markRenewalNotice( + policyId: string, + dto: MarkRenewalNoticeDto, + sentById: string, + ) { + await this.ensurePolicy(policyId); + const sentAt = toDate(dto.sentAt) ?? new Date(); + return this.prisma.renewalNotice.upsert({ + where: { + policyId_generation: { policyId, generation: dto.generation }, + }, + create: { + policyId, + generation: dto.generation, + channel: dto.channel, + sentAt, + sentById, + notes: dto.notes, + }, + update: { + channel: dto.channel, + sentAt, + sentById, + notes: dto.notes, + }, + }); + } + private async ensurePolicy(id: string) { const found = await this.prisma.policy.findUnique({ where: { id }, diff --git a/apps/api/src/policies/renewal-notice.dto.ts b/apps/api/src/policies/renewal-notice.dto.ts new file mode 100644 index 0000000..904c508 --- /dev/null +++ b/apps/api/src/policies/renewal-notice.dto.ts @@ -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; +} diff --git a/apps/api/src/renewals/mail.service.spec.ts b/apps/api/src/renewals/mail.service.spec.ts new file mode 100644 index 0000000..de9e53f --- /dev/null +++ b/apps/api/src/renewals/mail.service.spec.ts @@ -0,0 +1,27 @@ +import { ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { MailService } from "./mail.service"; + +function config(values: Record): 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: "

Test

" }), + ).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: "

Test

" }), + ).rejects.toBeInstanceOf(ServiceUnavailableException); + }); +}); diff --git a/apps/api/src/renewals/mail.service.ts b/apps/api/src/renewals/mail.service.ts new file mode 100644 index 0000000..1479ab9 --- /dev/null +++ b/apps/api/src/renewals/mail.service.ts @@ -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("SES_REGION"); + const from = config.get("SES_FROM"); + const accessKeyId = config.get("SES_ACCESS_KEY"); + const secretAccessKey = config.get("SES_SECRET_KEY"); + this.configurationSet = config.get("SES_CONFIGURATION_SET") || undefined; + this.developmentNoop = + config.get("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 }; + } +} diff --git a/apps/api/src/renewals/renewal-email.spec.ts b/apps/api/src/renewals/renewal-email.spec.ts new file mode 100644 index 0000000..dd3c8c8 --- /dev/null +++ b/apps/api/src/renewals/renewal-email.spec.ts @@ -0,0 +1,64 @@ +import type { RenewalLetterRow } from "../reports/renewal-letter"; +import { renderRenewalEmail } from "./renewal-email"; + +function letter(overrides: Partial = {}): 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: '' }), + ); + + expect(result.html).not.toContain(" = { + 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 `${escapeHtml(label)}${escapeHtml(value)}`; +} + +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: `

Estimado(a) ${escapeHtml(letter.customerName)}:

${escapeHtml(GENERATION_TEXT[letter.generation] ?? "Le enviamos un aviso sobre la renovación de su póliza.")}

${details}

Por favor, comuníquese con Jorge Cuadros & Asociados para revisar su renovación.

Atentamente,
Jorge Cuadros & Asociados

`, + }; +} diff --git a/apps/api/src/renewals/renewals.controller.ts b/apps/api/src/renewals/renewals.controller.ts new file mode 100644 index 0000000..c8ec302 --- /dev/null +++ b/apps/api/src/renewals/renewals.controller.ts @@ -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); + } +} diff --git a/apps/api/src/renewals/renewals.module.ts b/apps/api/src/renewals/renewals.module.ts new file mode 100644 index 0000000..6015114 --- /dev/null +++ b/apps/api/src/renewals/renewals.module.ts @@ -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 {} diff --git a/apps/api/src/renewals/renewals.service.spec.ts b/apps/api/src/renewals/renewals.service.spec.ts new file mode 100644 index 0000000..7cc45a5 --- /dev/null +++ b/apps/api/src/renewals/renewals.service.spec.ts @@ -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"), + }); + }); +}); diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts new file mode 100644 index 0000000..2c74b18 --- /dev/null +++ b/apps/api/src/renewals/renewals.service.ts @@ -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 { + 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 { + await this.prisma.scheduledJobState.update({ + where: { name: JOB_NAME }, + data: { + lockedUntil: null, + ...(lastSuccessfulAt && { lastSuccessfulAt }), + }, + }); + } +} diff --git a/apps/api/src/reports/renewal-letter.ts b/apps/api/src/reports/renewal-letter.ts new file mode 100644 index 0000000..a14380b --- /dev/null +++ b/apps/api/src/reports/renewal-letter.ts @@ -0,0 +1,139 @@ +import { Prisma } from "@jorgecuadros/database"; + +export function renewalLetterSelect(generation: number) { + return Prisma.validator()({ + 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; +}>; + +export interface RenewalLetterRow extends Record { + __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; + 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, + }; +} diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index f7f4209..3615bd6 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -21,6 +21,10 @@ import { parseDate, type ReportDef, } from "./reports.types"; +import { + renewalLetterSelect, + toRenewalLetterRow, +} from "./renewal-letter"; /* ------------------------------------------------------------------ helpers */ @@ -615,10 +619,7 @@ const vigente: ReportDef = { * covers every carrier and tier instead of a clone per combination. * * `sentStatus` is read from `RenewalNotice` (schema.prisma) — the - * replacement for the legacy `CONTROL 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]`. + * replacement for the legacy `CONTROL RENEW[2/3] X MES` paper log. */ const avisoRenovacion: ReportDef = { slug: "aviso-renovacion", @@ -711,78 +712,16 @@ const avisoRenovacion: ReportDef = { : {}), }, orderBy: { policyTo: "asc" }, - select: { - 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 }, - }, - }, + select: renewalLetterSelect(generation), }); let totalPremium = new Prisma.Decimal(0); let sentCount = 0; const out = rows.map((r) => { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); - const notice = r.renewalNotices[0]; - if (notice?.sentAt) sentCount++; - // Legacy coverage columns not modeled as first-class Policy fields — - // 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; - 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, - }; + const letter = toRenewalLetterRow(r, generation); + if (letter.sentAt) sentCount++; + return letter; }); return { diff --git a/apps/web/src/app/renovaciones/page.tsx b/apps/web/src/app/renovaciones/page.tsx new file mode 100644 index 0000000..9c5d6f2 --- /dev/null +++ b/apps/web/src/app/renovaciones/page.tsx @@ -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 ( + + + + ); +} + +const GENERATION_LABEL: Record = { + 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(null); + const [pendingError, setPendingError] = useState(null); + const [actionError, setActionError] = useState(null); + const [notice, setNotice] = useState(null); + const [sweeping, setSweeping] = useState(false); + + const refresh = useCallback(async () => { + setPendingError(null); + try { + const data = await apiFetch( + `/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("/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 ( +
+

Renovaciones

+
+ No tiene permisos para enviar avisos de renovación. +
+
+ ); + } + + const counts = (pending ?? []).reduce>( + (acc, item) => ({ + ...acc, + [item.generation]: (acc[item.generation] ?? 0) + 1, + }), + {}, + ); + const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0); + + return ( + <> +
+

Renovaciones

+

Avisos de renovación

+

+ 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. +

+
+ + {actionError &&
{actionError}
} + {notice &&
{notice}
} + +
+
+
+

Barrido manual

+

+ Usa la fecha actual del servidor como referencia para seleccionar + avisos vencidos a 30 y 15 días, y vencidos hace 7 días. +

+
+ +
+
+ Ventana (días) + + setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30))) + } + /> +
+
+ + {pendingError && ( +
{pendingError}
+ )} + + {!pendingError && grouped.length === 0 && ( +
+ No hay avisos pendientes en esta ventana. +
+ )} + + {grouped.map((generation) => ( +
+

{GENERATION_LABEL[generation]}

+
+ + + + + + + + + + + + + + {(pending ?? []) + .filter((item) => item.generation === generation) + .map((item) => ( + + + + + + + + + + ))} + +
ClientePólizaTipoAseguradoraVencePrimaAcciones
+
{item.customerName}
+
+ {item.customerEmail ?? "Sin correo"} +
+
{item.policyNumber}{item.policyType}{item.provider}{formatDate(item.policyTo)} + {formatMoney(item.total ?? item.netPremium, item.currency)} + +
+ + +
+
+
+
+ ))} + + ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 575c243..78f11d6 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -79,6 +79,7 @@ const NAV: NavEntry[] = [ ability: "bank:manage-accounts", }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" }, + { href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" }, { href: "/operaciones", label: "Operaciones", ability: "db:manage" }, ], }, diff --git a/apps/web/src/components/CustomerForm.tsx b/apps/web/src/components/CustomerForm.tsx index face633..500dac8 100644 --- a/apps/web/src/components/CustomerForm.tsx +++ b/apps/web/src/components/CustomerForm.tsx @@ -30,6 +30,7 @@ type Values = { mobile: string; fax: string; email: string; + emailOptOut: boolean; identificationType: string; identificationNumber: string; identificationExpiration: string; @@ -54,6 +55,7 @@ function initial(c?: CustomerDetail): Values { mobile: c?.mobile ?? "", fax: c?.fax ?? "", email: c?.email ?? "", + emailOptOut: c?.emailOptOut ?? false, identificationType: c?.identificationType ?? "", identificationNumber: c?.identificationNumber ?? "", identificationExpiration: toDateInput(c?.identificationExpiration), @@ -103,6 +105,7 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) { mobile: s(v.mobile), fax: s(v.fax), email: s(v.email), + emailOptOut: v.emailOptOut, identificationType: s(v.identificationType), identificationNumber: s(v.identificationNumber), identificationExpiration: s(v.identificationExpiration), @@ -139,6 +142,13 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) { set("email", e.target.value)} /> + + + set("phone", e.target.value)} /> diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 864d6c2..3a49551 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -108,7 +108,7 @@ export class ApiError extends Error { } } -async function apiFetch( +export async function apiFetch( path: string, init?: RequestInit, ): Promise { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 39eda04..5de230e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -14,6 +14,7 @@ export type Ability = | "policy:delete" | "policy:ingest" | "policy:ocr-review" + | "renewal:send" | "property:create" | "property:update" | "property:delete" @@ -963,6 +964,7 @@ export interface CustomerDetail { mobile: string | null; fax: string | null; email: string | null; + emailOptOut: boolean; notes: string | null; identificationType: string | null; identificationNumber: string | null; @@ -993,6 +995,7 @@ export interface CustomerInput { mobile?: string; fax?: string; email?: string; + emailOptOut?: boolean; notes?: string; identificationType?: string; identificationNumber?: string; diff --git a/deploy/jorgecuadros-app.env.example b/deploy/jorgecuadros-app.env.example index 2442f95..4c705e5 100644 --- a/deploy/jorgecuadros-app.env.example +++ b/deploy/jorgecuadros-app.env.example @@ -32,3 +32,9 @@ S3_ENDPOINT=http://192.168.4.212:9000 S3_BUCKET=jorgecuadros-documents MINIO_ROOT_USER=jc_minio MINIO_ROOT_PASSWORD=CHANGE_ME + +SES_REGION= +SES_FROM= +SES_ACCESS_KEY= +SES_SECRET_KEY= +SES_CONFIGURATION_SET= diff --git a/deploy/jorgecuadros-app.stack.yml b/deploy/jorgecuadros-app.stack.yml index 336ce47..bd2cefb 100644 --- a/deploy/jorgecuadros-app.stack.yml +++ b/deploy/jorgecuadros-app.stack.yml @@ -55,6 +55,11 @@ services: S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER 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: - target: 3001 published: ${API_PORT:-3001} diff --git a/packages/database/prisma/migrations/20260801130000_renewal_email_notifications/migration.sql b/packages/database/prisma/migrations/20260801130000_renewal_email_notifications/migration.sql new file mode 100644 index 0000000..ba44107 --- /dev/null +++ b/packages/database/prisma/migrations/20260801130000_renewal_email_notifications/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8716ae8..662f29d 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -99,6 +99,7 @@ model Customer { mobile String? fax String? email String? + emailOptOut Boolean @default(false) notes String? @db.Text identificationType String? identificationNumber String? @@ -230,16 +231,17 @@ enum RenewalNoticeChannel { /// 3rd notice and when" is a query instead of a paper trail. See /// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces. model RenewalNotice { - id String @id @default(uuid()) - policyId String - policy Policy @relation(fields: [policyId], references: [id]) + id String @id @default(uuid()) + policyId String + policy Policy @relation(fields: [policyId], references: [id]) // 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming. - generation Int - channel RenewalNoticeChannel @default(MAIL) - sentAt DateTime? - sentById String? - notes String? @db.Text - createdAt DateTime @default(now()) + generation Int + channel RenewalNoticeChannel @default(MAIL) + sentAt DateTime? + sentById String? + providerMessageId String? + notes String? @db.Text + createdAt DateTime @default(now()) // One row per generation per policy — matches the legacy's 1st/2nd/3rd // notice cadence; re-running the same generation for a policy updates it @@ -953,3 +955,13 @@ model OpsJob { @@index([startedAt]) @@map("ops_jobs") } + +model ScheduledJobState { + name String @id + lockedUntil DateTime? + lastSuccessfulAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("scheduled_job_states") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ffc27e..f2736ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.665.0 version: 3.1093.0 + '@aws-sdk/client-sesv2': + specifier: ^3.1101.0 + version: 3.1101.0 '@jorgecuadros/database': specifier: workspace:* version: link:../../packages/database @@ -31,6 +34,9 @@ importers: '@nestjs/platform-express': 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) + '@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: specifier: ^0.41.1 version: 0.41.1 @@ -165,18 +171,38 @@ packages: resolution: {integrity: sha512-7452vEdp/nihIBWijnmcTBujXEFfbs4F02wyBDGqmNr6pwyo5GmQorx0zQIVg8QGFLXiBvsWKXBhCdiBcxNnGA==} 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': resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} 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': resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==} 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': resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==} 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': resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==} engines: {node: '>=20.0.0'} @@ -185,22 +211,42 @@ packages: resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==} 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': resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==} 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': resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==} 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': resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==} 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': resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==} 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': resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==} engines: {node: '>=20.0.0'} @@ -209,14 +255,26 @@ packages: resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==} 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': resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} 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': resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==} 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': resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} engines: {node: '>=20.0.0'} @@ -225,6 +283,10 @@ packages: resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} 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': resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} @@ -580,6 +642,12 @@ packages: '@nestjs/common': ^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': resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} peerDependencies: @@ -709,18 +777,38 @@ packages: resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==} 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': resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==} 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': resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==} 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': resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==} 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': resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==} engines: {node: '>=18.0.0'} @@ -814,6 +902,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1349,6 +1440,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@3.2.1: + resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2220,6 +2314,10 @@ packages: lru-cache@5.1.1: 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: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} @@ -3125,6 +3223,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + uuid@8.3.2: 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). @@ -3308,6 +3410,18 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/types': 3.974.2 @@ -3319,6 +3433,17 @@ snapshots: bowser: 2.14.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3327,6 +3452,14 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3337,6 +3470,32 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3362,6 +3521,15 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/credential-provider-env': 3.972.60 @@ -3376,6 +3544,20 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3384,6 +3566,14 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3394,6 +3584,16 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3403,6 +3603,15 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3423,6 +3632,17 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/types': 3.974.2 @@ -3430,6 +3650,13 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@aws-sdk/core': 3.976.0 @@ -3439,6 +3666,15 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@smithy/types': 4.16.1 @@ -3449,6 +3685,11 @@ snapshots: '@smithy/types': 4.16.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': {} '@babel/code-frame@7.29.7': @@ -3974,6 +4215,13 @@ snapshots: transitivePeerDependencies: - 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)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -4075,24 +4323,53 @@ snapshots: '@smithy/types': 4.16.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': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.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': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.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': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.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': dependencies: '@smithy/core': 3.29.7 @@ -4215,6 +4492,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.4.2': {} + '@types/mime@1.3.5': {} '@types/node@14.18.63': {} @@ -4842,6 +5121,11 @@ snapshots: create-require@1.1.1: {} + cron@3.2.1: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5960,6 +6244,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.5.0: {} + magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6788,6 +7074,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@11.0.3: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {}