notifications

This commit is contained in:
Bastian Wagner
2026-06-29 15:09:59 +02:00
parent bceacda53e
commit cadb198949
14 changed files with 214 additions and 79 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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],

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),
);
});
});

View File

@@ -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'
);
}
}

View 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 {}

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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`.