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, ) {} onModuleInit() { const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); const privateKey = this.configService.get('VAPID_PRIVATE_KEY'); const subject = this.configService.get( '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('VAPID_PUBLIC_KEY') ?? null; } async subscribe(dto: SubscribeDto): Promise { 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 { 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 { await this.sendToAll({ title: 'Нови вести! 📰', body: articleTitle, url: `/article/${articleSlug}`, tag: 'new-article', }); } async notifyLiveBlogUpdate( blogTitle: string, blogSlug: string, updatePreview: string, ): Promise { const body = updatePreview.length > 100 ? updatePreview.substring(0, 97) + '...' : updatePreview; await this.sendToAll({ title: `${blogTitle} 📡`, body, url: `/live-blog/${blogSlug}`, tag: `live-blog-${blogSlug}`, }); } async getStats(): Promise<{ totalSubscribers: number }> { const totalSubscribers = await this.subscriptionRepo.count(); return { totalSubscribers }; } }