add notifications

This commit is contained in:
Sergey Krylov 2025-07-16 09:04:39 +03:00
parent 23caed10b6
commit 35df6780ec
14 changed files with 424 additions and 27 deletions

View File

@ -91,6 +91,9 @@ model User {
chatMessages ChatMessage[]
followers Follow[] @relation(name: "followers")
followings Follow[] @relation(name: "followings")
notifications Notification[]
notificationSettings NotificationSettings?
telegramId String? @default("telegram_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -123,10 +126,46 @@ model Token {
@@map("tokens")
}
model Notification {
id String @id @default(uuid())
text String
type NotificationType
isRead Boolean @default(false) @map("is_read")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notifications")
}
model NotificationSettings {
id String @id @default(uuid())
siteNotifications Boolean @default(true) @map("site_notifications")
telegramNotifications Boolean @default(true) @map("telegram_notifications")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notification_settings")
}
enum NotificationType {
STREAM_START
NEW_FOLLOWER
NEW_SPONSORSHIP
ENABLE_TWO_FACTOR
VERIFIED_CHANNEL
@@map("notification_types")
}
enum TokenType {
EMAIL_VERIFY
PASSWORD_RESET
DEACTIVATE_ACCOUNT
TELEGRAM_AUTH
@@map("token_types")
}

View File

@ -15,6 +15,7 @@ import { FollowModule } from '@/src/module/follow/follow.module'
import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module'
import { MailModule } from '@/src/module/libs/mail/mail.module'
import { StorageModule } from '@/src/module/libs/storage/storage.module'
import { NotificationModule } from '@/src/module/notification/notification.module'
import { IngressModule } from '@/src/module/stream/ingress/ingress.module'
import { StreamModule } from '@/src/module/stream/stream.module'
import { WebhookModule } from '@/src/module/webhook/webhook.module'
@ -62,6 +63,7 @@ import { RedisModule } from './redis/redis.module'
ChatModule,
FollowModule,
ChannelModule,
NotificationModule,
],
})
export class CoreModule {}

View File

