From b0976c5e25b588b69363334c3b36fa34b2b83275 Mon Sep 17 00:00:00 2001 From: Vin Bui <75594943+vinnie4k@users.noreply.github.com> Date: Wed, 13 Sep 2023 13:45:08 -0400 Subject: [PATCH] Merge `vin/create-flyer` to `main` (#104) * Fixed notification issue * Added flyer notification * Added flyer notification * Updated Organization and Flyer models * Updated test cases * Added getFlyersByCategorySlug query * Update query description * Inline notification calls * Add createFlyer mutation * `deleteFlyer` mutation (#103) * Add deleteFlyer mutation * Address PR comments * Update utils.ts * Update FlyerRepo.ts --- src/middlewares/FlyerMiddleware.ts | 15 +++++++ src/repos/FlyerRepo.ts | 63 +++++++++++++++++++++++++++++- src/resolvers/FlyerResolver.ts | 51 +++++++++++++++++++++++- src/tests/flyer.test.ts | 20 ++++++++++ src/utils.ts | 53 +++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 src/middlewares/FlyerMiddleware.ts create mode 100644 src/utils.ts diff --git a/src/middlewares/FlyerMiddleware.ts b/src/middlewares/FlyerMiddleware.ts new file mode 100644 index 0000000..23fc64a --- /dev/null +++ b/src/middlewares/FlyerMiddleware.ts @@ -0,0 +1,15 @@ +import { MiddlewareFn } from 'type-graphql'; +import { Context } from 'vm'; +import utils from '../utils'; + +const FlyerUploadErrorInterceptor: MiddlewareFn = async ({ args, context }, next) => { + try { + // Upload image to our upload service + context.imageURL = await utils.uploadImage(args.imageB64); + return await next(); + } catch (err) { + throw new Error('An error occured while uploading the flyer image.'); + } +}; + +export default { FlyerUploadErrorInterceptor }; diff --git a/src/repos/FlyerRepo.ts b/src/repos/FlyerRepo.ts index 086b8c7..b9ca3e0 100644 --- a/src/repos/FlyerRepo.ts +++ b/src/repos/FlyerRepo.ts @@ -10,6 +10,7 @@ import { MAX_NUM_OF_TRENDING_FLYERS, } from '../common/constants'; import { OrganizationModel } from '../entities/Organization'; +import utils from '../utils'; const { IS_FILTER_ACTIVE } = process.env; @@ -223,7 +224,7 @@ const refreshTrendingFlyers = async (): Promise => { /** * Increments number of times clicked on a flyer by one. - * @function + * * @param {string} id - string representing the unique Object Id of a flyer. */ const incrementTimesClicked = async (id: string): Promise => { @@ -238,7 +239,67 @@ const incrementTimesClicked = async (id: string): Promise => { return flyer; }; +/** + * Create a new flyer. + * + * @param {string} categorySlug the slug for this flyer's category + * @param {string} endDate the end date for this flyer's event in UTC ISO8601 format + * @param {string} flyerURL the URL for this flyer when tapped + * @param {string} imageURL the URL representing this flyer's image + * @param {string} location the location for this flyer's event + * @param {string} organizationID the ID of the organization creating this flyer + * @param {string} startDate the start date for this flyer's event in UTC ISO8601 format + * @param {string} title the title for this flyer + * @returns the newly created Flyer + */ +const createFlyer = async ( + categorySlug: string, + endDate: string, + flyerURL: string, + imageURL: string, + location: string, + organizationID: string, + startDate: string, + title: string, +): Promise => { + // Fetch organization given organization ID + // This call will fail if the organization cannot be found + const organization = await OrganizationModel.findById(new ObjectId(organizationID)); + const organizationSlug = organization.slug; + + const newFlyer = Object.assign(new Flyer(), { + categorySlug, + endDate, + flyerURL, + imageURL, + location, + organization, + organizationSlug, + startDate, + title, + }); + return FlyerModel.create(newFlyer); +}; + +/** + * Delete a flyer + * + * @param id the flyer ID to remove + */ +const deleteFlyer = async (id: string): Promise => { + const flyerToRemove = await FlyerModel.findById(new ObjectId(id)); + if (!flyerToRemove) { + return null; + } + // Remove image from our servers + await utils.removeImage(flyerToRemove.imageURL); + const flyer = await FlyerModel.findByIdAndDelete(new ObjectId(id)); + return flyer; +}; + export default { + createFlyer, + deleteFlyer, getAllFlyers, getFlyerByID, getFlyersAfterDate, diff --git a/src/resolvers/FlyerResolver.ts b/src/resolvers/FlyerResolver.ts index b6ded3a..34312cb 100644 --- a/src/resolvers/FlyerResolver.ts +++ b/src/resolvers/FlyerResolver.ts @@ -1,7 +1,18 @@ -import { Resolver, Mutation, Arg, Query, FieldResolver, Root } from 'type-graphql'; +import { + Resolver, + Mutation, + Arg, + Query, + FieldResolver, + Root, + UseMiddleware, + Ctx, +} from 'type-graphql'; +import { Context } from 'vm'; import { Flyer } from '../entities/Flyer'; -import FlyerRepo from '../repos/FlyerRepo'; import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '../common/constants'; +import FlyerRepo from '../repos/FlyerRepo'; +import FlyerMiddleware from '../middlewares/FlyerMiddleware'; @Resolver((_of) => Flyer) class FlyerResolver { @@ -155,6 +166,42 @@ class FlyerResolver { async incrementTimesClicked(@Arg('id') id: string) { return FlyerRepo.incrementTimesClicked(id); } + + @Mutation((_returns) => Flyer, { + description: `Creates a single via given , , , , , , , and . + <startDate> and <endDate> must be in UTC ISO8601 format (e.g. YYYY-mm-ddTHH:MM:ssZ). + <imageB64> must be a Base64 encrypted string without 'data:image/png;base64,' prepended`, + }) + @UseMiddleware(FlyerMiddleware.FlyerUploadErrorInterceptor) + async createFlyer( + @Arg('categorySlug') categorySlug: string, + @Arg('endDate') endDate: string, + @Arg('flyerURL', { nullable: true }) flyerURL: string, + @Arg('imageB64') imageB64: string, + @Arg('location') location: string, + @Arg('organizationID') organizationID: string, + @Arg('startDate') startDate: string, + @Arg('title') title: string, + @Ctx() ctx: Context, + ) { + return FlyerRepo.createFlyer( + categorySlug, + endDate, + flyerURL, + ctx.imageURL, + location, + organizationID, + startDate, + title, + ); + } + + @Mutation((_returns) => Flyer, { + description: `Delete a flyer with the id <id>.`, + }) + async deleteFlyer(@Arg('id') id: string) { + return FlyerRepo.deleteFlyer(id); + } } export default FlyerResolver; diff --git a/src/tests/flyer.test.ts b/src/tests/flyer.test.ts index 42125cb..bf1ff98 100644 --- a/src/tests/flyer.test.ts +++ b/src/tests/flyer.test.ts @@ -243,3 +243,23 @@ describe('getFlyersByCategorySlug tests', () => { expect(getFlyersResponse).toHaveLength(limit); }); }); + +describe('deleteFlyer tests', () => { + test('flyer with ID exists', async () => { + const flyers = await FlyerFactory.create(2); + await FlyerModel.insertMany(flyers); + + const fetchedFlyers = await FlyerRepo.getAllFlyers(); + + const deleteFlyerResponse = await FlyerRepo.deleteFlyer(fetchedFlyers[0].id); + expect(deleteFlyerResponse.id).toStrictEqual(fetchedFlyers[0].id); + }); + + test('flyer with ID does not exist', async () => { + const flyers = await FlyerFactory.create(2); + await FlyerModel.insertMany(flyers); + + const deleteFlyerResponse = await FlyerRepo.deleteFlyer('64811792f910705ca1a981f8'); + expect(deleteFlyerResponse).toBeNull(); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0ac0bd3 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,53 @@ +import fetch from 'node-fetch-commonjs'; + +interface UploadResponse { + success: boolean; + data: string; +} + +/** + * Upload an image to our servers + * + * @param imageB64 the base64 string to upload + * @returns the URl representing the image + */ +const uploadImage = async (imageB64: string): Promise<string> => { + // Upload image via a POST request + const imagePayload = { + bucket: process.env.UPLOAD_BUCKET, + image: `data:image/png;base64,${imageB64}`, + }; + const response = await fetch(`${process.env.UPLOAD_SERVICE_URL}/upload/`, { + method: 'POST', + body: JSON.stringify(imagePayload), + }); + + const responseData = (await response.json()) as UploadResponse; + return responseData.data; +}; + +/** + * Remove an image from AppDev servers + * + * @param imageURL the image URL to remove + * @returns true if the deletion was successful; otherwise false + */ +const removeImage = async (imageURL: string): Promise<boolean> => { + // Delete image via a POST request + const payload = { + bucket: process.env.UPLOAD_BUCKET, + image_url: imageURL, + }; + const response = await fetch(`${process.env.UPLOAD_SERVICE_URL}/remove/`, { + method: 'POST', + body: JSON.stringify(payload), + }); + + const responseData = (await response.json()) as UploadResponse; + if (!responseData.success) { + console.log(`Removing an image from our servers failed with image URL: ${imageURL}`); + } + return responseData.success; +}; + +export default { removeImage, uploadImage };