From cadb198949a72e9980149c2eb3aeb651de47f33d Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 29 Jun 2026 15:09:59 +0200 Subject: [PATCH] notifications --- listify-api/package-lock.json | 46 ++++++++++++ listify-api/package.json | 1 + listify-api/src/app.module.ts | 2 + .../src/lists/list-reminder.service.ts | 33 +-------- listify-api/src/lists/lists.module.ts | 2 +- listify-api/src/mail/mail.service.ts | 4 +- .../src/mail/templates/list-reminder.hbs | 4 +- .../src/mail/templates/task-digest.hbs | 10 +-- .../notifications-scheduler.service.spec.ts | 72 +++++++++++++++++++ .../notifications-scheduler.service.ts | 61 ++++++++++++++++ .../src/notifications/notifications.module.ts | 11 +++ listify-api/src/tasks/task-digest.service.ts | 33 +-------- listify-api/src/tasks/tasks.module.ts | 6 +- readme.md | 8 +-- 14 files changed, 214 insertions(+), 79 deletions(-) create mode 100644 listify-api/src/notifications/notifications-scheduler.service.spec.ts create mode 100644 listify-api/src/notifications/notifications-scheduler.service.ts create mode 100644 listify-api/src/notifications/notifications.module.ts diff --git a/listify-api/package-lock.json b/listify-api/package-lock.json index ff1f66b..316496b 100644 --- a/listify-api/package-lock.json +++ b/listify-api/package-lock.json @@ -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", diff --git a/listify-api/package.json b/listify-api/package.json index 3f8f0f2..5b164d0 100644 --- a/listify-api/package.json +++ b/listify-api/package.json @@ -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", diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 4457d5e..0c55c41 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -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], diff --git a/listify-api/src/lists/list-reminder.service.ts b/listify-api/src/lists/list-reminder.service.ts index 5ec305d..6ec51c8 100644 --- a/listify-api/src/lists/list-reminder.service.ts +++ b/listify-api/src/lists/list-reminder.service.ts @@ -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('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 { if (this.processing) { return; diff --git a/listify-api/src/lists/lists.module.ts b/listify-api/src/lists/lists.module.ts index d848f2a..0b5175b 100644 --- a/listify-api/src/lists/lists.module.ts +++ b/listify-api/src/lists/lists.module.ts @@ -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 {} diff --git a/listify-api/src/mail/mail.service.ts b/listify-api/src/mail/mail.service.ts index de4868d..5014db8 100644 --- a/listify-api/src/mail/mail.service.ts +++ b/listify-api/src/mail/mail.service.ts @@ -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, diff --git a/listify-api/src/mail/templates/list-reminder.hbs b/listify-api/src/mail/templates/list-reminder.hbs index 7501344..b5abca9 100644 --- a/listify-api/src/mail/templates/list-reminder.hbs +++ b/listify-api/src/mail/templates/list-reminder.hbs @@ -13,7 +13,7 @@
{{appName}}
-

Erinnerung fuer {{listName}}

+

Erinnerung für {{listName}}

@@ -37,7 +37,7 @@ - Liste oeffnen + Liste öffnen diff --git a/listify-api/src/mail/templates/task-digest.hbs b/listify-api/src/mail/templates/task-digest.hbs index 03f9e90..696a7f9 100644 --- a/listify-api/src/mail/templates/task-digest.hbs +++ b/listify-api/src/mail/templates/task-digest.hbs @@ -13,23 +13,23 @@
{{appName}}
-

{{slotLabel}} fuer deine Tasks

+

{{slotLabel}} für deine Tasks

- {{#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.

{{#if overdueTaskCount}} -

Ueberfaellig

+

Überfällig

    {{#each overdueTasks}}
  • {{title}} - - faellig am {{dueDate}} + - fällig am {{dueDate}} {{#if notes}}
    {{notes}}
    {{/if}}
  • {{/each}} @@ -52,7 +52,7 @@ - Tasks oeffnen + Tasks öffnen diff --git a/listify-api/src/notifications/notifications-scheduler.service.spec.ts b/listify-api/src/notifications/notifications-scheduler.service.spec.ts new file mode 100644 index 0000000..e526058 --- /dev/null +++ b/listify-api/src/notifications/notifications-scheduler.service.spec.ts @@ -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; + let taskDigestService: Pick; + 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), + ); + }); +}); diff --git a/listify-api/src/notifications/notifications-scheduler.service.ts b/listify-api/src/notifications/notifications-scheduler.service.ts new file mode 100644 index 0000000..ccbcaf8 --- /dev/null +++ b/listify-api/src/notifications/notifications-scheduler.service.ts @@ -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 { + 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 { + 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('NODE_ENV') === 'test' || + this.configService.get('NOTIFICATIONS_SCHEDULE_ENABLED') === + 'false' + ); + } +} diff --git a/listify-api/src/notifications/notifications.module.ts b/listify-api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..bd78d3e --- /dev/null +++ b/listify-api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/listify-api/src/tasks/task-digest.service.ts b/listify-api/src/tasks/task-digest.service.ts index 60767bf..4487091 100644 --- a/listify-api/src/tasks/task-digest.service.ts +++ b/listify-api/src/tasks/task-digest.service.ts @@ -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('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 { if (this.processing) { return; diff --git a/listify-api/src/tasks/tasks.module.ts b/listify-api/src/tasks/tasks.module.ts index 582d772..8135b55 100644 --- a/listify-api/src/tasks/tasks.module.ts +++ b/listify-api/src/tasks/tasks.module.ts @@ -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 {} diff --git a/readme.md b/readme.md index 49f35e6..feb424e 100644 --- a/readme.md +++ b/readme.md @@ -17,14 +17,14 @@ Im Docker-Setup mit obigem Port-Mapping ist er erreichbar unter: http://localhost:8080/mcp ``` -Verfuegbare MCP-Tools: +Verfügbare MCP-Tools: - `list_existing_lists`: liest die Listen des angemeldeten Users. - `list_templates`: liest die Listenvorlagen des angemeldeten Users. -- `suggest_lists`: erzeugt strukturierte Vorschlaege fuer neue Listen, schreibt aber nichts in die Datenbank. +- `suggest_lists`: erzeugt strukturierte Vorschlaege für neue Listen, schreibt aber nichts in die Datenbank. - `create_list`: erstellt eine neue Liste mit optionalen Start-Items. -- `add_list_item`: fuegt ein Item zu einer bestehenden Liste hinzu. +- `add_list_item`: fügt ein Item zu einer bestehenden Liste hinzu. - `create_template`: erstellt ein neues Template mit optionalen Start-Items. -- `add_template_item`: fuegt ein Item zu einem bestehenden Template hinzu. +- `add_template_item`: fügt ein Item zu einem bestehenden Template hinzu. Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.