Skip to content

Commit a53a4a0

Browse files
notifications: introduce global notifications module and migrate job notifications usage
1 parent e8096c0 commit a53a4a0

12 files changed

+356
-150
lines changed

src/jobs/notifications.service.ts

Lines changed: 8 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,8 @@
1-
import { Injectable, Logger } from '@nestjs/common';
2-
import { DatabaseService } from '../common/database/database.service';
3-
import webpush, { WebPushError } from 'web-push';
4-
import admin from 'firebase-admin';
5-
import { Subscription } from '@prisma/client';
6-
import { Prisma } from '@prisma/client';
7-
8-
// VAPID keys should be set via environment variables
9-
if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_EMAIL) {
10-
webpush.setVapidDetails(
11-
`mailto:${process.env.VAPID_EMAIL}`,
12-
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
13-
process.env.VAPID_PRIVATE_KEY
14-
);
15-
}
16-
17-
// Initialize Firebase Admin if not already initialized
18-
if (!admin.apps.length && process.env.FIREBASE_SERVICE_ACCOUNT_KEY) {
19-
try {
20-
admin.initializeApp({
21-
credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY)),
22-
});
23-
} catch (error) {
24-
console.error('Failed to initialize Firebase Admin:', error);
25-
}
26-
}
27-
28-
export interface NotificationPayload {
29-
title: string;
30-
body: string;
31-
url: string;
32-
icon?: string;
33-
badge?: string;
34-
}
35-
36-
export interface WebSubscription extends Subscription {
37-
type: 'web';
38-
keys: Prisma.JsonValue;
39-
}
40-
41-
export interface FcmSubscription extends Subscription {
42-
type: 'fcm';
43-
keys: Prisma.JsonValue;
44-
}
45-
46-
export type SubscriptionRecord = WebSubscription | FcmSubscription;
47-
48-
@Injectable()
49-
export class NotificationsService {
50-
private readonly logger = new Logger(NotificationsService.name);
51-
52-
constructor(private readonly db: DatabaseService) {}
53-
54-
async sendNotification(subscription: SubscriptionRecord, notificationPayload: NotificationPayload): Promise<void> {
55-
try {
56-
this.logger.log('Sending notification to subscription:', subscription);
57-
58-
if (subscription.type === 'web') {
59-
if (!isWebKeys(subscription.keys)) {
60-
throw new Error(`Invalid keys for web subscription: ${JSON.stringify(subscription.keys)}`);
61-
}
62-
await webpush.sendNotification(
63-
{
64-
endpoint: subscription.endpoint,
65-
keys: {
66-
auth: (subscription.keys as any).auth,
67-
p256dh: (subscription.keys as any).p256dh,
68-
},
69-
},
70-
JSON.stringify({
71-
title: notificationPayload.title,
72-
body: notificationPayload.body,
73-
icon: notificationPayload.icon,
74-
badge: notificationPayload.badge,
75-
url: notificationPayload.url,
76-
})
77-
);
78-
} else if (subscription.type === 'fcm') {
79-
if (!isFcmKeys(subscription.keys)) {
80-
throw new Error(`Invalid keys for FCM subscription: ${JSON.stringify(subscription.keys)}`);
81-
}
82-
const fcmMessage = {
83-
notification: {
84-
title: notificationPayload.title,
85-
body: notificationPayload.body,
86-
},
87-
data: {
88-
url: notificationPayload.url,
89-
},
90-
token: (subscription.keys as any).token,
91-
android: {
92-
notification: {
93-
sound: 'notification',
94-
},
95-
},
96-
apns: {
97-
payload: {
98-
aps: {
99-
sound: 'notification',
100-
},
101-
},
102-
},
103-
};
104-
const response = await admin.messaging().send(fcmMessage);
105-
this.logger.log('FCM response:', response);
106-
}
107-
} catch (error: any) {
108-
if (subscription.type === 'web' && error instanceof WebPushError) {
109-
if (error.statusCode === 410) {
110-
await this.db.subscription.delete({ where: { id: subscription.id } });
111-
this.logger.log(`Subscription with id ${subscription.id} removed due to expiration.`);
112-
} else {
113-
this.logger.log(
114-
`Failed to send notification to subscription id ${subscription.id}:`,
115-
error.statusCode,
116-
error.body
117-
);
118-
}
119-
} else if (subscription.type === 'fcm') {
120-
if (
121-
error.code === 'messaging/invalid-registration-token' ||
122-
error.code === 'messaging/registration-token-not-registered'
123-
) {
124-
await this.db.subscription.delete({ where: { id: subscription.id } });
125-
this.logger.log(`Removed invalid FCM token for subscription id ${subscription.id}.`);
126-
} else {
127-
this.logger.log(`Failed to send FCM notification to subscription id ${subscription.id}:`, error);
128-
}
129-
} else {
130-
this.logger.log(`An error occurred while sending notification to subscription id ${subscription.id}:`, error);
131-
}
132-
}
133-
}
134-
}
135-
136-
export function isWebKeys(keys: Prisma.JsonValue): keys is { auth: string; p256dh: string } {
137-
return (
138-
typeof keys === 'object' &&
139-
keys !== null &&
140-
'auth' in keys &&
141-
'p256dh' in keys &&
142-
typeof (keys as any).auth === 'string' &&
143-
typeof (keys as any).p256dh === 'string'
144-
);
145-
}
146-
147-
export function isFcmKeys(keys: Prisma.JsonValue): keys is { token: string } {
148-
return typeof keys === 'object' && keys !== null && 'token' in keys && typeof (keys as any).token === 'string';
149-
}
1+
// DEPRECATED: Use NotificationsService from 'src/notifications/notifications.service'
2+
// This file remains temporarily to avoid breaking existing imports. Update imports to:
3+
// import { NotificationsService } from '../notifications/notifications.service';
4+
// Then delete this file.
5+
6+
export * from '../notifications/notifications.service';
7+
export * from '../notifications/interfaces/notification-payload.interface';
8+
export * from '../notifications/interfaces/subscription-record.interface';

