From 6b8682e68674812cf611d07a6fd59a94db26c9b1 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 17 Sep 2023 20:27:33 -0400 Subject: [PATCH] feat: begin work on beer type page and associated api routes --- package-lock.json | 34 +++--- package.json | 2 +- src/config/util/ServerError.ts | 5 +- .../data-fetching/beer-types/useBeerTypes.ts | 74 +++++++++++++ src/pages/api/beer-comments/[id].ts | 2 +- src/pages/api/beers/types/create.ts | 82 ++++++++++++++ src/pages/api/beers/types/index.ts | 51 +++++++++ src/pages/beers/types/index.tsx | 104 ++++++++++++++++++ src/services/BeerTypes/getAllBeerTypes.ts | 24 ++++ .../BeerTypes/schema/BeerTypeQueryResult.ts | 14 +++ src/util/createErrorToast.ts | 6 +- src/util/withPageAuthRequired.ts | 7 +- 12 files changed, 377 insertions(+), 28 deletions(-) create mode 100644 src/hooks/data-fetching/beer-types/useBeerTypes.ts create mode 100644 src/pages/api/beers/types/create.ts create mode 100644 src/pages/api/beers/types/index.ts create mode 100644 src/pages/beers/types/index.tsx create mode 100644 src/services/BeerTypes/getAllBeerTypes.ts create mode 100644 src/services/BeerTypes/schema/BeerTypeQueryResult.ts diff --git a/package-lock.json b/package-lock.json index e1278da..d6b52b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,7 @@ "onchange": "^7.1.0", "postcss": "^8.4.26", "prettier": "^3.0.0", - "prettier-plugin-jsdoc": "^0.4.2", + "prettier-plugin-jsdoc": "^1.0.2", "prettier-plugin-tailwindcss": "^0.4.1", "prisma": "^5.0.0", "tailwindcss": "^3.3.3", @@ -3485,9 +3485,9 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/comment-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", - "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.0.tgz", + "integrity": "sha512-QLyTNiZ2KDOibvFPlZ6ZngVsZ/0gYnE6uTXi5aoDg8ed3AkJAz4sEje3Y8a29hQ1s6A99MZXe47fLAXQ1rTqaw==", "dev": true, "engines": { "node": ">= 12.0.0" @@ -8839,20 +8839,20 @@ } }, "node_modules/prettier-plugin-jsdoc": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-jsdoc/-/prettier-plugin-jsdoc-0.4.2.tgz", - "integrity": "sha512-w2jnAQm3z0GAG0bhzVJeehzDtrhGMSxJjit5ApCc2oxWfc7+jmLAkbtdOXaSpfwZz3IWkk+PiQPeRrLNpbM+Mw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-jsdoc/-/prettier-plugin-jsdoc-1.0.2.tgz", + "integrity": "sha512-mhLT3qiSmfzjOEDvgLntX3XmSJaiDrgoN7WmOp4IH2mZ6LhbvZAnPDJH3Rs0k1O6WR7HcmM92fU1ArB0ALLG+A==", "dev": true, "dependencies": { "binary-searching": "^2.0.5", - "comment-parser": "^1.3.1", + "comment-parser": "^1.4.0", "mdast-util-from-markdown": "^1.2.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.13.1 || >=16.0.0" }, "peerDependencies": { - "prettier": ">=2.1.2" + "prettier": "^3.0.0" } }, "node_modules/prettier-plugin-tailwindcss": { @@ -13925,9 +13925,9 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "comment-parser": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", - "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.0.tgz", + "integrity": "sha512-QLyTNiZ2KDOibvFPlZ6ZngVsZ/0gYnE6uTXi5aoDg8ed3AkJAz4sEje3Y8a29hQ1s6A99MZXe47fLAXQ1rTqaw==", "dev": true }, "concat-map": { @@ -17721,13 +17721,13 @@ "dev": true }, "prettier-plugin-jsdoc": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-jsdoc/-/prettier-plugin-jsdoc-0.4.2.tgz", - "integrity": "sha512-w2jnAQm3z0GAG0bhzVJeehzDtrhGMSxJjit5ApCc2oxWfc7+jmLAkbtdOXaSpfwZz3IWkk+PiQPeRrLNpbM+Mw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-jsdoc/-/prettier-plugin-jsdoc-1.0.2.tgz", + "integrity": "sha512-mhLT3qiSmfzjOEDvgLntX3XmSJaiDrgoN7WmOp4IH2mZ6LhbvZAnPDJH3Rs0k1O6WR7HcmM92fU1ArB0ALLG+A==", "dev": true, "requires": { "binary-searching": "^2.0.5", - "comment-parser": "^1.3.1", + "comment-parser": "^1.4.0", "mdast-util-from-markdown": "^1.2.0" } }, diff --git a/package.json b/package.json index fbbc5d4..e321f75 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "onchange": "^7.1.0", "postcss": "^8.4.26", "prettier": "^3.0.0", - "prettier-plugin-jsdoc": "^0.4.2", + "prettier-plugin-jsdoc": "^1.0.2", "prettier-plugin-tailwindcss": "^0.4.1", "prisma": "^5.0.0", "tailwindcss": "^3.3.3", diff --git a/src/config/util/ServerError.ts b/src/config/util/ServerError.ts index 40d3e30..7f11d58 100644 --- a/src/config/util/ServerError.ts +++ b/src/config/util/ServerError.ts @@ -1,5 +1,8 @@ class ServerError extends Error { - constructor(message: string, public statusCode: number) { + constructor( + message: string, + public statusCode: number, + ) { super(message); this.name = 'ServerError'; } diff --git a/src/hooks/data-fetching/beer-types/useBeerTypes.ts b/src/hooks/data-fetching/beer-types/useBeerTypes.ts new file mode 100644 index 0000000..f91ac8a --- /dev/null +++ b/src/hooks/data-fetching/beer-types/useBeerTypes.ts @@ -0,0 +1,74 @@ +import BeerTypeQueryResult from '@/services/BeerTypes/schema/BeerTypeQueryResult'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +/** + * A custom hook using SWR to fetch beer types from the API. + * + * @param options The options to use when fetching beer types. + * @param options.pageSize The number of beer types to fetch per page. + * @returns An object with the following properties: + * + * - `beerTypes`: The beer types 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 useBeerTypes = ({ pageSize }: { pageSize: number }) => { + 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(BeerTypeQueryResult).safeParse(parsed.data.payload); + if (!parsedPayload.success) { + console.log(parsedPayload.error); + throw new Error('API response validation failed'); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + return { + beerTypes: parsedPayload.data, + pageCount, + }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => `/api/beers/types?page_num=${index + 1}&page_size=${pageSize}`, + fetcher, + { parallel: true }, + ); + + const beerTypes = data?.flatMap((d) => d.beerTypes) ?? []; + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + beerTypes, + error: error as unknown, + isAtEnd, + isLoading, + isLoadingMore, + pageCount, + setSize, + size, + }; +}; + +export default useBeerTypes; diff --git a/src/pages/api/beer-comments/[id].ts b/src/pages/api/beer-comments/[id].ts index f333689..30ece2b 100644 --- a/src/pages/api/beer-comments/[id].ts +++ b/src/pages/api/beer-comments/[id].ts @@ -53,7 +53,7 @@ const editComment = async ( id, }); - res.status(200).json({ + res.status(200).json({ success: true, message: 'Comment updated successfully', statusCode: 200, diff --git a/src/pages/api/beers/types/create.ts b/src/pages/api/beers/types/create.ts new file mode 100644 index 0000000..9065c68 --- /dev/null +++ b/src/pages/api/beers/types/create.ts @@ -0,0 +1,82 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +const BeerTypeValidationSchema = z.object({ + id: z.string().cuid(), + name: z.string(), + postedBy: z.object({ + id: z.string().cuid(), + username: z.string(), + }), + createdAt: z.date(), + updatedAt: z.date().nullable(), +}); + +const CreateBeerTypeValidationSchema = BeerTypeValidationSchema.omit({ + id: true, + postedBy: true, + createdAt: true, + updatedAt: true, +}); + +interface CreateBeerTypeRequest extends UserExtendedNextApiRequest { + body: z.infer; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface GetBeerTypeRequest extends NextApiRequest { + query: { + id: string; + }; +} + +const createBeerType = async ( + req: CreateBeerTypeRequest, + res: NextApiResponse>, +) => { + const user = req.user!; + const { name } = req.body; + + const newBeerType = await DBClient.instance.beerType.create({ + data: { + name, + postedBy: { connect: { id: user.id } }, + }, + select: { + id: true, + name: true, + postedBy: { select: { id: true, username: true } }, + createdAt: true, + updatedAt: true, + }, + }); + + res.status(200).json({ + message: 'Beer posts retrieved successfully', + statusCode: 200, + payload: newBeerType, + success: true, + }); +}; + +const router = createRouter< + CreateBeerTypeRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ bodySchema: CreateBeerTypeValidationSchema }), + getCurrentUser, + createBeerType, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/api/beers/types/index.ts b/src/pages/api/beers/types/index.ts new file mode 100644 index 0000000..ceda807 --- /dev/null +++ b/src/pages/api/beers/types/index.ts @@ -0,0 +1,51 @@ +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import DBClient from '@/prisma/DBClient'; +import getAllBeerTypes from '@/services/BeerTypes/getAllBeerTypes'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetBeerTypesRequest extends NextApiRequest { + query: { page_num: string; page_size: string }; +} + +const getBeerTypes = async ( + req: GetBeerTypesRequest, + res: NextApiResponse>, +) => { + const pageNum = parseInt(req.query.page_num, 10); + const pageSize = parseInt(req.query.page_size, 10); + + const beerTypes = await getAllBeerTypes(pageNum, pageSize); + const beerTypeCount = await DBClient.instance.beerType.count(); + + res.setHeader('X-Total-Count', beerTypeCount); + + res.status(200).json({ + message: 'Beer types retrieved successfully', + statusCode: 200, + payload: beerTypes, + success: true, + }); +}; + +const router = createRouter< + GetBeerTypesRequest, + NextApiResponse> +>(); + +router.get( + validateRequest({ + querySchema: z.object({ + page_num: z.string().regex(/^\d+$/), + page_size: z.string().regex(/^\d+$/), + }), + }), + getBeerTypes, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/beers/types/index.tsx b/src/pages/beers/types/index.tsx new file mode 100644 index 0000000..0f07fa2 --- /dev/null +++ b/src/pages/beers/types/index.tsx @@ -0,0 +1,104 @@ +import LoadingCard from '@/components/ui/LoadingCard'; +import Spinner from '@/components/ui/Spinner'; +import useBeerTypes from '@/hooks/data-fetching/beer-types/useBeerTypes'; + +import { NextPage } from 'next'; +import Head from 'next/head'; +import { MutableRefObject, useRef } from 'react'; +import { FaArrowUp } from 'react-icons/fa'; +import { useInView } from 'react-intersection-observer'; + +const BeerTypePage: NextPage = () => { + const PAGE_SIZE = 20; + const pageRef: MutableRefObject = useRef(null); + + const { beerTypes, isLoading, isLoadingMore, isAtEnd, size, setSize, error } = + useBeerTypes({ + pageSize: PAGE_SIZE, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ref: lastBeerTypeRef } = useInView({ + onChange: (visible) => { + if (!visible || isAtEnd) return; + setSize(size + 1); + }, + }); + + console.log(error); + console.log(beerTypes); + return ( + <> + + Beer Types | The Biergarten App + + +
+
+
+
+

The Biergarten App

+

Beers

+
+
+
+ {!!beerTypes.length && !isLoading && ( + <> + {beerTypes.map((beerType, i) => { + return ( +
+ {beerType.name} +
+ ); + })} + + )} + {(isLoading || isLoadingMore) && ( + <> + {Array.from({ length: PAGE_SIZE }, (_, i) => ( + + ))} + + )} +
+ + {(isLoading || isLoadingMore) && ( +
+ +
+ )} + + {isAtEnd && !isLoading && ( +
+
+ +
+
+ )} +
+
+ + ); +}; + +export default BeerTypePage; diff --git a/src/services/BeerTypes/getAllBeerTypes.ts b/src/services/BeerTypes/getAllBeerTypes.ts new file mode 100644 index 0000000..a1c0a3f --- /dev/null +++ b/src/services/BeerTypes/getAllBeerTypes.ts @@ -0,0 +1,24 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import BeerTypeQueryResult from './schema/BeerTypeQueryResult'; + +const getAllBeerTypes = async ( + pageNum: number, + pageSize: number, +): Promise[]> => { + const types = await DBClient.instance.beerType.findMany({ + take: pageSize, + skip: (pageNum - 1) * pageSize, + select: { + id: true, + name: true, + postedBy: { select: { id: true, username: true } }, + createdAt: true, + updatedAt: true, + }, + }); + + return types; +}; + +export default getAllBeerTypes; diff --git a/src/services/BeerTypes/schema/BeerTypeQueryResult.ts b/src/services/BeerTypes/schema/BeerTypeQueryResult.ts new file mode 100644 index 0000000..77d5c0a --- /dev/null +++ b/src/services/BeerTypes/schema/BeerTypeQueryResult.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +const BeerTypeQueryResult = z.object({ + id: z.string().cuid(), + name: z.string(), + postedBy: z.object({ + id: z.string().cuid(), + username: z.string(), + }), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date().nullable(), +}); + +export default BeerTypeQueryResult; diff --git a/src/util/createErrorToast.ts b/src/util/createErrorToast.ts index dce6b60..63dbee5 100644 --- a/src/util/createErrorToast.ts +++ b/src/util/createErrorToast.ts @@ -1,10 +1,6 @@ import toast from 'react-hot-toast'; -/** - * @param error - The error to display. - * - * Creates a toast message with the error message. - */ +/** @param error - The error to display. Creates a toast message with the error message. */ const createErrorToast = (error: unknown) => { const errorMessage = error instanceof Error ? error.message : 'Something went wrong.'; toast.error(errorMessage); diff --git a/src/util/withPageAuthRequired.ts b/src/util/withPageAuthRequired.ts index e9171ea..bf43ad2 100644 --- a/src/util/withPageAuthRequired.ts +++ b/src/util/withPageAuthRequired.ts @@ -36,9 +36,10 @@ export type ExtendedGetServerSideProps< * @returns A promise that resolves to a `GetServerSidePropsResult` object with props for * the wrapped component. * - * If authentication is successful, the `GetServerSidePropsResult` will include props - * generated by the wrapped component's `getServerSideProps` method. If authentication - * fails, the `GetServerSidePropsResult` will include a redirect to the login page. + * - If authentication is successful, the `GetServerSidePropsResult` will include props + * generated by the wrapped component's `getServerSideProps` method. + * - If authentication fails, the `GetServerSidePropsResult` will include a redirect to the + * login page. */ const withPageAuthRequired =