notifications
This commit is contained in:
46
listify-api/package-lock.json
generated
46
listify-api/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -2853,6 +2854,19 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||
@@ -3294,6 +3308,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -5569,6 +5589,23 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.7.0",
|
||||
"luxon": "~3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/intcreator"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -9611,6 +9648,15 @@
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"handlebars": "^4.7.9",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||
import { ListsModule } from './lists/lists.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { McpModule } from './mcp/mcp.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
import {
|
||||
databaseLoggerOptionsFromEnv,
|
||||
@@ -66,6 +67,7 @@ import { DatabaseLogger } from './database/database.logger';
|
||||
ListsModule,
|
||||
ListTemplatesModule,
|
||||
TasksModule,
|
||||
NotificationsModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||
@@ -12,9 +7,8 @@ import { UserListEntity } from './user-list.entity';
|
||||
import { ListsService } from './lists.service';
|
||||
|
||||
@Injectable()
|
||||
export class ListReminderService implements OnModuleInit, OnModuleDestroy {
|
||||
export class ListReminderService {
|
||||
private readonly logger = new Logger(ListReminderService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
private processing = false;
|
||||
|
||||
constructor(
|
||||
@@ -25,29 +19,6 @@ export class ListReminderService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly listsService: ListsService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Number(
|
||||
this.configService.get<string>('LIST_REMINDER_POLL_INTERVAL_MS', '60000'),
|
||||
);
|
||||
|
||||
this.timer = setInterval(
|
||||
() => void this.processDueReminders(),
|
||||
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000,
|
||||
);
|
||||
void this.processDueReminders();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async processDueReminders(now = new Date()): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
|
||||
@@ -30,6 +30,6 @@ import { UserListShareEntity } from './user-list-share.entity';
|
||||
],
|
||||
controllers: [ListsController],
|
||||
providers: [ListRealtimeService, ListReminderService, ListsService],
|
||||
exports: [ListRealtimeService, ListsService],
|
||||
exports: [ListRealtimeService, ListReminderService, ListsService],
|
||||
})
|
||||
export class ListsModule {}
|
||||
|
||||
@@ -191,8 +191,8 @@ export class MailService {
|
||||
displayName: payload.displayName,
|
||||
slotLabel:
|
||||
payload.slot === 'morning'
|
||||
? 'Morgenuebersicht'
|
||||
: 'Nachmittagsuebersicht',
|
||||
? 'Morgenübersicht'
|
||||
: 'Nachmittagsübersicht',
|
||||
dateLabel: this.formatDay(payload.date),
|
||||
tasksUrl: payload.tasksUrl,
|
||||
todayTasks: payload.todayTasks,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<tr>
|
||||
<td style="padding:28px 28px 18px;background:#0f5f3d;color:#ffffff;">
|
||||
<div style="font-size:14px;letter-spacing:.08em;text-transform:uppercase;opacity:.85;">{{appName}}</div>
|
||||
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">Erinnerung fuer {{listName}}</h1>
|
||||
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">Erinnerung für {{listName}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{listUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||
Liste oeffnen
|
||||
Liste öffnen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -13,23 +13,23 @@
|
||||
<tr>
|
||||
<td style="padding:28px 28px 18px;background:#0f5f3d;color:#ffffff;">
|
||||
<div style="font-size:14px;letter-spacing:.08em;text-transform:uppercase;opacity:.85;">{{appName}}</div>
|
||||
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">{{slotLabel}} fuer deine Tasks</h1>
|
||||
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">{{slotLabel}} für deine Tasks</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.55;">
|
||||
{{#if displayName}}Hallo {{displayName}}, {{/if}}du hast {{taskCount}} offene Tasks fuer {{dateLabel}} oder frueher.
|
||||
{{#if displayName}}Hallo {{displayName}}, {{/if}}du hast {{taskCount}} offene Tasks für {{dateLabel}} oder früher.
|
||||
</p>
|
||||
|
||||
{{#if overdueTaskCount}}
|
||||
<h2 style="margin:22px 0 10px;font-size:18px;line-height:1.3;">Ueberfaellig</h2>
|
||||
<h2 style="margin:22px 0 10px;font-size:18px;line-height:1.3;">Überfällig</h2>
|
||||
<ul style="margin:0 0 18px;padding-left:22px;font-size:16px;line-height:1.55;">
|
||||
{{#each overdueTasks}}
|
||||
<li style="margin:0 0 8px;">
|
||||
<strong>{{title}}</strong>
|
||||
<span style="color:#52645a;"> - faellig am {{dueDate}}</span>
|
||||
<span style="color:#52645a;"> - fällig am {{dueDate}}</span>
|
||||
{{#if notes}}<div style="color:#52645a;font-size:14px;">{{notes}}</div>{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
@@ -52,7 +52,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{tasksUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||
Tasks oeffnen
|
||||
Tasks öffnen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ListReminderService } from '../lists/list-reminder.service';
|
||||
import { TaskDigestService } from '../tasks/task-digest.service';
|
||||
import { NotificationsSchedulerService } from './notifications-scheduler.service';
|
||||
|
||||
describe('NotificationsSchedulerService', () => {
|
||||
let listReminderService: Pick<ListReminderService, 'processDueReminders'>;
|
||||
let taskDigestService: Pick<TaskDigestService, 'processDueDigests'>;
|
||||
let service: NotificationsSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
listReminderService = {
|
||||
processDueReminders: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
taskDigestService = {
|
||||
processDueDigests: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new NotificationsSchedulerService(
|
||||
new ConfigService({ NODE_ENV: 'production' }),
|
||||
listReminderService as ListReminderService,
|
||||
taskDigestService as TaskDigestService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('triggers list reminders and task digests', async () => {
|
||||
await service.processListReminders();
|
||||
await service.processTaskDigests();
|
||||
|
||||
expect(listReminderService.processDueReminders).toHaveBeenCalledTimes(1);
|
||||
expect(taskDigestService.processDueDigests).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts notification processing on application bootstrap', () => {
|
||||
service.onApplicationBootstrap();
|
||||
|
||||
expect(listReminderService.processDueReminders).toHaveBeenCalledTimes(1);
|
||||
expect(taskDigestService.processDueDigests).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not trigger schedules in tests', async () => {
|
||||
service = new NotificationsSchedulerService(
|
||||
new ConfigService({ NODE_ENV: 'test' }),
|
||||
listReminderService as ListReminderService,
|
||||
taskDigestService as TaskDigestService,
|
||||
);
|
||||
|
||||
await service.processListReminders();
|
||||
await service.processTaskDigests();
|
||||
|
||||
expect(listReminderService.processDueReminders).not.toHaveBeenCalled();
|
||||
expect(taskDigestService.processDueDigests).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs scheduler failures', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest
|
||||
.mocked(listReminderService.processDueReminders)
|
||||
.mockRejectedValueOnce(new Error('database down'));
|
||||
|
||||
await service.processListReminders();
|
||||
|
||||
expect(Logger.prototype.error).toHaveBeenCalledWith(
|
||||
'Scheduled list reminders failed.',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ListReminderService } from '../lists/list-reminder.service';
|
||||
import { TaskDigestService } from '../tasks/task-digest.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsSchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(NotificationsSchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly listReminderService: ListReminderService,
|
||||
private readonly taskDigestService: TaskDigestService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
void this.processListReminders();
|
||||
void this.processTaskDigests();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE, { name: 'notifications.list-reminders' })
|
||||
async processListReminders(): Promise<void> {
|
||||
if (this.schedulingDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.listReminderService.processDueReminders();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Scheduled list reminders failed.',
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE, { name: 'notifications.task-digests' })
|
||||
async processTaskDigests(): Promise<void> {
|
||||
if (this.schedulingDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.taskDigestService.processDueDigests();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Scheduled task digests failed.',
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private schedulingDisabled(): boolean {
|
||||
return (
|
||||
this.configService.get<string>('NODE_ENV') === 'test' ||
|
||||
this.configService.get<string>('NOTIFICATIONS_SCHEDULE_ENABLED') ===
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
11
listify-api/src/notifications/notifications.module.ts
Normal file
11
listify-api/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ListsModule } from '../lists/lists.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { NotificationsSchedulerService } from './notifications-scheduler.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), ListsModule, TasksModule],
|
||||
providers: [NotificationsSchedulerService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
|
||||
@@ -14,9 +9,8 @@ import { UserTaskEntity } from './user-task.entity';
|
||||
import type { TaskDigestSlot } from './task-digest.types';
|
||||
|
||||
@Injectable()
|
||||
export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
|
||||
export class TaskDigestService {
|
||||
private readonly logger = new Logger(TaskDigestService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
private processing = false;
|
||||
|
||||
constructor(
|
||||
@@ -29,29 +23,6 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly taskPushService: TaskPushService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Number(
|
||||
this.configService.get<string>('TASK_DIGEST_POLL_INTERVAL_MS', '60000'),
|
||||
);
|
||||
|
||||
this.timer = setInterval(
|
||||
() => void this.processDueDigests(),
|
||||
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000,
|
||||
);
|
||||
void this.processDueDigests();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async processDueDigests(now = new Date()): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TaskPushService } from './task-push.service';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { UserTaskEntity } from './user-task.entity';
|
||||
import { AuthModule } from 'src/auth/auth.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,10 +20,10 @@ import { AuthModule } from 'src/auth/auth.module';
|
||||
TaskPushSubscriptionEntity,
|
||||
UserTaskEntity,
|
||||
]),
|
||||
AuthModule
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [TasksController],
|
||||
providers: [TaskDigestService, TaskPushService, TasksService],
|
||||
exports: [TasksService],
|
||||
exports: [TaskDigestService, TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
Reference in New Issue
Block a user