push notification implemented
need testing
This commit is contained in:
1 parent
26a17b5a4c
commit
0bbf2ab56f
19 files changed
+907
-84
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user