diff --git a/__tests__/notifications/index.ts b/__tests__/notifications/index.ts index 3b6b825b99..81ef8595b9 100644 --- a/__tests__/notifications/index.ts +++ b/__tests__/notifications/index.ts @@ -1669,6 +1669,51 @@ describe('storeNotificationBundle', () => { expect(notifications.length).toEqual(3); }); + it('should not generate duplicate post added notifications for posts with same dedupKey', async () => { + await saveFixtures(con, User, usersFixture); + + const dedupKey = 'p1'; + const sharedCtx = { + userIds: [userId, '3', '4'], + source: sourcesFixture[0] as Reference, + user: usersFixture[1] as Reference, + doneBy: usersFixture[1] as Reference, + }; + const ctx1 = { + ...sharedCtx, + post: postsFixture[1] as Reference, + }; + const ctx2 = { + ...sharedCtx, + post: postsFixture[2] as Reference, + }; + + const notificationIds = await con.transaction(async (manager) => { + const results = await Promise.all([ + storeNotificationBundleV2( + manager, + generateNotificationV2(NotificationType.SourcePostAdded, ctx1), + dedupKey, + ), + storeNotificationBundleV2( + manager, + generateNotificationV2(NotificationType.SquadPostAdded, ctx2), + dedupKey, + ), + ]); + return results.flat(); + }); + + const notifications = await con.getRepository(UserNotification).findBy({ + notificationId: In(notificationIds.map((item) => item.id)), + }); + + expect(notifications.length).toEqual(3); + const uniqueKeys = notifications.map((item) => item.uniqueKey); + expect(new Set(uniqueKeys).size).toEqual(1); + expect(uniqueKeys[0]).toEqual(`post_added:dedup_${dedupKey}:post`); + }); + it('should generate user_given_top_reader notification', async () => { const topReader = { id: 'cdaac113-0e8b-4189-9a6b-ceea7b21de0e', diff --git a/__tests__/posts.ts b/__tests__/posts.ts index f54d74da3b..0465d44e31 100644 --- a/__tests__/posts.ts +++ b/__tests__/posts.ts @@ -5,6 +5,7 @@ import { GraphQLTestClient, GraphQLTestingState, initializeGraphQLTesting, + invokeTypedNotificationWorker, MockContext, saveFixtures, testMutationError, @@ -19,6 +20,7 @@ import { clearPostTranslations, Comment, Feed, + FeedType, FreeformPost, Post, PostMention, @@ -67,11 +69,11 @@ import { randomUUID } from 'crypto'; import nock from 'nock'; import { deleteKeysByPattern, + deleteRedisKey, getRedisObject, getRedisObjectExpiry, ioRedisPool, setRedisObject, - deleteRedisKey, } from '../src/redis'; import { checkHasMention, markdown } from '../src/common/markdown'; import { generateStorageKey, StorageTopic } from '../src/config'; @@ -83,12 +85,19 @@ import { PostModerationReason, SourcePostModeration, SourcePostModerationStatus, + WarningReason, } from '../src/entity/SourcePostModeration'; import { generateUUID } from '../src/ids'; import { GQLResponse } from 'mercurius-integration-testing'; import type { GQLPostSmartTitle } from '../src/schema/posts'; import { TransferError } from '../src/errors'; -import { TransferStatus, TransferType } from '@dailydotdev/schema'; +import { + Credits, + TransferResult, + TransferStatus, + TransferType, + UserBriefingRequest, +} from '@dailydotdev/schema'; import { SubscriptionCycles } from '../src/paddle'; import { remoteConfig } from '../src/remoteConfig'; import { @@ -104,11 +113,6 @@ import { } from '../src/entity/user/UserTransaction'; import { Product, ProductType } from '../src/entity/Product'; import { BriefingModel, BriefingType } from '../src/integrations/feed'; -import { - Credits, - TransferResult, - UserBriefingRequest, -} from '@dailydotdev/schema'; import { addDays, format, subDays } from 'date-fns'; import { PostAnalytics } from '../src/entity/posts/PostAnalytics'; import { PostAnalyticsHistory } from '../src/entity/posts/PostAnalyticsHistory'; @@ -118,6 +122,12 @@ import { createClient } from '@connectrpc/connect'; import isSameDay from 'date-fns/isSameDay'; import { PollPost } from '../src/entity/posts/PollPost'; import { PollOption } from '../src/entity/polls/PollOption'; +import { postAdded } from '../src/workers/notifications/postAdded'; +import { + generateUserNotificationUniqueKey, + NotificationType, +} from '../src/notifications/common'; +import { NotificationPostContext } from '../src/notifications'; jest.mock('../src/common/pubsub', () => ({ ...(jest.requireActual('../src/common/pubsub') as Record), @@ -2883,6 +2893,689 @@ describe('mutation editSharePost', () => { }); }); +describe('mutation createPostInMultipleSources', () => { + const MUTATION = /* GraphQL */ ` + mutation CreatePostInMultipleSources( + $sourceIds: [ID!]! + $title: String + $commentary: String + $imageUrl: String + $content: String + $image: Upload + $sharedPostId: ID + $externalLink: String + $options: [PollOptionInput!] + $duration: Int + ) { + createPostInMultipleSources( + sourceIds: $sourceIds + title: $title + commentary: $commentary + imageUrl: $imageUrl + content: $content + image: $image + sharedPostId: $sharedPostId + externalLink: $externalLink + options: $options + duration: $duration + ) { + id + sourceId + type + } + } + `; + + const freeformParams = { + sourceIds: ['squad', 'm', '1'], + title: 'Multi-squad post title', + content: 'This is a multi-squad post content', + }; + + const shareParams = { + sourceIds: ['squad', 'm', 'm2'], + sharedPostId: 'p1', // sharing existing post + }; + + beforeEach(async () => { + await con.getRepository(Feed).save({ + id: '1', + userId: '1', + type: FeedType.Main, + }); + await con.getRepository(SourceMember).save([ + { + userId: '1', + sourceId: 'squad', + role: SourceMemberRoles.Member, + referralToken: 'rt1-s', + }, + { + userId: '1', + sourceId: 'm', + role: SourceMemberRoles.Member, + referralToken: 'rt1-m', + }, + { + userId: '1', + sourceId: 'm2', + role: SourceMemberRoles.Member, + referralToken: 'rt1-m2', + }, + ]); + }); + + afterEach(async () => { + await con.getRepository(Post).deleteAll(); + await con.getRepository(SourcePostModeration).deleteAll(); + await con.getRepository(SourceMember).deleteAll(); + }); + + describe('authorization', () => { + it('should not authorize when not logged in', () => + testMutationErrorCode( + client, + { mutation: MUTATION, variables: freeformParams }, + 'UNAUTHENTICATED', + )); + + it('should throw error when user has no permission to post in any of the sources', async () => { + loggedUser = '2'; // user not a member of any sources + return testMutationErrorCode( + client, + { mutation: MUTATION, variables: freeformParams }, + 'FORBIDDEN', + ); + }); + + it('should handle mixed permissions with some sources allowed, others require moderation', async () => { + loggedUser = '1'; + const mixedParams = { + sourceIds: ['squad', 'm'], + title: 'Mixed permissions post', + content: 'Testing mixed permissions', + }; + + const res = await client.mutate< + { + createPostInMultipleSources: [ + { + id: string; + sourceId: string; + type: PostType; + }, + ]; + }, + typeof mixedParams + >(MUTATION, { variables: mixedParams }); + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(2); + + // Check that one is a direct post and one is a moderation item + const results = res.data.createPostInMultipleSources; + const postTypes = results.map((r) => r.type).sort(); + expect(postTypes).toEqual(['moderationItem', 'post']); + }); + }); + + describe('input validation', () => { + it('should throw error when sourceIds array is empty', async () => { + loggedUser = '1'; + return testMutationErrorCode( + client, + { mutation: MUTATION, variables: { ...freeformParams, sourceIds: [] } }, + 'ZOD_VALIDATION_ERROR', + ); + }); + + it('should throw error when sourceIds is null', async () => { + loggedUser = '1'; + return testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { ...freeformParams, sourceIds: null }, + }, + 'GRAPHQL_VALIDATION_FAILED', + ); + }); + + it('should throw error when title exceeds maximum length', async () => { + loggedUser = '1'; + const longTitle = 'a'.repeat(251); // exceeds 250 character limit + return testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { ...freeformParams, title: longTitle }, + }, + 'ZOD_VALIDATION_ERROR', + ); + }); + + it('should throw error when content exceeds maximum length', async () => { + loggedUser = '1'; + const longContent = 'a'.repeat(10001); // exceeds 10000 character limit + return testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { ...freeformParams, content: longContent }, + }, + 'ZOD_VALIDATION_ERROR', + ); + }); + + it('should throw error when trying to share non-existent post', async () => { + loggedUser = '1'; + return testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { ...shareParams, sharedPostId: 'nonexistent' }, + }, + 'NOT_FOUND', + ); + }); + + it('should handle source not found error', async () => { + loggedUser = '1'; + const invalidParams = { + sourceIds: ['nonexistent'], + title: 'Test post', + content: 'Test content', + }; + + return testMutationErrorCode( + client, + { mutation: MUTATION, variables: invalidParams }, + 'FORBIDDEN', + ); + }); + }); + + describe('freeform post creation', () => { + it('should successfully create freeform posts in multiple squads', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { variables: freeformParams }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(3); + + const [first, second, third] = res.data.createPostInMultipleSources; + expect(first.type).toBe('post'); + expect(first.sourceId).toBe('squad'); + expect(second.type).toBe('moderationItem'); + expect(second.sourceId).toBe('m'); + + // Verify posts were actually created + const [post, moderationItem, userSourcePost] = await Promise.all([ + await con.getRepository(FreeformPost).findOneByOrFail({ id: first.id }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: ['sourceId', 'createdById', 'title', 'content', 'status'], + where: { id: second.id }, + }), + await con.getRepository(FreeformPost).findOneByOrFail({ + id: third.id, + sourceId: '1', + }), + ]); + + expect(post).toStrictEqual( + expect.objectContaining({ + sourceId: 'squad', + authorId: '1', + title: freeformParams.title, + content: freeformParams.content, + }), + ); + expect(moderationItem).toStrictEqual( + expect.objectContaining({ + sourceId: 'm', + createdById: '1', + title: freeformParams.title, + content: freeformParams.content, + status: SourcePostModerationStatus.Pending, + }), + ); + expect(userSourcePost).toStrictEqual( + expect.objectContaining({ + sourceId: '1', + authorId: '1', + title: freeformParams.title, + }), + ); + }); + + it('should handle single squad posting', async () => { + loggedUser = '1'; + const singleParams = { ...freeformParams, sourceIds: ['squad'] }; + const res = await client.mutate(MUTATION, { variables: singleParams }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(1); + const [post] = res.data.createPostInMultipleSources; + expect(post.sourceId).toBe('squad'); + expect(post.type).toBe('post'); + }); + }); + + describe('share post creation', () => { + it('should successfully share post to multiple squads', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { variables: shareParams }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(3); + + const [first, second] = res.data.createPostInMultipleSources; + expect(first.type).toBe('post'); + expect(second.type).toBe('moderationItem'); + + // Verify posts were actually created + const [post, moderationItem] = await Promise.all([ + await con.getRepository(SharePost).findOneOrFail({ + where: { id: first.id }, + select: ['sourceId', 'authorId', 'sharedPostId'], + }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: ['sourceId', 'createdById', 'status', 'sharedPostId'], + where: { id: second.id }, + }), + ]); + + expect(post).toEqual({ + sourceId: 'squad', + authorId: '1', + sharedPostId: 'p1', + }); + expect(moderationItem).toEqual( + expect.objectContaining({ + sourceId: 'm', + createdById: '1', + sharedPostId: 'p1', + status: SourcePostModerationStatus.Pending, + }), + ); + }); + + it('should handle share post with commentary', async () => { + loggedUser = '1'; + const title = 'My comment'; + const shareWithCommentary = { + ...shareParams, + title, + }; + const res = await client.mutate(MUTATION, { + variables: shareWithCommentary, + }); + + expect(res.errors).toBeFalsy(); + + const [first, second] = res.data.createPostInMultipleSources; + expect(first.type).toBe('post'); + expect(second.type).toBe('moderationItem'); + + // Verify posts were actually created + const [post, moderationItem] = await Promise.all([ + await con.getRepository(SharePost).findOneOrFail({ + where: { id: first.id }, + select: ['sourceId', 'authorId', 'sharedPostId', 'title'], + }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: [ + 'sourceId', + 'createdById', + 'title', + 'status', + 'sharedPostId', + ], + where: { id: second.id }, + }), + ]); + + expect(post).toEqual({ + sourceId: 'squad', + authorId: '1', + sharedPostId: 'p1', + title, + }); + expect(moderationItem).toEqual( + expect.objectContaining({ + sourceId: 'm', + createdById: '1', + sharedPostId: 'p1', + status: SourcePostModerationStatus.Pending, + title, + }), + ); + }); + }); + + describe('submit link post creation', () => { + it('should successfully create link post in multiple squads', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { + variables: { + ...freeformParams, + externalLink: 'https://www.google.com', + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(3); + expect(res.data.createPostInMultipleSources).toEqual([ + expect.objectContaining({ type: 'post', sourceId: 'squad' }), + expect.objectContaining({ type: 'moderationItem', sourceId: 'm' }), + expect.objectContaining({ type: 'post', sourceId: '1' }), + ]); + const [first, second, third] = res.data.createPostInMultipleSources; + + // Verify posts were actually created + const [post, moderationItem, userSourcePost] = await Promise.all([ + await con.getRepository(SharePost).findOneByOrFail({ id: first.id }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: ['sourceId', 'sharedPostId'], + where: { id: second.id, status: SourcePostModerationStatus.Pending }, + }), + await con.getRepository(SharePost).findOneByOrFail({ + id: third.id, + sourceId: '1', + }), + ]); + + expect(post.sharedPostId).toBe(moderationItem.sharedPostId); + expect(post.sharedPostId).toBe(userSourcePost.sharedPostId); + expect(post.sharedPostId).toBeTruthy(); + }); + + it('should share post when already existing url is submitted', async () => { + loggedUser = '1'; + // Get one existent URL from the database + const existingArticle = await con + .getRepository(ArticlePost) + .findOneOrFail({ + select: ['id', 'url', 'sourceId'], + where: { url: Not('NULL') }, + }); + const { url } = existingArticle; + // ensure user is a member in that source + await con.getRepository(SourceMember).save({ + userId: '1', + sourceId: existingArticle.sourceId, + role: SourceMemberRoles.Member, + referralToken: 'rt1-existing', + }); + + // create multiple post with same URL + const res = await client.mutate(MUTATION, { + variables: { + ...freeformParams, + externalLink: url, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(3); + expect(res.data.createPostInMultipleSources).toEqual([ + expect.objectContaining({ type: 'post', sourceId: 'squad' }), + expect.objectContaining({ type: 'moderationItem', sourceId: 'm' }), + expect.objectContaining({ type: 'post', sourceId: '1' }), + ]); + const [first, second, third] = res.data.createPostInMultipleSources; + + // Verify posts were actually created + const [post, moderationItem, userSourcePost] = await Promise.all([ + await con.getRepository(SharePost).findOneByOrFail({ id: first.id }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: ['sharedPostId'], + where: { id: second.id }, + }), + await con.getRepository(SharePost).findOneByOrFail({ + id: third.id, + sourceId: '1', + }), + ]); + + expect(post.sharedPostId).toBe(existingArticle.id); + expect(moderationItem.sharedPostId).toBe(existingArticle.id); + expect(userSourcePost.sharedPostId).toBe(existingArticle.id); + }); + }); + + describe('poll post creation', () => { + const pollParams = { + sourceIds: ['1', 'squad', 'm'], + title: 'Poll post', + options: [ + { text: 'Option 1', order: 1 }, + { text: 'Option 2', order: 2 }, + ], + duration: 3, + }; + + it('should successfully create poll post in multiple squads', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { variables: pollParams }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createPostInMultipleSources).toHaveLength(3); + + const [first, second, third] = res.data.createPostInMultipleSources; + expect(first.type).toBe('post'); + expect(first.sourceId).toBe('1'); + expect(second.type).toBe('post'); + expect(second.sourceId).toBe('squad'); + expect(third.type).toBe('moderationItem'); + expect(third.sourceId).toBe('m'); + + // Verify posts were actually created + const [userSourcePost, post, moderationItem] = await Promise.all([ + await con + .getRepository(PollPost) + .findOneByOrFail({ id: first.id, sourceId: '1' }), + await con.getRepository(PollPost).findOneByOrFail({ + id: second.id, + sourceId: 'squad', + }), + await con.getRepository(SourcePostModeration).findOneOrFail({ + select: ['sourceId', 'createdById', 'title', 'pollOptions', 'status'], + where: { id: third.id }, + }), + ]); + + expect(userSourcePost).toStrictEqual( + expect.objectContaining({ + sourceId: '1', + authorId: '1', + title: pollParams.title, + }), + ); + const firstOptions = await userSourcePost.pollOptions; + expect(firstOptions).toHaveLength(2); + + expect(post).toStrictEqual( + expect.objectContaining({ + sourceId: 'squad', + }), + ); + const secondOptions = await post.pollOptions; + expect(secondOptions).toHaveLength(2); + + expect(moderationItem).toStrictEqual( + expect.objectContaining({ + sourceId: 'm', + createdById: '1', + title: pollParams.title, + status: 'pending', + }), + ); + expect(moderationItem.pollOptions).toHaveLength(2); + }); + + it('should handle single squad posting', async () => {}); + }); + + describe('warning reasons', () => { + it('should add warning reason when posting in multiple squads', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { variables: freeformParams }); + const addedModerationItem = res.data.createPostInMultipleSources + .filter((item: { type: string }) => item.type === 'moderationItem') + .at(0); + + const moderationItem = await con + .getRepository(SourcePostModeration) + .findOneOrFail({ + where: { id: addedModerationItem.id }, + select: ['flags', 'createdBy', 'sourceId'], + }); + + expect(moderationItem.flags.warningReason).toEqual( + WarningReason.MultipleSquadPost, + ); + }); + + it('should not add warning reason when posting in single squad', async () => { + loggedUser = '1'; + const singleParams = { ...freeformParams, sourceIds: ['m'] }; + const res = await client.mutate(MUTATION, { variables: singleParams }); + const [addedModerationItem] = res.data.createPostInMultipleSources; + const moderationItem = await con + .getRepository(SourcePostModeration) + .findOneOrFail({ + where: { id: addedModerationItem.id }, + select: ['flags', 'createdById', 'sourceId'], + }); + expect(moderationItem.flags.warningReason).toBeFalsy(); + expect(moderationItem.createdById).toEqual('1'); + expect(moderationItem.sourceId).toEqual('m'); + }); + + it('should add warning reason when the same post is shared twice in the same squad', async () => { + loggedUser = '1'; + const variables = { + sourceIds: ['m'], + sharedPostId: 'p1', + }; + // add it once + const res1 = await client.mutate(MUTATION, { variables }); + expect(res1.errors).toBeFalsy(); + const firstId = res1.data.createPostInMultipleSources[0].id; + + await deleteKeysByPattern(`${rateLimiterName}:*`); + + // add it again + const res2 = await client.mutate(MUTATION, { variables }); + expect(res2.errors).toBeFalsy(); + const secondId = res2.data.createPostInMultipleSources[0].id; + + expect(firstId).not.toBe(secondId); + const [firstPost, secondPost] = await Promise.all([ + await con + .getRepository(SourcePostModeration) + .findOneByOrFail({ id: firstId, sourceId: 'm' }), + await con + .getRepository(SourcePostModeration) + .findOneByOrFail({ id: secondId, sourceId: 'm' }), + ]); + + expect(firstPost.flags.warningReason).toBeFalsy(); + expect(secondPost.flags.warningReason).toBe( + WarningReason.DuplicatedInSameSquad, + ); + }); + + it('should add warning reason when the same post is shared twice in the different squads', async () => { + loggedUser = '1'; + const variables = { + sourceIds: ['m'], + sharedPostId: 'p1', + }; + // add it once + const res1 = await client.mutate(MUTATION, { + variables: { ...variables, sourceIds: ['squad'] }, + }); + expect(res1.errors).toBeFalsy(); + const firstId = res1.data.createPostInMultipleSources[0].id; + + await deleteKeysByPattern(`${rateLimiterName}:*`); + + // add it again + const res2 = await client.mutate(MUTATION, { variables }); + expect(res2.errors).toBeFalsy(); + const secondId = res2.data.createPostInMultipleSources[0].id; + + expect(firstId).not.toBe(secondId); + const [firstPost, secondPost] = await Promise.all([ + await con + .getRepository(SharePost) + .findOneByOrFail({ id: firstId, sourceId: 'squad' }), + await con + .getRepository(SourcePostModeration) + .findOneByOrFail({ id: secondId, sourceId: 'm' }), + ]); + + expect(firstPost.flags.dedupKey).toBe('p1'); + expect(secondPost.flags.dedupKey).toBe('p1'); + expect(secondPost.flags.warningReason).toBe( + WarningReason.MultipleSquadPost, + ); + }); + }); + + describe('notifications', () => { + it('should set same uniqueKey for detected duplicated posts', async () => { + loggedUser = '1'; + await con.getRepository(SquadSource).save({ + id: 's1', + handle: 's1', + name: 'Squad', + private: true, + }); + await con.getRepository(SourceMember).save({ + userId: '1', + sourceId: 's1', + role: SourceMemberRoles.Member, + referralToken: 'rt1-s1', + }); + + const res = await client.mutate(MUTATION, { + variables: { ...shareParams, sourceIds: ['s1', 'squad'] }, + }); + expect(res.errors).toBeFalsy(); + const [first, second] = res.data.createPostInMultipleSources; + const [firstPost, secondPost] = await Promise.all([ + con.getRepository(SharePost).findOneByOrFail({ id: first.id }), + con.getRepository(SharePost).findOneByOrFail({ id: second.id }), + ]); + const actual1 = + await invokeTypedNotificationWorker<'api.v1.post-visible'>(postAdded, { + post: firstPost, + }); + const actual2 = + await invokeTypedNotificationWorker<'api.v1.post-visible'>(postAdded, { + post: secondPost, + }); + + expect(actual1).toHaveLength(1); + expect(actual1![0].ctx.dedupKey).toBe(shareParams.sharedPostId); + expect(actual2).toHaveLength(0); + + const uniqueKey = generateUserNotificationUniqueKey({ + type: NotificationType.SquadPostAdded, + dedupKey: actual1![0].ctx.dedupKey, + referenceId: (actual1![0].ctx as NotificationPostContext).post.id, + referenceType: 'post', + }); + + expect(uniqueKey).toBe( + `post_added:dedup_${actual1![0].ctx.dedupKey}:post`, + ); + }); + }); +}); + describe('mutation viewPost', () => { const MUTATION = ` mutation ViewPost($id: ID!) { diff --git a/src/common/post.ts b/src/common/post.ts index 16ba4bd2ad..89bab99609 100644 --- a/src/common/post.ts +++ b/src/common/post.ts @@ -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 getDuplicatedPostBy = async ( + con: DataSource, + { dedupKey, sourceId }: Partial>, +): Promise => { + if (!dedupKey) return null; + + return await queryReadReplica(con, async ({ queryRunner }) => { + const pendingQb = queryRunner.manager + .getRepository(SourcePostModeration) + .createQueryBuilder('p') + .where({ + status: SourcePostModerationStatus.Pending, + }) + .andWhere(`p.flags->> 'dedupKey' = :dedupKey`, { dedupKey }); + const postQb = queryRunner.manager + .getRepository(Post) + .createQueryBuilder('p') + .where(`p.flags->> 'dedupKey' = :dedupKey`, { dedupKey }); + + if (sourceId) { + pendingQb + .orderBy(`CASE WHEN p.sourceId = :sourceId THEN 0 ELSE 1 END`, 'ASC') + .addOrderBy('p.sourceId', 'ASC') + .setParameter('sourceId', sourceId); + postQb + .orderBy(`CASE WHEN p.sourceId = :sourceId THEN 0 ELSE 1 END`, 'ASC') + .addOrderBy('p.sourceId', 'ASC') + .setParameter('sourceId', sourceId); + } + + const [pendingExists, postExists] = await Promise.all([ + pendingQb.getOne(), + postQb.getOne(), + ]); + + return postExists || pendingExists; + }); +}; + +const getModerationWarningFlag = async ({ + con, + isMultiPost = false, + dedupKey, + sourceId, +}: { + con: DataSource; + isMultiPost?: boolean; + dedupKey?: string; + sourceId?: string; +}): Promise => { + if (isMultiPost) { + return WarningReason.MultipleSquadPost; + } + + if (!dedupKey) { + return; + } + + const duplicatedPost = await getDuplicatedPostBy(con, { + dedupKey, + sourceId, + }); + + if (!duplicatedPost) { + return; + } + + return sourceId && duplicatedPost.sourceId === sourceId + ? WarningReason.DuplicatedInSameSquad + : WarningReason.MultipleSquadPost; +}; + 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,175 @@ export interface CreatePollPostProps duration: number; } +export interface CreateMultipleSourcePostProps + extends Omit, + Pick { + sharedPostId?: string; + externalLink?: string; + sourceIds: string[]; +} + +const MAX_MULTIPLE_POST_SOURCE_LIMIT = 4; + 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>(), + sourceIds: z.array(z.string()).min(1).max(MAX_MULTIPLE_POST_SOURCE_LIMIT), + sharedPostId: z.string().optional(), + externalLink: z.httpUrl().optional(), + }) + .extend( + pollCreationSchema + .pick({ + options: true, + duration: true, + }) + .partial().shape, + ); + +type CreatePostInSourceArgs = Omit< + z.infer, + 'sourceIds' +>; + +export const getMultipleSourcesPostType = ( + args: CreatePostInSourceArgs, +): 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 ( + con: DataSource | EntityManager, + ctx: AuthContext, + sourceId: string, + args: CreatePostInSourceArgs, +): Promise> => { + const type = getMultipleSourcesPostType(args); + 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!, + }), + ), + }, + }); + } + case PostType.Freeform: { + return await createFreeformPost(con, ctx, { + ...args, + sourceId, + }); + } + default: { + throw new Error('Invalid post type detected'); + } + } +}; + +export const getPostIdFromUrlOrCreateOne = async ( + ctx: AuthContext, + args: CreatePostInSourceArgs, +): Promise> => { + if (!args.externalLink) { + throw new Error('External link is required'); + } + + const { url, canonicalUrl } = standardizeURL(args.externalLink!); + const existingPost = await getExistingPost(ctx.con, { url, canonicalUrl }); + + if (existingPost) { + return existingPost; + } + + const id = await generateShortId(); + await createExternalLink({ + con: ctx.con, + ctx, + args: { + id, + authorId: ctx.userId, + url, + canonicalUrl, + title: args.title, + image: await args.image, + commentary: args.content, + originalUrl: args.externalLink, + }, + }); + + return { id }; +}; + type ValidatePostArgs = Pick; export const validatePost = ( @@ -702,7 +965,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 }; } @@ -756,7 +1019,7 @@ export const processApprovedModeratedPost = async ( originalUrl: externalLink, }, }); - return { ...moderated, postId: post.id }; + return { ...moderated, postId: post?.id }; } logger.error({ moderated }, 'unable to process moderated post'); @@ -1104,3 +1367,46 @@ export const ensurePostAnalyticsPermissions = async ({ ); } }; + +export const createFreeformPost = async ( + con: EntityManager | DataSource, + ctx: AuthContext, + args: CreatePostArgs, +) => { + const { sourceId, image } = args; + const { userId } = ctx; + const id = await generateShortId(); + const { title, content } = validatePost(args); + + if (!title) { + throw new ValidationError('Title can not be an empty string!'); + } + + await con.transaction(async (manager) => { + 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 }; +}; diff --git a/src/entity/SourcePostModeration.ts b/src/entity/SourcePostModeration.ts index 6bfeb29386..f86c71cf6a 100644 --- a/src/entity/SourcePostModeration.ts +++ b/src/entity/SourcePostModeration.ts @@ -46,8 +46,15 @@ export const rejectReason: Record = { [PostModerationReason.Other]: 'Other', }; +export enum WarningReason { + MultipleSquadPost = 'multiple_squad_post', + DuplicatedInSameSquad = 'duplicated_in_squad', +} + export type SourcePostModerationFlags = Partial<{ vordr: boolean; + warningReason: WarningReason; + dedupKey: string; }>; export type CreatePollOption = Pick; @@ -142,6 +149,7 @@ export class SourcePostModeration { @Column({ type: 'jsonb', default: {} }) @Index('IDX_source_post_moderation_flags_vordr', { synchronize: false }) + @Index('IDX_source_post_moderation_flags_dedupKey', { synchronize: false }) flags: SourcePostModerationFlags; @Column({ type: 'jsonb', default: [] }) diff --git a/src/entity/posts/utils.ts b/src/entity/posts/utils.ts index ff6928f2ad..8dcd01fa1e 100644 --- a/src/entity/posts/utils.ts +++ b/src/entity/posts/utils.ts @@ -35,10 +35,10 @@ import { logger } from '../../logger'; import { FastifyRequest } from 'fastify'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { - applyVordrHook, - applyVordrHookForUpdate, applyDeduplicationHook, applyDeduplicationHookForUpdate, + applyVordrHook, + applyVordrHookForUpdate, } from './hooks'; export type PostStats = { @@ -281,13 +281,14 @@ interface CreateExternalLinkArgs { con: ConnectionManager; ctx?: AuthContext; args: { + id?: string; title?: string | null; commentary?: string | null; url: string; canonicalUrl?: string; image?: string | null; authorId: string; - sourceId: string; + sourceId?: string; originalUrl: string; }; } @@ -337,8 +338,9 @@ export const createExternalLink = async ({ con, ctx, args, -}: CreateExternalLinkArgs): Promise => { +}: CreateExternalLinkArgs): Promise => { const { + id = await generateShortId(), title, url, canonicalUrl, @@ -349,7 +351,6 @@ export const createExternalLink = async ({ originalUrl, } = args; validateCommentary(commentary!); - const id = await generateShortId(); const isVisible = !!title; return con.transaction(async (entityManager) => { @@ -382,24 +383,28 @@ export const createExternalLink = async ({ }); await entityManager.getRepository(ArticlePost).insert(postData); - const post = await createSharePost({ - con: entityManager, - ctx, - args: { - authorId, - postId: id, - sourceId, - commentary, - visible: isVisible, - }, - }); + await notifyContentRequested(ctx?.log || logger, { id, url, origin: PostOrigin.Squad, }); - return post; + if (sourceId) { + return await createSharePost({ + con: entityManager, + ctx, + args: { + authorId, + postId: id, + sourceId, + commentary, + visible: isVisible, + }, + }); + } + + return null; }); }; @@ -471,6 +476,7 @@ export const createSharePost = async ({ private: privacy, visible, }, + type: PostType.Share, } as DeepPartial); // Apply vordr checks before saving diff --git a/src/migration/1758797445760-SourcePostModerationFlagsDedup.ts b/src/migration/1758797445760-SourcePostModerationFlagsDedup.ts new file mode 100644 index 0000000000..d532f39a76 --- /dev/null +++ b/src/migration/1758797445760-SourcePostModerationFlagsDedup.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SourcePostModerationFlagsDedup1758797445760 + implements MigrationInterface +{ + name = 'SourcePostModerationFlagsDedup1758797445760'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_source_post_moderation_flags_dedupKey" ON source_post_moderation USING HASH ((flags->>'dedupKey'))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_source_post_moderation_flags_dedupKey"`, + ); + } +} diff --git a/src/notifications/common.ts b/src/notifications/common.ts index 5ed31b09f3..cbafa002e0 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -482,10 +482,12 @@ export const generateUserNotificationUniqueKey = ({ type, referenceId, referenceType, + dedupKey, }: { type: NotificationType; referenceId?: string; referenceType?: NotificationReferenceType; + dedupKey?: string; }): string | null => { const uniqueKey = notificationTypeToUniqueKey[type]; @@ -493,7 +495,13 @@ export const generateUserNotificationUniqueKey = ({ return null; } - return [uniqueKey, referenceId, referenceType].filter(Boolean).join(':'); + return [ + uniqueKey, + dedupKey ? `dedup_${dedupKey}` : referenceId, + referenceType, + ] + .filter(Boolean) + .join(':'); }; export const cleanupSourcePostModerationNotifications = async ( diff --git a/src/notifications/index.ts b/src/notifications/index.ts index acfdcfc37a..fa429f6ceb 100644 --- a/src/notifications/index.ts +++ b/src/notifications/index.ts @@ -123,6 +123,7 @@ async function upsertAttachments( export async function storeNotificationBundleV2( entityManager: EntityManager, bundle: NotificationBundleV2, + dedupKey?: string, ): Promise<{ id: string }[]> { const [avatars, attachments] = await Promise.all([ upsertAvatarsV2(entityManager, bundle.avatars || []), @@ -147,7 +148,10 @@ export async function storeNotificationBundleV2( } const notification = generatedMaps[0] as NotificationV2; - const uniqueKey = generateUserNotificationUniqueKey(notification); + const uniqueKey = generateUserNotificationUniqueKey({ + ...notification, + dedupKey, + }); const chunkSize = 500; @@ -262,7 +266,7 @@ export async function generateAndStoreNotificationsV2( if (!bundle.userIds.length) { return; } - return storeNotificationBundleV2(entityManager, bundle); + return storeNotificationBundleV2(entityManager, bundle, ctx.dedupKey); }), ); } diff --git a/src/notifications/types.ts b/src/notifications/types.ts index 76d036b267..016c8e7c0c 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -38,6 +38,7 @@ export type NotificationBaseContext = { userIds: string[]; initiatorId?: string | null; sendAtMs?: number; + dedupKey?: string; }; export type NotificationSubmissionContext = NotificationBaseContext & { submission: Pick; diff --git a/src/schema/posts.ts b/src/schema/posts.ts index 5356d6d85e..272ad5b888 100644 --- a/src/schema/posts.ts +++ b/src/schema/posts.ts @@ -17,42 +17,47 @@ import { import { AuthContext, BaseContext, Context } from '../Context'; import { traceResolvers } from './trace'; import { + checkIfUserPostInSourceDirectlyOrThrow, createFreeformPost, + CreateMultipleSourcePostProps, createPollPost, - CreatePost, + type CreatePollPostProps, CreatePostArgs, + createPostIntoSourceId, createSourcePostModeration, CreateSourcePostModerationArgs, DEFAULT_POST_TITLE, EditablePost, EditPostArgs, + ensurePostAnalyticsPermissions, fetchLinkPreview, + findPostByUrl, + getAllModerationItemsAsAdmin, getDiscussionLink, + getModerationItemsAsAdminForSource, + getModerationItemsByUserForSource, + getMultipleSourcesPostType, + getPostIdFromUrlOrCreateOne, + getPostSmartTitle, + getPostTranslatedTitle, + getTranslationRecord, + type GQLSourcePostModeration, isValidHttpUrl, mapCloudinaryUrl, notifyView, ONE_MINUTE_IN_SECONDS, + parseBigInt, pickImageUrl, + postInMultipleSourcesArgsSchema, + type SourcePostModerationArgs, standardizeURL, + systemUser, toGQLEnum, updateFlagsStatement, uploadPostFile, UploadPreset, validatePost, validateSourcePostModeration, - getPostTranslatedTitle, - getPostSmartTitle, - getModerationItemsAsAdminForSource, - getModerationItemsByUserForSource, - type GQLSourcePostModeration, - type SourcePostModerationArgs, - getAllModerationItemsAsAdmin, - getTranslationRecord, - systemUser, - parseBigInt, - findPostByUrl, - ensurePostAnalyticsPermissions, - type CreatePollPostProps, } from '../common'; import { ContentImage, @@ -116,7 +121,12 @@ import { generateStorageKey, StorageTopic } from '../config'; import { isBefore, subDays } from 'date-fns'; import { ReportReason } from '../entity/common'; import { reportPost, saveHiddenPost } from '../common/reporting'; -import { PostCodeSnippetLanguage, UserVote } from '../types'; +import { + MultipleSourcesPostItemType, + MultipleSourcesPostResult, + PostCodeSnippetLanguage, + UserVote, +} from '../types'; import { PostCodeSnippet } from '../entity/posts/PostCodeSnippet'; import { SourcePostModeration, @@ -292,6 +302,17 @@ export type GQLPostAnalyticsHistory = Omit< export const typeDefs = /* GraphQL */ ` ${toGQLEnum(BriefingType, 'BriefingType')} + ${toGQLEnum(MultipleSourcesPostItemType, 'MultiplePostItemType')} + + """ + Multiple post item + """ + type MultiplePostItem { + type: MultiplePostItemType! + sourceId: ID! + id: ID! + slug: String + } """ Post moderation item @@ -1299,6 +1320,52 @@ export const typeDefs = /* GraphQL */ ` duration: Int ): SourcePostModeration! @auth @rateLimit(limit: 1, duration: 30) + """ + Create multiple source posts + """ + createPostInMultipleSources( + """ + Ids of the Sources to post into + """ + sourceIds: [ID!]! + """ + content of the post + """ + content: String + """ + Commentary on the post + """ + commentary: String + """ + title of the post + """ + title: String + """ + Image to upload + """ + image: Upload + """ + Image URL to use + """ + imageUrl: String + """ + ID of the post to share + """ + sharedPostId: ID + """ + External link of the post + """ + externalLink: String + """ + Poll options + """ + options: [PollOptionInput!] + """ + Duration in days + """ + duration: Int + ): [MultiplePostItem]! @auth @rateLimit(limit: 1, duration: 30) + """ Hide a post from all the user feeds """ @@ -2515,19 +2582,13 @@ export const resolvers: IResolvers = traceResolvers< return { _: true }; }, createFreeformPost: async ( - source, + _, args: CreatePostArgs, ctx: AuthContext, info, ): Promise => { - const { sourceId, image } = args; + const { sourceId } = 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!'); - } if (sourceId === userId) { await ensureUserSourceExists(userId, con); @@ -2537,31 +2598,8 @@ export const resolvers: IResolvers = traceResolvers< ensureSourcePermissions(ctx, sourceId, SourcePermissions.Post), ensurePostRateLimit(ctx.con, ctx.userId), ]); - await con.transaction(async (manager) => { - 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 createFreeformPost({ con: manager, ctx, args: params }); - await saveMentions(manager, id, userId, mentions, PostMention); - }); + const { id } = await createFreeformPost(ctx.con, ctx, args); return graphorm.queryOneOrFail(ctx, info, (builder) => ({ ...builder, @@ -2572,7 +2610,7 @@ export const resolvers: IResolvers = traceResolvers< })); }, editPost: async ( - source, + _, args: EditPostArgs, ctx: AuthContext, info, @@ -3271,6 +3309,81 @@ export const resolvers: IResolvers = traceResolvers< return getPostById(ctx, info, args.postId); }, + createPostInMultipleSources: async ( + _, + input: CreateMultipleSourcePostProps, + ctx: AuthContext, + ): Promise> => { + const args = postInMultipleSourcesArgsSchema.parse(input); + + const { sourceIds, ...postArgs } = args; + const detectedPostType = getMultipleSourcesPostType(args); + + await ensurePostRateLimit(ctx.con, ctx.userId); + const isPostingToSelfSource = sourceIds.includes(ctx.userId); + if (isPostingToSelfSource) { + await ensureUserSourceExists(ctx.userId, ctx.con); + } + + const hasExternalLink = !!postArgs.externalLink; + if (hasExternalLink) { + const { id } = await getPostIdFromUrlOrCreateOne(ctx, postArgs); + postArgs.sharedPostId = id; + } + + return await ctx.con.transaction(async (entityManager) => { + const output: Array = []; + for (const sourceId of sourceIds) { + const canUserPostDirectly = + await checkIfUserPostInSourceDirectlyOrThrow(ctx.con, { + sourceId, + userId: ctx.userId, + }); + + if (canUserPostDirectly) { + // directly post on squad + const { id } = await createPostIntoSourceId( + entityManager, + ctx, + sourceId, + postArgs, + ); + output.push({ + id, + type: MultipleSourcesPostItemType.Post, + sourceId, + }); + + continue; + } + + // OR create a pending post instead + const isMultiPost = sourceIds.length > 1; + const { options, ...pendingArgs } = postArgs; + const pendingPost = await validateSourcePostModeration(ctx, { + ...pendingArgs, + sourceId, + type: + detectedPostType === PostType.Article + ? PostType.Share + : detectedPostType, + pollOptions: options, + }); + const { id } = await createSourcePostModeration({ + ctx, + args: pendingPost, + options: { isMultiPost, entityManager }, + }); + output.push({ + id, + type: MultipleSourcesPostItemType.ModerationItem, + sourceId, + }); + } + + return output; + }); + }, }, Subscription: { postsEngaged: { diff --git a/src/schema/sources.ts b/src/schema/sources.ts index 991b333325..42d904cf52 100644 --- a/src/schema/sources.ts +++ b/src/schema/sources.ts @@ -1043,9 +1043,7 @@ export const canAccessSource = async ( if (permission === SourcePermissions.View && !source.private) { if (sourceTypesWithMembers.includes(source.type)) { const isMemberBlocked = member?.role === SourceMemberRoles.Blocked; - const canAccess = !isMemberBlocked; - - return canAccess; + return !isMemberBlocked; } return true; @@ -1056,7 +1054,7 @@ export const canAccessSource = async ( } const sourceId = source.id; - const repo = ctx.getRepository(SourceMember); + const repo = ctx.con.getRepository(SourceMember); const validateRankAgainst = await (requireGreaterAccessPrivilege[permission] ? repo.findOneByOrFail({ sourceId, userId: validateRankAgainstId }) : Promise.resolve(undefined)); @@ -1113,7 +1111,6 @@ export const isPrivilegedMember = async ( type PostPermissions = SourcePermissions.Post | SourcePermissions.PostRequest; export const canPostToSquad = ( - ctx: Context, squad: SquadSource, sourceMember: SourceMember | null, permission: PostPermissions = SourcePermissions.Post, @@ -1230,7 +1227,6 @@ export const ensureSourcePermissions = async ( source.type === SourceType.Squad && postPermissions.includes(permission) && !canPostToSquad( - ctx, source as SquadSource, sourceMember, permission as PostPermissions, diff --git a/src/types.ts b/src/types.ts index 592a2fd25f..0056b89670 100644 --- a/src/types.ts +++ b/src/types.ts @@ -289,6 +289,17 @@ export const clickhouseMigrationsDir = 'clickhouse/migrations'; export const clickhouseMigrationFilenameMatch = /^(\d+)_([a-zA-Z_]+)\.(up|down)\.sql$/i; +export enum MultipleSourcesPostItemType { + Post = 'post', + ModerationItem = 'moderationItem', +} + +export interface MultipleSourcesPostResult { + id: string; + type: MultipleSourcesPostItemType; + sourceId: string; +} + export type ServiceClient = { instance: Client; garmr: GarmrService; diff --git a/src/workers/notifications/utils.ts b/src/workers/notifications/utils.ts index 66a73b8da4..cc221c151a 100644 --- a/src/workers/notifications/utils.ts +++ b/src/workers/notifications/utils.ts @@ -135,6 +135,7 @@ export const buildPostContext = async ( post, source: await post.source, sharedPost, + dedupKey: post.flags.dedupKey, }; } return null;