From cf89dc92df0185a05967cecf939e6663c0c622a4 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 22:42:07 -0500 Subject: [PATCH] Restructure account api routes, fix update profile --- src/components/Account/UpdateProfileLink.tsx | 29 +++++ src/components/UserPage/UserHeader.tsx | 2 +- .../middleware/checkIfBeerPostOwner.ts | 31 ----- .../user-follows/useGetUsersFollowedByUser.ts | 6 +- .../user-follows/useGetUsersFollowingUser.ts | 4 + src/hooks/utilities/useNavbar.ts | 2 +- .../{profile.ts => [id]/profile/index.ts} | 0 .../api/users/[id]/profile/update-avatar.ts | 118 ++++++++++++++++++ .../api/users/[id]/profile/update-bio.ts | 89 +++++++++++++ .../account/edit-profile.tsx} | 93 +++++++++----- src/pages/{ => users}/account/index.tsx | 2 + .../Account/sendUpdateProfileRequest.tsx | 32 ----- .../Account/sendUpdateUserAvatarRequest.ts | 27 ++++ .../sendUpdateUserProfileRequest.ts.ts | 28 +++++ ...ofileSchema.tsx => UpdateProfileSchema.ts} | 6 +- 15 files changed, 366 insertions(+), 103 deletions(-) create mode 100644 src/components/Account/UpdateProfileLink.tsx delete mode 100644 src/config/nextConnect/middleware/checkIfBeerPostOwner.ts rename src/pages/api/users/{profile.ts => [id]/profile/index.ts} (100%) create mode 100644 src/pages/api/users/[id]/profile/update-avatar.ts create mode 100644 src/pages/api/users/[id]/profile/update-bio.ts rename src/pages/{account/profile.tsx => users/account/edit-profile.tsx} (68%) rename src/pages/{ => users}/account/index.tsx (96%) delete mode 100644 src/requests/Account/sendUpdateProfileRequest.tsx create mode 100644 src/requests/Account/sendUpdateUserAvatarRequest.ts create mode 100644 src/requests/Account/sendUpdateUserProfileRequest.ts.ts rename src/services/User/schema/{UpdateProfileSchema.tsx => UpdateProfileSchema.ts} (82%) diff --git a/src/components/Account/UpdateProfileLink.tsx b/src/components/Account/UpdateProfileLink.tsx new file mode 100644 index 0000000..e8c941a --- /dev/null +++ b/src/components/Account/UpdateProfileLink.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import React from 'react'; + +import { FaArrowRight } from 'react-icons/fa'; + +const UpdateProfileLink: React.FC = () => { + return ( +
+
+
+
+

Update Your Profile

+

You can update your profile information here.

+
+
+ + + +
+
+
+
+ ); +}; + +export default UpdateProfileLink; diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index b5280c4..156d66c 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -70,7 +70,7 @@ const UserHeader: FC = ({ user }) => { mutateFollowingCount={mutateFollowingCount} /> ) : ( - + Edit Profile )} diff --git a/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts b/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts deleted file mode 100644 index f5e1169..0000000 --- a/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { UserExtendedNextApiRequest } from '@/config/auth/types'; -import ServerError from '@/config/util/ServerError'; -import getBeerPostById from '@/services/BeerPost/getBeerPostById'; -import { NextApiResponse } from 'next'; -import { NextHandler } from 'next-connect'; - -interface CheckIfBeerPostOwnerRequest extends UserExtendedNextApiRequest { - query: { id: string }; -} - -const checkIfBeerPostOwner = async ( - req: RequestType, - res: NextApiResponse, - next: NextHandler, -) => { - const { id } = req.query; - const user = req.user!; - const beerPost = await getBeerPostById(id); - - if (!beerPost) { - throw new ServerError('Beer post not found', 404); - } - - if (beerPost.postedBy.id !== user.id) { - throw new ServerError('You are not authorized to edit this beer post', 403); - } - - return next(); -}; - -export default checkIfBeerPostOwner; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts index c8df097..dce2bfa 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -29,9 +29,9 @@ const useGetUsersFollowedByUser = ({ pageSize?: number; userId: string | undefined; }) => { - const fetcher = async (url: string | undefined) => { - if (!url) { - throw new Error('URL is undefined'); + const fetcher = async (url: string) => { + if (!userId) { + throw new Error('User ID is undefined'); } const response = await fetch(url); if (!response.ok) { diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts index a5ab521..fb3eb3d 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -33,6 +33,10 @@ interface UseGetUsersFollowingUser { const useGetUsersFollowingUser = ({ pageSize = 5, userId }: UseGetUsersFollowingUser) => { const fetcher = async (url: string) => { + if (!userId) { + throw new Error('User ID is undefined'); + } + const response = await fetch(url); if (!response.ok) { throw new Error(response.statusText); diff --git a/src/hooks/utilities/useNavbar.ts b/src/hooks/utilities/useNavbar.ts index 064f519..586d5e9 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -23,7 +23,7 @@ const useNavbar = () => { const { user } = useContext(UserContext); const authenticatedPages: readonly Page[] = [ - { slug: '/account', name: 'Account' }, + { slug: '/users/account', name: 'Account' }, { slug: `/users/${user?.id}`, name: 'Profile' }, { slug: '/api/users/logout', name: 'Logout' }, ]; diff --git a/src/pages/api/users/profile.ts b/src/pages/api/users/[id]/profile/index.ts similarity index 100% rename from src/pages/api/users/profile.ts rename to src/pages/api/users/[id]/profile/index.ts diff --git a/src/pages/api/users/[id]/profile/update-avatar.ts b/src/pages/api/users/[id]/profile/update-avatar.ts new file mode 100644 index 0000000..be6846b --- /dev/null +++ b/src/pages/api/users/[id]/profile/update-avatar.ts @@ -0,0 +1,118 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import { singleUploadMiddleware } from '@/config/multer/uploadMiddleware'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; + +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdateProfileRequest extends UserExtendedNextApiRequest { + file: Express.Multer.File; + body: { + bio: string; + }; +} + +interface UpdateUserProfileByIdParams { + id: string; + data: { + avatar: { + alt: string; + path: string; + caption: string; + }; + }; +} + +const updateUserAvatarById = async ({ id, data }: UpdateUserProfileByIdParams) => { + const user: z.infer = await DBClient.instance.user.update({ + where: { id }, + data: { + userAvatar: data.avatar + ? { + upsert: { + create: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + update: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + }, + } + : undefined, + }, + select: { + id: true, + username: true, + email: true, + bio: true, + userAvatar: true, + accountIsVerified: true, + createdAt: true, + firstName: true, + lastName: true, + updatedAt: true, + dateOfBirth: true, + role: true, + }, + }); + + return user; +}; + +const checkIfUserCanUpdateProfile = async ( + req: UpdateProfileRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const user = req.user!; + + if (user.id !== req.query.id) { + throw new ServerError('You can only update your own profile.', 403); + } + + await next(); +}; + +const updateProfile = async (req: UpdateProfileRequest, res: NextApiResponse) => { + const { file, user } = req; + + await updateUserAvatarById({ + id: user!.id, + data: { + avatar: { alt: file.originalname, path: file.path, caption: '' }, + }, + }); + res.status(200).json({ + message: 'User avatar updated successfully.', + statusCode: 200, + success: true, + }); +}; + +const router = createRouter< + UpdateProfileRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + checkIfUserCanUpdateProfile, + // @ts-expect-error + singleUploadMiddleware, + updateProfile, +); + +const handler = router.handler(); + +export default handler; +export const config = { api: { bodyParser: false } }; diff --git a/src/pages/api/users/[id]/profile/update-bio.ts b/src/pages/api/users/[id]/profile/update-bio.ts new file mode 100644 index 0000000..c75b91d --- /dev/null +++ b/src/pages/api/users/[id]/profile/update-bio.ts @@ -0,0 +1,89 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; + +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdateProfileRequest extends UserExtendedNextApiRequest { + body: { + bio: string; + }; +} + +interface UpdateUserProfileByIdParams { + id: string; + data: { bio: string }; +} + +const updateUserProfileById = async ({ id, data }: UpdateUserProfileByIdParams) => { + const user: z.infer = await DBClient.instance.user.update({ + where: { id }, + data: { bio: data.bio }, + select: { + id: true, + username: true, + email: true, + bio: true, + userAvatar: true, + accountIsVerified: true, + createdAt: true, + firstName: true, + lastName: true, + updatedAt: true, + dateOfBirth: true, + role: true, + }, + }); + + return user; +}; + +const updateProfile = async (req: UpdateProfileRequest, res: NextApiResponse) => { + const user = req.user!; + const { body } = req; + + await updateUserProfileById({ id: user!.id, data: { bio: body.bio } }); + + res.status(200).json({ + message: 'Profile updated successfully.', + statusCode: 200, + success: true, + }); +}; + +const checkIfUserCanUpdateProfile = async ( + req: UpdateProfileRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const user = req.user!; + + if (user.id !== req.query.id) { + throw new ServerError('You can only update your own profile.', 403); + } + + await next(); +}; + +const router = createRouter< + UpdateProfileRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + checkIfUserCanUpdateProfile, + validateRequest({ bodySchema: z.object({ bio: z.string().max(1000) }) }), + updateProfile, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/account/profile.tsx b/src/pages/users/account/edit-profile.tsx similarity index 68% rename from src/pages/account/profile.tsx rename to src/pages/users/account/edit-profile.tsx index 44057ff..bb04dad 100644 --- a/src/pages/account/profile.tsx +++ b/src/pages/users/account/edit-profile.tsx @@ -1,23 +1,30 @@ -import withPageAuthRequired from '@/util/withPageAuthRequired'; -import { GetServerSideProps, NextPage } from 'next'; -import { z } from 'zod'; +import { useContext, useEffect } from 'react'; + +import { GetServerSideProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; -import { zodResolver } from '@hookform/resolvers/zod'; import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + import toast from 'react-hot-toast'; + +import withPageAuthRequired from '@/util/withPageAuthRequired'; import createErrorToast from '@/util/createErrorToast'; -import UserAvatar from '@/components/Account/UserAvatar'; -import { useContext, useEffect } from 'react'; import UserContext from '@/contexts/UserContext'; +import UserAvatar from '@/components/Account/UserAvatar'; +import UpdateProfileForm from '@/components/Account/UpdateProfileForm'; + import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; -import Head from 'next/head'; -import UpdateProfileSchema from '../../services/User/schema/UpdateProfileSchema'; -import sendUpdateProfileRequest from '../../requests/Account/sendUpdateProfileRequest'; -import UpdateProfileForm from '../../components/Account/UpdateProfileForm'; +import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; +import sendUpdateUserAvatarRequest from '@/requests/Account/sendUpdateUserAvatarRequest'; +import sendUpdateUserProfileRequest from '@/requests/Account/sendUpdateUserProfileRequest.ts'; +import Spinner from '@/components/ui/Spinner'; const ProfilePage: NextPage = () => { const { user, mutate: mutateUser } = useContext(UserContext); @@ -28,31 +35,43 @@ const ProfilePage: NextPage = () => { setValue, formState: { errors, isSubmitting }, watch, - - reset, } = useForm>({ resolver: zodResolver(UpdateProfileSchema), - defaultValues: { - bio: user?.bio ?? '', - }, }); useEffect(() => { - if (!user || !user.bio) return; + if (!user || !user.bio) { + return; + } + setValue('bio', user.bio); }, [user, setValue]); + const router = useRouter(); + const onSubmit: SubmitHandler> = async (data) => { try { - await sendUpdateProfileRequest(data); const loadingToast = toast.loading('Updating profile...'); - await new Promise((resolve) => { - setTimeout(resolve, 1000); + if (!user) { + throw new Error('User is not logged in.'); + } + + if (data.userAvatar instanceof FileList && data.userAvatar.length === 1) { + await sendUpdateUserAvatarRequest({ + userId: user.id, + file: data.userAvatar[0], + }); + } + + await sendUpdateUserProfileRequest({ + userId: user.id, + bio: data.bio, }); + toast.remove(loadingToast); - reset(); - mutateUser!(); - toast.success('Profile updated!'); + await mutateUser!(); + await router.push(`/users/${user.id}`); + toast.success('Profile updated.'); } catch (error) { createErrorToast(error); } @@ -69,9 +88,11 @@ const ProfilePage: NextPage = () => { const watchedInput = watch('userAvatar'); if ( - !(watchedInput instanceof FileList) || - watchedInput.length !== 1 || - !watchedInput[0].type.startsWith('image/') + !( + watchedInput instanceof FileList && + watchedInput.length === 1 && + watchedInput[0].type.startsWith('image/') + ) ) { return ''; } @@ -87,13 +108,13 @@ const ProfilePage: NextPage = () => { The Biergarten App || Update Your Profile -
- {user && ( +
+ {user ? (
-
+
{ />
-

{user.username}

+

+ {user.username} +

-
+
{followingCount} Following {followerCount} Followers
-

- {watch('bio') || ( +

+ {watch('bio') ? ( + {watch('bio')} + ) : ( Your bio will appear here. )}

@@ -141,6 +166,10 @@ const ProfilePage: NextPage = () => {
+ ) : ( +
+ +
)}
diff --git a/src/pages/account/index.tsx b/src/pages/users/account/index.tsx similarity index 96% rename from src/pages/account/index.tsx rename to src/pages/users/account/index.tsx index d5cc046..fe42459 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/users/account/index.tsx @@ -11,6 +11,7 @@ import DeleteAccount from '@/components/Account/DeleteAccount'; import accountPageReducer from '@/reducers/accountPageReducer'; import UserAvatar from '@/components/Account/UserAvatar'; import UserPosts from '@/components/Account/UserPosts'; +import UpdateProfileLink from '@/components/Account/UpdateProfileLink'; const AccountPage: NextPage = () => { const { user } = useContext(UserContext); @@ -57,6 +58,7 @@ const AccountPage: NextPage = () => { + diff --git a/src/requests/Account/sendUpdateProfileRequest.tsx b/src/requests/Account/sendUpdateProfileRequest.tsx deleted file mode 100644 index 1a6b86b..0000000 --- a/src/requests/Account/sendUpdateProfileRequest.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; - -const sendUpdateProfileRequest = async (data: z.infer) => { - if (!(data.userAvatar instanceof FileList)) { - throw new Error('You must submit this form in a web browser.'); - } - - const { bio, userAvatar } = data; - - const formData = new FormData(); - if (userAvatar[0]) { - formData.append('image', userAvatar[0]); - } - - formData.append('bio', bio); - - const response = await fetch(`/api/users/profile`, { - method: 'PUT', - body: formData, - }); - - if (!response.ok) { - throw new Error('Something went wrong.'); - } - - const updatedUser = await response.json(); - - return updatedUser; -}; - -export default sendUpdateProfileRequest; diff --git a/src/requests/Account/sendUpdateUserAvatarRequest.ts b/src/requests/Account/sendUpdateUserAvatarRequest.ts new file mode 100644 index 0000000..8ce2c6c --- /dev/null +++ b/src/requests/Account/sendUpdateUserAvatarRequest.ts @@ -0,0 +1,27 @@ +interface UpdateProfileRequestParams { + file: File; + userId: string; +} + +const sendUpdateUserAvatarRequest = async ({ + file, + userId, +}: UpdateProfileRequestParams) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`/api/users/${userId}/`, { + method: 'PUT', + body: formData, + }); + + if (!response.ok) { + throw new Error('Failed to update profile.'); + } + + const json = await response.json(); + + return json; +}; + +export default sendUpdateUserAvatarRequest; diff --git a/src/requests/Account/sendUpdateUserProfileRequest.ts.ts b/src/requests/Account/sendUpdateUserProfileRequest.ts.ts new file mode 100644 index 0000000..8d421d6 --- /dev/null +++ b/src/requests/Account/sendUpdateUserProfileRequest.ts.ts @@ -0,0 +1,28 @@ +import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; +import { z } from 'zod'; + +interface UpdateProfileRequestParams { + userId: string; + bio: z.infer['bio']; +} + +const sendUpdateUserProfileRequest = async ({ + bio, + userId, +}: UpdateProfileRequestParams) => { + const response = await fetch(`/api/users/${userId}/profile/update-bio`, { + method: 'PUT', + body: JSON.stringify({ bio }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Failed to update profile.'); + } + + const json = await response.json(); + + return json; +}; + +export default sendUpdateUserProfileRequest; diff --git a/src/services/User/schema/UpdateProfileSchema.tsx b/src/services/User/schema/UpdateProfileSchema.ts similarity index 82% rename from src/services/User/schema/UpdateProfileSchema.tsx rename to src/services/User/schema/UpdateProfileSchema.ts index 93c3ed0..b8face2 100644 --- a/src/services/User/schema/UpdateProfileSchema.tsx +++ b/src/services/User/schema/UpdateProfileSchema.ts @@ -1,14 +1,14 @@ import { z } from 'zod'; const UpdateProfileSchema = z.object({ - bio: z.string().min(1, 'Bio cannot be empty'), + bio: z.string(), userAvatar: z .instanceof(typeof FileList !== 'undefined' ? FileList : Object) .refine((fileList) => fileList instanceof FileList, { message: 'You must submit this form in a web browser.', }) - .refine((fileList) => [...(fileList as FileList)].length === 1, { - message: 'You must upload one file.', + .refine((fileList) => [...(fileList as FileList)].length <= 1, { + message: 'You must upload only one or zero files.', }) .refine( (fileList) =>