pwa notifications
This commit is contained in:
211
listify-api/src/tasks/task-push.service.ts
Normal file
211
listify-api/src/tasks/task-push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user