diff --git a/.env.test b/.env.test index 0a6d007..626beeb 100644 --- a/.env.test +++ b/.env.test @@ -7,8 +7,8 @@ SECRET=secret PORT=8080 ALLOWED_ORIGINS=* MAX_FILE_SIZE_MB=2 +STORAGE_KEY=storage-key STORAGE_ROOT_DIR=public -SUPABASE_BUCKET=bucket-name -SUPABASE_ANON_KEY=public-anon-key -SUPABASE_URL=https://xyzcompany.supabase.co -SUPABASE_BUCKET_URL=https://xyzcompany.supabase.co/storage/v1/object/bucket \ No newline at end of file +STORAGE_BUCKET=bucket-name +STORAGE_URL=https://xyzcompany.supabase.co +STORAGE_BUCKET_URL=https://xyzcompany.supabase.co/storage/v1/object/bucket \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 9847726..8c96cc0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ export default tseslint.config({ rules: { '@stylistic/semi': 'error', '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-confusing-void-expression': 'off', '@typescript-eslint/restrict-template-expressions': 'off', diff --git a/prisma/migrations/20250813094856_add_user_avatar/migration.sql b/prisma/migrations/20250813094856_add_user_avatar/migration.sql new file mode 100644 index 0000000..2e74214 --- /dev/null +++ b/prisma/migrations/20250813094856_add_user_avatar/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the column `author_id` on the `images` table. All the data in the column will be lost. + - A unique constraint covering the columns `[user_id]` on the table `images` will be added. If there are existing duplicate values, this will fail. + - Added the required column `owner_id` to the `images` table without a default value. This is not possible if the table is not empty. + - Made the column `bio` on table `users` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "public"."images" DROP CONSTRAINT "images_author_id_fkey"; + +-- AlterTable +ALTER TABLE "public"."images" DROP COLUMN "author_id", +ADD COLUMN "owner_id" UUID NOT NULL, +ADD COLUMN "user_id" UUID; + +-- AlterTable +ALTER TABLE "public"."users" ALTER COLUMN "bio" SET NOT NULL, +ALTER COLUMN "bio" SET DEFAULT ''; + +-- CreateIndex +CREATE UNIQUE INDEX "images_user_id_key" ON "public"."images"("user_id"); + +-- AddForeignKey +ALTER TABLE "public"."images" ADD CONSTRAINT "images_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."images" ADD CONSTRAINT "images_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250827095023_add_avatar_model/migration.sql b/prisma/migrations/20250827095023_add_avatar_model/migration.sql new file mode 100644 index 0000000..4d3a78b --- /dev/null +++ b/prisma/migrations/20250827095023_add_avatar_model/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the column `user_id` on the `images` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "public"."images" DROP CONSTRAINT "images_user_id_fkey"; + +-- DropIndex +DROP INDEX "public"."images_user_id_key"; + +-- AlterTable +ALTER TABLE "public"."images" DROP COLUMN "user_id"; + +-- CreateTable +CREATE TABLE "public"."Avatar" ( + "user_id" UUID NOT NULL, + "image_id" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Avatar_user_id_key" ON "public"."Avatar"("user_id"); + +-- AddForeignKey +ALTER TABLE "public"."Avatar" ADD CONSTRAINT "Avatar_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Avatar" ADD CONSTRAINT "Avatar_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "public"."images"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b67a4f8..f03af29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,17 +11,18 @@ datasource db { model User { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid order Int @unique @default(autoincrement()) + bio String @default("") password String @db.Char(60) fullname String @db.VarChar(100) username String @unique @db.VarChar(50) isAdmin Boolean @default(false) @map("is_admin") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - votesOnPosts VotesOnPosts[] - comments Comment[] + avatar Avatar? images Image[] posts Post[] - bio String? + comments Comment[] + votesOnPosts VotesOnPosts[] @@map("users") } @@ -96,7 +97,7 @@ model VotesOnPosts { model Image { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid order Int @unique @default(autoincrement()) - ownerId String @map("author_id") @db.Uuid + ownerId String @map("owner_id") @db.Uuid owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -113,6 +114,14 @@ model Image { yPos Int @default(0) @map("y_pos") scale Float @default(1.0) posts Post[] + avatars Avatar[] @@map("images") } + +model Avatar { + userId String @unique @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + imageId String @map("image_id") @db.Uuid + image Image @relation(fields: [imageId], references: [id], onDelete: Cascade) +} diff --git a/prisma/seed/run.ts b/prisma/seed/run.ts index e0c1899..01b8c59 100644 --- a/prisma/seed/run.ts +++ b/prisma/seed/run.ts @@ -26,8 +26,8 @@ const postCount = titles.length; const tags = [ 'open_source', 'full_stack', - 'javaScript', - 'typeScript', + 'javascript', + 'typescript', 'security', 'frontend', 'software', @@ -65,14 +65,14 @@ async function main() { fullname: 'Clark Kent / Kal-El', bio: 'From Krypton with love.', username: 'superman', - isAdmin: false, + isAdmin: true, }, { password: bcrypt.hashSync(...passHashArgs), bio: 'From Gotham with love.', fullname: 'Bruce Wayne', username: 'batman', - isAdmin: false, + isAdmin: true, }, ], }); diff --git a/src/api/v1/auth/auth.router.ts b/src/api/v1/auth/auth.router.ts index cf46fa7..331f8b0 100644 --- a/src/api/v1/auth/auth.router.ts +++ b/src/api/v1/auth/auth.router.ts @@ -1,20 +1,19 @@ -import * as Exp from 'express'; import * as Types from '@/types'; import * as Utils from '@/lib/utils'; import * as AppError from '@/lib/app-error'; import * as Validators from '@/middlewares/validators'; -import { User } from '@/../prisma/client'; +import { Router, RequestHandler } from 'express'; import passport from '@/lib/passport'; import logger from '@/lib/logger'; -export const authRouter = Exp.Router(); +export const authRouter = Router(); authRouter.post('/signin', async (req, res, next) => { await ( passport.authenticate( 'local', { session: false }, - (error: unknown, user: User | false | null | undefined) => { + (error: unknown, user: Types.PublicUser | false | null | undefined) => { if (error || !user) { if (error) logger.error(error); next(new AppError.AppSignInError()); @@ -29,7 +28,7 @@ authRouter.post('/signin', async (req, res, next) => { }); } } - ) as Exp.RequestHandler + ) as RequestHandler )(req, res, next); }); diff --git a/src/api/v1/images/image.schema.ts b/src/api/v1/images/image.schema.ts index 96cdaf0..adf934d 100644 --- a/src/api/v1/images/image.schema.ts +++ b/src/api/v1/images/image.schema.ts @@ -1,20 +1,11 @@ +import * as Image from '@/lib/image'; import { z } from 'zod'; -export const posSchema = z.coerce - .number() - .optional() - .transform((n) => n && Math.trunc(n)); - -export const scaleSchema = z.coerce.number().optional(); - -export const infoSchema = z.string().trim().optional(); - -export const altSchema = z.string().trim().optional(); - -export const imageSchema = z.object({ - scale: scaleSchema, - info: infoSchema, - xPos: posSchema, - yPos: posSchema, - alt: altSchema, -}); +export const imageSchema = Image.imageSchema.merge( + z.object({ + isAvatar: z.preprocess( + (v) => [true, 'true', 'on'].includes(v as string | boolean), + z.boolean().optional().default(false) + ), + }) +); diff --git a/src/api/v1/images/images.router.ts b/src/api/v1/images/images.router.ts index bc1c150..e7ab861 100644 --- a/src/api/v1/images/images.router.ts +++ b/src/api/v1/images/images.router.ts @@ -1,18 +1,25 @@ -import * as Exp from 'express'; import * as Types from '@/types'; import * as Utils from '@/lib/utils'; +import * as Image from '@/lib/image'; +import * as Storage from '@/lib/storage'; import * as Schema from './image.schema'; import * as Service from './images.service'; import * as Middlewares from '@/middlewares'; -import { Image } from '@/../prisma/client'; +import { Router, Request, Response } from 'express'; +import { Image as ImageT } from '@/../prisma/client'; -export const imagesRouter = Exp.Router(); +export const imagesRouter = Router(); -imagesRouter.get('/', async (req, res) => { - res.json( - await Service.getAllImages(Utils.getPaginationFiltersFromReqQuery(req)) - ); -}); +imagesRouter.get( + '/', + Middlewares.authValidator, + Middlewares.adminValidator, + async (req, res) => { + res.json( + await Service.getAllImages(Utils.getPaginationFiltersFromReqQuery(req)) + ); + } +); imagesRouter.get('/:id', async (req, res) => { res.json(await Service.findImageById(req.params.id)); @@ -22,14 +29,14 @@ imagesRouter.post( '/', Middlewares.authValidator, Middlewares.createFileProcessor('image'), - async (req: Exp.Request, res: Exp.Response) => { + async (req: Request, res: Response) => { const user = req.user as Types.PublicUser; - const imageFile = await Service.getValidImageFileFormReq(req); + const imageFile = await Image.getValidImageFileFormReq(req); const data = { ...Schema.imageSchema.parse(req.body), - ...Service.getImageMetadata(imageFile), + ...Image.getImageMetadata(imageFile), }; - const uploadRes = await Service.uploadImage(imageFile, user); + const uploadRes = await Storage.uploadImage(imageFile, user); const savedImage = await Service.saveImage(uploadRes, data, user); res.status(201).json(savedImage); } @@ -39,24 +46,24 @@ imagesRouter.put( '/:id', Middlewares.authValidator, Middlewares.createOwnerValidator( - Service.getImageOwnerAndInjectImageInResLocals + Image.getImageOwnerAndInjectImageInResLocals ), Middlewares.createFileProcessor('image'), - async (req: Exp.Request, res: Exp.Response) => { + async (req: Request, res: Response) => { const { image } = res.locals; const user = req.user as Types.PublicUser; if (req.file) { - const imageFile = await Service.getValidImageFileFormReq(req); + const imageFile = await Image.getValidImageFileFormReq(req); const data = { ...Schema.imageSchema.parse(req.body), - ...Service.getImageMetadata(imageFile), + ...Image.getImageMetadata(imageFile), }; - const uploadRes = await Service.uploadImage(imageFile, user, image); + const uploadRes = await Storage.uploadImage(imageFile, user, image); const savedImage = await Service.saveImage(uploadRes, data, user); res.json(savedImage); } else { const data = Schema.imageSchema.parse(req.body); - const updatedImage = await Service.updateImageData(data, req.params.id); + const updatedImage = await Service.updateImageData(data, image, user); res.json(updatedImage); } } @@ -66,11 +73,11 @@ imagesRouter.delete( '/:id', Middlewares.authValidator, Middlewares.createAdminOrOwnerValidator( - Service.getImageOwnerAndInjectImageInResLocals + Image.getImageOwnerAndInjectImageInResLocals ), - async (req, res: Exp.Response) => { + async (req, res: Response) => { const { image } = res.locals; - await Service.removeUploadedImage(image); + await Storage.removeImage(image); await Service.deleteImageById(image.id); res.status(204).send(); } diff --git a/src/api/v1/images/images.service.ts b/src/api/v1/images/images.service.ts index aaf9a37..4b145fb 100644 --- a/src/api/v1/images/images.service.ts +++ b/src/api/v1/images/images.service.ts @@ -1,14 +1,18 @@ -import * as Exp from 'express'; import * as Types from '@/types'; import * as Utils from '@/lib/utils'; -import * as Config from '@/lib/config'; +import * as Image from '@/lib/image'; +import * as Storage from '@/lib/storage'; +import * as Schema from './image.schema'; import * as AppError from '@/lib/app-error'; -import { Image } from '@/../prisma/client'; +import { Image as ImageT } from 'prisma/client'; +import { z } from 'zod'; import db from '@/lib/db'; -import sharp from 'sharp'; -const include = Utils.fieldsToIncludeWithImage; -const notFoundErrMsg = 'image not found'; +const notFoundErrMsg = Image.NOT_FOUND_ERR_MSG; +const include = Image.FIELDS_TO_INCLUDE; + +export type _ImageDataInput = z.output; +export type _ImageFullData = _ImageDataInput & Types.ImageMetadata; export const getAllImages = async ( filters?: Types.PaginationFilters @@ -27,103 +31,28 @@ export const findImageById = async (id: string): Promise => { return image; }; -export const getValidImageFileFormReq = async ( - req: Exp.Request & { file?: Express.Multer.File } -): Promise => { - if (req.file) { - let metadata: sharp.Metadata; - try { - metadata = await sharp(req.file.buffer).metadata(); - } catch { - throw new AppError.AppBaseError( - 'Invalid image file', - 400, - 'InvalidImageError' - ); - } - const { format, width, height } = metadata; - const mimetype = `image/${format}`; - const ext = (() => { - switch (mimetype) { - case 'image/png': - return '.png'; - case 'image/jpeg': - return '.jpg'; - case 'image/webp': - return '.webp'; - default: { - const message = 'Unsupported image type (expect png, jpg, or webp)'; - throw new AppError.AppBaseError( - message, - 400, - 'UnsupportedImageTypeError' - ); - } - } - })(); - return { ...req.file, mimetype, format, width, height, ext }; - } - throw new AppError.AppBaseError( - 'image is required', - 400, - 'FileNotExistError' - ); -}; - -export const uploadImage = async ( - imageFile: Types.ImageFile, - user: Types.PublicUser, - imageData?: Image -) => { - let bucket = Config.SUPABASE_BUCKET, - upsert = false, - filePath: string; - if (imageData) { - const [bucketName, ...splittedPath] = imageData.storageFullPath.split('/'); - filePath = splittedPath.join('/'); - bucket = bucketName; - upsert = true; - } else { - const randomSuffix = Math.round(Math.random() * Date.now()) % 10 ** 8; - const uniqueFileName = `${user.id}-${randomSuffix}${imageFile.ext}`; - filePath = `${Config.STORAGE_ROOT_DIR}/${user.username}/${uniqueFileName}`; - } - const { data, error } = await Config.supabase.storage - .from(bucket) - .upload(filePath, imageFile.buffer, { - contentType: imageFile.mimetype, - upsert, - }); - if (error) throw new AppError.AppBaseError(error.message, 500, error.name); - return data; -}; - -export const getImageMetadata = ({ - mimetype, - width, - height, - size, -}: Types.ImageFile): Types.ImageMetadata => { - return { mimetype, width, height, size }; -}; - export const saveImage = async ( - uploadImageRes: Awaited>, - data: Types.FullImageData, + uploadedImage: Storage.UploadedImageData, + { isAvatar, ...data }: _ImageFullData, user: Types.PublicUser ): Promise => { - const src = `${Config.SUPABASE_BUCKET_URL}/${uploadImageRes.path}`; const imageData = { - ...data, - src, - ownerId: user.id, - storageId: uploadImageRes.id, - storageFullPath: uploadImageRes.fullPath, + ...Image.getImageUpsertData(uploadedImage, data, user), + ...(isAvatar + ? { + avatars: { + connectOrCreate: { + where: { userId: user.id }, + create: { userId: user.id }, + }, + }, + } + : {}), }; const dbQuery = db.image.upsert({ create: imageData, update: imageData, - where: { src }, + where: { src: imageData.src }, include, }); const savedImage = await Utils.handleDBKnownErrors(dbQuery, { @@ -133,42 +62,34 @@ export const saveImage = async ( }; export const updateImageData = async ( - data: Types.ImageDataInput, - id: string + { isAvatar, ...data }: _ImageDataInput, + { id }: ImageT, + user: Types.PublicUser ): Promise => { - const dbQuery = db.image.update({ where: { id }, data, include }); + const dbQuery = db.image.update({ + data: { + ...data, + ...(isAvatar + ? { + avatars: { + connectOrCreate: { + where: { userId: user.id }, + create: { userId: user.id }, + }, + }, + } + : {}), + }, + where: { id }, + include, + }); const savedImage = await Utils.handleDBKnownErrors(dbQuery, { uniqueFieldName: 'src', }); return savedImage; }; -export const removeUploadedImage = async (imageData: Image) => { - const [bucketName, ...splittedPath] = imageData.storageFullPath.split('/'); - const filePath = splittedPath.join('/'); - const bucket = bucketName; - const { data, error } = await Config.supabase.storage - .from(bucket) - .remove([filePath]); - if (error) throw new AppError.AppBaseError(error.message, 500, error.name); - return data; -}; - export const deleteImageById = async (id: string) => { const dbQuery = db.image.delete({ where: { id } }); return await Utils.handleDBKnownErrors(dbQuery, { notFoundErrMsg }); }; - -export const getImageOwnerAndInjectImageInResLocals = async ( - req: Exp.Request, - res: Exp.Response -) => { - const dbQuery = db.image.findUnique({ - where: { id: req.params.id }, - omit: { storageFullPath: false, storageId: false }, - }); - const image = await Utils.handleDBKnownErrors(dbQuery, { notFoundErrMsg }); - if (!image) throw new AppError.AppNotFoundError(notFoundErrMsg); - res.locals.image = image; - return image.ownerId; -}; diff --git a/src/api/v1/images/index.ts b/src/api/v1/images/index.ts index 03e3953..0fd0b09 100644 --- a/src/api/v1/images/index.ts +++ b/src/api/v1/images/index.ts @@ -1,3 +1,2 @@ export * from './images.service'; export * from './images.router'; -export * from './image.schema'; diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index 9c66557..9298490 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -1,10 +1,10 @@ -import * as Exp from 'express'; import { imagesRouter } from './images'; import { usersRouter } from './users'; import { postsRouter } from './posts'; import { authRouter } from './auth'; +import { Router } from 'express'; -export const apiRouter = Exp.Router(); +export const apiRouter = Router(); apiRouter.use('/auth', authRouter); apiRouter.use('/users', usersRouter); diff --git a/src/api/v1/posts/post.schema.ts b/src/api/v1/posts/post.schema.ts index f1ddea6..fb4668b 100644 --- a/src/api/v1/posts/post.schema.ts +++ b/src/api/v1/posts/post.schema.ts @@ -1,3 +1,4 @@ +import * as Image from '@/lib/image'; import { z } from 'zod'; const getRequiredAndTypeErrors = ( @@ -18,19 +19,14 @@ export const titleSchema = z .trim() .nonempty('A post must have a title'); -export const imageSchema = z - .string() - .trim() - .uuid({ message: 'expect image to be UUID' }) - .optional(); - export const contentSchema = z .string(getRequiredAndTypeErrors('Post-Content')) .trim() .nonempty('A post must have content'); -export const publishedSchema = z.boolean( - getRequiredAndTypeErrors('Published flag', 'boolean', true) +export const publishedSchema = z.preprocess( + (v) => v && [true, 'true', 'on'].includes(v as string | boolean), + z.boolean(getRequiredAndTypeErrors('Published flag', 'boolean', true)) ); export const tagSchema = z @@ -56,8 +52,8 @@ export const commentSchema = z.object({ export const postSchema = z.object({ title: titleSchema, - image: imageSchema, content: contentSchema, tags: tagsSchema.default([]), published: publishedSchema.optional(), + imagedata: Image.imageSchema.optional(), }); diff --git a/src/api/v1/posts/posts.router.ts b/src/api/v1/posts/posts.router.ts index 5dd13b0..7f5b282 100644 --- a/src/api/v1/posts/posts.router.ts +++ b/src/api/v1/posts/posts.router.ts @@ -1,23 +1,28 @@ -import * as Exp from 'express'; import * as Types from '@/types'; +import * as Image from '@/lib/image'; import * as Utils from '@/lib/utils'; import * as Schema from './post.schema'; +import * as Storage from '@/lib/storage'; import * as Service from './posts.service'; -import * as Validators from '@/middlewares/validators'; +import * as Middlewares from '@/middlewares'; +import { Router, Request, Response } from 'express'; -export const postsRouter = Exp.Router(); +export const postsRouter = Router(); const getPostAuthorIdAndInjectPostInResLocals = async ( - req: Exp.Request, - res: Exp.Response + req: Request, + res: Response ) => { const userId = Utils.getCurrentUserIdFromReq(req); - const post = await Service.findPostByIdOrThrow(req.params.id, userId); + const post = await Service._findPostWithAggregationOrThrow( + req.params.id, + userId + ); res.locals.post = post; return post.authorId; }; -const getCommentAuthorId = async (req: Exp.Request) => { +const getCommentAuthorId = async (req: Request) => { const userId = Utils.getCurrentUserIdFromReq(req); const comment = await Service.findPostCommentByCompoundIdOrThrow( req.params.pId, @@ -31,15 +36,15 @@ const createHandlersForGettingPrivatePostData = ( postService: (postId: string, authorId?: string) => unknown ) => { return [ - Validators.optionalAuthValidator, - async (req: Exp.Request, res: Exp.Response) => { + Middlewares.optionalAuthValidator, + async (req: Request, res: Response) => { const userId = Utils.getCurrentUserIdFromReq(req); res.json(await postService(req.params.id, userId)); }, ]; }; -postsRouter.get('/', Validators.optionalAuthValidator, async (req, res) => { +postsRouter.get('/', Middlewares.optionalAuthValidator, async (req, res) => { const filters = Utils.getPostFiltersFromReqQuery(req); const posts = await Service.findFilteredPosts(filters); res.json(posts); @@ -47,7 +52,7 @@ postsRouter.get('/', Validators.optionalAuthValidator, async (req, res) => { postsRouter.get( '/count', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const filters = Utils.getPostFiltersFromReqQuery(req); res.json(await Service.findFilteredPosts(filters, 'count')); @@ -61,7 +66,7 @@ postsRouter.get('/tags', async (req, res) => { postsRouter.get( '/comments', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const commentsFilter = Utils.getCommentFiltersFromReqQuery(req); res.json(await Service.findFilteredComments(commentsFilter)); @@ -70,22 +75,21 @@ postsRouter.get( postsRouter.get( '/votes', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const votesFilter = Utils.getVoteFiltersFromReqQuery(req); res.json(await Service.findFilteredVotes(votesFilter)); } ); -postsRouter.get('/tags/count', Validators.authValidator, async (req, res) => { - const user = req.user as Types.PublicUser; - const tagsCount = await Service.countPostsTagsByPostsAuthorId(user.id); - res.json(tagsCount); +postsRouter.get('/tags/count', async (req, res) => { + const postAuthorId = Utils.getAuthorIdFilterFromReqQuery(req); + res.json(await Service.countTagsOnPosts(postAuthorId)); }); postsRouter.get( '/comments/count', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const commentsFilter = Utils.getCommentFiltersFromReqQuery(req); res.json(await Service.findFilteredComments(commentsFilter, 'count')); @@ -94,7 +98,7 @@ postsRouter.get( postsRouter.get( '/votes/count', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const votesFilter = Utils.getVoteFiltersFromReqQuery(req); res.json(await Service.findFilteredVotes(votesFilter, 'count')); @@ -118,8 +122,8 @@ postsRouter.get( postsRouter.get( '/:id/comments', - Validators.optionalAuthValidator, - async (req: Exp.Request, res: Exp.Response) => { + Middlewares.optionalAuthValidator, + async (req: Request, res: Response) => { const postId = req.params.id; const filters = { ...Utils.getCommentFiltersFromReqQuery(req), postId }; res.json(await Service.findFilteredComments(filters)); @@ -128,8 +132,8 @@ postsRouter.get( postsRouter.get( '/:id/votes', - Validators.optionalAuthValidator, - async (req: Exp.Request, res: Exp.Response) => { + Middlewares.optionalAuthValidator, + async (req: Request, res: Response) => { const filters = { ...Utils.getVoteFiltersFromReqQuery(req), postId: req.params.id, @@ -150,7 +154,7 @@ postsRouter.get( postsRouter.get( '/:pId/comments/:cId', - Validators.optionalAuthValidator, + Middlewares.optionalAuthValidator, async (req, res) => { const userId = Utils.getCurrentUserIdFromReq(req); res.json( @@ -163,14 +167,35 @@ postsRouter.get( } ); -postsRouter.post('/', Validators.authValidator, async (req, res) => { - const user = req.user as Types.PublicUser; - const postData = { ...Schema.postSchema.parse(req.body), authorId: user.id }; - const createdPost = await Service.createPost(postData); - res.status(201).json(createdPost); -}); +postsRouter.post( + '/', + Middlewares.authValidator, + Middlewares.createFileProcessor('image'), + async (req: Request, res: Response) => { + const user = req.user as Types.PublicUser; + const { imagedata, ...postData } = Schema.postSchema.parse(req.body); + let createdPost; + if (req.file) { + const file = await Image.getValidImageFileFormReq(req); + const imageData = { + ...(imagedata ?? {}), + ...Image.getImageMetadata(file), + }; + const uploadedImage = await Storage.uploadImage(file, user); + createdPost = await Service.createPostWithImage( + postData, + user, + imageData, + uploadedImage + ); + } else { + createdPost = await Service.createPost(postData, user); + } + res.status(201).json(createdPost); + } +); -postsRouter.post('/:id/upvote', Validators.authValidator, async (req, res) => { +postsRouter.post('/:id/upvote', Middlewares.authValidator, async (req, res) => { const user = req.user as Types.PublicUser; const upvotedPost = await Service.upvotePost(req.params.id, user.id); res.json(upvotedPost); @@ -178,7 +203,7 @@ postsRouter.post('/:id/upvote', Validators.authValidator, async (req, res) => { postsRouter.post( '/:id/downvote', - Validators.authValidator, + Middlewares.authValidator, async (req, res) => { const user = req.user as Types.PublicUser; const downvotedPost = await Service.downvotePost(req.params.id, user.id); @@ -186,7 +211,7 @@ postsRouter.post( } ); -postsRouter.post('/:id/unvote', Validators.authValidator, async (req, res) => { +postsRouter.post('/:id/unvote', Middlewares.authValidator, async (req, res) => { const user = req.user as Types.PublicUser; const unvotedPost = await Service.unvotePost(req.params.id, user.id); res.json(unvotedPost); @@ -194,11 +219,8 @@ postsRouter.post('/:id/unvote', Validators.authValidator, async (req, res) => { postsRouter.post( '/:id/comments', - Validators.authValidator, - async ( - req: Exp.Request<{ id: string }, unknown, { content: string }>, - res - ) => { + Middlewares.authValidator, + async (req: Request<{ id: string }, unknown, { content: string }>, res) => { const user = req.user as Types.PublicUser; const commentData = Schema.commentSchema.parse(req.body); const newComment = await Service.findPostByIdAndCreateComment( @@ -212,21 +234,44 @@ postsRouter.post( postsRouter.put( '/:id', - Validators.authValidator, - Validators.createAdminOrOwnerValidator( + Middlewares.authValidator, + Middlewares.createAdminOrOwnerValidator( getPostAuthorIdAndInjectPostInResLocals ), - async (req, res) => { - const postData = Schema.postSchema.parse(req.body); - const createdPost = await Service.updatePost(req.params.id, postData); - res.json(createdPost); + Middlewares.createFileProcessor('image'), + async ( + req: Request, + res: Response + ) => { + const user = req.user as Types.PublicUser; + const { post } = res.locals; + const { imagedata, ...postData } = Schema.postSchema.parse(req.body); + let updatedPost; + if (req.file) { + const file = await Image.getValidImageFileFormReq(req); + const imageData = { + ...(imagedata ?? {}), + ...Image.getImageMetadata(file), + }; + const uploadedImage = await Storage.uploadImage(file, user, post.image); + updatedPost = await Service.updatePostWithImage( + post, + user, + postData, + imageData, + uploadedImage + ); + } else { + updatedPost = await Service.updatePost(post, postData, imagedata); + } + res.json(updatedPost); } ); postsRouter.put( '/:pId/comments/:cId', - Validators.authValidator, - Validators.createOwnerValidator(getCommentAuthorId), + Middlewares.authValidator, + Middlewares.createOwnerValidator(getCommentAuthorId), async (req, res) => { const user = req.user as Types.PublicUser; const commentData = Schema.commentSchema.parse(req.body); @@ -241,11 +286,11 @@ postsRouter.put( postsRouter.delete( '/:id', - Validators.authValidator, - Validators.createAdminOrOwnerValidator( + Middlewares.authValidator, + Middlewares.createAdminOrOwnerValidator( async (req, res) => await getPostAuthorIdAndInjectPostInResLocals(req, res) ), - async (req, res: Exp.Response) => { + async (req, res: Response) => { const userId = Utils.getCurrentUserIdFromReq(req); await Service.deletePost(res.locals.post, userId); res.status(204).end(); @@ -254,8 +299,8 @@ postsRouter.delete( postsRouter.delete( '/:pId/comments/:cId', - Validators.authValidator, - Validators.createAdminOrOwnerValidator(async (req, res) => { + Middlewares.authValidator, + Middlewares.createAdminOrOwnerValidator(async (req, res) => { const userId = Utils.getCurrentUserIdFromReq(req); req.params.id = req.params.pId; // For `getPostAuthorId(req)` const postAuthorId = await getPostAuthorIdAndInjectPostInResLocals( diff --git a/src/api/v1/posts/posts.service.ts b/src/api/v1/posts/posts.service.ts index 1e4f6bb..eec8986 100644 --- a/src/api/v1/posts/posts.service.ts +++ b/src/api/v1/posts/posts.service.ts @@ -1,5 +1,7 @@ import * as Types from '@/types'; import * as Utils from '@/lib/utils'; +import * as Image from '@/lib/image'; +import * as Storage from '@/lib/storage'; import * as AppError from '@/lib/app-error'; import { Prisma } from '@/../prisma/client'; import db from '@/lib/db'; @@ -31,28 +33,67 @@ export const getTags = async (tags?: Types.TagsFilter) => { }); }; -export const createPost = async (data: Types.NewPostAuthorizedData) => { - const dbQuery = db.post.create({ - data: { - title: data.title, - imageId: data.image, - content: data.content, - authorId: data.authorId, - published: data.published, - tags: { - create: data.tags.map((name) => ({ - tag: { - connectOrCreate: { where: { name }, create: { name } }, - }, - })), - }, +export const getPostUpdateData = ( + data: Types.NewPostParsedData, + imageId?: string +) => { + return { + imageId, + title: data.title, + content: data.content, + published: data.published, + tags: { + create: data.tags.map((name) => ({ + tag: { connectOrCreate: { where: { name }, create: { name } } }, + })), }, - include: Utils.fieldsToIncludeWithPost, - }); + }; +}; + +export const getPostCreateData = ( + data: Types.NewPostParsedData, + authorId: string, + imageId?: string +) => { + return { ...getPostUpdateData(data, imageId), authorId }; +}; + +const wrapPostCreate = async (dbQuery: Promise): Promise => { const handlerOptions = { uniqueFieldName: 'tag' }; return await Utils.handleDBKnownErrors(dbQuery, handlerOptions); }; +export const createPost = ( + postData: Types.NewPostParsedDataWithoutImage, + user: Types.PublicUser +) => { + return wrapPostCreate( + db.post.create({ + data: getPostCreateData(postData, user.id), + include: Utils.fieldsToIncludeWithPost, + }) + ); +}; + +export const createPostWithImage = ( + postData: Types.NewPostParsedDataWithoutImage, + user: Types.PublicUser, + imageData: Types.ImageFullData, + uploadedImage: Storage.UploadedImageData +) => { + return wrapPostCreate( + db.$transaction(async (prismaClient) => { + const image = await prismaClient.image.create({ + data: Image.getImageUpsertData(uploadedImage, imageData, user), + }); + return await prismaClient.post.create({ + data: getPostCreateData(postData, user.id, image.id), + include: Utils.fieldsToIncludeWithPost, + }); + }) + ); +}; + export const findPostByIdOrThrow = async (id: string, authorId?: string) => { const dbQuery = db.post.findUnique({ where: { id, ...getPrivatePostProtectionArgs(authorId) }, @@ -63,6 +104,29 @@ export const findPostByIdOrThrow = async (id: string, authorId?: string) => { return post; }; +export const _findPostWithAggregationOrThrow = async ( + id: string, + authorId?: string +) => { + const dbQuery = db.post.findUnique({ + where: { id, ...getPrivatePostProtectionArgs(authorId) }, + include: { + ...Utils.fieldsToIncludeWithPost, + image: { + ...Utils.fieldsToIncludeWithPost.image, + omit: { storageFullPath: false, storageId: false }, + }, + }, + }); + const post = await Utils.handleDBKnownErrors(dbQuery); + if (!post) throw new AppError.AppNotFoundError('Post Not Found'); + return post; +}; + +export type _PostFullData = Awaited< + ReturnType +>; + export const findFilteredPosts = async ( filters: Types.PostFilters = {}, operation: 'count' | 'findMany' = 'findMany' @@ -143,28 +207,7 @@ export const findFilteredVotes = async ( ); }; -export const updatePost = async (id: string, data: Types.NewPostParsedData) => { - const dbQuery = (async () => - ( - await db.$transaction([ - db.tagsOnPosts.deleteMany({ where: { postId: id } }), - db.post.update({ - where: { id }, - data: { - title: data.title, - imageId: data.image, - content: data.content, - published: data.published, - tags: { - create: data.tags.map((name) => ({ - tag: { connectOrCreate: { where: { name }, create: { name } } }, - })), - }, - }, - include: Utils.fieldsToIncludeWithPost, - }), - ]) - )[1])(); +const wrapPostUpdate = async (dbQuery: Promise): Promise => { const handlerOptions = { notFoundErrMsg: 'Post not found', uniqueFieldName: 'tag', @@ -172,6 +215,58 @@ export const updatePost = async (id: string, data: Types.NewPostParsedData) => { return await Utils.handleDBKnownErrors(dbQuery, handlerOptions); }; +export const updatePost = ( + post: _PostFullData, + postData: Types.NewPostParsedData, + imageData?: Types.ImageDataInput +) => { + return wrapPostUpdate( + db.$transaction(async (prismaClient) => { + if (imageData && post.image) { + await prismaClient.image.update({ + where: { id: post.image.id }, + data: imageData, + }); + } + await prismaClient.tagsOnPosts.deleteMany({ where: { postId: post.id } }); + return await prismaClient.post.update({ + where: { id: post.id }, + data: getPostUpdateData(postData), + include: Utils.fieldsToIncludeWithPost, + }); + }) + ); +}; + +export const updatePostWithImage = ( + post: _PostFullData, + user: Types.PublicUser, + postData: Types.NewPostParsedData, + imageData: Types.ImageFullData, + uploadedImage: Storage.UploadedImageData +) => { + return wrapPostUpdate( + db.$transaction(async (prismaClient) => { + const imgUpData = Image.getImageUpsertData( + uploadedImage, + imageData, + user + ); + const { id: imageId } = await db.image.upsert({ + where: { src: imgUpData.src }, + create: imgUpData, + update: imgUpData, + }); + await prismaClient.tagsOnPosts.deleteMany({ where: { postId: post.id } }); + return await prismaClient.post.update({ + where: { id: post.id }, + include: Utils.fieldsToIncludeWithPost, + data: getPostUpdateData(postData, imageId), + }); + }) + ); +}; + const upvoteOrDownvotePost = async ( action: 'upvote' | 'downvote', postId: string, @@ -239,18 +334,26 @@ export const deletePost = async ( let postImage; try { postImage = await db.image.findUnique({ + include: Image.FIELDS_TO_INCLUDE, + omit: { storageFullPath: false, storageId: false }, where: { id: post.imageId, ownerId: post.authorId }, - include: { _count: { select: { posts: true } } }, }); } catch (error) { - logger.log('Expect to found post image', error); + logger.error('Expect to found post image -', error); } // If the image is connected to this post only, delete it with the post in a single transaction if (postImage && postImage._count.posts === 1) { - const delImgQ = db.image.delete({ where: { id: postImage.id } }); - return await Utils.handleDBKnownErrors( - db.$transaction([delPostQ, delImgQ]) - ); + try { + await Storage.removeImage(postImage); + return await Utils.handleDBKnownErrors( + db.$transaction([ + delPostQ, + db.image.delete({ where: { id: postImage.id } }), + ]) + ); + } catch (error) { + logger.error('Failed to remove an image form the storage -', error); + } } } // Otherwise, only delete the post and leave its image alone ;) @@ -328,13 +431,14 @@ export const findPostTags = async (postId: string, authorId?: string) => { ); }; -export const countPostsTagsByPostsAuthorId = async (authorId: string) => { - const dbQuery = db.tagsOnPosts.findMany({ - where: { post: { authorId } }, - distinct: ['name'], - }); - const postDistinctTags = await Utils.handleDBKnownErrors(dbQuery); - return postDistinctTags.length; +export const countTagsOnPosts = async (postAuthorId?: string) => { + return await Utils.handleDBKnownErrors( + db.tag.count( + postAuthorId + ? { where: { posts: { some: { post: { authorId: postAuthorId } } } } } + : undefined + ) + ); }; export const countPostTags = async (postId: string, authorId?: string) => { diff --git a/src/api/v1/users/user.schema.ts b/src/api/v1/users/user.schema.ts index 389e233..b5d71e0 100644 --- a/src/api/v1/users/user.schema.ts +++ b/src/api/v1/users/user.schema.ts @@ -1,8 +1,14 @@ import { ADMIN_SECRET } from '@/lib/config'; import { z } from 'zod'; +export const avatarIdSchema = z + .string() + .trim() + .uuid({ message: 'Expect image to be UUID' }) + .optional(); + export const bioSchema = z - .string({ invalid_type_error: 'Use bio must be a string' }) + .string({ invalid_type_error: 'User bio must be a string' }) .trim() .optional(); @@ -29,50 +35,91 @@ export const fullnameSchema = z .max(96, 'Fullname must contain at most 96 characters'); export const passwordSchema = z - .object({ - password: z - .string({ - required_error: 'Password is required', - invalid_type_error: 'Password must be a string', - }) - .trim() - .min(8) - .max(50) - .regex( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, - 'Password must contain a number, a special character a lowercase letter, and an uppercase letter' - ), - confirm: z - .string({ - required_error: 'Password confirmation is required', - invalid_type_error: 'Password confirmation must be a string', - }) - .trim() - .nonempty(), + .string({ + required_error: 'Password is required', + invalid_type_error: 'Password must be a string', + }) + .trim() + .min(8, 'Password must contain at least 8 characters') + .max(50, 'Password must contain at most 50 characters') + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/, + 'Password must contain a number, a special character a lowercase letter, and an uppercase letter' + ); + +export const passConfSchema = z + .string({ + required_error: 'Password confirmation is required', + invalid_type_error: 'Password confirmation must be a string', }) - .refine(({ password, confirm }) => password === confirm, { - message: 'Passwords does not match', - path: ['confirm'], - }); + .trim() + .nonempty('Password confirmation is required'); export const secretSchema = z .string({ invalid_type_error: 'Secret must be a string' }) + .trim() .optional() .refine((secret) => !secret || secret === ADMIN_SECRET, { message: 'Invalid secret', path: ['secret'], }); // for isAdmin -export const userSchema = passwordSchema - .and( - z.object({ - username: usernameSchema, - fullname: fullnameSchema, - secret: secretSchema, - bio: bioSchema, - }) - ) - .transform(({ secret, username, fullname, password, bio }) => { - const isAdmin = Boolean(secret); - return { isAdmin, username, fullname, password, bio }; - }); +const userObjectSchema = z.object({ + avatarId: avatarIdSchema, + username: usernameSchema, + fullname: fullnameSchema, + password: passwordSchema, + confirm: passConfSchema, + secret: secretSchema, + bio: bioSchema, +}); + +export type UserObjectSchemaOut = z.output; +export type PartialUserObjectSchemaOut = z.output< + ReturnType +>; + +export const refinePassConfCheck = ( + data: UserObjectSchemaOut | PartialUserObjectSchemaOut +) => { + return data.password === data.confirm; +}; + +export const refinePassConfMessage = { + message: 'Passwords does not match', + path: ['confirm'], +}; + +export const refinePassConfArgs: [ + typeof refinePassConfCheck, + typeof refinePassConfMessage +] = [refinePassConfCheck, refinePassConfMessage]; + +export const transformUserObject = < + T extends UserObjectSchemaOut | PartialUserObjectSchemaOut +>({ + confirm: _, + secret, + ...data +}: T) => { + type TransformedData = typeof data & { isAdmin?: boolean }; + const result: TransformedData = { ...data }; + if (secret) result.isAdmin = Boolean(secret); + return result; +}; + +export const userSchema = userObjectSchema + .refine(...refinePassConfArgs) + .transform(transformUserObject); + +export const createUpdateUserSchema = ( + partialData: PartialUserObjectSchemaOut +) => { + const partialUserSchema = + partialData.password || partialData.confirm + ? userObjectSchema.partial().required({ password: true, confirm: true }) + : userObjectSchema.partial(); + return partialUserSchema + .refine(...refinePassConfArgs) + .transform(transformUserObject); +}; diff --git a/src/api/v1/users/users.router.ts b/src/api/v1/users/users.router.ts index 3164b31..329efec 100644 --- a/src/api/v1/users/users.router.ts +++ b/src/api/v1/users/users.router.ts @@ -1,12 +1,11 @@ -import * as Exp from 'express'; import * as Types from '@/types'; import * as Utils from '@/lib/utils'; import * as Schema from './user.schema'; import * as Service from './users.service'; import * as Validators from '@/middlewares/validators'; -import { Prisma } from '@/../prisma/client'; +import { Router, Request } from 'express'; -export const usersRouter = Exp.Router(); +export const usersRouter = Router(); usersRouter.get( '/', @@ -19,27 +18,10 @@ usersRouter.get( } ); -usersRouter.get( - '/:idOrUsername', - Validators.optionalAuthValidator, - async (req, res, next) => { - const param = req.params.idOrUsername; - const user = await Service.findUserByIdOrByUsernameOrThrow(param); - if (param === user.id) { - res.json(user); - } else { - const nextWrapper: Exp.NextFunction = (x: unknown) => { - if (x) next(x); - else res.json(user); - }; - await Validators.createAdminOrOwnerValidator(() => user.id)( - req, - res, - nextWrapper - ); - } - } -); +usersRouter.get('/:idOrUsername', async (req, res) => { + const { idOrUsername } = req.params; + res.json(await Service.findUserByIdOrByUsernameOrThrow(idOrUsername)); +}); usersRouter.post('/', async (req, res) => { const parsedNewUser = Schema.userSchema.parse(req.body); @@ -51,27 +33,37 @@ usersRouter.post('/', async (req, res) => { res.status(201).json(signupRes); }); +usersRouter.post('/guest', async (req, res) => { + const [randVal, ...randVals] = crypto.randomUUID().split('-'); + const randNum = Date.now() % 100000; + const questUser = await Service.createUser({ + bio: + 'Consider updating your profile data, especially the password, ' + + 'to be able to sign in to this profile again.', + password: `G_${randVals.slice(1, 3).join('_')}`, + username: `guest_${randVal}${randNum}`, + fullname: `Guest ${randVal}${randNum}`, + isAdmin: false, + }); + const signupRes: Types.AuthResponse = { + token: Utils.createJwtForUser(questUser), + user: questUser, + }; + res.status(201).json(signupRes); +}); + usersRouter.patch( '/:id', Validators.authValidator, Validators.createAdminOrOwnerValidator((req) => req.params.id), - async ( - req: Exp.Request<{ id: string }, unknown, Types.NewUserInput>, - res - ) => { - const { username, fullname, password, confirm, secret } = req.body; - const data: Prisma.UserUpdateInput = {}; - if (username) data.username = Schema.usernameSchema.parse(username); - if (fullname) data.fullname = Schema.fullnameSchema.parse(fullname); - if (password) { - data.password = Schema.passwordSchema.parse({ - password: password, - confirm, - }).password; - } - if (secret && Schema.secretSchema.parse(secret)) data.isAdmin = true; - await Service.updateUser(req.params.id, data); - res.status(204).end(); + async (req: Request<{ id: string }, unknown, Types.NewUserInput>, res) => { + const data = Schema.createUpdateUserSchema(req.body).parse(req.body); + const updatedUser = await Service.updateUser(req.params.id, data); + const resBody: Types.AuthResponse = { + token: req.headers.authorization ?? '', + user: updatedUser, + }; + res.json(resBody); } ); diff --git a/src/api/v1/users/users.service.ts b/src/api/v1/users/users.service.ts index 50a0236..a55c8b3 100644 --- a/src/api/v1/users/users.service.ts +++ b/src/api/v1/users/users.service.ts @@ -2,7 +2,6 @@ import * as Types from '@/types'; import * as Bcrypt from 'bcryptjs'; import * as Utils from '@/lib/utils'; import * as AppError from '@/lib/app-error'; -import { Prisma } from '@/../prisma/client'; import db from '@/lib/db'; const hashPassword = (password: string) => Bcrypt.hash(password, 10); @@ -10,15 +9,23 @@ const hashPassword = (password: string) => Bcrypt.hash(password, 10); export const getAllUsers = async (filters?: Types.PaginationFilters) => { return await db.user.findMany({ ...(filters ? Utils.getPaginationArgs(filters) : {}), + ...Utils.userAggregation, }); }; -export const createUser = async ( - newUser: Types.NewUserOutput -): Promise => { - const data = { ...newUser }; - data.password = await hashPassword(data.password); - const dbQuery = db.user.create({ data }); +export const createUser = async ({ + avatarId, + password, + ...data +}: Types.NewUserOutput): Promise => { + const dbQuery = db.user.create({ + data: { + ...data, + password: await hashPassword(password), + ...(avatarId ? { avatar: { create: { imageId: avatarId } } } : {}), + }, + ...Utils.userAggregation, + }); const handlerOptions = { uniqueFieldName: 'username' }; const user = await Utils.handleDBKnownErrors(dbQuery, handlerOptions); return user; @@ -27,7 +34,10 @@ export const createUser = async ( export const findUserById = async ( id: string ): Promise => { - const dbQuery = db.user.findUnique({ where: { id } }); + const dbQuery = db.user.findUnique({ + where: { id }, + ...Utils.userAggregation, + }); const user = await Utils.handleDBKnownErrors(dbQuery); return user; }; @@ -35,7 +45,10 @@ export const findUserById = async ( export const findUserByUsername = async ( username: string ): Promise => { - const dbQuery = db.user.findUnique({ where: { username } }); + const dbQuery = db.user.findUnique({ + where: { username }, + ...Utils.userAggregation, + }); const user = await Utils.handleDBKnownErrors(dbQuery); return user; }; @@ -62,21 +75,36 @@ export const findUserByIdOrByUsernameOrThrow = async (idOrUsername: string) => { export const updateUser = async ( id: string, - userData: Prisma.UserUpdateInput -): Promise => { - const data = { ...userData }; - if (data.password && typeof data.password === 'string') { - data.password = await hashPassword(data.password); - } - const dbQuery = db.user.update({ where: { id }, data }); + { avatarId, password, ...data }: Types.UpdateUserOutput +) => { + const dbQuery = db.user.update({ + where: { id }, + data: { + ...data, + ...(password && typeof password === 'string' + ? { password: await hashPassword(password) } + : {}), + ...(avatarId + ? { + avatar: { + connectOrCreate: { + where: { userId: id }, + create: { imageId: avatarId }, + }, + }, + } + : {}), + }, + ...Utils.userAggregation, + }); const handlerOptions = { notFoundErrMsg: 'User not found', uniqueFieldName: 'username', }; - await Utils.handleDBKnownErrors(dbQuery, handlerOptions); + return await Utils.handleDBKnownErrors(dbQuery, handlerOptions); }; export const deleteUser = async (id: string): Promise => { - const dbQuery = db.user.delete({ where: { id } }); + const dbQuery = db.user.delete({ where: { id }, ...Utils.userAggregation }); await Utils.handleDBKnownErrors(dbQuery); }; diff --git a/src/app.ts b/src/app.ts index eb281a3..d983d31 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ app.disable('x-powered-by'); app.use(helmet()); app.use(express.json()); app.use(Middlewares.requestLogger); +app.use(Middlewares.createNonAdminDataPurger()); logger.info('ALLOWED_ORIGINS: ', ALLOWED_ORIGINS); app.use( diff --git a/src/lib/config.ts b/src/lib/config.ts index 025e921..de1ef33 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,37 +1,15 @@ -import { createClient } from '@supabase/supabase-js'; - const ERR = 'EnvVarMissed'; if (!process.env.SECRET) console.error(`${ERR}: SECRET`); if (!process.env.ADMIN_SECRET) console.error(`${ERR}: ADMIN_SECRET`); -if ( - !process.env.SUPABASE_URL || - !process.env.SUPABASE_BUCKET || - !process.env.STORAGE_ROOT_DIR || - !process.env.SUPABASE_ANON_KEY || - !process.env.SUPABASE_BUCKET_URL -) { - throw new Error( - `${ERR}: SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_BUCKET, SUPABASE_BUCKET_URL, or STORAGE_ROOT_DIR` - ); -} - export const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()) : []; export const SECRET = process.env.SECRET ?? 'secret'; export const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'admin_secret'; +export const MAX_FILE_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 2; export const TOKEN_EXP_PERIOD = process.env.TOKEN_EXP_PERIOD ?? '3d'; export const NODE_ENV = process.env.NODE_ENV; export const CI = Boolean(process.env.CI); - -export const SUPABASE_URL = process.env.SUPABASE_URL; -export const SUPABASE_BUCKET = process.env.SUPABASE_BUCKET; -export const STORAGE_ROOT_DIR = process.env.STORAGE_ROOT_DIR; -export const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY; -export const SUPABASE_BUCKET_URL = process.env.SUPABASE_BUCKET_URL; -export const MAX_FILE_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 2; - -export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); diff --git a/src/lib/image/index.ts b/src/lib/image/index.ts new file mode 100644 index 0000000..7d8a138 --- /dev/null +++ b/src/lib/image/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './utils'; diff --git a/src/lib/image/schema.ts b/src/lib/image/schema.ts new file mode 100644 index 0000000..96cdaf0 --- /dev/null +++ b/src/lib/image/schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const posSchema = z.coerce + .number() + .optional() + .transform((n) => n && Math.trunc(n)); + +export const scaleSchema = z.coerce.number().optional(); + +export const infoSchema = z.string().trim().optional(); + +export const altSchema = z.string().trim().optional(); + +export const imageSchema = z.object({ + scale: scaleSchema, + info: infoSchema, + xPos: posSchema, + yPos: posSchema, + alt: altSchema, +}); diff --git a/src/lib/image/utils.ts b/src/lib/image/utils.ts new file mode 100644 index 0000000..09264ec --- /dev/null +++ b/src/lib/image/utils.ts @@ -0,0 +1,93 @@ +import * as Types from '@/types'; +import * as Utils from '@/lib/utils'; +import * as Storage from '@/lib/storage'; +import * as AppError from '@/lib/app-error'; +import { Request, Response } from 'express'; +import sharp from 'sharp'; +import db from '@/lib/db'; + +export const NOT_FOUND_ERR_MSG = 'image not found'; + +export const FIELDS_TO_INCLUDE = Utils.fieldsToIncludeWithImage; + +export const getValidImageFileFormReq = async ( + req: Request & { file?: Express.Multer.File } +): Promise => { + if (req.file) { + let metadata: sharp.Metadata; + try { + metadata = await sharp(req.file.buffer).metadata(); + } catch { + throw new AppError.AppBaseError( + 'Invalid image file', + 400, + 'InvalidImageError' + ); + } + const { format, width, height } = metadata; + const mimetype = `image/${format}`; + const ext = (() => { + switch (mimetype) { + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + case 'image/webp': + return '.webp'; + default: { + const message = 'Unsupported image type (expect png, jpg, or webp)'; + throw new AppError.AppBaseError( + message, + 400, + 'UnsupportedImageTypeError' + ); + } + } + })(); + return { ...req.file, mimetype, format, width, height, ext }; + } + throw new AppError.AppBaseError( + 'image is required', + 400, + 'FileNotExistError' + ); +}; + +export const getImageMetadata = ({ + mimetype, + width, + height, + size, +}: Types.ImageFile): Types.ImageMetadata => { + return { mimetype, width, height, size }; +}; + +export const getImageOwnerAndInjectImageInResLocals = async ( + req: Request, + res: Response +) => { + const dbQuery = db.image.findUnique({ + where: { id: req.params.id }, + omit: { storageFullPath: false, storageId: false }, + }); + const image = await Utils.handleDBKnownErrors(dbQuery, { + notFoundErrMsg: NOT_FOUND_ERR_MSG, + }); + if (!image) throw new AppError.AppNotFoundError(NOT_FOUND_ERR_MSG); + res.locals.image = image; + return image.ownerId; +}; + +export const getImageUpsertData = ( + uploadImageRes: Storage.UploadedImageData, + data: Types.ImageFullData, + user: Types.PublicUser +) => { + return { + ...data, + src: uploadImageRes.publicUrl, + owner: { connect: { id: user.id } }, + storageFullPath: uploadImageRes.fullPath, + storageId: uploadImageRes.id, + }; +}; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 4ba1e24..7358186 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,4 +1,4 @@ -import { CI, NODE_ENV } from './config'; +import * as Config from './config'; import winston from 'winston'; import util from 'node:util'; @@ -14,7 +14,7 @@ const stripSymbols = (anyObj: unknown): unknown => { const inspectObj = (anyObj: unknown): string => { if (typeof anyObj === 'string') return anyObj; const obj = stripSymbols(anyObj); - return util.inspect(obj, { depth: null, colors: true, compact: CI }); + return util.inspect(obj, { depth: null, colors: true, compact: Config.CI }); }; const winstonLevelColorsExt = Object.fromEntries( @@ -27,6 +27,9 @@ const winstonLevelColorsExt = Object.fromEntries( winston.addColors(winstonLevelColorsExt); export const logger = winston.createLogger({ + level: Config.NODE_ENV === 'production' ? 'http' : 'silly', + transports: [new winston.transports.Console()], + silent: Config.NODE_ENV === 'test', format: winston.format.combine( winston.format((info) => { info.level = info.level.toUpperCase(); @@ -40,12 +43,9 @@ export const logger = winston.createLogger({ }`; }) ), - transports: [new winston.transports.Console()], - silent: NODE_ENV === 'test', - level: 'info', }); -if (!CI) { +if (!Config.CI) { logger.add( new winston.transports.File({ tailable: true, diff --git a/src/lib/passport.ts b/src/lib/passport.ts index 50fd920..007d787 100644 --- a/src/lib/passport.ts +++ b/src/lib/passport.ts @@ -1,5 +1,6 @@ import * as Types from '@/types'; import * as JWT from 'passport-jwt'; +import * as Utils from '@/lib/utils'; import * as Config from '@/lib/config'; import * as PassportLocal from 'passport-local'; import passport from 'passport'; @@ -42,7 +43,7 @@ passport.use( (jwtPayload: Types.AppJwtPayload, done) => { const { id } = jwtPayload; db.user - .findUnique({ where: { id } }) + .findUnique({ where: { id }, ...Utils.userAggregation }) .then((user) => { if (user) done(null, user); else done(null, false); diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..1b7e8ba --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,67 @@ +import * as Types from '@/types'; +import * as AppError from '@/lib/app-error'; +import { createClient } from '@supabase/supabase-js'; +import { Image } from '@/../prisma/client'; + +if ( + !process.env.STORAGE_URL || + !process.env.STORAGE_KEY || + !process.env.STORAGE_BUCKET || + !process.env.STORAGE_ROOT_DIR || + !process.env.STORAGE_BUCKET_URL +) { + throw new Error( + 'StorageEnvVarMissed: STORAGE_URL, STORAGE_KEY, STORAGE_BUCKET, STORAGE_BUCKET_URL, or STORAGE_ROOT_DIR' + ); +} + +export const STORAGE_URL = process.env.STORAGE_URL; +export const STORAGE_KEY = process.env.STORAGE_KEY; +export const STORAGE_BUCKET = process.env.STORAGE_BUCKET; +export const STORAGE_ROOT_DIR = process.env.STORAGE_ROOT_DIR; +export const STORAGE_BUCKET_URL = process.env.STORAGE_BUCKET_URL; + +export const supabase = createClient(STORAGE_URL, STORAGE_KEY); + +export const uploadImage = async ( + imageFile: Types.ImageFile, + user: Types.PublicUser, + imageData?: Image | null +) => { + let bucket = STORAGE_BUCKET, + upsert = false, + filePath: string; + if (imageData) { + const [bucketName, ...splittedPath] = imageData.storageFullPath.split('/'); + filePath = splittedPath.join('/'); + bucket = bucketName; + upsert = true; + } else { + const randomSuffix = Math.round(Math.random() * Date.now()) % 10 ** 8; + const uniqueFileName = `${user.id}-${randomSuffix}${imageFile.ext}`; + filePath = `${STORAGE_ROOT_DIR}/${user.username}/${uniqueFileName}`; + } + const { data, error } = await supabase.storage + .from(bucket) + .upload(filePath, imageFile.buffer, { + contentType: imageFile.mimetype, + upsert, + }); + if (error) throw new AppError.AppBaseError(error.message, 500, error.name); + return { ...data, publicUrl: `${STORAGE_BUCKET_URL}/${data.path}` }; +}; + +export type UploadedImageData = Awaited>; + +export const removeImage = async (imageData: Image) => { + const [bucketName, ...splittedPath] = imageData.storageFullPath.split('/'); + const filePath = splittedPath.join('/'); + const bucket = bucketName; + const { data, error } = await supabase.storage + .from(bucket) + .remove([filePath]); + if (error) throw new AppError.AppBaseError(error.message, 500, error.name); + return data; +}; + +export type RemovedImageData = Awaited>; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 08d4a75..1aa1abd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,10 +1,12 @@ -import * as Exp from 'express'; import * as Types from '@/types'; import * as Config from '@/lib/config'; +import * as Storage from '@/lib/storage'; import * as AppError from '@/lib/app-error'; import { Prisma } from '@/../prisma/client'; +import { Request } from 'express'; import { z } from 'zod'; import ms from 'ms'; +import db from '@/lib/db'; import jwt from 'jsonwebtoken'; export const createJwtForUser = (user: Types.PublicUser): string => { @@ -52,26 +54,26 @@ export const handleDBKnownErrors = async ( return post; }; -export const getCurrentUserIdFromReq = (req: Exp.Request) => { +export const getCurrentUserIdFromReq = (req: Request) => { return (req.user as Types.PublicUser | undefined)?.id; }; -export const getTextFilterFromReqQuery = (req: Exp.Request) => { +export const getTextFilterFromReqQuery = (req: Request) => { return z.string().nonempty().safeParse(req.query.q).data; }; -export const getVoteTypeFilterFromReqQuery = (req: Exp.Request) => { +export const getVoteTypeFilterFromReqQuery = (req: Request) => { let isUpvote; if (req.query.upvote && !req.query.downvote) isUpvote = true; if (!req.query.upvote && req.query.downvote) isUpvote = false; return isUpvote; }; -export const getAuthorIdFilterFromReqQuery = (req: Exp.Request) => { +export const getAuthorIdFilterFromReqQuery = (req: Request) => { return z.string().uuid().optional().safeParse(req.query.author).data; }; -export const getTagsFilterFromReqQuery = (req: Exp.Request) => { +export const getTagsFilterFromReqQuery = (req: Request) => { /* E.g. `...?tags=x,y,z`, or `...?tags=x&blah=0&tags=y,z` */ const strTagsSchema = z .string() @@ -83,7 +85,7 @@ export const getTagsFilterFromReqQuery = (req: Exp.Request) => { }; export const getPaginationFiltersFromReqQuery = ( - req: Exp.Request + req: Request ): Types.PaginationFilters => { const { cursor, sort, limit } = req.query; return { @@ -94,7 +96,7 @@ export const getPaginationFiltersFromReqQuery = ( }; export const getCommonFiltersFromReqQuery = ( - req: Exp.Request + req: Request ): Types.PaginationFilters => { return { currentUserId: getCurrentUserIdFromReq(req), @@ -104,7 +106,7 @@ export const getCommonFiltersFromReqQuery = ( }; export const getCommentFiltersFromReqQuery = ( - req: Exp.Request + req: Request ): Types.CommentFilters => { return { ...getCommonFiltersFromReqQuery(req), @@ -112,16 +114,14 @@ export const getCommentFiltersFromReqQuery = ( }; }; -export const getVoteFiltersFromReqQuery = (req: Exp.Request) => { +export const getVoteFiltersFromReqQuery = (req: Request) => { return { ...getCommonFiltersFromReqQuery(req), isUpvote: getVoteTypeFilterFromReqQuery(req), }; }; -export const getPostFiltersFromReqQuery = ( - req: Exp.Request -): Types.PostFilters => { +export const getPostFiltersFromReqQuery = (req: Request): Types.PostFilters => { // Same as the comments filtration + tags filter return { ...getCommentFiltersFromReqQuery(req), @@ -145,8 +145,18 @@ export const getPaginationArgs = ( }; }; +export const userAggregation: Types.UserAggregation = { + include: { + avatar: { + select: { image: { omit: { storageId: true, storageFullPath: true } } }, + }, + }, + omit: { password: true }, +}; + export const fieldsToIncludeWithImage: Types.ImageDataToAggregate = { - owner: { omit: { password: true } }, + _count: { select: { posts: true } }, + owner: userAggregation, }; export const fieldsToIncludeWithPost = { @@ -154,10 +164,39 @@ export const fieldsToIncludeWithPost = { votes: { include: { user: true }, ...getPaginationArgs() }, comments: { include: { author: true }, ...getPaginationArgs() }, image: { include: fieldsToIncludeWithImage }, + author: userAggregation, tags: true, - author: true, }; -export const fieldsToIncludeWithComment = { post: true, author: true }; +export const fieldsToIncludeWithComment = { + post: true, + author: userAggregation, +}; + +export const fieldsToIncludeWithVote = { post: true, user: userAggregation }; -export const fieldsToIncludeWithVote = { post: true, user: true }; +export const PURGE_INTERVAL_MS = 12 * 60 * 60 * 1000; + +export const purgeNonAdminData = async (now: number, interval: number) => { + const createdAt = { lte: new Date(now - interval) }; + const author = { isAdmin: false }; + const images = await db.image.findMany({ + omit: { storageFullPath: false, storageId: false }, + where: { owner: author, createdAt }, + }); + for (const image of images) { + await db.$transaction(async (transClient) => { + await Storage.removeImage(image); + return await transClient.image.delete({ where: { id: image.id } }); + }); + } + await db.$transaction([ + db.comment.deleteMany({ where: { author, createdAt } }), + db.votesOnPosts.deleteMany({ where: { post: { author, createdAt } } }), + db.tagsOnPosts.deleteMany({ where: { post: { author, createdAt } } }), + db.post.deleteMany({ where: { author, createdAt } }), + db.user.deleteMany({ where: { ...author, createdAt } }), + ]); + const tags = (await db.tagsOnPosts.findMany({})).map((t) => t.name); + await db.tag.deleteMany({ where: { name: { notIn: tags } } }); +}; diff --git a/src/middlewares/error-handler.ts b/src/middlewares/error-handler.ts index fef54a6..c387e14 100644 --- a/src/middlewares/error-handler.ts +++ b/src/middlewares/error-handler.ts @@ -10,11 +10,7 @@ export const errorHandler = ( res: Response, _next: NextFunction ) => { - if (error instanceof Error) { - logger.error(error.message, error); - } else { - logger.error(error); - } + logger.error(error); let errorRes: AppErrorResponse; if (error instanceof ZodError) { res.status(400).json(error.issues); diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index 3932a8d..acfddf4 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -2,3 +2,4 @@ export * from './validators'; export * from './error-handler'; export * from './file-processor'; export * from './request-logger'; +export * from './non-admin-data-purger'; diff --git a/src/middlewares/non-admin-data-purger.ts b/src/middlewares/non-admin-data-purger.ts new file mode 100644 index 0000000..fe7d2c9 --- /dev/null +++ b/src/middlewares/non-admin-data-purger.ts @@ -0,0 +1,27 @@ +import { NextFunction, Request, Response } from 'express'; +import * as Utils from '@/lib/utils'; +import logger from '@/lib/logger'; + +export function createNonAdminDataPurger(initLastPurgeTime = 0) { + let lastPurgeTime = initLastPurgeTime; + + return async (req: Request, res: Response, next: NextFunction) => { + try { + const now = Date.now(); + if ( + (req.method === 'GET' || req.originalUrl.includes('/auth')) && + (!lastPurgeTime || now - lastPurgeTime >= Utils.PURGE_INTERVAL_MS) + ) { + await Utils.purgeNonAdminData(now, Utils.PURGE_INTERVAL_MS); + logger.info('All non-admin data has been purged'); + lastPurgeTime = now; + } + } catch (error) { + logger.error('Could not purge the non-admin data -', error); + } finally { + next(); + } + }; +} + +export default createNonAdminDataPurger; diff --git a/src/middlewares/request-logger.ts b/src/middlewares/request-logger.ts index e2fdd92..4e7145a 100644 --- a/src/middlewares/request-logger.ts +++ b/src/middlewares/request-logger.ts @@ -6,8 +6,17 @@ export const requestLogger = ( res: Response, next: NextFunction ) => { - const body = req.body as unknown; - logger.info(`${req.method}: ${req.originalUrl}`, body && { body }); + const start = process.hrtime(); + + res.on('finish', () => { + const [seconds, nanoseconds] = process.hrtime(start); + const duration = (seconds * 1e3 + nanoseconds / 1e6).toFixed(3); + const len = res.getHeader('Content-Length') ?? 0; + const { originalUrl: url, method } = req; + const status = res.statusCode; + logger.http(`${status} ${method} ${url} ${len} - ${duration} ms`); + }); + next(); }; diff --git a/src/server.ts b/src/server.ts index 7325734..9b7490a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,6 @@ import app from '@/app'; const PORT = Number(process.env.PORT) || 3001; app.listen(PORT, (error) => { - if (error) logger.error(error.message, error); + if (error) logger.error(error); logger.info(`The server is running on prot ${PORT}...`); }); diff --git a/src/tests/api/setup.ts b/src/tests/api/setup.ts index b7417f2..2dcf1da 100644 --- a/src/tests/api/setup.ts +++ b/src/tests/api/setup.ts @@ -1,16 +1,50 @@ +import * as API from '@/api'; import * as Types from '@/types'; +import * as Image from '@/lib/image'; import * as Utils from '@/lib/utils'; +import * as Middlewares from '@/middlewares'; +import { default as express } from 'express'; import { Prisma } from '@/../prisma/client'; -import { expect } from 'vitest'; +import { App } from 'supertest/types'; +import { BASE_URL } from './v1/utils'; +import { expect, vi } from 'vitest'; import { z } from 'zod'; -import app from '@/app'; import db from '@/lib/db'; import path from 'node:path'; import bcrypt from 'bcryptjs'; import supertest from 'supertest'; -export const setup = async (signinUrl: string) => { - const api = supertest(app); +const storageData = vi.hoisted(() => { + const uploadedData = { + fullPath: 'test-file-full-path.jpg', + path: 'test-file-path.jpg', + id: 'test-file-id', + }; + const uploadRes = { data: uploadedData, error: null }; + const removeRes = { data: [], error: null }; + const remove = vi.fn( + () => new Promise((resolve) => setImmediate(() => resolve(removeRes))) + ); + const upload = vi.fn( + () => new Promise((resolve) => setImmediate(() => resolve(uploadRes))) + ); + const from = vi.fn(() => ({ upload, remove })); + const storage = { client: { from }, upload, remove }; + return { uploadedData, uploadRes, removeRes, storage }; +}); + +vi.mock('@supabase/supabase-js', () => { + const storage = storageData.storage.client; + return { createClient: vi.fn(() => ({ storage })) }; +}); + +const app = express() + .use(express.json()) + .use(BASE_URL, API.V1.apiRouter) + .use(Middlewares.errorHandler); + +export const setup = async (signinUrl: string, expApp: App = app) => { + const api = supertest(expApp); const deleteAllPosts = async () => await db.post.deleteMany({}); const deleteAllUsers = async () => await db.user.deleteMany({}); @@ -20,8 +54,9 @@ export const setup = async (signinUrl: string) => { const createUser = async (data: Prisma.UserCreateInput) => { const password = bcrypt.hashSync(data.password, 10); return await db.user.create({ - data: { ...data, password }, omit: { password: false, isAdmin: false }, + include: { avatar: true, images: true }, + data: { ...data, password }, }); }; @@ -89,16 +124,24 @@ export const setup = async (signinUrl: string) => { width: 2048, }; - const imgData = { - ...imgOne, + const imagedata = { + info: 'blah blah blah...', alt: 'test-img-alt', scale: 1.25, xPos: 10, yPos: 25, }; - const createImage = async (imageData: Prisma.ImageCreateManyInput) => { + const imgData = { + ...imgOne, + ...imagedata, + }; + + const createImage = async ( + imageData: Prisma.ImageCreateManyInput & Prisma.ImageUncheckedCreateInput + ) => { return await db.image.upsert({ + include: Image.FIELDS_TO_INCLUDE, where: { src: imageData.src }, create: imageData, update: imageData, @@ -109,7 +152,7 @@ export const setup = async (signinUrl: string) => { return await db.image.createMany({ data: imageData }); }; - const postDataInput: Types.NewPostParsedData = { + const postDataInput: Types.NewPostParsedDataWithoutImage = { published: true, title: 'Test Post', content: 'Test post content...', @@ -134,11 +177,11 @@ export const setup = async (signinUrl: string) => { const commentData = { content: 'Keep it up' }; - const createPost = async (data: typeof postFullData) => { + const createPost = async (data: typeof postFullData, imageId?: string) => { return await db.post.create({ data: { + imageId, title: data.title, - imageId: data.image, content: data.content, authorId: data.authorId, published: data.published, @@ -176,13 +219,13 @@ export const setup = async (signinUrl: string) => { const assertPostData = ( actualPost: Types.PostFullData, - expectedPost: typeof postFullData & { image?: string } + expectedPost: typeof postFullData & { imageId?: string } ) => { expect(actualPost.title).toBe(expectedPost.title); expect(actualPost.content).toBe(expectedPost.content); expect(actualPost.authorId).toBe(expectedPost.authorId); expect(actualPost.published).toBe(expectedPost.published); - expect(actualPost.imageId).toStrictEqual(expectedPost.image ?? null); + expect(actualPost.imageId).toStrictEqual(expectedPost.imageId ?? null); expect(actualPost.comments.length).toBe(expectedPost.comments.length); expect(actualPost.tags.length).toBe(expectedPost.tags.length); expect(actualPost.tags.map(({ name }) => name.toLowerCase())).toStrictEqual( @@ -261,10 +304,12 @@ export const setup = async (signinUrl: string) => { userTwoData, commentData, newUserData, + storageData, adminData, xUserData, dbUserOne, dbUserTwo, + imagedata, userData, dbAdmin, dbXUser, diff --git a/src/tests/api/v1/auth.int.test.ts b/src/tests/api/v1/auth.int.test.ts index 3d7d1dc..782eb4f 100644 --- a/src/tests/api/v1/auth.int.test.ts +++ b/src/tests/api/v1/auth.int.test.ts @@ -54,16 +54,16 @@ describe('Authentication endpoint', async () => { it('should sign in and response with JWT and user insensitive-info', async () => { const res = await api.post(SIGNIN_URL).send(userData); const resBody = res.body as AuthResponse; - const resUser = resBody.user as User; + const resUser = resBody.user; const resJwtPayload = jwt.decode( resBody.token.replace(/^Bearer /, '') ) as User; expect(res.type).toMatch(/json/); expect(res.statusCode).toBe(200); + expect(Object.keys(resUser)).not.toContain('password'); expect(resUser.username).toBe(userData.username); expect(resUser.fullname).toBe(userData.fullname); expect(resUser.isAdmin).toStrictEqual(false); - expect(resUser.password).toBeUndefined(); expect(resBody.token).toMatch(/^Bearer /i); expect(resJwtPayload.id).toStrictEqual(dbUser.id); expect(resJwtPayload.isAdmin).toStrictEqual(false); diff --git a/src/tests/api/v1/images.int.test.ts b/src/tests/api/v1/images.int.test.ts index 42d9aa7..9dd0cc9 100644 --- a/src/tests/api/v1/images.int.test.ts +++ b/src/tests/api/v1/images.int.test.ts @@ -9,41 +9,22 @@ import { } from 'vitest'; import { AppErrorResponse } from '@/types'; import { IMAGES_URL, SIGNIN_URL } from './utils'; +import { Image } from 'prisma/client'; import setup from '../setup'; import fs from 'node:fs'; +import db from '@/lib/db'; -const { storage, upload, remove } = vi.hoisted(() => { - const uploadedData = { - fullPath: 'test-file-full-path.jpg', - path: 'test-file-path.jpg', - id: 'test-file-id', - }; - const uploadRes = { data: uploadedData, error: null }; - const removeRes = { data: [], error: null }; - const remove = vi.fn( - () => new Promise((resolve) => setImmediate(() => resolve(removeRes))) - ); - const upload = vi.fn( - () => new Promise((resolve) => setImmediate(() => resolve(uploadRes))) - ); - const from = vi.fn(() => ({ upload, remove })); - const storage = { from }; - return { uploadedData, uploadRes, removeRes, storage, upload, remove, from }; -}); - -vi.mock('@supabase/supabase-js', () => { - return { createClient: vi.fn(() => ({ storage })) }; -}); - -describe('Images endpoint', async () => { +describe('Image endpoints', async () => { const { api, imgOne, imgTwo, imgData, + imagedata, adminData, userOneData, userTwoData, + storageData, createImage, assertErrorRes, deleteAllUsers, @@ -57,7 +38,13 @@ describe('Images endpoint', async () => { assertResponseWithValidationError, } = await setup(SIGNIN_URL); - const { authorizedApi } = await prepForAuthorizedTest(userOneData); + const { + storage: { upload, remove }, + } = storageData; + + const { authorizedApi, signedInUserData } = await prepForAuthorizedTest( + userOneData + ); let url: string; const prepImageUrl = async () => { @@ -76,16 +63,29 @@ describe('Images endpoint', async () => { }); describe(`GET ${IMAGES_URL}`, () => { - it('should respond with an empty array', async () => { + it('should respond with 401 on an unauthenticated request', async () => { const res = await api.get(IMAGES_URL); + assertUnauthorizedErrorRes(res); + }); + + it('should respond with 401 on a request with user token', async () => { + const { authorizedApi } = await prepForAuthorizedTest(userOneData); + const res = await authorizedApi.get(IMAGES_URL); + assertUnauthorizedErrorRes(res); + }); + + it('should respond with an empty array on a request with admin token', async () => { + const { authorizedApi } = await prepForAuthorizedTest(adminData); + const res = await authorizedApi.get(IMAGES_URL); expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); expect(res.body).toStrictEqual([]); }); - it('should respond with an array of image objects', async () => { + it('should respond with an array of image objects on a request with admin token', async () => { await createManyImages([imgOne, imgTwo]); - const res = await api.get(IMAGES_URL); + const { authorizedApi } = await prepForAuthorizedTest(adminData); + const res = await authorizedApi.get(IMAGES_URL); expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); expect(res.body).toHaveLength(2); @@ -162,10 +162,7 @@ describe('Images endpoint', async () => { stream = fs.createReadStream('src/tests/files/good.jpg'); const res = await authorizedApi .post(IMAGES_URL) - .field('scale', imgData.scale) - .field('xPos', imgData.xPos) - .field('yPos', imgData.yPos) - .field('alt', imgData.alt) + .field(imagedata) .attach('image', stream); expect(res.statusCode).toBe(201); assertImageData(res, imgData); @@ -175,12 +172,12 @@ describe('Images endpoint', async () => { it('should upload the image with data and truncate the given position values', async () => { stream = fs.createReadStream('src/tests/files/good.jpg'); + const { xPos, yPos, ...data } = imagedata; const res = await authorizedApi .post(IMAGES_URL) - .field('xPos', imgData.xPos + 0.25) - .field('yPos', imgData.yPos + 0.75) - .field('scale', imgData.scale) - .field('alt', imgData.alt) + .field(data) + .field('xPos', xPos + 0.25) + .field('yPos', yPos + 0.75) .attach('image', stream); expect(res.statusCode).toBe(201); assertImageData(res, imgData); @@ -228,6 +225,45 @@ describe('Images endpoint', async () => { assertResponseWithValidationError(res, 'scale'); expect(upload).not.toHaveBeenCalledOnce(); }); + + it('should upload the avatar image and connect it to the current user', async () => { + const userId = signedInUserData.user.id; + stream = fs.createReadStream('src/tests/files/good.jpg'); + const res = await authorizedApi + .post(IMAGES_URL) + .field('isAvatar', true) + .attach('image', stream); + const dbAvatar = (await db.avatar.findMany({})).at(-1)!; + expect((res.body as Image).id).toBe(dbAvatar.imageId); + expect(dbAvatar.userId).toBe(userId); + assertImageData(res, imgOne); + expect(res.statusCode).toBe(201); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls.at(-1)?.at(-1)).toHaveProperty('upsert', false); + }); + + it('should upload the image and disconnect the user form an old image before reconnecting it', async () => { + const userId = signedInUserData.user.id; + await createImage({ + ...imgTwo, + ownerId: userId, + avatars: { create: { userId } }, + }); + stream = fs.createReadStream('src/tests/files/good.jpg'); + const res = await authorizedApi + .post(IMAGES_URL) + .field('isAvatar', true) + .attach('image', stream); + const dbAvatars = await db.avatar.findMany({}); + const avatar = dbAvatars.at(-1)!; + expect((res.body as Image).id).toBe(avatar.imageId); + expect(avatar.userId).toBe(userId); + expect(dbAvatars).toHaveLength(1); + expect(res.statusCode).toBe(201); + assertImageData(res, imgOne); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls.at(-1)?.at(-1)).toHaveProperty('upsert', false); + }); }); describe(`PUT ${IMAGES_URL}/:id`, () => { @@ -282,10 +318,7 @@ describe('Images endpoint', async () => { stream = fs.createReadStream('src/tests/files/good.jpg'); const res = await authorizedApi .put(url) - .field('scale', imgData.scale) - .field('xPos', imgData.xPos) - .field('yPos', imgData.yPos) - .field('alt', imgData.alt) + .field(imagedata) .attach('image', stream); expect(res.statusCode).toBe(200); assertImageData(res, imgData); @@ -295,12 +328,12 @@ describe('Images endpoint', async () => { it('should update the image with data and truncate the given position values', async () => { stream = fs.createReadStream('src/tests/files/good.jpg'); + const { xPos, yPos, ...data } = imagedata; const res = await authorizedApi .put(url) - .field('xPos', imgData.xPos + 0.25) - .field('yPos', imgData.yPos + 0.75) - .field('scale', imgData.scale) - .field('alt', imgData.alt) + .field(data) + .field('xPos', xPos + 0.25) + .field('yPos', yPos + 0.75) .attach('image', stream); expect(res.statusCode).toBe(200); assertImageData(res, imgData); @@ -359,6 +392,22 @@ describe('Images endpoint', async () => { assertResponseWithValidationError(res, 'scale'); expect(upload).not.toHaveBeenCalledOnce(); }); + + it('should update the avatar image and connect it to the current user', async () => { + const userId = signedInUserData.user.id; + stream = fs.createReadStream('src/tests/files/good.jpg'); + const res = await authorizedApi + .put(url) + .field('isAvatar', true) + .attach('image', stream); + const dbAvatar = (await db.avatar.findMany({})).at(-1)!; + expect((res.body as Image).id).toBe(dbAvatar.imageId); + expect(res.statusCode).toBe(200); + assertImageData(res, imgOne); + expect(dbAvatar.userId).toBe(userId); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls.at(-1)?.at(-1)).toHaveProperty('upsert', true); + }); }); describe(`Delete ${IMAGES_URL}/:id`, () => { diff --git a/src/tests/api/v1/posts.int.test.ts b/src/tests/api/v1/posts.int.test.ts index 37342e7..98651cc 100644 --- a/src/tests/api/v1/posts.int.test.ts +++ b/src/tests/api/v1/posts.int.test.ts @@ -1,23 +1,27 @@ import { Tag, Comment, VotesOnPosts, TagsOnPosts } from '@/../prisma/client'; -import { describe, it, expect, beforeEach, afterAll } from 'vitest'; -import { PostFullData, PublicImage } from '@/types'; +import { PostFullData, PublicImage, AppErrorResponse } from '@/types'; +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; import { POSTS_URL, SIGNIN_URL } from './utils'; import { ZodIssue } from 'zod'; import setup from '../setup'; import db from '@/lib/db'; +import fs from 'node:fs'; -describe('Posts endpoint', async () => { +describe('Post endpoints', async () => { const { postDataOutput, postDataInput, postFullData, userOneData, userTwoData, + storageData, commentData, adminData, xUserData, dbUserOne, dbUserTwo, + imagedata, + imgData, dbAdmin, dbXUser, imgOne, @@ -29,15 +33,21 @@ describe('Posts endpoint', async () => { assertPostData, deleteAllPosts, deleteAllUsers, + assertImageData, prepForAuthorizedTest, assertNotFoundErrorRes, assertInvalidIdErrorRes, assertResponseWithValidationError, } = await setup(SIGNIN_URL); + const { + storage: { upload, remove }, + } = storageData; + let dbImgOne: Omit; beforeEach(async () => { + vi.clearAllMocks(); await deleteAllPosts(); await deleteAllTags(); dbImgOne = await createImage(imgOne); @@ -333,7 +343,7 @@ describe('Posts endpoint', async () => { }; const SUCCESS_CODE = forUpdating ? 200 : 201; - const VERB = forUpdating ? 'update' : 'create'; + const action = forUpdating ? 'update' : 'create'; it('should respond with 401 on a request without valid JWT', async () => { const res = await sendRequest(postDataInput, false); @@ -375,7 +385,7 @@ describe('Posts endpoint', async () => { }); } - it(`should not ${VERB} a post without title`, async () => { + it(`should not ${action} a post without title`, async () => { const res = await sendRequest({ ...postDataInput, title: '' }); const resBody = res.body as ZodIssue[]; expect(res.statusCode).toBe(400); @@ -383,7 +393,7 @@ describe('Posts endpoint', async () => { expect(resBody[0].message).toMatch(/title/i); }); - it(`should not ${VERB} a post without content`, async () => { + it(`should not ${action} a post without content`, async () => { const res = await sendRequest({ ...postDataInput, content: '' }); const resBody = res.body as ZodIssue[]; expect(res.statusCode).toBe(400); @@ -391,7 +401,7 @@ describe('Posts endpoint', async () => { expect(resBody[0].message).toMatch(/content|body/i); }); - it(`should ${VERB} a post even with duplicated tags but not save duplication`, async () => { + it(`should ${action} a post even with duplicated tags but not save duplication`, async () => { const tags = [ ...postDataInput.tags.map((c) => c.toLowerCase()), ...postDataInput.tags.map((c) => c.toUpperCase()), @@ -410,7 +420,7 @@ describe('Posts endpoint', async () => { }); }); - it(`should ${VERB} a post even without tags`, async () => { + it(`should ${action} a post even without tags`, async () => { const res = await sendRequest({ ...postDataInput, tags: undefined, @@ -428,7 +438,7 @@ describe('Posts endpoint', async () => { }); }); - it(`should ${VERB} a published post`, async () => { + it(`should ${action} a published post`, async () => { const res = await sendRequest({ ...postDataInput, published: true }); const resBody = res.body as PostFullData; expect(res.statusCode).toBe(SUCCESS_CODE); @@ -443,7 +453,7 @@ describe('Posts endpoint', async () => { }); }); - it(`should ${VERB} a post with all tags converted to lowercase`, async () => { + it(`should ${action} a post with all tags converted to lowercase`, async () => { const res = await sendRequest({ ...postDataInput, tags: postDataInput.tags.map((c) => c.toUpperCase()), @@ -461,7 +471,7 @@ describe('Posts endpoint', async () => { }); }); - it(`should not ${VERB} a post with more than 7 tags`, async () => { + it(`should not ${action} a post with more than 7 tags`, async () => { const res = await sendRequest({ ...postDataInput, tags: Array.from({ length: 8 }).map((_, i) => `Cat_${i}`), @@ -469,11 +479,17 @@ describe('Posts endpoint', async () => { assertResponseWithValidationError(res, 'tags'); }); - it(`should ${VERB} a post with an image`, async () => { - const res = await sendRequest({ - ...postDataInput, - image: dbImgOne.id, - }); + it(`should ${action} a post without image, and ignore 'imagedata' field if present`, async () => { + const postData = { ...postDataInput, imagedata }; + let res; + if (forUpdating) { + const dbPost = await createPost(postDataToUpdate); + res = await authorizedApi + .put(`${POSTS_URL}/${dbPost.id}`) + .send(postData); + } else { + res = await authorizedApi.post(POSTS_URL).send(postData); + } const resBody = res.body as PostFullData; expect(res.statusCode).toBe(SUCCESS_CODE); expect(res.type).toMatch(/json/); @@ -483,15 +499,39 @@ describe('Posts endpoint', async () => { assertPostData(resBody, { ...postDataOutput, authorId: signedInUserData.user.id, - image: dbImgOne.id, + imageId: undefined, }); }); - it(`should ${VERB} a post without image id`, async () => { - const res = await sendRequest({ - ...postDataInput, - image: undefined, - }); + it(`should ${action} a post with an image`, async () => { + const stream = fs.createReadStream('src/tests/files/good.jpg'); + const updatedImagedata = forUpdating + ? { ...imagedata, xPos: 23, yPos: 19, alt: 'updated' } + : imagedata; + const preparedImagedata = Object.fromEntries( + Object.entries(updatedImagedata).map(([k, v]) => [ + `imagedata[${k}]`, + v, + ]) + ); + let res; + if (forUpdating) { + const dbPost = await createPost(postDataToUpdate); + res = await authorizedApi + .put(`${POSTS_URL}/${dbPost.id}`) + .field(postDataInput) + .field(preparedImagedata) + .attach('image', stream); + } else { + res = await authorizedApi + .post(POSTS_URL) + .field(postDataInput) + .field(preparedImagedata) + .attach('image', stream); + } + const dbImg = ( + await db.image.findMany({ orderBy: { order: 'desc' }, take: 1 }) + )[0]; const resBody = res.body as PostFullData; expect(res.statusCode).toBe(SUCCESS_CODE); expect(res.type).toMatch(/json/); @@ -501,24 +541,60 @@ describe('Posts endpoint', async () => { assertPostData(resBody, { ...postDataOutput, authorId: signedInUserData.user.id, - image: undefined, + imageId: dbImg.id, + }); + assertImageData(Object.assign(res, { body: resBody.image }), { + ...imgData, + ...updatedImagedata, }); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls.at(-1)?.at(-1)).toHaveProperty( + 'upsert', + false + ); }); - it(`should respond with 400 on {VERB} request with invalid image id`, async () => { - const res = await sendRequest({ - ...postDataInput, - image: `${crypto.randomUUID()}x_@`, - }); - assertResponseWithValidationError(res, 'image'); + it(`should respond with 400 on ${action} request with invalid image type`, async () => { + const stream = fs.createReadStream('src/tests/files/ugly.txt'); + let res; + if (forUpdating) { + const dbPost = await createPost(postDataToUpdate); + res = await authorizedApi + .put(`${POSTS_URL}/${dbPost.id}`) + .field(postDataInput) + .attach('image', stream); + } else { + res = await authorizedApi + .post(POSTS_URL) + .field(postDataInput) + .attach('image', stream); + } + const resBody = res.body as AppErrorResponse; + expect(res.type).toMatch(/json/); + expect(res.statusCode).toBe(400); + expect(resBody.error.message).toMatch(/invalid image/i); + expect(upload).not.toHaveBeenCalledOnce(); }); - it(`should respond with 400 on {VERB} request with unknown image id`, async () => { - const res = await sendRequest({ - ...postDataInput, - image: crypto.randomUUID(), - }); - assertInvalidIdErrorRes(res); + it('should respond with 400 on request with too large image', async () => { + const stream = fs.createReadStream('src/tests/files/bad.jpg'); + let res; + if (forUpdating) { + const dbPost = await createPost(postDataToUpdate); + res = await authorizedApi + .put(`${POSTS_URL}/${dbPost.id}`) + .field(postDataInput) + .attach('image', stream); + } else { + res = await authorizedApi + .post(POSTS_URL) + .field(postDataInput) + .attach('image', stream); + } + const resBody = res.body as AppErrorResponse; + expect(res.statusCode).toBe(400); + expect(res.type).toMatch(/json/); + expect(resBody.error.message).toMatch(/too large/i); }); }; }; @@ -742,11 +818,8 @@ describe('Posts endpoint', async () => { assertInvalidIdErrorRes(res); }); - it(`should delete the post and its image if it is owned by the post author`, async () => { - const dbPost = await createPost({ - ...postDataToDelete, - image: dbImgOne.id, - }); + it(`should delete the post and its image`, async () => { + const dbPost = await createPost(postDataToDelete, dbImgOne.id); const res = await authorizedApi.delete(`${POSTS_URL}/${dbPost.id}`); expect(res.statusCode).toBe(204); expect(res.body).toStrictEqual({}); @@ -756,45 +829,7 @@ describe('Posts endpoint', async () => { expect( await db.image.findUnique({ where: { id: dbImgOne.id } }) ).toBeNull(); - }); - - it(`should delete the post without its image if it is not owned by the post author`, async () => { - const { authorizedApi, signedInUserData } = - await prepForAuthorizedTest(xUserData); - const dbPost = await createPost({ - ...postDataToDelete, - image: dbImgOne.id, - authorId: signedInUserData.user.id, - }); - const res = await authorizedApi.delete(`${POSTS_URL}/${dbPost.id}`); - expect(res.statusCode).toBe(204); - expect(res.body).toStrictEqual({}); - expect( - await db.post.findUnique({ where: { id: dbPost.id } }) - ).toBeNull(); - expect( - (await db.image.findUnique({ where: { id: dbImgOne.id } }))?.src - ).toStrictEqual(dbImgOne.src); - }); - - it(`should delete the post without its image if it is in use on another post`, async () => { - await createPost({ - ...postDataToDelete, - image: dbImgOne.id, - }); - const dbPost = await createPost({ - ...postDataToDelete, - image: dbImgOne.id, - }); - const res = await authorizedApi.delete(`${POSTS_URL}/${dbPost.id}`); - expect(res.statusCode).toBe(204); - expect(res.body).toStrictEqual({}); - expect( - await db.post.findUnique({ where: { id: dbPost.id } }) - ).toBeNull(); - expect( - (await db.image.findUnique({ where: { id: dbImgOne.id } }))?.src - ).toStrictEqual(dbImgOne.src); + expect(remove).toHaveBeenCalledOnce(); }); } }; @@ -1780,34 +1815,28 @@ describe('Posts endpoint', async () => { ); describe(`GET ${POSTS_URL}/tags/count`, () => { - it('should respond with 401 on a request without JWT', async () => { - const res = await api.get(`${POSTS_URL}/tags/count`); - expect(res.statusCode).toBe(401); - expect(res.body).toStrictEqual({}); - }); - - it('should respond with the count of tags for the current signed-in user', async () => { - await createPost({ ...postFullData, authorId: dbUserOne.id }); - await createPost({ ...postFullData, authorId: dbUserOne.id }); - await createPost({ ...postFullData, authorId: dbUserTwo.id }); + it('should respond with the count of all tags, event the ones that not connected to posts', async () => { + await db.tag.create({ data: { name: crypto.randomUUID() } }); + await createPost(postFullData); + await createPost(postFullData); const distinctTags = new Set(postFullData.tags); const res = await authorizedApi.get(`${POSTS_URL}/tags/count`); expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); - expect(res.body).toStrictEqual(distinctTags.size); + expect(res.body).toStrictEqual(distinctTags.size + 1); }); - it('should respond with 0 if the current signed-in user do not have any post tags', async () => { - await createPost({ - ...postDataOutput, - tags: [], - authorId: dbUserOne.id, - }); + it('should respond with the count of distinct tags for the given user', async () => { + await createPost({ ...postFullData, authorId: dbUserOne.id }); + await createPost({ ...postFullData, authorId: dbUserOne.id }); await createPost({ ...postFullData, authorId: dbUserTwo.id }); - const res = await authorizedApi.get(`${POSTS_URL}/tags/count`); + const distinctTags = new Set(postFullData.tags); + const res = await api.get( + `${POSTS_URL}/tags/count?author=${dbUserOne.id}` + ); expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); - expect(res.body).toStrictEqual(0); + expect(res.body).toStrictEqual(distinctTags.size); }); }); diff --git a/src/tests/api/v1/users.int.test.ts b/src/tests/api/v1/users.int.test.ts index f9be8b5..7f68106 100644 --- a/src/tests/api/v1/users.int.test.ts +++ b/src/tests/api/v1/users.int.test.ts @@ -7,23 +7,25 @@ import { beforeEach, TestFunction, } from 'vitest'; +import { AppErrorResponse, AuthResponse, PublicUser } from '@/types'; import { SIGNIN_URL, USERS_URL, ADMIN_SECRET } from './utils'; -import { AppErrorResponse, AuthResponse } from '@/types'; -import { Prisma, User } from '@/../prisma/client'; +import { Image, Prisma, User } from '@/../prisma/client'; import { z } from 'zod'; import db from '@/lib/db'; import setup from '../setup'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; -describe('Users endpoint', async () => { +describe('User endpoints', async () => { const { newUserData, xUserData, adminData, userData, + imgData, api, createUser, + createImage, deleteAllUsers, deleteAllPosts, prepForAuthorizedTest, @@ -90,17 +92,13 @@ describe('Users endpoint', async () => { const createPostNewUserTest = (isAdmin: boolean): TestFunction => { return async () => { - const res = await api.post(USERS_URL).send( - isAdmin - ? { - ...newUserData, - secret: ADMIN_SECRET, // Must be defined - } - : newUserData - ); + const res = await api.post(USERS_URL).send({ + ...newUserData, + ...(isAdmin ? { secret: ADMIN_SECRET } : {}), + }); const resBody = res.body as AuthResponse; // Pretend that the user is a `User` and tests should prove that it is a `PublicUser` - const resUser = resBody.user as User; + const resUser = resBody.user; const dbUser = await db.user.findUniqueOrThrow({ where: { id: resUser.id }, omit: { password: false }, @@ -110,7 +108,7 @@ describe('Users endpoint', async () => { ) as User; expect(res.type).toMatch(/json/); expect(res.statusCode).toBe(201); - expect(resUser.password).toBeUndefined(); + expect(resUser.avatar).toBeDefined(); expect(resUser.isAdmin).toStrictEqual(isAdmin); expect(resUser.username).toBe(newUserData.username); expect(resUser.fullname).toBe(newUserData.fullname); @@ -121,6 +119,7 @@ describe('Users endpoint', async () => { expect(resJwtPayload.updatedAt).toBeUndefined(); expect(resJwtPayload.username).toBeUndefined(); expect(resJwtPayload.fullname).toBeUndefined(); + expect(Object.keys(resUser)).not.toContain('password'); expect(dbUser.password).toMatch(/^\$2[a|b|x|y]\$.{56}/); expect(dbUser.isAdmin).toBe(isAdmin); expect(dbUser.bio).toBe(newUserData.bio); @@ -133,6 +132,36 @@ describe('Users endpoint', async () => { it('should create an admin user', createPostNewUserTest(true)); }); + describe(`POST ${USERS_URL}/guest`, () => { + it('should create a random user and sign it in', async () => { + const res = await api.post(`${USERS_URL}/guest`); + const resBody = res.body as AuthResponse; + const resUser = resBody.user; + const dbUser = (await db.user.findMany({ omit: { password: false } })).at( + -1 + ) as User; + const resJwtPayload = jwt.decode( + resBody.token.replace(/^Bearer /, '') + ) as User; + expect(res.type).toMatch(/json/); + expect(res.statusCode).toBe(201); + expect(resUser.avatar).toBeDefined(); + expect(resBody.token).toMatch(/^Bearer /i); + expect(resUser.isAdmin).toStrictEqual(false); + expect(resJwtPayload.fullname).toBeUndefined(); + expect(resJwtPayload.username).toBeUndefined(); + expect(resJwtPayload.createdAt).toBeUndefined(); + expect(resJwtPayload.updatedAt).toBeUndefined(); + expect(resJwtPayload.id).toStrictEqual(dbUser.id); + expect(resJwtPayload.isAdmin).toStrictEqual(false); + expect(Object.keys(resUser)).not.toContain('password'); + expect(dbUser.password).toMatch(/^\$2[a|b|x|y]\$.{56}/); + expect(resUser.bio).toBe(resUser.bio); + expect(dbUser.bio).toBe(resUser.bio); + expect(dbUser.isAdmin).toBe(false); + }); + }); + describe(`GET ${USERS_URL}`, () => { it('should respond with 401 on request without JWT', async () => { const res = await api.get(USERS_URL); @@ -151,10 +180,11 @@ describe('Users endpoint', async () => { const dbUser = await createUser(userData); const { authorizedApi } = await prepForAuthorizedTest(adminData); const res = await authorizedApi.get(USERS_URL); - const users = res.body as User[]; + const users = res.body as PublicUser[]; expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); expect(res.body).toHaveLength(2); + expect(users[1].avatar).toBeDefined(); expect(users[1].username).toBe(userData.username); expect(users[1].fullname).toBe(userData.fullname); await db.user.delete({ where: { id: dbUser.id } }); @@ -176,25 +206,20 @@ describe('Users endpoint', async () => { assertNotFoundErrorRes(res); }); - it('should respond with 401 on non-owner request with username', async () => { - await createUser(xUserData); + it('should find a user by id/username', async () => { const dbUser = await createUser(userData); - const { authorizedApi } = await prepForAuthorizedTest(xUserData); - const res = await authorizedApi.get(`${USERS_URL}/${dbUser.username}`); - assertUnauthorizedErrorRes(res); - }); - - it('should respond with the found user on request with id for anyone', async () => { - const dbUser = await createUser(userData); - const res = await api.get(`${USERS_URL}/${dbUser.id}`); - const resUser = res.body as User; - expect(res.statusCode).toBe(200); - expect(res.type).toMatch(/json/); - expect(resUser.id).toBe(dbUser.id); - expect(resUser.isAdmin).toStrictEqual(false); - expect(resUser.username).toBe(dbUser.username); - expect(resUser.fullname).toBe(dbUser.fullname); - expect(resUser.password).toBeUndefined(); + for (const param of [dbUser.id, dbUser.username]) { + const res = await api.get(`${USERS_URL}/${param}`); + const resUser = res.body as PublicUser; + expect(res.statusCode).toBe(200); + expect(res.type).toMatch(/json/); + expect(resUser.id).toBe(dbUser.id); + expect(resUser.avatar).toBeDefined(); + expect(resUser.isAdmin).toStrictEqual(false); + expect(resUser.username).toBe(dbUser.username); + expect(resUser.fullname).toBe(dbUser.fullname); + expect(Object.keys(resUser)).not.toContain('password'); + } }); it('should respond with the found user on owner request with id or username', async () => { @@ -202,14 +227,15 @@ describe('Users endpoint', async () => { const { authorizedApi } = await prepForAuthorizedTest(userData); for (const param of [dbUser.id, dbUser.username]) { const res = await authorizedApi.get(`${USERS_URL}/${param}`); - const resUser = res.body as User; + const resUser = res.body as PublicUser; expect(res.statusCode).toBe(200); expect(res.type).toMatch(/json/); expect(resUser.id).toBe(dbUser.id); + expect(resUser.avatar).toBeDefined(); expect(resUser.isAdmin).toStrictEqual(false); expect(resUser.username).toBe(dbUser.username); expect(resUser.fullname).toBe(dbUser.fullname); - expect(resUser.password).toBeUndefined(); + expect(Object.keys(resUser)).not.toContain('password'); } }); }); @@ -218,57 +244,87 @@ describe('Users endpoint', async () => { let longString = ''; for (let i = 0; i < 1000; i++) longString += 'x'; + let dbXImg: Omit; let dbUser: User; + + const getAllFields = () => { + return Object.entries({ + ...xUserData, + bio: 'Test bio', + avatarId: dbXImg.id, + }).map((k, v) => ({ k: v })); + }; + beforeEach(async () => { await createUser(adminData); dbUser = await createUser(userData); + const dbXUser = await createUser(xUserData); + await createImage({ + ...imgData, + ownerId: dbUser.id, + avatars: { create: { userId: dbUser.id } }, + }); + dbXImg = await createImage({ ...imgData, ownerId: dbXUser.id }); }); afterEach(deleteAllUsers); const createTestForUpdateField = ( - data: Prisma.UserUpdateInput & { confirm?: string; secret?: string }, + data: Prisma.UserUpdateInput & { + confirm?: string; + secret?: string; + avatarId?: string; + }, credentials: { username: string; password: string } ) => { return async () => { - const { authorizedApi } = await prepForAuthorizedTest(credentials); + const { + authorizedApi, + signedInUserData: { token }, + } = await prepForAuthorizedTest(credentials); const res = await authorizedApi .patch(`${USERS_URL}/${dbUser.id}`) - .send(data); - const updatedDBUser = await db.user.findUnique({ + .send({ ...data, avatarId: dbXImg.id }); + const updatedDBUser = (await db.user.findUnique({ where: { id: dbUser.id }, omit: { password: false }, - }); - expect(res.statusCode).toBe(204); - expect(updatedDBUser).toBeTruthy(); - if (updatedDBUser) { - const updatedFields = Object.keys(data); - if (updatedFields.includes('username')) { - expect(updatedDBUser.username).toBe(data.username); - } else { - expect(updatedDBUser.username).toBe(dbUser.username); - } - if (updatedFields.includes('fullname')) { - expect(updatedDBUser.fullname).toBe(data.fullname); - } else { - expect(updatedDBUser.fullname).toBe(dbUser.fullname); - } - if (updatedFields.includes('password')) { - expect( - bcrypt.compareSync( - data.password as string, - updatedDBUser.password - ) - ).toBe(true); - } else { - expect(updatedDBUser.password).toBe(dbUser.password); - } - if (updatedFields.includes('secret')) { - expect(updatedDBUser.isAdmin).toBe(data.secret === ADMIN_SECRET); - } - expect(+updatedDBUser.createdAt).toBe(+dbUser.createdAt); - expect(+updatedDBUser.updatedAt).toBeGreaterThan(+dbUser.updatedAt); + include: { avatar: { select: { image: true } } }, + })) as User & { avatar: { image: Image } }; + expect(res.statusCode).toBe(200); + expect(JSON.stringify((res.body as AuthResponse).user)).toBe( + JSON.stringify({ + ...updatedDBUser, + password: undefined, + }) + ); + expect((res.body as AuthResponse).token).toBe(token); + expect((res.body as AuthResponse).user.avatar?.image.id).toBe( + dbXImg.id + ); + expect(updatedDBUser.avatar.image.id).toBe(dbXImg.id); + const updatedFields = Object.keys(data); + if (updatedFields.includes('username')) { + expect(updatedDBUser.username).toBe(data.username); + } else { + expect(updatedDBUser.username).toBe(dbUser.username); + } + if (updatedFields.includes('fullname')) { + expect(updatedDBUser.fullname).toBe(data.fullname); + } else { + expect(updatedDBUser.fullname).toBe(dbUser.fullname); } + if (updatedFields.includes('password')) { + expect( + bcrypt.compareSync(data.password as string, updatedDBUser.password) + ).toBe(true); + } else { + expect(updatedDBUser.password).toBe(dbUser.password); + } + if (updatedFields.includes('secret')) { + expect(updatedDBUser.isAdmin).toBe(data.secret === ADMIN_SECRET); + } + expect(+updatedDBUser.createdAt).toBe(+dbUser.createdAt); + expect(+updatedDBUser.updatedAt).toBeGreaterThan(+dbUser.updatedAt); }; }; @@ -290,19 +346,20 @@ describe('Users endpoint', async () => { }; it('should respond with 401, on a request without JWT', async () => { - const res = await api - .patch(`${USERS_URL}/${dbUser.id}`) - .send({ username: 'foobar' }); - assertUnauthorizedErrorRes(res); + for (const field of getAllFields()) { + const res = await api.patch(`${USERS_URL}/${dbUser.id}`).send(field); + assertUnauthorizedErrorRes(res); + } }); it('should respond with 401, on a request with non-owner/admin JWT', async () => { - await createUser(xUserData); const { authorizedApi } = await prepForAuthorizedTest(xUserData); - const res = await authorizedApi - .patch(`${USERS_URL}/${dbUser.id}`) - .send({ username: 'foobar' }); - assertUnauthorizedErrorRes(res); + for (const field of getAllFields()) { + const res = await authorizedApi + .patch(`${USERS_URL}/${dbUser.id}`) + .send(field); + assertUnauthorizedErrorRes(res); + } }); it('should not change username if the given is already exists, on request with owner JWT', async () => { @@ -413,6 +470,11 @@ describe('Users endpoint', async () => { adminData ) ); + + it( + 'should change bio, on request with owner JWT', + createTestForUpdateField({ bio: 'Test bio' }, userData) + ); }); describe(`DELETE ${USERS_URL}/:id`, () => { diff --git a/src/tests/non-admin-data-purger.test.ts b/src/tests/non-admin-data-purger.test.ts new file mode 100644 index 0000000..f08d1df --- /dev/null +++ b/src/tests/non-admin-data-purger.test.ts @@ -0,0 +1,312 @@ +import db from '@/lib/db'; +import setup from './api/setup'; +import { BASE_URL, POSTS_URL, SIGNIN_URL } from './api/v1/utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { default as express, RequestHandler } from 'express'; +import * as Middlewares from '@/middlewares'; +import * as Utils from '@/lib/utils'; +import * as API from '@/api'; + +const createApp = (beforeRouteHandlers?: RequestHandler | RequestHandler[]) => { + const app = express(); + if (beforeRouteHandlers) { + app.use( + ...(Array.isArray(beforeRouteHandlers) + ? beforeRouteHandlers + : [beforeRouteHandlers]) + ); + } + app.use(BASE_URL, API.V1.apiRouter); + return app; +}; + +describe('Non-Admin Purge', () => { + afterEach(async () => { + vi.useRealTimers(); + await db.comment.deleteMany({}); + await db.votesOnPosts.deleteMany({}); + await db.tagsOnPosts.deleteMany({}); + await db.post.deleteMany({}); + await db.tag.deleteMany({}); + await db.image.deleteMany({}); + await db.user.deleteMany({}); + }); + + it('should purge only non-admin data on GET request after passing the purge interval', async () => { + const app = createApp(Middlewares.createNonAdminDataPurger()); + const { + api, + dbAdmin, + dbXUser, + imgData, + dbUserOne, + dbUserTwo, + createPost, + createImage, + postFullData, + } = await setup(SIGNIN_URL, app); + const nonAdminTags = ['non-admin-tag-1', 'non-admin-tag-2']; + const dbNonAdminPost = await createPost({ + ...postFullData, + authorId: dbUserOne.id, + tags: [...postFullData.tags, ...nonAdminTags], + }); + const dbNonAdminImg = await createImage({ + ...imgData, + ownerId: dbUserOne.id, + }); + const dbAdminPost = await createPost({ + ...postFullData, + authorId: dbAdmin.id, + }); + const dbAdminImg = await createImage({ + ...imgData, + src: 'https://test.foo/img.png', + ownerId: dbAdmin.id, + }); + vi.setSystemTime(Date.now() + Utils.PURGE_INTERVAL_MS); + await api.get(POSTS_URL); + expect(await db.user.findUnique({ where: { id: dbXUser.id } })).toBeNull(); + expect( + await db.user.findUnique({ + where: { id: dbUserOne.id }, + }) + ).toBeNull(); + expect( + await db.user.findUnique({ where: { id: dbUserTwo.id } }) + ).toBeNull(); + expect( + await db.image.findUnique({ + where: { id: dbNonAdminImg.id }, + include: { owner: true }, + }) + ).toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbNonAdminPost.id } }) + ).toBeNull(); + expect( + await db.tag.findMany({ where: { name: { in: nonAdminTags } } }) + ).toHaveLength(0); + expect( + await db.user.findUnique({ where: { id: dbAdmin.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ where: { id: dbAdminImg.id } }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbAdminPost.id } }) + ).not.toBeNull(); + }); + + it('should purge only non-admin data on POST */auth/* request after passing the purge interval', async () => { + const app = createApp(Middlewares.createNonAdminDataPurger()); + const { + api, + dbAdmin, + dbXUser, + imgData, + dbUserOne, + dbUserTwo, + createPost, + createImage, + postFullData, + } = await setup(SIGNIN_URL, app); + const nonAdminTags = ['non-admin-tag-1', 'non-admin-tag-2']; + const dbNonAdminPost = await createPost({ + ...postFullData, + authorId: dbUserOne.id, + tags: [...postFullData.tags, ...nonAdminTags], + }); + const dbNonAdminImg = await createImage({ + ...imgData, + ownerId: dbUserOne.id, + }); + const dbAdminPost = await createPost({ + ...postFullData, + authorId: dbAdmin.id, + }); + const dbAdminImg = await createImage({ + ...imgData, + src: 'https://test.foo/img.png', + ownerId: dbAdmin.id, + }); + vi.setSystemTime(Date.now() + Utils.PURGE_INTERVAL_MS); + await api.post('/auth'); + expect(await db.user.findUnique({ where: { id: dbXUser.id } })).toBeNull(); + expect( + await db.user.findUnique({ + where: { id: dbUserOne.id }, + }) + ).toBeNull(); + expect( + await db.user.findUnique({ where: { id: dbUserTwo.id } }) + ).toBeNull(); + expect( + await db.image.findUnique({ + where: { id: dbNonAdminImg.id }, + include: { owner: true }, + }) + ).toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbNonAdminPost.id } }) + ).toBeNull(); + expect( + await db.tag.findMany({ where: { name: { in: nonAdminTags } } }) + ).toHaveLength(0); + expect( + await db.user.findUnique({ where: { id: dbAdmin.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ where: { id: dbAdminImg.id } }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbAdminPost.id } }) + ).not.toBeNull(); + }); + + it('should not purge any data on non-GET & non-auth request after passing the purge interval', async () => { + const app = createApp(Middlewares.createNonAdminDataPurger()); + const { + api, + dbAdmin, + dbXUser, + imgData, + dbUserOne, + dbUserTwo, + createPost, + createImage, + postFullData, + } = await setup(SIGNIN_URL, app); + const nonAdminTags = ['non-admin-tag-1', 'non-admin-tag-2']; + const dbNonAdminPost = await createPost({ + ...postFullData, + authorId: dbUserOne.id, + tags: [...postFullData.tags, ...nonAdminTags], + }); + const dbNonAdminImg = await createImage({ + ...imgData, + ownerId: dbUserOne.id, + }); + const dbAdminPost = await createPost({ + ...postFullData, + authorId: dbAdmin.id, + }); + const dbAdminImg = await createImage({ + ...imgData, + src: 'https://test.foo/img.png', + ownerId: dbAdmin.id, + }); + vi.setSystemTime(Date.now() + Utils.PURGE_INTERVAL_MS); + const requests = [ + api.post(POSTS_URL), + api.put(POSTS_URL), + api.delete(POSTS_URL), + api.patch(POSTS_URL), + api.options(POSTS_URL), + ]; + for (const req of requests) { + await req; + expect( + await db.user.findUnique({ where: { id: dbXUser.id } }) + ).not.toBeNull(); + expect( + await db.user.findUnique({ + where: { id: dbUserOne.id }, + }) + ).not.toBeNull(); + expect( + await db.user.findUnique({ where: { id: dbUserTwo.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ + where: { id: dbNonAdminImg.id }, + include: { owner: true }, + }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbNonAdminPost.id } }) + ).not.toBeNull(); + expect( + await db.tag.findMany({ where: { name: { in: nonAdminTags } } }) + ).toHaveLength(nonAdminTags.length); + expect( + await db.user.findUnique({ where: { id: dbAdmin.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ where: { id: dbAdminImg.id } }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbAdminPost.id } }) + ).not.toBeNull(); + } + }); + + it('should not purge any data on GET request before passing the purge interval', async () => { + const now = Date.now(); + const app = createApp(Middlewares.createNonAdminDataPurger(now)); + const { + api, + dbAdmin, + dbXUser, + imgData, + dbUserOne, + dbUserTwo, + createPost, + createImage, + postFullData, + } = await setup(SIGNIN_URL, app); + const nonAdminTags = ['non-admin-tag-1', 'non-admin-tag-2']; + const dbNonAdminPost = await createPost({ + ...postFullData, + authorId: dbUserOne.id, + tags: [...postFullData.tags, ...nonAdminTags], + }); + const dbNonAdminImg = await createImage({ + ...imgData, + ownerId: dbUserOne.id, + }); + const dbAdminPost = await createPost({ + ...postFullData, + authorId: dbAdmin.id, + }); + const dbAdminImg = await createImage({ + ...imgData, + src: 'https://test.foo/img.png', + ownerId: dbAdmin.id, + }); + vi.setSystemTime(now + (Utils.PURGE_INTERVAL_MS - 1)); // Just 1 ms before passing purge interval + await api.get(POSTS_URL); + expect( + await db.user.findUnique({ where: { id: dbXUser.id } }) + ).not.toBeNull(); + expect( + await db.user.findUnique({ + where: { id: dbUserOne.id }, + }) + ).not.toBeNull(); + expect( + await db.user.findUnique({ where: { id: dbUserTwo.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ + where: { id: dbNonAdminImg.id }, + include: { owner: true }, + }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbNonAdminPost.id } }) + ).not.toBeNull(); + expect( + await db.tag.findMany({ where: { name: { in: nonAdminTags } } }) + ).toHaveLength(nonAdminTags.length); + expect( + await db.user.findUnique({ where: { id: dbAdmin.id } }) + ).not.toBeNull(); + expect( + await db.image.findUnique({ where: { id: dbAdminImg.id } }) + ).not.toBeNull(); + expect( + await db.post.findUnique({ where: { id: dbAdminPost.id } }) + ).not.toBeNull(); + }); +}); diff --git a/src/types.ts b/src/types.ts index abc3f11..58c8522 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import { postSchema, commentSchema } from '@/api/v1/posts'; import { PrismaClient, Prisma } from '@/../prisma/client'; -import { imageSchema } from '@/api/v1/images'; -import { userSchema } from '@/api/v1/users'; +import { imageSchema } from '@/lib/image/schema'; +import { createUpdateUserSchema, userSchema } from '@/api/v1/users'; import { JwtPayload } from 'jsonwebtoken'; import { z } from 'zod'; @@ -14,11 +14,19 @@ export interface UserSensitiveDataToOmit { password: true; } -export interface OmitUserSensitiveData { +export interface UserDataToAggregate { + avatar: { select: { image: { omit: ImageSensitiveDataToOmit } } }; +} + +export interface UserAggregation { omit: UserSensitiveDataToOmit; + include: UserDataToAggregate; } -export type PublicUser = Prisma.UserGetPayload; +export type PublicUser = Prisma.UserGetPayload<{ + include: UserAggregation['include']; + omit: UserAggregation['omit']; +}>; export interface ImageSensitiveDataToOmit { storageId: true; @@ -30,7 +38,8 @@ export interface OmitImageSensitiveData { } export interface ImageDataToAggregate { - owner: OmitUserSensitiveData; + _count: { select: { posts: true } }; + owner: UserAggregation; } export type PublicImage = Prisma.ImageGetPayload<{ @@ -38,13 +47,34 @@ export type PublicImage = Prisma.ImageGetPayload<{ include: ImageDataToAggregate; }>; +export interface ImageMetadata { + mimetype: string; + height: number; + width: number; + size: number; +} + +export interface ImageFile extends Express.Multer.File, ImageMetadata { + format: string; + ext: string; +} + +export type ImageDataInput = z.output; + +export type ImageFullData = ImageDataInput & ImageMetadata; + export type CustomPrismaClient = PrismaClient<{ omit: { image: ImageSensitiveDataToOmit; user: UserSensitiveDataToOmit }; }>; export type NewUserInput = z.input; - export type NewUserOutput = z.output; +export type UpdateUserInput = z.input< + ReturnType +>; +export type UpdateUserOutput = z.output< + ReturnType +>; export type JwtUser = Prisma.UserGetPayload<{ select: { id: true; isAdmin: true }; @@ -68,16 +98,19 @@ export type PostFullData = Prisma.PostGetPayload<{ include: { _count: { select: { comments: true; votes: true } }; image: { omit: ImageSensitiveDataToOmit; include: ImageDataToAggregate }; - comments: { include: { author: OmitUserSensitiveData } }; - votes: { include: { user: OmitUserSensitiveData } }; - author: OmitUserSensitiveData; + comments: { include: { author: UserAggregation } }; + votes: { include: { user: UserAggregation } }; + author: UserAggregation; tags: true; }; }>; export type NewPostParsedData = z.output; -export type NewPostAuthorizedData = NewPostParsedData & { authorId: string }; +export type NewPostParsedDataWithoutImage = Omit< + NewPostParsedData, + 'imagedata' +>; export type NewCommentParsedData = z.output; @@ -108,19 +141,3 @@ export interface VoteFilters extends PaginationFilters { isUpvote?: boolean; postId?: string; } - -export interface ImageMetadata { - mimetype: string; - height: number; - width: number; - size: number; -} - -export interface ImageFile extends Express.Multer.File, ImageMetadata { - format: string; - ext: string; -} - -export type ImageDataInput = z.output; - -export type FullImageData = ImageDataInput & ImageMetadata;