-
Notifications
You must be signed in to change notification settings - Fork 110
feat: add support for multiple squad posting and notification dedup #3154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 36 commits
f7e4d91
234b6d0
11a5d71
5bd9cce
512dadb
c5f0e74
646ef2c
83e08f1
6b237f2
4d80092
aa21828
c466221
65fcbe8
a641e60
ddb96ef
544afbf
23b9ede
0c4bfac
79c49c6
e76ce71
8774724
4b74c9f
11c58fd
3a8a82f
ace07c3
fc92c79
ae49fbc
76288eb
a89e596
76a3734
9d3a222
9713a0a
4175c0b
08dc152
26ab10c
c01e373
8ff6199
b79064c
b68902f
f894315
ae3f7b0
3f79f1d
2e1e971
dc74b90
4dadd0d
9bfbd16
f8f4c9a
139b1bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { DataSource, EntityManager, In, Not } from 'typeorm'; | ||
| import { | ||
| ArticlePost, | ||
| Comment, | ||
| ConnectionManager, | ||
| createExternalLink, | ||
|
|
@@ -8,8 +9,11 @@ import { | |
| FreeformPost, | ||
| generateTitleHtml, | ||
| Post, | ||
| PostMention, | ||
| PostOrigin, | ||
| type PostTranslation, | ||
| PostType, | ||
| preparePostForInsert, | ||
| Source, | ||
| SourceMember, | ||
| SourceType, | ||
|
|
@@ -18,13 +22,10 @@ import { | |
| User, | ||
| validateCommentary, | ||
| WelcomePost, | ||
| type PostTranslation, | ||
| ArticlePost, | ||
| preparePostForInsert, | ||
| } from '../entity'; | ||
| import { ForbiddenError, ValidationError } from 'apollo-server-errors'; | ||
| import { isValidHttpUrl, standardizeURL } from './links'; | ||
| import { findMarkdownTag, markdown } from './markdown'; | ||
| import { findMarkdownTag, markdown, saveMentions } from './markdown'; | ||
| import { generateShortId } from '../ids'; | ||
| import { GQLPost } from '../schema/posts'; | ||
| // @ts-expect-error - no types | ||
|
|
@@ -37,16 +38,17 @@ import { PostCodeSnippet } from '../entity/posts/PostCodeSnippet'; | |
| import { logger } from '../logger'; | ||
| import { downloadJsonFile } from './googleCloud'; | ||
| import { | ||
| ContentLanguage, | ||
| type ChangeObject, | ||
| ContentLanguage, | ||
| type I18nRecord, | ||
| type PostCodeSnippetJsonFile, | ||
| } from '../types'; | ||
| import { uniqueifyObjectArray } from './utils'; | ||
| import { | ||
| type CreatePollOption, | ||
| SourcePostModeration, | ||
| SourcePostModerationStatus, | ||
| type CreatePollOption, | ||
| WarningReason, | ||
| } from '../entity/SourcePostModeration'; | ||
| import { mapCloudinaryUrl, uploadPostFile, UploadPreset } from './cloudinary'; | ||
| import { getMentions } from '../schema/comments'; | ||
|
|
@@ -60,6 +62,9 @@ import { PollOption } from '../entity/polls/PollOption'; | |
| import addDays from 'date-fns/addDays'; | ||
| import { PollPost } from '../entity/posts/PollPost'; | ||
| import { pollCreationSchema } from './schema/polls'; | ||
| import { generateDeduplicationKey } from '../entity/posts/hooks'; | ||
| import { z } from 'zod'; | ||
| import { canPostToSquad } from '../schema/sources'; | ||
|
|
||
| export type SourcePostModerationArgs = ConnectionArguments & { | ||
| sourceId: string; | ||
|
|
@@ -324,7 +329,7 @@ export const createPollPost = async ({ | |
| }); | ||
| }; | ||
|
|
||
| export const createFreeformPost = async ({ | ||
| export const insertFreeformPost = async ({ | ||
| con, | ||
| args, | ||
| ctx, | ||
|
|
@@ -373,13 +378,90 @@ export interface CreateSourcePostModeration | |
| interface CreateSourcePostModerationProps { | ||
| ctx: AuthContext; | ||
| args: CreateSourcePostModeration; | ||
| options?: Partial<{ | ||
| isMultiPost: boolean; | ||
| entityManager: EntityManager; | ||
| }>; | ||
| } | ||
|
|
||
| const hasDuplicatedPostBy = async ( | ||
| con: DataSource, | ||
| { dedupKey, sourceId }: Partial<Record<'dedupKey' | 'sourceId', string>>, | ||
| ): Promise<boolean> => { | ||
| if (!dedupKey) return false; | ||
|
|
||
| return await queryReadReplica(con, async ({ queryRunner }) => { | ||
| const pendingQb = queryRunner.manager | ||
| .getRepository(SourcePostModeration) | ||
| .createQueryBuilder('p') | ||
| .where({ | ||
| status: SourcePostModerationStatus.Pending, | ||
| }) | ||
| .andWhere(`p.flags->> 'dedupKey' = :dedupKey`, { dedupKey }) | ||
| .limit(1); | ||
capJavert marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const postQb = queryRunner.manager | ||
| .getRepository(Post) | ||
| .createQueryBuilder('p') | ||
| .where(`p.flags->> 'dedupKey' = :dedupKey`, { dedupKey }) | ||
| .limit(1); | ||
capJavert marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if (sourceId) { | ||
| pendingQb.andWhere(`p.sourceId = :sourceId`, { sourceId }); | ||
| postQb.andWhere(`p.sourceId = :sourceId`, { sourceId }); | ||
| } | ||
|
|
||
| const [pendingExists, postExists] = await Promise.all([ | ||
| pendingQb.getOne(), | ||
| postQb.getOne(), | ||
| ]); | ||
|
|
||
| return !!(pendingExists || postExists); | ||
| }); | ||
| }; | ||
|
|
||
| const getModerationWarningFlag = async ({ | ||
| con, | ||
| isMultiPost = false, | ||
| dedupKey, | ||
| sourceId, | ||
| }: { | ||
| con: DataSource; | ||
| isMultiPost?: boolean; | ||
| dedupKey?: string; | ||
| sourceId?: string; | ||
| }): Promise<WarningReason | undefined> => { | ||
| if (isMultiPost) { | ||
| return WarningReason.MultipleSquadPost; | ||
| } | ||
|
|
||
| if (!dedupKey) { | ||
rebelchris marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return; | ||
| } | ||
|
|
||
| const isDuplicatedInSameSquad = await hasDuplicatedPostBy(con, { | ||
|
||
| dedupKey, | ||
| sourceId, | ||
| }); | ||
| if (isDuplicatedInSameSquad) { | ||
| return WarningReason.DuplicatedInSameSquad; | ||
| } | ||
|
|
||
| const isDuplicatedAcrossSquads = await hasDuplicatedPostBy(con, { | ||
|
||
| dedupKey, | ||
| }); | ||
| if (isDuplicatedAcrossSquads) { | ||
| return WarningReason.MultipleSquadPost; | ||
| } | ||
|
|
||
| return; | ||
|
||
| }; | ||
|
|
||
| export const createSourcePostModeration = async ({ | ||
| ctx, | ||
| ctx: { userId, con, req }, | ||
| args, | ||
| options = {}, | ||
| }: CreateSourcePostModerationProps) => { | ||
| const { con } = ctx; | ||
| const { isMultiPost = false } = options; | ||
|
|
||
| if (args.postId) { | ||
| const post = await con | ||
|
|
@@ -397,19 +479,34 @@ export const createSourcePostModeration = async ({ | |
| }); | ||
|
|
||
| const content = `${args.title} ${args.content}`.trim(); | ||
| const dedupKey = generateDeduplicationKey(args); | ||
|
|
||
| newModerationEntry.flags = { | ||
| vordr: await checkWithVordr( | ||
| const [warningReason, vordr] = await Promise.all([ | ||
| getModerationWarningFlag({ | ||
| con, | ||
| isMultiPost, | ||
| dedupKey, | ||
| sourceId: args.sourceId, | ||
| }), | ||
| checkWithVordr( | ||
| { | ||
| id: newModerationEntry.id, | ||
| type: VordrFilterType.PostModeration, | ||
| content, | ||
| }, | ||
| { con, userId: ctx.userId, req: ctx.req }, | ||
| { con, userId, req }, | ||
| ), | ||
| ]); | ||
|
|
||
| newModerationEntry.flags = { | ||
| warningReason, | ||
| vordr, | ||
| dedupKey, | ||
| }; | ||
|
|
||
| return await con.getRepository(SourcePostModeration).save(newModerationEntry); | ||
| return await (options?.entityManager || con) | ||
| .getRepository(SourcePostModeration) | ||
| .save(newModerationEntry); | ||
| }; | ||
|
|
||
| export interface CreateSourcePostModerationArgs | ||
|
|
@@ -446,9 +543,136 @@ export interface CreatePollPostProps | |
| duration: number; | ||
| } | ||
|
|
||
| export interface CreateMultipleSourcePostProps | ||
| extends Omit<CreatePostArgs, 'sourceId'>, | ||
| Pick<CreatePollPostProps, 'options' | 'duration'> { | ||
| sharedPostId?: string; | ||
| sourceIds: string[]; | ||
| } | ||
|
|
||
| const MAX_MULTIPLE_POST_SOURCE_LIMIT = 4; | ||
AmarTrebinjac marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const MAX_TITLE_LENGTH = 250; | ||
| const MAX_CONTENT_LENGTH = 10_000; | ||
|
|
||
| export const postInMultipleSourcesArgsSchema = z | ||
| .object({ | ||
| title: z.string().max(MAX_TITLE_LENGTH).optional(), | ||
| content: z.string().max(MAX_CONTENT_LENGTH).optional(), | ||
| image: z.custom<Promise<FileUpload>>(), | ||
| sourceIds: z.array(z.string()).min(1).max(MAX_MULTIPLE_POST_SOURCE_LIMIT), | ||
| sharedPostId: z.string().optional(), | ||
| }) | ||
| .extend( | ||
| pollCreationSchema | ||
| .pick({ | ||
| options: true, | ||
| duration: true, | ||
| }) | ||
| .partial().shape, | ||
| ); | ||
|
|
||
| export const getMultipleSourcesPostType = ( | ||
| args: z.infer<typeof postInMultipleSourcesArgsSchema>, | ||
| ): PostType => { | ||
| if (args.options?.length) { | ||
| return PostType.Poll; | ||
| } | ||
|
|
||
| if (args.sharedPostId) { | ||
| return PostType.Share; | ||
| } | ||
|
|
||
| return PostType.Freeform; | ||
| }; | ||
|
|
||
| export const checkIfUserPostInSourceDirectlyOrThrow = async ( | ||
| con: DataSource, | ||
| { sourceId, userId }: Record<'sourceId' | 'userId', string>, | ||
| ) => { | ||
| const isSameUserSource = sourceId === userId; | ||
|
|
||
| if (isSameUserSource) { | ||
| return true; | ||
| } | ||
|
|
||
| const [source, squadMember] = await Promise.all([ | ||
| con.getRepository(Source).findOneBy({ | ||
| id: sourceId, | ||
| }), | ||
| con.getRepository(SourceMember).findOneBy({ | ||
| userId, | ||
| sourceId, | ||
| }), | ||
| ]); | ||
|
|
||
| if (!source || !squadMember || source?.type !== SourceType.Squad) { | ||
| throw new ForbiddenError('Access denied!'); | ||
| } | ||
|
|
||
| return canPostToSquad(source as SquadSource, squadMember); | ||
| }; | ||
|
|
||
| export const createPostIntoSourceId = async ( | ||
| ctx: AuthContext, | ||
| sourceId: string, | ||
| args: z.infer<typeof postInMultipleSourcesArgsSchema>, | ||
| entityManager?: EntityManager, | ||
| ): Promise<Pick<Post, 'id'>> => { | ||
| const type = getMultipleSourcesPostType(args); | ||
| const con = entityManager || ctx.con; | ||
| switch (type) { | ||
| case PostType.Share: { | ||
| await ctx.con | ||
| .getRepository(Post) | ||
| .findOneByOrFail({ id: args.sharedPostId }); | ||
| return await createSharePost({ | ||
| con, | ||
| ctx, | ||
| args: { | ||
| authorId: ctx.userId, | ||
| sourceId, | ||
| postId: args.sharedPostId!, | ||
| commentary: args.title, | ||
| }, | ||
| }); | ||
| } | ||
| case PostType.Poll: { | ||
| const id = await generateShortId(); | ||
| const { options, ...pollArgs } = args; | ||
| return await createPollPost({ | ||
| con, | ||
| ctx, | ||
| args: { | ||
| ...pollArgs, | ||
| id, | ||
| sourceId, | ||
| title: `${args.title}`, | ||
| authorId: ctx.userId, | ||
| pollOptions: options!.map((option) => | ||
| ctx.con.getRepository(PollOption).create({ | ||
| text: option.text, | ||
| numVotes: 0, | ||
| order: option.order, | ||
| postId: id!, | ||
| }), | ||
| ), | ||
| }, | ||
| }); | ||
| } | ||
| default: { | ||
| return await createFreeformPost( | ||
capJavert marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ctx, | ||
| { | ||
| ...args, | ||
| sourceId, | ||
| }, | ||
| { entityManager }, | ||
| ); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| type ValidatePostArgs = Pick<EditPostArgs, 'title' | 'content'>; | ||
|
|
||
| export const validatePost = ( | ||
|
|
@@ -702,7 +926,7 @@ export const processApprovedModeratedPost = async ( | |
| sourceId, | ||
| authorId: createdById, | ||
| }; | ||
| const post = await createFreeformPost({ con, args: params as CreatePost }); | ||
| const post = await insertFreeformPost({ con, args: params as CreatePost }); | ||
| return { ...moderated, postId: post.id }; | ||
| } | ||
|
|
||
|
|
@@ -1104,3 +1328,46 @@ export const ensurePostAnalyticsPermissions = async ({ | |
| ); | ||
| } | ||
| }; | ||
|
|
||
| export const createFreeformPost = async ( | ||
| ctx: AuthContext, | ||
| args: CreatePostArgs, | ||
| options?: Partial<{ entityManager: EntityManager }>, | ||
| ) => { | ||
| const { sourceId, image } = args; | ||
| const { con, userId } = ctx; | ||
| const id = await generateShortId(); | ||
| const { title, content } = validatePost(args); | ||
|
|
||
| if (!title) { | ||
| throw new ValidationError('Title can not be an empty string!'); | ||
| } | ||
|
|
||
| await (options?.entityManager || con).transaction(async (manager) => { | ||
capJavert marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const mentions = await getMentions(manager, content, userId, sourceId); | ||
| const contentHtml = markdown.render(content, { mentions }); | ||
| const params: CreatePost = { | ||
| id, | ||
| title, | ||
| content, | ||
| contentHtml, | ||
| authorId: userId, | ||
| sourceId, | ||
| }; | ||
|
|
||
| if (image && process.env.CLOUDINARY_URL) { | ||
| const upload = await image; | ||
| const { url: coverImageUrl } = await uploadPostFile( | ||
| id, | ||
| upload.createReadStream(), | ||
| UploadPreset.PostBannerImage, | ||
| ); | ||
| params.image = coverImageUrl; | ||
| } | ||
|
|
||
| await insertFreeformPost({ con: manager, ctx, args: params }); | ||
| await saveMentions(manager, id, userId, mentions, PostMention); | ||
| }); | ||
|
|
||
| return { id }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renamed, now
createFreeformPostis a new function that also do some checks before insert