diff --git a/README.md b/README.md index 981de35..949c5ee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The Biergarten App is a web application designed for beer lovers to share their favorite brews and breweries with like-minded people online. -This application's stack consists of Next.js, Prisma and Vercel Postgres. I'm motivated to +This application's stack consists of Next.js, Prisma and Neon Postgres. I'm motivated to learn more about these technologies while exploring my passion for beer. I've also incorporated different APIs into the application, such as the Cloudinary API for @@ -66,8 +66,8 @@ beer known as iso-alpha acids. - [Prisma](https://www.prisma.io/) - An open-source ORM for Node.js and TypeScript applications. -- [Vercel Postgres](https://vercel.com/dashboard/stores) - - A managed PostgreSQL database service provided by Vercel. +- [Neon Postgres](https://neon.tech/) + - A managed PostgreSQL database service powered by Neon. - [Cloudinary](https://cloudinary.com/) - A cloud-based image and video management service that provides developers with an easy way to upload, store, and manipulate media assets. @@ -94,7 +94,7 @@ You will also need to create a free account with the following services: - [Cloudinary](https://cloudinary.com/users/register/free) - [SparkPost](https://www.sparkpost.com/) -- [Vercel Postgres](https://vercel.com/dashboard/stores) +- [Neon Postgres](https://neon.tech/) - [Mapbox](https://account.mapbox.com/auth/signup/) ### Setup @@ -126,13 +126,11 @@ SESSION_SECRET= SESSION_TOKEN_NAME= SESSION_MAX_AGE= NODE_ENV= -POSTGRES_URL= + POSTGRES_PRISMA_URL= POSTGRES_URL_NON_POOLING= -POSTGRES_USER= -POSTGRES_HOST= -POSTGRES_PASSWORD= -POSTGRES_DATABASE= +SHADOW_DATABASE_URL= + MAPBOX_ACCESS_TOKEN= NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= SPARKPOST_API_KEY= @@ -156,10 +154,13 @@ SPARKPOST_SENDER_ADDRESS=" > .env - You can set this to `biergarten`. - `SESSION_MAX_AGE` is the maximum age of the session cookie in milliseconds. - You can set this to `604800000` (1 week). -- `POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, - `POSTGRES_HOST`, `POSTGRES_PASSWORD`, and `POSTGRES_DATABASE` are the credentials for - your Vercel Postgres database. - - You can create a free account [here](https://vercel.com/dashboard/stores). +- `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database. +- `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres + database used for migrations. +- `SHADOW_DATABASE_URL` is a connection string for a secondary database used for + migrations to detect schema drift. + - You can create a free account [here](https://neon.tech) + - Consult the [docs](https://neon.tech/docs/guides/prisma) for more information. - `MAPBOX_ACCESS_TOKEN` and `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` are the access tokens for your Mapbox account. - You can create a free account [here](https://account.mapbox.com/auth/signup/). @@ -171,7 +172,7 @@ SPARKPOST_SENDER_ADDRESS=" > .env - You can create a free account [here](https://www.sparkpost.com/). - `SPARKPOST_SENDER_ADDRESS` is the email address that will be used to send emails. -4. Initialize the database and run the migrations. +1. Initialize the database and run the migrations. ```bash npx prisma generate diff --git a/package-lock.json b/package-lock.json index e0a1f95..ba81f1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-dom": "^18.2.0", "react-email": "^1.9.3", "react-hook-form": "^7.43.9", + "react-hot-toast": "^2.4.1", "react-icons": "^4.8.0", "react-intersection-observer": "^9.4.3", "react-map-gl": "^7.0.23", @@ -3541,8 +3542,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/daisyui": { "version": "2.51.6", @@ -5719,6 +5719,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz", + "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -9353,6 +9361,21 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-icons": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz", @@ -11233,9 +11256,9 @@ } }, "node_modules/vm2": { - "version": "3.9.17", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", - "integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==", + "version": "3.9.19", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", + "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", "optional": true, "dependencies": { "acorn": "^8.7.0", @@ -13934,8 +13957,7 @@ "csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "daisyui": { "version": "2.51.6", @@ -15557,6 +15579,12 @@ "slash": "^3.0.0" } }, + "goober": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz", + "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==", + "requires": {} + }, "gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -17998,6 +18026,14 @@ "integrity": "sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==", "requires": {} }, + "react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "requires": { + "goober": "^2.1.10" + } + }, "react-icons": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz", @@ -19389,9 +19425,9 @@ } }, "vm2": { - "version": "3.9.17", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.17.tgz", - "integrity": "sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==", + "version": "3.9.19", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz", + "integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==", "optional": true, "requires": { "acorn": "^8.7.0", diff --git a/package.json b/package.json index 47259d4..7f1b4a2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbo", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", @@ -42,6 +42,7 @@ "react-dom": "^18.2.0", "react-email": "^1.9.3", "react-hook-form": "^7.43.9", + "react-hot-toast": "^2.4.1", "react-icons": "^4.8.0", "react-intersection-observer": "^9.4.3", "react-map-gl": "^7.0.23", diff --git a/src/components/Account/AccountInfo.tsx b/src/components/Account/AccountInfo.tsx new file mode 100644 index 0000000..52ac646 --- /dev/null +++ b/src/components/Account/AccountInfo.tsx @@ -0,0 +1,166 @@ +import validateEmail from '@/requests/valdiateEmail'; +import validateUsername from '@/requests/validateUsername'; +import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import { Switch } from '@headlessui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/router'; +import { FC, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import FormError from '../ui/forms/FormError'; +import FormInfo from '../ui/forms/FormInfo'; +import FormLabel from '../ui/forms/FormLabel'; +import FormTextInput from '../ui/forms/FormTextInput'; + +interface AccountInfoProps { + user: z.infer; +} + +const AccountInfo: FC = ({ user }) => { + const router = useRouter(); + const EditUserSchema = BaseCreateUserSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, + }).extend({ + email: z + .string() + .email({ message: 'Email must be a valid email address.' }) + .refine( + async (email) => { + if (user.email === email) return true; + return validateEmail(email); + }, + { message: 'Email is already taken.' }, + ), + username: z + .string() + .min(1, { message: 'Username must not be empty.' }) + .max(20, { message: 'Username must be less than 20 characters.' }) + .refine( + async (username) => { + if (user.username === username) return true; + return validateUsername(username); + }, + { message: 'Username is already taken.' }, + ), + }); + + const { register, handleSubmit, formState, reset } = useForm< + z.infer + >({ + resolver: zodResolver(EditUserSchema), + defaultValues: { + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + }); + + const [inEditMode, setInEditMode] = useState(false); + + const onSubmit = async (data: z.infer) => { + const response = await fetch(`/api/users/${user.id}/edit`, { + body: JSON.stringify(data), + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Something went wrong.'); + } + + await response.json(); + + router.reload(); + }; + + return ( +
+
+
+ + +
+ + Username + {formState.errors.username?.message} + + + + Email + {formState.errors.email?.message} + + + +
+
+ + First Name + {formState.errors.firstName?.message} + + +
+
+ + Last Name + {formState.errors.lastName?.message} + + +
+
+
+ {inEditMode && ( + + )} +
+
+
+ ); +}; + +export default AccountInfo; diff --git a/src/components/BeerBreweryComments/CommentCardBody.tsx b/src/components/BeerBreweryComments/CommentCardBody.tsx index 2f6c606..11e0636 100644 --- a/src/components/BeerBreweryComments/CommentCardBody.tsx +++ b/src/components/BeerBreweryComments/CommentCardBody.tsx @@ -4,6 +4,7 @@ import { FC, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { z } from 'zod'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; + import CommentContentBody from './CommentContentBody'; import EditCommentBody from './EditCommentBody'; diff --git a/src/components/BeerBreweryComments/CommentCardDropdown.tsx b/src/components/BeerBreweryComments/CommentCardDropdown.tsx index cee88c5..a163b46 100644 --- a/src/components/BeerBreweryComments/CommentCardDropdown.tsx +++ b/src/components/BeerBreweryComments/CommentCardDropdown.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import { Dispatch, SetStateAction, FC, useContext } from 'react'; import { FaEllipsisH } from 'react-icons/fa'; import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; diff --git a/src/components/BeerBreweryComments/CommentContentBody.tsx b/src/components/BeerBreweryComments/CommentContentBody.tsx index d7dcc98..9d6a41a 100644 --- a/src/components/BeerBreweryComments/CommentContentBody.tsx +++ b/src/components/BeerBreweryComments/CommentContentBody.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import { format } from 'date-fns'; import { Dispatch, FC, SetStateAction, useContext } from 'react'; diff --git a/src/components/BeerBreweryComments/EditCommentBody.tsx b/src/components/BeerBreweryComments/EditCommentBody.tsx index 768ff25..4b3938b 100644 --- a/src/components/BeerBreweryComments/EditCommentBody.tsx +++ b/src/components/BeerBreweryComments/EditCommentBody.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { FC, useState, Dispatch, SetStateAction } from 'react'; +import { FC, useState, Dispatch, SetStateAction, useContext } from 'react'; import { Rating } from 'react-daisyui'; import { useForm, SubmitHandler } from 'react-hook-form'; import { z } from 'zod'; @@ -7,6 +7,7 @@ import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPost import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments'; +import ToastContext from '@/contexts/ToastContext'; import FormError from '../ui/forms/FormError'; import FormInfo from '../ui/forms/FormInfo'; import FormLabel from '../ui/forms/FormLabel'; @@ -42,6 +43,7 @@ const EditCommentBody: FC = ({ resolver: zodResolver(CreateCommentValidationSchema), }); + const { toast } = useContext(ToastContext); const { errors } = formState; const [isDeleting, setIsDeleting] = useState(false); @@ -58,6 +60,7 @@ const EditCommentBody: FC = ({ setInEditMode(true); await handleEditRequest(comment.id, data); await mutate(); + toast.success('Submitted edits'); setInEditMode(false); }; diff --git a/src/components/BeerById/BeerInfoHeader.tsx b/src/components/BeerById/BeerInfoHeader.tsx index 188924c..f53a2f3 100644 --- a/src/components/BeerById/BeerInfoHeader.tsx +++ b/src/components/BeerById/BeerInfoHeader.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import format from 'date-fns/format'; import { FC, useContext } from 'react'; -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import { FaRegEdit } from 'react-icons/fa'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { z } from 'zod'; diff --git a/src/components/BeerById/BeerPostCommentsSection.tsx b/src/components/BeerById/BeerPostCommentsSection.tsx index 2c732ab..1329d5b 100644 --- a/src/components/BeerById/BeerPostCommentsSection.tsx +++ b/src/components/BeerById/BeerPostCommentsSection.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; @@ -6,6 +6,7 @@ import { FC, MutableRefObject, useContext, useRef } from 'react'; import { z } from 'zod'; import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments'; import { useRouter } from 'next/router'; +import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; import BeerCommentForm from './BeerCommentForm'; import LoadingComponent from './LoadingComponent'; @@ -20,29 +21,25 @@ const BeerPostCommentsSection: FC = ({ beerPost }) const router = useRouter(); const pageNum = parseInt(router.query.comments_page as string, 10) || 1; - const PAGE_SIZE = 4; + const PAGE_SIZE = 15; const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } = - useBeerPostComments({ - id: beerPost.id, - pageNum, - pageSize: PAGE_SIZE, - }); + useBeerPostComments({ id: beerPost.id, pageNum, pageSize: PAGE_SIZE }); const commentSectionRef: MutableRefObject = useRef(null); - async function handleDeleteRequest(id: string) { + const handleDeleteRequest = async (id: string) => { const response = await fetch(`/api/beer-comments/${id}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete comment.'); } - } + }; - async function handleEditRequest( + const handleEditRequest = async ( id: string, - data: { content: string; rating: number }, - ) { + data: z.infer, + ) => { const response = await fetch(`/api/beer-comments/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -52,7 +49,7 @@ const BeerPostCommentsSection: FC = ({ beerPost }) if (!response.ok) { throw new Error('Failed to update comment.'); } - } + }; return (
diff --git a/src/components/BeerById/BeerRecommendations.tsx b/src/components/BeerById/BeerRecommendations.tsx index 1e3b3bb..5be84b4 100644 --- a/src/components/BeerById/BeerRecommendations.tsx +++ b/src/components/BeerById/BeerRecommendations.tsx @@ -1,40 +1,101 @@ -import BeerRecommendationQueryResult from '@/services/BeerPost/schema/BeerRecommendationQueryResult'; import Link from 'next/link'; -import { FunctionComponent } from 'react'; +import { FC, MutableRefObject, useRef } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { z } from 'zod'; +import useBeerRecommendations from '@/hooks/data-fetching/beer-posts/useBeerRecommendations'; +import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import debounce from 'lodash/debounce'; +import BeerRecommendationLoadingComponent from './BeerRecommendationLoadingComponent'; + +const BeerRecommendationsSection: FC<{ + beerPost: z.infer; +}> = ({ beerPost }) => { + const PAGE_SIZE = 10; + + const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = useBeerRecommendations({ + beerPost, + pageSize: PAGE_SIZE, + }); + + const { ref: penultimateBeerPostRef } = useInView({ + /** + * When the last beer post comes into view, call setSize from useBeerPostsByBrewery to + * load more beer posts. + */ + onChange: (visible) => { + if (!visible || isAtEnd) return; + debounce(() => setSize(size + 1), 200)(); + }, + }); + + const beerRecommendationsRef: MutableRefObject = useRef(null); -interface BeerRecommendationsProps { - beerRecommendations: BeerRecommendationQueryResult[]; -} -const BeerRecommendations: FunctionComponent = ({ - beerRecommendations, -}) => { return ( -
-
- {beerRecommendations.map((beerPost) => ( -
+
+
+ <> +
- -

- {beerPost.name} -

- - -

- {beerPost.brewery.name} -

- -
- -
- {beerPost.abv}% ABV - {beerPost.ibu} IBU +

Also check out

- ))} + + {!!beerPosts.length && ( +
+ {beerPosts.map((post, index) => { + const isPenultimateBeerPost = index === beerPosts.length - 2; + + /** + * Attach a ref to the second last beer post in the list. When it comes + * into view, the component will call setSize to load more beer posts. + */ + + return ( +
+
+ + {post.name} + + + + {post.brewery.name} + +
+ +
+
+ {post.type.name} +
+
+ {post.abv}% ABV + {post.ibu} IBU +
+
+
+ ); + })} +
+ )} + + { + /** + * If there are more beer posts to load, show a loading component with a + * skeleton loader and a loading spinner. + */ + !!isLoadingMore && !isAtEnd && ( + + ) + } +
); }; -export default BeerRecommendations; +export default BeerRecommendationsSection; diff --git a/src/components/BeerIndex/BeerCard.tsx b/src/components/BeerIndex/BeerCard.tsx index d1c3d77..9092126 100644 --- a/src/components/BeerIndex/BeerCard.tsx +++ b/src/components/BeerIndex/BeerCard.tsx @@ -3,7 +3,7 @@ import { FC, useContext } from 'react'; import Image from 'next/image'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { z } from 'zod'; -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount'; import BeerPostLikeButton from '../BeerById/BeerPostLikeButton'; diff --git a/src/components/BreweryById/BreweryBeerSection.tsx b/src/components/BreweryById/BreweryBeerSection.tsx index 4f0ba6b..5400277 100644 --- a/src/components/BreweryById/BreweryBeerSection.tsx +++ b/src/components/BreweryById/BreweryBeerSection.tsx @@ -1,10 +1,11 @@ import UseBeerPostsByBrewery from '@/hooks/data-fetching/beer-posts/useBeerPostsByBrewery'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import Link from 'next/link'; -import { FC } from 'react'; +import { FC, MutableRefObject, useContext, useRef } from 'react'; import { useInView } from 'react-intersection-observer'; import { z } from 'zod'; import { FaPlus } from 'react-icons/fa'; +import UserContext from '@/contexts/UserContext'; import BeerRecommendationLoadingComponent from '../BeerById/BeerRecommendationLoadingComponent'; interface BreweryCommentsSectionProps { @@ -13,6 +14,8 @@ interface BreweryCommentsSectionProps { const BreweryBeersSection: FC = ({ breweryPost }) => { const PAGE_SIZE = 2; + const { user } = useContext(UserContext); + const { beerPosts, isAtEnd, isLoadingMore, setSize, size } = UseBeerPostsByBrewery({ breweryId: breweryPost.id, pageSize: PAGE_SIZE, @@ -28,8 +31,10 @@ const BreweryBeersSection: FC = ({ breweryPost }) = }, }); + const beerRecommendationsRef: MutableRefObject = useRef(null); + return ( -
+
<>
@@ -37,13 +42,15 @@ const BreweryBeersSection: FC = ({ breweryPost }) =

Brews

- - - Add Beer - + {user && ( + + + Add Beer + + )}
diff --git a/src/components/BreweryById/BreweryCommentsSection.tsx b/src/components/BreweryById/BreweryCommentsSection.tsx index ecc8363..80c770b 100644 --- a/src/components/BreweryById/BreweryCommentsSection.tsx +++ b/src/components/BreweryById/BreweryCommentsSection.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import { FC, MutableRefObject, useContext, useRef } from 'react'; import { z } from 'zod'; @@ -9,6 +9,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem import CommentQueryResult from '@/services/types/CommentSchema/CommentQueryResult'; import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments'; +import ToastContext from '@/contexts/ToastContext'; import LoadingComponent from '../BeerById/LoadingComponent'; import CommentsComponent from '../ui/CommentsComponent'; import CommentForm from '../ui/CommentForm'; @@ -63,6 +64,7 @@ const BreweryCommentForm: FC = ({ breweryPost, mutate } resolver: zodResolver(CreateCommentValidationSchema), }); + const { toast } = useContext(ToastContext); const onSubmit: SubmitHandler> = async ( data, ) => { @@ -72,6 +74,7 @@ const BreweryCommentForm: FC = ({ breweryPost, mutate } breweryPostId: breweryPost.id, }); await mutate(); + toast.loading('Created new comment.'); reset(); }; diff --git a/src/components/BreweryById/BreweryInfoHeader.tsx b/src/components/BreweryById/BreweryInfoHeader.tsx index 42cb969..3c7badb 100644 --- a/src/components/BreweryById/BreweryInfoHeader.tsx +++ b/src/components/BreweryById/BreweryInfoHeader.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount'; import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; diff --git a/src/components/BreweryIndex/BreweryCard.tsx b/src/components/BreweryIndex/BreweryCard.tsx index bded816..a9779e8 100644 --- a/src/components/BreweryIndex/BreweryCard.tsx +++ b/src/components/BreweryIndex/BreweryCard.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import useGetBreweryPostLikeCount from '@/hooks/data-fetching/brewery-likes/useGetBreweryPostLikeCount'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import { FC, useContext } from 'react'; diff --git a/src/components/Login/LoginForm.tsx b/src/components/Login/LoginForm.tsx index c10508b..915f2e8 100644 --- a/src/components/Login/LoginForm.tsx +++ b/src/components/Login/LoginForm.tsx @@ -5,7 +5,8 @@ import { useRouter } from 'next/router'; import { useContext, useState } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; import { z } from 'zod'; -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; +import ToastContext from '@/contexts/ToastContext'; import ErrorAlert from '../ui/alerts/ErrorAlert'; import FormError from '../ui/forms/FormError'; import FormInfo from '../ui/forms/FormInfo'; @@ -30,12 +31,15 @@ const LoginForm = () => { const [responseError, setResponseError] = useState(''); const { mutate } = useContext(UserContext); + const { toast } = useContext(ToastContext); const onSubmit: SubmitHandler = async (data) => { try { + const id = toast.loading('Logging in.'); await sendLoginUserRequest(data); await mutate!(); await router.push(`/user/current`); + toast.remove(id); } catch (error) { if (error instanceof Error) { setResponseError(error.message); diff --git a/src/components/RegisterUserForm.tsx b/src/components/RegisterUserForm.tsx index a8ecf26..163e540 100644 --- a/src/components/RegisterUserForm.tsx +++ b/src/components/RegisterUserForm.tsx @@ -1,7 +1,5 @@ import sendRegisterUserRequest from '@/requests/sendRegisterUserRequest'; -import CreateUserValidationSchema, { - CreateUserValidationSchemaWithUsernameAndEmailCheck, -} from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchemaWithUsernameAndEmailCheck } from '@/services/User/schema/CreateUserValidationSchemas'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; @@ -19,13 +17,15 @@ import FormTextInput from './ui/forms/FormTextInput'; const RegisterUserForm: FC = () => { const router = useRouter(); const { reset, register, handleSubmit, formState } = useForm< - z.infer + z.infer >({ resolver: zodResolver(CreateUserValidationSchemaWithUsernameAndEmailCheck) }); const { errors } = formState; const [serverResponseError, setServerResponseError] = useState(''); - const onSubmit = async (data: z.infer) => { + const onSubmit = async ( + data: z.infer, + ) => { try { await sendRegisterUserRequest(data); reset(); diff --git a/src/components/ui/CommentsComponent.tsx b/src/components/ui/CommentsComponent.tsx index ece5752..fb08e56 100644 --- a/src/components/ui/CommentsComponent.tsx +++ b/src/components/ui/CommentsComponent.tsx @@ -49,9 +49,10 @@ const CommentsComponent: FC = ({ handleEditRequest, }) => { const { ref: penultimateCommentRef } = useInView({ + threshold: 0.1, /** - * When the second last comment comes into view, call setSize from useBeerPostComments - * to load more comments. + * When the last comment comes into view, call setSize from useBeerPostComments to + * load more comments. */ onChange: (visible) => { if (!visible || isAtEnd) return; @@ -62,9 +63,9 @@ const CommentsComponent: FC = ({ return ( <> {!!comments.length && ( -
+
{comments.map((comment, index) => { - const isPenultimateComment = index === comments.length - 2; + const isLastComment = index === comments.length - 1; /** * Attach a ref to the last comment in the list. When it comes into view, the @@ -72,7 +73,7 @@ const CommentsComponent: FC = ({ */ return (
{ + let className: 'alert-success' | 'alert-error' | 'alert-info'; + + switch (toastType) { + case 'success': + className = 'alert-success'; + break; + case 'error': + className = 'alert-error'; + break; + default: + className = 'alert-info'; + } + + return className; +}; + +const CustomToast: FC<{ children: ReactNode }> = ({ children }) => { + return ( + + + {(t) => { + const alertType = toastToClassName(t.type); + return ( +
+
+
{resolveValue(t.message, t)}
+ +
+
+ ); + }} +
+ {children} +
+ ); +}; +export default CustomToast; diff --git a/src/config/env/index.ts b/src/config/env/index.ts index 0b4fc36..9567c3f 100644 --- a/src/config/env/index.ts +++ b/src/config/env/index.ts @@ -19,13 +19,8 @@ const envSchema = z.object({ SESSION_TOKEN_NAME: z.string(), SESSION_MAX_AGE: z.coerce.number().positive(), - POSTGRES_URL: z.string().url(), POSTGRES_PRISMA_URL: z.string().url(), POSTGRES_URL_NON_POOLING: z.string().url(), - POSTGRES_USER: z.string(), - POSTGRES_PASSWORD: z.string(), - POSTGRES_DATABASE: z.string(), - POSTGRES_HOST: z.string(), SHADOW_DATABASE_URL: z.string().url(), NODE_ENV: z.enum(['development', 'production', 'test']), @@ -119,80 +114,32 @@ export const SESSION_TOKEN_NAME = parsed.data.SESSION_TOKEN_NAME; export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE; /** - * PostgreSQL connection URL taken from Vercel. + * PostgreSQL connection URL for Prisma taken from Neon. * * @example * 'postgresql://user:password@host:5432/database'; * - * @see https://vercel.com/dashboard/stores - */ -export const POSTGRES_URL = parsed.data.POSTGRES_URL; - -/** - * PostgreSQL connection URL for Prisma taken from Vercel. - * - * @example - * 'postgresql://user:password@host:5432/database'; - * - * @see https://vercel.com/dashboard/stores + * @see https://neon.tech/docs/guides/prisma */ export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL; /** - * Non-pooling PostgreSQL connection URL taken from Vercel. + * Non-pooling PostgreSQL connection URL taken from Neon. * * @example * 'postgresql://user:password@host:5432/database'; * - * @see https://vercel.com/dashboard/stores + * @see https://neon.tech/docs/guides/prisma */ export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING; /** - * The PostgreSQL user from Vercel. - * - * @example - * 'user'; - * - * @see https://vercel.com/dashboard/stores - */ -export const POSTGRES_USER = parsed.data.POSTGRES_USER; - -/** - * The PostgreSQL password from Vercel. - * - * @example - * 'password'; - * - * @see https://vercel.com/dashboard/stores - */ -export const POSTGRES_PASSWORD = parsed.data.POSTGRES_PASSWORD; - -/** - * The PostgreSQL database from Vercel. - * - * @example - * 'database'; - * - * @see https://vercel.com/dashboard/stores - */ -export const POSTGRES_DATABASE = parsed.data.POSTGRES_DATABASE; - -/** - * The PostgreSQL host from Vercel. - * - * @example - * 'ep-sweet-pineapple.us-east-1.postgres.vercel-storage.com'; - * - * @see https://vercel.com/dashboard/stores - */ -export const POSTGRES_HOST = parsed.data.POSTGRES_HOST; - -/** - * The URL of another PostgreSQL database to shadow. + * The URL of another Neon PostgreSQL database to shadow for migrations. * * @example * 'postgresql://user:password@host:5432/database'; + * + * @see https://neon.tech/docs/guides/prisma-migrate */ export const SHADOW_DATABASE_URL = parsed.data.SHADOW_DATABASE_URL; diff --git a/src/contexts/ToastContext.ts b/src/contexts/ToastContext.ts new file mode 100644 index 0000000..e5b5c5e --- /dev/null +++ b/src/contexts/ToastContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; +import toast from 'react-hot-toast'; + +const ToastContext = createContext<{ + toast: typeof toast; +}>({ toast }); + +export default ToastContext; diff --git a/src/contexts/userContext.ts b/src/contexts/UserContext.ts similarity index 100% rename from src/contexts/userContext.ts rename to src/contexts/UserContext.ts diff --git a/src/hooks/auth/useRedirectIfLoggedIn.ts b/src/hooks/auth/useRedirectIfLoggedIn.ts index ee6d16a..50e8ac0 100644 --- a/src/hooks/auth/useRedirectIfLoggedIn.ts +++ b/src/hooks/auth/useRedirectIfLoggedIn.ts @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import { useRouter } from 'next/router'; import { useContext } from 'react'; diff --git a/src/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost.ts b/src/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost.ts index 3425ed3..f95dd5b 100644 --- a/src/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost.ts +++ b/src/hooks/data-fetching/beer-likes/useCheckIfUserLikesBeerPost.ts @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { useContext } from 'react'; import useSWR from 'swr'; diff --git a/src/hooks/data-fetching/beer-posts/useBeerPosts.ts b/src/hooks/data-fetching/beer-posts/useBeerPosts.ts index 043a35e..67a23c9 100644 --- a/src/hooks/data-fetching/beer-posts/useBeerPosts.ts +++ b/src/hooks/data-fetching/beer-posts/useBeerPosts.ts @@ -49,6 +49,7 @@ const useBeerPosts = ({ pageSize }: { pageSize: number }) => { const { data, error, isLoading, setSize, size } = useSWRInfinite( (index) => `/api/beers?page_num=${index + 1}&page_size=${pageSize}`, fetcher, + { parallel: true }, ); const beerPosts = data?.flatMap((d) => d.beerPosts) ?? []; diff --git a/src/hooks/data-fetching/beer-posts/useBeerRecommendations.ts b/src/hooks/data-fetching/beer-posts/useBeerRecommendations.ts new file mode 100644 index 0000000..5e5cc53 --- /dev/null +++ b/src/hooks/data-fetching/beer-posts/useBeerRecommendations.ts @@ -0,0 +1,80 @@ +import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +interface UseBeerRecommendationsParams { + pageSize: number; + beerPost: z.infer; +} + +/** + * A custom hook using SWR to fetch beer recommendations from the API. + * + * @param options The options to use when fetching beer recommendations. + * @param options.pageSize The number of beer recommendations to fetch per page. + * @param options.beerPost The beer post to fetch recommendations for. + * @returns An object with the following properties: + * + * - `beerPosts`: The beer posts fetched from the API. + * - `error`: The error that occurred while fetching the data. + * - `isAtEnd`: A boolean indicating whether all data has been fetched. + * - `isLoading`: A boolean indicating whether the data is being fetched. + * - `isLoadingMore`: A boolean indicating whether more data is being fetched. + * - `pageCount`: The total number of pages of data. + * - `setSize`: A function to set the size of the data. + * - `size`: The size of the data. + */ +const UseBeerPostsByBrewery = ({ pageSize, beerPost }: UseBeerRecommendationsParams) => { + const fetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const json = await response.json(); + const count = response.headers.get('X-Total-Count'); + + const parsed = APIResponseValidationSchema.safeParse(json); + if (!parsed.success) { + throw new Error('API response validation failed'); + } + + const parsedPayload = z.array(beerPostQueryResult).safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API response validation failed'); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + return { + beerPosts: parsedPayload.data, + pageCount, + }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => + `/api/beers/${beerPost.id}/recommendations/?page_num=${ + index + 1 + }&page_size=${pageSize}`, + fetcher, + ); + + const beerPosts = data?.flatMap((d) => d.beerPosts) ?? []; + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + beerPosts, + pageCount, + size, + setSize, + isLoading, + isLoadingMore, + isAtEnd, + error: error as unknown, + }; +}; + +export default UseBeerPostsByBrewery; diff --git a/src/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost.ts b/src/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost.ts index 0d24194..0cb5b22 100644 --- a/src/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost.ts +++ b/src/hooks/data-fetching/brewery-likes/useCheckIfUserLikesBreweryPost.ts @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { useContext } from 'react'; import useSWR from 'swr'; diff --git a/src/hooks/data-fetching/brewery-posts/useBreweryPosts.ts b/src/hooks/data-fetching/brewery-posts/useBreweryPosts.ts index 18127ee..afc74dc 100644 --- a/src/hooks/data-fetching/brewery-posts/useBreweryPosts.ts +++ b/src/hooks/data-fetching/brewery-posts/useBreweryPosts.ts @@ -46,6 +46,7 @@ const useBreweryPosts = ({ pageSize }: { pageSize: number }) => { const { data, error, isLoading, setSize, size } = useSWRInfinite( (index) => `/api/breweries?page_num=${index + 1}&page_size=${pageSize}`, fetcher, + { parallel: true }, ); const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? []; diff --git a/src/hooks/utilities/useNavbar.ts b/src/hooks/utilities/useNavbar.ts index 474ec92..184777e 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import { useRouter } from 'next/router'; import { useState, useEffect, useContext } from 'react'; @@ -34,8 +34,8 @@ const useNavbar = () => { /** These pages are accessible to both authenticated and unauthenticated users. */ const otherPages: readonly Page[] = [ - { slug: '/breweries', name: 'Breweries' }, { slug: '/beers', name: 'Beers' }, + { slug: '/breweries', name: 'Breweries' }, ]; /** diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index aa265a4..86a4e86 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,4 +1,4 @@ -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import '@/styles/globals.css'; import type { AppProps } from 'next/app'; @@ -11,6 +11,7 @@ import { Space_Grotesk } from 'next/font/google'; import Head from 'next/head'; import Layout from '@/components/ui/Layout'; import useUser from '@/hooks/auth/useUser'; +import CustomToast from '@/components/ui/CustomToast'; const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], @@ -39,9 +40,12 @@ export default function App({ Component, pageProps }: AppProps) { - + + + + ); diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 0a68738..bebc7d1 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -1,120 +1,17 @@ import withPageAuthRequired from '@/util/withPageAuthRequired'; import { NextPage } from 'next'; -import { FC, useState } from 'react'; -import { Switch, Tab } from '@headlessui/react'; +import { Tab } from '@headlessui/react'; import Head from 'next/head'; -import FormInfo from '@/components/ui/forms/FormInfo'; -import FormLabel from '@/components/ui/forms/FormLabel'; -import FormError from '@/components/ui/forms/FormError'; -import FormTextInput from '@/components/ui/forms/FormTextInput'; -import { zodResolver } from '@hookform/resolvers/zod'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import DBClient from '@/prisma/DBClient'; +import AccountInfo from '@/components/Account/AccountInfo'; interface AccountPageProps { user: z.infer; } -const AccountInfo: FC<{ - user: z.infer; -}> = ({ user }) => { - const { register, handleSubmit, formState, reset } = useForm< - z.infer - >({ - resolver: zodResolver(GetUserSchema), - defaultValues: { - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - dateOfBirth: user.dateOfBirth, - }, - }); - - const [inEditMode, setInEditMode] = useState(false); - - return ( -
-
-
- - { - setInEditMode((editMode) => !editMode); - reset(); - }} - id="edit-toggle" - /> -
- -
{})}> -
- - Username - {formState.errors.username?.message} - - - - Email - {''} - - - -
-
- - First Name - {formState.errors.firstName?.message} - - -
-
- - Last Name - {formState.errors.lastName?.message} - - -
-
-
- {inEditMode && } -
-
-
- ); -}; - const AccountPage: NextPage = ({ user }) => { return ( <> @@ -126,7 +23,7 @@ const AccountPage: NextPage = ({ user }) => { />
-
+
@@ -141,10 +38,13 @@ const AccountPage: NextPage = ({ user }) => {
- + Account Info - + + Security + + Your Posts diff --git a/src/pages/api/beer-comments/[id].ts b/src/pages/api/beer-comments/[id].ts index bbd9178..bf9129d 100644 --- a/src/pages/api/beer-comments/[id].ts +++ b/src/pages/api/beer-comments/[id].ts @@ -4,8 +4,9 @@ import validateRequest from '@/config/nextConnect/middleware/validateRequest'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import ServerError from '@/config/util/ServerError'; import DBClient from '@/prisma/DBClient'; +import findBeerCommentById from '@/services/BeerComment/findBeerCommentById'; import CreateCommentValidationSchema from '@/services/types/CommentSchema/CreateCommentValidationSchema'; - +import editBeerCommentById from '@/services/BeerComment/editBeerCommentById'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { NextApiResponse } from 'next'; import { createRouter, NextHandler } from 'next-connect'; @@ -27,9 +28,7 @@ const checkIfCommentOwner = async ( ) => { const { id } = req.query; const user = req.user!; - const comment = await DBClient.instance.beerComment.findUnique({ - where: { id }, - }); + const comment = await findBeerCommentById(id); if (!comment) { throw new ServerError('Comment not found', 404); @@ -48,13 +47,10 @@ const editComment = async ( ) => { const { id } = req.query; - const updated = await DBClient.instance.beerComment.update({ - where: { id }, - data: { - content: req.body.content, - rating: req.body.rating, - updatedAt: new Date(), - }, + const updated = await editBeerCommentById({ + content: req.body.content, + rating: req.body.rating, + id, }); return res.status(200).json({ diff --git a/src/pages/api/beers/[id]/images/index.ts b/src/pages/api/beers/[id]/images/index.ts index 25f31d4..f3dc0e8 100644 --- a/src/pages/api/beers/[id]/images/index.ts +++ b/src/pages/api/beers/[id]/images/index.ts @@ -1,5 +1,3 @@ -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'; @@ -14,6 +12,8 @@ import { NextApiResponse } from 'next'; import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import processImageDataIntoDB from '@/services/BeerImage/processImageDataIntoDB'; +import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema'; const { storage } = cloudinaryConfig; @@ -34,15 +34,10 @@ const uploadMiddleware = expressWrapper( ), ); -const BeerPostImageValidationSchema = z.object({ - caption: z.string(), - alt: z.string(), -}); - interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest { files?: Express.Multer.File[]; query: { id: string }; - body: z.infer; + body: z.infer; } const processImageData = async ( @@ -54,24 +49,15 @@ const processImageData = async ( if (!files || !files.length) { throw new ServerError('No images uploaded', 400); } - const beerImagePromises: Promise[] = []; - 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 processImageDataIntoDB({ + alt: body.alt, + caption: body.caption, + beerPostId: req.query.id, + userId: user!.id, + files, }); - const beerImages = await Promise.all(beerImagePromises); - res.status(200).json({ success: true, message: `Successfully uploaded ${beerImages.length} image${ @@ -90,7 +76,7 @@ router.post( getCurrentUser, // @ts-expect-error uploadMiddleware, - validateRequest({ bodySchema: BeerPostImageValidationSchema }), + validateRequest({ bodySchema: ImageMetadataValidationSchema }), processImageData, ); diff --git a/src/pages/api/beers/[id]/like/index.ts b/src/pages/api/beers/[id]/like/index.ts index dbf76ec..d422617 100644 --- a/src/pages/api/beers/[id]/like/index.ts +++ b/src/pages/api/beers/[id]/like/index.ts @@ -11,7 +11,7 @@ import removeBeerPostLikeById from '@/services/BeerPostLike/removeBeerPostLikeBy import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; -import DBClient from '@/prisma/DBClient'; +import getBeerPostLikeCount from '@/services/BeerPostLike/getBeerPostLikeCount'; const sendLikeRequest = async ( req: UserExtendedNextApiRequest, @@ -25,7 +25,10 @@ const sendLikeRequest = async ( throw new ServerError('Could not find a beer post with that id', 404); } - const alreadyLiked = await findBeerPostLikeById(beer.id, user.id); + const alreadyLiked = await findBeerPostLikeById({ + beerPostId: beer.id, + likedById: user.id, + }); const jsonResponse = { success: true as const, @@ -50,9 +53,7 @@ const getLikeCount = async ( ) => { const id = req.query.id as string; - const likeCount = await DBClient.instance.beerPostLike.count({ - where: { beerPostId: id }, - }); + const likeCount = await getBeerPostLikeCount(id); res.status(200).json({ success: true, diff --git a/src/pages/api/beers/[id]/like/is-liked.ts b/src/pages/api/beers/[id]/like/is-liked.ts index 09f1b71..2b325b2 100644 --- a/src/pages/api/beers/[id]/like/is-liked.ts +++ b/src/pages/api/beers/[id]/like/is-liked.ts @@ -2,25 +2,20 @@ import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; import { UserExtendedNextApiRequest } from '@/config/auth/types'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import validateRequest from '@/config/nextConnect/middleware/validateRequest'; -import DBClient from '@/prisma/DBClient'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { NextApiResponse } from 'next'; import { createRouter } from 'next-connect'; import { z } from 'zod'; +import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById'; const checkIfLiked = async ( req: UserExtendedNextApiRequest, res: NextApiResponse>, ) => { const user = req.user!; - const id = req.query.id as string; + const beerPostId = req.query.id as string; - const alreadyLiked = await DBClient.instance.beerPostLike.findFirst({ - where: { - beerPostId: id, - likedById: user.id, - }, - }); + const alreadyLiked = await findBeerPostLikeById({ beerPostId, likedById: user.id }); res.status(200).json({ success: true, @@ -37,11 +32,7 @@ const router = createRouter< router.get( getCurrentUser, - validateRequest({ - querySchema: z.object({ - id: z.string().uuid(), - }), - }), + validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), checkIfLiked, ); diff --git a/src/pages/api/beers/[id]/recommendations.ts b/src/pages/api/beers/[id]/recommendations.ts new file mode 100644 index 0000000..28c7c23 --- /dev/null +++ b/src/pages/api/beers/[id]/recommendations.ts @@ -0,0 +1,63 @@ +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import getBeerPostById from '@/services/BeerPost/getBeerPostById'; +import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface BeerPostRequest extends NextApiRequest { + query: { id: string; page_num: string; page_size: string }; +} + +const router = createRouter< + BeerPostRequest, + NextApiResponse> +>(); + +const getBeerRecommendationsRequest = async ( + req: BeerPostRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const beerPost = await getBeerPostById(id); + + if (!beerPost) { + throw new ServerError('Beer post not found', 404); + } + + const pageNum = parseInt(req.query.page_num as string, 10); + const pageSize = parseInt(req.query.page_size as string, 10); + + const { count, beerRecommendations } = await getBeerRecommendations({ + beerPost, + pageNum, + pageSize, + }); + + res.setHeader('X-Total-Count', count); + res.status(200).json({ + success: true, + message: 'Recommendations fetched successfully', + statusCode: 200, + payload: beerRecommendations, + }); +}; + +router.get( + validateRequest({ + querySchema: z.object({ + id: z.string().uuid(), + page_num: z.string().regex(/^[0-9]+$/), + page_size: z.string().regex(/^[0-9]+$/), + }), + }), + getBeerRecommendationsRequest, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/breweries/map/index.ts b/src/pages/api/breweries/map/index.ts index 5a75e0e..7d9fdd8 100644 --- a/src/pages/api/breweries/map/index.ts +++ b/src/pages/api/breweries/map/index.ts @@ -28,7 +28,12 @@ const getBreweryPosts = async ( await DBClient.instance.breweryPost.findMany({ select: { location: { - select: { coordinates: true, city: true, country: true, stateOrProvince: true }, + select: { + coordinates: true, + city: true, + country: true, + stateOrProvince: true, + }, }, id: true, name: true, diff --git a/src/pages/api/users/[id]/edit.ts b/src/pages/api/users/[id]/edit.ts new file mode 100644 index 0000000..4ef5b14 --- /dev/null +++ b/src/pages/api/users/[id]/edit.ts @@ -0,0 +1,108 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import findUserByEmail from '@/services/User/findUserByEmail'; +import findUserById from '@/services/User/findUserById'; +import findUserByUsername from '@/services/User/findUserByUsername'; +import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +const EditUserSchema = BaseCreateUserSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, +}); + +interface EditUserRequest extends UserExtendedNextApiRequest { + body: z.infer; + query: { + id: string; + }; +} + +const checkIfUserCanEditUser = async ( + req: EditUserRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const authenticatedUser = req.user!; + + const userToUpdate = await findUserById(req.query.id); + if (!userToUpdate) { + throw new ServerError('User not found', 404); + } + + if (authenticatedUser.id !== userToUpdate.id) { + throw new ServerError('You are not permitted to edit this user', 403); + } + + await next(); +}; + +const editUser = async ( + req: EditUserRequest, + res: NextApiResponse>, +) => { + const { email, firstName, lastName, username } = req.body; + + const [usernameIsTaken, emailIsTaken] = await Promise.all([ + findUserByUsername(username), + findUserByEmail(email), + ]); + + const emailChanged = req.user!.email !== email; + const usernameChanged = req.user!.username !== username; + + if (emailIsTaken && emailChanged) { + throw new ServerError('Email is already taken', 400); + } + + if (usernameIsTaken && usernameChanged) { + throw new ServerError('Username is already taken', 400); + } + + const updatedUser = await DBClient.instance.user.update({ + where: { id: req.user!.id }, + data: { + email, + firstName, + lastName, + username, + accountIsVerified: emailChanged ? false : undefined, + }, + }); + + res.json({ + message: 'User edited successfully', + payload: updatedUser, + success: true, + statusCode: 200, + }); +}; + +const router = createRouter< + EditUserRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + validateRequest({ + bodySchema: EditUserSchema, + querySchema: z.object({ id: z.string().uuid() }), + }), + checkIfUserCanEditUser, + editUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/users/check-email.ts b/src/pages/api/users/check-email.ts index d122d1e..4d03c37 100644 --- a/src/pages/api/users/check-email.ts +++ b/src/pages/api/users/check-email.ts @@ -29,7 +29,7 @@ const checkEmail = async (req: NextApiRequest, res: NextApiResponse) => { success: true, payload: { emailIsTaken: !!email }, statusCode: 200, - message: 'Getting username availability.', + message: 'Getting email availability.', }); }; diff --git a/src/pages/api/users/register.ts b/src/pages/api/users/register.ts index 0cd4a77..51b93a6 100644 --- a/src/pages/api/users/register.ts +++ b/src/pages/api/users/register.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; import { createRouter } from 'next-connect'; import createNewUser from '@/services/User/createNewUser'; -import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import findUserByUsername from '@/services/User/findUserByUsername'; import findUserByEmail from '@/services/User/findUserByEmail'; diff --git a/src/pages/beers/[id]/index.tsx b/src/pages/beers/[id]/index.tsx index dcf4e02..4138520 100644 --- a/src/pages/beers/[id]/index.tsx +++ b/src/pages/beers/[id]/index.tsx @@ -2,15 +2,9 @@ import { NextPage, GetServerSideProps } from 'next'; import Head from 'next/head'; import Image from 'next/image'; -import BeerInfoHeader from '@/components/BeerById/BeerInfoHeader'; -import BeerPostCommentsSection from '@/components/BeerById/BeerPostCommentsSection'; -import BeerRecommendations from '@/components/BeerById/BeerRecommendations'; - import getBeerPostById from '@/services/BeerPost/getBeerPostById'; -import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; -import { BeerPost } from '@prisma/client'; import { z } from 'zod'; @@ -18,16 +12,19 @@ import 'react-responsive-carousel/lib/styles/carousel.min.css'; import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/utilities/useMediaQuery'; import { Tab } from '@headlessui/react'; +import dynamic from 'next/dynamic'; + +const [BeerInfoHeader, BeerPostCommentsSection, BeerRecommendations] = [ + dynamic(() => import('@/components/BeerById/BeerInfoHeader')), + dynamic(() => import('@/components/BeerById/BeerPostCommentsSection')), + dynamic(() => import('@/components/BeerById/BeerRecommendations')), +]; interface BeerPageProps { beerPost: z.infer; - beerRecommendations: (BeerPost & { - brewery: { id: string; name: string }; - beerImages: { id: string; alt: string; url: string }[]; - })[]; } -const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations }) => { +const BeerByIdPage: NextPage = ({ beerPost }) => { const isDesktop = useMediaQuery('(min-width: 1024px)'); return ( @@ -72,7 +69,7 @@ const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations }
- +
) : ( @@ -90,7 +87,7 @@ const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations } - + @@ -109,12 +106,8 @@ export const getServerSideProps: GetServerSideProps = async (cont return { notFound: true }; } - const { type, brewery, id } = beerPost; - const beerRecommendations = await getBeerRecommendations({ type, brewery, id }); - const props = { beerPost: JSON.parse(JSON.stringify(beerPost)), - beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), }; return { props }; diff --git a/src/pages/beers/index.tsx b/src/pages/beers/index.tsx index 453599d..195f4ca 100644 --- a/src/pages/beers/index.tsx +++ b/src/pages/beers/index.tsx @@ -9,7 +9,7 @@ import { FaArrowUp } from 'react-icons/fa'; import LoadingCard from '@/components/ui/LoadingCard'; const BeerPage: NextPage = () => { - const PAGE_SIZE = 6; + const PAGE_SIZE = 20; const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBeerPosts({ pageSize: PAGE_SIZE, diff --git a/src/pages/breweries/[id]/index.tsx b/src/pages/breweries/[id]/index.tsx index 291c612..30b8c2e 100644 --- a/src/pages/breweries/[id]/index.tsx +++ b/src/pages/breweries/[id]/index.tsx @@ -9,10 +9,15 @@ import 'react-responsive-carousel/lib/styles/carousel.min.css'; // requires a lo import { Carousel } from 'react-responsive-carousel'; import useMediaQuery from '@/hooks/utilities/useMediaQuery'; import { Tab } from '@headlessui/react'; -import BreweryInfoHeader from '@/components/BreweryById/BreweryInfoHeader'; -import BreweryPostMap from '@/components/BreweryById/BreweryPostMap'; -import BreweryBeersSection from '@/components/BreweryById/BreweryBeerSection'; -import BreweryCommentsSection from '@/components/BreweryById/BreweryCommentsSection'; + +import dynamic from 'next/dynamic'; + +const [BreweryInfoHeader, BreweryBeersSection, BreweryCommentsSection, BreweryPostMap] = [ + dynamic(() => import('@/components/BreweryById/BreweryInfoHeader')), + dynamic(() => import('@/components/BreweryById/BreweryBeerSection')), + dynamic(() => import('@/components/BreweryById/BreweryCommentsSection')), + dynamic(() => import('@/components/BreweryById/BreweryPostMap')), +]; interface BreweryPageProps { breweryPost: z.infer; diff --git a/src/pages/breweries/index.tsx b/src/pages/breweries/index.tsx index 96e9add..275e318 100644 --- a/src/pages/breweries/index.tsx +++ b/src/pages/breweries/index.tsx @@ -1,7 +1,7 @@ import BreweryCard from '@/components/BreweryIndex/BreweryCard'; import LoadingCard from '@/components/ui/LoadingCard'; import Spinner from '@/components/ui/Spinner'; -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import useBreweryPosts from '@/hooks/data-fetching/brewery-posts/useBreweryPosts'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import { NextPage } from 'next'; @@ -17,7 +17,7 @@ interface BreweryPageProps { } const BreweryPage: NextPage = () => { - const PAGE_SIZE = 6; + const PAGE_SIZE = 20; const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } = useBreweryPosts({ diff --git a/src/pages/user/current.tsx b/src/pages/user/current.tsx index c5d739f..0d0f5c4 100644 --- a/src/pages/user/current.tsx +++ b/src/pages/user/current.tsx @@ -1,6 +1,6 @@ import Spinner from '@/components/ui/Spinner'; import withPageAuthRequired from '@/util/withPageAuthRequired'; -import UserContext from '@/contexts/userContext'; +import UserContext from '@/contexts/UserContext'; import { GetServerSideProps, NextPage } from 'next'; import { useContext } from 'react'; diff --git a/src/requests/sendRegisterUserRequest.ts b/src/requests/sendRegisterUserRequest.ts index 7e106b4..b29b5e5 100644 --- a/src/requests/sendRegisterUserRequest.ts +++ b/src/requests/sendRegisterUserRequest.ts @@ -1,4 +1,4 @@ -import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { z } from 'zod'; @@ -6,9 +6,7 @@ import { z } from 'zod'; async function sendRegisterUserRequest(data: z.infer) { const response = await fetch('/api/users/register', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); diff --git a/src/requests/valdiateEmail.ts b/src/requests/valdiateEmail.ts index b10f0a7..dbd8fc0 100644 --- a/src/requests/valdiateEmail.ts +++ b/src/requests/valdiateEmail.ts @@ -12,14 +12,14 @@ const validateEmail = async (email: string) => { } const parsedPayload = z - .object({ usernameIsTaken: z.boolean() }) + .object({ emailIsTaken: z.boolean() }) .safeParse(parsed.data.payload); if (!parsedPayload.success) { return false; } - return !parsedPayload.data.usernameIsTaken; + return !parsedPayload.data.emailIsTaken; }; export default validateEmail; diff --git a/src/services/BeerComment/editBeerCommentById.ts b/src/services/BeerComment/editBeerCommentById.ts new file mode 100644 index 0000000..61e4570 --- /dev/null +++ b/src/services/BeerComment/editBeerCommentById.ts @@ -0,0 +1,22 @@ +import DBClient from '@/prisma/DBClient'; + +interface EditBeerCommentByIdArgs { + id: string; + content: string; + rating: number; +} + +const editBeerCommentById = async ({ id, content, rating }: EditBeerCommentByIdArgs) => { + const updated = await DBClient.instance.beerComment.update({ + where: { id }, + data: { + content, + rating, + updatedAt: new Date(), + }, + }); + + return updated; +}; + +export default editBeerCommentById; diff --git a/src/services/BeerComment/findBeerCommentById.ts b/src/services/BeerComment/findBeerCommentById.ts new file mode 100644 index 0000000..1cc1f0a --- /dev/null +++ b/src/services/BeerComment/findBeerCommentById.ts @@ -0,0 +1,11 @@ +import DBClient from '@/prisma/DBClient'; + +const findBeerCommentById = async (id: string) => { + const comment = await DBClient.instance.beerComment.findUnique({ + where: { id }, + }); + + return comment; +}; + +export default findBeerCommentById; diff --git a/src/services/BeerImage/processImageDataIntoDB.ts b/src/services/BeerImage/processImageDataIntoDB.ts new file mode 100644 index 0000000..a59cf2c --- /dev/null +++ b/src/services/BeerImage/processImageDataIntoDB.ts @@ -0,0 +1,39 @@ +import DBClient from '@/prisma/DBClient'; +import { BeerImage } from '@prisma/client'; +import { z } from 'zod'; +import ImageMetadataValidationSchema from '../types/ImageSchema/ImageMetadataValidationSchema'; + +interface ProcessImageDataArgs { + files: Express.Multer.File[]; + alt: z.infer['alt']; + caption: z.infer['caption']; + beerPostId: string; + userId: string; +} + +const processImageDataIntoDB = ({ + alt, + caption, + files, + beerPostId, + userId, +}: ProcessImageDataArgs) => { + const beerImagePromises: Promise[] = []; + files.forEach((file) => { + beerImagePromises.push( + DBClient.instance.beerImage.create({ + data: { + alt, + caption, + postedBy: { connect: { id: userId } }, + beerPost: { connect: { id: beerPostId } }, + path: file.path, + }, + }), + ); + }); + + return Promise.all(beerImagePromises); +}; + +export default processImageDataIntoDB; diff --git a/src/services/BeerPost/getBeerRecommendations.ts b/src/services/BeerPost/getBeerRecommendations.ts index ca900c4..69a44e0 100644 --- a/src/services/BeerPost/getBeerRecommendations.ts +++ b/src/services/BeerPost/getBeerRecommendations.ts @@ -1,22 +1,51 @@ import DBClient from '@/prisma/DBClient'; -import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; +import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; + import { z } from 'zod'; -const getBeerRecommendations = async ( - beerPost: Pick, 'type' | 'brewery' | 'id'>, -) => { - const beerRecommendations = await DBClient.instance.beerPost.findMany({ +interface GetBeerRecommendationsArgs { + beerPost: z.infer; + pageNum: number; + pageSize: number; +} + +const getBeerRecommendations = async ({ + beerPost, + pageNum, + pageSize, +}: GetBeerRecommendationsArgs) => { + const skip = (pageNum - 1) * pageSize; + const take = pageSize; + const beerRecommendations: z.infer[] = + await DBClient.instance.beerPost.findMany({ + where: { + OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }], + NOT: { id: beerPost.id }, + }, + select: { + id: true, + name: true, + ibu: true, + abv: true, + description: true, + createdAt: true, + type: { select: { name: true, id: true } }, + brewery: { select: { name: true, id: true } }, + postedBy: { select: { id: true, username: true } }, + beerImages: { select: { path: true, caption: true, id: true, alt: true } }, + }, + take, + skip, + }); + + const count = await DBClient.instance.beerPost.count({ where: { OR: [{ typeId: beerPost.type.id }, { breweryId: beerPost.brewery.id }], NOT: { id: beerPost.id }, }, - include: { - beerImages: { select: { id: true, path: true, caption: true, alt: true } }, - brewery: { select: { id: true, name: true } }, - }, }); - return beerRecommendations; + return { beerRecommendations, count }; }; export default getBeerRecommendations; diff --git a/src/services/BeerPostLike/findBeerPostLikeById.ts b/src/services/BeerPostLike/findBeerPostLikeById.ts index ad5dac9..11e2789 100644 --- a/src/services/BeerPostLike/findBeerPostLikeById.ts +++ b/src/services/BeerPostLike/findBeerPostLikeById.ts @@ -1,6 +1,14 @@ import DBClient from '@/prisma/DBClient'; -const findBeerPostLikeById = async (beerPostId: string, likedById: string) => +interface FindBeerPostLikeByIdArgs { + beerPostId: string; + likedById: string; +} + +const findBeerPostLikeById = async ({ + beerPostId, + likedById, +}: FindBeerPostLikeByIdArgs) => DBClient.instance.beerPostLike.findFirst({ where: { beerPostId, likedById } }); export default findBeerPostLikeById; diff --git a/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts b/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts index e0c9cf5..0ef26fe 100644 --- a/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts +++ b/src/services/BreweryPost/types/BreweryPostMapQueryResult.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; const BreweryPostMapQueryResult = z.object({ location: z.object({ diff --git a/src/services/User/createNewUser.ts b/src/services/User/createNewUser.ts index c77c82f..62e18e5 100644 --- a/src/services/User/createNewUser.ts +++ b/src/services/User/createNewUser.ts @@ -1,7 +1,7 @@ import { hashPassword } from '@/config/auth/passwordFns'; import DBClient from '@/prisma/DBClient'; import { z } from 'zod'; -import CreateUserValidationSchema from './schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from './schema/CreateUserValidationSchemas'; import GetUserSchema from './schema/GetUserSchema'; const createNewUser = async ({ diff --git a/src/services/User/schema/CreateUserValidationSchema.ts b/src/services/User/schema/CreateUserValidationSchemas.ts similarity index 77% rename from src/services/User/schema/CreateUserValidationSchema.ts rename to src/services/User/schema/CreateUserValidationSchemas.ts index feffe4a..1488673 100644 --- a/src/services/User/schema/CreateUserValidationSchema.ts +++ b/src/services/User/schema/CreateUserValidationSchemas.ts @@ -3,9 +3,9 @@ import validateUsername from '@/requests/validateUsername'; import sub from 'date-fns/sub'; import { z } from 'zod'; -const minimumDateOfBirth = sub(new Date(), { years: 19 }); -const CreateUserValidationSchema = z.object({ - // use special characters, numbers, and uppercase letters +const MINIMUM_DATE_OF_BIRTH = sub(new Date(), { years: 19 }); + +export const BaseCreateUserSchema = z.object({ password: z .string() .min(8, { message: 'Password must be at least 8 characters.' }) @@ -33,29 +33,25 @@ const CreateUserValidationSchema = z.object({ .refine((lastName) => /^[a-zA-Z]+$/.test(lastName), { message: 'Last name must only contain letters.', }), - dateOfBirth: z.string().refine( - (dateOfBirth) => { - const parsedDateOfBirth = new Date(dateOfBirth); - return parsedDateOfBirth <= minimumDateOfBirth; - }, - { message: 'You must be at least 19 years old to register.' }, - ), -}); - -export default CreateUserValidationSchema.extend({ + dateOfBirth: z + .string() + .refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, { + message: 'You must be at least 19 years old to register.', + }), username: z .string() .min(1, { message: 'Username must not be empty.' }) .max(20, { message: 'Username must be less than 20 characters.' }), - email: z.string().email({ message: 'Email must be a valid email address.' }), -}).refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match.', - path: ['confirmPassword'], }); +export const CreateUserValidationSchema = BaseCreateUserSchema.refine( + (data) => data.password === data.confirmPassword, + { message: 'Passwords do not match.', path: ['confirmPassword'] }, +); + export const CreateUserValidationSchemaWithUsernameAndEmailCheck = - CreateUserValidationSchema.extend({ + BaseCreateUserSchema.extend({ email: z .string() .email({ message: 'Email must be a valid email address.' }) diff --git a/src/services/types/ImageSchema/ImageMetadataValidationSchema.ts b/src/services/types/ImageSchema/ImageMetadataValidationSchema.ts new file mode 100644 index 0000000..4ae1614 --- /dev/null +++ b/src/services/types/ImageSchema/ImageMetadataValidationSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +const ImageMetadataValidationSchema = z.object({ + caption: z.string().min(1, { message: 'Caption is required.' }), + alt: z.string().min(1, { message: 'Alt text is required.' }), +}); + +export default ImageMetadataValidationSchema;