feat: working on push notifications journey

This commit is contained in:
Abdalhamid Alhamad
2024-12-24 12:10:49 +03:00
parent c7470302bd
commit 3719498c2f
35 changed files with 1508 additions and 56 deletions
@@ -0,0 +1 @@
export * from './notifications.controller';
@@ -0,0 +1,36 @@
import { Controller, Get, HttpCode, HttpStatus, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { PageOptionsRequestDto } from '~/core/dtos';
import { NotificationsPageResponseDto } from '../dtos/response';
import { NotificationsService } from '../services/notifications.service';
@Controller('notifications')
@ApiTags('Notifications')
@ApiBearerAuth()
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
@UseGuards(AccessTokenGuard)
@ApiResponse({ type: NotificationsPageResponseDto })
async getNotifications(@AuthenticatedUser() { sub }: IJwtPayload, @Query() pageOptionsDto: PageOptionsRequestDto) {
const { notifications, count, unreadCount } = await this.notificationsService.getNotifications(sub, pageOptionsDto);
return new NotificationsPageResponseDto(notifications, {
itemCount: count,
unreadCount,
page: pageOptionsDto.page,
size: pageOptionsDto.size,
});
}
@Post('mark-as-read')
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.NO_CONTENT)
markAsRead(@AuthenticatedUser() { sub }: IJwtPayload) {
return this.notificationsService.markAsRead(sub);
}
}
@@ -0,0 +1,2 @@
export * from './notifications-page.response.dto';
export * from './notifications.response.dto';
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageMetaResponseDto } from '~/core/dtos';
import { INotificationPageMeta } from '../../interfaces';
export class NotificationMetaResponseDto extends PageMetaResponseDto {
@ApiProperty({ example: 4 })
readonly unreadCount!: number;
constructor(meta: INotificationPageMeta) {
super(meta);
this.unreadCount = meta.unreadCount;
}
}
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Notification } from '../../entities';
import { INotificationPageMeta } from '../../interfaces';
import { NotificationMetaResponseDto } from './notifications-meta.response.dto';
import { NotificationsResponseDto } from './notifications.response.dto';
export class NotificationsPageResponseDto {
@ApiProperty({ type: [NotificationsResponseDto] })
data!: NotificationsResponseDto[];
@ApiProperty({ type: NotificationMetaResponseDto })
meta: INotificationPageMeta;
constructor(data: Notification[], meta: INotificationPageMeta) {
this.data = data.map((notification) => new NotificationsResponseDto(notification));
this.meta = new NotificationMetaResponseDto(meta);
}
}
@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Notification } from '../../entities';
import { NotificationStatus } from '../../enums';
export class NotificationsResponseDto {
@ApiProperty({ example: 'f1b1b9b1-1b1b-1b1b-1b1b-1b1b1b1b1b1b' })
id!: string;
@ApiProperty({ example: 'Test' })
title: string;
@ApiProperty({ example: 'TestBody' })
body: string;
@ApiProperty({ example: NotificationStatus.UNREAD })
status!: NotificationStatus;
@ApiProperty({ example: '2021-09-01T00:00:00.000Z' })
createdAt!: Date;
constructor(notification: Notification) {
this.id = notification.id;
this.title = notification.title;
this.body = notification.message;
this.status = notification.status!;
this.createdAt = notification.createdAt;
}
}
@@ -0,0 +1 @@
export * from './notification.entity';
@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '~/auth/entities';
import { NotificationChannel, NotificationScope, NotificationStatus } from '../enums';
@Entity('notifications')
export class Notification {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { name: 'title' })
title!: string;
@Column('varchar', { name: 'message' })
message!: string;
@Column('varchar', { name: 'recipient', nullable: true })
recipient?: string | null;
@Column('varchar', { name: 'scope' })
scope!: NotificationScope;
@Column('varchar', { name: 'status', nullable: true })
status!: NotificationStatus | null;
@Column('varchar', { name: 'channel' })
channel!: NotificationChannel;
@Column('uuid', { name: 'user_id', nullable: true })
userId!: string;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user!: User;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}
@@ -0,0 +1,3 @@
export * from './notification-channel.enum';
export * from './notification-scope.enum';
export * from './notification-status.enum';
@@ -0,0 +1,5 @@
export enum NotificationChannel {
EMAIL = 'EMAIL',
SMS = 'SMS',
PUSH = 'PUSH',
}
@@ -0,0 +1,5 @@
export enum NotificationScope {
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
}
@@ -0,0 +1,4 @@
export enum NotificationStatus {
READ = 'READ',
UNREAD = 'UNREAD',
}
@@ -0,0 +1 @@
export * from './notification-page-meta.interface';
@@ -0,0 +1,5 @@
import { IPageMeta } from '~/core/dtos';
export interface INotificationPageMeta extends IPageMeta {
unreadCount: number;
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '~/auth/auth.module';
import { NotificationsController } from './controllers';
import { Notification } from './entities';
import { NotificationsRepository } from './repositories';
import { FirebaseService } from './services/firebase.service';
import { NotificationsService } from './services/notifications.service';
@Module({
imports: [TypeOrmModule.forFeature([Notification]), AuthModule],
providers: [NotificationsService, FirebaseService, NotificationsRepository],
exports: [NotificationsService],
controllers: [NotificationsController],
})
export class NotificationModule {}
@@ -0,0 +1 @@
export * from './notifications.repository';
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { Notification } from '../entities';
import { NotificationChannel, NotificationStatus } from '../enums';
const ONE = 1;
@Injectable()
export class NotificationsRepository {
constructor(@InjectRepository(Notification) private readonly notificationsRepository: Repository<Notification>) {}
getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
const readQuery = this.notificationsRepository.createQueryBuilder('notification');
readQuery.where('notification.userId = :userId', { userId });
readQuery.andWhere('notification.channel = :channel', { channel: NotificationChannel.PUSH });
readQuery.orderBy('notification.createdAt', 'DESC');
readQuery.skip((pageOptionsDto.page - ONE) * pageOptionsDto.size);
readQuery.take(pageOptionsDto.size);
return readQuery.getManyAndCount();
}
getUnreadNotificationsCount(userId: string) {
return this.notificationsRepository.count({ where: { userId, status: NotificationStatus.UNREAD } });
}
markAsRead(userId: string) {
return this.notificationsRepository.update(
{ userId, status: NotificationStatus.UNREAD },
{ status: NotificationStatus.READ },
);
}
createNotification(notification: Partial<Notification>) {
return this.notificationsRepository.save(notification);
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
constructor(private readonly configService: ConfigService) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
}
sendNotification(tokens: string | string[], title: string, body: string) {
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
admin.messaging().sendEachForMulticast(message);
}
}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { DeviceService } from '~/auth/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { Notification } from '../entities';
import { NotificationsRepository } from '../repositories';
import { FirebaseService } from './firebase.service';
@Injectable()
export class NotificationsService {
constructor(
private readonly deviceService: DeviceService,
private readonly firebaseService: FirebaseService,
private readonly notificationRepository: NotificationsRepository,
) {}
async sendPushNotification(userId: string, title: string, body: string) {
// Get the device tokens for the user
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
return;
}
// Send the notification
return this.firebaseService.sendNotification(tokens, title, body);
}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
const [[notifications, count], unreadCount] = await Promise.all([
this.notificationRepository.getNotifications(userId, pageOptionsDto),
this.notificationRepository.getUnreadNotificationsCount(userId),
]);
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
return this.notificationRepository.createNotification(notification);
}
markAsRead(userId: string) {
return this.notificationRepository.markAsRead(userId);
}
}