@ -28,6 +28,16 @@ input ChangeEmailInput {
email: String!
}
input ChangeNotificationSettingsInput {
siteNotifications: Boolean!
telegramNotifications: Boolean!
}
type ChangeNotificationsSettingsResponse {
notificationSettings: NotificationSettingsModel!
telegramAuthToken: String
}
input ChangePasswordInput {
newPassword: String!
oldPassword: String!
@ -122,6 +132,7 @@ input LoginInput {
type Mutation {
changeChatSettings(data: ChangeChatSettingsInput!): StreamModel!
changeEmail(data: ChangeEmailInput!): UserModel!
changeNotificationSettigs(data: ChangeNotificationSettingsInput!): ChangeNotificationsSettingsResponse!
changePassword(data: ChangePasswordInput!): UserModel!
changeProfileAvatar(avatar: Upload!): Boolean!
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
@ -157,6 +168,35 @@ input NewPasswordInput {
token: String!
}
type NotificationModel {
createdAt: DateTime!
id: String!
isRead: Boolean!
text: String!
type: NotificationType!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
type NotificationSettingsModel {
createdAt: DateTime!
id: String!
siteNotifications: Boolean!
telegramNotifications: Boolean!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
enum NotificationType {
ENABLE_TWO_FACTOR
NEW_FOLLOWER
NEW_SPONSORSHIP
STREAM_START
VERIFIED_CHANNEL
}
type Query {
findAllCategories: [CategoryModel!]!
findAllStreams(filters: FilterInput!): [StreamModel!]!
@ -167,12 +207,14 @@ type Query {
findMessagesByStream(streamId: String!): [ChatMessageModel!]!
findMyFollowers: [FollowModel!]!
findMyFollowings: [FollowModel!]!
findNotificationByUser: [NotificationModel!]!
findProfile: UserModel!
findRandomCategories: [CategoryModel!]!
findRandomStreams: [StreamModel!]!
findRecommendedChannels: [UserModel!]!
findSessionsByUser: [SessionModel!]!
findSocialLinks: [SocialLinkModel!]!
findUnreadNotificationsCount: Float!
generateTotpSecret: TotpModel!
}
@ -265,9 +307,12 @@ type UserModel {
isTotpEnabled: Boolean!
isVerified: Boolean!
name: String!
notification: [NotificationModel!]!
notificationSettings: NotificationSettingsModel!
password: String!
socialLink: [SocialLinkModel!]!
stream: StreamModel!
telegramId: String
totpSecret: String
updatedAt: DateTime!
}

View File

@ -1,5 +1,7 @@
import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'
import { FollowModel } from '@/src/module/follow/model/follow.model'
import { NotificationSettingsModel } from '@/src/module/notification/models/notification-settings.model'
import { NotificationModel } from '@/src/module/notification/models/notification.model'
import { StreamModel } from '@/src/module/stream/models/stream.model'
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { User } from '@prisma/generated'
@ -57,6 +59,15 @@ export class UserModel implements User {
@Field(() => [FollowModel])
followings: FollowModel[]
@Field(() => String, { nullable: true })
telegramId: string
@Field(() => [NotificationModel])
notification: NotificationModel[]
@Field(() => NotificationSettingsModel)
notificationSettings: NotificationSettingsModel
@Field(() => Date)
createdAt: Date

View File

@ -1,8 +1,9 @@
import { NotificationService } from '@/src/module/notification/notification.service'
import { Module } from '@nestjs/common'
import { FollowService } from './follow.service'
import { FollowResolver } from './follow.resolver'
@Module({
providers: [FollowResolver, FollowService],
providers: [FollowResolver, FollowService, NotificationService],
})
export class FollowModule {}

View File

@ -1,4 +1,5 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { NotificationService } from '@/src/module/notification/notification.service'
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { User } from '@prisma/generated'
@ -6,6 +7,7 @@ import { User } from '@prisma/generated'
export class FollowService {
constructor(
private readonly prismaService: PrismaService,
private readonly notificationService: NotificationService,
) {
}
@ -48,16 +50,25 @@ export class FollowService {
throw new ConflictException('Подписка уже существует')
}
return this.prismaService.follow.create({
const follow = await this.prismaService.follow.create({
data: {
followerId: user.id,
followingId: channel.id,
},
include: {
following: true,
following: {
include: {
notificationSettings: true,
},
},
follower: true,
},
})
if (follow.following.notificationSettings?.siteNotifications) {
await this.notificationService.createNewFollowing(follow.following.id, follow.follower)
}
return follow
}
public async unfollow(user: User, channelId: string) {

View File

@ -0,0 +1,13 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsBoolean } from 'class-validator'
@InputType()
export class ChangeNotificationSettingsInput {
@Field(() => Boolean)
@IsBoolean()
public siteNotifications: boolean
@Field(() => Boolean)
@IsBoolean()
public telegramNotifications: boolean
}

View File

@ -0,0 +1,37 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Field, ObjectType } from '@nestjs/graphql'
import type { NotificationSettings } from '@/prisma/generated'
@ObjectType()
export class NotificationSettingsModel implements NotificationSettings {
@Field(() => String)
public id: string
@Field(() => Boolean)
public siteNotifications: boolean
@Field(() => Boolean)
public telegramNotifications: boolean
@Field(() => UserModel)
public user: UserModel
@Field(() => String)
public userId: string
@Field(() => Date)
public createdAt: Date
@Field(() => Date)
public updatedAt: Date
}
@ObjectType()
export class ChangeNotificationsSettingsResponse {
@Field(() => NotificationSettingsModel)
public notificationSettings: NotificationSettingsModel
@Field(() => String, { nullable: true })
public telegramAuthToken?: string
}

View File

@ -0,0 +1,36 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'
import { type Notification, NotificationType } from '@/prisma/generated'
import { UserModel } from '../../auth/account/models/user.model'
registerEnumType(NotificationType, {
name: 'NotificationType',
})
@ObjectType()
export class NotificationModel implements Notification {
@Field(() => String)
public id: string
@Field(() => String)
public text: string
@Field(() => NotificationType)
public type: NotificationType
@Field(() => Boolean)
public isRead: boolean
@Field(() => UserModel)
public user: UserModel
@Field(() => String)
public userId: string
@Field(() => Date)
public createdAt: Date
@Field(() => Date)
public updatedAt: Date
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { NotificationService } from './notification.service'
import { NotificationResolver } from './notification.resolver'
@Module({
providers: [NotificationResolver, NotificationService],
})
export class NotificationModule {}

View File

@ -0,0 +1,34 @@
import { ChangeNotificationSettingsInput } from '@/src/module/notification/inputs/change-notification-settings.input'
import { ChangeNotificationsSettingsResponse } from '@/src/module/notification/models/notification-settings.model'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated'
import { NotificationService } from './notification.service'
import { NotificationModel } from './models/notification.model'
@Resolver('Notification')
export class NotificationResolver {
constructor(private readonly notificationService: NotificationService) {}
@Authorization()
@Query(() => Number, { name: 'findUnreadNotificationsCount' })
public findUnreadCount(@Authorized() user: User) {
return this.notificationService.findUnreadCount(user)
}
@Authorization()
@Query(() => [NotificationModel], { name: 'findNotificationByUser' })
public findByUser(@Authorized() user: User) {
return this.notificationService.findByUser(user)
}
@Authorization()
@Mutation(() => ChangeNotificationsSettingsResponse, { name: 'changeNotificationSettigs' })
public async changeSettings(
@Authorized() user: User,
@Args('data') input: ChangeNotificationSettingsInput,
) {
return this.notificationService.changeSettings(user, input)
}
}

View File

@ -0,0 +1,131 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { ChangeNotificationSettingsInput } from '@/src/module/notification/inputs/change-notification-settings.input'
import { generateToken } from '@/src/shared/util/generate-token.util'
import { Injectable } from '@nestjs/common'
import { $Enums, User } from '@prisma/generated'
import TokenType = $Enums.TokenType
import NotificationType = $Enums.NotificationType
@Injectable()
export class NotificationService {
constructor(
private readonly prismaService: PrismaService,
) {
}
public async findUnreadCount(user: User) {
return this.prismaService.notification.count({
where: {
isRead: false,
userId: user.id,
},
})
}
public async findByUser(user: User) {
await this.prismaService.notification.updateMany({
where: {
isRead: false,
userId: user.id,
},
data: { isRead: true },
})
return this.prismaService.notification.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
})
}
public async changeSettings(user: User, input: ChangeNotificationSettingsInput) {
const { siteNotifications, telegramNotifications } = input
const notificationSetting = await this.prismaService.notificationSettings.upsert({
where: { userId: user.id },
create: {
siteNotifications,
telegramNotifications,
userId: user.id,
},
update: { siteNotifications, telegramNotifications },
include: { user: true },
})
if (notificationSetting.telegramNotifications && !notificationSetting.user.telegramId) {
const telegramAuthToken = await generateToken(
this.prismaService,
user,
TokenType.TELEGRAM_AUTH,
)
return {
notificationSetting,
token: telegramAuthToken.token,
}
}
if (!notificationSetting.telegramNotifications && notificationSetting.user.telegramId) {
await this.prismaService.user.update({
where: { id: user.id },
data: { telegramId: null },
})
return { notificationSetting }
}
return { notificationSetting }
}
public async createStreamStart(userId: string, channel: User) {
return this.prismaService.notification.create({
data: {
text: `<b className='font-medium'>Не пропустите!</b>
<p>Присоединяйтесь к стриму на канале <a href='/${channel.name}' className='font-semibold'>${channel.displayName}</a>.</p>`,
type: NotificationType.STREAM_START,
user: {
connect: {
id: userId,
},
},
},
})
}
public async createNewFollowing(userId: string, follower: User) {
return this.prismaService.notification.create({
data: {
text: `<b className='font-medium'>У вас новый подписчик!</b>
<p>Это пользователь <a href='/${follower.name}' className='font-semibold'>${follower.displayName}</a>.</p>`,
type: NotificationType.NEW_FOLLOWER,
user: {
connect: {
id: userId,
},
},
},
})
}
public async createEnableTwoFactor(userId: string) {
return this.prismaService.notification.create({
data: {
text: `<b className='font-medium'>Обеспечьте свою безопасность!</b>
<p>Включите двухфакторную аутентификацию в настройках вашего аккаунта, чтобы повысить уровень защиты.</p>`,
type: NotificationType.ENABLE_TWO_FACTOR,
userId,
},
})
}
public async createVerifyChannel(userId: string) {
return this.prismaService.notification.create({
data: {
text: `<b className='font-medium'>Поздравляем!</b>
<p>Ваш канал верифицирован, и теперь рядом с вашим каналом будет галочка.</p>`,
type: NotificationType.VERIFIED_CHANNEL,
userId,
},
})
}
}

View File

@ -1,3 +1,4 @@
import { NotificationService } from '@/src/module/notification/notification.service';
import { RawBodyMiddleware } from '@/src/shared/middlewares/raw-body.middleware'
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'
import { WebhookController } from './webhook.controller'
@ -5,7 +6,7 @@ import { WebhookService } from './webhook.service'
@Module({
controllers: [WebhookController],
providers: [WebhookService],
providers: [WebhookService, NotificationService],
})
export class WebhookModule {
public configure(consumer: MiddlewareConsumer) {

View File

@ -1,5 +1,6 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { LiveKitService } from '@/src/module/libs/livekit/livekit.service'
import { NotificationService } from '@/src/module/notification/notification.service'
import { Injectable } from '@nestjs/common'
@Injectable()
@ -7,6 +8,7 @@ export class WebhookService {
constructor(
private prismaService: PrismaService,
private liveKitService: LiveKitService,
private readonly notificationService: NotificationService,
) {
}
@ -14,17 +16,43 @@ export class WebhookService {
const event = this.liveKitService.webhook.receive(body, authorization, true)
if (event.event === 'ingress_started' && event.ingressInfo?.ingressId) {
await this.prismaService.stream.update({
const stream = await this.prismaService.stream.update({
where: { ingressId: event.ingressInfo.ingressId },
data: { isLive: true },
include: { user: true },
})
const followers = await this.prismaService.follow.findMany({
where: {
followingId: stream.user!.id,
follower: { isDeactivated: false },
},
include: {
follower: {
include: {
notificationSettings: true,
},
},
},
})
for (const follow of followers) {
const follower = follow.follower
if (follower.notificationSettings?.siteNotifications) {
await this.notificationService.createStreamStart(follower.id, stream.user!)
}
}
}
if (event.event === 'ingress_ended' && event.ingressInfo?.ingressId) {
await this.prismaService.stream.update({
const stream = await this.prismaService.stream.update({
where: { ingressId: event.ingressInfo.ingressId },
data: { isLive: false },
})
await this.prismaService.chatMessage.deleteMany({
where: { streamId: stream.id },
})
}
}
}