notifications
This commit is contained in:
@@ -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