Feat: add create brewery post, brewery image upload

Add address autocomplete, using MapBox
This commit is contained in:
Aaron William Po
2023-06-10 22:09:51 -04:00
parent 140abaa5a1
commit aa994f0067
19 changed files with 855 additions and 39 deletions

View File

@@ -12,7 +12,7 @@ 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 addBeerImageToDB from '@/services/BeerImage/addBeerImageToDB';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
const { storage } = cloudinaryConfig;
@@ -50,7 +50,7 @@ const processImageData = async (
throw new ServerError('No images uploaded', 400);
}
const beerImages = await processImageDataIntoDB({
const beerImages = await addBeerImageToDB({
alt: body.alt,
caption: body.caption,
beerPostId: req.query.id,

View File

@@ -0,0 +1,86 @@
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';
import ImageMetadataValidationSchema from '@/services/types/ImageSchema/ImageMetadataValidationSchema';
import addBreweryImageToDB from '@/services/BreweryImage/addBreweryImageToDB';
const { storage } = cloudinaryConfig;
const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => {
const { mimetype } = file;
const isImage = mimetype.startsWith('image/');
if (!isImage) {
cb(null, false);
}
cb(null, true);
};
const uploadMiddleware = expressWrapper(
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
'images',
),
);
interface UploadBreweryPostImagesRequest extends UserExtendedNextApiRequest {
files?: Express.Multer.File[];
query: { id: string };
body: z.infer<typeof ImageMetadataValidationSchema>;
}
const processImageData = async (
req: UploadBreweryPostImagesRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { files, user, body } = req;
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const breweryImages = await addBreweryImageToDB({
alt: body.alt,
caption: body.caption,
breweryPostId: req.query.id,
userId: user!.id,
files,
});
res.status(200).json({
success: true,
message: `Successfully uploaded ${breweryImages.length} image${
breweryImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};
const router = createRouter<
UploadBreweryPostImagesRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser,
// @ts-expect-error
uploadMiddleware,
validateRequest({ bodySchema: ImageMetadataValidationSchema }),
processImageData,
);
const handler = router.handler(NextConnectOptions);
export default handler;
export const config = { api: { bodyParser: false } };

View File

@@ -0,0 +1,76 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import { createRouter } from 'next-connect';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import CreateBreweryPostSchema from '@/services/BreweryPost/types/CreateBreweryPostSchema';
import createNewBreweryPost from '@/services/BreweryPost/createNewBreweryPost';
import geocode from '@/config/mapbox/geocoder';
import ServerError from '@/config/util/ServerError';
import DBClient from '@/prisma/DBClient';
interface CreateBreweryPostRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof CreateBreweryPostSchema>;
}
const createBreweryPost = async (
req: CreateBreweryPostRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { name, description, dateEstablished, address, city, country, region } = req.body;
const userId = req.user!.id;
const fullAddress = `${address}, ${city}, ${region}, ${country}`;
const geocoded = await geocode(fullAddress);
if (!geocoded) {
throw new ServerError('Address is not valid', 400);
}
const [latitude, longitude] = geocoded.center;
const location = await DBClient.instance.location.create({
data: {
address,
city,
country,
stateOrProvince: region,
coordinates: [latitude, longitude],
postedBy: { connect: { id: userId } },
},
select: { id: true },
});
const newBreweryPost = await createNewBreweryPost({
name,
description,
locationId: location.id,
dateEstablished,
userId,
});
res.status(201).json({
message: 'Brewery post created successfully',
statusCode: 201,
payload: newBreweryPost,
success: true,
});
};
const router = createRouter<
CreateBreweryPostRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
validateRequest({ bodySchema: CreateBreweryPostSchema }),
getCurrentUser,
createBreweryPost,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -11,6 +11,7 @@ import findUserByEmail from '@/services/User/findUserByEmail';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NODE_ENV } from '@/config/env';
import sendConfirmationEmail from '@/services/User/sendConfirmationEmail';
interface RegisterUserRequest extends NextApiRequest {
@@ -44,7 +45,9 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
username: user.username,
});
await sendConfirmationEmail(user);
if (NODE_ENV === 'production') {
await sendConfirmationEmail(user);
}
res.status(201).json({
success: true,

View File

@@ -33,7 +33,6 @@ export const getServerSideProps = withPageAuthRequired<CreateBeerPageProps>(
const id = context.params?.id as string;
const breweryPost = await getBreweryPostById(id);
const beerTypes = await DBClient.instance.beerType.findMany();
return {

View File

@@ -0,0 +1,280 @@
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 FormPageLayout from '@/components/ui/forms/FormPageLayout';
import FormSegment from '@/components/ui/forms/FormSegment';
import FormTextArea from '@/components/ui/forms/FormTextArea';
import FormTextInput from '@/components/ui/forms/FormTextInput';
import createErrorToast from '@/util/createErrorToast';
import withPageAuthRequired from '@/util/withPageAuthRequired';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import type { AddressAutofillRetrieveResponse } from '@mapbox/search-js-core';
import { GetServerSideProps, NextPage } from 'next';
import Head from 'next/head';
import { FieldError, SubmitHandler, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaBeer } from 'react-icons/fa';
import { z } from 'zod';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import CreateBreweryPostSchema from '@/services/BreweryPost/types/CreateBreweryPostSchema';
import UploadImageValidationSchema from '@/services/types/ImageSchema/UploadImageValidationSchema';
import sendUploadBreweryImagesRequest from '@/requests/BreweryImage/sendUploadBreweryImageRequest';
const AddressAutofill = dynamic(
// @ts-expect-error
() => import('@mapbox/search-js-react').then((mod) => mod.AddressAutofill),
{ ssr: false },
);
const CreateBreweryPostWithImagesSchema = CreateBreweryPostSchema.merge(
UploadImageValidationSchema,
);
const sendCreateBreweryPostRequest = async (
data: z.infer<typeof CreateBreweryPostSchema>,
) => {
const response = await fetch('/api/breweries/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response parsing failed');
}
const parsedPayload = BreweryPostQueryResult.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API response payload parsing failed');
}
return parsedPayload.data;
};
const CreateBreweryPage: NextPage = () => {
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
} = useForm<z.infer<typeof CreateBreweryPostWithImagesSchema>>({
resolver: zodResolver(CreateBreweryPostWithImagesSchema),
});
const router = useRouter();
const onSubmit: SubmitHandler<
z.infer<typeof CreateBreweryPostWithImagesSchema>
> = async (data) => {
const loadingToast = toast.loading('Creating brewery...');
try {
if (!(data.images instanceof FileList)) {
return;
}
const breweryPost = await sendCreateBreweryPostRequest(data);
await sendUploadBreweryImagesRequest({ breweryPost, images: data.images });
await router.push(`/breweries/${breweryPost.id}`);
toast.remove(loadingToast);
toast.success('Created brewery.');
} catch (error) {
toast.remove(loadingToast);
reset();
createErrorToast(error);
}
};
const onAutoCompleteChange = (address: string) => {
setValue('address', address);
};
const onAutoCompleteRetrieve = (address: AddressAutofillRetrieveResponse) => {
const { country, region, place } = address.features[0].properties as unknown as {
country?: string;
region?: string;
place?: string;
};
setValue('country', country);
setValue('region', region);
setValue('city', place!);
};
return (
<>
<Head>
<title>Create Brewery</title>
</Head>
<div className="flex w-full flex-col items-center justify-center">
<div className="w-full">
<FormPageLayout
backLink="/breweries"
backLinkText="Back to Breweries"
headingText="Create Brewery"
headingIcon={FaBeer}
>
<form
onSubmit={handleSubmit(onSubmit)}
className="form-control"
autoComplete="off"
>
<div>
<FormInfo>
<FormLabel htmlFor="name">Name</FormLabel>
<FormError>{errors.name?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Lorem Ipsum Brewing Company"
formValidationSchema={register('name')}
error={!!errors.name}
type="text"
id="name"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="description">Description</FormLabel>
<FormError>{errors.description?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
placeholder="We make beer, and we make it good."
formValidationSchema={register('description')}
error={!!errors.description}
rows={4}
id="description"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="dateEstablished">Date Established</FormLabel>
<FormError>{errors.dateEstablished?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="2021-01-01"
formValidationSchema={register('dateEstablished')}
error={!!errors.dateEstablished}
type="date"
id="dateEstablished"
disabled={isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="images">Images</FormLabel>
<FormError>
{(errors.images as FieldError | undefined)?.message}
</FormError>
</FormInfo>
<FormSegment>
<input
type="file"
{...register('images')}
multiple
className="file-input-bordered file-input w-full"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div>
<FormInfo>
<FormLabel htmlFor="address">Address</FormLabel>
<FormError>{errors.address?.message}</FormError>
</FormInfo>
<FormSegment>
<AddressAutofill
accessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN!}
onRetrieve={onAutoCompleteRetrieve}
onChange={onAutoCompleteChange}
>
<input
id="address"
type="text"
placeholder="1234 Main St"
className={`input-bordered input w-full appearance-none rounded-lg transition ease-in-out ${
errors.address?.message ? 'input-error' : ''
}`}
{...register('address')}
disabled={isSubmitting}
/>
</AddressAutofill>
</FormSegment>
<div className="flex space-x-3">
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="city">City</FormLabel>
<FormError>{errors.city?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Toronto"
formValidationSchema={register('city')}
error={!!errors.city}
type="text"
id="city"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div className="w-1/2">
<FormInfo>
<FormLabel htmlFor="region">Region</FormLabel>
<FormError>{errors.region?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Ontario"
formValidationSchema={register('region')}
error={!!errors.region}
type="text"
id="region"
disabled={isSubmitting}
/>
</FormSegment>
</div>
</div>
<FormInfo>
<FormLabel htmlFor="country">Country</FormLabel>
<FormError>{errors.country?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextInput
placeholder="Canada"
formValidationSchema={register('country')}
error={!!errors.country}
type="text"
id="country"
disabled={isSubmitting}
/>
</FormSegment>
</div>
<div className="mt-4">
<Button type="submit" isSubmitting={isSubmitting}>
Create Brewery Post
</Button>
</div>
</form>
</FormPageLayout>
</div>
</div>
</>
);
};
export default CreateBreweryPage;
export const getServerSideProps: GetServerSideProps = withPageAuthRequired();