diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 817cf55..3304555 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -72,27 +72,30 @@ model SocialLink { } model User { - id String @id @default(uuid()) - email String @unique - password String - name String @unique - displayName String @map("display_name") - avatar String? - bio String? - token Token[] - isVerified Boolean @default(false) @map("is_verified") - isEmailVerified Boolean @default(false) @map("is_email_verified") - isTotpEnabled Boolean @default(false) @map("is_totp_enabled") - isDeactivated Boolean @default(false) @map("is_deactivated") - deactivatedAt DateTime? @map("deactivated_at") - socialLink SocialLink[] - totpSecret String? @map("totp_secret") - stream Stream? - chatMessages ChatMessage[] - followers Follow[] @relation(name: "followers") - followings Follow[] @relation(name: "followings") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + email String @unique + password String + name String @unique + displayName String @map("display_name") + avatar String? + bio String? + token Token[] + isVerified Boolean @default(false) @map("is_verified") + isEmailVerified Boolean @default(false) @map("is_email_verified") + isTotpEnabled Boolean @default(false) @map("is_totp_enabled") + isDeactivated Boolean @default(false) @map("is_deactivated") + deactivatedAt DateTime? @map("deactivated_at") + socialLink SocialLink[] + totpSecret String? @map("totp_secret") + stream Stream? + 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") @@map("users") } @@ -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") } diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 521f75e..620605b 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -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 {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index d2d8cd3..44b589a 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -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! } diff --git a/backend/src/module/auth/account/models/user.model.ts b/backend/src/module/auth/account/models/user.model.ts index 21248c8..657a759 100644 --- a/backend/src/module/auth/account/models/user.model.ts +++ b/backend/src/module/auth/account/models/user.model.ts @@ -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 diff --git a/backend/src/module/follow/follow.module.ts b/backend/src/module/follow/follow.module.ts index ef9e064..d0cfe23 100644 --- a/backend/src/module/follow/follow.module.ts +++ b/backend/src/module/follow/follow.module.ts @@ -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 {} diff --git a/backend/src/module/follow/follow.service.ts b/backend/src/module/follow/follow.service.ts index 7528262..0ce4166 100644 --- a/backend/src/module/follow/follow.service.ts +++ b/backend/src/module/follow/follow.service.ts @@ -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) { diff --git a/backend/src/module/notification/inputs/change-notification-settings.input.ts b/backend/src/module/notification/inputs/change-notification-settings.input.ts new file mode 100644 index 0000000..3ccb0e6 --- /dev/null +++ b/backend/src/module/notification/inputs/change-notification-settings.input.ts @@ -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 +} diff --git a/backend/src/module/notification/models/notification-settings.model.ts b/backend/src/module/notification/models/notification-settings.model.ts new file mode 100644 index 0000000..74b16da --- /dev/null +++ b/backend/src/module/notification/models/notification-settings.model.ts @@ -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 +} diff --git a/backend/src/module/notification/models/notification.model.ts b/backend/src/module/notification/models/notification.model.ts new file mode 100644 index 0000000..ab5fae0 --- /dev/null +++ b/backend/src/module/notification/models/notification.model.ts @@ -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 +} diff --git a/backend/src/module/notification/notification.module.ts b/backend/src/module/notification/notification.module.ts new file mode 100644 index 0000000..94b9aa0 --- /dev/null +++ b/backend/src/module/notification/notification.module.ts @@ -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 {} diff --git a/backend/src/module/notification/notification.resolver.ts b/backend/src/module/notification/notification.resolver.ts new file mode 100644 index 0000000..ca03a0c --- /dev/null +++ b/backend/src/module/notification/notification.resolver.ts @@ -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) + } +} diff --git a/backend/src/module/notification/notification.service.ts b/backend/src/module/notification/notification.service.ts new file mode 100644 index 0000000..5c9cfd8 --- /dev/null +++ b/backend/src/module/notification/notification.service.ts @@ -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: `Не пропустите! +

Присоединяйтесь к стриму на канале ${channel.displayName}.

`, + type: NotificationType.STREAM_START, + user: { + connect: { + id: userId, + }, + }, + }, + + }) + } + + public async createNewFollowing(userId: string, follower: User) { + return this.prismaService.notification.create({ + data: { + text: `У вас новый подписчик! +

Это пользователь ${follower.displayName}.

`, + type: NotificationType.NEW_FOLLOWER, + user: { + connect: { + id: userId, + }, + }, + }, + }) + } + + public async createEnableTwoFactor(userId: string) { + return this.prismaService.notification.create({ + data: { + text: `Обеспечьте свою безопасность! +

Включите двухфакторную аутентификацию в настройках вашего аккаунта, чтобы повысить уровень защиты.

`, + type: NotificationType.ENABLE_TWO_FACTOR, + userId, + }, + }) + } + + public async createVerifyChannel(userId: string) { + return this.prismaService.notification.create({ + data: { + text: `Поздравляем! +

Ваш канал верифицирован, и теперь рядом с вашим каналом будет галочка.

`, + type: NotificationType.VERIFIED_CHANNEL, + userId, + }, + }) + } +} diff --git a/backend/src/module/webhook/webhook.module.ts b/backend/src/module/webhook/webhook.module.ts index 4fa36b5..672e7d0 100644 --- a/backend/src/module/webhook/webhook.module.ts +++ b/backend/src/module/webhook/webhook.module.ts @@ -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) { diff --git a/backend/src/module/webhook/webhook.service.ts b/backend/src/module/webhook/webhook.service.ts index 0d11e68..62724c0 100644 --- a/backend/src/module/webhook/webhook.service.ts +++ b/backend/src/module/webhook/webhook.service.ts @@ -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 }, + }) } } }