Skip to content

Commit

Permalink
Feat: #211 유저 프로필 이미지 업로드 기능 구현 (#212)
Browse files Browse the repository at this point in the history
* Feat: #211 유저 프로필 이미지 업로드 기능 구현

* Feat: #211 DB 명세서에 따라 프로필 사진 저장 방식 변경

* Feat: #211 유저 프로필 이미지 저장 형식을 UUID로 변경

* Chore: #211 코드리뷰 반영 수정

* Chore: #211 코드리뷰 반영 수정

* Chore: #211 토큰에서 유저 정보를 추출할 수 없을 때 오류 추가

* Feat: #211 이미지 업로드 시 서버 측에서 UUID를 가져와 zustand 스토어에 저장하도록 수정
  • Loading branch information
Yoonyesol authored Oct 20, 2024
1 parent 382fe2a commit c41604b
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 24 deletions.
25 changes: 18 additions & 7 deletions src/components/user/auth-form/ProfileImageContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { useEffect } from 'react';
import { GoPlusCircle } from 'react-icons/go';
import { FaRegTrashCan } from 'react-icons/fa6';
import { useFormContext } from 'react-hook-form';
import { convertBytesToString } from '@utils/converter';
import { USER_SETTINGS } from '@constants/settings';
import { JPG, PNG, SVG, WEBP } from '@constants/mimeFileType';
import useToast from '@hooks/useToast';
import { useEffect } from 'react';
import { useUploadProfileImage } from '@hooks/query/useUserQuery';
import useStore from '@stores/useStore';

type ProfileImageContainerProps = {
imageUrl: string | null;
setImageUrl: (url: string) => void;
};

