diff --git a/listify-api/src/tasks/task-digest.service.spec.ts b/listify-api/src/tasks/task-digest.service.spec.ts index ba0911b..2cb774d 100644 --- a/listify-api/src/tasks/task-digest.service.spec.ts +++ b/listify-api/src/tasks/task-digest.service.spec.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserEntity } from '../auth/user.entity'; import { MailService } from '../mail/mail.service'; @@ -20,7 +21,14 @@ describe('TaskDigestService', () => { sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), }; taskPushService = { - sendTaskDigest: jest.fn().mockResolvedValue(undefined), + sendTaskDigest: jest + .fn() + .mockResolvedValue({ + enabled: true, + subscriptionCount: 1, + sentCount: 1, + failedCount: 0, + }), }; service = new TaskDigestService( usersRepository as never, @@ -154,6 +162,65 @@ describe('TaskDigestService', () => { expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled(); }); + it('sends a test digest without marking the regular digest processed', async () => { + await saveUser({ taskDigestPreference: 'none' }); + + const result = await service.sendTestDigest('owner-1'); + + expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith( + 'owner@example.com', + expect.objectContaining({ + displayName: 'Owner', + todayTasks: [ + { + title: 'Test-Benachrichtigung', + notes: + 'Diese Aufgabe wurde nur fuer den Benachrichtigungstest erzeugt.', + dueDate: expect.any(String), + }, + ], + overdueTasks: [], + }), + ); + expect(taskPushService.sendTaskDigest).toHaveBeenCalledWith( + 'owner-1', + expect.objectContaining({ + todayTasks: [ + expect.objectContaining({ title: 'Test-Benachrichtigung' }), + ], + }), + ); + expect(result.mail.sent).toBe(true); + expect(result.push).toEqual({ + enabled: true, + subscriptionCount: 1, + sentCount: 1, + failedCount: 0, + sent: true, + }); + expect(result.usedFallbackTask).toBe(true); + expect( + (await usersRepository.findOne({ where: { id: 'owner-1' } })) + ?.taskDigestMorningProcessedDate, + ).toBeNull(); + }); + + it('returns separate mail and push status for test digest failures', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + await saveUser(); + await saveTask({ id: 'today-task' }); + jest + .mocked(mailService.sendTaskDigestEmail) + .mockRejectedValueOnce(new Error('smtp down')); + + const result = await service.sendTestDigest('owner-1'); + + expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1); + expect(result.mail).toEqual({ sent: false, error: 'smtp down' }); + expect(result.push.sent).toBe(true); + expect(result.usedFallbackTask).toBe(false); + }); + async function saveUser(overrides: Partial = {}) { return usersRepository.save({ id: overrides.id ?? 'owner-1', diff --git a/listify-api/src/tasks/task-digest.service.ts b/listify-api/src/tasks/task-digest.service.ts index 4487091..c9b9400 100644 --- a/listify-api/src/tasks/task-digest.service.ts +++ b/listify-api/src/tasks/task-digest.service.ts @@ -1,13 +1,48 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; import { UserEntity } from '../auth/user.entity'; import { MailService } from '../mail/mail.service'; +import type { TaskDigestEmailItem } from '../mail/mail.types'; import { TaskPushService } from './task-push.service'; import { UserTaskEntity } from './user-task.entity'; import type { TaskDigestSlot } from './task-digest.types'; +interface TaskDigestPayload { + slot: TaskDigestSlot; + date: string; + tasksUrl: string; + todayTasks: TaskDigestEmailItem[]; + overdueTasks: TaskDigestEmailItem[]; +} + +interface TaskDigestPayloadResult { + payload: TaskDigestPayload; + taskCount: number; + usedFallbackTask: boolean; +} + +export interface TaskNotificationTestResult { + message: string; + date: string; + slot: TaskDigestSlot; + taskCount: number; + usedFallbackTask: boolean; + mail: { + sent: boolean; + error?: string; + }; + push: { + enabled: boolean; + subscriptionCount: number; + sentCount: number; + failedCount: number; + sent: boolean; + error?: string; + }; +} + @Injectable() export class TaskDigestService { private readonly logger = new Logger(TaskDigestService.name); @@ -55,46 +90,88 @@ export class TaskDigestService { } } + async sendTestDigest(userId: string): Promise { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User was not found.'); + } + + const now = new Date(); + const slot = this.currentSlot(now) ?? 'morning'; + const dateKey = this.dateKey(now); + const { payload, taskCount, usedFallbackTask } = + await this.buildDigestPayload(user.id, slot, dateKey, true); + const result: TaskNotificationTestResult = { + message: 'Task notification test processed.', + date: dateKey, + slot, + taskCount, + usedFallbackTask, + mail: { sent: false }, + push: { + enabled: false, + subscriptionCount: 0, + sentCount: 0, + failedCount: 0, + sent: false, + }, + }; + + try { + await this.mailService.sendTaskDigestEmail(user.email, { + ...payload, + displayName: user.name ?? undefined, + }); + result.mail.sent = true; + } catch (error) { + result.mail.error = this.errorMessage(error); + this.logger.error( + `Task test email could not be sent for user ${user.id}.`, + error instanceof Error ? error.stack : undefined, + ); + } + + try { + const pushResult = await this.taskPushService.sendTaskDigest( + user.id, + payload, + ); + result.push.enabled = pushResult.enabled; + result.push.subscriptionCount = pushResult.subscriptionCount; + result.push.sentCount = pushResult.sentCount; + result.push.failedCount = pushResult.failedCount; + result.push.sent = pushResult.sentCount > 0; + } catch (error) { + result.push.error = this.errorMessage(error); + this.logger.error( + `Task test push notification could not be sent for user ${user.id}.`, + error instanceof Error ? error.stack : undefined, + ); + } + + return result; + } + private async processUserDigest( user: UserEntity, slot: TaskDigestSlot, dateKey: string, ): Promise { - const tasks = await this.tasksRepository.find({ - where: { - ownerId: user.id, - deletedAt: IsNull(), - completed: false, - dueDate: LessThanOrEqual(dateKey), - }, - order: { dueDate: 'ASC', createdAt: 'ASC' }, - }); - const overdueTasks = tasks.filter((task) => task.dueDate < dateKey); - const todayTasks = tasks.filter((task) => task.dueDate === dateKey); - - if (tasks.length > 0) { - const digestPayload = { - slot, - date: dateKey, - tasksUrl: this.tasksUrl(), - todayTasks: todayTasks.map((task) => ({ - title: task.title, - notes: task.notes ?? undefined, - dueDate: task.dueDate, - })), - overdueTasks: overdueTasks.map((task) => ({ - title: task.title, - notes: task.notes ?? undefined, - dueDate: task.dueDate, - })), - }; + const { payload, taskCount } = await this.buildDigestPayload( + user.id, + slot, + dateKey, + false, + ); + if (taskCount > 0) { try { await this.mailService.sendTaskDigestEmail(user.email, { - ...digestPayload, + ...payload, displayName: user.name ?? undefined, }); - await this.taskPushService.sendTaskDigest(user.id, digestPayload); + await this.taskPushService.sendTaskDigest(user.id, payload); } catch (error) { this.logger.error( `Task digest could not be sent for user ${user.id}.`, @@ -108,6 +185,59 @@ export class TaskDigestService { await this.usersRepository.save(user); } + private async buildDigestPayload( + userId: string, + slot: TaskDigestSlot, + dateKey: string, + includeFallbackTask: boolean, + ): Promise { + const tasks = await this.tasksRepository.find({ + where: { + ownerId: userId, + deletedAt: IsNull(), + completed: false, + dueDate: LessThanOrEqual(dateKey), + }, + order: { dueDate: 'ASC', createdAt: 'ASC' }, + }); + const overdueTasks = tasks.filter((task) => task.dueDate < dateKey); + const todayTasks = tasks.filter((task) => task.dueDate === dateKey); + const usedFallbackTask = includeFallbackTask && tasks.length === 0; + const mappedTodayTasks = todayTasks.map((task) => this.toDigestItem(task)); + const mappedOverdueTasks = overdueTasks.map((task) => + this.toDigestItem(task), + ); + + if (usedFallbackTask) { + mappedTodayTasks.push({ + title: 'Test-Benachrichtigung', + notes: + 'Diese Aufgabe wurde nur fuer den Benachrichtigungstest erzeugt.', + dueDate: dateKey, + }); + } + + return { + payload: { + slot, + date: dateKey, + tasksUrl: this.tasksUrl(), + todayTasks: mappedTodayTasks, + overdueTasks: mappedOverdueTasks, + }, + taskCount: tasks.length + (usedFallbackTask ? 1 : 0), + usedFallbackTask, + }; + } + + private toDigestItem(task: UserTaskEntity): TaskDigestEmailItem { + return { + title: task.title, + notes: task.notes ?? undefined, + dueDate: task.dueDate, + }; + } + private shouldProcessUser( user: UserEntity, slot: TaskDigestSlot, @@ -205,4 +335,8 @@ export class TaskDigestService { return `${clientUrl.replace(/\/$/, '')}/tasks`; } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; + } } diff --git a/listify-api/src/tasks/task-push.service.ts b/listify-api/src/tasks/task-push.service.ts index 4781555..53af352 100644 --- a/listify-api/src/tasks/task-push.service.ts +++ b/listify-api/src/tasks/task-push.service.ts @@ -17,6 +17,13 @@ interface TaskDigestPushPayload { overdueTasks: TaskDigestEmailItem[]; } +export interface TaskPushDigestResult { + enabled: boolean; + subscriptionCount: number; + sentCount: number; + failedCount: number; +} + @Injectable() export class TaskPushService { private readonly logger = new Logger(TaskPushService.name); @@ -89,34 +96,47 @@ export class TaskPushService { async sendTaskDigest( userId: string, payload: TaskDigestPushPayload, - ): Promise { + ): Promise { if (!this.configureWebPush()) { this.logger.warn( 'Task push digest skipped because VAPID keys are not configured.', ); - return; + return { + enabled: false, + subscriptionCount: 0, + sentCount: 0, + failedCount: 0, + }; } const subscriptions = await this.subscriptionsRepository.find({ where: { userId }, }); - - await Promise.all( + const results = await Promise.all( subscriptions.map((subscription) => this.sendToSubscription(subscription, payload), ), ); + const sentCount = results.filter(Boolean).length; + + return { + enabled: true, + subscriptionCount: subscriptions.length, + sentCount, + failedCount: subscriptions.length - sentCount, + }; } private async sendToSubscription( subscription: TaskPushSubscriptionEntity, payload: TaskDigestPushPayload, - ): Promise { + ): Promise { try { await webPush.sendNotification( this.toPushSubscription(subscription), JSON.stringify(this.toNotificationPayload(payload)), ); + return true; } catch (error) { const statusCode = typeof error === 'object' && error !== null && 'statusCode' in error @@ -125,13 +145,14 @@ export class TaskPushService { if (statusCode === 404 || statusCode === 410) { await this.subscriptionsRepository.delete({ id: subscription.id }); - return; + return false; } this.logger.error( `Task push notification could not be sent to subscription ${subscription.id}.`, error instanceof Error ? error.stack : undefined, ); + return false; } } diff --git a/listify-api/src/tasks/tasks.controller.ts b/listify-api/src/tasks/tasks.controller.ts index 41592c7..38c4d3e 100644 --- a/listify-api/src/tasks/tasks.controller.ts +++ b/listify-api/src/tasks/tasks.controller.ts @@ -18,6 +18,7 @@ import { SaveTaskPushSubscriptionDto, } from './dto/task-push-subscription.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { TaskDigestService } from './task-digest.service'; import { TaskPushService } from './task-push.service'; import { TasksService } from './tasks.service'; import type { AuthenticatedRequest } from '../auth/auth.types'; @@ -26,6 +27,7 @@ import type { AuthenticatedRequest } from '../auth/auth.types'; @UseGuards(JwtAuthGuard) export class TasksController { constructor( + private readonly taskDigestService: TaskDigestService, private readonly taskPushService: TaskPushService, private readonly tasksService: TasksService, ) {} @@ -73,6 +75,11 @@ export class TasksController { ); } + @Post('notifications/test') + sendTestNotification(@Req() request: AuthenticatedRequest) { + return this.taskDigestService.sendTestDigest(this.requireUserId(request)); + } + @Get(':taskId') getTask( @Req() request: AuthenticatedRequest,