Skip to content

Instantly share code, notes, and snippets.

@devhammed
Last active August 22, 2025 19:44
Show Gist options
  • Select an option

  • Save devhammed/30bd4155b17489bdaff8c32dc042178f to your computer and use it in GitHub Desktop.

Select an option

Save devhammed/30bd4155b17489bdaff8c32dc042178f to your computer and use it in GitHub Desktop.
Laravel-like Notification Service for Nest.js
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>&nbsp;</td></tr>
<tr>
<td>&nbsp;</td>
<td>
<img src="data:image/png;base64,${logoBase64}" alt="${this.appConfig.name}" width="60" height="60" class="logo" />
</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</td></tr>
</table>
</div>
</div>
</td>
<td>&nbsp;</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');
}
}
import { NotificationRoute } from '@/common/services/notification.service';
export interface HasNotificationRoutes {
notificationRoutes(): NotificationRoute[];
}
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;
}
export interface NotificationMetadata {
orderId?: string;
}
export enum NotificaionType {
ORDER_SHIPPED = 'order_shipped',
}
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()}
`,
},
];
}
}
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;
}
}
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;
}
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;
}),
);
}
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