src/jobs/reddit.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import { JobService } from './job.service';
3-
import { NotificationsService, NotificationPayload } from './notifications.service';
3+
import { NotificationsService } from '../notifications/notifications.service';
4+
import { NotificationPayload } from '../notifications/interfaces/notification-payload.interface';
45
import { DatabaseService } from '../common/database/database.service';
56

67
@Injectable()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { IsString, IsIn, IsObject } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class CreateSubscriptionDto {
5+
@IsString()
6+
@IsIn(['web', 'fcm'])
7+
@ApiProperty({ enum: ['web', 'fcm'], description: 'Subscription type source', example: 'web' })
8+
type!: 'web' | 'fcm';
9+
10+
@IsString()
11+
@ApiProperty({
12+
description: 'Push endpoint URL provided by the browser or FCM',
13+
example: 'https://fcm.googleapis.com/fcm/send/abc123',
14+
})
15+
endpoint!: string;
16+
17+
@IsObject()
18+
@ApiProperty({ description: 'Keys object containing auth and p256dh (for Web Push)' })
19+
keys!: Record<string, any>;
20+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { IsString, IsOptional, IsUrl } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class MassNotificationDto {
5+
@IsString()
6+
@ApiProperty({ description: 'Notification title', example: 'System Update' })
7+
title!: string;
8+
9+
@IsString()
10+
@ApiProperty({ description: 'Notification body text', example: 'We have shipped a new feature.' })
11+
body!: string;
12+
13+
@IsString()
14+
@IsUrl({ require_protocol: true }, { message: 'url must include protocol (https://...)' })
15+
@ApiProperty({
16+
description: 'URL to open when the notification is clicked',
17+
example: 'https://codebuilder.org/dashboard',
18+
})
19+
url!: string;
20+
21+
@IsOptional()
22+
@IsString()
23+
@ApiPropertyOptional({ description: 'Icon URL', example: 'https://codebuilder.org/icon.png' })
24+
icon?: string;
25+
26+
@IsOptional()
27+
@IsString()
28+
@ApiPropertyOptional({ description: 'Badge URL', example: 'https://codebuilder.org/badge.png' })
29+
badge?: string;
30+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Provider } from '@nestjs/common';
2+
import admin from 'firebase-admin';
3+
4+
export const FIREBASE_ADMIN = 'FIREBASE_ADMIN';
5+
6+
export const FirebaseAdminProvider: Provider = {
7+
provide: FIREBASE_ADMIN,
8+
useFactory: () => {
9+
if (!admin.apps.length && process.env.FIREBASE_SERVICE_ACCOUNT_KEY) {
10+
try {
11+
admin.initializeApp({
12+
credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY)),
13+
});
14+
} catch (err) {
15+
console.error('Failed to initialize Firebase Admin:', err);
16+
}
17+
}
18+
return admin;
19+
},
20+
};

