This commit is contained in:
Bastian Wagner
2026-06-29 15:25:14 +02:00
parent cadb198949
commit 9519409ec7
4 changed files with 267 additions and 38 deletions

View File

@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { UserEntity } from '../auth/user.entity'; import { UserEntity } from '../auth/user.entity';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
@@ -20,7 +21,14 @@ describe('TaskDigestService', () => {
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
}; };
taskPushService = { taskPushService = {
sendTaskDigest: jest.fn().mockResolvedValue(undefined), sendTaskDigest: jest
.fn()
.mockResolvedValue({
enabled: true,
subscriptionCount: 1,
sentCount: 1,
failedCount: 0,
}),
}; };
service = new TaskDigestService( service = new TaskDigestService(
usersRepository as never, usersRepository as never,
@@ -154,6 +162,65 @@ describe('TaskDigestService', () => {
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled(); 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<UserEntity> = {}) { async function saveUser(overrides: Partial<UserEntity> = {}) {
return usersRepository.save({ return usersRepository.save({
id: overrides.id ?? 'owner-1', id: overrides.id ?? 'owner-1',

View File

@@ -1,13 +1,48 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
import { UserEntity } from '../auth/user.entity'; import { UserEntity } from '../auth/user.entity';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import type { TaskDigestEmailItem } from '../mail/mail.types';
import { TaskPushService } from './task-push.service'; import { TaskPushService } from './task-push.service';
import { UserTaskEntity } from './user-task.entity'; import { UserTaskEntity } from './user-task.entity';
import type { TaskDigestSlot } from './task-digest.types'; 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() @Injectable()
export class TaskDigestService { export class TaskDigestService {
private readonly logger = new Logger(TaskDigestService.name); private readonly logger = new Logger(TaskDigestService.name);
@@ -55,46 +90,88 @@ export class TaskDigestService {
} }
} }
async sendTestDigest(userId: string): Promise<TaskNotificationTestResult> {
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( private async processUserDigest(
user: UserEntity, user: UserEntity,
slot: TaskDigestSlot, slot: TaskDigestSlot,
dateKey: string, dateKey: string,
): Promise<void> { ): Promise<void> {
const tasks = await this.tasksRepository.find({ const { payload, taskCount } = await this.buildDigestPayload(
where: { user.id,
ownerId: user.id, slot,
deletedAt: IsNull(), dateKey,
completed: false, 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,
})),
};
if (taskCount > 0) {
try { try {
await this.mailService.sendTaskDigestEmail(user.email, { await this.mailService.sendTaskDigestEmail(user.email, {
...digestPayload, ...payload,
displayName: user.name ?? undefined, displayName: user.name ?? undefined,
}); });
await this.taskPushService.sendTaskDigest(user.id, digestPayload); await this.taskPushService.sendTaskDigest(user.id, payload);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Task digest could not be sent for user ${user.id}.`, `Task digest could not be sent for user ${user.id}.`,
@@ -108,6 +185,59 @@ export class TaskDigestService {
await this.usersRepository.save(user); await this.usersRepository.save(user);
} }
private async buildDigestPayload(
userId: string,
slot: TaskDigestSlot,
dateKey: string,
includeFallbackTask: boolean,
): Promise<TaskDigestPayloadResult> {
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( private shouldProcessUser(
user: UserEntity, user: UserEntity,
slot: TaskDigestSlot, slot: TaskDigestSlot,
@@ -205,4 +335,8 @@ export class TaskDigestService {
return `${clientUrl.replace(/\/$/, '')}/tasks`; return `${clientUrl.replace(/\/$/, '')}/tasks`;
} }
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}
} }

View File

@@ -17,6 +17,13 @@ interface TaskDigestPushPayload {
overdueTasks: TaskDigestEmailItem[]; overdueTasks: TaskDigestEmailItem[];
} }
export interface TaskPushDigestResult {
enabled: boolean;
subscriptionCount: number;
sentCount: number;
failedCount: number;
}
@Injectable() @Injectable()
export class TaskPushService { export class TaskPushService {
private readonly logger = new Logger(TaskPushService.name); private readonly logger = new Logger(TaskPushService.name);
@@ -89,34 +96,47 @@ export class TaskPushService {
async sendTaskDigest( async sendTaskDigest(
userId: string, userId: string,
payload: TaskDigestPushPayload, payload: TaskDigestPushPayload,
): Promise<void> { ): Promise<TaskPushDigestResult> {
if (!this.configureWebPush()) { if (!this.configureWebPush()) {
this.logger.warn( this.logger.warn(
'Task push digest skipped because VAPID keys are not configured.', '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({ const subscriptions = await this.subscriptionsRepository.find({
where: { userId }, where: { userId },
}); });
const results = await Promise.all(
await Promise.all(
subscriptions.map((subscription) => subscriptions.map((subscription) =>
this.sendToSubscription(subscription, payload), this.sendToSubscription(subscription, payload),
), ),
); );
const sentCount = results.filter(Boolean).length;
return {
enabled: true,
subscriptionCount: subscriptions.length,
sentCount,
failedCount: subscriptions.length - sentCount,
};
} }
private async sendToSubscription( private async sendToSubscription(
subscription: TaskPushSubscriptionEntity, subscription: TaskPushSubscriptionEntity,
payload: TaskDigestPushPayload, payload: TaskDigestPushPayload,
): Promise<void> { ): Promise<boolean> {
try { try {
await webPush.sendNotification( await webPush.sendNotification(
this.toPushSubscription(subscription), this.toPushSubscription(subscription),
JSON.stringify(this.toNotificationPayload(payload)), JSON.stringify(this.toNotificationPayload(payload)),
); );
return true;
} catch (error) { } catch (error) {
const statusCode = const statusCode =
typeof error === 'object' && error !== null && 'statusCode' in error typeof error === 'object' && error !== null && 'statusCode' in error
@@ -125,13 +145,14 @@ export class TaskPushService {
if (statusCode === 404 || statusCode === 410) { if (statusCode === 404 || statusCode === 410) {
await this.subscriptionsRepository.delete({ id: subscription.id }); await this.subscriptionsRepository.delete({ id: subscription.id });
return; return false;
} }
this.logger.error( this.logger.error(
`Task push notification could not be sent to subscription ${subscription.id}.`, `Task push notification could not be sent to subscription ${subscription.id}.`,
error instanceof Error ? error.stack : undefined, error instanceof Error ? error.stack : undefined,
); );
return false;
} }
} }

View File

@@ -18,6 +18,7 @@ import {
SaveTaskPushSubscriptionDto, SaveTaskPushSubscriptionDto,
} from './dto/task-push-subscription.dto'; } from './dto/task-push-subscription.dto';
import { UpdateTaskDto } from './dto/update-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto';
import { TaskDigestService } from './task-digest.service';
import { TaskPushService } from './task-push.service'; import { TaskPushService } from './task-push.service';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
import type { AuthenticatedRequest } from '../auth/auth.types'; import type { AuthenticatedRequest } from '../auth/auth.types';
@@ -26,6 +27,7 @@ import type { AuthenticatedRequest } from '../auth/auth.types';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class TasksController { export class TasksController {
constructor( constructor(
private readonly taskDigestService: TaskDigestService,
private readonly taskPushService: TaskPushService, private readonly taskPushService: TaskPushService,
private readonly tasksService: TasksService, 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') @Get(':taskId')
getTask( getTask(
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,