Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts
2026-08-07 12:42:33 +00:00

102 lines
4.1 KiB
TypeScript

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';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* 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<void> {
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<void> {
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({
// The people who would notice expired contracts still listed as
// active are the ones working the contract desk.
recipients: {
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
},
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}`,
);
}
}
}
}