From 573b70d10fa5b23419fb3eeca511914823b75428 Mon Sep 17 00:00:00 2001 From: Isaac Han Date: Fri, 21 Apr 2023 07:30:59 -0400 Subject: [PATCH] Implement community board [1/7] (#81) * Begin implementing FlyerRepo - implemented basic FlyerRepo functions - updated Flyer model to support uploading in app instead of through a google form * Implement flyers for community board - alphabetized imports and fields - finished implementing flyers for community board * Address pr review comments - addressed pr review comments - removed redundant filtering in FlyerRepo - added checks for null return values in FlyerRepo - alphabetized imports and exports --- src/entities/Flyer.ts | 30 ++--- src/repos/FlyerRepo.ts | 225 +++++++++++++++++++++++++++++++++ src/resolvers/FlyerResolver.ts | 146 +++++++++++++++++++++ 3 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 src/repos/FlyerRepo.ts create mode 100644 src/resolvers/FlyerResolver.ts diff --git a/src/entities/Flyer.ts b/src/entities/Flyer.ts index dc7e3ba..7de2817 100644 --- a/src/entities/Flyer.ts +++ b/src/entities/Flyer.ts @@ -9,16 +9,28 @@ export class Flyer { @Field() @Property() - flyerURL: string; + date: Date; @Field() - @Property() - date: Date; + @Property({ nullable: true }) + description: string; @Field() @Property() imageURL: string; + @Field() + @Property({ default: false }) + isTrending: boolean; + + @Field() + @Property() + location: string; + + @Field() + @Property({ default: false }) + nsfw: boolean; + @Field((type) => Organization) @Property({ type: () => Organization }) organization: Organization; @@ -35,21 +47,9 @@ export class Flyer { @Property() title: string; - @Field() - @Property({ default: false }) - nsfw: boolean; - - @Field() - @Property({ default: false }) - isTrending: boolean; - @Field() @Property({ default: 0 }) trendiness: number; - - @Field() - @Property() - isFiltered: boolean; } export const FlyerModel = getModelForClass(Flyer); diff --git a/src/repos/FlyerRepo.ts b/src/repos/FlyerRepo.ts new file mode 100644 index 0000000..9ea41d4 --- /dev/null +++ b/src/repos/FlyerRepo.ts @@ -0,0 +1,225 @@ +import Filter from 'bad-words'; +import Fuse from 'fuse.js'; + +import { ObjectId } from 'mongodb'; +import { + DEFAULT_LIMIT, + DEFAULT_OFFSET, + FILTERED_WORDS, + MAX_NUM_DAYS_OF_TRENDING_ARTICLES, +} from '../common/constants'; +import { Flyer, FlyerModel } from '../entities/Flyer'; +import { OrganizationModel } from '../entities/Organization'; + +const { IS_FILTER_ACTIVE } = process.env; + +function isFlyerFiltered(flyer: Flyer) { + if (IS_FILTER_ACTIVE === 'true') { + const filter = new Filter({ list: FILTERED_WORDS }); + return filter.isProfane(flyer.title); + } + return false; +} + +const getAllFlyers = async (offset = DEFAULT_OFFSET, limit = DEFAULT_LIMIT): Promise => { + return FlyerModel.find({}) + .sort({ date: 'desc' }) + .skip(offset) + .limit(limit) + .then((flyers) => { + return flyers.filter((flyer) => !isFlyerFiltered(flyer)); + }); +}; + +const getFlyerByID = async (id: string): Promise => { + return FlyerModel.findById(new ObjectId(id)).then((flyer) => { + if (!isFlyerFiltered(flyer)) { + return flyer; + } + return null; + }); +}; + +const getFlyersByIDs = async (ids: string[]): Promise => { + return Promise.all(ids.map((id) => FlyerModel.findById(new ObjectId(id)))).then((flyers) => { + // Filter out all null values that were returned by ObjectIds not associated + // with flyers in database + return flyers.filter((flyer) => flyer !== null && !isFlyerFiltered(flyer)); + }); +}; + +const getFlyersByOrganizationSlug = async ( + slug: string, + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, +): Promise => { + return FlyerModel.find({ 'organization.slug': slug }) + .sort({ date: 'desc' }) + .skip(offset) + .limit(limit) + .then((flyers) => { + return flyers.filter((flyer) => flyer !== null && !isFlyerFiltered(flyer)); + }); +}; + +const getFlyersByOrganizationSlugs = async ( + slugs: string[], + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, +): Promise => { + const uniqueSlugs = [...new Set(slugs)]; + return FlyerModel.find({ 'organization.slug': { $in: uniqueSlugs } }) + .sort({ date: 'desc' }) + .skip(offset) + .limit(limit) + .then((flyers) => { + return flyers.filter((flyer) => flyer !== null && !isFlyerFiltered(flyer)); + }); +}; + +const getFlyersByOrganizationID = async ( + organizationID: string, + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, +): Promise => { + const organization = await (await OrganizationModel.findById(organizationID)).execPopulate(); + return FlyerModel.find({ 'organization.slug': organization.slug }) + .sort({ date: 'desc' }) + .skip(offset) + .limit(limit) + .then((flyers) => { + return flyers.filter((flyer) => flyer !== null && !isFlyerFiltered(flyer)); + }); +}; + +const getFlyersByOrganizationIDs = async ( + organizationIDs: string[], + limit: number = DEFAULT_LIMIT, + offset: number = DEFAULT_OFFSET, +): Promise => { + const uniqueOrgIDs = [...new Set(organizationIDs)].map((id) => new ObjectId(id)); + const orgSlugs = await OrganizationModel.find({ _id: { $in: uniqueOrgIDs } }).select('slug'); + return getFlyersByOrganizationSlugs( + orgSlugs.map((org) => org.slug), + limit, + offset, + ); +}; + +const getFlyersAfterDate = async (since: string, limit = DEFAULT_LIMIT): Promise => { + return ( + FlyerModel.find({ + // Get all Flyers after or on the desired date + date: { $gte: new Date(new Date(since).setHours(0, 0, 0)) }, + }) + // Sort dates in order of most recent to least + .sort({ date: 'desc' }) + .limit(limit) + .then((flyers) => { + return flyers.filter((flyer) => flyer !== null && !isFlyerFiltered(flyer)); + }) + ); +}; + +/** + * Performs fuzzy search on all Flyers to find Flyers with title/publisher matching the query. + * @param query the term to search for + * @param limit the number of results to return + * @returns at most limit Flyers with titles or publishers matching the query + */ +const searchFlyers = async (query: string, limit = DEFAULT_LIMIT) => { + const allFlyers = await FlyerModel.find({}); + const searcher = new Fuse(allFlyers, { + keys: ['title', 'organization.name'], + }); + + return searcher + .search(query) + .map((searchRes) => searchRes.item) + .slice(0, limit); +}; + +/** + * Computes and returns the trending Flyers in the database. + * + * @function + * @param {number} limit - number of Flyers to retrieve. + */ +const getTrendingFlyers = async (limit = DEFAULT_LIMIT): Promise => { + const flyers = await FlyerModel.find({ isTrending: true }).exec(); + return flyers.filter((flyer) => !isFlyerFiltered(flyer)).slice(0, limit); +}; + +/** + * Refreshes trending Flyers. + */ +const refreshTrendingFlyers = async (): Promise => { + // Set previous trending Flyers to not trending + const oldTrendingFlyers = await FlyerModel.find({ isTrending: true }).exec(); + oldTrendingFlyers.forEach(async (a) => { + const flyer = await FlyerModel.findById(new ObjectId(a._id)); // eslint-disable-line + flyer.isTrending = false; + await flyer.save(); + }); + + // Get new trending Flyers + const flyers = await FlyerModel.aggregate() + // Get a sample of random Flyers + .sample(100) + // Get Flyers after 30 days ago + .match({ + date: { + $gte: new Date( + new Date().setDate(new Date().getDate() - MAX_NUM_DAYS_OF_TRENDING_ARTICLES), + ), + }, + }); + + flyers.forEach(async (a) => { + const flyer = await FlyerModel.findById(new ObjectId(a._id)); // eslint-disable-line + flyer.isTrending = true; + await flyer.save(); + }); + + return flyers; +}; + +/** + * Increments number of shoutouts on an Flyer and publication by one. + * @function + * @param {string} id - string representing the unique Object Id of an Flyer. + */ +const incrementShoutouts = async (id: string): Promise => { + const flyer = await FlyerModel.findById(new ObjectId(id)); + if (flyer) { + flyer.shoutouts += 1; + return flyer.save(); + } + return flyer; +}; + +/** + * Checks if an Flyer's title contains profanity. + * @function + * @param {string} title - Flyer title. + */ +const checkProfanity = async (title: string): Promise => { + const filter = new Filter(); + return filter.isProfane(title); +}; + +export default { + checkProfanity, + getAllFlyers, + getFlyerByID, + getFlyersAfterDate, + getFlyersByIDs, + getFlyersByOrganizationID, + getFlyersByOrganizationIDs, + getFlyersByOrganizationSlug, + getFlyersByOrganizationSlugs, + getTrendingFlyers, + incrementShoutouts, + refreshTrendingFlyers, + searchFlyers, +}; diff --git a/src/resolvers/FlyerResolver.ts b/src/resolvers/FlyerResolver.ts new file mode 100644 index 0000000..42bfc52 --- /dev/null +++ b/src/resolvers/FlyerResolver.ts @@ -0,0 +1,146 @@ +import { Resolver, Mutation, Arg, Query, FieldResolver, Root } from 'type-graphql'; +import { Flyer } from '../entities/Flyer'; +import FlyerRepo from '../repos/FlyerRepo'; +import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '../common/constants'; +import UserRepo from '../repos/UserRepo'; + +@Resolver((_of) => Flyer) +class FlyerResolver { + @Query((_returns) => Flyer, { + nullable: true, + description: 'Returns a single via the given ', + }) + async getFlyerByID(@Arg('id') id: string) { + return FlyerRepo.getFlyerByID(id); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: 'Returns a list of via the given list of ', + }) + async getFlyersByIDs(@Arg('ids', (type) => [String]) ids: string[]) { + return FlyerRepo.getFlyersByIDs(ids); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: `Returns a list of of size with offset . Default is ${DEFAULT_LIMIT} and default is 0`, + }) + async getAllFlyers( + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + @Arg('offset', { defaultValue: DEFAULT_OFFSET }) offset: number, + ) { + const Flyers = await FlyerRepo.getAllFlyers(offset, limit); + return Flyers; + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: + 'Returns a list of of size via the given . Results can offsetted by >= 0.', + }) + async getFlyersByOrganizationID( + @Arg('publicationID') publicationID: string, + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + @Arg('offset', { defaultValue: DEFAULT_OFFSET }) offset: number, + ) { + return FlyerRepo.getFlyersByOrganizationID(publicationID, limit, offset); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: + 'Returns a list of of size via the given list of . Results offsetted by >= 0.', + }) + async getFlyersByOrganizationIDs( + @Arg('publicationIDs', (type) => [String]) publicationIDs: string[], + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + @Arg('offset', { defaultValue: DEFAULT_OFFSET }) offset: number, + ) { + return FlyerRepo.getFlyersByOrganizationIDs(publicationIDs, limit, offset); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: + 'Returns a list of of size via the given . Results can be offsetted by >= 0.', + }) + async getFlyersByOrganizationSlug( + @Arg('slug') slug: string, + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + @Arg('offset', { defaultValue: DEFAULT_OFFSET }) offset: number, + ) { + return FlyerRepo.getFlyersByOrganizationSlug(slug, limit, offset); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: + 'Returns a list of of size via the given list of . Results can be offsetted by >= 0.', + }) + async getFlyersByOrganizationSlugs( + @Arg('slugs', (type) => [String]) slugs: string[], + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + @Arg('offset', { defaultValue: DEFAULT_OFFSET }) offset: number, + ) { + return FlyerRepo.getFlyersByOrganizationSlugs(slugs, limit, offset); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: `Returns a list of a given date, limited by . + is formatted as an compliant RFC 2822 timestamp. Valid examples include: "2019-01-31", "Aug 9, 1995", "Wed, 09 Aug 1995 00:00:00", etc. Default is ${DEFAULT_LIMIT}`, + }) + async getFlyersAfterDate( + @Arg('since') since: string, + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + ) { + return FlyerRepo.getFlyersAfterDate(since, limit); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: `Returns a list of trending of size . Default is ${DEFAULT_LIMIT}`, + }) + async getTrendingFlyers(@Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number) { + return FlyerRepo.getTrendingFlyers(limit); + } + + @Query((_returns) => [Flyer], { + nullable: false, + description: `Returns a list of of size matches a particular query. Default is ${DEFAULT_LIMIT}`, + }) + async searchFlyers( + @Arg('query') query: string, + @Arg('limit', { defaultValue: DEFAULT_LIMIT }) limit: number, + ) { + return FlyerRepo.searchFlyers(query, limit); + } + + @FieldResolver((_returns) => Number, { description: 'The trendiness score of a ' }) + async trendiness(@Root() flyer: Flyer): Promise { + const presentDate = new Date().getTime(); + // Due to the way Mongo interprets 'Flyer' object, + // Flyer['_doc'] must be used to access fields of a Flyer object + return flyer['_doc'].shoutouts / ((presentDate - flyer['_doc'].date.getTime())/1000); // eslint-disable-line + } + + @FieldResolver((_returns) => Boolean, { + description: 'If an contains not suitable for work content', + }) + async nsfw(@Root() flyer: Flyer): Promise { + return FlyerRepo.checkProfanity(flyer['_doc'].title); //eslint-disable-line + } + + @Mutation((_returns) => Flyer, { + nullable: true, + description: `Increments the shoutouts of a with the given . + Increments the numShoutouts given of the user with the given [uuid].`, + }) + async incrementShoutouts(@Arg('uuid') uuid: string, @Arg('id') id: string) { + UserRepo.incrementShoutouts(uuid); + return FlyerRepo.incrementShoutouts(id); + } +} + +export default FlyerResolver;