From 4d3428e56c2e94900165b99b64d48be742226006 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 14 Jul 2025 06:11:54 +0300 Subject: [PATCH] add chat --- backend/package.json | 1 + backend/prisma/schema.prisma | 68 +++++++++++------- backend/src/core/config/graphql.config.ts | 1 + backend/src/core/core.module.ts | 2 + backend/src/core/graphql/schema.gql | 31 ++++++++ backend/src/module/chat/chat.module.ts | 8 +++ backend/src/module/chat/chat.resolver.ts | 49 +++++++++++++ backend/src/module/chat/chat.service.ts | 70 +++++++++++++++++++ .../chat/input/change-chat-settings.input.ts | 17 +++++ .../module/chat/input/send-message.input.ts | 16 +++++ .../module/chat/models/chat-message.model.ts | 25 +++++++ .../src/module/stream/models/stream.model.ts | 15 +++- backend/yarn.lock | 5 ++ 13 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 backend/src/module/chat/chat.module.ts create mode 100644 backend/src/module/chat/chat.resolver.ts create mode 100644 backend/src/module/chat/chat.service.ts create mode 100644 backend/src/module/chat/input/change-chat-settings.input.ts create mode 100644 backend/src/module/chat/input/send-message.input.ts create mode 100644 backend/src/module/chat/models/chat-message.model.ts diff --git a/backend/package.json b/backend/package.json index d657182..7084545 100644 --- a/backend/package.json +++ b/backend/package.json @@ -48,6 +48,7 @@ "express-session": "^1.18.1", "geoip-lite": "^1.4.10", "graphql": "^16.11.0", + "graphql-subscriptions": "^3.0.0", "graphql-upload": "14", "hi-base32": "^0.5.1", "i18n-iso-countries": "^7.14.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a737599..0696232 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,23 +9,40 @@ datasource db { } model Stream { - id String @id @default(uuid()) - title String - thumbnailUrl String? @map("thumbnail_url") - ingressId String? @unique @map("ingress_id") - serverUrl String? @map("server_url") - key String? @map("key") - isLive Boolean @default(false) @map("is_live") - 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") - category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade) - categoryId String? @map("category_id") + id String @id @default(uuid()) + title String + thumbnailUrl String? @map("thumbnail_url") + ingressId String? @unique @map("ingress_id") + serverUrl String? @map("server_url") + key String? @map("key") + isLive Boolean @default(false) @map("is_live") + 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") + category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade) + categoryId String? @map("category_id") + chatMessages ChatMessage[] + isChatEnable Boolean @default(true) @map("is_chat_enable") + isChatFollowersOnly Boolean @default(false) @map("is_chat_followers_onlys") + isChatPremiumFollowersOnly Boolean @default(false) @map("is_chat_premium_followers_onlys") @@map("stream") } +model ChatMessage { + id String @id @default(uuid()) + text String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @map("user_id") + stream Stream @relation(fields: [streamId], references: [id], onDelete: Cascade) + streamId String @map("stream_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("chat_messages ") +} + model SocialLink { id String @id @default(uuid()) title String @@ -40,24 +57,25 @@ model SocialLink { } model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique password String - name String @unique - displayName String @map("display_name") + 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") + 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") + totpSecret String? @map("totp_secret") stream Stream? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + chatMessages ChatMessage[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("users") } diff --git a/backend/src/core/config/graphql.config.ts b/backend/src/core/config/graphql.config.ts index 28ca9c1..f6a1701 100644 --- a/backend/src/core/config/graphql.config.ts +++ b/backend/src/core/config/graphql.config.ts @@ -16,5 +16,6 @@ export function getGraphQLConfig(configService: ConfigService): ApolloDriverConf autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'), sortSchema: true, context: ({ req, res }: ContextType) => ({ req, res }), + installSubscriptionHandlers: true, } } diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index f6736ff..ede8ed4 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -8,6 +8,7 @@ import { SessionModule } from '@/src/module/auth/session/session.module' import { TotpModule } from '@/src/module/auth/totp/totp.module' import { VerificationModule } from '@/src/module/auth/verification/verification.module' import { CategoryModule } from '@/src/module/category/category.module' +import { ChatModule } from '@/src/module/chat/chat.module' import { CronModule } from '@/src/module/cron/cron.module' import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module' import { MailModule } from '@/src/module/libs/mail/mail.module' @@ -56,6 +57,7 @@ import { RedisModule } from './redis/redis.module' IngressModule, WebhookModule, CategoryModule, + ChatModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index fb20c7f..fbdc4f4 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -18,6 +18,12 @@ type CategoryModel { updatedAt: DateTime! } +input ChangeChatSettingsInput { + isChatEnable: Boolean! + isChatFollowersOnly: Boolean! + isChatPremiumFollowersOnly: Boolean! +} + input ChangeEmailInput { email: String! } @@ -38,6 +44,15 @@ input ChangeStreamInfoInput { title: String! } +type ChatMessageModel { + createdAt: DateTime! + id: ID! + streamId: ID! + text: String! + updatedAt: DateTime! + userId: ID! +} + input CreateUserInput { email: String! name: String! @@ -95,6 +110,7 @@ input LoginInput { } type Mutation { + changeChatSettings(data: ChangeChatSettingsInput!): StreamModel! changeEmail(data: ChangeEmailInput!): UserModel! changePassword(data: ChangePasswordInput!): UserModel! changeProfileAvatar(avatar: Upload!): Boolean! @@ -117,6 +133,7 @@ type Mutation { removeStreamThumbnail: Boolean! reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean! + sendChatMessage(data: SendMessageInput!): ChatMessageModel! setNewPassword(data: NewPasswordInput!): Boolean! updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel! verifyAccount(data: VerificationInput!): AuthModel! @@ -133,6 +150,7 @@ type Query { findAllStreams(filters: FilterInput!): [StreamModel!]! findCategoryBySlug(slug: String!): CategoryModel! findCurrentSession: SessionModel! + findMessagesByStream(streamId: String!): [ChatMessageModel!]! findProfile: UserModel! findRandomCategories: [CategoryModel!]! findRandomStreams: [StreamModel!]! @@ -145,6 +163,11 @@ input ResetPasswordInput { email: String! } +input SendMessageInput { + streamId: String! + text: String! +} + type SessionMetadataModel { device: DeviceModel! ip: String! @@ -181,9 +204,13 @@ input SocialLinkOrderInput { type StreamModel { category: CategoryModel! categoryId: ID! + chatMessages: [ChatMessageModel!]! createdAt: DateTime! id: ID! ingressId: String + isChatEnable: Boolean! + isChatFollowersOnly: Boolean! + isChatPremiumFollowersOnly: Boolean! isLive: Boolean! key: String serverUrl: String @@ -194,6 +221,10 @@ type StreamModel { userId: ID! } +type Subscription { + chatMessageAdded(streamId: String!): ChatMessageModel! +} + type TotpModel { qrcodeUrl: String! secret: String! diff --git a/backend/src/module/chat/chat.module.ts b/backend/src/module/chat/chat.module.ts new file mode 100644 index 0000000..5fd7478 --- /dev/null +++ b/backend/src/module/chat/chat.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { ChatService } from './chat.service' +import { ChatResolver } from './chat.resolver' + +@Module({ + providers: [ChatResolver, ChatService], +}) +export class ChatModule {} diff --git a/backend/src/module/chat/chat.resolver.ts b/backend/src/module/chat/chat.resolver.ts new file mode 100644 index 0000000..cee7093 --- /dev/null +++ b/backend/src/module/chat/chat.resolver.ts @@ -0,0 +1,49 @@ +import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input' +import { SendMessageInput } from '@/src/module/chat/input/send-message.input' +import { StreamModel } from '@/src/module/stream/models/stream.model' +import { Authorization } from '@/src/shared/decorators/auth.decorator' +import { Authorized } from '@/src/shared/decorators/authorized.decorator' +import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql' +import { ChatMessage, User } from '@prisma/generated' +import { PubSub } from 'graphql-subscriptions' +import { ChatService } from './chat.service' +import { ChatMessageModel } from './models/chat-message.model' + +@Resolver('Chat') +export class ChatResolver { + public pubSub: PubSub + constructor(private readonly chatService: ChatService) { + this.pubSub = new PubSub() + } + + @Query(() => [ChatMessageModel], { name: 'findMessagesByStream' }) + public async findMessagesByStream(@Args('streamId') streamId: string) { + return this.chatService.findMessagesByStream(streamId) + } + + @Authorization() + @Mutation(() => ChatMessageModel, { name: 'sendChatMessage' }) + public async sendMessage( + @Authorized('id') userId: User['id'], + @Args('data') input: SendMessageInput, + ) { + const message = this.chatService.sendMessage(userId, input) + void this.pubSub.publish('CHAT_MESSAGE_ADDED', { message }) + + return message + } + + @Subscription(() => ChatMessageModel, { name: 'chatMessageAdded', filter: (payload: { message: ChatMessage }, variables: ChatMessageModel) => payload.message.streamId === variables.streamId }) + public async chatMessageAdded(@Args('streamId') streamId: string) { + return this.pubSub.asyncIterableIterator('CHAT_MESSAGE_ADDED') + } + + @Authorization() + @Mutation(() => StreamModel, { name: 'changeChatSettings' }) + public async changeSettings( + @Args('data') input: ChangeChatSettingsInput, + @Authorized() user: User, + ) { + return this.chatService.changeSettings(user, input) + } +} diff --git a/backend/src/module/chat/chat.service.ts b/backend/src/module/chat/chat.service.ts new file mode 100644 index 0000000..22c6944 --- /dev/null +++ b/backend/src/module/chat/chat.service.ts @@ -0,0 +1,70 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input' +import { SendMessageInput } from '@/src/module/chat/input/send-message.input' +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' +import { Stream, User } from '@prisma/generated' + +@Injectable() +export class ChatService { + constructor( + public readonly prismaService: PrismaService, + ) { + } + + public async findMessagesByStream(streamId: Stream['id']) { + return this.prismaService.chatMessage.findMany({ + where: { streamId }, + orderBy: { createdAt: 'desc' }, + include: { user: true }, + }) + } + + public async sendMessage(userId: User['id'], input: SendMessageInput) { + const { text, streamId } = input + + const stream = await this.prismaService.stream.findUnique({ + where: { id: streamId }, + }) + if (!stream) { + throw new NotFoundException('Стрим не найден') + } + if (!stream.isLive) { + throw new BadRequestException('Стрим не запущен') + } + + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + }) + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + + return this.prismaService.chatMessage.create({ + data: { + user: { + connect: { + id: user.id, + }, + }, + stream: { + connect: { + id: stream.id, + }, + }, + text, + }, + include: { + stream: true, + }, + }) + } + + public async changeSettings(user: User, input: ChangeChatSettingsInput) { + const { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly } = input + + return this.prismaService.stream.update({ + where: { userId: user.id }, + data: { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly }, + }) + } +} diff --git a/backend/src/module/chat/input/change-chat-settings.input.ts b/backend/src/module/chat/input/change-chat-settings.input.ts new file mode 100644 index 0000000..9448522 --- /dev/null +++ b/backend/src/module/chat/input/change-chat-settings.input.ts @@ -0,0 +1,17 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsBoolean } from 'class-validator' + +@InputType() +export class ChangeChatSettingsInput { + @Field(() => Boolean) + @IsBoolean() + public isChatEnable: boolean + + @Field(() => Boolean) + @IsBoolean() + public isChatFollowersOnly: boolean + + @Field(() => Boolean) + @IsBoolean() + public isChatPremiumFollowersOnly: boolean +} diff --git a/backend/src/module/chat/input/send-message.input.ts b/backend/src/module/chat/input/send-message.input.ts new file mode 100644 index 0000000..0089326 --- /dev/null +++ b/backend/src/module/chat/input/send-message.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql' +import { Stream } from '@prisma/generated' +import { IsNotEmpty, IsString } from 'class-validator' + +@InputType() +export class SendMessageInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + public text: string + + @Field(() => String) + @IsString() + @IsNotEmpty() + streamId: Stream['id'] +} diff --git a/backend/src/module/chat/models/chat-message.model.ts b/backend/src/module/chat/models/chat-message.model.ts new file mode 100644 index 0000000..9f3f6b5 --- /dev/null +++ b/backend/src/module/chat/models/chat-message.model.ts @@ -0,0 +1,25 @@ +import { UserModel } from '@/src/module/auth/account/models/user.model' +import { StreamModel } from '@/src/module/stream/models/stream.model' +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { ChatMessage } from '@prisma/generated' + +@ObjectType() +export class ChatMessageModel implements ChatMessage { + @Field(() => ID) + public id: string + + @Field(() => String) + text: string + + @Field(() => ID) + streamId: StreamModel['id'] + + @Field(() => ID) + userId: UserModel['id'] + + @Field(() => Date) + public createdAt: Date + + @Field(() => Date) + public updatedAt: Date +} diff --git a/backend/src/module/stream/models/stream.model.ts b/backend/src/module/stream/models/stream.model.ts index da2aa98..ce66147 100644 --- a/backend/src/module/stream/models/stream.model.ts +++ b/backend/src/module/stream/models/stream.model.ts @@ -1,5 +1,6 @@ import { UserModel } from '@/src/module/auth/account/models/user.model' -import { CategoryModel } from '@/src/module/category/models/category.model'; +import { CategoryModel } from '@/src/module/category/models/category.model' +import { ChatMessageModel } from '@/src/module/chat/models/chat-message.model'; import { Field, ID, ObjectType } from '@nestjs/graphql' import { Stream } from '@prisma/generated' @@ -38,6 +39,18 @@ export class StreamModel implements Stream { @Field(() => String, { nullable: true }) ingressId: string + @Field(() => Boolean) + isChatEnable: boolean + + @Field(() => Boolean) + isChatPremiumFollowersOnly: boolean + + @Field(() => Boolean) + isChatFollowersOnly: boolean + + @Field(() => [ChatMessageModel]) + chatMessages: ChatMessageModel[] + @Field(() => Date) updatedAt: Date diff --git a/backend/yarn.lock b/backend/yarn.lock index 1ed73af..1cb3593 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -6013,6 +6013,11 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql-subscriptions@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-3.0.0.tgz#820c846ef271414c08f64827b5c9a192801e1b6f" + integrity sha512-kZCdevgmzDjGAOqH7GlDmQXYAkuHoKpMlJrqF40HMPhUhM5ZWSFSxCwD/nSi6AkaijmMfsFhoJRGJ27UseCvRA== + graphql-tag@2.12.6: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"