diff --git a/apps/notifications/src/__tests__/mappings/trackCollaboratorAccept.test.ts b/apps/notifications/src/__tests__/mappings/trackCollaboratorAccept.test.ts new file mode 100644 index 0000000..9355d56 --- /dev/null +++ b/apps/notifications/src/__tests__/mappings/trackCollaboratorAccept.test.ts @@ -0,0 +1,96 @@ +import { expect, jest, test } from '@jest/globals' +import { Processor } from '../../main' +import * as sns from '../../sns' +import * as web from '../../web' + +import { + createTracks, + createUsers, + insertMobileDevices, + insertMobileSettings, + insertNotifications, + resetTests, + setUserEmailAndSettings, + setupTest +} from '../../utils/populateDB' + +describe('Track Collaborator Accept', () => { + let processor: Processor + + const sendPushNotificationSpy = jest + .spyOn(sns, 'sendPushNotification') + .mockImplementation(() => Promise.resolve({ endpointDisabled: false })) + + const sendBrowserNotificationSpy = jest + .spyOn(web, 'sendBrowserNotification') + .mockImplementation(() => Promise.resolve(3)) + + beforeEach(async () => { + const setup = await setupTest() + processor = setup.processor + }) + + afterEach(async () => { + await resetTests(processor) + }) + + test('Process push notification for track collaborator accept', async () => { + // user 1 = collaborator (accepted), user 2 = inviter / track owner (recipient) + await createUsers(processor.discoveryDB, [{ user_id: 1 }, { user_id: 2 }]) + await createTracks(processor.discoveryDB, [ + { track_id: 10, owner_id: 2 } + ]) + await setUserEmailAndSettings(processor.identityDB, 'live', 2) + + await insertNotifications(processor.discoveryDB, [ + { + id: 1, + specifier: '1', + group_id: + 'track_collaborator_accept:track_id:10:collaborator_user_id:1:inviter_user_id:2', + type: 'track_collaborator_accept', + data: { + track_id: 10, + collaborator_user_id: 1, + inviter_user_id: 2 + }, + user_ids: [2] + } + ]) + + await insertMobileSettings(processor.identityDB, [{ userId: 2 }]) + await insertMobileDevices(processor.identityDB, [{ userId: 2 }]) + + const pending = processor.listener.takePending() + expect(pending?.appNotifications).toHaveLength(1) + + const title = 'Collaboration Accepted' + const body = + 'user_1 accepted your invitation to collaborate on track_title_10.' + await processor.appNotificationsProcessor.process(pending.appNotifications) + + expect(sendPushNotificationSpy).toHaveBeenCalledWith( + { + type: 'ios', + targetARN: 'arn:2', + badgeCount: 1 + }, + expect.objectContaining({ + title, + body, + data: expect.objectContaining({ + type: 'TrackCollaboratorAccept', + entityId: 10 + }) + }) + ) + + expect(sendBrowserNotificationSpy).toHaveBeenCalledWith( + true, + expect.any(Object), + 2, + title, + body + ) + }) +}) diff --git a/apps/notifications/src/__tests__/mappings/trackCollaboratorInvite.test.ts b/apps/notifications/src/__tests__/mappings/trackCollaboratorInvite.test.ts new file mode 100644 index 0000000..eacc09f --- /dev/null +++ b/apps/notifications/src/__tests__/mappings/trackCollaboratorInvite.test.ts @@ -0,0 +1,95 @@ +import { expect, jest, test } from '@jest/globals' +import { Processor } from '../../main' +import * as sns from '../../sns' +import * as web from '../../web' + +import { + createTracks, + createUsers, + insertMobileDevices, + insertMobileSettings, + insertNotifications, + resetTests, + setUserEmailAndSettings, + setupTest +} from '../../utils/populateDB' + +describe('Track Collaborator Invite', () => { + let processor: Processor + + const sendPushNotificationSpy = jest + .spyOn(sns, 'sendPushNotification') + .mockImplementation(() => Promise.resolve({ endpointDisabled: false })) + + const sendBrowserNotificationSpy = jest + .spyOn(web, 'sendBrowserNotification') + .mockImplementation(() => Promise.resolve(3)) + + beforeEach(async () => { + const setup = await setupTest() + processor = setup.processor + }) + + afterEach(async () => { + await resetTests(processor) + }) + + test('Process push notification for track collaborator invite', async () => { + // user 1 = invited collaborator (recipient), user 2 = inviter / track owner + await createUsers(processor.discoveryDB, [{ user_id: 1 }, { user_id: 2 }]) + await createTracks(processor.discoveryDB, [ + { track_id: 10, owner_id: 2 } + ]) + await setUserEmailAndSettings(processor.identityDB, 'live', 1) + + await insertNotifications(processor.discoveryDB, [ + { + id: 1, + specifier: '2', + group_id: + 'track_collaborator_invite:track_id:10:collaborator_user_id:1:inviter_user_id:2', + type: 'track_collaborator_invite', + data: { + track_id: 10, + collaborator_user_id: 1, + inviter_user_id: 2 + }, + user_ids: [1] + } + ]) + + await insertMobileSettings(processor.identityDB, [{ userId: 1 }]) + await insertMobileDevices(processor.identityDB, [{ userId: 1 }]) + + const pending = processor.listener.takePending() + expect(pending?.appNotifications).toHaveLength(1) + + const title = 'Track Collaboration Invite' + const body = 'user_2 invited you to collaborate on track_title_10.' + await processor.appNotificationsProcessor.process(pending.appNotifications) + + expect(sendPushNotificationSpy).toHaveBeenCalledWith( + { + type: 'ios', + targetARN: 'arn:1', + badgeCount: 1 + }, + expect.objectContaining({ + title, + body, + data: expect.objectContaining({ + type: 'TrackCollaboratorInvite', + entityId: 10 + }) + }) + ) + + expect(sendBrowserNotificationSpy).toHaveBeenCalledWith( + true, + expect.any(Object), + 1, + title, + body + ) + }) +}) diff --git a/apps/notifications/src/email/notifications/components/Body.tsx b/apps/notifications/src/email/notifications/components/Body.tsx index 75feb2e..d9e44d8 100644 --- a/apps/notifications/src/email/notifications/components/Body.tsx +++ b/apps/notifications/src/email/notifications/components/Body.tsx @@ -124,6 +124,14 @@ const snippetMap = { const { users } = notification return `${users[0].name} has been added as a manager on your account.` }, + ['track_collaborator_invite'](notification) { + const { users } = notification + return `${users[0].name} invited you to collaborate on a track.` + }, + ['track_collaborator_accept'](notification) { + const { users } = notification + return `${users[0].name} accepted your invitation to collaborate.` + }, ['create'](notification) { const [user] = notification.users if ( diff --git a/apps/notifications/src/email/notifications/components/notifications/Notification.tsx b/apps/notifications/src/email/notifications/components/notifications/Notification.tsx index f3452c2..2d438c5 100644 --- a/apps/notifications/src/email/notifications/components/notifications/Notification.tsx +++ b/apps/notifications/src/email/notifications/components/notifications/Notification.tsx @@ -332,6 +332,26 @@ const notificationMap = { ) }, + ['track_collaborator_invite'](notification) { + const [user] = notification.users + return ( + + + + ) + }, + ['track_collaborator_accept'](notification) { + const [user] = notification.users + return ( + + + + ) + }, ['create'](notification) { const [user] = notification.users if ( diff --git a/apps/notifications/src/processNotifications/mappers/mapNotifications.ts b/apps/notifications/src/processNotifications/mappers/mapNotifications.ts index 4974730..c3ad626 100644 --- a/apps/notifications/src/processNotifications/mappers/mapNotifications.ts +++ b/apps/notifications/src/processNotifications/mappers/mapNotifications.ts @@ -35,6 +35,8 @@ import { TrackAddedToPurchasedAlbumNotification, RequestManagerNotification, ApproveManagerNotification, + TrackCollaboratorInviteNotification, + TrackCollaboratorAcceptNotification, ClaimableRewardNotification, RewardInCooldownNotification, CommentNotification, @@ -81,6 +83,8 @@ import { USDCPurchaseBuyer } from './usdcPurchaseBuyer' import { USDCWithdrawal } from './usdcWithdrawal' import { USDCTransfer } from './usdcTransfer' import { RequestManager } from './requestManager' +import { TrackCollaboratorInvite } from './trackCollaboratorInvite' +import { TrackCollaboratorAccept } from './trackCollaboratorAccept' import { TrackAddedToPurchasedAlbum } from './trackAddedToPurchasedAlbum' import { RewardInCooldown } from './rewardInCooldown' import { ClaimableReward } from './claimableReward' @@ -283,6 +287,24 @@ const mapNotification = ( identityDb, approveManagerNotification ) + } else if (notification.type === 'track_collaborator_invite') { + const trackCollaboratorInviteNotification = notification as NotificationRow & { + data: TrackCollaboratorInviteNotification + } + return new TrackCollaboratorInvite( + dnDb, + identityDb, + trackCollaboratorInviteNotification + ) + } else if (notification.type === 'track_collaborator_accept') { + const trackCollaboratorAcceptNotification = notification as NotificationRow & { + data: TrackCollaboratorAcceptNotification + } + return new TrackCollaboratorAccept( + dnDb, + identityDb, + trackCollaboratorAcceptNotification + ) } else if (notification.type === 'claimable_reward') { const challengeCooldownCompleteNotification = notification as NotificationRow & { diff --git a/apps/notifications/src/processNotifications/mappers/trackCollaboratorAccept.ts b/apps/notifications/src/processNotifications/mappers/trackCollaboratorAccept.ts new file mode 100644 index 0000000..18151a8 --- /dev/null +++ b/apps/notifications/src/processNotifications/mappers/trackCollaboratorAccept.ts @@ -0,0 +1,126 @@ +import { Knex } from 'knex' +import { EntityType } from '../../email/notifications/types' +import { ResourceIds, Resources } from '../../email/notifications/renderEmail' +import { sendPushNotification } from '../../sns' +import { NotificationRow } from '../../types/dn' +import { TrackCollaboratorAcceptNotification } from '../../types/notifications' +import { disableDeviceArns } from '../../utils/disableArnEndpoint' +import { sendBrowserNotification } from '../../web' +import { BaseNotification } from './base' +import { + Device, + buildUserNotificationSettings +} from './userNotificationSettings' + +type TrackCollaboratorAcceptRow = Omit & { + data: TrackCollaboratorAcceptNotification +} + +const body = (collaboratorName: string, trackTitle: string): string => + `${collaboratorName} accepted your invitation to collaborate on ${trackTitle}.` + +export class TrackCollaboratorAccept extends BaseNotification { + trackId: number + collaboratorUserId: number + inviterUserId: number + + constructor( + dnDB: Knex, + identityDB: Knex, + notification: TrackCollaboratorAcceptRow + ) { + super(dnDB, identityDB, notification) + this.trackId = this.notification.data.track_id + this.collaboratorUserId = this.notification.data.collaborator_user_id + this.inviterUserId = this.notification.data.inviter_user_id + } + + async processNotification({ + isBrowserPushEnabled + }: { + isBrowserPushEnabled: boolean + }) { + const users = await this.getUsersBasicInfo([ + this.collaboratorUserId, + this.inviterUserId + ]) + if ( + users?.[this.collaboratorUserId]?.is_deactivated || + users?.[this.inviterUserId]?.is_deactivated + ) { + return + } + + const tracks = await this.fetchEntities([this.trackId], EntityType.Track) + const track = tracks?.[this.trackId] + const trackTitle = track && 'title' in track ? track.title : 'a track' + const collaboratorName = users[this.collaboratorUserId].name + + // Notify the inviter (track owner) + const userNotificationSettings = await buildUserNotificationSettings( + this.identityDB, + [this.inviterUserId] + ) + + const title = 'Collaboration Accepted' + const notificationBody = body(collaboratorName, trackTitle) + + await sendBrowserNotification( + isBrowserPushEnabled, + userNotificationSettings, + this.inviterUserId, + title, + notificationBody + ) + + if ( + userNotificationSettings.shouldSendPushNotification({ + receiverUserId: this.inviterUserId, + initiatorUserId: this.collaboratorUserId + }) + ) { + const devices: Device[] = userNotificationSettings.getDevices( + this.inviterUserId + ) + const pushes = await Promise.all( + devices.map((device) => { + return sendPushNotification( + { + type: device.type, + badgeCount: + userNotificationSettings.getBadgeCount(this.inviterUserId) + 1, + targetARN: device.awsARN + }, + { + title, + body: notificationBody, + data: { + id: `timestamp:${this.getNotificationTimestamp()}:group_id:${ + this.notification.group_id + }`, + type: 'TrackCollaboratorAccept', + entityId: this.trackId + } + } + ) + }) + ) + await disableDeviceArns(this.identityDB, pushes) + await this.incrementBadgeCount(this.inviterUserId) + } + } + + getResourcesForEmail(): ResourceIds { + return { + users: new Set([this.collaboratorUserId]) + } + } + + formatEmailProps(resources: Resources) { + const user = resources.users[this.collaboratorUserId] + return { + type: this.notification.type, + users: [user] + } + } +} diff --git a/apps/notifications/src/processNotifications/mappers/trackCollaboratorInvite.ts b/apps/notifications/src/processNotifications/mappers/trackCollaboratorInvite.ts new file mode 100644 index 0000000..ae71c44 --- /dev/null +++ b/apps/notifications/src/processNotifications/mappers/trackCollaboratorInvite.ts @@ -0,0 +1,128 @@ +import { Knex } from 'knex' +import { EntityType } from '../../email/notifications/types' +import { ResourceIds, Resources } from '../../email/notifications/renderEmail' +import { sendPushNotification } from '../../sns' +import { NotificationRow } from '../../types/dn' +import { TrackCollaboratorInviteNotification } from '../../types/notifications' +import { disableDeviceArns } from '../../utils/disableArnEndpoint' +import { sendBrowserNotification } from '../../web' +import { BaseNotification } from './base' +import { + Device, + buildUserNotificationSettings +} from './userNotificationSettings' + +type TrackCollaboratorInviteRow = Omit & { + data: TrackCollaboratorInviteNotification +} + +const body = (inviterName: string, trackTitle: string): string => + `${inviterName} invited you to collaborate on ${trackTitle}.` + +export class TrackCollaboratorInvite extends BaseNotification { + trackId: number + collaboratorUserId: number + inviterUserId: number + + constructor( + dnDB: Knex, + identityDB: Knex, + notification: TrackCollaboratorInviteRow + ) { + super(dnDB, identityDB, notification) + this.trackId = this.notification.data.track_id + this.collaboratorUserId = this.notification.data.collaborator_user_id + this.inviterUserId = this.notification.data.inviter_user_id + } + + async processNotification({ + isBrowserPushEnabled + }: { + isBrowserPushEnabled: boolean + }) { + const users = await this.getUsersBasicInfo([ + this.collaboratorUserId, + this.inviterUserId + ]) + if ( + users?.[this.collaboratorUserId]?.is_deactivated || + users?.[this.inviterUserId]?.is_deactivated + ) { + return + } + + const tracks = await this.fetchEntities([this.trackId], EntityType.Track) + const track = tracks?.[this.trackId] + const trackTitle = track && 'title' in track ? track.title : 'a track' + const inviterName = users[this.inviterUserId].name + + // Notify the invited collaborator + const userNotificationSettings = await buildUserNotificationSettings( + this.identityDB, + [this.collaboratorUserId] + ) + + const title = 'Track Collaboration Invite' + const notificationBody = body(inviterName, trackTitle) + + await sendBrowserNotification( + isBrowserPushEnabled, + userNotificationSettings, + this.collaboratorUserId, + title, + notificationBody + ) + + if ( + userNotificationSettings.shouldSendPushNotification({ + receiverUserId: this.collaboratorUserId, + initiatorUserId: this.inviterUserId + }) + ) { + const devices: Device[] = userNotificationSettings.getDevices( + this.collaboratorUserId + ) + const pushes = await Promise.all( + devices.map((device) => { + return sendPushNotification( + { + type: device.type, + badgeCount: + userNotificationSettings.getBadgeCount( + this.collaboratorUserId + ) + 1, + targetARN: device.awsARN + }, + { + title, + body: notificationBody, + data: { + id: `timestamp:${this.getNotificationTimestamp()}:group_id:${ + this.notification.group_id + }`, + type: 'TrackCollaboratorInvite', + entityId: this.trackId + } + } + ) + }) + ) + await disableDeviceArns(this.identityDB, pushes) + await this.incrementBadgeCount(this.collaboratorUserId) + } + } + + getResourcesForEmail(): ResourceIds { + return { + users: new Set([this.inviterUserId]) + } + } + + formatEmailProps(resources: Resources) { + const user = resources.users[this.inviterUserId] + return { + type: this.notification.type, + users: [user] + } + } +} diff --git a/apps/notifications/src/types/notifications.ts b/apps/notifications/src/types/notifications.ts index a7d86ac..44efb87 100644 --- a/apps/notifications/src/types/notifications.ts +++ b/apps/notifications/src/types/notifications.ts @@ -157,6 +157,18 @@ export type ApproveManagerNotification = { user_id: number } +export type TrackCollaboratorInviteNotification = { + track_id: number + collaborator_user_id: number + inviter_user_id: number +} + +export type TrackCollaboratorAcceptNotification = { + track_id: number + collaborator_user_id: number + inviter_user_id: number +} + export type RewardInCooldownNotification = { amount: number specifier: string