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[]; notificationTag?: string; } export interface TaskPushDigestResult { enabled: boolean; subscriptionCount: number; sentCount: number; failedCount: number; } @Injectable() export class TaskPushService { private readonly logger = new Logger(TaskPushService.name); private configured = false; constructor( @InjectRepository(TaskPushSubscriptionEntity) private readonly subscriptionsRepository: Repository, private readonly configService: ConfigService, ) {} publicKey(): { enabled: boolean; publicKey?: string } { const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); const privateKey = this.configService.get('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 { if (!this.configureWebPush()) { this.logger.warn( 'Task push digest skipped because VAPID keys are not configured.', ); return { enabled: false, subscriptionCount: 0, sentCount: 0, failedCount: 0, }; } const subscriptions = await this.subscriptionsRepository.find({ where: { userId }, }); 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 { try { await webPush.sendNotification( this.toPushSubscription(subscription), JSON.stringify(this.toNotificationPayload(payload)), { TTL: 60 * 60, urgency: 'high', }, ); return true; } 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 false; } this.logger.error( `Task push notification could not be sent to subscription ${subscription.id}.`, error instanceof Error ? error.stack : undefined, ); return false; } } 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: payload.notificationTag ?? `task-digest-${payload.slot}-${payload.date}`, }; } private configureWebPush(): boolean { if (this.configured) { return true; } const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); const privateKey = this.configService.get('VAPID_PRIVATE_KEY'); if (!publicKey || !privateKey) { return false; } webPush.setVapidDetails( this.configService.get( '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; } }