More work on beer image upload

patFix schema so beer image and brewery image have createdBy column. Rename 'url' to 'path' in schema, add 'caption' column.
This commit is contained in:
Aaron William Po
2023-02-11 21:42:22 -05:00
parent 45cc10a009
commit 912008e68d
17 changed files with 193 additions and 58 deletions

View File

@@ -8,7 +8,12 @@ const BeerCard: FC<{ post: BeerPostQueryResult }> = ({ post }) => {
<div className="card bg-base-300" key={post.id}>
<figure className="card-image h-96">
{post.beerImages.length > 0 && (
<Image src={post.beerImages[0].url} alt={post.name} width="1029" height="110" />
<Image
src={post.beerImages[0].path}
alt={post.name}
width="1029"
height="110"
/>
)}
</figure>

View File

@@ -1,15 +1,32 @@
import { NextApiRequest, NextApiResponse } from 'next';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { RequestHandler } from 'next-connect/dist/types/node';
import type { HandlerOptions } from 'next-connect/dist/types/types';
import { z } from 'zod';
import logger from '../pino/logger';
import ServerError from '../util/ServerError';
const NextConnectOptions = {
onNoMatch(req: NextApiRequest, res: NextApiResponse) {
type NextConnectOptionsT = HandlerOptions<
RequestHandler<
NextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>
>;
const NextConnectOptions: NextConnectOptionsT = {
onNoMatch(req, res) {
res.status(405).json({
message: 'Method not allowed.',
statusCode: 405,
success: false,
});
},
onError(error: unknown, req: NextApiRequest, res: NextApiResponse) {
onError(error, req, res) {
if (process.env.NODE_ENV !== 'production') {
logger.error(error);
}
const message = error instanceof Error ? error.message : 'Internal server error.';
const statusCode = error instanceof ServerError ? error.statusCode : 500;
res.status(statusCode).json({

View File

@@ -0,0 +1,31 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import ServerError from '@/config/util/ServerError';
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
interface CheckIfBeerPostOwnerRequest extends UserExtendedNextApiRequest {
query: { id: string };
}
const checkIfBeerPostOwner = async <RequestType extends CheckIfBeerPostOwnerRequest>(
req: RequestType,
res: NextApiResponse,
next: NextHandler,
) => {
const { id } = req.query;
const user = req.user!;
const beerPost = await getBeerPostById(id);
if (!beerPost) {
throw new ServerError('Beer post not found', 404);
}
if (beerPost.postedBy.id !== user.id) {
throw new ServerError('You are not authorized to edit this beer post', 403);
}
return next();
};
export default checkIfBeerPostOwner;

View File

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

View File

@@ -1,10 +1,11 @@
import DBClient from '@/prisma/DBClient';
import { BeerImage } from '@prisma/client';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import { NextHandler, createRouter, expressWrapper } from 'next-connect';
import { createRouter, expressWrapper } from 'next-connect';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import multer from 'multer';
@@ -12,55 +13,70 @@ import cloudinaryConfig from '@/config/cloudinary';
import { NextApiResponse } from 'next';
import { z } from 'zod';
import ServerError from '@/config/util/ServerError';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
const { storage } = cloudinaryConfig;
const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => {
if (
file.mimetype === 'image/png' ||
file.mimetype === 'image/jpg' ||
file.mimetype === 'image/jpeg'
) {
cb(null, true);
} else {
const { mimetype } = file;
const isImage = mimetype.startsWith('image/');
if (!isImage) {
cb(null, false);
}
cb(null, true);
};
const uploadMiddleware = multer({ storage, fileFilter }).array('images');
const uploadMiddleware = expressWrapper(
multer({ storage, fileFilter, limits: { files: 3 } }).array('images'),
);
const BeerPostImageValidationSchema = z.object({
caption: z.string(),
alt: z.string(),
});
interface UploadBeerPostImagesRequest extends UserExtendedNextApiRequest {
files?:
| Express.Multer.File[]
| {
[fieldname: string]: Express.Multer.File[];
};
query: {
id: string;
};
// beerPost?: BeerPostQueryResult;
files?: Express.Multer.File[];
query: { id: string };
body: z.infer<typeof BeerPostImageValidationSchema>;
}
const checkIfBeerPostOwner = async (
const processImageData = async (
req: UploadBeerPostImagesRequest,
res: NextApiResponse,
next: NextHandler,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const user = req.user!;
const beerPost = await getBeerPostById(id);
const { files, user, body } = req;
if (!beerPost) {
throw new ServerError('Beer post not found', 404);
if (!files || !files.length) {
throw new ServerError('No images uploaded', 400);
}
const beerImagePromises: Promise<BeerImage>[] = [];
if (beerPost.postedBy.id !== user.id) {
throw new ServerError('You are not authorized to edit this beer post', 403);
}
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,
},
}),
);
});
return next();
const beerImages = await Promise.all(beerImagePromises);
res.status(200).json({
success: true,
message: `Successfully uploaded ${beerImages.length} image${
beerImages.length > 1 ? 's' : ''
}`,
statusCode: 200,
});
};
const router = createRouter<
@@ -68,8 +84,13 @@ const router = createRouter<
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser,
// @ts-expect-error
router.post(getCurrentUser, expressWrapper(uploadMiddleware), checkIfBeerPostOwner);
uploadMiddleware,
validateRequest({ bodySchema: BeerPostImageValidationSchema }),
processImageData,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -53,7 +53,7 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
{beerPost.beerImages[0] && (
<Image
alt={beerPost.beerImages[0].alt}
src={beerPost.beerImages[0].url}
src={beerPost.beerImages[0].path}
height={1080}
width={1920}
className="h-[42rem] w-full object-cover"

View File

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

View File

@@ -26,6 +26,8 @@ model User {
beerComments BeerComment[]
breweryComments BreweryComment[]
BeerPostLikes BeerPostLike[]
BeerImage BeerImage[]
BreweryImage BreweryImage[]
}
model BeerPost {
@@ -109,18 +111,24 @@ model BeerImage {
id String @id @default(uuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String
url String
path String
alt String
caption String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String
}
model BreweryImage {
id String @id @default(uuid())
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String
url String
path String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
caption String
alt String
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,8 @@ const getAllBeerPosts = async (pageNum: number, pageSize: number) => {
},
beerImages: {
select: {
url: true,
path: true,
caption: true,
id: true,
alt: true,
},

View File

@@ -39,7 +39,8 @@ const getBeerPostById = async (id: string) => {
beerImages: {
select: {
alt: true,
url: true,
path: true,
caption: true,
id: true,
},
},

View File

@@ -10,7 +10,7 @@ const getBeerRecommendations = async (
NOT: { id: beerPost.id },
},
include: {
beerImages: { select: { id: true, url: true, alt: true } },
beerImages: { select: { id: true, path: true, caption: true, alt: true } },
brewery: { select: { id: true, name: true } },
},
});

View File

@@ -7,7 +7,8 @@ export default interface BeerPostQueryResult {
};
description: string;
beerImages: {
url: string;
path: string;
caption: string;
id: string;
alt: string;
}[];