export default function ProfileImageContainer({ imageUrl, setImageUrl }: ProfileImageContainerProps) {
const { setValue } = useFormContext();
const { toastWarn } = useToast();
const { mutate: uploadImageMutate } = useUploadProfileImage();
const { editUserInfo, userInfo } = useStore();

useEffect(() => {
return () => {
Expand All @@ -32,14 +35,22 @@ export default function ProfileImageContainer({ imageUrl, setImageUrl }: Profile
);
}

const image = URL.createObjectURL(file);
setImageUrl(image);
setValue('profileImageName', image);
const IMG_EXTENSIONS = [JPG, PNG, WEBP, SVG];
const permitType = IMG_EXTENSIONS.some((extensions) => extensions === file.type);
if (!permitType) {
e.target.value = '';
return toastWarn(`${IMG_EXTENSIONS.join(', ')} 형식의 이미지 파일만 업로드 가능합니다.`);
}

uploadImageMutate({ file });

const localImageUrl = URL.createObjectURL(file);
setImageUrl(localImageUrl);
};

const handleRemoveImg = () => {
setImageUrl('');
setValue('profileImageName', '');
editUserInfo({ profileImageName: null });
};

return (
Expand Down
30 changes: 28 additions & 2 deletions src/hooks/query/useUserQuery.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useToast from '@hooks/useToast';
import { updateLinks, updateUserInfo } from '@services/userService';
import { generateLinksQueryKey, generateUserInfoQueryKey } from '@utils/queryKeyGenerator';
import { updateLinks, updateUserInfo, uploadProfileImage } from '@services/userService';
import useStore from '@stores/useStore';
import { generateLinksQueryKey, generateProfileFileQueryKey, generateUserInfoQueryKey } from '@utils/queryKeyGenerator';
import type { EditUserInfoRequest, EditUserLinksForm } from '@/types/UserType';

export function useUpdateUserInfo() {
Expand All @@ -22,6 +22,32 @@ export function useUpdateUserInfo() {
return mutation;
}

export function useUploadProfileImage() {
const queryClient = useQueryClient();
const { toastSuccess, toastError } = useToast();
const { userInfo, editUserInfo } = useStore();
const userProfileImageQueryKey = generateProfileFileQueryKey(userInfo.userId);

const mutation = useMutation({
mutationFn: ({ file }: { file: File }) =>
uploadProfileImage(file, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
onError: () => toastError('이미지 업로드에 실패했습니다. 다시 시도해 주세요.'),
onSuccess: (response) => {
const { imageName } = response.data;

if (!imageName) return toastError('이미지 업로드에 실패했습니다. 다시 시도해 주세요.');

toastSuccess('이미지가 업로드되었습니다.');
editUserInfo({ profileImageName: imageName });
queryClient.invalidateQueries({ queryKey: userProfileImageQueryKey });
},
});

return mutation;
}

export function useUpdateLinks() {
const { userInfo } = useStore();
const queryClient = useQueryClient();
Expand Down
9 changes: 9 additions & 0 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import type { Task } from '@/types/TaskType';
import type { Role } from '@/types/RoleType';
import type { ProjectUser, TaskFileForMemory, TaskUser, TeamUser, UploadTaskFile } from '@/types/MockType';

type ImageInfo = {
userId: number;
file: Blob;
uploadName: string;
};

export const JWT_TOKEN_DUMMY = 'mocked-header.mocked-payload-4.mocked-signature';

export const VERIFICATION_CODE_DUMMY = '1234';
Expand Down Expand Up @@ -806,3 +812,6 @@ export const FILE_DUMMY: TaskFileForMemory[] = [
uploadName: 'FILE_UUID_3.txt',
},
];

// MSW 프로필 이미지 임시 저장을 위한 변수
export const PROFILE_IMAGE_DUMMY: ImageInfo[] = [];
70 changes: 59 additions & 11 deletions src/mocks/services/userServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { http, HttpResponse } from 'msw';
import { JWT_TOKEN_DUMMY, ROLE_DUMMY, TEAM_DUMMY, TEAM_USER_DUMMY, USER_DUMMY } from '@mocks/mockData';
import {
JWT_TOKEN_DUMMY,
PROFILE_IMAGE_DUMMY,
ROLE_DUMMY,
TEAM_DUMMY,
TEAM_USER_DUMMY,
USER_DUMMY,
} from '@mocks/mockData';
import { NICKNAME_REGEX } from '@constants/regex';
import { convertTokenToUserId } from '@utils/converter';
import { fileNameParser } from '@utils/fileNameParser';
import type { Team } from '@/types/TeamType';
import type { Role } from '@/types/RoleType';
import type { EditUserInfoForm, EditUserLinksForm, User } from '@/types/UserType';
Expand Down Expand Up @@ -29,7 +37,7 @@ const userServiceHandler = [

const userIndex = userId ? USER_DUMMY.findIndex((user) => user.userId === userId) : -1;

if (!userId || userIndex === -1) {
if (userIndex === -1) {
return HttpResponse.json(
{ message: '해당 사용자를 찾을 수 없습니다. 입력 정보를 확인해 주세요.' },
{ status: 401 },
Expand Down Expand Up @@ -80,19 +88,59 @@ const userServiceHandler = [

return HttpResponse.json(null, { status: 200 });
}),
// 유저 검색 API
http.get(`${BASE_URL}/user/search`, ({ request }) => {
const url = new URL(request.url);
const nickname = url.searchParams.get('nickname');
// 유저 프로필 이미지 업로드 API
http.post(`${BASE_URL}/user/profile/image`, async ({ request }) => {
const accessToken = request.headers.get('Authorization');

if (!accessToken) return new HttpResponse(null, { status: 401 });

// 접두사(nickname)과 일치하는 유저 정보 최대 5명 추출
const prefixRegex = new RegExp(`^${nickname}`);
const filteredUsers = USER_DUMMY.filter((user) => prefixRegex.test(user.nickname)).slice(0, 5);
const formData = await request.formData();
const file = formData.get('file');

if (!file) return new HttpResponse(null, { status: 400 });
if (!(file instanceof File)) return new HttpResponse('업로드된 문서는 파일이 아닙니다.', { status: 400 });

let userId;
// ToDo: 추후 삭제
if (accessToken === JWT_TOKEN_DUMMY) {
const payload = JWT_TOKEN_DUMMY.split('.')[1];
userId = Number(payload.replace('mocked-payload-', ''));
} else {
// 토큰에서 userId 추출
userId = convertTokenToUserId(accessToken);
}

if (!userId) {
return HttpResponse.json({ message: '토큰에 포함된 유저 정보가 존재하지 않습니다.' }, { status: 401 });
}

const userIndex = USER_DUMMY.findIndex((user) => user.userId === userId);

if (userIndex === -1) {
return HttpResponse.json(
{ message: '해당 사용자를 찾을 수 없습니다. 입력 정보를 확인해 주세요.' },
{ status: 401 },
);
}

const { fileName, extension } = fileNameParser(file.name);
const uploadName = extension ? `${fileName}_${Date.now()}.${extension}` : `${fileName}_${Date.now()}`;

// 유저 정보에 이미지 추가
USER_DUMMY[userIndex].profileImageName = uploadName;

// 프로필 이미지 더미 데이터 추가
const profileImageIndex = PROFILE_IMAGE_DUMMY.findIndex((user) => user.userId === userId);
if (profileImageIndex !== -1) {
PROFILE_IMAGE_DUMMY[profileImageIndex].uploadName = uploadName;
} else {
PROFILE_IMAGE_DUMMY.push({
userId,
file: new Blob([file], { type: file.type }),
uploadName,
});
}

return HttpResponse.json(filteredUsers);
return HttpResponse.json({ imageName: uploadName }, { status: 200 });
}),
// 전체 팀 목록 조회 API (가입한 팀, 대기중인 팀)
http.get(`${BASE_URL}/user/team`, ({ request }) => {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/setting/UserSettingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function UserSettingPage() {
profileImageName: userInfoData.profileImageName,
},
});
const { formState, register, setValue, watch, handleSubmit } = methods;
const { formState, register, watch, setValue, handleSubmit } = methods;
const nickname = watch('nickname');

const { checkedNickname, lastCheckedNickname, handleCheckNickname } = useNicknameDuplicateCheck(
Expand Down
15 changes: 15 additions & 0 deletions src/services/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export async function updateLinks(links: EditUserLinksForm, axiosConfig: AxiosRe
return authAxios.patch('/user/links', links, axiosConfig);
}

/**
* 유저 프로필 업로드 API
*
* @export
* @async
* @param {File} file - 파일 객체
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse<void>>}
*/
export async function uploadProfileImage(file: File, axiosConfig: AxiosRequestConfig = {}) {
const fileFormData = new FormData();
fileFormData.append('file', file);
return authAxios.postForm(`/user/profile/image`, fileFormData, axiosConfig);
}

/**
* 유저 목록을 검색하는 API
*
Expand Down
6 changes: 3 additions & 3 deletions src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { create, StateCreator } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { AUTH_SETTINGS } from '@constants/settings';
import { decrypt, encrypt } from '@utils/cryptoHelper';
import { EditUserInfoRequest, EditUserLinksForm, User } from '@/types/UserType';
import { EditUserInfoRequest, EditUserLinksForm, User, UserProfileImageForm } from '@/types/UserType';

// Auth Slice
type AuthStore = {
Expand All @@ -20,7 +20,7 @@ type AuthStore = {
type UserStore = {
userInfo: User;
setUserInfo: (newUserInfo: User) => void;
editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm) => void;
editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm | UserProfileImageForm) => void;
clearUserInfo: () => void;
};

Expand Down Expand Up @@ -90,7 +90,7 @@ const createUserSlice: StateCreator<Store, [], [], UserStore> = (set) => ({
userInfo: newUserInfo,
}),

editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm) =>
editUserInfo: (newUserInfo: EditUserInfoRequest | EditUserLinksForm | UserProfileImageForm) =>
set((state) => ({
userInfo: { ...state.userInfo, ...newUserInfo },
})),
Expand Down
2 changes: 2 additions & 0 deletions src/types/UserType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type UserInfo = User & {
password: string | null;
};

export type UserProfileImageForm = Pick<User, 'profileImageName'>;

export type SearchUser = Pick<User, 'userId' | 'nickname'>;
export type UserWithRole = SearchUser & Pick<Role, 'roleName'>;

Expand Down
12 changes: 12 additions & 0 deletions src/utils/queryKeyGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Project } from '@/types/ProjectType';

export const queryKeys = {
userInfo: 'userInfo',
profileImage: 'profileImage',
links: 'links',
users: 'users',
teams: 'teams',
Expand All @@ -25,6 +26,17 @@ export function generateUserInfoQueryKey() {
return [queryKeys.userInfo];
}

/**
* 유저 프로필 이미지 queryKey 생성 함수
*
* @export
* @param {number} userId - 유저의 고유 ID
* @returns {(string | number)[]}
*/
export function generateProfileFileQueryKey(userId: number) {
return [queryKeys.profileImage, userId];
}

/**
* 유저 링크 queryKey 생성 함수
*
Expand Down

0 comments on commit c41604b

Please sign in to comment.