From 7c87be09cfbc37cdf6fd9c42f7469f4b514573a7 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Nov 2023 22:04:55 -0500 Subject: [PATCH 1/7] Feat: implement client side functionality for user follow feature --- src/components/UserPage/UserFollowButton.tsx | 66 +++++++++++++++ src/components/UserPage/UserHeader.tsx | 27 +++++-- .../user-follows/useFollowStatus.ts | 38 +++++++++ .../user-follows/useGetUsersFollowedByUser.ts | 3 +- .../user-follows/useGetUsersFollowingUser.ts | 3 +- src/pages/account/index.tsx | 2 +- src/pages/api/users/[id]/follow-user.ts | 81 +++++++++++++++++++ src/pages/api/users/[id]/is-followed.ts | 71 ++++++++++++++++ src/pages/users/[id].tsx | 18 +---- ...quest.tsx => sendForgotPasswordRequest.ts} | 0 .../UserFollow/sendUserFollowRequest.ts | 16 ++++ 11 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 src/components/UserPage/UserFollowButton.tsx create mode 100644 src/hooks/data-fetching/user-follows/useFollowStatus.ts create mode 100644 src/pages/api/users/[id]/follow-user.ts create mode 100644 src/pages/api/users/[id]/is-followed.ts rename src/requests/User/{sendForgotPasswordRequest.tsx => sendForgotPasswordRequest.ts} (100%) create mode 100644 src/requests/UserFollow/sendUserFollowRequest.ts diff --git a/src/components/UserPage/UserFollowButton.tsx b/src/components/UserPage/UserFollowButton.tsx new file mode 100644 index 0000000..36f141c --- /dev/null +++ b/src/components/UserPage/UserFollowButton.tsx @@ -0,0 +1,66 @@ +import useFollowStatus from '@/hooks/data-fetching/user-follows/useFollowStatus'; +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; +import sendUserFollowRequest from '@/requests/UserFollow/sendUserFollowRequest'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import { FC, useState } from 'react'; +import { FaUserCheck, FaUserPlus } from 'react-icons/fa'; +import { z } from 'zod'; + +interface UserFollowButtonProps { + mutateFollowerCount: ReturnType['mutate']; + mutateFollowingCount: ReturnType['mutate']; + user: z.infer; +} + +const UserFollowButton: FC = ({ + user, + mutateFollowerCount, + mutateFollowingCount, +}) => { + const { isFollowed, mutate: mutateFollowStatus } = useFollowStatus(user.id); + + const [isLoading, setIsLoading] = useState(false); + + const onClick = async () => { + try { + setIsLoading(true); + await sendUserFollowRequest(user.id); + await Promise.all([ + mutateFollowStatus(), + mutateFollowerCount(), + mutateFollowingCount(), + ]); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default UserFollowButton; diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index be41117..409052e 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -1,20 +1,30 @@ import useTimeDistance from '@/hooks/utilities/useTimeDistance'; -import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; -import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; + import { FC } 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 UserAvatar from '../Account/UserAvatar'; +import UserFollowButton from './UserFollowButton'; interface UserHeaderProps { user: z.infer; - followerCount: ReturnType['followerCount']; - followingCount: ReturnType['followingCount']; } -const UserHeader: FC = ({ user, followerCount, followingCount }) => { +const UserHeader: FC = ({ user }) => { const timeDistance = useTimeDistance(new Date(user.createdAt)); + const { followingCount, mutate: mutateFollowingCount } = useGetUsersFollowedByUser({ + userId: user.id, + pageSize: 10, + }); + + const { followerCount, mutate: mutateFollowerCount } = useGetUsersFollowingUser({ + userId: user.id, + pageSize: 10, + }); + return (
@@ -42,6 +52,13 @@ const UserHeader: FC = ({ user, followerCount, followingCount } )} +
+ +
); diff --git a/src/hooks/data-fetching/user-follows/useFollowStatus.ts b/src/hooks/data-fetching/user-follows/useFollowStatus.ts new file mode 100644 index 0000000..c750ecf --- /dev/null +++ b/src/hooks/data-fetching/user-follows/useFollowStatus.ts @@ -0,0 +1,38 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWR from 'swr'; +import { z } from 'zod'; + +const useFollowStatus = (userFollowedId: string) => { + const { data, error, isLoading, mutate } = useSWR( + `/api/users/${userFollowedId}/is-followed`, + async (url) => { + const response = await fetch(url); + const json = await response.json(); + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Invalid API response.'); + } + + const { payload } = parsed.data; + const parsedPayload = z.object({ isFollowed: z.boolean() }).safeParse(payload); + + if (!parsedPayload.success) { + throw new Error('Invalid API response.'); + } + + const { isFollowed } = parsedPayload.data; + + return isFollowed; + }, + ); + + return { + isFollowed: data, + error: error as unknown, + isLoading, + mutate, + }; +}; + +export default useFollowStatus; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts index d3a3c4a..a1766a7 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -34,7 +34,7 @@ const useGetUsersFollowedByUser = ({ return { following: parsedPayload.data, pageCount, followingCount: count }; }; - const { data, error, isLoading, setSize, size } = useSWRInfinite( + const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite( (index) => `/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`, fetcher, @@ -57,6 +57,7 @@ const useGetUsersFollowedByUser = ({ isLoading, isLoadingMore, isAtEnd, + mutate, error: error as unknown, }; }; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts index a9b4763..a92d493 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -34,7 +34,7 @@ const useGetUsersFollowingUser = ({ return { followers: parsedPayload.data, pageCount, followerCount: count }; }; - const { data, error, isLoading, setSize, size } = useSWRInfinite( + const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite( (index) => `/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`, fetcher, @@ -57,6 +57,7 @@ const useGetUsersFollowingUser = ({ isLoading, isLoadingMore, isAtEnd, + mutate, error: error as unknown, }; }; diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 4d9405f..93dcf26 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -35,7 +35,7 @@ const AccountPage: NextPage = () => {
-
+
diff --git a/src/pages/api/users/[id]/follow-user.ts b/src/pages/api/users/[id]/follow-user.ts new file mode 100644 index 0000000..0bb438e --- /dev/null +++ b/src/pages/api/users/[id]/follow-user.ts @@ -0,0 +1,81 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +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 findUserById from '@/services/User/findUserById'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const followUser = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const currentUser = req.user!; + const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({ + where: { + followerId: currentUser.id, + followingId: id, + }, + }); + + if (!userIsFollowedBySessionUser) { + await DBClient.instance.userFollow.create({ + data: { followerId: currentUser.id, followingId: id }, + }); + + res.status(200).json({ + message: 'Now following user.', + success: true, + statusCode: 200, + }); + + return; + } + + await DBClient.instance.userFollow.delete({ + where: { + followerId_followingId: { + followerId: currentUser.id, + followingId: id, + }, + }, + }); + + res.status(200).json({ + message: 'No longer following user.', + success: true, + statusCode: 200, + }); +}; + +router.post( + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + getCurrentUser, + followUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/users/[id]/is-followed.ts b/src/pages/api/users/[id]/is-followed.ts new file mode 100644 index 0000000..682b15d --- /dev/null +++ b/src/pages/api/users/[id]/is-followed.ts @@ -0,0 +1,71 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +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 findUserById from '@/services/User/findUserById'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const checkIfUserIsFollowedBySessionUser = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const currentUser = req.user!; + + const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({ + where: { + followerId: currentUser.id, + followingId: id, + }, + }); + + if (!userIsFollowedBySessionUser) { + res.status(200).json({ + message: 'User is not followed by the current user.', + success: true, + statusCode: 200, + payload: { isFollowed: false }, + }); + + return; + } + + res.status(200).json({ + message: 'User is followed by the current user.', + success: true, + statusCode: 200, + payload: { isFollowed: true }, + }); +}; + +router.get( + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + getCurrentUser, + checkIfUserIsFollowedBySessionUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/users/[id].tsx b/src/pages/users/[id].tsx index 02d713b..a628cae 100644 --- a/src/pages/users/[id].tsx +++ b/src/pages/users/[id].tsx @@ -7,8 +7,6 @@ import { FC } from 'react'; import { z } from 'zod'; import withPageAuthRequired from '@/util/withPageAuthRequired'; import UserHeader from '@/components/UserPage/UserHeader'; -import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; -import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; interface UserInfoPageProps { user: z.infer; @@ -19,16 +17,6 @@ const UserInfoPage: FC = ({ user }) => { const isDesktop = useMediaQuery('(min-width: 1024px)'); const title = `${user.username} | The Biergarten App`; - const { followingCount } = useGetUsersFollowedByUser({ - userId: user.id, - pageSize: 10, - }); - - const { followerCount } = useGetUsersFollowingUser({ - userId: user.id, - pageSize: 10, - }); - return ( <> @@ -38,11 +26,7 @@ const UserInfoPage: FC = ({ user }) => { <>
- + {isDesktop ? (
diff --git a/src/requests/User/sendForgotPasswordRequest.tsx b/src/requests/User/sendForgotPasswordRequest.ts similarity index 100% rename from src/requests/User/sendForgotPasswordRequest.tsx rename to src/requests/User/sendForgotPasswordRequest.ts diff --git a/src/requests/UserFollow/sendUserFollowRequest.ts b/src/requests/UserFollow/sendUserFollowRequest.ts new file mode 100644 index 0000000..30f1347 --- /dev/null +++ b/src/requests/UserFollow/sendUserFollowRequest.ts @@ -0,0 +1,16 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +const sendUserFollowRequest = async (userId: string) => { + const response = await fetch(`/api/users/${userId}/follow-user`, { method: 'POST' }); + const json = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Invalid API response.'); + } + + return parsed; +}; + +export default sendUserFollowRequest; From 0c09db24a713b433d2468e308d1ec7ada9644737 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Nov 2023 23:06:31 -0500 Subject: [PATCH 2/7] Styles: update styling for user page header --- src/components/UserPage/UserHeader.tsx | 3 +++ src/pages/users/[id].tsx | 25 ++---------------------- src/prisma/seed/create/createNewUsers.ts | 2 +- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index 409052e..310b4e8 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -52,6 +52,9 @@ const UserHeader: FC = ({ user }) => { )} +
+

{user.bio}

+
= ({ user }) => { <> -
-
+
+
- - {isDesktop ? ( -
-
-
-
-

About Me

-

{user.bio}

-
-
-
- -
-
-
-
-
-
- ) : ( - <> - )}
diff --git a/src/prisma/seed/create/createNewUsers.ts b/src/prisma/seed/create/createNewUsers.ts index 87878cd..7c2af97 100644 --- a/src/prisma/seed/create/createNewUsers.ts +++ b/src/prisma/seed/create/createNewUsers.ts @@ -75,7 +75,7 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => { const dateOfBirth = faker.date.birthdate({ mode: 'age', min: 19 }); const createdAt = faker.date.past({ years: 4 }); - const bio = faker.lorem.paragraphs(3).replace(/\n/g, ' '); + const bio = faker.lorem.paragraphs(1).replace(/\n/g, ' '); const user: UserData = { firstName, From ab252c41b9ae1add007afdf68ed2de2bac899f97 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 00:23:30 -0500 Subject: [PATCH 3/7] 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 } }; From 208fdc31774f9cb9f6e04b20165b03efca8b5de2 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 02:11:46 -0500 Subject: [PATCH 4/7] Refactor: remove multer-storage-cloudinary --- package-lock.json | 147 ++++++++++++++------- package.json | 11 +- src/config/cloudinary/CloudinaryStorage.ts | 99 ++++++++++++++ src/config/cloudinary/index.ts | 12 +- src/config/multer/uploadMiddleware.ts | 4 +- 5 files changed, 208 insertions(+), 65 deletions(-) create mode 100644 src/config/cloudinary/CloudinaryStorage.ts diff --git a/package-lock.json b/package-lock.json index b819fe8..83d4ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@react-email/components": "^0.0.11", "@react-email/render": "^0.0.9", "@react-email/tailwind": "^0.0.12", + "@types/express": "^4.17.21", "@vercel/analytics": "^1.1.0", "argon2": "^0.31.1", "cloudinary": "^1.41.0", @@ -30,8 +31,8 @@ "lodash": "^4.17.21", "mapbox-gl": "^2.15.0", "multer": "^1.4.5-lts.1", - "multer-storage-cloudinary": "^4.0.0", "next": "^14.0.3", + "next-cloudinary": "^5.10.0", "next-connect": "^1.0.0-next.3", "passport": "^0.6.0", "passport-local": "^1.0.0", @@ -289,6 +290,36 @@ "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==", "dev": true }, + "node_modules/@cloudinary-util/url-loader": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@cloudinary-util/url-loader/-/url-loader-3.16.0.tgz", + "integrity": "sha512-KqZYuSkQg5FnlREecay5sJTXYdfg/3PaS9Az+XZbCcrdbQQJtWotyE4K3S3F7YBSqTdBItE/XqmgzmmcGLZ3Pw==", + "dependencies": { + "@cloudinary-util/util": "2.3.0", + "@cloudinary/url-gen": "^1.10.2" + } + }, + "node_modules/@cloudinary-util/util": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@cloudinary-util/util/-/util-2.3.0.tgz", + "integrity": "sha512-0Gojd+ZRQjJQmlBEAa8Ua94amvx7uWHoUzVUEGi1S8bgF1wPcMqG07cSUGQfRwHsFQ/9XOesx76Df622E+CevA==" + }, + "node_modules/@cloudinary/transformation-builder-sdk": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@cloudinary/transformation-builder-sdk/-/transformation-builder-sdk-1.8.0.tgz", + "integrity": "sha512-/QLSDDI+rfYH3bFH+DpgEk8NgSoeqwiea9GW5VXplcpebQOOcFqOzNFM6Hlik3PDqq4JMP4s3E7BoPZ1Rdk/IQ==", + "dependencies": { + "@cloudinary/url-gen": "^1.7.0" + } + }, + "node_modules/@cloudinary/url-gen": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@cloudinary/url-gen/-/url-gen-1.13.0.tgz", + "integrity": "sha512-p7ASCX2fZw1JWtMG2ADd/Y0TfvE5SX9v0TDJDlzXX3OlNxLSuqzLWGgj5oJD4ZTo62FdxH24N8HPZ7NXEXtj5g==", + "dependencies": { + "@cloudinary/transformation-builder-sdk": "^1.7.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -2030,7 +2061,6 @@ "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", "integrity": "sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -2057,7 +2087,6 @@ "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -2078,10 +2107,9 @@ } }, "node_modules/@types/express": { - "version": "4.17.18", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.18.tgz", - "integrity": "sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ==", - "dev": true, + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2093,7 +2121,6 @@ "version": "4.17.37", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz", "integrity": "sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -2114,8 +2141,7 @@ "node_modules/@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", - "integrity": "sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==", - "dev": true + "integrity": "sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==" }, "node_modules/@types/json-schema": { "version": "7.0.13", @@ -2190,8 +2216,7 @@ "node_modules/@types/mime": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz", - "integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==", - "dev": true + "integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==" }, "node_modules/@types/minimist": { "version": "1.2.3", @@ -2285,14 +2310,12 @@ "node_modules/@types/qs": { "version": "6.9.8", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.8.tgz", - "integrity": "sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==", - "dev": true + "integrity": "sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==" }, "node_modules/@types/range-parser": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.5.tgz", - "integrity": "sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==", - "dev": true + "integrity": "sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==" }, "node_modules/@types/react": { "version": "18.2.25", @@ -2370,7 +2393,6 @@ "version": "0.17.2", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.2.tgz", "integrity": "sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -2380,7 +2402,6 @@ "version": "1.15.3", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.3.tgz", "integrity": "sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/mime": "*", @@ -7474,14 +7495,6 @@ "node": ">= 6.0.0" } }, - "node_modules/multer-storage-cloudinary": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", - "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", - "peerDependencies": { - "cloudinary": "^1.21.0" - } - }, "node_modules/murmurhash-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", @@ -7565,6 +7578,19 @@ } } }, + "node_modules/next-cloudinary": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/next-cloudinary/-/next-cloudinary-5.10.0.tgz", + "integrity": "sha512-FmLvteYJjpvE69pNZlp/xzwf32/iNuYM9INrh0GTNhgKxoKP/Ej/ij+kjKkZq8eAA8epSeDh3IUoewwRpJfuTQ==", + "dependencies": { + "@cloudinary-util/url-loader": "^3.16.0", + "@cloudinary-util/util": "^2.3.0" + }, + "peerDependencies": { + "next": "^12 || ^13 || ^14", + "react": "^17 || ^18" + } + }, "node_modules/next-connect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-connect/-/next-connect-1.0.0.tgz", @@ -10941,6 +10967,36 @@ "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==", "dev": true }, + "@cloudinary-util/url-loader": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@cloudinary-util/url-loader/-/url-loader-3.16.0.tgz", + "integrity": "sha512-KqZYuSkQg5FnlREecay5sJTXYdfg/3PaS9Az+XZbCcrdbQQJtWotyE4K3S3F7YBSqTdBItE/XqmgzmmcGLZ3Pw==", + "requires": { + "@cloudinary-util/util": "2.3.0", + "@cloudinary/url-gen": "^1.10.2" + } + }, + "@cloudinary-util/util": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@cloudinary-util/util/-/util-2.3.0.tgz", + "integrity": "sha512-0Gojd+ZRQjJQmlBEAa8Ua94amvx7uWHoUzVUEGi1S8bgF1wPcMqG07cSUGQfRwHsFQ/9XOesx76Df622E+CevA==" + }, + "@cloudinary/transformation-builder-sdk": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@cloudinary/transformation-builder-sdk/-/transformation-builder-sdk-1.8.0.tgz", + "integrity": "sha512-/QLSDDI+rfYH3bFH+DpgEk8NgSoeqwiea9GW5VXplcpebQOOcFqOzNFM6Hlik3PDqq4JMP4s3E7BoPZ1Rdk/IQ==", + "requires": { + "@cloudinary/url-gen": "^1.7.0" + } + }, + "@cloudinary/url-gen": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@cloudinary/url-gen/-/url-gen-1.13.0.tgz", + "integrity": "sha512-p7ASCX2fZw1JWtMG2ADd/Y0TfvE5SX9v0TDJDlzXX3OlNxLSuqzLWGgj5oJD4ZTo62FdxH24N8HPZ7NXEXtj5g==", + "requires": { + "@cloudinary/transformation-builder-sdk": "^1.7.0" + } + }, "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -12079,7 +12135,6 @@ "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", "integrity": "sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -12106,7 +12161,6 @@ "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", - "dev": true, "requires": { "@types/node": "*" } @@ -12127,10 +12181,9 @@ } }, "@types/express": { - "version": "4.17.18", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.18.tgz", - "integrity": "sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ==", - "dev": true, + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -12142,7 +12195,6 @@ "version": "4.17.37", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz", "integrity": "sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==", - "dev": true, "requires": { "@types/node": "*", "@types/qs": "*", @@ -12163,8 +12215,7 @@ "@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", - "integrity": "sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==", - "dev": true + "integrity": "sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==" }, "@types/json-schema": { "version": "7.0.13", @@ -12239,8 +12290,7 @@ "@types/mime": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz", - "integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==", - "dev": true + "integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==" }, "@types/minimist": { "version": "1.2.3", @@ -12333,14 +12383,12 @@ "@types/qs": { "version": "6.9.8", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.8.tgz", - "integrity": "sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==", - "dev": true + "integrity": "sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==" }, "@types/range-parser": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.5.tgz", - "integrity": "sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==", - "dev": true + "integrity": "sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==" }, "@types/react": { "version": "18.2.25", @@ -12417,7 +12465,6 @@ "version": "0.17.2", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.2.tgz", "integrity": "sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==", - "dev": true, "requires": { "@types/mime": "^1", "@types/node": "*" @@ -12427,7 +12474,6 @@ "version": "1.15.3", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.3.tgz", "integrity": "sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg==", - "dev": true, "requires": { "@types/http-errors": "*", "@types/mime": "*", @@ -16062,12 +16108,6 @@ "xtend": "^4.0.0" } }, - "multer-storage-cloudinary": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", - "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", - "requires": {} - }, "murmurhash-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", @@ -16117,6 +16157,15 @@ "watchpack": "2.4.0" } }, + "next-cloudinary": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/next-cloudinary/-/next-cloudinary-5.10.0.tgz", + "integrity": "sha512-FmLvteYJjpvE69pNZlp/xzwf32/iNuYM9INrh0GTNhgKxoKP/Ej/ij+kjKkZq8eAA8epSeDh3IUoewwRpJfuTQ==", + "requires": { + "@cloudinary-util/url-loader": "^3.16.0", + "@cloudinary-util/util": "^2.3.0" + } + }, "next-connect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-connect/-/next-connect-1.0.0.tgz", diff --git a/package.json b/package.json index e6f4f03..7c8276c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@react-email/components": "^0.0.11", "@react-email/render": "^0.0.9", "@react-email/tailwind": "^0.0.12", + "@types/express": "^4.17.21", "@vercel/analytics": "^1.1.0", "argon2": "^0.31.1", "cloudinary": "^1.41.0", @@ -35,8 +36,8 @@ "lodash": "^4.17.21", "mapbox-gl": "^2.15.0", "multer": "^1.4.5-lts.1", - "multer-storage-cloudinary": "^4.0.0", "next": "^14.0.3", + "next-cloudinary": "^5.10.0", "next-connect": "^1.0.0-next.3", "passport": "^0.6.0", "passport-local": "^1.0.0", @@ -64,28 +65,28 @@ "@types/multer": "^1.4.7", "@types/node": "^20.4.2", "@types/passport-local": "^1.0.35", - "@types/react-dom": "^18.2.7", "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", "@types/sparkpost": "^2.1.5", "@vercel/fetch": "^7.0.0", "autoprefixer": "^10.4.14", "daisyui": "^3.9.2", "dotenv-cli": "^7.2.1", + "eslint": "^8.51.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-typescript": "17.1.0", "eslint-config-next": "^13.5.4", "eslint-config-prettier": "^9.0.0", "eslint-plugin-react": "^7.33.2", - "eslint": "^8.51.0", "generate-password": "^1.7.1", "onchange": "^7.1.0", "postcss": "^8.4.26", + "prettier": "^3.0.0", "prettier-plugin-jsdoc": "^1.0.2", "prettier-plugin-tailwindcss": "^0.4.1", - "prettier": "^3.0.0", "prisma": "^5.6.0", - "tailwindcss-animate": "^1.0.6", "tailwindcss": "^3.3.3", + "tailwindcss-animate": "^1.0.6", "ts-node": "^10.9.1", "typescript": "^5.3.2" }, diff --git a/src/config/cloudinary/CloudinaryStorage.ts b/src/config/cloudinary/CloudinaryStorage.ts new file mode 100644 index 0000000..e60db02 --- /dev/null +++ b/src/config/cloudinary/CloudinaryStorage.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-underscore-dangle */ + +import type { StorageEngine } from 'multer'; +import type { UploadApiOptions, UploadApiResponse, v2 as cloudinary } from 'cloudinary'; +import type { Request } from 'express'; + +/** + * Represents a storage engine for uploading files to Cloudinary. + * + * @example + * const storage = new CloudinaryStorage({ + * cloudinary, + * params: { + * folder: 'my-folder', + * allowed_formats: ['jpg', 'png'], + * }, + * }); + */ +class CloudinaryStorage implements StorageEngine { + private cloudinary: typeof cloudinary; + + private params: UploadApiOptions; + + /** + * Creates an instance of CloudinaryStorage. + * + * @param options - The options for configuring the Cloudinary storage engine. + * @param options.cloudinary - The Cloudinary instance. + * @param options.params - The parameters for uploading files to Cloudinary. + */ + constructor(options: { cloudinary: typeof cloudinary; params: UploadApiOptions }) { + this.cloudinary = options.cloudinary; + this.params = options.params; + } + + /** + * Removes the file from Cloudinary. + * + * @param req - The request object. + * @param file - The file to be removed. + * @param callback - The callback function to be called if an error occurs. + */ + public _removeFile( + req: Request, + file: Express.Multer.File, + callback: (error: Error) => void, + ) { + this.cloudinary.uploader.destroy(file.filename, { invalidate: true }, callback); + } + + /** + * Handles the file upload to Cloudinary. + * + * @param req - The request object. + * @param file - The file to be uploaded. + * @param callback - The callback function to be called after the file is uploaded. + */ + public _handleFile( + req: Request, + file: Express.Multer.File, + callback: (error?: unknown, info?: Partial) => void, + ) { + this.uploadFile(file) + .then((cloudResponse) => { + callback(null, { + path: cloudResponse.secure_url, + size: cloudResponse.bytes, + filename: cloudResponse.public_id, + }); + }) + .catch((error) => { + callback(error); + }); + } + + /** + * Uploads a file to Cloudinary. + * + * @param file - The file to be uploaded. + * @returns A promise that resolves to the upload response. + */ + private uploadFile(file: Express.Multer.File): Promise { + return new Promise((resolve, reject) => { + const stream = this.cloudinary.uploader.upload_stream( + this.params, + (err, response) => { + if (err != null) { + return reject(err); + } + return resolve(response!); + }, + ); + + file.stream.pipe(stream); + }); + } +} + +export default CloudinaryStorage; diff --git a/src/config/cloudinary/index.ts b/src/config/cloudinary/index.ts index 655c9bb..e45c40a 100644 --- a/src/config/cloudinary/index.ts +++ b/src/config/cloudinary/index.ts @@ -1,8 +1,7 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { v2 as cloudinary } from 'cloudinary'; -import { CloudinaryStorage } from 'multer-storage-cloudinary'; import { CLOUDINARY_CLOUD_NAME, CLOUDINARY_KEY, CLOUDINARY_SECRET } from '../env'; +import CloudinaryStorage from './CloudinaryStorage'; cloudinary.config({ cloud_name: CLOUDINARY_CLOUD_NAME, @@ -10,10 +9,7 @@ cloudinary.config({ api_secret: CLOUDINARY_SECRET, }); -// @ts-expect-error -const storage = new CloudinaryStorage({ cloudinary, params: { folder: 'BeerApp' } }); +/** Cloudinary storage instance. */ +const storage = new CloudinaryStorage({ cloudinary, params: { folder: 'biergarten' } }); -/** Configuration object for Cloudinary image upload. */ -const cloudinaryConfig = { cloudinary, storage }; - -export default cloudinaryConfig; +export { cloudinary, storage }; diff --git a/src/config/multer/uploadMiddleware.ts b/src/config/multer/uploadMiddleware.ts index 53a0f37..bdfe28b 100644 --- a/src/config/multer/uploadMiddleware.ts +++ b/src/config/multer/uploadMiddleware.ts @@ -1,8 +1,6 @@ import multer from 'multer'; import { expressWrapper } from 'next-connect'; -import cloudinaryConfig from '../cloudinary'; - -const { storage } = cloudinaryConfig; +import { storage } from '../cloudinary'; const fileFilter: multer.Options['fileFilter'] = (req, file, callback) => { const { mimetype } = file; From 293200fbe22e65268917bfb34b1501d427651f02 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 14:33:06 -0500 Subject: [PATCH 5/7] Refactor: replace all next/image components with Cloudinary wrapper --- src/components/Account/UserAvatar.tsx | 5 +++-- src/components/BeerIndex/BeerCard.tsx | 6 ++++-- src/components/BreweryIndex/BreweryCard.tsx | 6 ++++-- src/config/cloudinary/CloudinaryStorage.ts | 8 ++------ src/pages/beers/[id]/index.tsx | 6 +++--- src/pages/breweries/[id]/index.tsx | 6 ++++-- src/pages/login/index.tsx | 10 +++++----- 7 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/components/Account/UserAvatar.tsx b/src/components/Account/UserAvatar.tsx index 385082d..f1c54bd 100644 --- a/src/components/Account/UserAvatar.tsx +++ b/src/components/Account/UserAvatar.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import Image from 'next/image'; +import { CldImage } from 'next-cloudinary'; import { z } from 'zod'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; import { FaUser } from 'react-icons/fa'; @@ -25,11 +25,12 @@ const UserAvatar: FC = ({ user }) => {
) : ( - user avatar ); diff --git a/src/components/BeerIndex/BeerCard.tsx b/src/components/BeerIndex/BeerCard.tsx index 09e03d4..1d8eac9 100644 --- a/src/components/BeerIndex/BeerCard.tsx +++ b/src/components/BeerIndex/BeerCard.tsx @@ -1,10 +1,11 @@ import Link from 'next/link'; import { FC, useContext } from 'react'; -import Image from 'next/image'; + import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { z } from 'zod'; import UserContext from '@/contexts/UserContext'; import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount'; +import { CldImage } from 'next-cloudinary'; import BeerPostLikeButton from '../BeerById/BeerPostLikeButton'; const BeerCard: FC<{ post: z.infer }> = ({ post }) => { @@ -16,9 +17,10 @@ const BeerCard: FC<{ post: z.infer }> = ({ post }) =
{post.beerImages.length > 0 && ( - {post.name} }> = ({ @@ -17,11 +18,12 @@ const BreweryCard: FC<{ brewery: z.infer }> = ({
{brewery.breweryImages.length > 0 && ( - {brewery.name} )} diff --git a/src/config/cloudinary/CloudinaryStorage.ts b/src/config/cloudinary/CloudinaryStorage.ts index e60db02..1b4e40a 100644 --- a/src/config/cloudinary/CloudinaryStorage.ts +++ b/src/config/cloudinary/CloudinaryStorage.ts @@ -40,11 +40,7 @@ class CloudinaryStorage implements StorageEngine { * @param file - The file to be removed. * @param callback - The callback function to be called if an error occurs. */ - public _removeFile( - req: Request, - file: Express.Multer.File, - callback: (error: Error) => void, - ) { + _removeFile(req: Request, file: Express.Multer.File, callback: (error: Error) => void) { this.cloudinary.uploader.destroy(file.filename, { invalidate: true }, callback); } @@ -55,7 +51,7 @@ class CloudinaryStorage implements StorageEngine { * @param file - The file to be uploaded. * @param callback - The callback function to be called after the file is uploaded. */ - public _handleFile( + _handleFile( req: Request, file: Express.Multer.File, callback: (error?: unknown, info?: Partial) => void, diff --git a/src/pages/beers/[id]/index.tsx b/src/pages/beers/[id]/index.tsx index 97db821..ade7a96 100644 --- a/src/pages/beers/[id]/index.tsx +++ b/src/pages/beers/[id]/index.tsx @@ -1,7 +1,5 @@ import { NextPage, GetServerSideProps } from 'next'; import Head from 'next/head'; -import Image from 'next/image'; - import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; @@ -13,6 +11,7 @@ import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/utilities/useMediaQuery'; import { Tab } from '@headlessui/react'; import dynamic from 'next/dynamic'; +import { CldImage } from 'next-cloudinary'; const [BeerInfoHeader, BeerPostCommentsSection, BeerRecommendations] = [ dynamic(() => import('@/components/BeerById/BeerInfoHeader')), @@ -45,10 +44,11 @@ const BeerByIdPage: NextPage = ({ beerPost }) => { {beerPost.beerImages.length ? beerPost.beerImages.map((image, index) => (
- {image.alt} diff --git a/src/pages/breweries/[id]/index.tsx b/src/pages/breweries/[id]/index.tsx index 80510b2..b593086 100644 --- a/src/pages/breweries/[id]/index.tsx +++ b/src/pages/breweries/[id]/index.tsx @@ -4,7 +4,7 @@ import { GetServerSideProps, NextPage } from 'next'; import { z } from 'zod'; import Head from 'next/head'; -import Image from 'next/image'; + import 'react-responsive-carousel/lib/styles/carousel.min.css'; // requires a loader import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/utilities/useMediaQuery'; @@ -12,6 +12,7 @@ import { Tab } from '@headlessui/react'; import dynamic from 'next/dynamic'; import { MAPBOX_ACCESS_TOKEN } from '@/config/env'; +import { CldImage } from 'next-cloudinary'; const [BreweryInfoHeader, BreweryBeersSection, BreweryCommentsSection, BreweryPostMap] = [ dynamic(() => import('@/components/BreweryById/BreweryInfoHeader')), @@ -47,9 +48,10 @@ const BreweryByIdPage: NextPage = ({ breweryPost, mapboxToken {breweryPost.breweryImages.length ? breweryPost.breweryImages.map((image, index) => (
- {image.alt} { useRedirectWhenLoggedIn(); @@ -20,11 +20,11 @@ const LoginPage: NextPage = () => {
- Login Image
From 49d5b782a95fe4e7b78f29d3e07211bf41b2738c Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 14:34:26 -0500 Subject: [PATCH 6/7] Add proper toast notifications to edit/create beer post. fix delete --- src/components/CreateBeerPostForm.tsx | 7 ++++--- src/components/EditBeerPostForm.tsx | 12 ++++++++---- src/pages/api/beers/[id]/index.ts | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/CreateBeerPostForm.tsx b/src/components/CreateBeerPostForm.tsx index d77ef37..2ef6fcf 100644 --- a/src/components/CreateBeerPostForm.tsx +++ b/src/components/CreateBeerPostForm.tsx @@ -12,6 +12,7 @@ import sendUploadBeerImagesRequest from '@/requests/BeerImage/sendUploadBeerImag import toast from 'react-hot-toast'; +import createErrorToast from '@/util/createErrorToast'; import Button from './ui/forms/Button'; import FormError from './ui/forms/FormError'; import FormInfo from './ui/forms/FormInfo'; @@ -51,14 +52,14 @@ const CreateBeerPostForm: FunctionComponent = ({ } try { + const loadingToast = toast.loading('Creating beer post...'); const beerPost = await sendCreateBeerPostRequest(data); await sendUploadBeerImagesRequest({ beerPost, images: data.images }); await router.push(`/beers/${beerPost.id}`); + toast.dismiss(loadingToast); toast.success('Created beer post.'); } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Something went wrong.'; - - toast.error(errorMessage); + createErrorToast(e); } }; diff --git a/src/components/EditBeerPostForm.tsx b/src/components/EditBeerPostForm.tsx index 1897c8f..ca0a8d6 100644 --- a/src/components/EditBeerPostForm.tsx +++ b/src/components/EditBeerPostForm.tsx @@ -9,6 +9,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import deleteBeerPostRequest from '@/requests/BeerPost/deleteBeerPostRequest'; import EditBeerPostValidationSchema from '@/services/BeerPost/schema/EditBeerPostValidationSchema'; import sendEditBeerPostRequest from '@/requests/BeerPost/sendEditBeerPostRequest'; +import createErrorToast from '@/util/createErrorToast'; import Button from './ui/forms/Button'; import FormError from './ui/forms/FormError'; import FormInfo from './ui/forms/FormInfo'; @@ -33,23 +34,26 @@ const EditBeerPostForm: FC = ({ previousValues }) => { const { isSubmitting, errors } = formState; const onSubmit: SubmitHandler = async (data) => { try { + const loadingToast = toast.loading('Editing beer post...'); await sendEditBeerPostRequest(data); await router.push(`/beers/${data.id}`); toast.success('Edited beer post.'); + toast.dismiss(loadingToast); } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Something went wrong.'; - toast.error(errorMessage); + createErrorToast(e); await router.push(`/beers/${data.id}`); } }; const onDelete = async () => { try { + const loadingToast = toast.loading('Deleting beer post...'); await deleteBeerPostRequest(previousValues.id); + toast.dismiss(loadingToast); await router.push('/beers'); + toast.success('Deleted beer post.'); } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Something went wrong.'; - toast.error(errorMessage); + createErrorToast(e); await router.push(`/beers`); } }; diff --git a/src/pages/api/beers/[id]/index.ts b/src/pages/api/beers/[id]/index.ts index 5517867..aeb526d 100644 --- a/src/pages/api/beers/[id]/index.ts +++ b/src/pages/api/beers/[id]/index.ts @@ -57,7 +57,7 @@ const editBeerPost = async ( const deleteBeerPost = async (req: BeerPostRequest, res: NextApiResponse) => { const { id } = req.query; - const deleted = deleteBeerPostById({ beerPostId: id }); + const deleted = await deleteBeerPostById({ beerPostId: id }); if (!deleted) { throw new ServerError('Beer post not found', 404); } From f5941a9ae5280747415217ac0c8bf40af8517fae Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Fri, 1 Dec 2023 14:55:56 -0500 Subject: [PATCH 7/7] Add NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME to mandatory env vars --- README.md | 6 +++--- src/config/cloudinary/index.ts | 8 ++++++-- src/config/env/index.ts | 5 +++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3afa8c4..50867c6 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ npm install ```bash echo "BASE_URL= -CLOUDINARY_CLOUD_NAME= +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= CLOUDINARY_KEY= CLOUDINARY_SECRET= CONFIRMATION_TOKEN_SECRET= @@ -149,8 +149,8 @@ SPARKPOST_SENDER_ADDRESS=" > .env - `BASE_URL` is the base URL of the application. - For example, if you are running the application locally, you can set this to `http://localhost:3000`. -- `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_KEY`, and `CLOUDINARY_SECRET` are the credentials - for your Cloudinary account. +- `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_KEY`, and `CLOUDINARY_SECRET` are the + credentials for your Cloudinary account. - You can create a free account [here](https://cloudinary.com/users/register/free). - `CONFIRMATION_TOKEN_SECRET` is the secret used to sign the confirmation token used for email confirmation. diff --git a/src/config/cloudinary/index.ts b/src/config/cloudinary/index.ts index e45c40a..625b176 100644 --- a/src/config/cloudinary/index.ts +++ b/src/config/cloudinary/index.ts @@ -1,10 +1,14 @@ import { v2 as cloudinary } from 'cloudinary'; -import { CLOUDINARY_CLOUD_NAME, CLOUDINARY_KEY, CLOUDINARY_SECRET } from '../env'; +import { + NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, + CLOUDINARY_KEY, + CLOUDINARY_SECRET, +} from '../env'; import CloudinaryStorage from './CloudinaryStorage'; cloudinary.config({ - cloud_name: CLOUDINARY_CLOUD_NAME, + cloud_name: NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, api_key: CLOUDINARY_KEY, api_secret: CLOUDINARY_SECRET, }); diff --git a/src/config/env/index.ts b/src/config/env/index.ts index 426e828..2fe19e8 100644 --- a/src/config/env/index.ts +++ b/src/config/env/index.ts @@ -11,7 +11,7 @@ import 'dotenv/config'; */ const envSchema = z.object({ BASE_URL: z.string().url(), - CLOUDINARY_CLOUD_NAME: z.string(), + NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: z.string(), CLOUDINARY_KEY: z.string(), CLOUDINARY_SECRET: z.string(), RESET_PASSWORD_TOKEN_SECRET: z.string(), @@ -56,7 +56,8 @@ export const BASE_URL = parsed.data.BASE_URL; * @see https://cloudinary.com/console */ -export const CLOUDINARY_CLOUD_NAME = parsed.data.CLOUDINARY_CLOUD_NAME; +export const NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME = + parsed.data.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME; /** * Cloudinary API key.