placebo.mk/backend/src/modules/push/push.service.ts
2026-02-22 02:23:41 +01:00

174 lines
4.6 KiB
TypeScript

import * as webpush from 'web-push';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PushSubscriptionEntity } from './push-subscription.entity';
import { SubscribeDto, UnsubscribeDto } from './push.dto';
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
url?: string;
tag?: string;
}
@Injectable()
export class PushService implements OnModuleInit {
private readonly logger = new Logger(PushService.name);
constructor(
private configService: ConfigService,
@InjectRepository(PushSubscriptionEntity)
private subscriptionRepo: Repository<PushSubscriptionEntity>,
) {}
onModuleInit() {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
const subject = this.configService.get<string>(
'VAPID_SUBJECT',
'mailto:contact@placebo.mk',
);
if (!publicKey || !privateKey) {
this.logger.warn(
'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid',
);
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
this.logger.log('VAPID keys configured successfully');
}
getPublicKey(): string | null {
return this.configService.get<string>('VAPID_PUBLIC_KEY') ?? null;
}
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint },
});
if (existing) {
if (dto.userId && existing.userId !== dto.userId) {
existing.userId = dto.userId;
return this.subscriptionRepo.save(existing);
}
return existing;
}
const subscription = this.subscriptionRepo.create({
endpoint: dto.endpoint,
p256dh: dto.p256dh,
auth: dto.auth,
userId: dto.userId ?? null,
});
return this.subscriptionRepo.save(subscription);
}
async unsubscribe(dto: UnsubscribeDto): Promise<void> {
await this.subscriptionRepo.delete({ endpoint: dto.endpoint });
}
async sendToAll(
payload: PushPayload,
): Promise<{ sent: number; failed: number }> {
const subscriptions = await this.subscriptionRepo.find();
if (subscriptions.length === 0) {
return { sent: 0, failed: 0 };
}
const notification = JSON.stringify({
title: payload.title,
body: payload.body,
icon: payload.icon ?? '/icons/icon-192.png',
badge: payload.badge ?? '/icons/badge-72.png',
url: payload.url ?? '/',
tag: payload.tag,
});
let sent = 0;
let failed = 0;
const results = await Promise.allSettled(
subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
notification,
{
TTL: 86400,
},
);
return { success: true, id: sub.id };
} catch (error) {
if (error instanceof Error) {
this.logger.warn(`Push failed for ${sub.id}: ${error.message}`);
}
if (
error instanceof webpush.WebPushError &&
(error.statusCode === 410 || error.statusCode === 404)
) {
await this.subscriptionRepo.delete({ id: sub.id });
this.logger.log(`Removed invalid subscription: ${sub.id}`);
}
return { success: false, id: sub.id };
}
}),
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) {
sent++;
} else {
failed++;
}
}
this.logger.log(`Push sent: ${sent}, failed: ${failed}`);
return { sent, failed };
}
async notifyNewArticle(
articleTitle: string,
articleSlug: string,
): Promise<void> {
await this.sendToAll({
title: 'Нови вести! 📰',
body: articleTitle,
url: `/article/${articleSlug}`,
tag: 'new-article',
});
}
async notifyLiveBlogUpdate(
blogTitle: string,
blogSlug: string,
updatePreview: string,
): Promise<void> {
const body =
updatePreview.length > 100
? updatePreview.substring(0, 97) + '...'
: updatePreview;
await this.sendToAll({
title: `${blogTitle} 📡`,
body,
url: `/live-blog/${blogSlug}`,
tag: `live-blog-${blogSlug}`,
});
}
}