179 lines
4.8 KiB
TypeScript
179 lines
4.8 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}`,
|
|
});
|
|
}
|
|
|
|
async getStats(): Promise<{ totalSubscribers: number }> {
|
|
const totalSubscribers = await this.subscriptionRepo.count();
|
|
return { totalSubscribers };
|
|
}
|
|
}
|