Skip to content

Commit

Permalink
feat(server): support notification (#1683)
Browse files Browse the repository at this point in the history
* feat(server): support notification

* add third-party notification center
  • Loading branch information
0fatal committed Feb 23, 2024
1 parent d52a578 commit d9f527b
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 1 deletion.
5 changes: 4 additions & 1 deletion server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { APP_INTERCEPTOR } from '@nestjs/core'
import { AppInterceptor } from './app.interceptor'
import { InterceptorModule } from './interceptor/interceptor.module'
import { MonitorModule } from './monitor/monitor.module'
import { NotificationModule } from './notification/notification.module'
import { ServerConfig } from './constants'
import { EventEmitterModule } from '@nestjs/event-emitter'

@Module({
Expand All @@ -56,7 +58,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'
AccountModule,
SettingModule,
I18nModule.forRoot({
fallbackLanguage: 'en',
fallbackLanguage: ServerConfig.DEFAULT_LANGUAGE,
loaderOptions: {
path: path.join(__dirname, '/i18n/'),
watch: false,
Expand All @@ -77,6 +79,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'
GroupModule,
InterceptorModule,
MonitorModule,
NotificationModule,
EventEmitterModule.forRoot(),
],
controllers: [AppController],
Expand Down
12 changes: 12 additions & 0 deletions server/src/billing/billing-payment-task.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import * as assert from 'assert'
import { Account } from 'src/account/entities/account'
import { AccountTransaction } from 'src/account/entities/account-transaction'
import Decimal from 'decimal.js'
import { NotificationService } from 'src/notification/notification.service'
import { NotificationType } from 'src/notification/notification-type'

@Injectable()
export class BillingPaymentTaskService {
private readonly logger = new Logger(BillingPaymentTaskService.name)
private readonly lockTimeout = 60 * 60 // in second
private lastTick = TASK_LOCK_INIT_TIME

constructor(private readonly notificationService: NotificationService) {}

@Cron(CronExpression.EVERY_MINUTE)
async tick() {
if (ServerConfig.DISABLED_BILLING_PAYMENT_TASK) {
Expand Down Expand Up @@ -142,6 +146,14 @@ export class BillingPaymentTaskService {
this.logger.warn(
`Application ${billing.appid} stopped due to insufficient balance`,
)

this.notificationService.notify({
type: NotificationType.InsufficientBalance,
uid: account.createdBy,
payload: {
appid: billing.appid,
},
})
}
}
})
Expand Down
19 changes: 19 additions & 0 deletions server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@ dotenv.config({ path: '.env.local' })
dotenv.config()

export class ServerConfig {
static get DEFAULT_LANGUAGE() {
return process.env.DEFAULT_LANGUAGE || 'en'
}

static get DATABASE_URL() {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not defined')
}
return process.env.DATABASE_URL
}

static get NOTIFICATION_CENTER_URL() {
return process.env.NOTIFICATION_CENTER_URL
}

static get NOTIFICATION_CENTER_TOKEN() {
return process.env.NOTIFICATION_CENTER_TOKEN || ''
}

static get JWT_SECRET() {
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined')
Expand Down Expand Up @@ -75,6 +87,13 @@ export class ServerConfig {
return process.env.DISABLED_STORAGE_USAGE_LIMIT_TASK === 'true'
}

static get DISABLE_NOTIFICATION_TASK() {
if (!process.env.DISABLE_NOTIFICATION_TASK) {
return true
}
return process.env.DISABLE_NOTIFICATION_TASK === 'true'
}

static get APPID_LENGTH(): number {
return parseInt(process.env.APPID_LENGTH || '6')
}
Expand Down
6 changes: 6 additions & 0 deletions server/src/generated/i18n.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,11 @@ export type I18nTranslations = {
"notFound": string;
};
};
"notification": {
"InsufficientBalance": {
"title": string;
"content": string;
};
};
};
export type I18nPath = Path<I18nTranslations>;
6 changes: 6 additions & 0 deletions server/src/i18n/en/notification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"InsufficientBalance": {
"title": "Laf account is in arrears",
"content": "Your account is in arrears, the app {appid} will stop running, please recharge your account promptly!"
}
}
6 changes: 6 additions & 0 deletions server/src/i18n/zh-CN/notification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"InsufficientBalance": {
"title": "laf 账户欠费",
"content": "你的账户已欠费,应用 {appid} 将停止运行,请及时充值"
}
}
6 changes: 6 additions & 0 deletions server/src/i18n/zh/notification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"InsufficientBalance": {
"title": "laf 账户欠费",
"content": "你的账户已欠费,应用 {appid} 将停止运行,请及时充值"
}
}
36 changes: 36 additions & 0 deletions server/src/notification/entities/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger'
import { ObjectId } from 'mongodb'
import { NotificationType } from '../notification-type'

export enum NotificationState {
Pending = 'Pending',
Done = 'Done',
}

export class Notification {
@ApiProperty({ type: String })
_id?: ObjectId

@ApiProperty({ enum: NotificationType })
type: NotificationType

@ApiProperty()
title: string

@ApiProperty()
content: string

@ApiProperty({ enum: NotificationState })
state: NotificationState

@ApiProperty({ type: String })
target: ObjectId

lockedAt: Date

@ApiProperty()
createdAt: Date

@ApiProperty()
updatedAt: Date
}
99 changes: 99 additions & 0 deletions server/src/notification/notification-task.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Cron, CronExpression } from '@nestjs/schedule'
import { SystemDatabase } from 'src/system-database'
import { Notification, NotificationState } from './entities/notification'
import { HttpService } from '@nestjs/axios'
import { Injectable, Logger } from '@nestjs/common'
import { ServerConfig } from 'src/constants'
import { UserService } from 'src/user/user.service'

