import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; /** * How many days before a contract lapses the customer is reminded. Mirrored by * the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows * the same countdown on the row. */ const EXPIRY_NOTICE_DAYS = 10; /** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */ @Injectable() export class ContractExpiryService { private readonly logger = new Logger(ContractExpiryService.name); constructor( private readonly contractsRepository: ContractsRepository, private readonly inbox: NotificationInboxService, ) {} /** * Warn every customer whose contract lapses in ~10 days, once. The repository * window is a rolling 24h slice, so a contract is picked up by exactly one * daily run — no reminded-flag column needed. * * ponytail: a missed run (API down over the slice) skips that contract's * reminder; the portal list still shows its countdown for the whole window. */ @Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' }) async remindExpiringContracts(): Promise { try { const expiring = await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS); let notified = 0; for (const contract of expiring) { if (!contract.companyId || !contract.contractValidUntil) continue; const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB'); await this.inbox.notify({ recipients: { companyId: contract.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.CONTRACT_STATUS, title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`, body: `Your contract ${contract.reference} is valid until ${endsOn}. ` + 'After that date it stops accepting new bookings — contact EDR if ' + 'you need it renewed.', link: '/contracts', data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' }, }); notified += 1; } this.logger.log( `Contract expiry reminder: ${notified} customer(s) warned of a contract ` + `lapsing in ${EXPIRY_NOTICE_DAYS} days`, ); } catch (err) { // Never throws into the scheduler — a failed reminder must not stop the // expiry sweep from running. this.logger.error( `Contract expiry reminder failed: ${(err as Error).message}`, (err as Error).stack, ); } } @Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' }) async expireLapsedContracts(): Promise { try { const affected = await this.contractsRepository.expireLapsedContracts(); this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`); } catch (err) { this.logger.error( `Contract expiry sweep failed: ${(err as Error).message}`, (err as Error).stack, ); try { await this.inbox.notify({ recipients: { allBackoffice: true }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: 'Contract expiry sweep failed', body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`, data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' }, }); } catch (notifyErr) { this.logger.error( `Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`, ); } } } }