232 lines
6.5 KiB
TypeScript
232 lines
6.5 KiB
TypeScript
|
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||
|
|
import { ConfigService } from '@nestjs/config';
|
||
|
|
import { OnEvent } from '@nestjs/event-emitter';
|
||
|
|
import * as admin from 'firebase-admin';
|
||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
||
|
|
|
||
|
|
interface FcmToken {
|
||
|
|
token: string;
|
||
|
|
device: string;
|
||
|
|
createdAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface NotificationPayload {
|
||
|
|
title: string;
|
||
|
|
body: string;
|
||
|
|
data?: Record<string, string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class NotificationsService implements OnModuleInit {
|
||
|
|
private readonly logger = new Logger(NotificationsService.name);
|
||
|
|
private firebaseInitialized = false;
|
||
|
|
|
||
|
|
constructor(
|
||
|
|
private readonly prisma: PrismaService,
|
||
|
|
private readonly configService: ConfigService,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
onModuleInit() {
|
||
|
|
const projectId = this.configService.get<string>('firebase.projectId');
|
||
|
|
const clientEmail = this.configService.get<string>('firebase.clientEmail');
|
||
|
|
const privateKey = this.configService.get<string>('firebase.privateKey');
|
||
|
|
|
||
|
|
if (!projectId || !clientEmail || !privateKey) {
|
||
|
|
this.logger.warn(
|
||
|
|
'Firebase credentials not configured. Push notifications disabled.',
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (admin.apps.length === 0) {
|
||
|
|
admin.initializeApp({
|
||
|
|
credential: admin.credential.cert({
|
||
|
|
projectId,
|
||
|
|
clientEmail,
|
||
|
|
privateKey,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
this.firebaseInitialized = true;
|
||
|
|
this.logger.log('Firebase Admin initialized for push notifications');
|
||
|
|
}
|
||
|
|
|
||
|
|
async registerToken(
|
||
|
|
userId: string,
|
||
|
|
token: string,
|
||
|
|
device: string = 'web',
|
||
|
|
): Promise<void> {
|
||
|
|
const user = await this.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { fcmTokens: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
const existingTokens: FcmToken[] = (user?.fcmTokens as FcmToken[]) || [];
|
||
|
|
|
||
|
|
// Remove existing entry for this token (if re-registering)
|
||
|
|
const filtered = existingTokens.filter((t) => t.token !== token);
|
||
|
|
|
||
|
|
filtered.push({
|
||
|
|
token,
|
||
|
|
device,
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
await this.prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: { fcmTokens: filtered },
|
||
|
|
});
|
||
|
|
|
||
|
|
this.logger.log(`FCM token registered for user ${userId} (${device})`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async removeToken(userId: string, token: string): Promise<void> {
|
||
|
|
const user = await this.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { fcmTokens: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
const existingTokens: FcmToken[] = (user?.fcmTokens as FcmToken[]) || [];
|
||
|
|
const filtered = existingTokens.filter((t) => t.token !== token);
|
||
|
|
|
||
|
|
await this.prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: { fcmTokens: filtered.length > 0 ? filtered : null },
|
||
|
|
});
|
||
|
|
|
||
|
|
this.logger.log(`FCM token removed for user ${userId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async sendPushNotification(
|
||
|
|
userId: string,
|
||
|
|
category: string,
|
||
|
|
payload: NotificationPayload,
|
||
|
|
): Promise<void> {
|
||
|
|
if (!this.firebaseInitialized) {
|
||
|
|
this.logger.debug('Firebase not initialized, skipping push notification');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check user notification preferences
|
||
|
|
const user = await this.prisma.user.findUnique({
|
||
|
|
where: { id: userId },
|
||
|
|
select: { notificationPreferences: true, fcmTokens: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) return;
|
||
|
|
|
||
|
|
// Check if inApp notifications are enabled for this category
|
||
|
|
const prefs = user.notificationPreferences as Record<string, any> | null;
|
||
|
|
if (prefs?.notifications?.[category]?.inApp === false) {
|
||
|
|
this.logger.debug(
|
||
|
|
`Push notification skipped for user ${userId}: ${category} inApp disabled`,
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const tokens: FcmToken[] = (user.fcmTokens as FcmToken[]) || [];
|
||
|
|
if (tokens.length === 0) {
|
||
|
|
this.logger.debug(`No FCM tokens for user ${userId}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const tokenStrings = tokens.map((t) => t.token);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await admin.messaging().sendEachForMulticast({
|
||
|
|
tokens: tokenStrings,
|
||
|
|
notification: {
|
||
|
|
title: payload.title,
|
||
|
|
body: payload.body,
|
||
|
|
},
|
||
|
|
data: payload.data || {},
|
||
|
|
webpush: {
|
||
|
|
fcmOptions: {
|
||
|
|
link: payload.data?.link || '/',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Clean up invalid tokens
|
||
|
|
if (response.failureCount > 0) {
|
||
|
|
const invalidTokens: string[] = [];
|
||
|
|
response.responses.forEach((resp, idx) => {
|
||
|
|
if (
|
||
|
|
!resp.success &&
|
||
|
|
resp.error?.code === 'messaging/registration-token-not-registered'
|
||
|
|
) {
|
||
|
|
invalidTokens.push(tokenStrings[idx]);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (invalidTokens.length > 0) {
|
||
|
|
const validTokens = tokens.filter(
|
||
|
|
(t) => !invalidTokens.includes(t.token),
|
||
|
|
);
|
||
|
|
await this.prisma.user.update({
|
||
|
|
where: { id: userId },
|
||
|
|
data: {
|
||
|
|
fcmTokens: validTokens.length > 0 ? validTokens : null,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
this.logger.log(
|
||
|
|
`Removed ${invalidTokens.length} invalid FCM tokens for user ${userId}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.logger.log(
|
||
|
|
`Push notification sent to user ${userId}: ${response.successCount} success, ${response.failureCount} failed`,
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error(
|
||
|
|
`Failed to send push notification to user ${userId}: ${error.message}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==========================================
|
||
|
|
// EVENT LISTENERS
|
||
|
|
// ==========================================
|
||
|
|
|
||
|
|
@OnEvent('notification.message')
|
||
|
|
async handleMessageNotification(event: {
|
||
|
|
recipientUserId: string;
|
||
|
|
senderName: string;
|
||
|
|
messagePreview: string;
|
||
|
|
conversationId: string;
|
||
|
|
}) {
|
||
|
|
await this.sendPushNotification(event.recipientUserId, 'message_updates', {
|
||
|
|
title: `New message from ${event.senderName}`,
|
||
|
|
body: event.messagePreview || 'You have a new message',
|
||
|
|
data: {
|
||
|
|
type: 'message',
|
||
|
|
conversationId: event.conversationId,
|
||
|
|
link: `/messages?conversation=${event.conversationId}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
@OnEvent('notification.connection_request')
|
||
|
|
async handleConnectionRequestNotification(event: {
|
||
|
|
recipientUserId: string;
|
||
|
|
senderName: string;
|
||
|
|
connectionRequestId: string;
|
||
|
|
}) {
|
||
|
|
await this.sendPushNotification(
|
||
|
|
event.recipientUserId,
|
||
|
|
'new_lead_alerts',
|
||
|
|
{
|
||
|
|
title: 'New Connection Request',
|
||
|
|
body: `${event.senderName} wants to connect with you`,
|
||
|
|
data: {
|
||
|
|
type: 'connection_request',
|
||
|
|
connectionRequestId: event.connectionRequestId,
|
||
|
|
link: '/dashboard/connections',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|