Extract upload middleware to separate file and implement edit profile functionality.
This commit is contained in:
Aaron William Po
2023-12-01 00:23:30 -05:00
parent 0c09db24a7
commit ab252c41b9
8 changed files with 319 additions and 58 deletions

View File

@@ -1,11 +1,13 @@
import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { FC } from 'react'; import { FC, useContext } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { format } from 'date-fns'; import { format } from 'date-fns';
import GetUserSchema from '@/services/User/schema/GetUserSchema'; import GetUserSchema from '@/services/User/schema/GetUserSchema';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; 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 UserAvatar from '../Account/UserAvatar';
import UserFollowButton from './UserFollowButton'; import UserFollowButton from './UserFollowButton';
@@ -25,6 +27,8 @@ const UserHeader: FC<UserHeaderProps> = ({ user }) => {
pageSize: 10, pageSize: 10,
}); });
const { user: currentUser } = useContext(UserContext);
return ( return (
<header className="card text-center items-center"> <header className="card text-center items-center">
<div className="card-body items-center w-full"> <div className="card-body items-center w-full">
@@ -55,13 +59,22 @@ const UserHeader: FC<UserHeaderProps> = ({ user }) => {
<div className="w-6/12"> <div className="w-6/12">
<p className="text-sm">{user.bio}</p> <p className="text-sm">{user.bio}</p>
</div> </div>
<div className="h-20 flex items-center justify-center">
{currentUser?.id !== user.id ? (
<div className="flex items-center justify-center">
<UserFollowButton <UserFollowButton
mutateFollowerCount={mutateFollowerCount} mutateFollowerCount={mutateFollowerCount}
user={user} user={user}
mutateFollowingCount={mutateFollowingCount} mutateFollowingCount={mutateFollowingCount}
/> />
</div> </div>
) : (
<div className="flex items-center justify-center">
<Link href={`/account/profile`} className="btn btn-primary">
Edit Profile
</Link>
</div>
)}
</div> </div>
</header> </header>
); );

View File

@@ -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'),
);

View File

@@ -33,6 +33,7 @@ const UseBeerPostsByBrewery = ({ pageSize, breweryId }: UseBeerPostsByBreweryPar
} }
const json = await response.json(); const json = await response.json();
const count = response.headers.get('X-Total-Count'); const count = response.headers.get('X-Total-Count');
const parsed = APIResponseValidationSchema.safeParse(json); const parsed = APIResponseValidationSchema.safeParse(json);

View File

@@ -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<typeof GetUserSchema>;
}
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<typeof UpdateProfileSchema>) => {
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<ProfilePageProps> = ({ user }) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reset,
} = useForm<z.infer<typeof UpdateProfileSchema>>({
resolver: zodResolver(UpdateProfileSchema),
});
const onSubmit: SubmitHandler<z.infer<typeof UpdateProfileSchema>> = 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 (
<div className="flex flex-col items-center justify-center">
<div className="w-9/12">
<pre>{JSON.stringify(user, null, 2)}</pre>
<form className="form-control" noValidate onSubmit={handleSubmit(onSubmit)}>
<FormInfo>
<FormLabel htmlFor="bio">Bio</FormLabel>
<FormError>{errors.bio?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
disabled={isSubmitting}
id="bio"
type="text"
formValidationSchema={register('bio')}
error={!!errors.bio}
placeholder="Bio"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="userAvatar">Avatar</FormLabel>
<FormError>{errors.userAvatar?.message}</FormError>
</FormInfo>
<FormSegment>
<input
disabled={isSubmitting}
type="file"
id="userAvatar"
className="file-input-bordered file-input w-full"
{...register('userAvatar')}
/>
</FormSegment>
<div className="mt-6">
<Button type="submit" isSubmitting={isSubmitting}>
Update Profile
</Button>
</div>
</form>
</div>
</div>
);
};
export default ProfilePage;
export const getServerSideProps: GetServerSideProps =
withPageAuthRequired<ProfilePageProps>(async (context, session) => {
const { id } = session;
const user = await findUserById(id);
if (!user) {
return { notFound: true };
}
return { props: { user: JSON.parse(JSON.stringify(user)) } };
});

View File

@@ -1,38 +1,17 @@
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; 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 getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import multer from 'multer';
import cloudinaryConfig from '@/config/cloudinary';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import addBeerImageToDB from '@/services/BeerImage/addBeerImageToDB'; import addBeerImageToDB from '@/services/BeerImage/addBeerImageToDB';
import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema'; import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema';
import { uploadMiddlewareMultiple } from '@/config/multer/uploadMiddleware';
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',
),
);
interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest { interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[]; files?: Express.Multer.File[];
@@ -75,7 +54,7 @@ const router = createRouter<
router.post( router.post(
getCurrentUser, getCurrentUser,
// @ts-expect-error // @ts-expect-error
uploadMiddleware, uploadMiddlewareMultiple,
validateRequest({ bodySchema: ImageMetadataValidationSchema }), validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData, processImageData,
); );

View File

@@ -18,11 +18,14 @@ const getAllBeersByBrewery = async (
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
const { page_size, page_num, id } = req.query; const { page_size, page_num, id } = req.query;
const pageNum = parseInt(page_num, 10);
const pageSize = parseInt(page_size, 10);
const beers: z.infer<typeof BeerPostQueryResult>[] = const beers: z.infer<typeof BeerPostQueryResult>[] =
await DBClient.instance.beerPost.findMany({ await DBClient.instance.beerPost.findMany({
where: { breweryId: id }, where: { breweryId: id },
take: parseInt(page_size, 10), skip: (pageNum - 1) * pageSize,
skip: parseInt(page_num, 10) * parseInt(page_size, 10), take: pageSize,
select: { select: {
id: true, id: true,
name: true, name: true,

View File

@@ -1,38 +1,17 @@
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; 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 getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import multer from 'multer';
import cloudinaryConfig from '@/config/cloudinary';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema'; import ImageMetadataValidationSchema from '@/services/schema/ImageSchema/ImageMetadataValidationSchema';
import addBreweryImageToDB from '@/services/BreweryImage/addBreweryImageToDB'; import addBreweryImageToDB from '@/services/BreweryImage/addBreweryImageToDB';
import { uploadMiddlewareMultiple } from '@/config/multer/uploadMiddleware';
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',
),
);
interface UploadBreweryPostImagesRequest extends UserExtendedNextApiRequest { interface UploadBreweryPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[]; files?: Express.Multer.File[];
@@ -75,7 +54,7 @@ const router = createRouter<
router.post( router.post(
getCurrentUser, getCurrentUser,
// @ts-expect-error // @ts-expect-error
uploadMiddleware, uploadMiddlewareMultiple,
validateRequest({ bodySchema: ImageMetadataValidationSchema }), validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData, processImageData,
); );

View File

@@ -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<typeof GetUserSchema> = 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<z.infer<typeof APIResponseValidationSchema>>
>();
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 } };