Restructure account api routes, fix update profile

This commit is contained in:
Aaron William Po
2023-12-02 22:42:07 -05:00
parent 0d23b02957
commit cf89dc92df
15 changed files with 366 additions and 103 deletions

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
import React from 'react';
import { FaArrowRight } from 'react-icons/fa';
const UpdateProfileLink: React.FC = () => {
return (
<div className="card mt-8">
<div className="card-body flex flex-col space-y-3">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Update Your Profile</h1>
<p className="text-sm">You can update your profile information here.</p>
</div>
<div>
<Link
href="/users/account/edit-profile"
className="btn-sk btn btn-circle btn-ghost btn-sm"
>
<FaArrowRight className="text-xl" />
</Link>
</div>
</div>
</div>
</div>
);
};
export default UpdateProfileLink;

View File

@@ -70,7 +70,7 @@ const UserHeader: FC<UserHeaderProps> = ({ user }) => {
mutateFollowingCount={mutateFollowingCount} mutateFollowingCount={mutateFollowingCount}
/> />
) : ( ) : (
<Link href={`/account/profile`} className="btn btn-primary"> <Link href={`/users/account/edit-profile`} className="btn btn-primary">
Edit Profile Edit Profile
</Link> </Link>
)} )}

View File

@@ -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 <RequestType extends CheckIfBeerPostOwnerRequest>(
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;

View File

@@ -29,9 +29,9 @@ const useGetUsersFollowedByUser = ({
pageSize?: number; pageSize?: number;
userId: string | undefined; userId: string | undefined;
}) => { }) => {
const fetcher = async (url: string | undefined) => { const fetcher = async (url: string) => {
if (!url) { if (!userId) {
throw new Error('URL is undefined'); throw new Error('User ID is undefined');
} }
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {

View File

@@ -33,6 +33,10 @@ interface UseGetUsersFollowingUser {
const useGetUsersFollowingUser = ({ pageSize = 5, userId }: UseGetUsersFollowingUser) => { const useGetUsersFollowingUser = ({ pageSize = 5, userId }: UseGetUsersFollowingUser) => {
const fetcher = async (url: string) => { const fetcher = async (url: string) => {
if (!userId) {
throw new Error('User ID is undefined');
}
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(response.statusText); throw new Error(response.statusText);

View File

@@ -23,7 +23,7 @@ const useNavbar = () => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
const authenticatedPages: readonly Page[] = [ const authenticatedPages: readonly Page[] = [
{ slug: '/account', name: 'Account' }, { slug: '/users/account', name: 'Account' },
{ slug: `/users/${user?.id}`, name: 'Profile' }, { slug: `/users/${user?.id}`, name: 'Profile' },
{ slug: '/api/users/logout', name: 'Logout' }, { slug: '/api/users/logout', name: 'Logout' },
]; ];

View File

@@ -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<typeof GetUserSchema> = 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<z.infer<typeof APIResponseValidationSchema>>
>();
router.put(
getCurrentUser,
checkIfUserCanUpdateProfile,
// @ts-expect-error
singleUploadMiddleware,
updateProfile,
);
const handler = router.handler();
export default handler;
export const config = { api: { bodyParser: false } };

View File

@@ -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<typeof GetUserSchema> = 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<z.infer<typeof APIResponseValidationSchema>>
>();
router.put(
getCurrentUser,
checkIfUserCanUpdateProfile,
validateRequest({ bodySchema: z.object({ bio: z.string().max(1000) }) }),
updateProfile,
);
const handler = router.handler();
export default handler;

View File

@@ -1,23 +1,30 @@
import withPageAuthRequired from '@/util/withPageAuthRequired'; import { useContext, useEffect } from 'react';
import { GetServerSideProps, NextPage } from 'next';
import { z } from 'zod'; 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 { SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import withPageAuthRequired from '@/util/withPageAuthRequired';
import createErrorToast from '@/util/createErrorToast'; import createErrorToast from '@/util/createErrorToast';
import UserAvatar from '@/components/Account/UserAvatar';
import { useContext, useEffect } from 'react';
import UserContext from '@/contexts/UserContext'; 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 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 Head from 'next/head';
import UpdateProfileSchema from '../../services/User/schema/UpdateProfileSchema'; import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema';
import sendUpdateProfileRequest from '../../requests/Account/sendUpdateProfileRequest'; import sendUpdateUserAvatarRequest from '@/requests/Account/sendUpdateUserAvatarRequest';
import UpdateProfileForm from '../../components/Account/UpdateProfileForm'; import sendUpdateUserProfileRequest from '@/requests/Account/sendUpdateUserProfileRequest.ts';
import Spinner from '@/components/ui/Spinner';
const ProfilePage: NextPage = () => { const ProfilePage: NextPage = () => {
const { user, mutate: mutateUser } = useContext(UserContext); const { user, mutate: mutateUser } = useContext(UserContext);
@@ -28,31 +35,43 @@ const ProfilePage: NextPage = () => {
setValue, setValue,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
watch, watch,
reset,
} = useForm<z.infer<typeof UpdateProfileSchema>>({ } = useForm<z.infer<typeof UpdateProfileSchema>>({
resolver: zodResolver(UpdateProfileSchema), resolver: zodResolver(UpdateProfileSchema),
defaultValues: {
bio: user?.bio ?? '',
},
}); });
useEffect(() => { useEffect(() => {
if (!user || !user.bio) return; if (!user || !user.bio) {
return;
}
setValue('bio', user.bio); setValue('bio', user.bio);
}, [user, setValue]); }, [user, setValue]);
const router = useRouter();
const onSubmit: SubmitHandler<z.infer<typeof UpdateProfileSchema>> = async (data) => { const onSubmit: SubmitHandler<z.infer<typeof UpdateProfileSchema>> = async (data) => {
try { try {
await sendUpdateProfileRequest(data);
const loadingToast = toast.loading('Updating profile...'); const loadingToast = toast.loading('Updating profile...');
await new Promise((resolve) => { if (!user) {
setTimeout(resolve, 1000); 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); toast.remove(loadingToast);
reset(); await mutateUser!();
mutateUser!(); await router.push(`/users/${user.id}`);
toast.success('Profile updated!'); toast.success('Profile updated.');
} catch (error) { } catch (error) {
createErrorToast(error); createErrorToast(error);
} }
@@ -69,9 +88,11 @@ const ProfilePage: NextPage = () => {
const watchedInput = watch('userAvatar'); const watchedInput = watch('userAvatar');
if ( if (
!(watchedInput instanceof FileList) || !(
watchedInput.length !== 1 || watchedInput instanceof FileList &&
!watchedInput[0].type.startsWith('image/') watchedInput.length === 1 &&
watchedInput[0].type.startsWith('image/')
)
) { ) {
return ''; return '';
} }
@@ -87,13 +108,13 @@ const ProfilePage: NextPage = () => {
<title>The Biergarten App || Update Your Profile</title> <title>The Biergarten App || Update Your Profile</title>
<meta name="description" content="Update your user profile." /> <meta name="description" content="Update your user profile." />
</Head> </Head>
<div className="mt-20 flex flex-col items-center justify-center"> <div className="my-10 flex flex-col items-center justify-center">
{user && ( {user ? (
<div className="w-10/12 lg:w-7/12"> <div className="w-10/12 lg:w-7/12">
<div className="card"> <div className="card">
<div className="card-body"> <div className="card-body">
<div className="my-10 flex flex-col items-center justify-center"> <div className="my-10 flex flex-col items-center justify-center">
<div className="my-5 h-52"> <div className="my-2 h-40 xl:my-5 xl:h-52">
<UserAvatar <UserAvatar
user={{ user={{
...user, ...user,
@@ -113,17 +134,21 @@ const ProfilePage: NextPage = () => {
/> />
</div> </div>
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold">{user.username}</h2> <h2 className="my-1 text-2xl font-bold xl:text-3xl">
{user.username}
</h2>
<div className="flex space-x-3 text-lg font-bold"> <div className="flex space-x-3 font-bold xl:text-lg">
<span>{followingCount} Following</span> <span>{followingCount} Following</span>
<span>{followerCount} Followers</span> <span>{followerCount} Followers</span>
</div> </div>
</div> </div>
<div> <div>
<p className="text-lg"> <p className="hyphens-auto text-center xl:text-lg">
{watch('bio') || ( {watch('bio') ? (
<span className="hyphens-manual">{watch('bio')}</span>
) : (
<span className="italic">Your bio will appear here.</span> <span className="italic">Your bio will appear here.</span>
)} )}
</p> </p>
@@ -141,6 +166,10 @@ const ProfilePage: NextPage = () => {
</div> </div>
</div> </div>
</div> </div>
) : (
<div className="flex h-96 items-center justify-center">
<Spinner />
</div>
)} )}
</div> </div>
</> </>

View File

@@ -11,6 +11,7 @@ import DeleteAccount from '@/components/Account/DeleteAccount';
import accountPageReducer from '@/reducers/accountPageReducer'; import accountPageReducer from '@/reducers/accountPageReducer';
import UserAvatar from '@/components/Account/UserAvatar'; import UserAvatar from '@/components/Account/UserAvatar';
import UserPosts from '@/components/Account/UserPosts'; import UserPosts from '@/components/Account/UserPosts';
import UpdateProfileLink from '@/components/Account/UpdateProfileLink';
const AccountPage: NextPage = () => { const AccountPage: NextPage = () => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
@@ -57,6 +58,7 @@ const AccountPage: NextPage = () => {
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="h-full space-y-5"> <Tab.Panel className="h-full space-y-5">
<UpdateProfileLink />
<AccountInfo pageState={pageState} dispatch={dispatch} /> <AccountInfo pageState={pageState} dispatch={dispatch} />
<Security pageState={pageState} dispatch={dispatch} /> <Security pageState={pageState} dispatch={dispatch} />
<DeleteAccount pageState={pageState} dispatch={dispatch} /> <DeleteAccount pageState={pageState} dispatch={dispatch} />

View File

@@ -1,32 +0,0 @@
import { z } from 'zod';
import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema';
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();
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;

View File

@@ -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;

View File

@@ -0,0 +1,28 @@
import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema';
import { z } from 'zod';
interface UpdateProfileRequestParams {
userId: string;
bio: z.infer<typeof UpdateProfileSchema>['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;

View File

@@ -1,14 +1,14 @@
import { z } from 'zod'; import { z } from 'zod';
const UpdateProfileSchema = z.object({ const UpdateProfileSchema = z.object({
bio: z.string().min(1, 'Bio cannot be empty'), bio: z.string(),
userAvatar: z userAvatar: z
.instanceof(typeof FileList !== 'undefined' ? FileList : Object) .instanceof(typeof FileList !== 'undefined' ? FileList : Object)
.refine((fileList) => fileList instanceof FileList, { .refine((fileList) => fileList instanceof FileList, {
message: 'You must submit this form in a web browser.', message: 'You must submit this form in a web browser.',
}) })
.refine((fileList) => [...(fileList as FileList)].length === 1, { .refine((fileList) => [...(fileList as FileList)].length <= 1, {
message: 'You must upload one file.', message: 'You must upload only one or zero files.',
}) })
.refine( .refine(
(fileList) => (fileList) =>