From 8782ae472f6bb6a729034690baa8686d32844bd6 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 31 Oct 2024 16:16:20 +0100 Subject: [PATCH 1/4] feat: add block mutations --- __tests__/contentPreference.ts | 454 +++++++++++++++++++++++++++++++- src/common/contentPreference.ts | 129 +++++++++ src/schema/contentPreference.ts | 57 +++- 3 files changed, 638 insertions(+), 2 deletions(-) diff --git a/__tests__/contentPreference.ts b/__tests__/contentPreference.ts index 7bdf88a84..bc085c221 100644 --- a/__tests__/contentPreference.ts +++ b/__tests__/contentPreference.ts @@ -22,7 +22,14 @@ import { ContentPreferenceType, } from '../src/entity/contentPreference/types'; import { NotificationPreferenceUser } from '../src/entity/notifications/NotificationPreferenceUser'; -import { Feed, FeedSource, Keyword, Source, SourceType } from '../src/entity'; +import { + Feed, + FeedSource, + FeedTag, + Keyword, + Source, + SourceType, +} from '../src/entity'; import { ContentPreferenceFeedKeyword } from '../src/entity/contentPreference/ContentPreferenceFeedKeyword'; import { ContentPreferenceSource } from '../src/entity/contentPreference/ContentPreferenceSource'; import { @@ -1103,3 +1110,448 @@ describe('query contentPreferenceStatus', () => { ); }); }); + +describe('mutation block', () => { + const MUTATION = `mutation Block($id: ID!, $entity: ContentPreferenceType!) { + block(id: $id, entity: $entity) { + _ + } + }`; + + beforeEach(async () => { + await saveFixtures( + con, + User, + usersFixture.map((item) => { + return { + ...item, + id: `${item.id}-blm`, + username: `${item.username}-blm`, + }; + }), + ); + }); + + it('should block user', async () => { + loggedUser = '1-blm'; + + const res = await client.query(MUTATION, { + variables: { + id: '3-blm', + entity: ContentPreferenceType.User, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceUser) + .findOneBy({ + userId: '1-blm', + referenceId: '3-blm', + }); + + expect(contentPreference).not.toBeNull(); + expect(contentPreference!.status).toBe(ContentPreferenceStatus.Blocked); + + const notificationPreferences = await con + .getRepository(NotificationPreferenceUser) + .findBy({ + userId: '1-blm', + referenceUserId: '3-blm', + }); + + expect(notificationPreferences).toHaveLength(0); + }); + + it('should not block yourself', async () => { + loggedUser = '1-blm'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '1-blm', + entity: ContentPreferenceType.User, + }, + }, + 'CONFLICT', + ); + }); + + it('should not block ghost user', async () => { + loggedUser = '1-blm'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: ghostUser.id, + entity: ContentPreferenceType.User, + }, + }, + 'CONFLICT', + ); + }); + + describe('keyword', () => { + beforeEach(async () => { + await saveFixtures(con, Keyword, [ + { value: 'keyword-bl1', occurrences: 300, status: 'allow' }, + { value: 'keyword-bl2', occurrences: 200, status: 'allow' }, + { value: 'keyword-bl3', occurrences: 100, status: 'allow' }, + ]); + + await saveFixtures(con, Feed, [{ id: '1-blm', userId: '1-blm' }]); + }); + + it('should block', async () => { + loggedUser = '1-blm'; + + const res = await client.query(MUTATION, { + variables: { + id: 'keyword-bl1', + entity: ContentPreferenceType.Keyword, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceFeedKeyword) + .findOneBy({ + userId: '1-blm', + referenceId: 'keyword-bl1', + }); + + expect(contentPreference).not.toBeNull(); + expect(contentPreference!.status).toBe(ContentPreferenceStatus.Blocked); + + const feedTag = await con.getRepository(FeedTag).findOneBy({ + feedId: loggedUser, + tag: 'keyword-bl1', + blocked: true, + }); + + expect(feedTag).not.toBeNull(); + expect(feedTag!.blocked).toBe(true); + }); + }); + + describe('source', () => { + beforeEach(async () => { + await saveFixtures(con, Source, [ + { + id: 'a-blm', + name: 'A-blm', + image: 'http://image.com/a-fm', + handle: 'a-blm', + type: SourceType.Machine, + }, + ]); + + await saveFixtures(con, Feed, [{ id: '1-blm', userId: '1-blm' }]); + }); + + it('should block', async () => { + loggedUser = '1-blm'; + + const res = await client.query(MUTATION, { + variables: { + id: 'a-blm', + entity: ContentPreferenceType.Source, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1-blm', + referenceId: 'a-blm', + }); + + expect(contentPreference).not.toBeNull(); + expect(contentPreference!.status).toBe(ContentPreferenceStatus.Blocked); + + const feedSource = await con.getRepository(FeedSource).findOneBy({ + feedId: '1-blm', + sourceId: 'a-blm', + }); + expect(feedSource).not.toBeNull(); + expect(feedSource!.blocked).toBe(true); + }); + }); +}); + +describe('mutation unblock', () => { + const MUTATION = `mutation Unblock($id: ID!, $entity: ContentPreferenceType!) { + unblock(id: $id, entity: $entity) { + _ + } + }`; + + beforeEach(async () => { + await saveFixtures( + con, + User, + usersFixture.map((item) => { + return { + ...item, + id: `${item.id}-ublm`, + username: `${item.username}-ublm`, + }; + }), + ); + + await con.getRepository(ContentPreferenceUser).save([ + { + userId: '1-ublm', + referenceId: '2-ublm', + referenceUserId: '2-ublm', + status: ContentPreferenceStatus.Blocked, + }, + { + userId: '2-ublm', + referenceId: '1-ublm', + referenceUserId: '1-ublm', + status: ContentPreferenceStatus.Blocked, + }, + ]); + }); + + it('should unblock user', async () => { + loggedUser = '1-ublm'; + + const res = await client.query(MUTATION, { + variables: { + id: '2-ublm', + entity: ContentPreferenceType.User, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceUser) + .findOneBy({ + userId: '1-ublm', + referenceId: '2-ublm', + }); + + expect(contentPreference).toBeNull(); + + const notificationPreferences = await con + .getRepository(NotificationPreferenceUser) + .findBy({ + userId: '1-ublm', + referenceUserId: '2-ublm', + }); + + expect(notificationPreferences).toHaveLength(0); + }); + + it('should do nothing if user is not blocked', async () => { + loggedUser = '1-ublm'; + + const res = await client.query(MUTATION, { + variables: { + id: '3-ublm', + entity: ContentPreferenceType.User, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceUser) + .findOneBy({ + userId: '1-ublm', + referenceId: '3-ublm', + }); + + expect(contentPreference).toBeNull(); + + const notificationPreferences = await con + .getRepository(NotificationPreferenceUser) + .findBy({ + userId: '1-ublm', + referenceUserId: '3-ublm', + }); + + expect(notificationPreferences).toHaveLength(0); + }); + + it('should remove notification preferences', async () => { + loggedUser = '1-ublm'; + + await con.getRepository(NotificationPreferenceUser).save([ + { + userId: '1-ublm', + referenceUserId: '2-ublm', + referenceId: '2-ublm', + status: NotificationPreferenceStatus.Subscribed, + notificationType: NotificationType.UserPostAdded, + }, + ]); + + const res = await client.query(MUTATION, { + variables: { + id: '2-ublm', + entity: ContentPreferenceType.User, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceUser) + .findOneBy({ + userId: '1-ublm', + referenceId: '2-ublm', + }); + + expect(contentPreference).toBeNull(); + + const notificationPreferences = await con + .getRepository(NotificationPreferenceUser) + .findBy({ + userId: '1-ublm', + referenceUserId: '2-ublm', + }); + + expect(notificationPreferences).toHaveLength(0); + }); + + describe('keyword', () => { + beforeEach(async () => { + await saveFixtures(con, Keyword, [ + { value: 'keyword-ublm1', occurrences: 300, status: 'allow' }, + { value: 'keyword-ublm2', occurrences: 200, status: 'allow' }, + { value: 'keyword-ublm3', occurrences: 100, status: 'allow' }, + ]); + + await saveFixtures(con, Feed, [{ id: '1-ublm', userId: '1-ublm' }]); + + await con.getRepository(ContentPreferenceFeedKeyword).save([ + { + userId: '1-ublm', + referenceId: 'keyword-ublm1', + feedId: '1-ublm', + status: ContentPreferenceStatus.Blocked, + }, + { + userId: '2-ublm', + referenceId: 'keyword-ublm2', + feedId: '1-ublm', + status: ContentPreferenceStatus.Blocked, + }, + ]); + + await con.getRepository(FeedTag).save([ + { + feedId: '1-ublm', + tag: 'keyword-ublm1', + blocked: true, + }, + { + feedId: '1-ublm', + tag: 'keyword-ublm2', + blocked: true, + }, + ]); + }); + + it('should unblock', async () => { + loggedUser = '1-ublm'; + + const res = await client.query(MUTATION, { + variables: { + id: '2-ublm', + entity: ContentPreferenceType.Keyword, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceFeedKeyword) + .findOneBy({ + userId: '2-ublm', + referenceId: 'keyword-ublm1', + }); + + expect(contentPreference).toBeNull(); + + const feedSource = await con.getRepository(FeedTag).findOneBy({ + feedId: '2-fm', + tag: 'keyword-ublm1', + }); + expect(feedSource).toBeNull(); + }); + }); + + describe('source', () => { + beforeEach(async () => { + await saveFixtures(con, Source, [ + { + id: 'a-ublm', + name: 'A-ublm', + image: 'http://image.com/a-ublm', + handle: 'a-ublm', + type: SourceType.Machine, + }, + ]); + + await saveFixtures(con, Feed, [{ id: '1-ublm', userId: '1-ublm' }]); + + await con.getRepository(ContentPreferenceSource).save([ + { + userId: '1-ublm', + referenceId: 'a-ublm', + feedId: '1-ublm', + status: ContentPreferenceStatus.Blocked, + }, + ]); + + await con.getRepository(FeedSource).save([ + { + feedId: '1-ublm', + sourceId: 'a-ublm', + blocked: true, + }, + ]); + }); + + it('should unblock', async () => { + loggedUser = '1-ublm'; + + const res = await client.query(MUTATION, { + variables: { + id: 'a-ublm', + entity: ContentPreferenceType.Source, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceFeedKeyword) + .findOneBy({ + userId: '1-ublm', + referenceId: 'a-ublm', + }); + + expect(contentPreference).toBeNull(); + + const feedSource = await con.getRepository(FeedSource).findOneBy({ + feedId: '1-ublm', + sourceId: 'a-ublm', + }); + expect(feedSource).toBeNull(); + }); + }); +}); diff --git a/src/common/contentPreference.ts b/src/common/contentPreference.ts index 2ee6857d1..2b4e6e7a0 100644 --- a/src/common/contentPreference.ts +++ b/src/common/contentPreference.ts @@ -36,6 +36,14 @@ type UnFollowEntity = ({ id: string; }) => Promise; +type BlockEntity = ({ + ctx, + id, +}: { + ctx: AuthContext; + id: string; +}) => Promise; + const entityToNotificationTypeMap: Record< ContentPreferenceType, NotificationType[] @@ -235,6 +243,93 @@ const unfollowSource: UnFollowEntity = async ({ ctx, id }) => { }); }; +const blockUser: BlockEntity = async ({ ctx, id }) => { + if (ctx.userId === id) { + throw new ConflictError('Cannot block yourself'); + } + + if (ghostUser.id === id) { + throw new ConflictError('Cannot block this user'); + } + + await ctx.con.transaction(async (entityManager) => { + const repository = entityManager.getRepository(ContentPreferenceUser); + + const contentPreference = repository.create({ + userId: ctx.userId, + referenceId: id, + referenceUserId: id, + status: ContentPreferenceStatus.Blocked, + }); + + await repository.save(contentPreference); + + cleanContentNotificationPreference({ + ctx, + entityManager, + id, + notificationTypes: entityToNotificationTypeMap.user, + notficationEntity: NotificationPreferenceUser, + }); + }); +}; + +const blockKeyword: BlockEntity = async ({ ctx, id }) => { + await ctx.con.transaction(async (entityManager) => { + const repository = entityManager.getRepository( + ContentPreferenceFeedKeyword, + ); + + const contentPreference = repository.create({ + userId: ctx.userId, + referenceId: id, + keywordId: id, + feedId: ctx.userId, + status: ContentPreferenceStatus.Blocked, + }); + + await repository.save(contentPreference); + + // TODO follow phase 3 remove when backward compatibility is done + await entityManager.getRepository(FeedTag).save({ + feedId: ctx.userId, + tag: id, + blocked: true, + }); + }); +}; + +const blockSource: BlockEntity = async ({ ctx, id }) => { + await ctx.con.transaction(async (entityManager) => { + const repository = entityManager.getRepository(ContentPreferenceSource); + + const contentPreference = repository.create({ + userId: ctx.userId, + referenceId: id, + sourceId: id, + feedId: ctx.userId, + status: ContentPreferenceStatus.Blocked, + }); + + await repository.save(contentPreference); + + cleanContentNotificationPreference({ + ctx, + entityManager, + id, + notificationTypes: entityToNotificationTypeMap.source, + notficationEntity: NotificationPreferenceSource, + }); + + // TODO follow phase 3 remove when backward compatibility is done + await entityManager.getRepository(FeedSource).save({ + feedId: ctx.userId, + sourceId: id, + blocked: true, + }); + }); +}; + export const followEntity = ({ ctx, id, @@ -278,3 +373,37 @@ export const unfollowEntity = ({ throw new Error('Entity not supported'); } }; + +export const blockEntity = async ({ + ctx, + id, + entity, +}: { + ctx: AuthContext; + id: string; + entity: ContentPreferenceType; +}): Promise => { + switch (entity) { + case ContentPreferenceType.User: + return blockUser({ ctx, id }); + case ContentPreferenceType.Keyword: + return blockKeyword({ ctx, id }); + case ContentPreferenceType.Source: + return blockSource({ ctx, id }); + default: + throw new Error('Entity not supported'); + } +}; + +export const unblockEntity = async ({ + ctx, + id, + entity, +}: { + ctx: AuthContext; + id: string; + entity: ContentPreferenceType; +}): Promise => { + // currently unblock is just like unfollow, eg. remove everything from db + return unfollowEntity({ ctx, id, entity }); +}; diff --git a/src/schema/contentPreference.ts b/src/schema/contentPreference.ts index f9954bf33..c6b91c093 100644 --- a/src/schema/contentPreference.ts +++ b/src/schema/contentPreference.ts @@ -7,7 +7,12 @@ import { ContentPreferenceStatus, ContentPreferenceType, } from '../entity/contentPreference/types'; -import { followEntity, unfollowEntity } from '../common/contentPreference'; +import { + blockEntity, + followEntity, + unblockEntity, + unfollowEntity, +} from '../common/contentPreference'; import { GQLEmptyResponse, offsetPageGenerator } from './common'; import graphorm from '../graphorm'; import { Connection, ConnectionArguments } from 'graphql-relay'; @@ -148,6 +153,34 @@ export const typeDefs = /* GraphQL */ ` """ entity: ContentPreferenceType! ): EmptyResponse @auth + + """ + Block entity + """ + block( + """ + Id of the entity + """ + id: ID! + """ + Entity to block (user, source..) + """ + entity: ContentPreferenceType! + ): EmptyResponse @auth + + """ + Unblock entity + """ + unblock( + """ + Id of the entity + """ + id: ID! + """ + Entity to unblock (user, source..) + """ + entity: ContentPreferenceType! + ): EmptyResponse @auth } `; @@ -304,6 +337,28 @@ export const resolvers: IResolvers = traceResolvers< ): Promise => { await unfollowEntity({ ctx, id, entity }); + return { + _: true, + }; + }, + block: async ( + _, + { id, entity }: { id: string; entity: ContentPreferenceType }, + ctx: AuthContext, + ): Promise => { + await blockEntity({ ctx, id, entity }); + + return { + _: true, + }; + }, + unblock: async ( + _, + { id, entity }: { id: string; entity: ContentPreferenceType }, + ctx: AuthContext, + ): Promise => { + await unblockEntity({ ctx, id, entity }); + return { _: true, }; From 3a96a653b691005197748337cfd0c4c1b43c5066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Bari=C4=87?= Date: Tue, 5 Nov 2024 09:39:29 +0100 Subject: [PATCH 2/4] feat: squad content preference (#2378) --- __tests__/contentPreference.ts | 87 ++++++++ __tests__/feeds.ts | 36 ++-- __tests__/sources.ts | 192 ++++++++++++++++++ src/common/contentPreference.ts | 40 +++- src/entity/SourceMember.ts | 8 - .../ContentPreferenceSource.ts | 14 +- .../1730390077794-ContentPreferenceSquad.ts | 17 ++ src/schema/feeds.ts | 10 + src/schema/sources.ts | 148 +++++++++++--- 9 files changed, 495 insertions(+), 57 deletions(-) create mode 100644 src/migration/1730390077794-ContentPreferenceSquad.ts diff --git a/__tests__/contentPreference.ts b/__tests__/contentPreference.ts index bc085c221..87b73a1c7 100644 --- a/__tests__/contentPreference.ts +++ b/__tests__/contentPreference.ts @@ -660,6 +660,7 @@ describe('mutation follow', () => { expect(contentPreference).not.toBeNull(); expect(contentPreference!.status).toBe(ContentPreferenceStatus.Follow); + expect(contentPreference!.flags.referralToken).not.toBeNull(); const feedSource = await con.getRepository(FeedSource).findOneBy({ feedId: '1-fm', @@ -701,6 +702,50 @@ describe('mutation follow', () => { expect(feedSource).not.toBeNull(); expect(feedSource!.blocked).toBe(false); }); + + it('should not overwrite referralToken if preference already exists', async () => { + loggedUser = '1-fm'; + + const res = await client.query(MUTATION, { + variables: { + id: 'a-fm', + entity: ContentPreferenceType.Source, + status: ContentPreferenceStatus.Follow, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreferenceBefore = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1-fm', + referenceId: 'a-fm', + }); + + expect(contentPreferenceBefore).not.toBeNull(); + + const res2 = await client.query(MUTATION, { + variables: { + id: 'a-fm', + entity: ContentPreferenceType.Source, + status: ContentPreferenceStatus.Subscribed, + }, + }); + + expect(res2.errors).toBeFalsy(); + + const contentPreferenceAfter = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1-fm', + referenceId: 'a-fm', + }); + + expect(contentPreferenceBefore!.flags.referralToken).toBe( + contentPreferenceAfter!.flags.referralToken, + ); + }); }); it('should not follow user if limit is reached', async () => { @@ -1284,6 +1329,48 @@ describe('mutation block', () => { expect(feedSource).not.toBeNull(); expect(feedSource!.blocked).toBe(true); }); + + it('should not overwrite referralToken if preference already exists', async () => { + loggedUser = '1-blm'; + + const res = await client.query(MUTATION, { + variables: { + id: 'a-blm', + entity: ContentPreferenceType.Source, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreferenceBefore = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1-blm', + referenceId: 'a-blm', + }); + + expect(contentPreferenceBefore).not.toBeNull(); + + const res2 = await client.query(MUTATION, { + variables: { + id: 'a-blm', + entity: ContentPreferenceType.Source, + }, + }); + + expect(res2.errors).toBeFalsy(); + + const contentPreferenceAfter = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1-blm', + referenceId: 'a-blm', + }); + + expect(contentPreferenceBefore!.flags.referralToken).toBe( + contentPreferenceAfter!.flags.referralToken, + ); + }); }); }); diff --git a/__tests__/feeds.ts b/__tests__/feeds.ts index ad67724fe..fab751cdc 100644 --- a/__tests__/feeds.ts +++ b/__tests__/feeds.ts @@ -217,9 +217,11 @@ const saveFeedFixtures = async (): Promise => { await saveFixtures(con, ContentPreferenceSource, [ { feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, referenceId: 'a', - role: SourceMemberRoles.Member, sourceId: 'a', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, @@ -227,9 +229,11 @@ const saveFeedFixtures = async (): Promise => { }, { feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, referenceId: 'b', - role: SourceMemberRoles.Member, sourceId: 'b', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, @@ -2177,9 +2181,11 @@ describe('mutation addFiltersToFeed', () => { { createdAt: expect.any(Date), feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: expect.any(String), + }, referenceId: 'a', - role: SourceMemberRoles.Member, sourceId: 'a', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, @@ -2188,9 +2194,11 @@ describe('mutation addFiltersToFeed', () => { { createdAt: expect.any(Date), feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: expect.any(String), + }, referenceId: 'b', - role: SourceMemberRoles.Member, sourceId: 'b', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, @@ -2427,9 +2435,11 @@ describe('mutation removeFiltersFromFeed', () => { { createdAt: expect.any(Date), feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: expect.any(String), + }, referenceId: 'a', - role: SourceMemberRoles.Member, sourceId: 'a', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, @@ -2438,9 +2448,11 @@ describe('mutation removeFiltersFromFeed', () => { { createdAt: expect.any(Date), feedId: '1', - flags: {}, + flags: { + role: SourceMemberRoles.Member, + referralToken: expect.any(String), + }, referenceId: 'b', - role: SourceMemberRoles.Member, sourceId: 'b', status: ContentPreferenceStatus.Blocked, type: ContentPreferenceType.Source, diff --git a/__tests__/sources.ts b/__tests__/sources.ts index bb2099226..864d7737f 100644 --- a/__tests__/sources.ts +++ b/__tests__/sources.ts @@ -5,6 +5,7 @@ import { isNullOrUndefined } from '../src/common/object'; import createOrGetConnection from '../src/db'; import { defaultPublicSourceFlags, + Feed, NotificationPreferenceSource, Post, PostKeyword, @@ -41,6 +42,8 @@ import { testMutationErrorCode, testQueryErrorCode, } from './helpers'; +import { ContentPreferenceSource } from '../src/entity/contentPreference/ContentPreferenceSource'; +import { ContentPreferenceStatus } from '../src/entity/contentPreference/types'; let con: DataSource; let state: GraphQLTestingState; @@ -113,6 +116,11 @@ beforeEach(async () => { sourcesFixture[5], ]); await saveFixtures(con, User, usersFixture); + await saveFixtures( + con, + Feed, + usersFixture.map((user) => ({ userId: user.id, id: user.id })), + ); await con .getRepository(Source) .update({ id: In(['a', 'b', 'c', 'squad']) }, { type: SourceType.Squad }); @@ -2479,6 +2487,30 @@ describe('mutation updateMemberRole', () => { referralToken: randomUUID(), createdAt: new Date(2022, 11, 20), }); + await con.getRepository(ContentPreferenceSource).save([ + { + userId: '2', + sourceId: 'a', + referenceId: 'a', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, + }, + { + userId: '3', + sourceId: 'a', + referenceId: 'a', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, + }, + ]); }); it('should not authorize when not logged in', () => @@ -2561,6 +2593,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Moderator); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Moderator); }); it('should allow admin to promote a moderator to an admin', async () => { @@ -2580,6 +2617,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Admin); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Admin); }); it('should allow admin to demote an admin to a moderator', async () => { @@ -2599,6 +2641,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Moderator); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Moderator); }); it('should allow admin to demote a moderator to a member', async () => { @@ -2618,6 +2665,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Member); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Member); }); it('should allow admin to remove and block an admin', async () => { @@ -2637,6 +2689,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Blocked); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Blocked); }); it('should allow admin to remove and block a moderator', async () => { @@ -2656,6 +2713,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Blocked); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Blocked); }); it('should allow admin to remove and block a member', async () => { @@ -2672,6 +2734,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '2', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Blocked); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '2', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Blocked); }); it('should restrict moderator to remove and block a moderator', async () => { @@ -2732,6 +2799,11 @@ describe('mutation updateMemberRole', () => { .getRepository(SourceMember) .findOneBy({ userId: '3', sourceId: 'a' }); expect(member.role).toEqual(SourceMemberRoles.Blocked); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '3', referenceId: 'a' }); + expect(contentPreference!.flags.role).toEqual(SourceMemberRoles.Blocked); }); }); @@ -2752,6 +2824,17 @@ describe('mutation unblockMember', () => { referralToken: randomUUID(), createdAt: new Date(2022, 11, 20), }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '3', + sourceId: 'a', + referenceId: 'a', + feedId: '1', + status: ContentPreferenceStatus.Blocked, + flags: { + role: SourceMemberRoles.Blocked, + referralToken: randomUUID(), + }, + }); }); it('should not authorize when not logged in', () => @@ -2802,6 +2885,11 @@ describe('mutation unblockMember', () => { .getRepository(SourceMember) .findOneBy({ userId: '3', sourceId: 'a' }); expect(member).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '3', referenceId: 'a' }); + expect(contentPreference).toBeNull(); }); it('should allow admin to unblock a member', async () => { @@ -2814,6 +2902,11 @@ describe('mutation unblockMember', () => { .getRepository(SourceMember) .findOneBy({ userId: '3', sourceId: 'a' }); expect(member).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '3', referenceId: 'a' }); + expect(contentPreference).toBeNull(); }); }); @@ -2842,6 +2935,17 @@ describe('mutation leaveSource', () => { referralToken: 'rt2', role: SourceMemberRoles.Member, }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: 'rt2', + }, + }); }); it('should not authorize when not logged in', () => @@ -2861,6 +2965,12 @@ describe('mutation leaveSource', () => { const sourceMembers = await con .getRepository(SourceMember) .countBy(variables); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '1', referenceId: 's1' }); + expect(contentPreference).toBeNull(); + expect(sourceMembers).toEqual(0); }); @@ -3016,6 +3126,24 @@ describe('mutation joinSource', () => { notificationType: NotificationType.SquadPostAdded, }); expect(preference).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ + userId: '1', + referenceId: 's1', + }); + expect(contentPreference).toMatchObject({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: expect.any(String), + }, + }); }); it('should succeed if an existing member tries to join again', async () => { @@ -3247,6 +3375,17 @@ describe('mutation hideSourceFeedPosts', () => { referralToken: 'rt2', role: SourceMemberRoles.Member, }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: 'rt2', + }, + }); }); it('should not authorize when not logged in', () => @@ -3311,6 +3450,11 @@ describe('mutation hideSourceFeedPosts', () => { userId: '1', }); expect(sourceMember?.flags.hideFeedPosts).toEqual(true); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '1', referenceId: 's1' }); + expect(contentPreference!.flags.hideFeedPosts).toEqual(true); }); }); @@ -3339,6 +3483,17 @@ describe('mutation showSourceFeedPosts', () => { referralToken: 'rt2', role: SourceMemberRoles.Member, }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: 'rt2', + }, + }); }); it('should not authorize when not logged in', () => @@ -3403,6 +3558,11 @@ describe('mutation showSourceFeedPosts', () => { userId: '1', }); expect(sourceMember?.flags.hideFeedPosts).toEqual(false); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '1', referenceId: 's1' }); + expect(contentPreference!.flags.hideFeedPosts).toEqual(false); }); }); @@ -3431,6 +3591,17 @@ describe('mutation collapsePinnedPosts', () => { referralToken: 'rt2', role: SourceMemberRoles.Member, }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: 'rt2', + }, + }); }); it('should not authorize when not logged in', () => @@ -3495,6 +3666,11 @@ describe('mutation collapsePinnedPosts', () => { userId: '1', }); expect(sourceMember?.flags.collapsePinnedPosts).toEqual(true); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '1', referenceId: 's1' }); + expect(contentPreference!.flags.collapsePinnedPosts).toEqual(true); }); }); @@ -3523,6 +3699,17 @@ describe('mutation expandPinnedPosts', () => { referralToken: 'rt2', role: SourceMemberRoles.Member, }); + await con.getRepository(ContentPreferenceSource).save({ + userId: '1', + referenceId: 's1', + sourceId: 's1', + feedId: '1', + status: ContentPreferenceStatus.Subscribed, + flags: { + role: SourceMemberRoles.Member, + referralToken: 'rt2', + }, + }); }); it('should not authorize when not logged in', () => @@ -3587,6 +3774,11 @@ describe('mutation expandPinnedPosts', () => { userId: '1', }); expect(sourceMember?.flags.collapsePinnedPosts).toEqual(false); + + const contentPreference = await con + .getRepository(ContentPreferenceSource) + .findOneBy({ userId: '1', referenceId: 's1' }); + expect(contentPreference!.flags.collapsePinnedPosts).toEqual(false); }); }); diff --git a/src/common/contentPreference.ts b/src/common/contentPreference.ts index 2b4e6e7a0..de4621ff8 100644 --- a/src/common/contentPreference.ts +++ b/src/common/contentPreference.ts @@ -17,6 +17,8 @@ import { NotificationPreferenceUser, } from '../entity'; import { ghostUser } from './utils'; +import { randomUUID } from 'crypto'; +import { SourceMemberRoles } from '../roles'; type FollowEntity = ({ ctx, @@ -44,7 +46,7 @@ type BlockEntity = ({ id: string; }) => Promise; -const entityToNotificationTypeMap: Record< +export const entityToNotificationTypeMap: Record< ContentPreferenceType, NotificationType[] > = { @@ -60,18 +62,20 @@ export const contentPreferenceNotificationTypes = Object.values( entityToNotificationTypeMap, ).flat(); -const cleanContentNotificationPreference = async ({ +export const cleanContentNotificationPreference = async ({ ctx, entityManager, id, notificationTypes, notficationEntity, + userId, }: { ctx: AuthContext; entityManager?: EntityManager; id: string; notificationTypes: NotificationType[]; notficationEntity: EntityTarget; + userId: string; }) => { const notificationRepository = (entityManager ?? ctx.con).getRepository( notficationEntity, @@ -82,7 +86,7 @@ const cleanContentNotificationPreference = async ({ } await notificationRepository.delete({ - userId: ctx.userId, + userId, referenceId: id, notificationType: In(notificationTypes), }); @@ -116,6 +120,7 @@ const followUser: FollowEntity = async ({ ctx, id, status }) => { id, notificationTypes: entityToNotificationTypeMap.user, notficationEntity: NotificationPreferenceUser, + userId: ctx.userId, }); } }); @@ -137,6 +142,7 @@ const unfollowUser: UnFollowEntity = async ({ ctx, id }) => { id, notificationTypes: entityToNotificationTypeMap.user, notficationEntity: NotificationPreferenceUser, + userId: ctx.userId, }); }); }; @@ -195,9 +201,19 @@ const followSource: FollowEntity = async ({ ctx, id, status }) => { sourceId: id, feedId: ctx.userId, status, + flags: { + referralToken: randomUUID(), + role: SourceMemberRoles.Member, + }, }); - await repository.save(contentPreference); + await repository + .createQueryBuilder() + .insert() + .into(ContentPreferenceSource) + .values(contentPreference) + .orUpdate(['status'], ['referenceId', 'userId']) + .execute(); if (status !== ContentPreferenceStatus.Subscribed) { cleanContentNotificationPreference({ @@ -206,6 +222,7 @@ const followSource: FollowEntity = async ({ ctx, id, status }) => { id, notificationTypes: entityToNotificationTypeMap.source, notficationEntity: NotificationPreferenceSource, + userId: ctx.userId, }); } @@ -234,6 +251,7 @@ const unfollowSource: UnFollowEntity = async ({ ctx, id }) => { id, notificationTypes: entityToNotificationTypeMap.source, notficationEntity: NotificationPreferenceSource, + userId: ctx.userId, }); await entityManager.getRepository(FeedSource).delete({ @@ -270,6 +288,7 @@ const blockUser: BlockEntity = async ({ ctx, id }) => { id, notificationTypes: entityToNotificationTypeMap.user, notficationEntity: NotificationPreferenceUser, + userId: ctx.userId, }); }); }; @@ -309,9 +328,19 @@ const blockSource: BlockEntity = async ({ ctx, id }) => { sourceId: id, feedId: ctx.userId, status: ContentPreferenceStatus.Blocked, + flags: { + referralToken: randomUUID(), + role: SourceMemberRoles.Member, + }, }); - await repository.save(contentPreference); + await repository + .createQueryBuilder() + .insert() + .into(ContentPreferenceSource) + .values(contentPreference) + .orUpdate(['status'], ['referenceId', 'userId']) + .execute(); cleanContentNotificationPreference({ ctx, @@ -319,6 +348,7 @@ const blockSource: BlockEntity = async ({ ctx, id }) => { id, notificationTypes: entityToNotificationTypeMap.source, notficationEntity: NotificationPreferenceSource, + userId: ctx.userId, }); // TODO follow phase 3 remove when backward compatibility is done diff --git a/src/entity/SourceMember.ts b/src/entity/SourceMember.ts index 2f8cccc28..2cc26b1a8 100644 --- a/src/entity/SourceMember.ts +++ b/src/entity/SourceMember.ts @@ -1,12 +1,8 @@ import { Column, Entity, Index, ManyToOne, PrimaryColumn } from 'typeorm'; -import { randomBytes } from 'crypto'; import type { Source } from './Source'; import type { User } from './user'; -import { promisify } from 'util'; import { SourceMemberRoles } from '../roles'; -const randomBytesAsync = promisify(randomBytes); - export type SourceMemberFlags = Partial<{ hideFeedPosts: boolean; collapsePinnedPosts: boolean; @@ -54,7 +50,3 @@ export class SourceMember { @Column({ type: 'jsonb', default: {} }) flags: SourceMemberFlags; } - -const TOKEN_BYTES = 32; -export const generateMemberToken = async (): Promise => - (await randomBytesAsync(TOKEN_BYTES)).toString('base64url'); diff --git a/src/entity/contentPreference/ContentPreferenceSource.ts b/src/entity/contentPreference/ContentPreferenceSource.ts index be48ae78d..e58f7b330 100644 --- a/src/entity/contentPreference/ContentPreferenceSource.ts +++ b/src/entity/contentPreference/ContentPreferenceSource.ts @@ -1,4 +1,4 @@ -import { ChildEntity, Column, JoinColumn, ManyToOne } from 'typeorm'; +import { ChildEntity, Column, Index, JoinColumn, ManyToOne } from 'typeorm'; import { ContentPreference } from './ContentPreference'; import { ContentPreferenceType } from './types'; import type { Feed } from '../Feed'; @@ -6,6 +6,12 @@ import type { Source } from 'graphql'; import { SourceMemberRoles } from '../../roles'; import type { SourceMemberFlags } from '../SourceMember'; +export type ContentPreferenceSourceFlags = Partial<{ + role: SourceMemberRoles; + referralToken: string; +}> & + SourceMemberFlags; + @ChildEntity(ContentPreferenceType.Source) export class ContentPreferenceSource extends ContentPreference { @Column({ type: 'text', default: null }) @@ -14,11 +20,9 @@ export class ContentPreferenceSource extends ContentPreference { @Column({ type: 'text', default: null }) feedId: string; - @Column({ type: 'text', default: SourceMemberRoles.Member }) - role: SourceMemberRoles; - @Column({ type: 'jsonb', default: {} }) - flags: SourceMemberFlags; + @Index('IDX_content_preference_flags_referralToken', { synchronize: false }) + flags: ContentPreferenceSourceFlags; @ManyToOne('Source', { lazy: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'sourceId' }) diff --git a/src/migration/1730390077794-ContentPreferenceSquad.ts b/src/migration/1730390077794-ContentPreferenceSquad.ts new file mode 100644 index 000000000..bf4eb2f61 --- /dev/null +++ b/src/migration/1730390077794-ContentPreferenceSquad.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ContentPreferenceSquad1730390077794 implements MigrationInterface { + name = 'ContentPreferenceSquad1730390077794'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_content_preference_flags_referralToken" ON "content_preference" ((flags->>'referralToken'))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_content_preference_flags_referralToken"`, + ); + } +} diff --git a/src/schema/feeds.ts b/src/schema/feeds.ts index fc18d64bc..2ebd43113 100644 --- a/src/schema/feeds.ts +++ b/src/schema/feeds.ts @@ -76,6 +76,8 @@ import { popularFeedClient } from '../integrations/feed/generators'; import { ContentPreferenceFeedKeyword } from '../entity/contentPreference/ContentPreferenceFeedKeyword'; import { ContentPreferenceStatus } from '../entity/contentPreference/types'; import { ContentPreferenceSource } from '../entity/contentPreference/ContentPreferenceSource'; +import { randomUUID } from 'crypto'; +import { SourceMemberRoles } from '../roles'; interface GQLTagsCategory { id: string; @@ -1876,6 +1878,10 @@ export const resolvers: IResolvers = traceResolvers< sourceId: source.id, feedId: feedId, status: ContentPreferenceStatus.Follow, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, })) as ContentPreferenceSource[], ) .orUpdate(['status'], ['referenceId', 'userId']) @@ -1908,6 +1914,10 @@ export const resolvers: IResolvers = traceResolvers< sourceId: source.id, feedId: feedId, status: ContentPreferenceStatus.Blocked, + flags: { + role: SourceMemberRoles.Member, + referralToken: randomUUID(), + }, })) as ContentPreferenceSource[], ) .orUpdate(['status'], ['referenceId', 'userId']) diff --git a/src/schema/sources.ts b/src/schema/sources.ts index c6407c886..490846f2e 100644 --- a/src/schema/sources.ts +++ b/src/schema/sources.ts @@ -4,7 +4,7 @@ import { ConnectionArguments } from 'graphql-relay'; import { AuthContext, BaseContext, Context } from '../Context'; import { createSharePost, - generateMemberToken, + NotificationPreferenceSource, Source, SourceFeed, SourceFlagsPublic, @@ -66,6 +66,12 @@ import { EntityTarget } from 'typeorm/common/EntityTarget'; import { traceResolvers } from './trace'; import { SourceCategory } from '../entity/sources/SourceCategory'; import { validate } from 'uuid'; +import { ContentPreferenceStatus } from '../entity/contentPreference/types'; +import { ContentPreferenceSource } from '../entity/contentPreference/ContentPreferenceSource'; +import { + cleanContentNotificationPreference, + entityToNotificationTypeMap, +} from '../common/contentPreference'; export interface GQLSourceCategory { id: string; @@ -1095,10 +1101,27 @@ const addNewSourceMember = async ( con: DataSource | EntityManager, member: Omit, 'referralToken'>, ): Promise => { + const referralToken = randomUUID(); + await con.getRepository(SourceMember).insert({ ...member, - referralToken: await generateMemberToken(), + referralToken, }); + + await con.getRepository(ContentPreferenceSource).insert( + con.getRepository(ContentPreferenceSource).create({ + userId: member.userId, + referenceId: member.sourceId, + sourceId: member.sourceId, + feedId: member.userId, + status: ContentPreferenceStatus.Subscribed, + flags: { + ...member.flags, + role: member.role, + referralToken, + }, + }), + ); }; export const getPermissionsForMember = ( @@ -1154,14 +1177,25 @@ const updateHideFeedPostsFlag = async ( ): Promise => { await ensureSourcePermissions(ctx, sourceId, SourcePermissions.View); - await ctx.con.getRepository(SourceMember).update( - { sourceId, userId: ctx.userId }, - { - flags: updateFlagsStatement({ - hideFeedPosts: value, - }), - }, - ); + await ctx.con.transaction(async (entityManager) => { + await entityManager.getRepository(SourceMember).update( + { sourceId, userId: ctx.userId }, + { + flags: updateFlagsStatement({ + hideFeedPosts: value, + }), + }, + ); + + await entityManager.getRepository(ContentPreferenceSource).update( + { referenceId: sourceId, userId: ctx.userId }, + { + flags: updateFlagsStatement({ + hideFeedPosts: value, + }), + }, + ); + }); return { _: true }; }; @@ -1173,14 +1207,25 @@ const togglePinnedPosts = async ( ): Promise => { await ensureSourcePermissions(ctx, sourceId, SourcePermissions.View); - await ctx.con.getRepository(SourceMember).update( - { sourceId, userId: ctx.userId }, - { - flags: updateFlagsStatement({ - collapsePinnedPosts: value, - }), - }, - ); + await ctx.con.transaction(async (entityManager) => { + await entityManager.getRepository(SourceMember).update( + { sourceId, userId: ctx.userId }, + { + flags: updateFlagsStatement({ + collapsePinnedPosts: value, + }), + }, + ); + + await entityManager.getRepository(ContentPreferenceSource).update( + { referenceId: sourceId, userId: ctx.userId }, + { + flags: updateFlagsStatement({ + collapsePinnedPosts: value, + }), + }, + ); + }); return { _: true }; }; @@ -1887,10 +1932,27 @@ export const resolvers: IResolvers = traceResolvers< ctx: AuthContext, ): Promise => { await ensureSourcePermissions(ctx, sourceId, SourcePermissions.Leave); - await ctx.con.getRepository(SourceMember).delete({ - sourceId, - userId: ctx.userId, + await ctx.con.transaction(async (entityManager) => { + await entityManager.getRepository(SourceMember).delete({ + sourceId, + userId: ctx.userId, + }); + + await entityManager.getRepository(ContentPreferenceSource).delete({ + userId: ctx.userId, + referenceId: sourceId, + }); + + await cleanContentNotificationPreference({ + ctx, + entityManager, + id: sourceId, + notificationTypes: entityToNotificationTypeMap.source, + notficationEntity: NotificationPreferenceSource, + userId: ctx.userId, + }); }); + return { _: true }; }, updateMemberRole: async ( @@ -1917,9 +1979,34 @@ export const resolvers: IResolvers = traceResolvers< } } - await ctx.con - .getRepository(SourceMember) - .update({ sourceId, userId: memberId }, { role }); + await ctx.con.transaction(async (entityManager) => { + await entityManager + .getRepository(SourceMember) + .update({ sourceId, userId: memberId }, { role }); + + const isBlock = role === SourceMemberRoles.Blocked; + + await entityManager.getRepository(ContentPreferenceSource).update( + { userId: memberId, referenceId: sourceId }, + { + status: isBlock ? ContentPreferenceStatus.Blocked : undefined, + flags: updateFlagsStatement({ + role, + }), + }, + ); + + if (isBlock) { + await cleanContentNotificationPreference({ + ctx, + entityManager, + id: sourceId, + notificationTypes: entityToNotificationTypeMap.source, + notficationEntity: NotificationPreferenceSource, + userId: memberId, + }); + } + }); return { _: true }; }, @@ -1934,9 +2021,16 @@ export const resolvers: IResolvers = traceResolvers< SourcePermissions.MemberUnblock, ); - await ctx.con - .getRepository(SourceMember) - .delete({ sourceId, userId: memberId }); + await ctx.con.transaction(async (entityManager) => { + await entityManager + .getRepository(SourceMember) + .delete({ sourceId, userId: memberId }); + + await entityManager.getRepository(ContentPreferenceSource).delete({ + userId: memberId, + referenceId: sourceId, + }); + }); return { _: true }; }, From 0db51cb158d8ded35f8bf17ce90232deb6fc44a7 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 5 Nov 2024 11:36:20 +0100 Subject: [PATCH 3/4] feat: additional notification --- src/common/contentPreference.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/contentPreference.ts b/src/common/contentPreference.ts index f7694862c..65d170ab5 100644 --- a/src/common/contentPreference.ts +++ b/src/common/contentPreference.ts @@ -55,6 +55,7 @@ export const entityToNotificationTypeMap: Record< [ContentPreferenceType.Source]: [ NotificationType.SourcePostAdded, NotificationType.SquadPostAdded, + NotificationType.SquadMemberJoined, ], }; From 38bc62a0a5c00094edabf90444a8604dee6d6f15 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 5 Nov 2024 12:11:56 +0100 Subject: [PATCH 4/4] fix: do not remove muted preference --- __tests__/contentPreference.ts | 41 +++++++++++++++++++++++++++++++++ src/common/contentPreference.ts | 8 +++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/__tests__/contentPreference.ts b/__tests__/contentPreference.ts index 87b73a1c7..d83def5ec 100644 --- a/__tests__/contentPreference.ts +++ b/__tests__/contentPreference.ts @@ -948,6 +948,47 @@ describe('mutation unfollow', () => { expect(notificationPreferences).toHaveLength(0); }); + it('should not remove muted notification preferences', async () => { + loggedUser = '1-um'; + + await con.getRepository(NotificationPreferenceUser).save([ + { + userId: '1-um', + referenceUserId: '2-um', + referenceId: '2-um', + status: NotificationPreferenceStatus.Muted, + notificationType: NotificationType.UserPostAdded, + }, + ]); + + const res = await client.query(MUTATION, { + variables: { + id: '2-um', + entity: ContentPreferenceType.User, + }, + }); + + expect(res.errors).toBeFalsy(); + + const contentPreference = await con + .getRepository(ContentPreferenceUser) + .findOneBy({ + userId: '1-um', + referenceId: '2-um', + }); + + expect(contentPreference).toBeNull(); + + const notificationPreferences = await con + .getRepository(NotificationPreferenceUser) + .findBy({ + userId: '1-um', + referenceUserId: '2-um', + }); + + expect(notificationPreferences).toHaveLength(1); + }); + describe('keyword', () => { beforeEach(async () => { await saveFixtures(con, Keyword, [ diff --git a/src/common/contentPreference.ts b/src/common/contentPreference.ts index 65d170ab5..146595a7f 100644 --- a/src/common/contentPreference.ts +++ b/src/common/contentPreference.ts @@ -4,8 +4,11 @@ import { ContentPreferenceType, } from '../entity/contentPreference/types'; import { ContentPreferenceUser } from '../entity/contentPreference/ContentPreferenceUser'; -import { NotificationType } from '../notifications/common'; -import { EntityManager, EntityTarget, In } from 'typeorm'; +import { + NotificationPreferenceStatus, + NotificationType, +} from '../notifications/common'; +import { EntityManager, EntityTarget, In, Not } from 'typeorm'; import { ConflictError } from '../errors'; import { ContentPreferenceFeedKeyword } from '../entity/contentPreference/ContentPreferenceFeedKeyword'; import { ContentPreferenceSource } from '../entity/contentPreference/ContentPreferenceSource'; @@ -91,6 +94,7 @@ export const cleanContentNotificationPreference = async ({ userId, referenceId: id, notificationType: In(notificationTypes), + status: Not(NotificationPreferenceStatus.Muted), }); };