push notification implemented

need testing
This commit is contained in:
dimitar committed 2026-02-22 02:23:41 +01:00
1 parent 26a17b5a4c
commit 0bbf2ab56f
19 files changed
+907 -84

No files matched your search

+4
View File
@@ -10,6 +10,7 @@ import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { PushModule } from './modules/push/push.module';
import {
Article,
Author,
@@ -21,6 +22,7 @@ import {
Reaction,
} from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity';
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
@Module({
imports: [
@@ -44,6 +46,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
Comment,
Reaction,
ShareEvent,
PushSubscriptionEntity,
],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true',
@@ -55,6 +58,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
AuthModule,
CommentModule,
AnalyticsModule,
PushModule,
],
controllers: [AppController],
providers: [AppService],
+2
View File
@@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({
imports: [
TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
],
controllers: [ArticlesController],
providers: [ArticlesService],
+21
View File
@@ -4,6 +4,7 @@ import {
Logger,
Inject,
forwardRef,
Optional,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -14,6 +15,7 @@ import {
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
import { PushService } from './push/push.service';
@Injectable()
export class ArticlesService {
@@ -24,6 +26,9 @@ export class ArticlesService {
private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {}
async create(dto: CreateArticleDto): Promise<Article> {
@@ -183,6 +188,7 @@ export class ArticlesService {
status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> {
const article = await this.findOne(id);
const wasDraft = article.status === ArticleStatus.DRAFT;
article.status = status;
const savedArticle = await this.articleRepository.save(article);
@@ -202,6 +208,21 @@ export class ArticlesService {
}
}
// Send push notification for newly published articles
if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) {
try {
await this.pushService.notifyNewArticle(
savedArticle.title,
savedArticle.slug,
);
this.logger.log(
`Push notification sent for article: ${savedArticle.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedArticle;
}
+2
View File
@@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({
imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
],
controllers: [LiveBlogController],
providers: [LiveBlogService],
+21
View File
@@ -5,6 +5,7 @@ import {
OnModuleInit,
Inject,
forwardRef,
Optional,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
@@ -19,6 +20,7 @@ import {
CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto,
} from './articles.dto';
import { PushService } from './push/push.service';
interface SseClient {
id: string;
@@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit {
private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {}
onModuleInit() {
@@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
update: savedUpdate,
});
// Send push notification for live blog updates (only for live blogs)
if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) {
try {
await this.pushService.notifyLiveBlogUpdate(
liveBlogEntity.title,
liveBlogEntity.slug,
savedUpdate.content,
);
this.logger.debug(
`Push notification sent for live blog update: ${liveBlogEntity.title}`,
);
} catch (error) {
this.logger.error('Failed to send push notification:', error);
}
}
return savedUpdate;
}
@@ -0,0 +1,29 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('push_subscriptions')
export class PushSubscriptionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ type: 'text', unique: true })
endpoint: string;
@Column({ type: 'text' })
p256dh: string;
@Column({ type: 'text' })
auth: string;
@Column({ type: 'varchar', nullable: true })
userId: string | null;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,38 @@
import {
Controller,
Get,
Post,
Delete,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { PushService } from './push.service';
import { SubscribeDto, UnsubscribeDto } from './push.dto';
import { Public } from '../auth/public.decorator';
@Controller('push')
export class PushController {
constructor(private readonly pushService: PushService) {}
@Public()
@Get('vapid-public-key')
getVapidPublicKey(): { publicKey: string | null } {
return { publicKey: this.pushService.getPublicKey() };
}
@Public()
@Post('subscribe')
@HttpCode(HttpStatus.CREATED)
async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> {
await this.pushService.subscribe(dto);
return { success: true };
}
@Public()
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() dto: UnsubscribeDto): Promise<void> {
await this.pushService.unsubscribe(dto);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
export class SubscribeDto {
@IsUrl()
@IsNotEmpty()
endpoint: string;
@IsString()
@IsNotEmpty()
p256dh: string;
@IsString()
@IsNotEmpty()
auth: string;
@IsOptional()
@IsString()
userId?: string;
}
export class UnsubscribeDto {
@IsUrl()
@IsNotEmpty()
endpoint: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PushController } from './push.controller';
import { PushService } from './push.service';
import { PushSubscriptionEntity } from './push-subscription.entity';
@Module({
imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])],
controllers: [PushController],
providers: [PushService],
exports: [PushService],
})
export class PushModule {}
+173
View File
@@ -0,0 +1,173 @@
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}`,
});
}
}