src/notifications/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './notifications.module';
2+
export * from './notifications.service';
3+
export * from './interfaces/notification-payload.interface';
4+
export * from './interfaces/subscription-record.interface';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface NotificationPayload {
2+
title: string;
3+
body: string;
4+
url: string;
5+
icon?: string;
6+
badge?: string;
7+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Subscription, Prisma } from '@prisma/client';
2+
3+
export interface WebSubscription extends Subscription {
4+
type: 'web';
5+
keys: Prisma.JsonValue;
6+
}
7+
8+
export interface FcmSubscription extends Subscription {
9+
type: 'fcm';
10+
keys: Prisma.JsonValue;
11+
}
12+
13+
export type SubscriptionRecord = WebSubscription | FcmSubscription;
14+
15+
export function isWebKeys(keys: Prisma.JsonValue): keys is { auth: string; p256dh: string } {
16+
return (
17+
typeof keys === 'object' &&
18+
keys !== null &&
19+
'auth' in keys &&
20+
'p256dh' in keys &&
21+
typeof (keys as any).auth === 'string' &&
22+
typeof (keys as any).p256dh === 'string'
23+
);
24+
}
25+
26+
export function isFcmKeys(keys: Prisma.JsonValue): keys is { token: string } {
27+
return typeof keys === 'object' && keys !== null && 'token' in keys && typeof (keys as any).token === 'string';
28+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Controller, Get, Post, Body, Ip, HttpCode, HttpStatus } from '@nestjs/common';
2+
import { NotificationsService } from './notifications.service';
3+
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
4+
import { MassNotificationDto } from './dto/mass-notification.dto';
5+
import { ApiTags } from '@nestjs/swagger';
6+
import { Api } from '../common/decorators/api.decorator';
7+
8+
@ApiTags('notifications')
9+
@Controller('notifications')
10+
export class NotificationsController {
11+
constructor(private readonly notificationsService: NotificationsService) {}
12+
13+
@Get('public-key')
14+
@Api({
15+
summary: 'Get VAPID public key',
16+
description: 'Returns the Web Push VAPID public key for browser clients.',
17+
envelope: true,
18+
responses: [{ status: 200, description: 'Public key returned.' }],
19+
})
20+
getPublicKey() {
21+
return { publicKey: this.notificationsService.getPublicKey() };
22+
}
23+
24+
@Post('subscribe')
25+
@HttpCode(HttpStatus.CREATED)
26+
@Api({
27+
summary: 'Create or update push subscription',
28+
description: 'Stores (upserts) a Web Push or FCM subscription tied to the client.',
29+
bodyType: CreateSubscriptionDto,
30+
envelope: true,
31+
responses: [
32+
{ status: 201, description: 'Subscription stored.' },
33+
{ status: 400, description: 'Invalid subscription payload.' },
34+
],
35+
})
36+
async subscribe(@Body() dto: CreateSubscriptionDto, @Ip() ip: string) {
37+
const record = await this.notificationsService.upsertSubscription(dto, ip || 'Unknown');
38+
return { message: 'Subscription stored', data: record };
39+
}
40+
41+
@Post('mass')
42+
@Api({
43+
summary: 'Send a mass notification',
44+
description: 'Sends a notification to all stored subscriptions.',
45+
bodyType: MassNotificationDto,
46+
envelope: true,
47+
responses: [
48+
{ status: 200, description: 'Mass notification request accepted.' },
49+
{ status: 400, description: 'Invalid notification payload.' },
50+
],
51+
})
52+
async mass(@Body() dto: MassNotificationDto) {
53+
return this.notificationsService.sendMassNotification(dto);
54+
}
55+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module, Global } from '@nestjs/common';
2+
import { NotificationsService } from './notifications.service';
3+
import { NotificationsController } from './notifications.controller';
4+
import { FirebaseAdminProvider } from './firebase-admin.provider';
5+
import { WebPushProvider } from './webpush.provider';
6+
import { CommonModule } from '../common/common.module';
7+
8+
@Global()
9+
@Module({
10+
imports: [CommonModule],
11+
controllers: [NotificationsController],
12+
providers: [NotificationsService, FirebaseAdminProvider, WebPushProvider],
13+
exports: [NotificationsService],
14+
})
15+
export class NotificationsModule {}

0 commit comments

Comments
 (0)