feat: add notification retention scheduler

This commit is contained in:
Bastian Wagner
2026-08-04 19:41:44 +02:00
parent eb1173c706
commit a7b087050c
4 changed files with 102 additions and 1 deletions

View File

@@ -40,6 +40,8 @@ export type LOGEVENT =
| 'log_retention_cleanup_run'
| 'log_retention_cleanup_run_fail'
| 'notification_create_fail'
| 'notification_retention_cleanup_run'
| 'notification_retention_cleanup_run_fail'
| 'public_access_enabled'
| 'public_access_rotated';
@@ -84,6 +86,8 @@ export const LOGEVENT_VALUES: LOGEVENT[] = [
'log_retention_cleanup_run',
'log_retention_cleanup_run_fail',
'notification_create_fail',
'notification_retention_cleanup_run',
'notification_retention_cleanup_run_fail',
'public_access_enabled',
'public_access_rotated',
];

View File

@@ -0,0 +1,55 @@
import { LessThan } from 'typeorm';
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
describe('NotificationRetentionScheduler', () => {
const repository = { delete: jest.fn() };
const configService = { get: jest.fn() };
const logger = { info: jest.fn(), error: jest.fn() };
let scheduler: NotificationRetentionScheduler;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers().setSystemTime(new Date('2026-08-04T12:00:00.000Z'));
configService.get.mockReturnValue(365);
repository.delete.mockResolvedValue({ affected: 3 });
scheduler = new NotificationRetentionScheduler(repository as any, configService as any, logger as any);
});
afterEach(() => {
jest.useRealTimers();
});
it('deletes notifications older than the configured retention window', async () => {
await scheduler.cleanupOldNotifications();
expect(configService.get).toHaveBeenCalledWith('app.logRetentionDays');
expect(repository.delete).toHaveBeenCalledWith({
createdAt: LessThan(new Date('2025-08-04T12:00:00.000Z')),
});
});
it('logs the number of deleted notifications', async () => {
repository.delete.mockResolvedValue({ affected: 7 });
await scheduler.cleanupOldNotifications();
expect(logger.info).toHaveBeenCalledWith({
event: 'notification_retention_cleanup_run',
details: 'deletedCount=7 retentionDays=365',
userId: -1,
});
});
it('logs and does not rethrow when the delete fails', async () => {
repository.delete.mockRejectedValue(new Error('connection reset'));
await expect(scheduler.cleanupOldNotifications()).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith({
event: 'notification_retention_cleanup_run_fail',
details: 'connection reset',
userId: -1,
});
expect(logger.info).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Notification } from './entities/notification.entity';
@Injectable()
export class NotificationRetentionScheduler {
constructor(
@InjectRepository(Notification)
private readonly repository: Repository<Notification>,
private readonly configService: ConfigService,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_5AM)
async cleanupOldNotifications(): Promise<void> {
const retentionDays = this.configService.get<number>('app.logRetentionDays');
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
try {
const result = await this.repository.delete({ createdAt: LessThan(cutoff) });
await this.logger.info({
event: 'notification_retention_cleanup_run',
details: `deletedCount=${result.affected ?? 0} retentionDays=${retentionDays}`,
userId: -1,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logger.error({
event: 'notification_retention_cleanup_run_fail',
details: errorMessage,
userId: -1,
});
}
}
}

View File

@@ -8,6 +8,7 @@ import { NotificationRecipient } from './entities/notification-recipient.entity'
import { NotificationsController } from './notifications.controller';
import { NotificationsListener } from './notifications.listener';
import { NotificationsService } from './notifications.service';
import { NotificationRetentionScheduler } from './notification-retention.scheduler';
@Module({
imports: [
@@ -16,6 +17,6 @@ import { NotificationsService } from './notifications.service';
TeamsModule,
],
controllers: [NotificationsController],
providers: [NotificationsService, NotificationsListener],
providers: [NotificationsService, NotificationsListener, NotificationRetentionScheduler],
})
export class NotificationsModule {}