Merge pull request #6 from aaronpo97/dev

More dev work.
This commit is contained in:
Aaron Po
2023-02-20 16:43:47 -05:00
committed by GitHub
68 changed files with 5341 additions and 463 deletions

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ next-env.d.ts
# http requests # http requests
*.http *.http
# uploaded images
public/uploads

View File

@@ -1,7 +1,7 @@
import sendCreateBeerCommentRequest from '@/requests/sendCreateBeerCommentRequest'; import sendCreateBeerCommentRequest from '@/requests/sendCreateBeerCommentRequest';
import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema'; import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Dispatch, SetStateAction, FunctionComponent, useState, useEffect } from 'react'; import { Dispatch, SetStateAction, FunctionComponent, useState, useEffect } from 'react';
@@ -26,7 +26,6 @@ const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({ beerPost })
z.infer<typeof BeerCommentValidationSchema> z.infer<typeof BeerCommentValidationSchema>
>({ >({
defaultValues: { defaultValues: {
beerPostId: beerPost.id,
rating: 0, rating: 0,
}, },
resolver: zodResolver(BeerCommentValidationSchema), resolver: zodResolver(BeerCommentValidationSchema),
@@ -35,8 +34,8 @@ const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({ beerPost })
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
useEffect(() => { useEffect(() => {
setRating(0); setRating(0);
reset({ beerPostId: beerPost.id, rating: 0, content: '' }); reset({ rating: 0, content: '' });
}, [beerPost.id, reset]); }, [reset]);
const router = useRouter(); const router = useRouter();
const onSubmit: SubmitHandler<z.infer<typeof BeerCommentValidationSchema>> = async ( const onSubmit: SubmitHandler<z.infer<typeof BeerCommentValidationSchema>> = async (
@@ -44,7 +43,11 @@ const BeerCommentForm: FunctionComponent<BeerCommentFormProps> = ({ beerPost })
) => { ) => {
setValue('rating', 0); setValue('rating', 0);
setRating(0); setRating(0);
await sendCreateBeerCommentRequest(data); await sendCreateBeerCommentRequest({
content: data.content,
rating: data.rating,
beerPostId: beerPost.id,
});
reset(); reset();
router.replace(router.asPath, undefined, { scroll: false }); router.replace(router.asPath, undefined, { scroll: false });
}; };

View File

@@ -2,50 +2,29 @@ import Link from 'next/link';
import formatDistanceStrict from 'date-fns/formatDistanceStrict'; import formatDistanceStrict from 'date-fns/formatDistanceStrict';
import format from 'date-fns/format'; import format from 'date-fns/format';
import { FC, useContext, useEffect, useState } from 'react'; import { FC, useContext, useEffect, useState } from 'react';
import { FaRegThumbsUp, FaThumbsUp } from 'react-icons/fa'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/userContext';
import sendCheckIfUserLikesBeerPostRequest from '@/requests/sendCheckIfUserLikesBeerPostRequest'; import BeerPostLikeButton from './BeerPostLikeButton';
import sendLikeRequest from '../../requests/sendLikeRequest';
const BeerInfoHeader: FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost }) => { const BeerInfoHeader: FC<{ beerPost: BeerPostQueryResult; initialLikeCount: number }> = ({
beerPost,
initialLikeCount,
}) => {
const createdAtDate = new Date(beerPost.createdAt); const createdAtDate = new Date(beerPost.createdAt);
const [timeDistance, setTimeDistance] = useState(''); const [timeDistance, setTimeDistance] = useState('');
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
const [loading, setLoading] = useState(true); const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(false);
useEffect(() => { useEffect(() => {
if (!user) { setLikeCount(initialLikeCount);
setLoading(false); }, [initialLikeCount]);
return;
}
sendCheckIfUserLikesBeerPostRequest(beerPost.id)
.then((currentLikeStatus) => {
setIsLiked(currentLikeStatus);
setLoading(false);
})
.catch((e) => {
console.error(e);
setLoading(false);
});
}, [user, beerPost.id]);
useEffect(() => { useEffect(() => {
setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date())); setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date()));
}, [beerPost.createdAt]); }, [beerPost.createdAt]);
const handleLike = async () => {
try {
await sendLikeRequest(beerPost);
setIsLiked(!isLiked);
} catch (e) {
console.error(e);
}
};
return ( return (
<div className="card flex flex-col justify-center bg-base-300"> <div className="card flex flex-col justify-center bg-base-300">
<div className="card-body"> <div className="card-body">
@@ -75,8 +54,8 @@ const BeerInfoHeader: FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost }) =>
<p>{beerPost.description}</p> <p>{beerPost.description}</p>
<div className="mt-5 flex justify-between"> <div className="mt-5 flex justify-between">
<div className="space-y-1">
<div> <div>
<div className="mb-1">
<Link <Link
className="link-hover link text-lg font-bold" className="link-hover link text-lg font-bold"
href={`/beers/types/${beerPost.type.id}`} href={`/beers/types/${beerPost.type.id}`}
@@ -88,31 +67,15 @@ const BeerInfoHeader: FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost }) =>
<span className="mr-4 text-lg font-medium">{beerPost.abv}% ABV</span> <span className="mr-4 text-lg font-medium">{beerPost.abv}% ABV</span>
<span className="text-lg font-medium">{beerPost.ibu} IBU</span> <span className="text-lg font-medium">{beerPost.ibu} IBU</span>
</div> </div>
<div>
<span>
Liked by {likeCount} user{likeCount !== 1 && 's'}
</span>
</div> </div>
<div className="card-actions"> </div>
<div className="card-actions items-end">
{user && ( {user && (
<button <BeerPostLikeButton beerPostId={beerPost.id} setLikeCount={setLikeCount} />
type="button"
className={`btn gap-2 rounded-2xl ${
!isLiked ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
handleLike();
}}
disabled={loading}
>
{isLiked ? (
<>
<FaThumbsUp className="text-2xl" />
<span>Liked</span>
</>
) : (
<>
<FaRegThumbsUp className="text-2xl" />
<span>Like</span>
</>
)}
</button>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,69 @@
import UserContext from '@/contexts/userContext';
import sendLikeRequest from '@/requests/sendLikeRequest';
import { Dispatch, FC, SetStateAction, useContext, useEffect, useState } from 'react';
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
import sendCheckIfUserLikesBeerPostRequest from '@/requests/sendCheckIfUserLikesBeerPostRequest';
const BeerPostLikeButton: FC<{
beerPostId: string;
setLikeCount: Dispatch<SetStateAction<number>>;
}> = ({ beerPostId, setLikeCount }) => {
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const { user } = useContext(UserContext);
useEffect(() => {
if (!user) {
setLoading(false);
return;
}
sendCheckIfUserLikesBeerPostRequest(beerPostId)
.then((currentLikeStatus) => {
setIsLiked(currentLikeStatus);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, [user, beerPostId]);
const handleLike = async () => {
try {
setLoading(true);
await sendLikeRequest(beerPostId);
setIsLiked(!isLiked);
setLikeCount((prevCount) => prevCount + (isLiked ? -1 : 1));
setLoading(false);
} catch (error) {
setLoading(false);
}
};
return (
<button
type="button"
className={`btn gap-2 rounded-2xl ${
!isLiked ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
handleLike();
}}
disabled={loading}
>
{isLiked ? (
<>
<FaThumbsUp className="text-2xl" />
Liked
</>
) : (
<>
<FaRegThumbsUp className="text-2xl" />
Like
</>
)}
</button>
);
};
export default BeerPostLikeButton;

View File

@@ -13,7 +13,7 @@ const CommentCard: React.FC<{
}, [comment.createdAt]); }, [comment.createdAt]);
return ( return (
<div className="card-body h-[1/9]"> <div className="card-body h-64">
<div className="flex justify-between"> <div className="flex justify-between">
<div> <div>
<h3 className="text-2xl font-semibold">{comment.postedBy.username}</h3> <h3 className="text-2xl font-semibold">{comment.postedBy.username}</h3>

View File

@@ -1,12 +1,13 @@
import sendCreateBeerPostRequest from '@/requests/sendCreateBeerPostRequest'; import sendCreateBeerPostRequest from '@/requests/sendCreateBeerPostRequest';
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema'; import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { BeerType } from '@prisma/client'; import { BeerType } from '@prisma/client';
import router from 'next/router'; import router from 'next/router';
import { FunctionComponent } from 'react'; import { FunctionComponent, useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import ErrorAlert from './ui/alerts/ErrorAlert';
import Button from './ui/forms/Button'; import Button from './ui/forms/Button';
import FormError from './ui/forms/FormError'; import FormError from './ui/forms/FormError';
import FormInfo from './ui/forms/FormInfo'; import FormInfo from './ui/forms/FormInfo';
@@ -16,7 +17,7 @@ import FormSelect from './ui/forms/FormSelect';
import FormTextArea from './ui/forms/FormTextArea'; import FormTextArea from './ui/forms/FormTextArea';
import FormTextInput from './ui/forms/FormTextInput'; import FormTextInput from './ui/forms/FormTextInput';
type BeerPostT = z.infer<typeof BeerPostValidationSchema>; type BeerPostT = z.infer<typeof CreateBeerPostValidationSchema>;
interface BeerFormProps { interface BeerFormProps {
formType: 'edit' | 'create'; formType: 'edit' | 'create';
@@ -37,7 +38,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<BeerPostT>({ } = useForm<BeerPostT>({
resolver: zodResolver(BeerPostValidationSchema), resolver: zodResolver(CreateBeerPostValidationSchema),
defaultValues: { defaultValues: {
name: defaultValues?.name, name: defaultValues?.name,
description: defaultValues?.description, description: defaultValues?.description,
@@ -46,7 +47,12 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
}, },
}); });
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit: SubmitHandler<BeerPostT> = async (data) => { const onSubmit: SubmitHandler<BeerPostT> = async (data) => {
setIsSubmitting(true);
switch (formType) { switch (formType) {
case 'create': { case 'create': {
try { try {
@@ -54,8 +60,9 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
router.push(`/beers/${response.id}`); router.push(`/beers/${response.id}`);
break; break;
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console if (e instanceof Error) {
console.error(e); setError(e.message);
}
break; break;
} }
} }
@@ -68,6 +75,9 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
return ( return (
<form className="form-control" onSubmit={handleSubmit(onSubmit)}> <form className="form-control" onSubmit={handleSubmit(onSubmit)}>
<div className="my-5">
{error && <ErrorAlert error={error} setError={setError} />}
</div>
<FormInfo> <FormInfo>
<FormLabel htmlFor="name">Name</FormLabel> <FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError> <FormError>{errors.name?.message}</FormError>
@@ -79,6 +89,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
error={!!errors.name} error={!!errors.name}
type="text" type="text"
id="name" id="name"
disabled={isSubmitting}
/> />
</FormSegment> </FormSegment>
{formType === 'create' && breweries.length && ( {formType === 'create' && breweries.length && (
@@ -89,6 +100,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
</FormInfo> </FormInfo>
<FormSegment> <FormSegment>
<FormSelect <FormSelect
disabled={isSubmitting}
formRegister={register('breweryId')} formRegister={register('breweryId')}
error={!!errors.breweryId} error={!!errors.breweryId}
id="breweryId" id="breweryId"
@@ -110,6 +122,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
<FormError>{errors.abv?.message}</FormError> <FormError>{errors.abv?.message}</FormError>
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
disabled={isSubmitting}
placeholder="12" placeholder="12"
formValidationSchema={register('abv', { valueAsNumber: true })} formValidationSchema={register('abv', { valueAsNumber: true })}
error={!!errors.abv} error={!!errors.abv}
@@ -123,6 +136,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
<FormError>{errors.ibu?.message}</FormError> <FormError>{errors.ibu?.message}</FormError>
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
disabled={isSubmitting}
placeholder="52" placeholder="52"
formValidationSchema={register('ibu', { valueAsNumber: true })} formValidationSchema={register('ibu', { valueAsNumber: true })}
error={!!errors.ibu} error={!!errors.ibu}
@@ -138,6 +152,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
</FormInfo> </FormInfo>
<FormSegment> <FormSegment>
<FormTextArea <FormTextArea
disabled={isSubmitting}
placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque." placeholder="Ratione cumque quas quia aut impedit ea culpa facere. Ut in sit et quas reiciendis itaque."
error={!!errors.description} error={!!errors.description}
formValidationSchema={register('description')} formValidationSchema={register('description')}
@@ -152,6 +167,7 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
</FormInfo> </FormInfo>
<FormSegment> <FormSegment>
<FormSelect <FormSelect
disabled={isSubmitting}
formRegister={register('typeId')} formRegister={register('typeId')}
error={!!errors.typeId} error={!!errors.typeId}
id="typeId" id="typeId"
@@ -164,11 +180,19 @@ const BeerForm: FunctionComponent<BeerFormProps> = ({
/> />
</FormSegment> </FormSegment>
<Button type="submit">{`${ {!isSubmitting && (
<Button type="submit" isSubmitting={isSubmitting}>{`${
formType === 'edit' formType === 'edit'
? `Edit ${defaultValues?.name || 'beer post'}` ? `Edit ${defaultValues?.name || 'beer post'}`
: 'Create beer post' : 'Create beer post'
} `}</Button> }`}</Button>
)}
{isSubmitting && (
<Button type="submit" isSubmitting={isSubmitting}>
Submitting
</Button>
)}
</form> </form>
); );
}; };

View File

@@ -1,14 +1,19 @@
import Link from 'next/link'; import Link from 'next/link';
import { FC } from 'react'; import { FC } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
const BeerCard: FC<{ post: BeerPostQueryResult }> = ({ post }) => { const BeerCard: FC<{ post: BeerPostQueryResult }> = ({ post }) => {
return ( return (
<div className="card bg-base-300" key={post.id}> <div className="card bg-base-300" key={post.id}>
<figure className="card-image h-96"> <figure className="card-image h-96">
{post.beerImages.length > 0 && ( {post.beerImages.length > 0 && (
<Image src={post.beerImages[0].url} alt={post.name} width="1029" height="110" /> <Image
src={post.beerImages[0].path}
alt={post.name}
width="1029"
height="110"
/>
)} )}
</figure> </figure>

View File

@@ -2,8 +2,10 @@ import sendLoginUserRequest from '@/requests/sendLoginUserRequest';
import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema'; import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import ErrorAlert from '../ui/alerts/ErrorAlert';
import FormError from '../ui/forms/FormError'; import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo'; import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel'; import FormLabel from '../ui/forms/FormLabel';
@@ -13,7 +15,7 @@ import FormTextInput from '../ui/forms/FormTextInput';
type LoginT = z.infer<typeof LoginValidationSchema>; type LoginT = z.infer<typeof LoginValidationSchema>;
const LoginForm = () => { const LoginForm = () => {
const router = useRouter(); const router = useRouter();
const { register, handleSubmit, formState } = useForm<LoginT>({ const { register, handleSubmit, formState, reset } = useForm<LoginT>({
resolver: zodResolver(LoginValidationSchema), resolver: zodResolver(LoginValidationSchema),
defaultValues: { defaultValues: {
username: '', username: '',
@@ -23,18 +25,23 @@ const LoginForm = () => {
const { errors } = formState; const { errors } = formState;
const [responseError, setResponseError] = useState<string>('');
const onSubmit: SubmitHandler<LoginT> = async (data) => { const onSubmit: SubmitHandler<LoginT> = async (data) => {
try { try {
const response = await sendLoginUserRequest(data); const response = await sendLoginUserRequest(data);
router.push(`/users/${response.id}`); router.push(`/users/${response.id}`);
} catch (error) { } catch (error) {
console.error(error); if (error instanceof Error) {
setResponseError(error.message);
reset();
}
} }
}; };
return ( return (
<form className="form-control w-9/12 space-y-5" onSubmit={handleSubmit(onSubmit)}> <form className="form-control w-full space-y-5" onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<FormInfo> <FormInfo>
<FormLabel htmlFor="username">username</FormLabel> <FormLabel htmlFor="username">username</FormLabel>
@@ -65,8 +72,9 @@ const LoginForm = () => {
</FormSegment> </FormSegment>
</div> </div>
{responseError && <ErrorAlert error={responseError} setError={setResponseError} />}
<div className="w-full"> <div className="w-full">
<button type="submit" className="btn btn-primary w-full"> <button type="submit" className="btn-primary btn w-full">
Login Login
</button> </button>
</div> </div>

View File

@@ -2,9 +2,10 @@
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
/* eslint-disable jsx-a11y/label-has-for */ /* eslint-disable jsx-a11y/label-has-for */
import UserContext from '@/contexts/userContext';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
interface Page { interface Page {
slug: string; slug: string;
@@ -14,19 +15,36 @@ const Navbar = () => {
const router = useRouter(); const router = useRouter();
const [currentURL, setCurrentURL] = useState('/'); const [currentURL, setCurrentURL] = useState('/');
const { user } = useContext(UserContext);
useEffect(() => { useEffect(() => {
setCurrentURL(router.asPath); setCurrentURL(router.asPath);
}, [router.asPath]); }, [router.asPath]);
const pages: Page[] = [ const authenticatedPages: readonly Page[] = [
{ slug: '/account', name: 'Account' },
{ slug: '/api/users/logout', name: 'Logout' },
];
const unauthenticatedPages: readonly Page[] = [
{ slug: '/login', name: 'Login' },
{ slug: '/register', name: 'Register' },
];
const otherPages: readonly Page[] = [
{ slug: '/beers', name: 'Beers' }, { slug: '/beers', name: 'Beers' },
{ slug: '/breweries', name: 'Breweries' }, { slug: '/breweries', name: 'Breweries' },
]; ];
const pages: readonly Page[] = [
...otherPages,
...(user ? authenticatedPages : unauthenticatedPages),
];
return ( return (
<nav className="navbar bg-primary"> <nav className="navbar bg-primary text-primary-content">
<div className="flex-1"> <div className="flex-1">
<Link className="btn btn-ghost text-3xl normal-case" href="/"> <Link className="btn-ghost btn text-3xl normal-case" href="/">
<span className="cursor-pointer text-xl font-bold">The Biergarten App</span> <span className="cursor-pointer text-xl font-bold">The Biergarten App</span>
</Link> </Link>
</div> </div>
@@ -39,7 +57,7 @@ const Navbar = () => {
<span <span
className={`text-lg uppercase ${ className={`text-lg uppercase ${
currentURL === page.slug ? 'font-extrabold' : 'font-semibold' currentURL === page.slug ? 'font-extrabold' : 'font-semibold'
} text-base-content`} } text-primary-content`}
> >
{page.name} {page.name}
</span> </span>
@@ -51,7 +69,7 @@ const Navbar = () => {
</div> </div>
<div className="flex-none lg:hidden"> <div className="flex-none lg:hidden">
<div className="dropdown-end dropdown"> <div className="dropdown-end dropdown">
<label tabIndex={0} className="btn btn-ghost btn-circle"> <label tabIndex={0} className="btn-ghost btn-circle btn">
<span className="w-10 rounded-full"> <span className="w-10 rounded-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -75,7 +93,7 @@ const Navbar = () => {
{pages.map((page) => ( {pages.map((page) => (
<li key={page.slug}> <li key={page.slug}>
<Link href={page.slug}> <Link href={page.slug}>
<span className="select-none">{page.name}</span> <span className="select-none text-primary-content">{page.name}</span>
</Link> </Link>
</li> </li>
))} ))}

View File

@@ -0,0 +1,32 @@
import { Dispatch, FC, SetStateAction } from 'react';
import { FiAlertTriangle } from 'react-icons/fi';
interface ErrorAlertProps {
error: string;
setError: Dispatch<SetStateAction<string>>;
}
const ErrorAlert: FC<ErrorAlertProps> = ({ error, setError }) => {
return (
<div className="alert alert-error shadow-lg">
<div>
<FiAlertTriangle className="h-6 w-6" />
<span>{error}</span>
</div>
<div className="flex-none">
<button
className="btn-ghost btn-sm btn"
type="button"
onClick={() => {
setError('');
}}
>
OK
</button>
</div>
</div>
);
};
export default ErrorAlert;

View File

@@ -3,11 +3,19 @@ import { FunctionComponent } from 'react';
interface FormButtonProps { interface FormButtonProps {
children: string; children: string;
type: 'button' | 'submit' | 'reset'; type: 'button' | 'submit' | 'reset';
isSubmitting?: boolean;
} }
const Button: FunctionComponent<FormButtonProps> = ({ children, type }) => ( const Button: FunctionComponent<FormButtonProps> = ({
children,
type,
isSubmitting = false,
}) => (
// eslint-disable-next-line react/button-has-type // eslint-disable-next-line react/button-has-type
<button type={type} className="btn btn-primary mt-4 w-full rounded-xl"> <button
type={type}
className={`btn btn-primary mt-4 w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
>
{children} {children}
</button> </button>
); );

View File

@@ -8,6 +8,7 @@ interface FormSelectProps {
error: boolean; error: boolean;
placeholder: string; placeholder: string;
message: string; message: string;
disabled?: boolean;
} }
/** /**
@@ -40,6 +41,7 @@ const FormSelect: FunctionComponent<FormSelectProps> = ({
formRegister, formRegister,
placeholder, placeholder,
message, message,
disabled = false,
}) => ( }) => (
<select <select
id={id} id={id}
@@ -47,6 +49,7 @@ const FormSelect: FunctionComponent<FormSelectProps> = ({
error ? 'select-error' : '' error ? 'select-error' : ''
}`} }`}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled}
{...formRegister} {...formRegister}
> >
<option value="">{message}</option> <option value="">{message}</option>

View File

@@ -12,6 +12,7 @@ const withPageAuthRequired =
} }
return await fn(context); return await fn(context);
} catch (error) { } catch (error) {
console.log(error);
return { return {
redirect: { redirect: {
destination: '/login', destination: '/login',

View File

@@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { v2 as cloudinary } from 'cloudinary';
import { CloudinaryStorage } from 'multer-storage-cloudinary';
import ServerError from '../util/ServerError';
const { CLOUDINARY_CLOUD_NAME, CLOUDINARY_KEY, CLOUDINARY_SECRET } = process.env;
if (!(CLOUDINARY_CLOUD_NAME && CLOUDINARY_KEY && CLOUDINARY_SECRET)) {
throw new ServerError(
'The cloudinary credentials were not found in the environment variables.',
500,
);
}
cloudinary.config({
cloud_name: CLOUDINARY_CLOUD_NAME,
api_key: CLOUDINARY_KEY,
api_secret: CLOUDINARY_SECRET,
});
// @ts-expect-error
const storage = new CloudinaryStorage({ cloudinary, params: { folder: 'BeerApp' } });
/** Configuration object for Cloudinary image upload. */
const cloudinaryConfig = { cloudinary, storage };
export default cloudinaryConfig;

View File

@@ -1,14 +1,20 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { Options } from 'next-connect'; import type { RequestHandler } from 'next-connect/dist/types/node';
import type { HandlerOptions } from 'next-connect/dist/types/types';
import { z } from 'zod'; import { z } from 'zod';
import logger from '../pino/logger';
import ServerError from '../util/ServerError'; import ServerError from '../util/ServerError';
const NextConnectConfig: Options< type NextConnectOptionsT = HandlerOptions<
RequestHandler<
NextApiRequest, NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>> NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
> = { >
>;
const NextConnectOptions: NextConnectOptionsT = {
onNoMatch(req, res) { onNoMatch(req, res) {
res.status(405).json({ res.status(405).json({
message: 'Method not allowed.', message: 'Method not allowed.',
@@ -17,6 +23,10 @@ const NextConnectConfig: Options<
}); });
}, },
onError(error, req, res) { onError(error, req, res) {
if (process.env.NODE_ENV !== 'production') {
logger.error(error);
}
const message = error instanceof Error ? error.message : 'Internal server error.'; const message = error instanceof Error ? error.message : 'Internal server error.';
const statusCode = error instanceof ServerError ? error.statusCode : 500; const statusCode = error instanceof ServerError ? error.statusCode : 500;
res.status(statusCode).json({ res.status(statusCode).json({
@@ -27,4 +37,4 @@ const NextConnectConfig: Options<
}, },
}; };
export default NextConnectConfig; export default NextConnectOptions;

View File

@@ -0,0 +1,31 @@
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

@@ -2,8 +2,8 @@ import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect'; import { NextHandler } from 'next-connect';
import findUserById from '@/services/User/findUserById'; import findUserById from '@/services/User/findUserById';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import { getLoginSession } from '../session'; import { getLoginSession } from '../../auth/session';
import { UserExtendedNextApiRequest } from '../types'; import { UserExtendedNextApiRequest } from '../../auth/types';
/** Get the current user from the session. Adds the user to the request object. */ /** Get the current user from the session. Adds the user to the request object. */
const getCurrentUser = async ( const getCurrentUser = async (
@@ -19,7 +19,7 @@ const getCurrentUser = async (
} }
req.user = user; req.user = user;
await next(); return next();
}; };
export default getCurrentUser; export default getCurrentUser;

View File

@@ -28,10 +28,11 @@ const validateRequest =
}) => }) =>
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => { async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
if (bodySchema) { if (bodySchema) {
const parsed = bodySchema.safeParse(req.body); const parsed = bodySchema.safeParse(JSON.parse(JSON.stringify(req.body)));
if (!parsed.success) { if (!parsed.success) {
throw new ServerError('Invalid request body.', 400); throw new ServerError('Invalid request body.', 400);
} }
req.body = parsed.data;
} }
if (querySchema) { if (querySchema) {
@@ -42,7 +43,7 @@ const validateRequest =
req.query = parsed.data; req.query = parsed.data;
} }
next(); return next();
}; };
export default validateRequest; export default validateRequest;

View File

@@ -1,9 +1,5 @@
import pino from 'pino'; import pino from 'pino';
const logger = pino({ const logger = pino();
transport: {
target: 'pino-pretty',
},
});
export default logger; export default logger;

View File

@@ -3,6 +3,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
import useSWR from 'swr'; import useSWR from 'swr';
const useUser = () => { const useUser = () => {
// check cookies for user
const { const {
data: user, data: user,
error, error,
@@ -11,6 +12,7 @@ const useUser = () => {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
throw new Error(response.statusText); throw new Error(response.statusText);
} }

4264
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"format": "npx prettier . --write", "format": "npx prettier . --write",
"prestart": "npm run build", "prestart": "npm run build",
"prismaDev": "dotenv -e .env.local prisma migrate dev", "prismaDev": "dotenv -e .env.local prisma migrate dev",
"vercel-build": "DISABLE_ERD=true npx prisma generate && npx prisma migrate deploy && next build",
"seed": "npx ts-node ./prisma/seed/index.ts" "seed": "npx ts-node ./prisma/seed/index.ts"
}, },
"dependencies": { "dependencies": {
@@ -18,10 +19,13 @@
"@next/font": "13.1.6", "@next/font": "13.1.6",
"@prisma/client": "^4.9.0", "@prisma/client": "^4.9.0",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"cloudinary": "^1.33.0",
"cookie": "0.5.0", "cookie": "0.5.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"multer": "^1.4.5-lts.1",
"multer-storage-cloudinary": "^4.0.0",
"next": "13.1.6", "next": "13.1.6",
"next-connect": "^0.13.0", "next-connect": "^1.0.0-next.3",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pino": "^8.8.0", "pino": "^8.8.0",
@@ -36,7 +40,9 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@mermaid-js/mermaid-cli": "^9.3.0",
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.13.0", "@types/node": "^18.13.0",
"@types/passport-local": "^1.0.35", "@types/passport-local": "^1.0.35",
"@types/react": "18.0.27", "@types/react": "18.0.27",
@@ -55,6 +61,7 @@
"prettier-plugin-jsdoc": "^0.4.2", "prettier-plugin-jsdoc": "^0.4.2",
"prettier-plugin-tailwindcss": "^0.2.2", "prettier-plugin-tailwindcss": "^0.2.2",
"prisma": "^4.9.0", "prisma": "^4.9.0",
"prisma-erd-generator": "^1.2.5",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^4.9.5" "typescript": "^4.9.5"

16
pages/account/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import Layout from '@/components/ui/Layout';
import { NextPage } from 'next';
interface AccountPageProps {}
const AccountPage: NextPage<AccountPageProps> = () => {
return (
<Layout>
<div>
<h1>Account Page</h1>
</div>
</Layout>
);
};
export default AccountPage;

View File

@@ -1,25 +1,28 @@
import validateRequest from '@/config/zod/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import createNewBeerComment from '@/services/BeerComment/createNewBeerComment'; import createNewBeerComment from '@/services/BeerComment/createNewBeerComment';
import { BeerCommentQueryResultT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import { BeerCommentQueryResultT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema'; import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBeerCommentValidationSchema';
import nextConnect from 'next-connect'; import { createRouter } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
interface CreateCommentRequest extends UserExtendedNextApiRequest { interface CreateCommentRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof BeerCommentValidationSchema>; body: z.infer<typeof BeerCommentValidationSchema>;
query: { id: string };
} }
const createComment = async ( const createComment = async (
req: CreateCommentRequest, req: CreateCommentRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>, res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => { ) => {
const { content, rating, beerPostId } = req.body; const { content, rating } = req.body;
const beerPostId = req.query.id;
const newBeerComment: BeerCommentQueryResultT = await createNewBeerComment({ const newBeerComment: BeerCommentQueryResultT = await createNewBeerComment({
content, content,
@@ -36,10 +39,19 @@ const createComment = async (
}); });
}; };
const handler = nextConnect(NextConnectConfig).post( const router = createRouter<
validateRequest({ bodySchema: BeerCommentValidationSchema }), CreateCommentRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
validateRequest({
bodySchema: BeerCommentValidationSchema,
querySchema: z.object({ id: z.string().uuid() }),
}),
getCurrentUser, getCurrentUser,
createComment, createComment,
); );
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -0,0 +1,98 @@
import DBClient from '@/prisma/DBClient';
import { BeerImage } from '@prisma/client';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import { createRouter, expressWrapper } 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';
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: 3 } }).array('images'),
);
const BeerPostImageValidationSchema = z.object({
caption: z.string(),
alt: z.string(),
});
interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[];
query: { id: string };
body: z.infer<typeof BeerPostImageValidationSchema>;
}
const processImageData = async (
req: UploadBeerPostImagesRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { files, user, body } = req;
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const beerImagePromises: Promise<BeerImage>[] = [];
files.forEach((file) => {
beerImagePromises.push(
DBClient.instance.beerImage.create({
data: {
alt: body.alt,
postedBy: { connect: { id: user!.id } },
beerPost: { connect: { id: req.query.id } },
path: file.path,
caption: body.caption,
},
}),
);
});
const beerImages = await Promise.all(beerImagePromises);
res.status(200).json({
success: true,
message: `Successfully uploaded ${beerImages.length} image${
beerImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};
const router = createRouter<
UploadBeerPostImagesRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser,
// @ts-expect-error
uploadMiddleware,
validateRequest({ bodySchema: BeerPostImageValidationSchema }),
processImageData,
);
const handler = router.handler(NextConnectOptions);
export default handler;
export const config = { api: { bodyParser: false } };

View File

@@ -1,16 +1,16 @@
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/zod/middleware/validateRequest'; import { createRouter } from 'next-connect';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import nextConnect from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import createBeerPostLike from '@/services/BeerPostLike/createBeerPostLike'; import createBeerPostLike from '@/services/BeerPostLike/createBeerPostLike';
import removeBeerPostLikeById from '@/services/BeerPostLike/removeBeerPostLikeById'; import removeBeerPostLikeById from '@/services/BeerPostLike/removeBeerPostLikeById';
import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById'; import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
const sendLikeRequest = async ( const sendLikeRequest = async (
req: UserExtendedNextApiRequest, req: UserExtendedNextApiRequest,
@@ -24,7 +24,7 @@ const sendLikeRequest = async (
throw new ServerError('Could not find a beer post with that id', 404); throw new ServerError('Could not find a beer post with that id', 404);
} }
const alreadyLiked = await findBeerPostLikeById(id); const alreadyLiked = await findBeerPostLikeById(beer.id, user.id);
const jsonResponse = { const jsonResponse = {
success: true as const, success: true as const,
@@ -43,14 +43,16 @@ const sendLikeRequest = async (
res.status(200).json(jsonResponse); res.status(200).json(jsonResponse);
}; };
const handler = nextConnect(NextConnectConfig).post( const router = createRouter<
UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser, getCurrentUser,
validateRequest({ validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }),
querySchema: z.object({
id: z.string().uuid(),
}),
}),
sendLikeRequest, sendLikeRequest,
); );
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -1,11 +1,11 @@
import getCurrentUser from '@/config/auth/middleware/getCurrentUser'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import validateRequest from '@/config/zod/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import nextConnect from 'next-connect'; import { createRouter } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
const checkIfLiked = async ( const checkIfLiked = async (
@@ -18,7 +18,7 @@ const checkIfLiked = async (
const alreadyLiked = await DBClient.instance.beerPostLike.findFirst({ const alreadyLiked = await DBClient.instance.beerPostLike.findFirst({
where: { where: {
beerPostId: id, beerPostId: id,
userId: user.id, likedById: user.id,
}, },
}); });
@@ -30,10 +30,20 @@ const checkIfLiked = async (
}); });
}; };
const handler = nextConnect(NextConnectConfig).get( const router = createRouter<
UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
getCurrentUser, getCurrentUser,
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({
querySchema: z.object({
id: z.string().uuid(),
}),
}),
checkIfLiked, checkIfLiked,
); );
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -1,16 +1,16 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/zod/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import nextConnect from 'next-connect'; import { createRouter } from 'next-connect';
import createNewBeerPost from '@/services/BeerPost/createNewBeerPost'; import createNewBeerPost from '@/services/BeerPost/createNewBeerPost';
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema'; import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
interface CreateBeerPostRequest extends UserExtendedNextApiRequest { interface CreateBeerPostRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof BeerPostValidationSchema>; body: z.infer<typeof CreateBeerPostValidationSchema>;
} }
const createBeerPost = async ( const createBeerPost = async (
@@ -37,10 +37,16 @@ const createBeerPost = async (
}); });
}; };
const handler = nextConnect(NextConnectConfig).post( const router = createRouter<
validateRequest({ bodySchema: BeerPostValidationSchema }), CreateBeerPostRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
validateRequest({ bodySchema: CreateBeerPostValidationSchema }),
getCurrentUser, getCurrentUser,
createBeerPost, createBeerPost,
); );
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -1,9 +1,9 @@
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import nextConnect from 'next-connect'; import { createRouter } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
const sendCurrentUser = async (req: UserExtendedNextApiRequest, res: NextApiResponse) => { const sendCurrentUser = async (req: UserExtendedNextApiRequest, res: NextApiResponse) => {
@@ -16,9 +16,12 @@ const sendCurrentUser = async (req: UserExtendedNextApiRequest, res: NextApiResp
}); });
}; };
const handler = nextConnect< const router = createRouter<
UserExtendedNextApiRequest, UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>> NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig).get(getCurrentUser, sendCurrentUser); >();
router.get(getCurrentUser, sendCurrentUser);
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -1,36 +1,35 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import passport from 'passport'; import passport from 'passport';
import nextConnect from 'next-connect'; import { createRouter, expressWrapper } from 'next-connect';
import localStrat from '@/config/auth/localStrat'; import localStrat from '@/config/auth/localStrat';
import { setLoginSession } from '@/config/auth/session'; import { setLoginSession } from '@/config/auth/session';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema'; import LoginValidationSchema from '@/services/User/schema/LoginValidationSchema';
import { UserExtendedNextApiRequest } from '../../../config/auth/types'; import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
export default nextConnect< const router = createRouter<
UserExtendedNextApiRequest, UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>> NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig) >();
.use(passport.initialize())
.use((req, res, next) => { router.post(
const parsed = LoginValidationSchema.safeParse(req.body); validateRequest({ bodySchema: LoginValidationSchema }),
if (!parsed.success) { expressWrapper(async (req, res, next) => {
throw new ServerError('Username and password are required.', 400); passport.initialize();
}
passport.use(localStrat); passport.use(localStrat);
passport.authenticate('local', { session: false }, (error, token) => { passport.authenticate('local', { session: false }, (error, token) => {
if (error) { if (error) {
next(error); next(error);
} else { return;
}
req.user = token; req.user = token;
next(); next();
}
})(req, res, next); })(req, res, next);
}) }),
.post(async (req, res) => { async (req, res) => {
const user = req.user!; const user = req.user!;
await setLoginSession(res, user); await setLoginSession(res, user);
@@ -40,4 +39,8 @@ export default nextConnect<
statusCode: 200, statusCode: 200,
success: true, success: true,
}); });
}); },
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -1,16 +1,18 @@
import { getLoginSession } from '@/config/auth/session'; import { getLoginSession } from '@/config/auth/session';
import { removeTokenCookie } from '@/config/auth/cookie'; import { removeTokenCookie } from '@/config/auth/cookie';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import nextConnect from 'next-connect'; import { createRouter } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
const handler = nextConnect< const router = createRouter<
NextApiRequest, NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>> NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>(NextConnectConfig).all(async (req, res) => { >();
router.all(async (req, res) => {
const session = await getLoginSession(req); const session = await getLoginSession(req);
if (!session) { if (!session) {
@@ -18,10 +20,9 @@ const handler = nextConnect<
} }
removeTokenCookie(res); removeTokenCookie(res);
res.status(200).json({
message: 'Logged out.', res.redirect('/');
statusCode: 200,
success: true,
});
}); });
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -1,13 +1,15 @@
import { setLoginSession } from '@/config/auth/session';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import ServerError from '@/config/util/ServerError'; import ServerError from '@/config/util/ServerError';
import nc from 'next-connect'; import { createRouter } from 'next-connect';
import createNewUser from '@/services/User/createNewUser'; import createNewUser from '@/services/User/createNewUser';
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema'; import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import findUserByUsername from '@/services/User/findUserByUsername'; import findUserByUsername from '@/services/User/findUserByUsername';
import findUserByEmail from '@/services/User/findUserByEmail'; import findUserByEmail from '@/services/User/findUserByEmail';
import validateRequest from '@/config/zod/middleware/validateRequest'; import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
interface RegisterUserRequest extends NextApiRequest { interface RegisterUserRequest extends NextApiRequest {
body: z.infer<typeof CreateUserValidationSchema>; body: z.infer<typeof CreateUserValidationSchema>;
@@ -34,6 +36,11 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
} }
const user = await createNewUser(req.body); const user = await createNewUser(req.body);
await setLoginSession(res, {
id: user.id,
username: user.username,
});
res.status(201).json({ res.status(201).json({
message: 'User created successfully.', message: 'User created successfully.',
payload: user, payload: user,
@@ -42,9 +49,12 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
}); });
}; };
const handler = nc(NextConnectConfig).post( const router = createRouter<
validateRequest({ bodySchema: CreateUserValidationSchema }), RegisterUserRequest,
registerUser, NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
); >();
router.post(validateRequest({ bodySchema: CreateUserValidationSchema }), registerUser);
const handler = router.handler(NextConnectOptions);
export default handler; export default handler;

View File

@@ -4,41 +4,47 @@ import BeerRecommendations from '@/components/BeerById/BeerRecommendations';
import CommentCard from '@/components/BeerById/CommentCard'; import CommentCard from '@/components/BeerById/CommentCard';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/userContext';
import DBClient from '@/prisma/DBClient';
import getAllBeerComments from '@/services/BeerComment/getAllBeerComments'; import getAllBeerComments from '@/services/BeerComment/getAllBeerComments';
import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult'; import { BeerCommentQueryResultArrayT } from '@/services/BeerComment/schema/BeerCommentQueryResult';
import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import { BeerPost } from '@prisma/client'; import { BeerPost } from '@prisma/client';
import { NextPage, GetServerSideProps } from 'next'; import { NextPage, GetServerSideProps } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState, useEffect, useContext } from 'react'; import { useState, useEffect, useContext } from 'react';
interface BeerPageProps { interface BeerPageProps {
beerPost: BeerPostQueryResult; beerPost: BeerPostQueryResult;
beerRecommendations: (BeerPost & { beerRecommendations: (BeerPost & {
brewery: { brewery: { id: string; name: string };
id: string; beerImages: { id: string; alt: string; url: string }[];
name: string;
};
beerImages: {
id: string;
alt: string;
url: string;
}[];
})[]; })[];
beerComments: BeerCommentQueryResultArrayT; beerComments: BeerCommentQueryResultArrayT;
commentsPageCount: number;
likeCount: number;
} }
const BeerByIdPage: NextPage<BeerPageProps> = ({ const BeerByIdPage: NextPage<BeerPageProps> = ({
beerPost, beerPost,
beerRecommendations, beerRecommendations,
beerComments, beerComments,
commentsPageCount,
likeCount,
}) => { }) => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
const [comments, setComments] = useState(beerComments); const [comments, setComments] = useState(beerComments);
const router = useRouter();
const commentsPageNum = router.query.comments_page
? parseInt(router.query.comments_page as string, 10)
: 1;
useEffect(() => { useEffect(() => {
setComments(beerComments); setComments(beerComments);
}, [beerComments]); }, [beerComments]);
@@ -49,11 +55,11 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
<title>{beerPost.name}</title> <title>{beerPost.name}</title>
<meta name="description" content={beerPost.description} /> <meta name="description" content={beerPost.description} />
</Head> </Head>
<main> <div>
{beerPost.beerImages[0] && ( {beerPost.beerImages[0] && (
<Image <Image
alt={beerPost.beerImages[0].alt} alt={beerPost.beerImages[0].alt}
src={beerPost.beerImages[0].url} src={beerPost.beerImages[0].path}
height={1080} height={1080}
width={1920} width={1920}
className="h-[42rem] w-full object-cover" className="h-[42rem] w-full object-cover"
@@ -61,10 +67,10 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
)} )}
<div className="my-12 flex w-full items-center justify-center "> <div className="my-12 flex w-full items-center justify-center ">
<div className="w-10/12 space-y-3"> <div className="w-11/12 space-y-3 lg:w-9/12">
<BeerInfoHeader beerPost={beerPost} /> <BeerInfoHeader beerPost={beerPost} initialLikeCount={likeCount} />
<div className="mt-4 flex space-x-3"> <div className="mt-4 flex flex-col space-y-3 sm:flex-row sm:space-y-0 sm:space-x-3">
<div className="w-[60%] space-y-3"> <div className="w-full space-y-3 sm:w-[60%]">
<div className="card h-96 bg-base-300"> <div className="card h-96 bg-base-300">
<div className="card-body h-full"> <div className="card-body h-full">
{user ? ( {user ? (
@@ -78,19 +84,52 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="card h-[135rem] bg-base-300"> <div className="card bg-base-300 pb-6">
{comments.map((comment) => ( {comments.map((comment) => (
<CommentCard key={comment.id} comment={comment} /> <CommentCard key={comment.id} comment={comment} />
))} ))}
<div className="flex items-center justify-center">
<div className="btn-group grid w-6/12 grid-cols-2">
<Link
className={`btn-outline btn ${
commentsPageNum === 1
? 'btn-disabled pointer-events-none'
: 'pointer-events-auto'
}`}
href={{
pathname: `/beers/${beerPost.id}`,
query: { comments_page: commentsPageNum - 1 },
}}
scroll={false}
>
Next Comments
</Link>
<Link
className={`btn-outline btn ${
commentsPageNum === commentsPageCount
? 'btn-disabled pointer-events-none'
: 'pointer-events-auto'
}`}
href={{
pathname: `/beers/${beerPost.id}`,
query: { comments_page: commentsPageNum + 1 },
}}
scroll={false}
>
Previous Comments
</Link>
</div> </div>
</div> </div>
<div className="w-[40%]"> </div>
</div>
<div className="sm:w-[40%]">
<BeerRecommendations beerRecommendations={beerRecommendations} /> <BeerRecommendations beerRecommendations={beerRecommendations} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </div>
</Layout> </Layout>
); );
}; };
@@ -98,21 +137,34 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (context) => { export const getServerSideProps: GetServerSideProps<BeerPageProps> = async (context) => {
const beerPost = await getBeerPostById(context.params!.id! as string); const beerPost = await getBeerPostById(context.params!.id! as string);
const beerCommentPageNum = parseInt(context.query.comments_page as string, 10) || 1;
if (!beerPost) { if (!beerPost) {
return { notFound: true }; return { notFound: true };
} }
const { type, brewery, id } = beerPost; const { type, brewery, id } = beerPost;
const beerRecommendations = await getBeerRecommendations({ type, brewery, id });
const pageSize = 5;
const beerComments = await getAllBeerComments( const beerComments = await getAllBeerComments(
{ id: beerPost.id }, { id: beerPost.id },
{ pageSize: 9, pageNum: 1 }, { pageSize, pageNum: beerCommentPageNum },
); );
const beerRecommendations = await getBeerRecommendations({ type, brewery, id }); const numberOfPosts = await DBClient.instance.beerComment.count({
where: { beerPostId: beerPost.id },
});
const pageCount = numberOfPosts ? Math.ceil(numberOfPosts / pageSize) : 0;
const likeCount = await DBClient.instance.beerPostLike.count({
where: { beerPostId: beerPost.id },
});
const props = { const props = {
beerPost: JSON.parse(JSON.stringify(beerPost)), beerPost: JSON.parse(JSON.stringify(beerPost)),
beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)),
beerComments: JSON.parse(JSON.stringify(beerComments)), beerComments: JSON.parse(JSON.stringify(beerComments)),
commentsPageCount: JSON.parse(JSON.stringify(pageCount)),
likeCount: JSON.parse(JSON.stringify(likeCount)),
}; };
return { props }; return { props };

View File

@@ -1,5 +1,6 @@
import BeerForm from '@/components/BeerForm'; import BeerForm from '@/components/BeerForm';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import withPageAuthRequired from '@/config/auth/withPageAuthRequired';
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts'; import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts';
@@ -30,7 +31,7 @@ const Create: NextPage<CreateBeerPageProps> = ({ breweries, types }) => {
); );
}; };
export const getServerSideProps = async () => { export const getServerSideProps = withPageAuthRequired(async () => {
const breweryPosts = await getAllBreweryPosts(); const breweryPosts = await getAllBreweryPosts();
const beerTypes = await DBClient.instance.beerType.findMany(); const beerTypes = await DBClient.instance.beerType.findMany();
@@ -40,6 +41,6 @@ export const getServerSideProps = async () => {
types: JSON.parse(JSON.stringify(beerTypes)), types: JSON.parse(JSON.stringify(beerTypes)),
}, },
}; };
}; });
export default Create; export default Create;

View File

@@ -6,7 +6,8 @@ import DBClient from '@/prisma/DBClient';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import Pagination from '@/components/BeerIndex/Pagination'; import Pagination from '@/components/BeerIndex/Pagination';
import BeerCard from '@/components/BeerIndex/BeerCard'; import BeerCard from '@/components/BeerIndex/BeerCard';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import Head from 'next/head';
interface BeerPageProps { interface BeerPageProps {
initialBeerPosts: BeerPostQueryResult[]; initialBeerPosts: BeerPostQueryResult[];
@@ -20,6 +21,10 @@ const BeerPage: NextPage<BeerPageProps> = ({ initialBeerPosts, pageCount }) => {
const pageNum = parseInt(query.page_num as string, 10) || 1; const pageNum = parseInt(query.page_num as string, 10) || 1;
return ( return (
<Layout> <Layout>
<Head>
<title>Beer</title>
<meta name="description" content="Beer posts" />
</Head>
<div className="flex items-center justify-center bg-base-100"> <div className="flex items-center justify-center bg-base-100">
<main className="my-10 flex w-10/12 flex-col space-y-4"> <main className="my-10 flex w-10/12 flex-col space-y-4">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">

View File

@@ -1,4 +1,4 @@
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import getBreweryPostById from '@/services/BreweryPost/getBreweryPostById'; import getBreweryPostById from '@/services/BreweryPost/getBreweryPostById';
import { GetServerSideProps, NextPage } from 'next'; import { GetServerSideProps, NextPage } from 'next';

View File

@@ -4,6 +4,10 @@ import { useRouter } from 'next/router';
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import useUser from '@/hooks/useUser'; import useUser from '@/hooks/useUser';
import LoginForm from '@/components/Login/LoginForm'; import LoginForm from '@/components/Login/LoginForm';
import Image from 'next/image';
import { FaUserCircle } from 'react-icons/fa';
import Head from 'next/head';
const LoginPage: NextPage = () => { const LoginPage: NextPage = () => {
const { user } = useUser(); const { user } = useUser();
@@ -19,14 +23,33 @@ const LoginPage: NextPage = () => {
return ( return (
<Layout> <Layout>
<Head>
<title>Login</title>
<meta name="description" content="Login to your account" />
</Head>
<div className="flex h-full flex-row"> <div className="flex h-full flex-row">
<div className="flex h-full w-[40%] flex-col items-center justify-center bg-base-100"> <div className="hidden h-full flex-col items-center justify-center bg-base-100 lg:flex lg:w-[60%]">
<h1>Login</h1> <Image
src="https://picsum.photos/1040/1080"
alt="Login Image"
width={4920}
height={4080}
className="h-full w-full object-cover"
/>
</div> </div>
<div className="flex h-full w-[60%] flex-col items-center justify-center bg-base-300"> <div className="flex h-full w-full flex-col items-center space-y-5 bg-base-300 lg:w-[40%]">
<div className="mt-44 w-9/12">
<div className=" flex flex-col items-center space-y-2">
<FaUserCircle className="text-3xl" />
<h1 className="text-4xl font-bold">Login</h1>
</div>
<div className="mt-10">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>
</div>
</div>
</Layout> </Layout>
); );
}; };

168
pages/register/index.tsx Normal file
View File

@@ -0,0 +1,168 @@
import ErrorAlert from '@/components/ui/alerts/ErrorAlert';
import Button from '@/components/ui/forms/Button';
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 Layout from '@/components/ui/Layout';
import sendRegisterUserRequest from '@/requests/sendRegisterUserRequest';
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { NextPage } from 'next';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { FaUserCircle } from 'react-icons/fa';
import { z } from 'zod';
interface RegisterUserProps {}
const RegisterUserPage: NextPage<RegisterUserProps> = () => {
const router = useRouter();
const { reset, register, handleSubmit, formState } = useForm<
z.infer<typeof CreateUserValidationSchema>
>({
resolver: zodResolver(CreateUserValidationSchema),
});
const { errors } = formState;
const [serverResponseError, setServerResponseError] = useState('');
const onSubmit = async (data: z.infer<typeof CreateUserValidationSchema>) => {
try {
await sendRegisterUserRequest(data);
reset();
router.push('/', undefined, { shallow: true });
} catch (error) {
setServerResponseError(
error instanceof Error
? error.message
: 'Something went wrong. We could not register your account.',
);
}
};
return (
<Layout>
<div
className="flex h-full flex-col items-center justify-center space-y-6"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex flex-col items-center space-y-2">
<FaUserCircle className="text-3xl" />
<h1 className="text-4xl font-bold">Register</h1>
</div>
<form className="form-control w-7/12 space-y-5" noValidate>
{serverResponseError && (
<ErrorAlert error={serverResponseError} setError={setServerResponseError} />
)}
<div>
<div className="flex flex-row space-x-3">
<div className="w-[50%]">
<FormInfo>
<FormLabel htmlFor="firstName">First name</FormLabel>
<FormError>{errors.firstName?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="firstName"
type="text"
formValidationSchema={register('firstName')}
error={!!errors.firstName}
placeholder="first name"
/>
</FormSegment>
</div>
<div className="w-[50%]">
<FormInfo>
<FormLabel htmlFor="lastName">Last name</FormLabel>
<FormError>{errors.lastName?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="lastName"
type="text"
formValidationSchema={register('lastName')}
error={!!errors.lastName}
placeholder="last name"
/>
</FormSegment>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="username">username</FormLabel>
<FormError>{errors.username?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="username"
type="text"
formValidationSchema={register('username')}
error={!!errors.username}
placeholder="username"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="email">email</FormLabel>
<FormError>{errors.email?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="email"
type="email"
formValidationSchema={register('email')}
error={!!errors.email}
placeholder="email"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="password">password</FormLabel>
<FormError>{errors.password?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="password"
type="password"
formValidationSchema={register('password')}
error={!!errors.password}
placeholder="password"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="confirmPassword">confirm password</FormLabel>
<FormError>{errors.confirmPassword?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="confirmPassword"
type="password"
formValidationSchema={register('confirmPassword')}
error={!!errors.confirmPassword}
placeholder="confirm password"
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="dateOfBirth">Date of birth</FormLabel>
<FormError>{errors.dateOfBirth?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
id="dateOfBirth"
type="date"
formValidationSchema={register('dateOfBirth')}
error={!!errors.dateOfBirth}
placeholder="date of birth"
/>
</FormSegment>
<Button type="submit">Register User</Button>
</div>
</form>
</div>
</Layout>
);
};
export default RegisterUserPage;

View File

@@ -1,11 +1,17 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
const DBClient = { const DBClient = {
instance: new PrismaClient(), instance:
globalForPrisma.prisma ||
new PrismaClient({
log: ['info', 'warn'],
}),
}; };
export type IDBClient = typeof DBClient; if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = DBClient.instance;
Object.freeze(DBClient); }
export default DBClient; export default DBClient;

1
prisma/ERD.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `url` on the `BeerImage` table. All the data in the column will be lost.
- You are about to drop the column `url` on the `BreweryImage` table. All the data in the column will be lost.
- Added the required column `caption` to the `BeerImage` table without a default value. This is not possible if the table is not empty.
- Added the required column `path` to the `BeerImage` table without a default value. This is not possible if the table is not empty.
- Added the required column `postedById` to the `BeerImage` table without a default value. This is not possible if the table is not empty.
- Added the required column `caption` to the `BreweryImage` table without a default value. This is not possible if the table is not empty.
- Added the required column `path` to the `BreweryImage` table without a default value. This is not possible if the table is not empty.
- Added the required column `postedById` to the `BreweryImage` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "BeerImage" DROP COLUMN "url",
ADD COLUMN "caption" TEXT NOT NULL,
ADD COLUMN "path" TEXT NOT NULL,
ADD COLUMN "postedById" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "BreweryImage" DROP COLUMN "url",
ADD COLUMN "caption" TEXT NOT NULL,
ADD COLUMN "path" TEXT NOT NULL,
ADD COLUMN "postedById" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "BeerImage" ADD CONSTRAINT "BeerImage_postedById_fkey" FOREIGN KEY ("postedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreweryImage" ADD CONSTRAINT "BreweryImage_postedById_fkey" FOREIGN KEY ("postedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `userId` on the `BeerPostLike` table. All the data in the column will be lost.
- Added the required column `likedById` to the `BeerPostLike` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "BeerPostLike" DROP CONSTRAINT "BeerPostLike_userId_fkey";
-- AlterTable
ALTER TABLE "BeerPostLike" DROP COLUMN "userId",
ADD COLUMN "likedById" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "BeerPostLike" ADD CONSTRAINT "BeerPostLike_likedById_fkey" FOREIGN KEY ("likedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -5,6 +5,13 @@ generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
generator erdSVG {
provider = "prisma-erd-generator"
output = "./ERD.svg"
includeRelationFromFields = true
theme = "neutral"
}
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
@@ -26,6 +33,8 @@ model User {
beerComments BeerComment[] beerComments BeerComment[]
breweryComments BreweryComment[] breweryComments BreweryComment[]
BeerPostLikes BeerPostLike[] BeerPostLikes BeerPostLike[]
BeerImage BeerImage[]
BreweryImage BreweryImage[]
} }
model BeerPost { model BeerPost {
@@ -51,8 +60,8 @@ model BeerPostLike {
id String @id @default(uuid()) id String @id @default(uuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade) beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String beerPostId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade)
userId String likedById String
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
} }
@@ -109,18 +118,24 @@ model BeerImage {
id String @id @default(uuid()) id String @id @default(uuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade) beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String beerPostId String
url String path String
alt String alt String
caption String
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String
} }
model BreweryImage { model BreweryImage {
id String @id @default(uuid()) id String @id @default(uuid())
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade) breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String breweryPostId String
url String path String
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
caption String
alt String alt String
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String
} }

View File

@@ -8,6 +8,11 @@ const cleanDatabase = async () => {
await prisma.$executeRaw`TRUNCATE TABLE "BreweryPost" CASCADE`; await prisma.$executeRaw`TRUNCATE TABLE "BreweryPost" CASCADE`;
await prisma.$executeRaw`TRUNCATE TABLE "BeerComment" CASCADE`; await prisma.$executeRaw`TRUNCATE TABLE "BeerComment" CASCADE`;
await prisma.$executeRaw`TRUNCATE TABLE "BreweryComment" CASCADE`; await prisma.$executeRaw`TRUNCATE TABLE "BreweryComment" CASCADE`;
await prisma.$executeRaw`TRUNCATE TABLE "BeerPostLike" CASCADE`;
await prisma.$executeRaw`TRUNCATE TABLE "BeerImage" CASCADE`;
await prisma.$executeRaw`TRUNCATE TABLE "BreweryImage" CASCADE`;
await prisma.$disconnect();
}; };
export default cleanDatabase; export default cleanDatabase;

View File

@@ -1,15 +1,16 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { BeerPost, BeerImage } from '@prisma/client'; import { BeerPost, BeerImage, User } from '@prisma/client';
import DBClient from '../../DBClient'; import DBClient from '../../DBClient';
interface CreateNewBeerImagesArgs { interface CreateNewBeerImagesArgs {
numberOfImages: number; numberOfImages: number;
beerPosts: BeerPost[]; joinData: { beerPosts: BeerPost[]; users: User[] };
} }
const createNewBeerImages = async ({ const createNewBeerImages = async ({
numberOfImages, numberOfImages,
beerPosts, joinData: { beerPosts, users },
}: CreateNewBeerImagesArgs) => { }: CreateNewBeerImagesArgs) => {
const prisma = DBClient.instance; const prisma = DBClient.instance;
const createdAt = faker.date.past(1); const createdAt = faker.date.past(1);
@@ -18,12 +19,15 @@ const createNewBeerImages = async ({
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let i = 0; i < numberOfImages; i++) { for (let i = 0; i < numberOfImages; i++) {
const beerPost = beerPosts[Math.floor(Math.random() * beerPosts.length)]; const beerPost = beerPosts[Math.floor(Math.random() * beerPosts.length)];
const user = users[Math.floor(Math.random() * users.length)];
beerImagesPromises.push( beerImagesPromises.push(
prisma.beerImage.create({ prisma.beerImage.create({
data: { data: {
url: 'https://picsum.photos/900/1600', path: 'https://picsum.photos/1040/1080',
alt: 'Placeholder beer image.', alt: 'Placeholder beer image.',
caption: 'Placeholder beer image caption.',
beerPost: { connect: { id: beerPost.id } }, beerPost: { connect: { id: beerPost.id } },
postedBy: { connect: { id: user.id } },
createdAt, createdAt,
}, },
}), }),

View File

@@ -22,7 +22,7 @@ const createNewBeerPostLikes = async ({
DBClient.instance.beerPostLike.create({ DBClient.instance.beerPostLike.create({
data: { data: {
beerPost: { connect: { id: beerPost.id } }, beerPost: { connect: { id: beerPost.id } },
user: { connect: { id: user.id } }, likedBy: { connect: { id: user.id } },
}, },
}), }),
); );

View File

@@ -1,15 +1,19 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { BreweryPost, BreweryImage } from '@prisma/client'; import { BreweryPost, BreweryImage, User } from '@prisma/client';
import DBClient from '../../DBClient'; import DBClient from '../../DBClient';
interface CreateBreweryImagesArgs { interface CreateBreweryImagesArgs {
numberOfImages: number; numberOfImages: number;
joinData: {
breweryPosts: BreweryPost[]; breweryPosts: BreweryPost[];
users: User[];
};
} }
const createNewBreweryImages = async ({ const createNewBreweryImages = async ({
numberOfImages, numberOfImages,
breweryPosts, joinData: { breweryPosts, users },
}: CreateBreweryImagesArgs) => { }: CreateBreweryImagesArgs) => {
const prisma = DBClient.instance; const prisma = DBClient.instance;
const createdAt = faker.date.past(1); const createdAt = faker.date.past(1);
@@ -18,13 +22,16 @@ const createNewBreweryImages = async ({
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let i = 0; i < numberOfImages; i++) { for (let i = 0; i < numberOfImages; i++) {
const breweryPost = breweryPosts[Math.floor(Math.random() * breweryPosts.length)]; const breweryPost = breweryPosts[Math.floor(Math.random() * breweryPosts.length)];
const user = users[Math.floor(Math.random() * users.length)];
breweryImagesPromises.push( breweryImagesPromises.push(
prisma.breweryImage.create({ prisma.breweryImage.create({
data: { data: {
url: 'https://picsum.photos/900/1600', path: 'https://picsum.photos/1040/1080',
alt: 'Placeholder brewery image.', alt: 'Placeholder brewery image.',
caption: 'Placeholder brewery image caption.',
breweryPost: { connect: { id: breweryPost.id } }, breweryPost: { connect: { id: breweryPost.id } },
postedBy: { connect: { id: user.id } },
createdAt, createdAt,
}, },
}), }),

View File

@@ -18,7 +18,7 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let i = 0; i < numberOfUsers; i++) { for (let i = 0; i < numberOfUsers; i++) {
const randomValue = crypto.randomBytes(2).toString('hex'); const randomValue = crypto.randomBytes(4).toString('hex');
const firstName = faker.name.firstName(); const firstName = faker.name.firstName();
const lastName = faker.name.lastName(); const lastName = faker.name.lastName();
const username = `${firstName[0]}.${lastName}.${randomValue}`; const username = `${firstName[0]}.${lastName}.${randomValue}`;

View File

@@ -54,11 +54,11 @@ import createNewUsers from './create/createNewUsers';
}), }),
createNewBeerImages({ createNewBeerImages({
numberOfImages: 1000, numberOfImages: 1000,
beerPosts, joinData: { beerPosts, users },
}), }),
createNewBreweryImages({ createNewBreweryImages({
numberOfImages: 1000, numberOfImages: 1000,
breweryPosts, joinData: { breweryPosts, users },
}), }),
]); ]);

View File

@@ -3,11 +3,15 @@ import BeerCommentValidationSchema from '@/services/BeerComment/schema/CreateBee
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod'; import { z } from 'zod';
const BeerCommentValidationSchemaWithId = BeerCommentValidationSchema.extend({
beerPostId: z.string().uuid(),
});
const sendCreateBeerCommentRequest = async ({ const sendCreateBeerCommentRequest = async ({
beerPostId, beerPostId,
content, content,
rating, rating,
}: z.infer<typeof BeerCommentValidationSchema>) => { }: z.infer<typeof BeerCommentValidationSchemaWithId>) => {
const response = await fetch(`/api/beers/${beerPostId}/comments`, { const response = await fetch(`/api/beers/${beerPostId}/comments`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -29,7 +33,6 @@ const sendCreateBeerCommentRequest = async ({
const parsedResponse = APIResponseValidationSchema.safeParse(data); const parsedResponse = APIResponseValidationSchema.safeParse(data);
if (!parsedResponse.success) { if (!parsedResponse.success) {
console.log(parsedResponse.error);
throw new Error('Invalid API response'); throw new Error('Invalid API response');
} }
@@ -37,7 +40,6 @@ const sendCreateBeerCommentRequest = async ({
const parsedPayload = BeerCommentQueryResult.safeParse(parsedResponse.data.payload); const parsedPayload = BeerCommentQueryResult.safeParse(parsedResponse.data.payload);
if (!parsedPayload.success) { if (!parsedPayload.success) {
console.log(parsedPayload.error);
throw new Error('Invalid API response payload'); throw new Error('Invalid API response payload');
} }

View File

@@ -1,40 +1,36 @@
import BeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema'; import { beerPostQueryResultSchema } from '@/services/BeerPost/schema/BeerPostQueryResult';
import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod'; import { z } from 'zod';
const sendCreateBeerPostRequest = async ( const sendCreateBeerPostRequest = async (
data: z.infer<typeof BeerPostValidationSchema>, data: z.infer<typeof CreateBeerPostValidationSchema>,
) => { ) => {
const response = await fetch('/api/beers/create', { const response = await fetch('/api/beers/create', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
const json = await response.json(); const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json); const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) { if (!parsed.success) {
throw new Error('Invalid API response'); throw new Error('Invalid API response');
} }
const { payload } = parsed.data; const { payload, success, message } = parsed.data;
if ( if (!success) {
!( throw new Error(message);
payload &&
typeof payload === 'object' &&
'id' in payload &&
typeof payload.id === 'string'
)
) {
throw new Error('Invalid API response');
} }
return payload; const parsedPayload = beerPostQueryResultSchema.safeParse(payload);
if (!parsedPayload.success) {
throw new Error('Invalid API response payload');
}
return parsedPayload.data;
}; };
export default sendCreateBeerPostRequest; export default sendCreateBeerPostRequest;

View File

@@ -1,8 +1,7 @@
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
const sendLikeRequest = async (beerPost: BeerPostQueryResult) => { const sendLikeRequest = async (beerPostId: string) => {
const response = await fetch(`/api/beers/${beerPost.id}/like`, { const response = await fetch(`/api/beers/${beerPostId}/like`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -17,6 +17,9 @@ const sendLoginUserRequest = async (data: { username: string; password: string }
throw new Error('API response validation failed'); throw new Error('API response validation failed');
} }
if (!parsed.data.success) {
throw new Error(parsed.data.message);
}
const parsedPayload = BasicUserInfoSchema.safeParse(parsed.data.payload); const parsedPayload = BasicUserInfoSchema.safeParse(parsed.data.payload);
if (!parsedPayload.success) { if (!parsedPayload.success) {
throw new Error('API response payload validation failed'); throw new Error('API response payload validation failed');

View File

@@ -0,0 +1,35 @@
import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
async function sendRegisterUserRequest(data: z.infer<typeof CreateUserValidationSchema>) {
const response = await fetch('/api/users/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
if (!parsed.data.success) {
throw new Error(parsed.data.message);
}
const parsedPayload = GetUserSchema.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API response payload validation failed.');
}
return parsedPayload.data;
}
export default sendRegisterUserRequest;

View File

@@ -2,8 +2,9 @@ import DBClient from '@/prisma/DBClient';
import { z } from 'zod'; import { z } from 'zod';
import BeerCommentValidationSchema from './schema/CreateBeerCommentValidationSchema'; import BeerCommentValidationSchema from './schema/CreateBeerCommentValidationSchema';
const CreateBeerCommentWithUserSchema = BeerCommentValidationSchema.extend({ const CreateNewBeerCommentServiceSchema = BeerCommentValidationSchema.extend({
userId: z.string().uuid(), userId: z.string().uuid(),
beerPostId: z.string().uuid(),
}); });
const createNewBeerComment = async ({ const createNewBeerComment = async ({
@@ -11,7 +12,7 @@ const createNewBeerComment = async ({
rating, rating,
beerPostId, beerPostId,
userId, userId,
}: z.infer<typeof CreateBeerCommentWithUserSchema>) => { }: z.infer<typeof CreateNewBeerCommentServiceSchema>) => {
return DBClient.instance.beerComment.create({ return DBClient.instance.beerComment.create({
data: { data: {
content, content,

View File

@@ -1,5 +1,5 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import BeerPostQueryResult from '../BeerPost/schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
import { BeerCommentQueryResultArrayT } from './schema/BeerCommentQueryResult'; import { BeerCommentQueryResultArrayT } from './schema/BeerCommentQueryResult';
const getAllBeerComments = async ( const getAllBeerComments = async (
@@ -9,27 +9,17 @@ const getAllBeerComments = async (
const skip = (pageNum - 1) * pageSize; const skip = (pageNum - 1) * pageSize;
const beerComments: BeerCommentQueryResultArrayT = const beerComments: BeerCommentQueryResultArrayT =
await DBClient.instance.beerComment.findMany({ await DBClient.instance.beerComment.findMany({
where: { skip,
beerPostId: id, take: pageSize,
}, where: { beerPostId: id },
select: { select: {
id: true, id: true,
content: true, content: true,
rating: true, rating: true,
createdAt: true, createdAt: true,
postedBy: { postedBy: { select: { id: true, username: true, createdAt: true } },
select: {
id: true,
username: true,
createdAt: true,
}, },
}, orderBy: { createdAt: 'desc' },
},
orderBy: {
createdAt: 'desc',
},
skip,
take: pageSize,
}); });
return beerComments; return beerComments;
}; };

View File

@@ -4,7 +4,7 @@ export const BeerCommentQueryResult = z.object({
id: z.string().uuid(), id: z.string().uuid(),
content: z.string().min(1).max(300), content: z.string().min(1).max(300),
rating: z.number().int().min(1).max(5), rating: z.number().int().min(1).max(5),
createdAt: z.date().or(z.string().datetime()), createdAt: z.coerce.date(),
postedBy: z.object({ postedBy: z.object({
id: z.string().uuid(), id: z.string().uuid(),
username: z.string().min(1).max(50), username: z.string().min(1).max(50),

View File

@@ -3,20 +3,13 @@ import { z } from 'zod';
const BeerCommentValidationSchema = z.object({ const BeerCommentValidationSchema = z.object({
content: z content: z
.string() .string()
.min(1, { .min(1, { message: 'Comment must not be empty.' })
message: 'Comment must not be empty.', .max(300, { message: 'Comment must be less than 300 characters.' }),
})
.max(300, {
message: 'Comment must be less than 300 characters.',
}),
rating: z rating: z
.number() .number()
.int() .int()
.min(1, { message: 'Rating must be greater than 1.' }) .min(1, { message: 'Rating must be greater than 1.' })
.max(5, { message: 'Rating must be less than 5.' }), .max(5, { message: 'Rating must be less than 5.' }),
beerPostId: z.string().uuid({
message: 'Beer post ID must be a valid UUID.',
}),
}); });
export default BeerCommentValidationSchema; export default BeerCommentValidationSchema;

View File

@@ -1,8 +1,9 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import { z } from 'zod'; import { z } from 'zod';
import BeerPostValidationSchema from './schema/CreateBeerPostValidationSchema'; import { BeerPostQueryResult } from './schema/BeerPostQueryResult';
import CreateBeerPostValidationSchema from './schema/CreateBeerPostValidationSchema';
const CreateBeerPostWithUserSchema = BeerPostValidationSchema.extend({ const CreateBeerPostWithUserSchema = CreateBeerPostValidationSchema.extend({
userId: z.string().uuid(), userId: z.string().uuid(),
}); });
@@ -15,7 +16,7 @@ const createNewBeerPost = async ({
breweryId, breweryId,
userId, userId,
}: z.infer<typeof CreateBeerPostWithUserSchema>) => { }: z.infer<typeof CreateBeerPostWithUserSchema>) => {
const newBeerPost = await DBClient.instance.beerPost.create({ const newBeerPost: BeerPostQueryResult = await DBClient.instance.beerPost.create({
data: { data: {
name, name,
description, description,
@@ -25,6 +26,18 @@ const createNewBeerPost = async ({
postedBy: { connect: { id: userId } }, postedBy: { connect: { id: userId } },
brewery: { connect: { id: breweryId } }, brewery: { connect: { id: breweryId } },
}, },
select: {
id: true,
name: true,
description: true,
abv: true,
ibu: true,
createdAt: true,
beerImages: { select: { id: true, path: true, caption: true, alt: true } },
brewery: { select: { id: true, name: true } },
type: { select: { id: true, name: true } },
postedBy: { select: { id: true, username: true } },
},
}); });
return newBeerPost; return newBeerPost;
}; };

View File

@@ -1,5 +1,5 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import BeerPostQueryResult from './schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
const prisma = DBClient.instance; const prisma = DBClient.instance;
@@ -10,35 +10,14 @@ const getAllBeerPosts = async (pageNum: number, pageSize: number) => {
select: { select: {
id: true, id: true,
name: true, name: true,
type: {
select: {
name: true,
id: true,
},
},
ibu: true, ibu: true,
abv: true, abv: true,
brewery: {
select: {
name: true,
id: true,
},
},
description: true, description: true,
createdAt: true, createdAt: true,
postedBy: { type: { select: { name: true, id: true } },
select: { brewery: { select: { name: true, id: true } },
id: true, postedBy: { select: { id: true, username: true } },
username: true, beerImages: { select: { path: true, caption: true, id: true, alt: true } },
},
},
beerImages: {
select: {
url: true,
id: true,
alt: true,
},
},
}, },
take: pageSize, take: pageSize,
skip, skip,

View File

@@ -1,60 +1,23 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import BeerPostQueryResult from './schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
const prisma = DBClient.instance; const prisma = DBClient.instance;
const getBeerPostById = async (id: string) => { const getBeerPostById = async (id: string) => {
const beerPost: BeerPostQueryResult | null = await prisma.beerPost.findFirst({ const beerPost: BeerPostQueryResult | null = await prisma.beerPost.findFirst({
select: { select: {
beerComments: {
select: {
id: true,
content: true,
createdAt: true,
postedBy: {
select: {
username: true,
id: true,
},
},
rating: true,
},
},
id: true, id: true,
name: true, name: true,
brewery: {
select: {
name: true,
id: true,
},
},
ibu: true, ibu: true,
abv: true, abv: true,
type: {
select: {
name: true,
id: true,
},
},
beerImages: {
select: {
alt: true,
url: true,
id: true,
},
},
createdAt: true, createdAt: true,
description: true, description: true,
postedBy: { postedBy: { select: { username: true, id: true } },
select: { brewery: { select: { name: true, id: true } },
username: true, type: { select: { name: true, id: true } },
id: true, beerImages: { select: { alt: true, path: true, caption: true, id: true } },
},
},
},
where: {
id,
}, },
where: { id },
}); });
return beerPost; return beerPost;

View File

@@ -1,5 +1,5 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
import BeerPostQueryResult from './schema/BeerPostQueryResult'; import { BeerPostQueryResult } from '@/services/BeerPost/schema/BeerPostQueryResult';
const getBeerRecommendations = async ( const getBeerRecommendations = async (
beerPost: Pick<BeerPostQueryResult, 'type' | 'brewery' | 'id'>, beerPost: Pick<BeerPostQueryResult, 'type' | 'brewery' | 'id'>,
@@ -10,7 +10,7 @@ const getBeerRecommendations = async (
NOT: { id: beerPost.id }, NOT: { id: beerPost.id },
}, },
include: { include: {
beerImages: { select: { id: true, url: true, alt: true } }, beerImages: { select: { id: true, path: true, caption: true, alt: true } },
brewery: { select: { id: true, name: true } }, brewery: { select: { id: true, name: true } },
}, },
}); });

View File

@@ -1,27 +1,36 @@
export default interface BeerPostQueryResult { import { z } from 'zod';
id: string;
name: string;
brewery: {
id: string;
name: string;
};
description: string;
beerImages: {
url: string;
id: string;
alt: string;
}[];
ibu: number; export const beerPostQueryResultSchema = z.object({
abv: number; id: z.string(),
type: { name: z.string(),
id: string; brewery: z.object({
name: string; id: z.string(),
}; name: z.string(),
postedBy: { }),
id: string; description: z.string(),
username: string; beerImages: z.array(
}; z.object({
path: z.string(),
caption: z.string(),
id: z.string(),
alt: z.string(),
}),
),
ibu: z.number(),
abv: z.number(),
type: z.object({
id: z.string(),
name: z.string(),
}),
postedBy: z.object({
id: z.string(),
username: z.string(),
}),
createdAt: z.coerce.date(),
});
createdAt: Date; export const beerPostQueryResultArraySchema = z.array(beerPostQueryResultSchema);
}
export type BeerPostQueryResult = z.infer<typeof beerPostQueryResultSchema>;
export type BeerPostQueryResultArray = z.infer<typeof beerPostQueryResultArraySchema>;

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
const BeerPostValidationSchema = z.object({ const CreateBeerPostValidationSchema = z.object({
name: z name: z
.string({ .string({
required_error: 'Beer name is required.', required_error: 'Beer name is required.',
@@ -40,4 +40,4 @@ const BeerPostValidationSchema = z.object({
.uuid({ message: 'Invalid brewery id.' }), .uuid({ message: 'Invalid brewery id.' }),
}); });
export default BeerPostValidationSchema; export default CreateBeerPostValidationSchema;

View File

@@ -12,7 +12,7 @@ const createBeerPostLike = async ({
return DBClient.instance.beerPostLike.create({ return DBClient.instance.beerPostLike.create({
data: { data: {
beerPost: { connect: { id } }, beerPost: { connect: { id } },
user: { connect: { id: user.id } }, likedBy: { connect: { id: user.id } },
}, },
}); });
}; };

View File

@@ -1,6 +1,6 @@
import DBClient from '@/prisma/DBClient'; import DBClient from '@/prisma/DBClient';
const findBeerPostLikeById = async (id: string) => const findBeerPostLikeById = async (beerPostId: string, likedById: string) =>
DBClient.instance.beerPostLike.findUnique({ where: { id } }); DBClient.instance.beerPostLike.findFirst({ where: { beerPostId, likedById } });
export default findBeerPostLikeById; export default findBeerPostLikeById;

View File

@@ -2,7 +2,8 @@ import sub from 'date-fns/sub';
import { z } from 'zod'; import { z } from 'zod';
const minimumDateOfBirth = sub(new Date(), { years: 19 }); const minimumDateOfBirth = sub(new Date(), { years: 19 });
const CreateUserValidationSchema = z.object({ const CreateUserValidationSchema = z
.object({
email: z.string().email({ message: 'Email must be a valid email address.' }), email: z.string().email({ message: 'Email must be a valid email address.' }),
// use special characters, numbers, and uppercase letters // use special characters, numbers, and uppercase letters
password: z password: z
@@ -17,9 +18,21 @@ const CreateUserValidationSchema = z.object({
.refine((password) => /[^a-zA-Z0-9]/.test(password), { .refine((password) => /[^a-zA-Z0-9]/.test(password), {
message: 'Password must contain at least one special character.', message: 'Password must contain at least one special character.',
}), }),
confirmPassword: z.string(),
firstName: z.string().min(1, { message: 'First name must not be empty.' }), firstName: z
lastName: z.string().min(1, { message: 'Last name must not be empty.' }), .string()
.min(1, { message: 'First name must not be empty.' })
.max(20, { message: 'First name must be less than 20 characters.' })
.refine((firstName) => /^[a-zA-Z]+$/.test(firstName), {
message: 'First name must only contain letters.',
}),
lastName: z
.string()
.min(1, { message: 'Last name must not be empty.' })
.max(20, { message: 'Last name must be less than 20 characters.' })
.refine((lastName) => /^[a-zA-Z]+$/.test(lastName), {
message: 'Last name must only contain letters.',
}),
username: z username: z
.string() .string()
.min(1, { message: 'Username must not be empty.' }) .min(1, { message: 'Username must not be empty.' })
@@ -27,11 +40,14 @@ const CreateUserValidationSchema = z.object({
dateOfBirth: z.string().refine( dateOfBirth: z.string().refine(
(dateOfBirth) => { (dateOfBirth) => {
const parsedDateOfBirth = new Date(dateOfBirth); const parsedDateOfBirth = new Date(dateOfBirth);
return parsedDateOfBirth <= minimumDateOfBirth; return parsedDateOfBirth <= minimumDateOfBirth;
}, },
{ message: 'You must be at least 19 years old to register.' }, { message: 'You must be at least 19 years old to register.' },
), ),
}); })
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
export default CreateUserValidationSchema; export default CreateUserValidationSchema;

View File

@@ -3,12 +3,12 @@ import { z } from 'zod';
const GetUserSchema = z.object({ const GetUserSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
username: z.string(), username: z.string(),
createdAt: z.date().or(z.string()), createdAt: z.coerce.date(),
updatedAt: z.date().or(z.string()).optional(), updatedAt: z.coerce.date().optional(),
email: z.string().email(), email: z.string().email(),
firstName: z.string(), firstName: z.string(),
lastName: z.string(), lastName: z.string(),
dateOfBirth: z.date().or(z.string()), dateOfBirth: z.coerce.date(),
}); });
export default GetUserSchema; export default GetUserSchema;