diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 7ae659d45..c433c6e55 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -848,6 +848,7 @@ export type EnumPipelineProgressableTypeFilter = { export enum FileFolder { Attachment = 'Attachment', + PersonPicture = 'PersonPicture', ProfilePicture = 'ProfilePicture', WorkspaceLogo = 'WorkspaceLogo' } @@ -928,6 +929,7 @@ export type Mutation = { uploadAttachment: Scalars['String']; uploadFile: Scalars['String']; uploadImage: Scalars['String']; + uploadPersonPicture: Scalars['String']; uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; verify: Verify; @@ -1094,6 +1096,12 @@ export type MutationUploadImageArgs = { }; +export type MutationUploadPersonPictureArgs = { + file: Scalars['Upload']; + id: Scalars['String']; +}; + + export type MutationUploadProfilePictureArgs = { file: Scalars['Upload']; }; @@ -2582,6 +2590,21 @@ export type DeleteManyPersonMutationVariables = Exact<{ export type DeleteManyPersonMutation = { __typename?: 'Mutation', deleteManyPerson: { __typename?: 'AffectedRows', count: number } }; +export type UploadPersonPictureMutationVariables = Exact<{ + id: Scalars['String']; + file: Scalars['Upload']; +}>; + + +export type UploadPersonPictureMutation = { __typename?: 'Mutation', uploadPersonPicture: string }; + +export type RemovePersonPictureMutationVariables = Exact<{ + where: PersonWhereUniqueInput; +}>; + + +export type RemovePersonPictureMutation = { __typename?: 'Mutation', updateOnePerson?: { __typename?: 'Person', id: string, avatarUrl?: string | null } | null }; + export type GetPipelinesQueryVariables = Exact<{ where?: InputMaybe; }>; @@ -4398,6 +4421,72 @@ export function useDeleteManyPersonMutation(baseOptions?: Apollo.MutationHookOpt export type DeleteManyPersonMutationHookResult = ReturnType; export type DeleteManyPersonMutationResult = Apollo.MutationResult; export type DeleteManyPersonMutationOptions = Apollo.BaseMutationOptions; +export const UploadPersonPictureDocument = gql` + mutation UploadPersonPicture($id: String!, $file: Upload!) { + uploadPersonPicture(id: $id, file: $file) +} + `; +export type UploadPersonPictureMutationFn = Apollo.MutationFunction; + +/** + * __useUploadPersonPictureMutation__ + * + * To run a mutation, you first call `useUploadPersonPictureMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUploadPersonPictureMutation` 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 [uploadPersonPictureMutation, { data, loading, error }] = useUploadPersonPictureMutation({ + * variables: { + * id: // value for 'id' + * file: // value for 'file' + * }, + * }); + */ +export function useUploadPersonPictureMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadPersonPictureDocument, options); + } +export type UploadPersonPictureMutationHookResult = ReturnType; +export type UploadPersonPictureMutationResult = Apollo.MutationResult; +export type UploadPersonPictureMutationOptions = Apollo.BaseMutationOptions; +export const RemovePersonPictureDocument = gql` + mutation RemovePersonPicture($where: PersonWhereUniqueInput!) { + updateOnePerson(data: {avatarUrl: null}, where: $where) { + id + avatarUrl + } +} + `; +export type RemovePersonPictureMutationFn = Apollo.MutationFunction; + +/** + * __useRemovePersonPictureMutation__ + * + * To run a mutation, you first call `useRemovePersonPictureMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRemovePersonPictureMutation` 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 [removePersonPictureMutation, { data, loading, error }] = useRemovePersonPictureMutation({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useRemovePersonPictureMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RemovePersonPictureDocument, options); + } +export type RemovePersonPictureMutationHookResult = ReturnType; +export type RemovePersonPictureMutationResult = Apollo.MutationResult; +export type RemovePersonPictureMutationOptions = Apollo.BaseMutationOptions; export const GetPipelinesDocument = gql` query GetPipelines($where: PipelineWhereInput) { findManyPipeline(where: $where) { diff --git a/front/src/modules/people/queries/update.ts b/front/src/modules/people/queries/update.ts index 8f1203a9a..5c3c31780 100644 --- a/front/src/modules/people/queries/update.ts +++ b/front/src/modules/people/queries/update.ts @@ -54,3 +54,18 @@ export const DELETE_MANY_PERSON = gql` } } `; + +export const UPDATE_PERSON_PICTURE = gql` + mutation UploadPersonPicture($id: String!, $file: Upload!) { + uploadPersonPicture(id: $id, file: $file) + } +`; + +export const REMOVE_PERSON_PICTURE = gql` + mutation RemovePersonPicture($where: PersonWhereUniqueInput!) { + updateOnePerson(data: { avatarUrl: null }, where: $where) { + id + avatarUrl + } + } +`; diff --git a/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx b/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx index 83f5cd58d..bb7f09c43 100644 --- a/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx +++ b/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx @@ -1,3 +1,4 @@ +import { ChangeEvent, useRef } from 'react'; import { Tooltip } from 'react-tooltip'; import styled from '@emotion/styled'; import { v4 as uuidV4 } from 'uuid'; @@ -16,6 +17,7 @@ type OwnProps = { title: string; date: string; renderTitleEditComponent?: () => JSX.Element; + onUploadPicture?: (file: File) => void; }; const StyledShowPageSummaryCard = styled.div` @@ -62,27 +64,52 @@ const StyledTooltip = styled(Tooltip)` padding: ${({ theme }) => theme.spacing(2)}; `; +const StyledAvatarWrapper = styled.div` + cursor: pointer; +`; + +const StyledFileInput = styled.input` + display: none; +`; + export function ShowPageSummaryCard({ id, logoOrAvatar, title, date, renderTitleEditComponent, + onUploadPicture, }: OwnProps) { const beautifiedCreatedAt = date !== '' ? beautifyPastDateRelativeToNow(date) : ''; const exactCreatedAt = date !== '' ? beautifyExactDateTime(date) : ''; const dateElementId = `date-id-${uuidV4()}`; + const inputFileRef = useRef(null); + + const onFileChange = (e: ChangeEvent) => { + if (e.target.files) onUploadPicture?.(e.target.files[0]); + }; + const onAvatarClick = () => { + if (onUploadPicture) inputFileRef?.current?.click?.(); + }; return ( - + + + + + {renderTitleEditComponent ? ( diff --git a/front/src/pages/people/PersonShow.tsx b/front/src/pages/people/PersonShow.tsx index e27642057..4ed2755e0 100644 --- a/front/src/pages/people/PersonShow.tsx +++ b/front/src/pages/people/PersonShow.tsx @@ -1,15 +1,19 @@ import { useParams } from 'react-router-dom'; +import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; import { Timeline } from '@/activities/timeline/components/Timeline'; import { PersonPropertyBox } from '@/people/components/PersonPropertyBox'; -import { usePersonQuery } from '@/people/queries'; +import { GET_PERSON, usePersonQuery } from '@/people/queries'; import { IconUser } from '@/ui/icon'; import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer'; import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer'; import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer'; import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard'; -import { CommentableType } from '~/generated/graphql'; +import { + CommentableType, + useUploadPersonPictureMutation, +} from '~/generated/graphql'; import { PeopleFullNameEditableField } from '../../modules/people/editable-field/components/PeopleFullNameEditableField'; import { ShowPageContainer } from '../../modules/ui/layout/components/ShowPageContainer'; @@ -21,6 +25,20 @@ export function PersonShow() { const person = data?.findUniquePerson; const theme = useTheme(); + const [uploadPicture] = useUploadPersonPictureMutation(); + + async function onUploadPicture(file: File) { + if (!file || !person?.id) { + return; + } + await uploadPicture({ + variables: { + file, + id: person?.id, + }, + refetchQueries: [getOperationName(GET_PERSON) ?? ''], + }); + } return ( person ? : <> } + onUploadPicture={onUploadPicture} /> {person && } diff --git a/server/src/constants/settings/index.ts b/server/src/constants/settings/index.ts index b4d9c22e0..f3004a280 100644 --- a/server/src/constants/settings/index.ts +++ b/server/src/constants/settings/index.ts @@ -5,6 +5,7 @@ export const settings: Settings = { imageCropSizes: { 'profile-picture': ['original'], 'workspace-logo': ['original'], + 'person-picture': ['original'], }, maxFileSize: '10MB', }, diff --git a/server/src/core/file/interfaces/file-folder.interface.ts b/server/src/core/file/interfaces/file-folder.interface.ts index 613c7980b..2e4e99bca 100644 --- a/server/src/core/file/interfaces/file-folder.interface.ts +++ b/server/src/core/file/interfaces/file-folder.interface.ts @@ -4,6 +4,7 @@ export enum FileFolder { ProfilePicture = 'profile-picture', WorkspaceLogo = 'workspace-logo', Attachment = 'attachment', + PersonPicture = 'person-picture', } registerEnumType(FileFolder, { diff --git a/server/src/core/person/person.module.ts b/server/src/core/person/person.module.ts index 9ca83ef93..42faed52f 100644 --- a/server/src/core/person/person.module.ts +++ b/server/src/core/person/person.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { CommentModule } from 'src/core/comment/comment.module'; import { ActivityModule } from 'src/core/activity/activity.module'; +import { FileModule } from 'src/core/file/file.module'; import { PersonService } from './person.service'; import { PersonResolver } from './person.resolver'; import { PersonRelationsResolver } from './person-relations.resolver'; @Module({ - imports: [CommentModule, ActivityModule], + imports: [CommentModule, ActivityModule, FileModule], providers: [PersonService, PersonResolver, PersonRelationsResolver], exports: [PersonService], }) diff --git a/server/src/core/person/person.resolver.spec.ts b/server/src/core/person/person.resolver.spec.ts index 5a8af6f3d..1344450c5 100644 --- a/server/src/core/person/person.resolver.spec.ts +++ b/server/src/core/person/person.resolver.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AbilityFactory } from 'src/ability/ability.factory'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { PersonService } from './person.service'; import { PersonResolver } from './person.resolver'; @@ -20,6 +21,10 @@ describe('PersonResolver', () => { provide: AbilityFactory, useValue: {}, }, + { + provide: FileUploadService, + useValue: {}, + }, ], }).compile(); diff --git a/server/src/core/person/person.resolver.ts b/server/src/core/person/person.resolver.ts index 64281f4e0..927097d3e 100644 --- a/server/src/core/person/person.resolver.ts +++ b/server/src/core/person/person.resolver.ts @@ -10,6 +10,9 @@ import { UseGuards } from '@nestjs/common'; import { accessibleBy } from '@casl/prisma'; import { Prisma } from '@prisma/client'; +import { FileUpload, GraphQLUpload } from 'graphql-upload'; + +import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { Person } from 'src/core/@generated/person/person.model'; @@ -34,13 +37,18 @@ import { import { UserAbility } from 'src/decorators/user-ability.decorator'; import { AppAbility } from 'src/ability/ability.factory'; import { Workspace } from 'src/core/@generated/workspace/workspace.model'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { PersonService } from './person.service'; @UseGuards(JwtAuthGuard) @Resolver(() => Person) export class PersonResolver { - constructor(private readonly personService: PersonService) {} + constructor( + private readonly personService: PersonService, + private readonly fileUploadService: FileUploadService, + ) {} @Query(() => [Person], { nullable: false, @@ -156,4 +164,32 @@ export class PersonResolver { select: prismaSelect.value, } as Prisma.PersonCreateArgs); } + + @Mutation(() => String) + @UseGuards(AbilityGuard) + @CheckAbilities(UpdatePersonAbilityHandler) + async uploadPersonPicture( + @Args('id') id: string, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename, mimetype }: FileUpload, + ): Promise { + const stream = createReadStream(); + const buffer = await streamToBuffer(stream); + + const { paths } = await this.fileUploadService.uploadImage({ + file: buffer, + filename, + mimeType: mimetype, + fileFolder: FileFolder.PersonPicture, + }); + + await this.personService.update({ + where: { id }, + data: { + avatarUrl: paths[0], + }, + }); + + return paths[0]; + } }