From ab252c41b9ae1add007afdf68ed2de2bac899f97 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 00:23:30 -0500 Subject: [PATCH] Refactor Extract upload middleware to separate file and implement edit profile functionality. --- src/components/UserPage/UserHeader.tsx | 29 +++- src/config/multer/uploadMiddleware.ts | 30 ++++ .../beer-posts/useBeerPostsByBrewery.ts | 1 + src/pages/account/profile.tsx | 157 ++++++++++++++++++ src/pages/api/beers/[id]/images/index.ts | 27 +-- src/pages/api/breweries/[id]/beers/index.ts | 7 +- src/pages/api/breweries/[id]/images/index.ts | 27 +-- src/pages/api/users/profile.ts | 99 +++++++++++ 8 files changed, 319 insertions(+), 58 deletions(-) create mode 100644 src/config/multer/uploadMiddleware.ts create mode 100644 src/pages/account/profile.tsx create mode 100644 src/pages/api/users/profile.ts diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index 310b4e8..5227bf5 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -1,11 +1,13 @@ import useTimeDistance from '@/hooks/utilities/useTimeDistance'; -import { FC } from 'react'; +import { FC, useContext } from 'react'; import { z } from 'zod'; import { format } from 'date-fns'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; +import UserContext from '@/contexts/UserContext'; +import Link from 'next/link'; import UserAvatar from '../Account/UserAvatar'; import UserFollowButton from './UserFollowButton'; @@ -25,6 +27,8 @@ const UserHeader: FC = ({ user }) => { pageSize: 10, }); + const { user: currentUser } = useContext(UserContext); + return (
@@ -55,13 +59,22 @@ const UserHeader: FC = ({ user }) => {

{user.bio}

-
- -
+ + {currentUser?.id !== user.id ? ( +
+ +
+ ) : ( +
+ + Edit Profile + +
+ )}
); diff --git a/src/config/multer/uploadMiddleware.ts b/src/config/multer/uploadMiddleware.ts new file mode 100644 index 0000000..53a0f37 --- /dev/null +++ b/src/config/multer/uploadMiddleware.ts @@ -0,0 +1,30 @@ +import multer from 'multer'; +import { expressWrapper } from 'next-connect'; +import cloudinaryConfig from '../cloudinary'; + +const { storage } = cloudinaryConfig; + +const fileFilter: multer.Options['fileFilter'] = (req, file, callback) => { + const { mimetype } = file; + + const isImage = mimetype.startsWith('image/'); + + if (!isImage) { + callback(null, false); + } + callback(null, true); +}; + +export const uploadMiddlewareMultiple = expressWrapper( + multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array( + 'images', + ), +); + +export const singleUploadMiddleware = expressWrapper( + multer({ + storage, + fileFilter, + limits: { files: 1, fileSize: 15 * 1024 * 1024 }, + }).single('image'), +); diff --git a/src/hooks/data-fetching/beer-posts/useBeerPostsByBrewery.ts b/src/hooks/data-fetching/beer-posts/useBeerPostsByBrewery.ts index 9ac5619..628f022 100644 --- a/src/hooks/data-fetching/beer-posts/useBeerPostsByBrewery.ts +++ b/src/hooks/data-fetching/beer-posts/useBeerPostsByBrewery.ts @@ -33,6 +33,7 @@ const UseBeerPostsByBrewery = ({ pageSize, breweryId }: UseBeerPostsByBreweryPar } const json = await response.json(); + const count = response.headers.get('X-Total-Count'); const parsed = APIResponseValidationSchema.safeParse(json); diff --git a/src/pages/account/profile.tsx b/src/pages/account/profile.tsx new file mode 100644 index 0000000..7355edf --- /dev/null +++ b/src/pages/account/profile.tsx @@ -0,0 +1,157 @@ +import FormError from '@/components/ui/forms/FormError'; +import FormInfo from '@/components/ui/forms/FormInfo'; +import FormLabel from '@/components/ui/forms/FormLabel'; +import FormSegment from '@/components/ui/forms/FormSegment'; +import FormTextInput from '@/components/ui/forms/FormTextInput'; +import findUserById from '@/services/User/findUserById'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import withPageAuthRequired from '@/util/withPageAuthRequired'; +import { GetServerSideProps, NextPage } from 'next'; +import { z } from 'zod'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import createErrorToast from '@/util/createErrorToast'; +import Button from '@/components/ui/forms/Button'; + +interface ProfilePageProps { + user: z.infer; +} + +const UpdateProfileSchema = z.object({ + bio: z.string().min(1, 'Bio cannot be empty'), + 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 exactly one file.', + }) + .refine( + (fileList) => + [...(fileList as FileList)] + .map((file) => file.type) + .every((fileType) => fileType.startsWith('image/')), + { message: 'You must upload only images.' }, + ) + .refine( + (fileList) => + [...(fileList as FileList)] + .map((file) => file.size) + .every((fileSize) => fileSize < 15 * 1024 * 1024), + { message: 'You must upload images smaller than 15MB.' }, + ), +}); + +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(); + 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; +}; + +const ProfilePage: NextPage = ({ user }) => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reset, + } = useForm>({ + resolver: zodResolver(UpdateProfileSchema), + }); + + const onSubmit: SubmitHandler> = async (data) => { + try { + await sendUpdateProfileRequest(data); + const loadingToast = toast.loading('Updating profile...'); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + toast.remove(loadingToast); + // reset(); + toast.success('Profile updated!'); + } catch (error) { + createErrorToast(error); + } + }; + return ( +
+
+
{JSON.stringify(user, null, 2)}
+
+ + Bio + {errors.bio?.message} + + + + + + + + Avatar + {errors.userAvatar?.message} + + + + + +
+ +
+
+
+
+ ); +}; + +export default ProfilePage; + +export const getServerSideProps: GetServerSideProps = + withPageAuthRequired(async (context, session) => { + const { id } = session; + + const user = await findUserById(id); + + if (!user) { + return { notFound: true }; + } + + return { props: { user: JSON.parse(JSON.stringify(user)) } }; + }); diff --git a/src/pages/api/beers/[id]/images/index.ts b/src/pages/api/beers/[id]/images/index.ts index 1ade6cf..69a245f 100644 --- a/src/pages/api/beers/[id]/images/index.ts +++ b/src/pages/api/beers/[id]/images/index.ts @@ -1,38 +1,17 @@ import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { UserExtendedNextApiRequest } from '@/config/auth/types'; -import { createRouter, expressWrapper } from 'next-connect'; +import { createRouter } from 'next-connect'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; -import multer from 'multer'; - -import cloudinaryConfig from '@/config/cloudinary'; import { NextApiResponse } from 'next'; import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import addBeerImageToDB from '@/services/BeerImage/addBeerImageToDB'; import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema'; - -const { storage } = cloudinaryConfig; - -const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => { - const { mimetype } = file; - - const isImage = mimetype.startsWith('image/'); - - if (!isImage) { - cb(null, false); - } - cb(null, true); -}; - -const uploadMiddleware = expressWrapper( - multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array( - 'images', - ), -); +import { uploadMiddlewareMultiple } from '@/config/multer/uploadMiddleware'; interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest { files?: Express.Multer.File[]; @@ -75,7 +54,7 @@ const router = createRouter< router.post( getCurrentUser, // @ts-expect-error - uploadMiddleware, + uploadMiddlewareMultiple, validateRequest({ bodySchema: ImageMetadataValidationSchema }), processImageData, ); diff --git a/src/pages/api/breweries/[id]/beers/index.ts b/src/pages/api/breweries/[id]/beers/index.ts index 943d9ca..b2bcb59 100644 --- a/src/pages/api/breweries/[id]/beers/index.ts +++ b/src/pages/api/breweries/[id]/beers/index.ts @@ -18,11 +18,14 @@ const getAllBeersByBrewery = async ( // eslint-disable-next-line @typescript-eslint/naming-convention const { page_size, page_num, id } = req.query; + const pageNum = parseInt(page_num, 10); + const pageSize = parseInt(page_size, 10); + const beers: z.infer[] = await DBClient.instance.beerPost.findMany({ where: { breweryId: id }, - take: parseInt(page_size, 10), - skip: parseInt(page_num, 10) * parseInt(page_size, 10), + skip: (pageNum - 1) * pageSize, + take: pageSize, select: { id: true, name: true, diff --git a/src/pages/api/breweries/[id]/images/index.ts b/src/pages/api/breweries/[id]/images/index.ts index 780c5a4..ad056ef 100644 --- a/src/pages/api/breweries/[id]/images/index.ts +++ b/src/pages/api/breweries/[id]/images/index.ts @@ -1,38 +1,17 @@ import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { UserExtendedNextApiRequest } from '@/config/auth/types'; -import { createRouter, expressWrapper } from 'next-connect'; +import { createRouter } from 'next-connect'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; -import multer from 'multer'; - -import cloudinaryConfig from '@/config/cloudinary'; import { NextApiResponse } from 'next'; import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema'; import addBreweryImageToDB from '@/services/BreweryImage/addBreweryImageToDB'; - -const { storage } = cloudinaryConfig; - -const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => { - const { mimetype } = file; - - const isImage = mimetype.startsWith('image/'); - - if (!isImage) { - cb(null, false); - } - cb(null, true); -}; - -const uploadMiddleware = expressWrapper( - multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array( - 'images', - ), -); +import { uploadMiddlewareMultiple } from '@/config/multer/uploadMiddleware'; interface UploadBreweryPostImagesRequest extends UserExtendedNextApiRequest { files?: Express.Multer.File[]; @@ -75,7 +54,7 @@ const router = createRouter< router.post( getCurrentUser, // @ts-expect-error - uploadMiddleware, + uploadMiddlewareMultiple, validateRequest({ bodySchema: ImageMetadataValidationSchema }), processImageData, ); diff --git a/src/pages/api/users/profile.ts b/src/pages/api/users/profile.ts new file mode 100644 index 0000000..555c641 --- /dev/null +++ b/src/pages/api/users/profile.ts @@ -0,0 +1,99 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import { singleUploadMiddleware } from '@/config/multer/uploadMiddleware'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdateProfileRequest extends UserExtendedNextApiRequest { + file?: Express.Multer.File; + body: { + bio: string; + }; +} + +interface UpdateUserProfileByIdParams { + id: string; + data: { + bio: string; + avatar: { + alt: string; + path: string; + caption: string; + }; + }; +} + +const updateUserProfileById = async ({ id, data }: UpdateUserProfileByIdParams) => { + const { alt, path, caption } = data.avatar; + const user: z.infer = await DBClient.instance.user.update({ + where: { id }, + data: { + bio: data.bio, + userAvatar: { + upsert: { create: { alt, path, caption }, update: { alt, path, caption } }, + }, + }, + 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 { file, body, user } = req; + + if (!file) { + throw new Error('No file uploaded'); + } + + await updateUserProfileById({ + id: user!.id, + data: { + bio: body.bio, + avatar: { alt: file.originalname, path: file.path, caption: '' }, + }, + }); + res.status(200).json({ + message: 'User confirmed successfully.', + statusCode: 200, + success: true, + }); +}; + +const router = createRouter< + UpdateProfileRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + // @ts-expect-error + singleUploadMiddleware, + + validateRequest({ bodySchema: z.object({ bio: z.string().max(1000) }) }), + updateProfile, +); + +const handler = router.handler(); + +export default handler; +export const config = { api: { bodyParser: false } };