240 lines
6.6 KiB
TypeScript
240 lines
6.6 KiB
TypeScript
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<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<TaskPushDigestResult> {
|
|
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<boolean> {
|
|
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<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;
|
|
}
|
|
}
|