pwa notifications

This commit is contained in:
Bastian Wagner
2026-06-29 14:52:33 +02:00
parent 8fdee223c0
commit a033c50c25
18 changed files with 780 additions and 26 deletions

View File

@@ -0,0 +1,211 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import webPush, { PushSubscription } from 'web-push';
import { TaskPushSubscriptionEntity } from './task-push-subscription.entity';
import { SaveTaskPushSubscriptionDto } from './dto/task-push-subscription.dto';
import type { TaskDigestEmailItem } from '../mail/mail.types';
import type { TaskDigestSlot } from './task-digest.types';
interface TaskDigestPushPayload {
slot: TaskDigestSlot;
date: string;
tasksUrl: string;
todayTasks: TaskDigestEmailItem[];
overdueTasks: TaskDigestEmailItem[];
}
@Injectable()
export class TaskPushService {
private readonly logger = new Logger(TaskPushService.name);
private configured = false;
constructor(
@InjectRepository(TaskPushSubscriptionEntity)
private readonly subscriptionsRepository: Repository<TaskPushSubscriptionEntity>,
private readonly configService: ConfigService,
) {}
publicKey(): { enabled: boolean; publicKey?: string } {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
return publicKey && privateKey
? { enabled: true, publicKey }
: { enabled: false };
}
async saveSubscription(
userId: string,
dto: SaveTaskPushSubscriptionDto,
): Promise<{ message: string }> {
const endpoint = this.requireText(
dto.endpoint,
'Push endpoint is required.',
);
const p256dh = this.requireText(dto.keys?.p256dh, 'Push key is required.');
const auth = this.requireText(
dto.keys?.auth,
'Push auth secret is required.',
);
const existingSubscription = await this.subscriptionsRepository.findOne({
where: { endpoint },
});
const subscription =
existingSubscription ??
this.subscriptionsRepository.create({
id: randomUUID(),
endpoint,
});
subscription.userId = userId;
subscription.p256dh = p256dh;
subscription.auth = auth;
subscription.expiresAt =
typeof dto.expirationTime === 'number'
? new Date(dto.expirationTime)
: null;
await this.subscriptionsRepository.save(subscription);
return { message: 'Push subscription saved.' };
}
async deleteSubscription(
userId: string,
endpoint?: string,
): Promise<{ message: string }> {
if (!endpoint) {
await this.subscriptionsRepository.delete({ userId });
return { message: 'Push subscriptions deleted.' };
}
await this.subscriptionsRepository.delete({ userId, endpoint });
return { message: 'Push subscription deleted.' };
}
async sendTaskDigest(
userId: string,
payload: TaskDigestPushPayload,
): Promise<void> {
if (!this.configureWebPush()) {
this.logger.warn(
'Task push digest skipped because VAPID keys are not configured.',
);
return;
}
const subscriptions = await this.subscriptionsRepository.find({
where: { userId },
});
await Promise.all(
subscriptions.map((subscription) =>
this.sendToSubscription(subscription, payload),
),
);
}
private async sendToSubscription(
subscription: TaskPushSubscriptionEntity,
payload: TaskDigestPushPayload,
): Promise<void> {
try {
await webPush.sendNotification(
this.toPushSubscription(subscription),
JSON.stringify(this.toNotificationPayload(payload)),
);
} catch (error) {
const statusCode =
typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode?: unknown }).statusCode
: undefined;
if (statusCode === 404 || statusCode === 410) {
await this.subscriptionsRepository.delete({ id: subscription.id });
return;
}
this.logger.error(
`Task push notification could not be sent to subscription ${subscription.id}.`,
error instanceof Error ? error.stack : undefined,
);
}
}
private toPushSubscription(
subscription: TaskPushSubscriptionEntity,
): PushSubscription {
return {
endpoint: subscription.endpoint,
expirationTime: subscription.expiresAt?.getTime() ?? null,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
};
}
private toNotificationPayload(payload: TaskDigestPushPayload): {
title: string;
body: string;
url: string;
tag: string;
} {
const todayCount = payload.todayTasks.length;
const overdueCount = payload.overdueTasks.length;
const taskCount = todayCount + overdueCount;
const title =
payload.slot === 'morning'
? `Guten Morgen: ${taskCount} offene Tasks`
: `Nachmittags-Check: ${taskCount} offene Tasks`;
const parts = [
todayCount > 0 ? `${todayCount} fuer heute` : null,
overdueCount > 0 ? `${overdueCount} ueberfaellig` : null,
].filter((part): part is string => part !== null);
return {
title,
body: parts.join(', '),
url: payload.tasksUrl,
tag: `task-digest-${payload.slot}-${payload.date}`,
};
}
private configureWebPush(): boolean {
if (this.configured) {
return true;
}
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
if (!publicKey || !privateKey) {
return false;
}
webPush.setVapidDetails(
this.configService.get<string>(
'VAPID_SUBJECT',
'mailto:no-reply@listify.local',
),
publicKey,
privateKey,
);
this.configured = true;
return true;
}
private requireText(value: string | undefined, message: string): string {
const normalizedValue = value?.trim();
if (!normalizedValue) {
throw new BadRequestException(message);
}
return normalizedValue;
}
}