@Injectable()
export class NotificationTaskService {
private lockTimeout = 15 // in seconds
private readonly logger = new Logger(NotificationTaskService.name)

constructor(
private readonly httpService: HttpService,
private readonly userService: UserService,
) {}

@Cron(CronExpression.EVERY_30_SECONDS)
tick() {
if (ServerConfig.DISABLE_NOTIFICATION_TASK) return

this.handlePendingNotifications()
}

async handlePendingNotifications() {
const db = SystemDatabase.db

const res = await db
.collection<Notification>('Notification')
.findOneAndUpdate(
{
state: NotificationState.Pending,
lockedAt: {
$lt: new Date(Date.now() - this.lockTimeout * 1000),
},
},
{
$set: {
lockedAt: new Date(),
},
},
)

if (!res.value) return
const notification = res.value

const ok = await this.sendNotification(notification)
if (!ok) return

await db.collection<Notification>('Notification').updateOne(
{
_id: notification._id,
},
{
$set: {
state: NotificationState.Done,
updatedAt: new Date(),
},
},
)
}

async sendNotification(data: Notification) {
if (!ServerConfig.NOTIFICATION_CENTER_URL) {
return true
}

const user = await this.userService.findOneById(data.target)
if (!user) {
this.logger.error(`failed to send notification ${data._id}, no user`)
return true
}

if (!user.email) {
this.logger.log(`skip to send notification ${data._id} due to no channel`)
return true
}

const to = [user.email].join('|')

const res = await this.httpService.axiosRef.post(
ServerConfig.NOTIFICATION_CENTER_URL,
{
title: data.title,
description: data.content,
token: ServerConfig.NOTIFICATION_CENTER_TOKEN,
channel: 'notification',
async: true,
to: to,
},
)
if (!res.data.success) {
this.logger.error(`failed to send notification ${data._id}`, res.data)
return false
}
return true
}
}
3 changes: 3 additions & 0 deletions server/src/notification/notification-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum NotificationType {
InsufficientBalance = 'InsufficientBalance',
}
30 changes: 30 additions & 0 deletions server/src/notification/notification.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
import { ApiResponsePagination, ResponseUtil } from 'src/utils/response'
import { NotificationService } from './notification.service'
import { InjectUser } from 'src/utils/decorator'
import { User } from 'src/user/entities/user'
import { JwtAuthGuard } from 'src/authentication/jwt.auth.guard'
import { ApiOperation } from '@nestjs/swagger'
import { Notification } from './entities/notification'

@Controller('notification')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}

@ApiOperation({ summary: 'Get notification list' })
@ApiResponsePagination(Notification)
@UseGuards(JwtAuthGuard)
@Get('list')
async findAll(
@Query('page') page: number,
@Query('pageSize') pageSize: number,
@InjectUser() user: User,
) {
page = page ? Number(page) : 1
pageSize = pageSize ? Number(pageSize) : 10

const res = await this.notificationService.findAll(user, page, pageSize)

return ResponseUtil.ok(res)
}
}
15 changes: 15 additions & 0 deletions server/src/notification/notification.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Global, Module } from '@nestjs/common'
import { NotificationService } from './notification.service'
import { NotificationTaskService } from './notification-task.service'
import { NotificationController } from './notification.controller'
import { HttpModule } from '@nestjs/axios'
import { UserModule } from 'src/user/user.module'

@Global()
@Module({
providers: [NotificationService, NotificationTaskService],
exports: [NotificationService],
controllers: [NotificationController],
imports: [HttpModule, UserModule],
})
export class NotificationModule {}
73 changes: 73 additions & 0 deletions server/src/notification/notification.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common'
import { SystemDatabase } from 'src/system-database'
import type { User } from 'src/user/entities/user'
import { Notification, NotificationState } from './entities/notification'
import { TASK_LOCK_INIT_TIME } from 'src/constants'
import { I18nService } from 'nestjs-i18n'
import { NotificationType } from './notification-type'
import { ObjectId } from 'mongodb'

@Injectable()
export class NotificationService {
constructor(private readonly i18n: I18nService) {}

async notify(params: {
type: NotificationType
payload?: object
uid?: ObjectId
}) {
const { type, payload, uid } = params

const db = SystemDatabase.db
await db.collection<Notification>('Notification').insertOne({
title: this.getNotificationTitle(type, payload),
content: this.genNotificationContent(type, payload),
type,
target: uid,
state: NotificationState.Pending,
lockedAt: TASK_LOCK_INIT_TIME,
createdAt: new Date(),
updatedAt: new Date(),
})
}

getNotificationTitle(type: NotificationType, payload?: object) {
const title = this.i18n.t(`notification.${type}.title`, {
args: payload,
})
return title
}

genNotificationContent(type: NotificationType, payload?: object) {
const content = this.i18n.t(`notification.${type}.content`, {
args: payload,
})
return content
}

async findAll(user: User, page: number, pageSize: number) {
const db = SystemDatabase.db
const list = db
.collection<Notification>('Notification')
.find(
{ target: user._id },
{
skip: (page - 1) * pageSize,
limit: pageSize,
sort: { createdAt: -1 },
},
)
.toArray()

const total = await db
.collection<Notification>('Notification')
.countDocuments({ target: user._id })

return {
list,
total,
page,
pageSize,
}
}
}

0 comments on commit d9f527b

Please sign in to comment.