diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ca996d4..97d3426 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,23 +8,37 @@ datasource db { url = env("POSTGRES_URI") } +model SocialLink { + id String @id @default(uuid()) + title String + url String + position Int + 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("social_links") +} + 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") - totpSecret String? @map("totp_secret") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_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") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("users") } diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index fd2fa4a..5ee7ea4 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -69,6 +69,7 @@ type Mutation { changeProfileAvatar(avatar: Upload!): Boolean! changeProfileInfo(data: ChangeProfileInfoInput!): UserModel! clearSessionCookie: Boolean! + createSocialLink(data: SocialLinkInput!): SocialLinkModel! createUser(data: CreateUserInput!): UserModel! deactivateAccount(data: DeactivateAccountInput!): AuthModel! disableTotp: Boolean! @@ -77,8 +78,11 @@ type Mutation { logoutUser: Boolean! removeProfileAvatar: Boolean! removeSession(id: String!): Boolean! + removeSocialLink(id: String!): Boolean! + reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean! setNewPassword(data: NewPasswordInput!): Boolean! + updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel! verifyAccount(data: VerificationInput!): UserModel! } @@ -92,6 +96,7 @@ type Query { findCurrentSession: SessionModel! findProfile: UserModel! findSessionsByUser: [SessionModel!]! + findSocialLinks: [SocialLinkModel!]! generateTotpSecret: TotpModel! } @@ -112,6 +117,26 @@ type SessionModel { userId: String! } +input SocialLinkInput { + title: String! + url: String! +} + +type SocialLinkModel { + createdAt: DateTime! + id: ID! + position: Float! + title: String! + updatedAt: DateTime! + url: String! + userId: ID! +} + +input SocialLinkOrderInput { + id: String! + position: Float! +} + type TotpModel { qrcodeUrl: String! secret: String! @@ -134,6 +159,7 @@ type UserModel { isVerified: Boolean! name: String! password: String! + socialLink: [SocialLinkModel!]! 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 9294f71..20ec045 100644 --- a/backend/src/module/auth/account/models/user.model.ts +++ b/backend/src/module/auth/account/models/user.model.ts @@ -1,3 +1,4 @@ +import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'; import { Field, ID, ObjectType } from '@nestjs/graphql' import { User } from '@prisma/generated' @@ -42,6 +43,9 @@ export class UserModel implements User { @Field(() => String, { nullable: true }) totpSecret: string + @Field(() => [SocialLinkModel]) + socialLink: SocialLinkModel[] + @Field(() => Date) createdAt: Date diff --git a/backend/src/module/auth/profile/inputs/models/social-link.model.ts b/backend/src/module/auth/profile/inputs/models/social-link.model.ts new file mode 100644 index 0000000..b9b2b78 --- /dev/null +++ b/backend/src/module/auth/profile/inputs/models/social-link.model.ts @@ -0,0 +1,27 @@ +import { UserModel } from '@/src/module/auth/account/models/user.model' +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { SocialLink } from '@prisma/generated' + +@ObjectType() +export class SocialLinkModel implements SocialLink { + @Field(() => ID) + id: string + + @Field(() => ID) + userId: UserModel['id'] + + @Field(() => String) + title: string + + @Field(() => String) + url: string + + @Field(() => Number) + position: number + + @Field(() => Date) + updatedAt: Date + + @Field(() => Date) + createdAt: Date +} diff --git a/backend/src/module/auth/profile/inputs/social-link.input.ts b/backend/src/module/auth/profile/inputs/social-link.input.ts new file mode 100644 index 0000000..037c479 --- /dev/null +++ b/backend/src/module/auth/profile/inputs/social-link.input.ts @@ -0,0 +1,29 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsNotEmpty, IsNumber, IsString, IsUrl } from 'class-validator' + +@InputType() +export class SocialLinkInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + title: string + + @Field(() => String) + @IsString() + @IsNotEmpty() + @IsUrl() + url: string +} + +@InputType() +export class SocialLinkOrderInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + id: string + + @Field(() => Number) + @IsNumber() + @IsNotEmpty() + position: number +} diff --git a/backend/src/module/auth/profile/profile.resolver.ts b/backend/src/module/auth/profile/profile.resolver.ts index 89e7544..f674580 100644 --- a/backend/src/module/auth/profile/profile.resolver.ts +++ b/backend/src/module/auth/profile/profile.resolver.ts @@ -1,9 +1,14 @@ import { UserModel } from '@/src/module/auth/account/models/user.model' import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input' +import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model' +import { + SocialLinkInput, + SocialLinkOrderInput, +} from '@/src/module/auth/profile/inputs/social-link.input' import { Authorization } from '@/src/shared/decorators/auth.decorator' import { Authorized } from '@/src/shared/decorators/authorized.decorator' import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe' -import { Args, Mutation, Resolver } from '@nestjs/graphql' +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' import { ProfileService } from './profile.service' import { User } from '@/prisma/generated' import * as Upload from 'graphql-upload/Upload.js' @@ -36,4 +41,44 @@ export class ProfileResolver { ) { return this.profileService.changeInfo(user, input) } + + @Authorization() + @Mutation(() => SocialLinkModel, { name: 'createSocialLink' }) + public async createSocialLink( + @Authorized() user: User, + @Args('data') input: SocialLinkInput, + ) { + return this.profileService.createSocialLink(user, input) + } + + @Authorization() + @Mutation(() => Boolean, { name: 'reorderSocialLink' }) + public async reorderSocialLinks( + @Args('list', { type: () => [SocialLinkOrderInput] }) list: SocialLinkOrderInput[], + ) { + return this.profileService.reorderSocialLinks(list) + } + + @Authorization() + @Mutation(() => SocialLinkModel, { name: 'updateSocialLink' }) + public async updateSocialLink( + @Args('id') id: string, + @Args('data') input: SocialLinkInput, + ) { + return this.profileService.updateSocialLink(id, input) + } + + @Authorization() + @Mutation(() => Boolean, { name: 'removeSocialLink' }) + public async removeSocialLink( + @Args('id') id: string, + ) { + return this.profileService.removeSocialLink(id) + } + + @Authorization() + @Query(() => [SocialLinkModel], { name: 'findSocialLinks' }) + public async findSocialLink(@Authorized() user: User) { + return this.profileService.findSocialLink(user) + } } diff --git a/backend/src/module/auth/profile/profile.service.ts b/backend/src/module/auth/profile/profile.service.ts index fc7bfe5..44937de 100644 --- a/backend/src/module/auth/profile/profile.service.ts +++ b/backend/src/module/auth/profile/profile.service.ts @@ -1,9 +1,13 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input' +import { + SocialLinkInput, + SocialLinkOrderInput, +} from '@/src/module/auth/profile/inputs/social-link.input' import { ConflictException, Injectable } from '@nestjs/common' import sharp from 'sharp' import { StorageService } from '../../libs/storage/storage.service' -import { User } from '@/prisma/generated' +import { SocialLink, User } from '@/prisma/generated' import * as Upload from 'graphql-upload/Upload.js' @Injectable() @@ -72,4 +76,65 @@ export class ProfileService { data: { bio, displayName, name }, }) } + + public async createSocialLink(user: User, input: SocialLinkInput) { + const { title, url } = input + + const lastSocialLink = await this.prismaService.socialLink.findFirst({ + where: { userId: user.id }, + orderBy: { position: 'desc' }, + }) + + return this.prismaService.socialLink.create({ + data: { + url, + title, + position: lastSocialLink ? lastSocialLink.position + 1 : 1, + user: { + connect: { + id: user.id, + }, + }, + }, + }) + } + + public async findSocialLink(user: User) { + return this.prismaService.socialLink.findMany({ + where: { userId: user.id }, + }) + } + + public async reorderSocialLinks(list: SocialLinkOrderInput[]) { + if (list.length === 0) { + return + } + + const updatePromises = list.map((socialLink) => { + return this.prismaService.socialLink.update({ + where: { id: socialLink.id }, + data: { position: Number(socialLink.position) }, + }) + }) + + await Promise.all(updatePromises) + + return true + } + + public async updateSocialLink(id: SocialLink['id'], input: SocialLinkInput) { + const { title, url } = input + return this.prismaService.socialLink.update({ + where: { id }, + data: { title, url }, + }) + } + + public async removeSocialLink(id: SocialLink['id']) { + await this.prismaService.socialLink.delete({ + where: { id }, + }) + + return true + } }