From 55d8a9ca905d8f2cead3fdb13022a0c8e3714e63 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Wed, 29 Jan 2025 11:14:52 +0100 Subject: [PATCH 1/2] feat: add gifted plus notification worker (#2605) - Added notification for gift recipient; - Added email for gifter/recipient; - Added tests for worker and notification; --- .infra/common.ts | 6 +- __tests__/notifications/index.ts | 28 +++++- __tests__/workers/cdc/primary.ts | 26 +++++ .../userGiftedPlusNotification.ts | 94 +++++++++++++++++++ src/common/mailing.ts | 2 + src/notifications/common.ts | 1 + src/notifications/generate.ts | 12 +++ src/notifications/types.ts | 7 ++ src/paddle.ts | 8 ++ src/workers/newNotificationV2Mail.ts | 24 +++++ src/workers/notifications/index.ts | 2 + .../userGiftedPlusNotification.ts | 79 ++++++++++++++++ .../userUpdatedPlusSubscriptionSquad.ts | 2 +- 13 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 __tests__/workers/notifications/userGiftedPlusNotification.ts create mode 100644 src/workers/notifications/userGiftedPlusNotification.ts diff --git a/.infra/common.ts b/.infra/common.ts index 1f3a68290..afa8e4a08 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -35,6 +35,10 @@ export const workers: Worker[] = [ topic: 'user-updated', subscription: 'api.user-updated-plus-subscribed-custom-feed', }, + { + topic: 'user-updated', + subscription: 'api.user-gifted-plus-notification', + }, { topic: 'user-deleted', subscription: 'api.user-deleted-cio', @@ -328,7 +332,7 @@ export const workers: Worker[] = [ { topic: 'kvasir.v1.post-translated', subscription: 'api.post-translated', - }, + } ]; export const personalizedDigestWorkers: Worker[] = [ diff --git a/__tests__/notifications/index.ts b/__tests__/notifications/index.ts index 0368865f1..cd3c5cd02 100644 --- a/__tests__/notifications/index.ts +++ b/__tests__/notifications/index.ts @@ -6,6 +6,7 @@ import { NotificationCommentContext, NotificationCommenterContext, NotificationDoneByContext, + NotificationGiftPlusContext, NotificationPostContext, NotificationPostModerationContext, NotificationSourceContext, @@ -15,9 +16,9 @@ import { NotificationSubmissionContext, NotificationUpvotersContext, NotificationUserContext, + type NotificationUserTopReaderContext, Reference, storeNotificationBundleV2, - type NotificationUserTopReaderContext, } from '../../src/notifications'; import { postsFixture } from '../fixture/post'; import { @@ -1513,3 +1514,28 @@ describe('storeNotificationBundle', () => { expect(actual.attachments!.length).toEqual(0); }); }); + +describe('plus notifications', () => { + it('should notify user when they are gifted a subscription', async () => { + const type = NotificationType.UserGiftedPlus; + const gifter = usersFixture[1] as Reference; + const recipient = usersFixture[0] as Reference; + const squad = sourcesFixture[5] as Reference; + const ctx: NotificationGiftPlusContext = { + userIds: [recipient.id], + gifter, + recipient, + squad, + }; + const actual = generateNotificationV2(type, ctx); + + expect(actual.notification.type).toEqual(type); + expect(actual.userIds).toEqual([recipient.id]); + expect(actual.notification.public).toEqual(true); + expect(actual.notification.referenceId).toBe('squad'); + expect(actual.notification.description).toBeFalsy(); + expect(actual.notification.targetUrl).toContain(`/squads/${squad.handle}`); + expect(actual.avatars?.[0]?.image).toEqual(gifter.image); + expect(actual.attachments!.length).toEqual(0); + }); +}); diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index a594ed0ae..0934fa542 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -149,6 +149,8 @@ import { } from '../../../src/entity/SourcePostModeration'; import { NotificationType } from '../../../src/notifications/common'; import type { UserReport } from '../../../src/entity/UserReport'; +import { SubscriptionCycles } from '../../../src/paddle'; +import { addYears } from 'date-fns'; jest.mock('../../../src/common', () => ({ ...(jest.requireActual('../../../src/common') as Record), @@ -894,6 +896,30 @@ describe('user', () => { [after], ); }); + + it('should notify on gift plus subscription', async () => { + const after: ChangeObject = { + ...base, + subscriptionFlags: JSON.stringify({ + cycle: SubscriptionCycles.Yearly, + gifterId: '2', + giftExpirationDate: addYears(new Date(), 1), + }), + }; + await expectSuccessfulBackground( + worker, + mockChangeMessage({ + after, + before: base, + table: 'user', + op: 'u', + }), + ); + expectTypedEvent('user-updated', { + user: base, + newProfile: after, + } as unknown as PubSubSchema['user-updated']); + }); }); describe('user_state', () => { diff --git a/__tests__/workers/notifications/userGiftedPlusNotification.ts b/__tests__/workers/notifications/userGiftedPlusNotification.ts new file mode 100644 index 000000000..c2e4725eb --- /dev/null +++ b/__tests__/workers/notifications/userGiftedPlusNotification.ts @@ -0,0 +1,94 @@ +import createOrGetConnection from '../../../src/db'; +import { DataSource } from 'typeorm'; +import { ChangeObject } from '../../../src/types'; +import { usersFixture } from '../../fixture'; +import { SubscriptionCycles } from '../../../src/paddle'; +import { addYears } from 'date-fns'; +import { invokeNotificationWorker, saveFixtures } from '../../helpers'; +import { PLUS_MEMBER_SQUAD_ID } from '../../../src/workers/userUpdatedPlusSubscriptionSquad'; +import { SourceType, User, Source } from '../../../src/entity'; +import { NotificationGiftPlusContext } from '../../../src/notifications'; +import { NotificationType } from '../../../src/notifications/common'; + +let con: DataSource; + +beforeAll(async () => { + con = await createOrGetConnection(); +}); + +type ObjectType = Partial; +const base: ChangeObject = { + ...usersFixture[0], + subscriptionFlags: JSON.stringify({}), + createdAt: new Date().getTime(), + updatedAt: new Date().getTime(), + flags: JSON.stringify({}), +}; +const plusUser = { + ...base, + subscriptionFlags: JSON.stringify({ cycle: SubscriptionCycles.Yearly }), +}; +const giftedPlusUser = { + ...base, + subscriptionFlags: JSON.stringify({ + cycle: SubscriptionCycles.Yearly, + gifterId: 2, + giftExpirationDate: addYears(new Date(), 1), + }), +}; + +beforeEach(async () => { + jest.resetAllMocks(); + await saveFixtures(con, User, usersFixture); + await saveFixtures(con, Source, [ + { + id: PLUS_MEMBER_SQUAD_ID, + name: 'Plus Squad', + image: 'http://image.com/a', + handle: 'plus-squad-notify', + type: SourceType.Squad, + }, + ]); +}); + +const WORKER_RELATIVE_PATH = + '../../../src/workers/notifications/userGiftedPlusNotification'; + +describe('plus subscription gift', () => { + it('should early return for currently non plus user', async () => { + const worker = await import(WORKER_RELATIVE_PATH); + const actual = await invokeNotificationWorker(worker.default, { + user: base, + newProfile: base, + }); + expect(actual).toBeUndefined(); + }); + + it('should not return anything plus user since before', async () => { + const worker = await import(WORKER_RELATIVE_PATH); + const actual = await invokeNotificationWorker(worker.default, { + user: plusUser, + newProfile: plusUser, + }); + expect(actual).toBeUndefined(); + }); + + it('should return notification for gifted plus user', async () => { + const worker = await import(WORKER_RELATIVE_PATH); + const actual = (await invokeNotificationWorker(worker.default, { + user: base, + newProfile: giftedPlusUser, + })) as Array<{ + type: string; + ctx: NotificationGiftPlusContext; + }>; + expect(actual).toBeTruthy(); + expect(actual.length).toEqual(1); + expect(actual[0].type).toEqual(NotificationType.UserGiftedPlus); + + const notification = actual[0].ctx; + expect(notification.recipient.id).toEqual(base.id); + expect(notification.gifter.id).toEqual('2'); + expect(notification.squad.id).toEqual(PLUS_MEMBER_SQUAD_ID); + }); +}); diff --git a/src/common/mailing.ts b/src/common/mailing.ts index 3f8cb35c1..f4633b8cb 100644 --- a/src/common/mailing.ts +++ b/src/common/mailing.ts @@ -32,6 +32,8 @@ export enum CioUnsubscribeTopic { export enum CioTransactionalMessageTemplateId { VerifyCompany = '51', UserGivenTopReader = '52', + UserSentPlusGift = '65', + UserReceivedPlusGift = '66', } export const cioApi = new APIClient(process.env.CIO_APP_KEY); diff --git a/src/notifications/common.ts b/src/notifications/common.ts index fc78134e9..fc7d1cecf 100644 --- a/src/notifications/common.ts +++ b/src/notifications/common.ts @@ -60,6 +60,7 @@ export enum NotificationType { StreakResetRestore = 'streak_reset_restore', UserPostAdded = 'user_post_added', UserTopReaderBadge = 'user_given_top_reader', + UserGiftedPlus = 'user_gifted_plus', } export enum NotificationPreferenceType { diff --git a/src/notifications/generate.ts b/src/notifications/generate.ts index 2b8795f88..d887d704c 100644 --- a/src/notifications/generate.ts +++ b/src/notifications/generate.ts @@ -15,6 +15,7 @@ import { NotificationCommentContext, NotificationCommenterContext, NotificationDoneByContext, + NotificationGiftPlusContext, NotificationPostContext, NotificationPostModerationContext, NotificationSourceContext, @@ -145,6 +146,8 @@ export const notificationTitleMap: Record< }, source_post_submitted: (ctx: NotificationPostModerationContext) => `${ctx.user.name} just posted in ${ctx.source.name}. This post is waiting for your review before it gets published on the squad.`, + user_gifted_plus: (ctx: NotificationGiftPlusContext) => + `Surprise! 🎁 ${ctx.gifter.username} thought of you and gifted you a one-year daily.dev Plus membership! How’s that for a thoughtful surprise?`, }; export const generateNotificationMap: Record< @@ -430,4 +433,13 @@ export const generateNotificationMap: Record< .icon(NotificationIcon.Bell) .avatarUser(ctx.user) .objectPost(ctx.post, ctx.source, ctx.sharedPost!), + user_gifted_plus: (builder, ctx: NotificationGiftPlusContext) => + builder + .uniqueKey( + `${ctx.gifter.id}-${ctx.recipient.id}-${new Date().toISOString()}`, + ) + .icon(NotificationIcon.Bell) + .avatarUser(ctx.gifter) + .referenceSource(ctx.squad) + .targetSource(ctx.squad), }; diff --git a/src/notifications/types.ts b/src/notifications/types.ts index 8956ddabc..b10da7c2c 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -13,6 +13,7 @@ import { UserStreak, type Keyword, type UserTopReader, + SquadSource, } from '../entity'; import { ChangeObject } from '../types'; import { DeepPartial } from 'typeorm'; @@ -68,6 +69,12 @@ export type NotificationStreakContext = NotificationBaseContext & { }; }; +export type NotificationGiftPlusContext = NotificationBaseContext & { + gifter: Reference; + recipient: Reference; + squad: Reference; +}; + export type NotificationCommenterContext = NotificationCommentContext & { commenter: Reference; }; diff --git a/src/paddle.ts b/src/paddle.ts index 756c07343..50af38c24 100644 --- a/src/paddle.ts +++ b/src/paddle.ts @@ -1,3 +1,5 @@ +import { UserSubscriptionFlags } from './entity'; + export enum SubscriptionCycles { Monthly = 'monthly', Yearly = 'yearly', @@ -8,3 +10,9 @@ export const subscriptionGiftDuration = 31557600000; export const isPlusMember = (cycle: SubscriptionCycles | undefined): boolean => !!cycle?.length || false; + +export const isGiftedPlus = ( + subscriptionFlags: UserSubscriptionFlags, +): boolean => + (!!subscriptionFlags?.gifterId || false) && + isPlusMember(subscriptionFlags.cycle); diff --git a/src/workers/newNotificationV2Mail.ts b/src/workers/newNotificationV2Mail.ts index 14f54c02c..c0c41698f 100644 --- a/src/workers/newNotificationV2Mail.ts +++ b/src/workers/newNotificationV2Mail.ts @@ -91,6 +91,7 @@ export const notificationToTemplateId: Record = { squad_featured: '56', user_post_added: '58', user_given_top_reader: CioTransactionalMessageTemplateId.UserGivenTopReader, + user_gifted_plus: CioTransactionalMessageTemplateId.UserReceivedPlusGift, }; type TemplateData = Record; @@ -856,6 +857,29 @@ const notificationToTemplateData: Record = { keyword: keyword?.flags?.title || keyword.value, }; }, + user_gifted_plus: async (con, user) => { + const { subscriptionFlags } = await con.getRepository(User).findOneOrFail({ + where: { + id: user.id, + }, + select: ['subscriptionFlags'], + }); + + if (!subscriptionFlags?.gifterId) { + throw new Error('Gifter user not found'); + } + + const gifter = await con.getRepository(User).findOneOrFail({ + where: { + id: subscriptionFlags.gifterId, + }, + select: ['name', 'image'], + }); + return { + gifter_name: gifter.name, + gifter_image: gifter.image, + }; + }, }; const formatTemplateDate = (data: T): T => { diff --git a/src/workers/notifications/index.ts b/src/workers/notifications/index.ts index 38fd12cb3..260933023 100644 --- a/src/workers/notifications/index.ts +++ b/src/workers/notifications/index.ts @@ -22,6 +22,7 @@ import { collectionUpdated } from './collectionUpdated'; import devCardUnlocked from './devCardUnlocked'; import postBookmarkReminder from './postBookmarkReminder'; import userStreakResetNotification from './userStreakResetNotification'; +import userGiftedPlusNotification from './userGiftedPlusNotification'; import squadFeaturedUpdated from './squadFeaturedUpdated'; import sourcePostModerationSubmittedNotification from './sourcePostModerationSubmittedNotification'; import sourcePostModerationApprovedNotification from './sourcePostModerationApprovedNotification'; @@ -94,6 +95,7 @@ const notificationWorkers: NotificationWorker[] = [ sourcePostModerationApprovedNotification, sourcePostModerationRejectedNotification, userTopReaderAdded, + userGiftedPlusNotification, ]; export const workers = [...notificationWorkers.map(notificationWorkerToWorker)]; diff --git a/src/workers/notifications/userGiftedPlusNotification.ts b/src/workers/notifications/userGiftedPlusNotification.ts new file mode 100644 index 000000000..707f0e62c --- /dev/null +++ b/src/workers/notifications/userGiftedPlusNotification.ts @@ -0,0 +1,79 @@ +import { NotificationType } from '../../notifications/common'; +import { generateTypedNotificationWorker } from './worker'; +import { NotificationGiftPlusContext } from '../../notifications'; +import { SquadSource, User, UserSubscriptionFlags } from '../../entity'; +import { isGiftedPlus, isPlusMember } from '../../paddle'; +import { queryReadReplica } from '../../common/queryReadReplica'; +import { PLUS_MEMBER_SQUAD_ID } from '../userUpdatedPlusSubscriptionSquad'; +import { CioTransactionalMessageTemplateId, sendEmail } from '../../common'; +import { logger } from '../../logger'; + +async function sendNotificationEmailToGifter(ctx: NotificationGiftPlusContext) { + const message_data = { + recipient_name: ctx.recipient.name, + recipient_image: ctx.recipient.image, + }; + + await sendEmail({ + send_to_unsubscribed: true, + identifiers: { id: ctx.gifter.id }, + transactional_message_id: + CioTransactionalMessageTemplateId.UserSentPlusGift, + to: ctx.gifter.email, + message_data, + }); +} + +const worker = generateTypedNotificationWorker<'user-updated'>({ + subscription: 'api.user-gifted-plus-notification', + handler: async ({ user, newProfile: recipient }, con) => { + const { id: userId } = user; + + const beforeSubscriptionFlags: Partial = JSON.parse( + (user.subscriptionFlags as string) || '{}', + ); + const afterSubscriptionFlags: Partial = JSON.parse( + (recipient.subscriptionFlags as string) || '{}', + ); + + if ( + isPlusMember(beforeSubscriptionFlags?.cycle) || + !isGiftedPlus(afterSubscriptionFlags) || + !afterSubscriptionFlags?.gifterId + ) { + logger.warn( + { + user, + beforeSubscriptionFlags, + afterSubscriptionFlags, + }, + 'Invalid user-gifted-plus-notification', + ); + return; + } + + const [gifter, squad] = await queryReadReplica(con, ({ queryRunner }) => { + return Promise.all([ + queryRunner.manager.getRepository(User).findOneOrFail({ + where: { id: afterSubscriptionFlags.gifterId }, + }), + queryRunner.manager.getRepository(SquadSource).findOneOrFail({ + where: { id: PLUS_MEMBER_SQUAD_ID }, + }), + ]); + }); + + const ctx: NotificationGiftPlusContext = { + userIds: [userId, gifter.id], + gifter, + recipient, + squad, + }; + + await sendNotificationEmailToGifter(ctx); + + return [{ type: NotificationType.UserGiftedPlus, ctx }]; + }, +}); + +export default worker; diff --git a/src/workers/userUpdatedPlusSubscriptionSquad.ts b/src/workers/userUpdatedPlusSubscriptionSquad.ts index 4eb75acad..96ef2bbb9 100644 --- a/src/workers/userUpdatedPlusSubscriptionSquad.ts +++ b/src/workers/userUpdatedPlusSubscriptionSquad.ts @@ -5,7 +5,7 @@ import { SourceMemberRoles } from '../roles'; import { SourceMember, User } from '../entity'; import { queryReadReplica } from '../common/queryReadReplica'; -const PLUS_MEMBER_SQUAD_ID = '05862288-bace-4723-9218-d30fab6ae96d'; +export const PLUS_MEMBER_SQUAD_ID = '05862288-bace-4723-9218-d30fab6ae96d'; const worker: TypedWorker<'user-updated'> = { subscription: 'api.user-updated-plus-subscribed-squad', handler: async (message, con, log) => { From 7ec573cc875cf5edb2f7152d79931008d9794e2d Mon Sep 17 00:00:00 2001 From: Ole-Martin Bratteng <1681525+omBratteng@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:59:52 +0100 Subject: [PATCH 2/2] chore: cleanup commented out @fastify/websocket (#2626) --- package-lock.json | 1 - package.json | 1 - src/index.ts | 8 -------- 3 files changed, 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad13c71ab..b34557569 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@fastify/helmet": "^13.0.1", "@fastify/http-proxy": "^11.0.0", "@fastify/rate-limit": "^10.2.1", - "@fastify/websocket": "^11.0.1", "@google-cloud/bigquery": "^7.9.1", "@google-cloud/opentelemetry-resource-util": "^2.4.0", "@google-cloud/pubsub": "^4.9.0", diff --git a/package.json b/package.json index a54556c82..a8859b865 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@fastify/helmet": "^13.0.1", "@fastify/http-proxy": "^11.0.0", "@fastify/rate-limit": "^10.2.1", - "@fastify/websocket": "^11.0.1", "@google-cloud/bigquery": "^7.9.1", "@google-cloud/opentelemetry-resource-util": "^2.4.0", "@google-cloud/pubsub": "^4.9.0", diff --git a/src/index.ts b/src/index.ts index 764ab8fb5..4d7353d9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ import MercuriusGQLUpload from 'mercurius-upload'; import MercuriusCache from 'mercurius-cache'; import proxy, { type FastifyHttpProxyOptions } from '@fastify/http-proxy'; import { NoSchemaIntrospectionCustomRule } from 'graphql'; -// import fastifyWebsocket from '@fastify/websocket'; import './config'; @@ -151,13 +150,6 @@ export default async function app( res.send(stringifyHealthCheck({ status: 'ok' })); }); - // app.register(fastifyWebsocket, { - // options: { - // maxPayload: 1048576, - // verifyClient: (info, next) => next(true), - // }, - // }); - app.register(MercuriusGQLUpload, { maxFileSize: 1024 * 1024 * 20, maxFiles: 1,