Last active
August 22, 2025 19:44
-
-
Save devhammed/30bd4155b17489bdaff8c32dc042178f to your computer and use it in GitHub Desktop.
Laravel-like Notification Service for Nest.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import nodemailer from 'nodemailer'; | |
| import { FindOptionsWhere, IsNull, Like, Not, Repository } from 'typeorm'; | |
| import { User } from '@/user/entities/user.entity'; | |
| import { Notification } from '@/common/entities/notification.entity'; | |
| import { InjectRepository } from '@nestjs/typeorm'; | |
| import { | |
| forwardRef, | |
| Inject, | |
| Injectable, | |
| NotFoundException, | |
| } from '@nestjs/common'; | |
| import { Marked, Tokens } from 'marked'; | |
| import { | |
| notificationConfig, | |
| NotificationConfig, | |
| } from '@/common/config/notification.config'; | |
| import { Arr } from '@/common/helpers/arr.helper'; | |
| import { | |
| ApplicationConfig, | |
| applicationConfig, | |
| } from '@/common/config/application.config'; | |
| import { NotificationGateway } from '@/common/gateways/notification.gateway'; | |
| import { UpdateNotificationDto } from '@/common/dtos/update-notification.dto'; | |
| import { GetNotificationsQueryDto } from '@/common/dtos/get-notifications-query.dto'; | |
| import { join } from 'node:path'; | |
| import { readFile } from 'node:fs/promises'; | |
| import { QueueJobName } from '@/common/enums/queue-job-name.enum'; | |
| import { NotificationType } from '@/common/enums/notification-type.enum'; | |
| import { NotificationMetadata } from '@/common/interfaces/notification-metadata.interface'; | |
| import { QueueService } from '@/common/services/queue.service'; | |
| import { HasNotificationRoutes } from '@/common/interfaces/has-notification-routes.interface'; | |
| export type NotificationRoute = | |
| | { | |
| via: 'mail'; | |
| to: User | User['email'] | { name: string; address: string }; | |
| subject: string; | |
| body: string | { html: string; text: string }; | |
| } | |
| | { | |
| via: 'sms'; | |
| to: User | User['phone']; | |
| body: string; | |
| } | |
| | { | |
| via: 'websocket'; | |
| to: User | User['id']; | |
| event: string; | |
| data: Record<string, unknown>; | |
| } | |
| | { | |
| via: 'database'; | |
| to: User | User['id']; | |
| title: string; | |
| type: NotificationType; | |
| body: string; | |
| metadata: NotificationMetadata; | |
| }; | |
| @Injectable() | |
| export class NotificationService { | |
| private readonly mailTransporter: nodemailer.Transporter; | |
| private readonly marked: Marked; | |
| constructor( | |
| @Inject(notificationConfig.KEY) | |
| private readonly config: NotificationConfig, | |
| @Inject(applicationConfig.KEY) | |
| private readonly appConfig: ApplicationConfig, | |
| @InjectRepository(Notification) | |
| private readonly notificationRepository: Repository<Notification>, | |
| @Inject(forwardRef(() => QueueService)) | |
| private readonly queueService: QueueService, | |
| private readonly notificationGateway: NotificationGateway, | |
| ) { | |
| this.mailTransporter = nodemailer.createTransport(this.config.mail); | |
| this.marked = new Marked(); | |
| this.marked.use({ | |
| renderer: { | |
| link({ href, title, text }: Tokens.Link): string { | |
| // Button Link e.g. [[View order]](https://example.com) | |
| if (text.startsWith('[') && text.endsWith(']')) { | |
| text = text.slice(1, -1); | |
| return ` | |
| <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary"> | |
| <tbody> | |
| <tr> | |
| <td align="left"> | |
| <table role="presentation" border="0" cellpadding="0" cellspacing="0"> | |
| <tbody> | |
| <tr> | |
| <td><a ${title ? `title="${title}"` : ''} href="${href}" target="_blank">${text}</a></td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| `; | |
| } | |
| // Normal Link e.g. [email us](mailto:hello@example.com) | |
| return `<a ${title ? `title="${title}"` : ''} href="${href}" target="_blank">${text}</a>`; | |
| }, | |
| }, | |
| }); | |
| } | |
| async getUserNotificationsAndCount( | |
| userId: User['id'], | |
| query: GetNotificationsQueryDto, | |
| ): Promise<[Notification[], number]> { | |
| const { read, type, limit, skip, search } = query; | |
| const readAt = | |
| typeof read === 'boolean' ? (read ? Not(IsNull()) : IsNull()) : undefined; | |
| const wheres = <FindOptionsWhere<Notification>[]>[]; | |
| if (search) { | |
| wheres.push({ | |
| userId, | |
| type, | |
| readAt, | |
| title: Like(`%${search}%`), | |
| }); | |
| wheres.push({ | |
| userId, | |
| type, | |
| readAt, | |
| body: Like(`%${search}%`), | |
| }); | |
| } else { | |
| wheres.push({ | |
| userId, | |
| type, | |
| readAt, | |
| }); | |
| } | |
| return await this.notificationRepository.findAndCount({ | |
| skip, | |
| take: limit, | |
| where: wheres, | |
| }); | |
| } | |
| async getUserNotification( | |
| userId: User['id'], | |
| id: Notification['id'], | |
| ): Promise<Notification> { | |
| const notification = await this.notificationRepository.findOne({ | |
| where: { | |
| id, | |
| userId, | |
| }, | |
| }); | |
| if (!notification) { | |
| throw new NotFoundException('Notification not found.'); | |
| } | |
| return notification; | |
| } | |
| async updateUserNotification( | |
| userId: User['id'], | |
| id: Notification['id'], | |
| data: UpdateNotificationDto, | |
| ): Promise<Notification> { | |
| const notification = await this.getUserNotification(userId, id); | |
| Object.assign(notification, { | |
| readAt: data.read ? new Date() : null, | |
| }); | |
| await this.notificationRepository.save(notification); | |
| return notification; | |
| } | |
| async deleteUserNotification( | |
| userId: User['id'], | |
| id: Notification['id'], | |
| ): Promise<void> { | |
| await this.notificationRepository.delete({ | |
| id, | |
| userId, | |
| }); | |
| } | |
| async clearUserNotifications(userId: User['id']): Promise<void> { | |
| await this.notificationRepository.delete({ | |
| userId, | |
| }); | |
| } | |
| async send( | |
| notification: | |
| | HasNotificationRoutes | |
| | NotificationRoute | |
| | NotificationRoute[], | |
| ): Promise<string | string[]> { | |
| const routeOrRoutes = this.isHasNotificationRoutes(notification) | |
| ? notification.notificationRoutes() | |
| : notification; | |
| const promises = Arr.wrap(routeOrRoutes).map(async (route) => { | |
| if (route.to instanceof User) { | |
| if (route.via === 'mail') { | |
| route.to = { | |
| name: route.to.name, | |
| address: route.to.email, | |
| }; | |
| } else if (route.via === 'sms') { | |
| route.to = route.to.phone; | |
| } else { | |
| route.to = route.to.id; | |
| } | |
| } | |
| return await this.queueService.add({ | |
| name: QueueJobName.SEND_NOTIFICATION, | |
| data: route, | |
| }); | |
| }); | |
| const ids = await Promise.all(promises); | |
| return Arr.is(routeOrRoutes) ? ids : ids[0]; | |
| } | |
| async sendNow( | |
| notification: | |
| | HasNotificationRoutes | |
| | NotificationRoute | |
| | NotificationRoute[], | |
| ): Promise<string | string[]> { | |
| const routeOrRoutes = this.isHasNotificationRoutes(notification) | |
| ? notification.notificationRoutes() | |
| : notification; | |
| const promises = Arr.wrap(routeOrRoutes).map(async (route) => { | |
| switch (route.via) { | |
| case 'mail': { | |
| const { html, text } = | |
| typeof route.body === 'string' | |
| ? await this.renderMarkdown(route.body) | |
| : route.body; | |
| const to = | |
| route.to instanceof User | |
| ? { | |
| name: route.to.name, | |
| address: route.to.email, | |
| } | |
| : route.to; | |
| const logoBase64 = await readFile( | |
| join(__dirname, '..', '..', '..', 'public', 'images', 'logo.png'), | |
| 'base64', | |
| ); | |
| const info = (await this.mailTransporter.sendMail({ | |
| to, | |
| html: ` | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> | |
| <title>${route.subject}</title> | |
| <style media="all" type="text/css"> | |
| body { | |
| font-family: Helvetica, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| font-size: 16px; | |
| line-height: 1.3; | |
| -ms-text-size-adjust: 100%; | |
| -webkit-text-size-adjust: 100%; | |
| } | |
| table { | |
| border-collapse: separate; | |
| mso-table-lspace: 0pt; | |
| mso-table-rspace: 0pt; | |
| width: 100%; | |
| } | |
| table td { | |
| font-family: Helvetica, sans-serif; | |
| font-size: 16px; | |
| vertical-align: top; | |
| } | |
| body { | |
| background-color: #f4f5f6; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .body { | |
| background-color: #f4f5f6; | |
| width: 100%; | |
| } | |
| .container { | |
| margin: 0 auto !important; | |
| max-width: 600px; | |
| padding: 0; | |
| padding-top: 24px; | |
| width: 600px; | |
| } | |
| .content { | |
| box-sizing: border-box; | |
| display: block; | |
| margin: 0 auto; | |
| max-width: 600px; | |
| padding: 0; | |
| } | |
| .main { | |
| background: #ffffff; | |
| border: 1px solid #eaebed; | |
| border-radius: 16px; | |
| width: 100%; | |
| } | |
| .wrapper { | |
| box-sizing: border-box; | |
| padding: 24px; | |
| } | |
| .footer { | |
| clear: both; | |
| padding-top: 24px; | |
| text-align: center; | |
| width: 100%; | |
| } | |
| .footer td, | |
| .footer p, | |
| .footer span, | |
| .footer a { | |
| color: #9a9ea6; | |
| font-size: 16px; | |
| text-align: center; | |
| } | |
| p { | |
| font-family: Helvetica, sans-serif; | |
| font-size: 16px; | |
| font-weight: normal; | |
| margin: 0; | |
| margin-bottom: 16px; | |
| } | |
| a { | |
| color:#6600CC; | |
| text-decoration: underline; | |
| } | |
| .btn { | |
| box-sizing: border-box; | |
| min-width: 100% !important; | |
| width: 100%; | |
| } | |
| .btn > tbody > tr > td { | |
| padding-bottom: 16px; | |
| } | |
| .btn table { | |
| width: auto; | |
| } | |
| .btn table td { | |
| background-color: #ffffff; | |
| border-radius: 4px; | |
| text-align: center; | |
| } | |
| .btn a { | |
| background-color: #ffffff; | |
| border: solid 2px #6600CC; | |
| border-radius: 4px; | |
| box-sizing: border-box; | |
| color: #6600CC; | |
| cursor: pointer; | |
| display: inline-block; | |
| font-size: 16px; | |
| font-weight: bold; | |
| margin: 0; | |
| padding: 12px 24px; | |
| text-decoration: none; | |
| text-transform: capitalize; | |
| } | |
| .btn-primary table td { | |
| background-color: #6600CC; | |
| } | |
| .btn-primary a { | |
| background-color: #6600CC; | |
| border-color: #6600CC; | |
| color: #ffffff; | |
| } | |
| @media all { | |
| .btn-primary table td:hover { | |
| background-color: #6600CC !important; | |
| } | |
| .btn-primary a:hover { | |
| opacity: 0.8; | |
| background-color: #6600CC !important; | |
| border-color: #6600CC !important; | |
| } | |
| } | |
| .last { | |
| margin-bottom: 0; | |
| } | |
| .first { | |
| margin-top: 0; | |
| } | |
| .align-center { | |
| text-align: center; | |
| } | |
| .align-right { | |
| text-align: right; | |
| } | |
| .align-left { | |
| text-align: left; | |
| } | |
| .text-link { | |
| color: #6600CC !important; | |
| text-decoration: underline !important; | |
| } | |
| .clear { | |
| clear: both; | |
| } | |
| .mt0 { | |
| margin-top: 0; | |
| } | |
| .mb0 { | |
| margin-bottom: 0; | |
| } | |
| .logo { | |
| text-align: center; | |
| border: 0; | |
| outline: none; | |
| text-decoration: none; | |
| display: block; | |
| margin: 0 auto; | |
| } | |
| @media only screen and (max-width: 640px) { | |
| .main p, | |
| .main td, | |
| .main span { | |
| font-size: 16px !important; | |
| } | |
| .wrapper { | |
| padding: 8px !important; | |
| } | |
| .content { | |
| padding: 0 !important; | |
| } | |
| .container { | |
| padding: 0 !important; | |
| padding-top: 8px !important; | |
| width: 100% !important; | |
| } | |
| .main { | |
| border-left-width: 0 !important; | |
| border-radius: 0 !important; | |
| border-right-width: 0 !important; | |
| } | |
| .btn table { | |
| max-width: 100% !important; | |
| width: 100% !important; | |
| } | |
| .btn a { | |
| font-size: 16px !important; | |
| max-width: 100% !important; | |
| width: 100% !important; | |
| } | |
| } | |
| @media all { | |
| .ExternalClass { | |
| width: 100%; | |
| } | |
| .ExternalClass, | |
| .ExternalClass p, | |
| .ExternalClass span, | |
| .ExternalClass font, | |
| .ExternalClass td, | |
| .ExternalClass div { | |
| line-height: 100%; | |
| } | |
| .apple-link a { | |
| color: inherit !important; | |
| font-family: inherit !important; | |
| font-size: inherit !important; | |
| font-weight: inherit !important; | |
| line-height: inherit !important; | |
| text-decoration: none !important; | |
| } | |
| #MessageViewBody a { | |
| color: inherit; | |
| text-decoration: none; | |
| font-size: inherit; | |
| font-family: inherit; | |
| font-weight: inherit; | |
| line-height: inherit; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"> | |
| <tr><td> </td></tr> | |
| <tr> | |
| <td> </td> | |
| <td> | |
| <img src="data:image/png;base64,${logoBase64}" alt="${this.appConfig.name}" width="60" height="60" class="logo" /> | |
| </td> | |
| <td> </td> | |
| </tr> | |
| <tr> | |
| <td> </td> | |
| <td class="container"> | |
| <div class="content"> | |
| <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main"> | |
| <tr> | |
| <td class="wrapper"> | |
| ${html} | |
| </td> | |
| </tr> | |
| </table> | |
| <div class="footer"> | |
| <table role="presentation" border="0" cellpadding="0" cellspacing="0"> | |
| <tr> | |
| <td class="content-block"> | |
| <span class="apple-link">${this.appConfig.name}</span> | |
| </td> | |
| </tr> | |
| <tr><td> </td></tr> | |
| </table> | |
| </div> | |
| </div> | |
| </td> | |
| <td> </td> | |
| </tr> | |
| </table> | |
| </body> | |
| </html> | |
| `, | |
| text, | |
| subject: route.subject, | |
| from: this.config.mail.from, | |
| })) as { messageId: string }; | |
| return info.messageId; | |
| } | |
| case 'sms': { | |
| const phone = route.to instanceof User ? route.to.phone : route.to; | |
| if (!phone) { | |
| throw new Error('User phone number not found.'); | |
| } | |
| console.log('sending sms to ', phone, ' with body ', route.body); | |
| return phone; | |
| } | |
| case 'websocket': { | |
| const userId = route.to instanceof User ? route.to.id : route.to; | |
| return this.notificationGateway.broadcastTo( | |
| userId, | |
| route.event, | |
| route.data, | |
| ); | |
| } | |
| case 'database': { | |
| const userId = route.to instanceof User ? route.to.id : route.to; | |
| const notification = this.notificationRepository.create({ | |
| userId, | |
| title: route.title, | |
| type: route.type, | |
| body: route.body, | |
| metadata: route.metadata, | |
| }); | |
| await this.notificationRepository.save(notification); | |
| return notification.id; | |
| } | |
| } | |
| }); | |
| const ids = await Promise.all(promises); | |
| return Arr.is(routeOrRoutes) ? ids : ids[0]; | |
| } | |
| private isHasNotificationRoutes( | |
| notification: | |
| | HasNotificationRoutes | |
| | NotificationRoute | |
| | NotificationRoute[], | |
| ): notification is HasNotificationRoutes { | |
| return ( | |
| typeof notification === 'object' && | |
| 'notificationRoutes' in notification && | |
| typeof notification.notificationRoutes === 'function' | |
| ); | |
| } | |
| private async renderMarkdown( | |
| str: string, | |
| ): Promise<{ html: string; text: string }> { | |
| const text = this.stripIndent(str); | |
| const html = await this.marked.parse(text); | |
| return { html, text }; | |
| } | |
| private stripIndent(str: string): string { | |
| const lines = str.split('\n'); | |
| while (lines.length > 0 && lines[0].trim() === '') { | |
| lines.shift(); | |
| } | |
| while (lines.length > 0 && lines[lines.length - 1].trim() === '') { | |
| lines.pop(); | |
| } | |
| const indents = lines | |
| .filter((line) => line.trim().length > 0) | |
| .map((line) => line.match(/^(\s*)/)![0].length); | |
| const minIndent = indents.length > 0 ? Math.min(...indents) : 0; | |
| return lines.map((line) => line.slice(minIndent)).join('\n'); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { NotificationRoute } from '@/common/services/notification.service'; | |
| export interface HasNotificationRoutes { | |
| notificationRoutes(): NotificationRoute[]; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { User } from '@/user/entities/user.entity'; | |
| import { ApiProperty } from '@nestjs/swagger'; | |
| import { NotificationType } from '@/common/enums/notification-type.enum'; | |
| import { | |
| Column, | |
| CreateDateColumn, | |
| Entity, | |
| ManyToOne, | |
| PrimaryGeneratedColumn, | |
| UpdateDateColumn, | |
| } from 'typeorm'; | |
| import { NotificationMetadata } from '@/common/interfaces/notification-metadata.interface'; | |
| @Entity() | |
| export class Notification { | |
| @ApiProperty({ | |
| description: 'The unique identifier of the notification.', | |
| example: '123e4567-e89b-12d3-a456-426614174000', | |
| format: 'uuid', | |
| }) | |
| @PrimaryGeneratedColumn('uuid') | |
| id: string; | |
| @ApiProperty({ | |
| description: 'The unique identifier of the user.', | |
| example: '123e4567-e89b-12d3-a456-426614174000', | |
| format: 'uuid', | |
| }) | |
| @Column() | |
| userId: string; | |
| @ApiProperty({ | |
| description: 'The type of the notification.', | |
| enum: NotificationType, | |
| example: NotificationType.ORDER_SHIPPED, | |
| }) | |
| @Column({ | |
| type: 'varchar', | |
| }) | |
| type: NotificationType; | |
| @ApiProperty({ | |
| description: 'The title of the notification.', | |
| example: 'Order Shipped', | |
| }) | |
| @Column({ | |
| type: 'varchar', | |
| }) | |
| title: string; | |
| @ApiProperty({ | |
| description: 'The body of the notification.', | |
| example: 'Your order of Lambo has been shipped!', | |
| }) | |
| @Column({ | |
| type: 'varchar', | |
| }) | |
| body: string; | |
| @ApiProperty({ | |
| description: 'The metadata of the notification.', | |
| example: { | |
| orderId: '123e4567-e89b-12d3-a456-426614174000', | |
| }, | |
| }) | |
| @Column({ | |
| type: 'jsonb', | |
| default: {}, | |
| }) | |
| metadata: NotificationMetadata; | |
| @ManyToOne(() => User, (user) => user.notifications, { | |
| onUpdate: 'CASCADE', | |
| onDelete: 'CASCADE', | |
| }) | |
| user?: User; | |
| @ApiProperty({ | |
| description: 'When the notification was read.', | |
| example: '2021-01-01T00:00:00.000Z', | |
| nullable: true, | |
| format: 'date-time', | |
| }) | |
| @Column({ | |
| type: 'timestamp', | |
| nullable: true, | |
| }) | |
| readAt: Date | null; | |
| @ApiProperty({ | |
| description: 'When the notification was created.', | |
| example: '2021-01-01T00:00:00.000Z', | |
| format: 'date-time', | |
| }) | |
| @CreateDateColumn({ type: 'timestamp' }) | |
| created_at: Date; | |
| @ApiProperty({ | |
| description: 'When the notification was last updated.', | |
| example: '2021-01-01T00:00:00.000Z', | |
| format: 'date-time', | |
| }) | |
| @UpdateDateColumn({ type: 'timestamp' }) | |
| updated_at: Date; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface NotificationMetadata { | |
| orderId?: string; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export enum NotificaionType { | |
| ORDER_SHIPPED = 'order_shipped', | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { HasNotificationRoutes } from '@/common/interfaces/has-notification-routes.interface'; | |
| import { Order } from '@/shop/entities/order.entity'; | |
| import { NotificationRoute } from '@/common/services/notification.service'; | |
| import { NotificationType } from '@/common/enums/notification-type.enum'; | |
| import { Money } from '@/common/helpers/money.helper'; | |
| export class OrderShipped implements HasNotificationRoutes { | |
| private title: string = 'Order Shipped'; | |
| constructor(private readonly order: Order) {} | |
| notificationRoutes(): NotificationRoute[] { | |
| const order = this.order; | |
| return [ | |
| { | |
| via: 'database', | |
| to: order.user!, | |
| title: this.title, | |
| type: NotificationType.ORDER_SHIPPED, | |
| body: `Your order of ${order.product!.name} has been shipped.`, | |
| metadata: { | |
| orderId: order.id, | |
| }, | |
| }, | |
| { | |
| via: 'mail', | |
| to: order.user!, | |
| subject: this.title, | |
| body: ` | |
| ## Hello ${order.user!.name}, | |
| Your order of ${order.product!.name} has been shipped. | |
| ID: ${order.id} | |
| Amount: ${order.amount} | |
| Fee: ${order.deliveryFee} | |
| Date: ${order.createdAt.toUTCString()} | |
| `, | |
| }, | |
| ]; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { NotificationType } from '@/common/enums/notification-type.enum'; | |
| import { IsEnum, IsOptional } from 'class-validator'; | |
| import { ApiPropertyOptional } from '@nestjs/swagger'; | |
| import { IsProperBoolean } from '@/common/decorators/is-proper-boolean.decorator'; | |
| export class GetNotificationsQueryDto { | |
| @IsOptional() | |
| @IsInt({ message: 'page must be an integer' }) | |
| @Min(1, { message: 'page must be at least 1' }) | |
| @ApiPropertyOptional({ | |
| description: 'Page number (min: 1).', | |
| example: 1, | |
| minimum: 1, | |
| default: 1, | |
| }) | |
| page: number = 1; | |
| @IsOptional() | |
| @IsInt({ message: 'limit must be an integer' }) | |
| @Min(1, { message: 'limit must be at least 1' }) | |
| @Max(100, { message: 'limit must not exceed 100' }) | |
| @ApiPropertyOptional({ | |
| description: 'Number of items per page (max: 100).', | |
| example: 10, | |
| minimum: 1, | |
| maximum: 100, | |
| default: 10, | |
| }) | |
| limit: number = 10; | |
| @IsOptional() | |
| @IsString() | |
| @Transform( | |
| ({ value }: { value?: string }) => value?.toLowerCase().trim() ?? '', | |
| ) | |
| @ApiPropertyOptional({ | |
| description: 'Search query (e.g., "search term").', | |
| }) | |
| search?: string; | |
| @IsOptional() | |
| @IsEnum(NotificationType) | |
| @ApiPropertyOptional({ | |
| description: 'Filter notifications by type.', | |
| enum: NotificationType, | |
| example: NotificationType.DEPOSIT_SUCCESS, | |
| nullable: true, | |
| }) | |
| type?: NotificationType; | |
| @IsOptional() | |
| @IsProperBoolean() | |
| @ApiPropertyOptional({ | |
| description: 'Filter notifications by read status.', | |
| example: true, | |
| nullable: true, | |
| }) | |
| read?: boolean; | |
| get skip(): number { | |
| return (this.page - 1) * this.limit; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { IsNotEmpty } from 'class-validator'; | |
| import { ApiProperty } from '@nestjs/swagger'; | |
| import { IsProperBoolean } from '@/common/decorators/is-proper-boolean.decorator'; | |
| export class UpdateNotificationDto { | |
| @IsProperBoolean() | |
| @IsNotEmpty() | |
| @ApiProperty({ | |
| description: 'Mark the notification as read or not.', | |
| example: true, | |
| }) | |
| read: boolean; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Transform } from 'class-transformer'; | |
| import { applyDecorators } from '@nestjs/common'; | |
| import { IsBoolean } from 'class-validator'; | |
| export function IsProperBoolean() { | |
| return applyDecorators( | |
| IsBoolean(), | |
| Transform(({ obj, key }) => { | |
| const record = obj as Record<string, unknown>; | |
| const raw = record[key]; | |
| if (raw === 'true') { | |
| return true; | |
| } | |
| if (raw === 'false') { | |
| return false; | |
| } | |
| return raw as boolean | undefined; | |
| }), | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export class Arr { | |
| static is<T>(value: unknown): value is T[] { | |
| if (typeof Array.isArray === 'function') { | |
| return Array.isArray(value); | |
| } | |
| return Object.prototype.toString.call(value) === '[object Array]'; | |
| } | |
| static wrap<T>(value: T | T[] | null): T[] { | |
| if (value === null) { | |
| return []; | |
| } | |
| return this.is(value) ? value : [value]; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment