From fcdde024a3ea479ed69e1ad923e2c5081ab922f1 Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Fri, 28 Jul 2023 12:19:20 +0700 Subject: [PATCH] feat: Add workspace delete feature (#896) * Add workspace delete feature Co-authored-by: v1b3m * Add fixes and refactors Co-authored-by: v1b3m * Add more fixes Co-authored-by: v1b3m * Add requested changes Co-authored-by: v1b3m * Add workspace delete mutation Co-authored-by: v1b3m * Complete v1 of deletion Co-authored-by: Benjamin Mayanja * Revert unwanted changes Co-authored-by: Benjamin Mayanja Co-authored-by: RubensRafael * Update debouce import Co-authored-by: v1b3m Co-authored-by: RubensRafael * Fix server e2e tests on CI #3 * Fix server e2e tests on CI #4 * Fix server e2e tests on CI #5 * Added generic relation cell (#969) * Added generic relation cell * Deactivated debug * Added default warning * Put back display component * Removed unused types * fix: 906 edit avatar style (#923) * fix: 906 edit avatar style * fix: 906 add avatar size enum and mapping for font and height * fix: 906 remove unused vars * chore: optimize size of front docker image (#965) * Enable to drag under New button on pipeline (#970) * Add minor fix Co-authored-by: v1b3m Co-authored-by: RubensRafael --------- Co-authored-by: v1b3m Co-authored-by: RubensRafael Co-authored-by: Charles Bochet Co-authored-by: Lucas Bordeau Co-authored-by: 310387 <139059022+310387@users.noreply.github.com> Co-authored-by: Lucas Vieira Co-authored-by: Charles Bochet --- front/src/generated/graphql.tsx | 40 +++++- front/src/modules/auth/components/Modal.tsx | 13 +- .../profile/components/DeleteWorkspace.tsx | 126 ++++++++++++++++++ .../src/modules/ui/modal/components/Modal.tsx | 19 ++- front/src/modules/workspace/queries/update.ts | 8 ++ front/src/pages/settings/SettingsProfile.tsx | 5 + .../workspace/resolvers/workspace.resolver.ts | 23 +++- .../services/workspace.service.spec.ts | 5 + .../workspace/services/workspace.service.ts | 82 ++++++++++++ 9 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 front/src/modules/settings/profile/components/DeleteWorkspace.tsx diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index a8c7ddb71..1a21244b7 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -764,6 +764,7 @@ export type Mutation = { createOneCompany: Company; createOnePerson: Person; createOnePipelineProgress: PipelineProgress; + deleteCurrentWorkspace: Workspace; deleteManyCommentThreads: AffectedRows; deleteManyCompany: AffectedRows; deleteManyPerson: AffectedRows; @@ -2490,6 +2491,11 @@ export type RemoveWorkspaceMemberMutationVariables = Exact<{ export type RemoveWorkspaceMemberMutation = { __typename?: 'Mutation', deleteWorkspaceMember: { __typename?: 'WorkspaceMember', id: string } }; +export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: never; }>; + + +export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: string } }; + export const CreateCommentDocument = gql` mutation CreateComment($commentId: String!, $commentText: String!, $authorId: String!, $commentThreadId: String!, $createdAt: DateTime!) { @@ -4837,4 +4843,36 @@ export function useRemoveWorkspaceMemberMutation(baseOptions?: Apollo.MutationHo } export type RemoveWorkspaceMemberMutationHookResult = ReturnType; export type RemoveWorkspaceMemberMutationResult = Apollo.MutationResult; -export type RemoveWorkspaceMemberMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type RemoveWorkspaceMemberMutationOptions = Apollo.BaseMutationOptions; +export const DeleteCurrentWorkspaceDocument = gql` + mutation DeleteCurrentWorkspace { + deleteCurrentWorkspace { + id + } +} + `; +export type DeleteCurrentWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteCurrentWorkspaceMutation__ + * + * To run a mutation, you first call `useDeleteCurrentWorkspaceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteCurrentWorkspaceMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteCurrentWorkspaceMutation, { data, loading, error }] = useDeleteCurrentWorkspaceMutation({ + * variables: { + * }, + * }); + */ +export function useDeleteCurrentWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteCurrentWorkspaceDocument, options); + } +export type DeleteCurrentWorkspaceMutationHookResult = ReturnType; +export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult; +export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/front/src/modules/auth/components/Modal.tsx b/front/src/modules/auth/components/Modal.tsx index 0222befb9..5a6574e0f 100644 --- a/front/src/modules/auth/components/Modal.tsx +++ b/front/src/modules/auth/components/Modal.tsx @@ -1,22 +1,13 @@ import React from 'react'; -import styled from '@emotion/styled'; import { Modal as UIModal } from '@/ui/modal/components/Modal'; type Props = React.ComponentProps<'div'>; -const StyledContainer = styled.div` - align-items: center; - display: flex; - flex-direction: column; - padding: ${({ theme }) => theme.spacing(10)}; - width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)}); -`; - export function AuthModal({ children, ...restProps }: Props) { return ( - - {children} + + {children} ); } diff --git a/front/src/modules/settings/profile/components/DeleteWorkspace.tsx b/front/src/modules/settings/profile/components/DeleteWorkspace.tsx new file mode 100644 index 000000000..13a635ef1 --- /dev/null +++ b/front/src/modules/settings/profile/components/DeleteWorkspace.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { AnimatePresence, LayoutGroup } from 'framer-motion'; +import { useRecoilValue } from 'recoil'; + +import { useAuth } from '@/auth/hooks/useAuth'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { AppPath } from '@/types/AppPath'; +import { Button, ButtonVariant } from '@/ui/button/components/Button'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { Modal } from '@/ui/modal/components/Modal'; +import { SubSectionTitle } from '@/ui/title/components/SubSectionTitle'; +import { useDeleteCurrentWorkspaceMutation } from '~/generated/graphql'; +import { debounce } from '~/utils/debounce'; + +const StyledCenteredButton = styled(Button)` + justify-content: center; +`; + +const StyledDeleteButton = styled(StyledCenteredButton)` + border-color: ${({ theme }) => theme.color.red20}; + color: ${({ theme }) => theme.color.red}; + font-size: ${({ theme }) => theme.font.size.md}; + line-height: ${({ theme }) => theme.text.lineHeight.lg}; +`; + +const StyledTitle = styled.div` + font-size: ${({ theme }) => theme.font.size.lg}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledModal = styled(Modal)` + color: ${({ theme }) => theme.font.color.primary}; + > * + * { + margin-top: ${({ theme }) => theme.spacing(8)}; + } +`; + +export function DeleteWorkspace() { + const [isOpen, setIsOpen] = useState(false); + const [isValidEmail, setIsValidEmail] = useState(true); + const [email, setEmail] = useState(''); + const currentUser = useRecoilValue(currentUserState); + const userEmail = currentUser?.email; + + const [deleteCurrentWorkspace] = useDeleteCurrentWorkspaceMutation(); + const { signOut } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = useCallback(() => { + signOut(); + navigate(AppPath.SignIn); + }, [signOut, navigate]); + + const deleteWorkspace = async () => { + await deleteCurrentWorkspace(); + handleLogout(); + }; + + const isEmailMatchingUserEmail = debounce( + (email1?: string, email2?: string) => { + setIsValidEmail(Boolean(email1 && email2 && email1 === email2)); + }, + 250, + ); + + const handleEmailChange = (val: string) => { + setEmail(val); + isEmailMatchingUserEmail(val, userEmail); + }; + + const errorMessage = + email && !isValidEmail ? 'email provided is not correct' : ''; + + return ( + <> + + setIsOpen(!isOpen)} + variant={ButtonVariant.Secondary} + title="Delete workspace" + /> + + + + + Workspace Deletion +
+ This action cannot be undone. This will permanently delete your + entire workspace. Please type in your email to confirm. +
+ + + setIsOpen(false)} + variant={ButtonVariant.Secondary} + title="Cancel" + fullWidth + style={{ + marginTop: 10, + }} + /> +
+
+
+ + ); +} diff --git a/front/src/modules/ui/modal/components/Modal.tsx b/front/src/modules/ui/modal/components/Modal.tsx index aacad0d0f..64cee902d 100644 --- a/front/src/modules/ui/modal/components/Modal.tsx +++ b/front/src/modules/ui/modal/components/Modal.tsx @@ -2,6 +2,14 @@ import React from 'react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(10)}; + width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)}); +`; + const ModalDiv = styled(motion.div)` background: ${({ theme }) => theme.background.primary}; border-radius: ${({ theme }) => theme.border.radius.md}; @@ -21,9 +29,10 @@ const BackDrop = styled(motion.div)` z-index: 9999; `; -type Props = React.PropsWithChildren & { - isOpen?: boolean; -}; +type Props = React.PropsWithChildren & + React.ComponentProps<'div'> & { + isOpen?: boolean; + }; const modalVariants = { hidden: { opacity: 0 }, @@ -31,7 +40,7 @@ const modalVariants = { exit: { opacity: 0 }, }; -export function Modal({ isOpen = false, children }: Props) { +export function Modal({ isOpen = false, children, ...restProps }: Props) { if (!isOpen) { return null; } @@ -46,7 +55,7 @@ export function Modal({ isOpen = false, children }: Props) { exit="exit" variants={modalVariants} > - {children} + {children} diff --git a/front/src/modules/workspace/queries/update.ts b/front/src/modules/workspace/queries/update.ts index 91b824ff7..f2c1e4f4b 100644 --- a/front/src/modules/workspace/queries/update.ts +++ b/front/src/modules/workspace/queries/update.ts @@ -32,3 +32,11 @@ export const REMOVE_WORKSPACE_MEMBER = gql` } } `; + +export const DELETE_CURRENT_WORKSPACE = gql` + mutation DeleteCurrentWorkspace { + deleteCurrentWorkspace { + id + } + } +`; diff --git a/front/src/pages/settings/SettingsProfile.tsx b/front/src/pages/settings/SettingsProfile.tsx index f7b351345..a8dc1f3b0 100644 --- a/front/src/pages/settings/SettingsProfile.tsx +++ b/front/src/pages/settings/SettingsProfile.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; import { EmailField } from '@/settings/profile/components/EmailField'; import { NameFields } from '@/settings/profile/components/NameFields'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; @@ -49,6 +50,10 @@ export function SettingsProfile() { /> + + + + diff --git a/server/src/core/workspace/resolvers/workspace.resolver.ts b/server/src/core/workspace/resolvers/workspace.resolver.ts index 355ff07f3..b2feb8d51 100644 --- a/server/src/core/workspace/resolvers/workspace.resolver.ts +++ b/server/src/core/workspace/resolvers/workspace.resolver.ts @@ -20,7 +20,12 @@ import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { AbilityGuard } from 'src/guards/ability.guard'; import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; -import { UpdateWorkspaceAbilityHandler } from 'src/ability/handlers/workspace.ability-handler'; +import { + UpdateWorkspaceAbilityHandler, + DeleteWorkspaceAbilityHandler, +} from 'src/ability/handlers/workspace.ability-handler'; +import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { User } from 'src/core/@generated/user/user.model'; @UseGuards(JwtAuthGuard) @Resolver(() => Workspace) @@ -95,4 +100,20 @@ export class WorkspaceResolver { return paths[0]; } + + @UseGuards(AbilityGuard) + @CheckAbilities(DeleteWorkspaceAbilityHandler) + @Mutation(() => Workspace) + async deleteCurrentWorkspace( + @AuthWorkspace() { id: workspaceId }: Workspace, + @PrismaSelector({ modelName: 'Workspace' }) + { value: select }: PrismaSelect<'Workspace'>, + @AuthUser() { id: userId }: User, + ) { + return this.workspaceService.deleteWorkspace({ + workspaceId, + select, + userId, + }); + } } diff --git a/server/src/core/workspace/services/workspace.service.spec.ts b/server/src/core/workspace/services/workspace.service.spec.ts index ac424bd98..9bb373cc5 100644 --- a/server/src/core/workspace/services/workspace.service.spec.ts +++ b/server/src/core/workspace/services/workspace.service.spec.ts @@ -6,6 +6,7 @@ import { PipelineService } from 'src/core/pipeline/services/pipeline.service'; import { PipelineStageService } from 'src/core/pipeline/services/pipeline-stage.service'; import { PersonService } from 'src/core/person/person.service'; import { CompanyService } from 'src/core/company/company.service'; +import { PipelineProgressService } from 'src/core/pipeline/services/pipeline-progress.service'; import { WorkspaceService } from './workspace.service'; @@ -36,6 +37,10 @@ describe('WorkspaceService', () => { provide: CompanyService, useValue: {}, }, + { + provide: PipelineProgressService, + useValue: {}, + }, ], }).compile(); diff --git a/server/src/core/workspace/services/workspace.service.ts b/server/src/core/workspace/services/workspace.service.ts index fa3782d91..b44afabb8 100644 --- a/server/src/core/workspace/services/workspace.service.ts +++ b/server/src/core/workspace/services/workspace.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { v4 } from 'uuid'; +import { Prisma } from '@prisma/client'; import { PipelineStageService } from 'src/core/pipeline/services/pipeline-stage.service'; +import { PipelineProgressService } from 'src/core/pipeline/services/pipeline-progress.service'; import { PipelineService } from 'src/core/pipeline/services/pipeline.service'; import { PrismaService } from 'src/database/prisma.service'; import { CompanyService } from 'src/core/company/company.service'; import { PersonService } from 'src/core/person/person.service'; +import { assert } from 'src/utils/assert'; @Injectable() export class WorkspaceService { @@ -16,6 +19,7 @@ export class WorkspaceService { private readonly companyService: CompanyService, private readonly personService: PersonService, private readonly pipelineStageService: PipelineStageService, + private readonly pipelineProgressService: PipelineProgressService, ) {} // Find @@ -81,4 +85,82 @@ export class WorkspaceService { return workspace; } + + async deleteWorkspace({ + workspaceId, + select, + userId, + }: { + workspaceId: string; + select: Prisma.WorkspaceSelect; + userId: string; + }) { + const workspace = await this.findUnique({ + where: { id: workspaceId }, + select, + }); + assert(workspace, 'Workspace not found'); + + const where = { workspaceId }; + + const { + user, + workspaceMember, + refreshToken, + attachment, + comment, + commentThreadTarget, + commentThread, + } = this.prismaService.client; + + const commentThreads = await commentThread.findMany({ + where: { authorId: userId }, + }); + + await this.prismaService.client.$transaction([ + this.pipelineProgressService.deleteMany({ + where, + }), + this.companyService.deleteMany({ + where, + }), + this.personService.deleteMany({ + where, + }), + this.pipelineStageService.deleteMany({ + where, + }), + this.pipelineService.deleteMany({ + where, + }), + workspaceMember.deleteMany({ + where, + }), + attachment.deleteMany({ + where, + }), + comment.deleteMany({ + where, + }), + ...commentThreads.map(({ id: commentThreadId }) => + commentThreadTarget.deleteMany({ + where: { commentThreadId }, + }), + ), + commentThread.deleteMany({ + where, + }), + refreshToken.deleteMany({ + where: { userId }, + }), + user.delete({ + where: { + id: userId, + }, + }), + this.delete({ where: { id: workspaceId } }), + ]); + + return workspace; + } }