diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0696232..817cf55 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -40,7 +40,22 @@ model ChatMessage { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - @@map("chat_messages ") + @@map("chat_messages") +} + +model Follow { + id String @id @default(uuid()) + follower User @relation(name: "followers", fields: [followerId], references: [id], onDelete: Cascade) + followerId String @map("follower_id") + following User @relation(name: "followings", fields: [followingId], references: [id], onDelete: Cascade) + followingId String @map("following_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([followingId, followerId]) + @@index([followerId]) + @@index([followingId]) + @@map("follows") } model SocialLink { @@ -74,6 +89,8 @@ model User { 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") diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index ede8ed4..f25799d 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -10,6 +10,7 @@ import { VerificationModule } from '@/src/module/auth/verification/verification. 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 { 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' @@ -58,6 +59,7 @@ import { RedisModule } from './redis/redis.module' WebhookModule, CategoryModule, ChatModule, + FollowModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index fbdc4f4..3fc54b7 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -87,6 +87,16 @@ input FilterInput { take: Float } +type FollowModel { + createdAt: DateTime! + follower: UserModel! + followerId: ID! + following: UserModel! + followingId: ID! + id: ID! + updatedAt: DateTime! +} + input GenerateStreamTokenInput { channelId: String! userId: String! @@ -124,6 +134,7 @@ type Mutation { deactivateAccount(data: DeactivateAccountInput!): AuthModel! disableTotp: Boolean! enableTotp(data: EnableTotpInput!): Boolean! + followChannel(channelId: String!): FollowModel! generateStreamToken(data: GenerateStreamTokenInput!): GenerateTokenModel! loginUser(data: LoginInput!): AuthModel! logoutUser: Boolean! @@ -135,6 +146,7 @@ type Mutation { resetPassword(data: ResetPasswordInput!): Boolean! sendChatMessage(data: SendMessageInput!): ChatMessageModel! setNewPassword(data: NewPasswordInput!): Boolean! + unfollowChannel(channelId: String!): FollowModel! updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel! verifyAccount(data: VerificationInput!): AuthModel! } @@ -151,6 +163,8 @@ type Query { findCategoryBySlug(slug: String!): CategoryModel! findCurrentSession: SessionModel! findMessagesByStream(streamId: String!): [ChatMessageModel!]! + findMyFollowers: [FollowModel!]! + findMyFollowings: [FollowModel!]! findProfile: UserModel! findRandomCategories: [CategoryModel!]! findRandomStreams: [StreamModel!]! diff --git a/backend/src/module/follow/follow.module.ts b/backend/src/module/follow/follow.module.ts new file mode 100644 index 0000000..ef9e064 --- /dev/null +++ b/backend/src/module/follow/follow.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { FollowService } from './follow.service' +import { FollowResolver } from './follow.resolver' + +@Module({ + providers: [FollowResolver, FollowService], +}) +export class FollowModule {} diff --git a/backend/src/module/follow/follow.resolver.ts b/backend/src/module/follow/follow.resolver.ts new file mode 100644 index 0000000..d5aaf68 --- /dev/null +++ b/backend/src/module/follow/follow.resolver.ts @@ -0,0 +1,35 @@ +import { FollowModel } from '@/src/module/follow/model/follow.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 { FollowService } from './follow.service' + +@Resolver('Follow') +export class FollowResolver { + constructor(private readonly followService: FollowService) {} + + @Authorization() + @Query(() => [FollowModel], { name: 'findMyFollowers' }) + public async findMyFollowers(@Authorized() user: User) { + return this.followService.findMyFollowers(user) + } + + @Authorization() + @Query(() => [FollowModel], { name: 'findMyFollowings' }) + public async findMyFollowings(@Authorized() user: User) { + return this.followService.findMyFollowings(user) + } + + @Authorization() + @Mutation(() => FollowModel, { name: 'followChannel' }) + public async follow(@Authorized() user: User, @Args('channelId') channelId: string) { + return this.followService.follow(user, channelId) + } + + @Authorization() + @Mutation(() => FollowModel, { name: 'unfollowChannel' }) + public async unfollow(@Authorized() user: User, @Args('channelId') channelId: string) { + return this.followService.unfollow(user, channelId) + } +} diff --git a/backend/src/module/follow/follow.service.ts b/backend/src/module/follow/follow.service.ts new file mode 100644 index 0000000..7528262 --- /dev/null +++ b/backend/src/module/follow/follow.service.ts @@ -0,0 +1,94 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' +import { User } from '@prisma/generated' + +@Injectable() +export class FollowService { + constructor( + private readonly prismaService: PrismaService, + ) { + } + + public async findMyFollowers(user: User) { + return this.prismaService.follow.findMany({ + where: { followingId: user.id }, + orderBy: { createdAt: 'desc' }, + include: { follower: true, following: true }, + }) + } + + public async findMyFollowings(user: User) { + return this.prismaService.follow.findMany({ + where: { followerId: user.id }, + orderBy: { createdAt: 'desc' }, + include: { follower: true, following: true }, + }) + } + + public async follow(user: User, channelId: string) { + const channel = await this.prismaService.user.findUnique({ + where: { id: channelId }, + }) + + if (!channel) { + throw new NotFoundException('Пользователь не найден') + } + if (channel.id === user.id) { + throw new ConflictException('Нельзя подписаться на себя') + } + + const existingFollow = await this.prismaService.follow.findFirst({ + where: { + followerId: user.id, + followingId: channel.id, + }, + }) + + if (existingFollow) { + throw new ConflictException('Подписка уже существует') + } + + return this.prismaService.follow.create({ + data: { + followerId: user.id, + followingId: channel.id, + }, + include: { + following: true, + follower: true, + }, + }) + } + + public async unfollow(user: User, channelId: string) { + const channel = await this.prismaService.user.findUnique({ + where: { id: channelId }, + }) + + if (!channel) { + throw new NotFoundException('Пользователь не найден') + } + if (channel.id === user.id) { + throw new ConflictException('Нельзя отписаться от себя') + } + + const existingFollow = await this.prismaService.follow.findFirst({ + where: { + followerId: user.id, + followingId: channel.id, + }, + }) + + if (!existingFollow) { + throw new ConflictException('Подписки не существует') + } + + return this.prismaService.follow.delete({ + where: { id: existingFollow.id }, + include: { + following: true, + follower: true, + }, + }) + } +} diff --git a/backend/src/module/follow/model/follow.model.ts b/backend/src/module/follow/model/follow.model.ts new file mode 100644 index 0000000..5c8a855 --- /dev/null +++ b/backend/src/module/follow/model/follow.model.ts @@ -0,0 +1,27 @@ +import { UserModel } from '@/src/module/auth/account/models/user.model' +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { Follow } from '@prisma/generated' + +@ObjectType() +export class FollowModel implements Follow { + @Field(() => ID) + public id: string + + @Field(() => UserModel) + public following: UserModel + + @Field(() => ID) + public followingId: string + + @Field(() => UserModel) + public follower: UserModel + + @Field(() => ID) + public followerId: string + + @Field(() => Date) + public createdAt: Date + + @Field(() => Date) + public updatedAt: Date +}