diff --git a/package-lock.json b/package-lock.json index 44daaf770e1..3e2f3c3cf9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "root", - "version": "1.5.41", + "version": "1.5.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "root", - "version": "1.5.41", + "version": "1.5.42", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -91014,7 +91014,7 @@ }, "packages/common": { "name": "@audius/common", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@fingerprintjs/fingerprintjs-pro": "3.5.6", "@metaplex-foundation/mpl-token-metadata": "2.5.2", @@ -91252,7 +91252,7 @@ } }, "packages/embed": { - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "amplitude-js": "8.11.1", "axios": "0.19.2", @@ -91293,7 +91293,7 @@ "license": "MIT" }, "packages/eslint-config-audius": { - "version": "1.5.41", + "version": "1.5.42", "license": "ISC", "peerDependencies": { "@typescript-eslint/eslint-plugin": "5.48.2", @@ -91321,7 +91321,7 @@ }, "packages/libs": { "name": "@audius/sdk", - "version": "3.0.8-beta.13", + "version": "3.0.8", "license": "Apache-2.0", "dependencies": { "@audius/hedgehog": "2.1.0", @@ -93028,7 +93028,7 @@ }, "packages/mobile": { "name": "audius-mobile-client", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@amplitude/react-native": "2.6.0", "@fingerprintjs/fingerprintjs-pro-react-native": "2.0.0-test.2", @@ -95588,7 +95588,7 @@ } }, "packages/probers": { - "version": "1.5.41", + "version": "1.5.42", "license": "ISC", "dependencies": { "@testing-library/cypress": "^9.0.0", @@ -96329,7 +96329,7 @@ } }, "packages/sql-ts": { - "version": "1.0.0", + "version": "1.0.1", "license": "ISC", "dependencies": { "@rmp135/sql-ts": "1.18.0", @@ -96340,7 +96340,7 @@ }, "packages/stems": { "name": "@audius/stems", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@juggle/resize-observer": "3.3.1", "classnames": "2.2.6", @@ -99920,7 +99920,7 @@ }, "packages/web": { "name": "audius-client", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@coinbase/cbpay-js": "1.2.0", "@craco/craco": "7.0.0-alpha.3", diff --git a/package.json b/package.json index b4b5efc0c1d..7fb842c50fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "1.5.41", + "version": "1.5.42", "workspaces": [ "packages/*" ], diff --git a/packages/common/package.json b/packages/common/package.json index c51cab17a93..53471ac8757 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@audius/common", - "version": "1.5.41", + "version": "1.5.42", "description": "Common utilities and store for web and mobile.", "private": "true", "author": "Audius", diff --git a/packages/embed/package.json b/packages/embed/package.json index 0ce78ea61e4..576043f7a80 100644 --- a/packages/embed/package.json +++ b/packages/embed/package.json @@ -1,6 +1,6 @@ { "name": "embed", - "version": "1.5.41", + "version": "1.5.42", "scripts": { "dev": "preact watch --no-sw --template src/template.html", "start:dev": "env-cmd -f .env.dev env-cmd -f .env.local npm run -s dev", diff --git a/packages/eslint-config-audius/package.json b/packages/eslint-config-audius/package.json index 52cd8d7bb54..851804c5d88 100644 --- a/packages/eslint-config-audius/package.json +++ b/packages/eslint-config-audius/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-audius", - "version": "1.5.41", + "version": "1.5.42", "description": "Custom eslint config for Audius.", "author": "Audius", "homepage": "https://github.com/AudiusProject/audius-client#readme", diff --git a/packages/identity-service/build/aao-config.json b/packages/identity-service/build/aao-config.json new file mode 100644 index 00000000000..a68fc77f511 --- /dev/null +++ b/packages/identity-service/build/aao-config.json @@ -0,0 +1 @@ +["0xF1aE4652DBFe07d3fe71ECfe22fBB31fc3A3DB40", "0x67F4A367A4463fD24ABd5f8D5007303EA8689330", "0xe466d3d1F0035F33BE9dc7e551B9D3b5461F3F43"] diff --git a/packages/identity-service/build/sequelize/migrations/20181121184323-create-authentication.js b/packages/identity-service/build/sequelize/migrations/20181121184323-create-authentication.js new file mode 100644 index 00000000000..098939a48e9 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20181121184323-create-authentication.js @@ -0,0 +1,32 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Authentications', { + iv: { + type: Sequelize.STRING, + allowNull: false + }, + cipherText: { + type: Sequelize.STRING, + allowNull: false + }, + lookupKey: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Authentications'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190221071525-users.js b/packages/identity-service/build/sequelize/migrations/20190221071525-users.js new file mode 100644 index 00000000000..2ac0bff108a --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190221071525-users.js @@ -0,0 +1,45 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Users', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + email: { + type: Sequelize.STRING, + allowNull: false + }, + handle: { + type: Sequelize.STRING, + allowNull: true + }, + ownerWallet: { + type: Sequelize.STRING, + allowNull: true + }, + isConfigured: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + lastSeenDate: { + type: Sequelize.DATE, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Users'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190305010620-twitter_users.js b/packages/identity-service/build/sequelize/migrations/20190305010620-twitter_users.js new file mode 100644 index 00000000000..aa1ebc250ea --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190305010620-twitter_users.js @@ -0,0 +1,43 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('TwitterUsers', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + twitterProfile: { + type: Sequelize.JSONB, + allowNull: false, + unique: false + }, + verified: { + type: Sequelize.BOOLEAN, + allowNull: false + }, + uuid: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: Sequelize.INTEGER, + allowNull: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + .then(() => queryInterface.addIndex('TwitterUsers', { fields: ['uuid'], type: 'UNIQUE' })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('TwitterUsers'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190305021133-create-transaction.js b/packages/identity-service/build/sequelize/migrations/20190305021133-create-transaction.js new file mode 100644 index 00000000000..6dda4ac9346 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190305021133-create-transaction.js @@ -0,0 +1,47 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Transactions', { + encodedABI: { + type: Sequelize.TEXT, + allowNull: false, + primaryKey: true + }, + decodedABI: { + type: Sequelize.JSONB, + allowNull: false + }, + receipt: { + type: Sequelize.JSONB, + allowNull: false + }, + contractRegistryKey: { + type: Sequelize.STRING, + allowNull: false + }, + contractFn: { + type: Sequelize.STRING, + allowNull: false + }, + contractAddress: { + type: Sequelize.STRING, + allowNull: false + }, + senderAddress: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Transactions'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190404224929-added-track-listen.js b/packages/identity-service/build/sequelize/migrations/20190404224929-added-track-listen.js new file mode 100644 index 00000000000..7459133e434 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190404224929-added-track-listen.js @@ -0,0 +1,33 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('TrackListenCounts', { + trackId: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: false, + primaryKey: true + }, + listens: { + type: Sequelize.INTEGER, + defaultValue: 0 + }, + hour: { + type: Sequelize.DATE, + allowNull: false, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('TrackListenCounts'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190502202917-create-beta-password.js b/packages/identity-service/build/sequelize/migrations/20190502202917-create-beta-password.js new file mode 100644 index 00000000000..466a6a7ebda --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190502202917-create-beta-password.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('BetaPasswords', { + password: { + type: Sequelize.STRING, + primaryKey: true, + autoIncrement: false, + allowNull: false + }, + remainingLogins: { + type: Sequelize.INTEGER, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('BetaPasswords'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190515234930-rename_owner_wallet_to_wallet_address_in_users.js b/packages/identity-service/build/sequelize/migrations/20190515234930-rename_owner_wallet_to_wallet_address_in_users.js new file mode 100644 index 00000000000..e888ea931fc --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190515234930-rename_owner_wallet_to_wallet_address_in_users.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Users', 'ownerWallet', 'walletAddress'); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Users', 'walletAddress', 'ownerWallet'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190517213220-auth_migrations.js b/packages/identity-service/build/sequelize/migrations/20190517213220-auth_migrations.js new file mode 100644 index 00000000000..48ac0e47e13 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190517213220-auth_migrations.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + let addAuthMigrationsTablePromise = queryInterface.createTable('AuthMigrations', { + handle: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + let addParanoidAuthFieldPromise = queryInterface.addColumn('Authentications', 'deletedAt', { type: Sequelize.DATE, allowNull: true }); + return Promise.all([addAuthMigrationsTablePromise, addParanoidAuthFieldPromise]); + }, + down: (queryInterface, Sequelize) => { + let addAuthMigrationsTablePromise = queryInterface.dropTable('AuthMigrations'); + let addParanoidAuthFieldPromise = queryInterface.removeColumn('Authentications', 'deletedAt'); + return Promise.all([addAuthMigrationsTablePromise, addParanoidAuthFieldPromise]); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190620173111-waitlist.js b/packages/identity-service/build/sequelize/migrations/20190620173111-waitlist.js new file mode 100644 index 00000000000..200a6386b09 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190620173111-waitlist.js @@ -0,0 +1,23 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Waitlists', { + email: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Waitlists'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190624235850-instagram_users.js b/packages/identity-service/build/sequelize/migrations/20190624235850-instagram_users.js new file mode 100644 index 00000000000..7d5738e8de1 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190624235850-instagram_users.js @@ -0,0 +1,43 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('InstagramUsers', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + profile: { + type: Sequelize.JSONB, + allowNull: false, + unique: false + }, + accessToken: { + type: Sequelize.STRING, + allowNull: false + }, + uuid: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: Sequelize.INTEGER, + allowNull: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + .then(() => queryInterface.addIndex('InstagramUsers', { fields: ['uuid'], type: 'UNIQUE' })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('InstagramUsers'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20190703052835-SocialHandles.js b/packages/identity-service/build/sequelize/migrations/20190703052835-SocialHandles.js new file mode 100644 index 00000000000..f17e9f3cb3b --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20190703052835-SocialHandles.js @@ -0,0 +1,31 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('SocialHandles', { + handle: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + twitterHandle: { + allowNull: true, + type: Sequelize.STRING + }, + instagramHandle: { + allowNull: true, + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('SocialHandles'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191010175030-artist-track-picks.js b/packages/identity-service/build/sequelize/migrations/20191010175030-artist-track-picks.js new file mode 100644 index 00000000000..30853dcadc9 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191010175030-artist-track-picks.js @@ -0,0 +1,11 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('SocialHandles', 'pinnedTrackId', { + type: Sequelize.INTEGER + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('SocialHandles', 'pinnedTrackId'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191021185617-wallet_address_is_unique.js b/packages/identity-service/build/sequelize/migrations/20191021185617-wallet_address_is_unique.js new file mode 100644 index 00000000000..98a069f6d70 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191021185617-wallet_address_is_unique.js @@ -0,0 +1,12 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addConstraint('Users', ['walletAddress'], { + type: 'unique', + name: 'wallet_address_is_unique' + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeConstraint('Users', 'wallet_address_is_unique', {}); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191021185618-UserEvents.js b/packages/identity-service/build/sequelize/migrations/20191021185618-UserEvents.js new file mode 100644 index 00000000000..afc7929e208 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191021185618-UserEvents.js @@ -0,0 +1,39 @@ +'use strict'; +const models = require('../../src/models'); +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable('UserEvents', { + walletAddress: { + type: Sequelize.STRING, + primaryKey: true, + references: { model: 'Users', key: 'walletAddress' } + }, + needsRecoveryEmail: { + allowNull: true, + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + const users = await models.User.findAll({ + attributes: ['walletAddress'], + transaction + }); + const toInsert = users.map(({ dataValues }) => ({ + walletAddress: dataValues.walletAddress, + needsRecoveryEmail: true + })); + return models.UserEvents.bulkCreate(toInsert, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('UserEvents'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191025181732-subscriptions.js b/packages/identity-service/build/sequelize/migrations/20191025181732-subscriptions.js new file mode 100644 index 00000000000..6de7fe6458f --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191025181732-subscriptions.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Subscriptions', { + subscriberId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Subscriptions'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191025193919-create-notification.js b/packages/identity-service/build/sequelize/migrations/20191025193919-create-notification.js new file mode 100644 index 00000000000..3eca0333fb9 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191025193919-create-notification.js @@ -0,0 +1,45 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Notifications', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID + }, + type: { + type: Sequelize.ENUM('Follow', 'RepostTrack', 'RepostPlaylist', 'RepostAlbum', 'FavoriteTrack', 'FavoritePlaylist', 'FavoriteAlbum', 'CreateTrack', 'CreatePlaylist', 'CreateAlbum', 'Announcement', 'MilestoneListen', 'MilestoneRepost', 'MilestoneFavorite', 'MilestoneFollow') + }, + isRead: { + type: Sequelize.BOOLEAN + }, + isHidden: { + type: Sequelize.BOOLEAN + }, + userId: { + type: Sequelize.INTEGER + }, + entityId: { + allowNull: true, + type: Sequelize.INTEGER + }, + blocknumber: { + type: Sequelize.INTEGER + }, + timestamp: { + type: Sequelize.DATE + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Notifications'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191028173726-create-notification-actions.js b/packages/identity-service/build/sequelize/migrations/20191028173726-create-notification-actions.js new file mode 100644 index 00000000000..91ac774f87b --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191028173726-create-notification-actions.js @@ -0,0 +1,46 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('NotificationActions', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + notificationId: { + type: Sequelize.UUID, + allowNull: false, + onDelete: 'RESTRICT', + references: { + model: 'Notifications', + key: 'id', + as: 'notificationId' + } + }, + blocknumber: { + type: Sequelize.INTEGER, + allowNull: false + }, + actionEntityType: { + type: Sequelize.TEXT, + allowNull: false + }, + actionEntityId: { + type: Sequelize.INTEGER, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('NotificationActions'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191028214504-user-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20191028214504-user-notification-settings.js new file mode 100644 index 00000000000..cb68413709b --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191028214504-user-notification-settings.js @@ -0,0 +1,59 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('UserNotificationSettings', { + userId: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false + }, + favorites: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + announcements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + browserPushNotifications: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + emailFrequency: { + allowNull: false, + type: Sequelize.ENUM('daily', 'weekly', 'off'), + defaultValue: 'daily' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }).then(() => queryInterface.addIndex('UserNotificationSettings', ['userId'])); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('UserNotificationSettings', ['userId']) + .then(() => queryInterface.dropTable('UserNotificationSettings')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191104231723-blockchain-user-id.js b/packages/identity-service/build/sequelize/migrations/20191104231723-blockchain-user-id.js new file mode 100644 index 00000000000..85c437e17a1 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191104231723-blockchain-user-id.js @@ -0,0 +1,16 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'blockchainUserId', { + type: Sequelize.INTEGER, + allowNull: true + }) + .then(() => queryInterface.addIndex('Users', ['walletAddress'])) + .then(() => queryInterface.addIndex('Users', ['blockchainUserId'])); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('Users', ['blockchainUserId']) + .then(() => queryInterface.removeIndex('Users', ['walletAddress'])) + .then(() => queryInterface.removeColumn('Users', 'blockchainUserId')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191107221552-ip-timezone-user.js b/packages/identity-service/build/sequelize/migrations/20191107221552-ip-timezone-user.js new file mode 100644 index 00000000000..097021c31e0 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191107221552-ip-timezone-user.js @@ -0,0 +1,17 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'IP', { + type: Sequelize.STRING, + allowNull: true + }) + .then(() => queryInterface.addColumn('Users', 'timezone', { + type: Sequelize.STRING, + allowNull: true + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('Users', 'IP') + .then(() => queryInterface.removeColumn('Users', 'timezone')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191107223636-create-viewed-field.js b/packages/identity-service/build/sequelize/migrations/20191107223636-create-viewed-field.js new file mode 100644 index 00000000000..890d384bc3e --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191107223636-create-viewed-field.js @@ -0,0 +1,22 @@ +'use strict'; +const models = require('../../src/models'); +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('Notifications', 'isViewed', { + type: Sequelize.BOOLEAN, + allowNull: true + }, { transaction }); + await models.Notification.update({ + isViewed: false + }, { transaction, where: { isRead: { [models.Sequelize.Op.ne]: null } } }); + await queryInterface.changeColumn('Notifications', 'isViewed', { + type: Sequelize.BOOLEAN, + allowNull: false + }, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('Notifications', 'isViewed'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191108185559-create-notification-email.js b/packages/identity-service/build/sequelize/migrations/20191108185559-create-notification-email.js new file mode 100644 index 00000000000..b8bb15697d9 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191108185559-create-notification-email.js @@ -0,0 +1,36 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('NotificationEmails', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER + }, + timestamp: { + type: Sequelize.DATE, + primaryKey: true + }, + emailFrequency: { + allowNull: false, + type: Sequelize.ENUM('daily', 'weekly', 'off'), + defaultValue: 'daily' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('NotificationEmails'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191205235410-add_indexes_to_optimize_notifications.js b/packages/identity-service/build/sequelize/migrations/20191205235410-add_indexes_to_optimize_notifications.js new file mode 100644 index 00000000000..9e466b6a24e --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191205235410-add_indexes_to_optimize_notifications.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addIndex('NotificationActions', ['notificationId'], { transaction }); + await queryInterface.addIndex('Notifications', ['userId'], { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('NotificationActions', ['notificationId']) + .then(() => queryInterface.removeIndex('Notifications', ['userId'])); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191209222218-create-user-track-listen.js b/packages/identity-service/build/sequelize/migrations/20191209222218-create-user-track-listen.js new file mode 100644 index 00000000000..9867eeefa13 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191209222218-create-user-track-listen.js @@ -0,0 +1,33 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('UserTrackListens', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false + }, + trackId: { + type: Sequelize.INTEGER, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }).then(() => queryInterface.addIndex('UserTrackListens', ['userId'])); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('UserTrackListens', ['userId']) + .then(() => queryInterface.dropTable('UserTrackListens')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20191221192610-push_notifications.js b/packages/identity-service/build/sequelize/migrations/20191221192610-push_notifications.js new file mode 100644 index 00000000000..f7761fb1569 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20191221192610-push_notifications.js @@ -0,0 +1,92 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + const userNotificationMobileSettingsPromise = queryInterface.createTable('UserNotificationMobileSettings', { + userId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + announcements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }, { tableName: 'UserNotificationMobileSettings' }); + const notificationDeviceTokensPromise = queryInterface.createTable('NotificationDeviceTokens', { + userId: { + type: Sequelize.INTEGER, + allowNull: false + }, + deviceToken: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + deviceType: { + type: Sequelize.ENUM({ + values: [ + 'ios', + 'android' + ] + }), + allowNull: false + }, + awsARN: { + type: Sequelize.STRING, + allowNull: true + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + return Promise.all([ + userNotificationMobileSettingsPromise, + notificationDeviceTokensPromise + ]); + }, + down: (queryInterface, Sequelize) => { + return Promise.all([ + queryInterface.dropTable('UserNotificationMobileSettings'), + queryInterface.dropTable('NotificationDeviceTokens') + ]); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200107232937-create-pushed-announcement-notifications.js b/packages/identity-service/build/sequelize/migrations/20200107232937-create-pushed-announcement-notifications.js new file mode 100644 index 00000000000..06114419b00 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200107232937-create-pushed-announcement-notifications.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('PushedAnnouncementNotifications', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + announcementId: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('PushedAnnouncementNotifications'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200110002111-create-push-notification-badge-counts.js b/packages/identity-service/build/sequelize/migrations/20200110002111-create-push-notification-badge-counts.js new file mode 100644 index 00000000000..bd9799a8bd7 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200110002111-create-push-notification-badge-counts.js @@ -0,0 +1,25 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('PushNotificationBadgeCounts', { + userId: { + type: Sequelize.INTEGER, + primaryKey: true + }, + iosBadgeCount: { + type: Sequelize.INTEGER + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('PushNotificationBadgeCounts'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200317221617-create-notification-browser-subscription.js b/packages/identity-service/build/sequelize/migrations/20200317221617-create-notification-browser-subscription.js new file mode 100644 index 00000000000..30bd3466324 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200317221617-create-notification-browser-subscription.js @@ -0,0 +1,47 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('NotificationBrowserSubscriptions', { + userId: { + allowNull: false, + type: Sequelize.INTEGER + }, + endpoint: { + allowNull: false, + type: Sequelize.STRING, + primaryKey: true + }, + p256dhKey: { + allowNull: false, + type: Sequelize.STRING + }, + authKey: { + allowNull: false, + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + }, + enabled: { + allowNull: false, + type: Sequelize.BOOLEAN, + defaultValue: true + } + }).then(() => queryInterface + .sequelize.query("ALTER TYPE \"enum_NotificationDeviceTokens_deviceType\" ADD VALUE 'safari'")); + }, + down: (queryInterface) => { + return queryInterface.dropTable('NotificationBrowserSubscriptions') + .then(() => { + const query = 'DELETE FROM pg_enum ' + + 'WHERE enumlabel = \'safari\' ' + + 'AND enumtypid = ( SELECT oid FROM pg_type WHERE typname = "enum_NotificationDeviceTokens_deviceType")'; + return queryInterface.sequelize.query(query); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200320211740-create-user-notification-browser-settings.js b/packages/identity-service/build/sequelize/migrations/20200320211740-create-user-notification-browser-settings.js new file mode 100644 index 00000000000..59ac78c6925 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200320211740-create-user-notification-browser-settings.js @@ -0,0 +1,48 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('UserNotificationBrowserSettings', { + userId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + announcements: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('UserNotificationBrowserSettings'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200325175718-user-listen-counts.js b/packages/identity-service/build/sequelize/migrations/20200325175718-user-listen-counts.js new file mode 100644 index 00000000000..fcca8f7cd97 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200325175718-user-listen-counts.js @@ -0,0 +1,19 @@ +'use strict'; +/** + * Adds a 'count' field to UserTrackListens, which signifies the + * number of times a user has listened to a track + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('UserTrackListens', 'count', { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1 + }, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('UserTrackListens', 'count'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200325195706-unique-user-listens.js b/packages/identity-service/build/sequelize/migrations/20200325195706-unique-user-listens.js new file mode 100644 index 00000000000..60f294f677a --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200325195706-unique-user-listens.js @@ -0,0 +1,29 @@ +'use strict'; +/** + * Makes entries in the user list table unique on track id and user id. + * Deletes currently conflicting entries. We aren't sure how these arose in the + * first place. + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.query(` + DELETE FROM "UserTrackListens" + WHERE "id" in ( + SELECT "id" FROM ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY "userId", "trackId" ORDER BY "userId", "trackId") as rowNumber + FROM "UserTrackListens" + ) A + WHERE A.rowNumber > 1 + )`).then(() => { + return queryInterface.addConstraint('UserTrackListens', ['userId', 'trackId'], { + type: 'unique', + name: 'unique_on_user_id_and_track_id' + }); + }); + }, + down: (queryInterface, Sequelize) => { + // return new Promise(resolve => resolve()) + return queryInterface.removeConstraint('UserTrackListens', 'unique_on_user_id_and_track_id'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200403163850-user-playlist-favorites.js b/packages/identity-service/build/sequelize/migrations/20200403163850-user-playlist-favorites.js new file mode 100644 index 00000000000..067efd5bf93 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200403163850-user-playlist-favorites.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('UserPlaylistFavorites', { + userId: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('UserPlaylistFavorites'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200423221454-remove-waitlists.js b/packages/identity-service/build/sequelize/migrations/20200423221454-remove-waitlists.js new file mode 100644 index 00000000000..aabd61fb859 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200423221454-remove-waitlists.js @@ -0,0 +1,23 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Waitlists'); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.createTable('Waitlists', { + email: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200423231717-remove-beta-password.js b/packages/identity-service/build/sequelize/migrations/20200423231717-remove-beta-password.js new file mode 100644 index 00000000000..cf98021d633 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200423231717-remove-beta-password.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.dropTable('BetaPasswords'); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.createTable('BetaPasswords', { + password: { + type: Sequelize.STRING, + primaryKey: true, + autoIncrement: false, + allowNull: false + }, + remainingLogins: { + type: Sequelize.INTEGER, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200506213609-remix-notification.js b/packages/identity-service/build/sequelize/migrations/20200506213609-remix-notification.js new file mode 100644 index 00000000000..6e1647b7b91 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200506213609-remix-notification.js @@ -0,0 +1,63 @@ +'use strict'; +const models = require('../../src/models'); +/** + * Update the `UserNotificationBrowserSettings` table to add column `remixes` + * Update the `UserNotificationMobileSettings` table to add column `remixes` + * Update the enum `enum_Notifications_type` used in table `Notifications` column `type` + * Add the values `RemixCreate` and `RemixCosign` + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('UserNotificationBrowserSettings', 'remixes', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, { transaction }); + await queryInterface.addColumn('UserNotificationMobileSettings', 'remixes', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, { transaction }); + await queryInterface.sequelize.query(`ALTER TYPE "enum_Notifications_type" ADD VALUE 'RemixCreate'`); + await queryInterface.sequelize.query(`ALTER TYPE "enum_Notifications_type" ADD VALUE 'RemixCosign'`); + }); + }, + down: (queryInterface, Sequelize) => { + const tableName = 'Notifications'; + const columnName = 'type'; + const enumName = 'enum_Notifications_type'; + const newEnumName = `enum_Notifications_type_new`; + const prevValues = ['Follow', 'RepostTrack', 'RepostPlaylist', 'RepostAlbum', 'FavoriteTrack', + 'FavoritePlaylist', 'FavoriteAlbum', 'CreateTrack', 'CreatePlaylist', 'CreateAlbum', + 'Announcement', 'MilestoneListen', 'MilestoneRepost', 'MilestoneFavorite', 'MilestoneFollow']; + return queryInterface.sequelize.transaction(async (transaction) => { + // Create a copy of the type + await queryInterface.removeColumn('UserNotificationBrowserSettings', 'remixes', { transaction }); + await queryInterface.removeColumn('UserNotificationMobileSettings', 'remixes', { transaction }); + await models.Notification.destroy({ + where: { type: { [models.Sequelize.Op.in]: ['RemixCreate', 'RemixCosign'] } } + }); + await queryInterface.sequelize.query(` + CREATE TYPE "${newEnumName}" + AS ENUM ('${prevValues.join('\', \'')}') + `, { transaction }); + // Change column type to the new ENUM TYPE + await queryInterface.sequelize.query(` + ALTER TABLE "${tableName}" + ALTER COLUMN ${columnName} + TYPE "${newEnumName}" + USING ("${columnName}"::text::"${newEnumName}") + `, { transaction }); + // Drop old ENUM + await queryInterface.sequelize.query(` + DROP TYPE "${enumName}" + `, { transaction }); + // Rename new ENUM name + await queryInterface.sequelize.query(` + ALTER TYPE "${newEnumName}" + RENAME TO "${enumName}" + `, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200615162622-add-personal-website-and-donation-link.js b/packages/identity-service/build/sequelize/migrations/20200615162622-add-personal-website-and-donation-link.js new file mode 100644 index 00000000000..80a8113a726 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200615162622-add-personal-website-and-donation-link.js @@ -0,0 +1,25 @@ +'use strict'; +/** + * Adds 'website' and 'donation' fields to SocialHandles, which + * signify a personal/artist website and a donation link + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('SocialHandles', 'website', { + type: Sequelize.STRING, + allowNull: true + }, { transaction }); + await queryInterface.addColumn('SocialHandles', 'donation', { + type: Sequelize.STRING, + allowNull: true + }, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + queryInterface.removeColumn('SocialHandles', 'website'); + queryInterface.removeColumn('SocialHandles', 'donation'); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200706140854-download-email.js b/packages/identity-service/build/sequelize/migrations/20200706140854-download-email.js new file mode 100644 index 00000000000..968229524b2 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200706140854-download-email.js @@ -0,0 +1,47 @@ +'use strict'; +const models = require('../../src/models'); +/** + * Adds boolean columns 'hasSentDownloadAppEmail' and 'hasSignedInNativeMobile' + * to UserEvents to track if a user has signed in on native mobile and if not + * if they have been sent an email to download the app. + * Migration includes inserting row for each user so that they are not sent an + * email moving forward. + */ +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('UserEvents', 'hasSentDownloadAppEmail', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, { transaction }); + await queryInterface.addColumn('UserEvents', 'hasSignedInNativeMobile', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, { transaction }); + const users = await models.User.findAll({ + attributes: ['walletAddress'], + transaction + }); + const toInsert = users.map(({ dataValues }) => ({ + walletAddress: dataValues.walletAddress, + needsRecoveryEmail: false, + hasSignedInNativeMobile: true, + hasSentDownloadAppEmail: false + })); + await models.UserEvents.bulkCreate(toInsert, { transaction, ignoreDuplicates: true }); + await models.UserEvents.update({ + needsRecoveryEmail: false, + hasSignedInNativeMobile: true, + hasSentDownloadAppEmail: false + }, { where: {}, transaction }); + }); + }, + down: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeColumn('UserEvents', 'hasSentDownloadAppEmail', { transaction }); + await queryInterface.removeColumn('UserEvents', 'hasSignedInNativeMobile', { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20200723161647-add-live-emails.js b/packages/identity-service/build/sequelize/migrations/20200723161647-add-live-emails.js new file mode 100644 index 00000000000..75f148af16e --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20200723161647-add-live-emails.js @@ -0,0 +1,70 @@ +'use strict'; +const models = require('../../src/models'); +module.exports = { + up: (queryInterface, Sequelize) => { + // Add 'live' to "NotificatiionEmails" "emailFrequency" + return queryInterface.sequelize.query("ALTER TYPE \"enum_NotificationEmails_emailFrequency\" ADD VALUE 'live'") + // Do nothing if it already exists + // Deleting enum values is not possible w/o root db access, so in the case the enum already exists + // just continue on. If for some reason this threw but it didn't exist, we would fail at the next step + // not continue + .catch(() => { }) + // Change the default to 'live' + .finally(() => { + return queryInterface.sequelize.query("ALTER TABLE \"NotificationEmails\" ALTER \"emailFrequency\" set default 'live'::\"enum_NotificationEmails_emailFrequency\""); + }) + // Add 'live' to "UserNotificationSettings" "emailFrequency" + .then(() => { + return queryInterface.sequelize.query("ALTER TYPE \"enum_UserNotificationSettings_emailFrequency\" ADD VALUE 'live'"); + }) + // Do nothing if it already exists + // Deleting enum values is not possible w/o root db access, so in the case the enum already exists + // just continue on. If for some reason this threw but it didn't exist, we would fail at the next step + // not continue + .catch(() => { }) + // Change the default ot 'live' + .finally(() => { + return queryInterface.sequelize.query("ALTER TABLE \"UserNotificationSettings\" ALTER \"emailFrequency\" set default 'live'::\"enum_UserNotificationSettings_emailFrequency\""); + }) + // Set all the users who have the default (daily) to live + .then(() => { + return models.UserNotificationSettings.update({ + emailFrequency: 'live' + }, { + where: { emailFrequency: 'daily' } + }); + }); + }, + down: (queryInterface, Sequelize) => { + // Set all the users who have the default (live) to daily + return models.UserNotificationSettings.update({ + emailFrequency: 'daily' + }, { + where: { emailFrequency: 'live' } + }) + // Change the default back to 'daily' + .then(() => { + return queryInterface.sequelize.query("ALTER TABLE \"NotificationEmails\" ALTER \"emailFrequency\" set default 'daily'::\"enum_NotificationEmails_emailFrequency\""); + }) + // Normal users can't do this! + // Delete 'live' from "NotificationEmails" "emailFrequency" + // .then(() => { + // const query = 'DELETE FROM pg_enum ' + + // 'WHERE enumlabel = \'live\' ' + + // 'AND enumtypid = ( SELECT oid FROM pg_type WHERE typname = \'enum_NotificationEmails_emailFrequency\')' + // return queryInterface.sequelize.query(query) + // }) + // Change the default back to 'daily' + .then(() => { + return queryInterface.sequelize.query("ALTER TABLE \"UserNotificationSettings\" ALTER \"emailFrequency\" set default 'daily'::\"enum_UserNotificationSettings_emailFrequency\""); + }); + // Normal users can't do this! + // Delete 'live' from "NotificationEmails" "emailFrequency" + // .then(() => { + // const query = 'DELETE FROM pg_enum ' + + // 'WHERE enumlabel = \'live\' ' + + // 'AND enumtypid = ( SELECT oid FROM pg_type WHERE typname = \'enum_UserNotificationSettings_emailFrequency\')' + // return queryInterface.sequelize.query(query) + // }) + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20201027190747-notification-index.js b/packages/identity-service/build/sequelize/migrations/20201027190747-notification-index.js new file mode 100644 index 00000000000..c1cc519e845 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20201027190747-notification-index.js @@ -0,0 +1,19 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addIndex('NotificationActions', [ + 'notificationId', + 'actionEntityType', + 'actionEntityId', + 'blocknumber' + ]); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeIndex('NotificationActions', [ + 'notificationId', + 'actionEntityType', + 'actionEntityId', + 'blocknumber' + ]); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20201112160409-instagram-verified.js b/packages/identity-service/build/sequelize/migrations/20201112160409-instagram-verified.js new file mode 100644 index 00000000000..b59f02b4e54 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20201112160409-instagram-verified.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('InstagramUsers', 'verified', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('InstagramUsers', 'verified'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20201208220811-notification-trending-track.js b/packages/identity-service/build/sequelize/migrations/20201208220811-notification-trending-track.js new file mode 100644 index 00000000000..689de654908 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20201208220811-notification-trending-track.js @@ -0,0 +1,49 @@ +'use strict'; +const models = require('../../src/models'); +module.exports = { + up: async (queryInterface, Sequelize) => { + /** + * Add 'TrendingTrack' to enum 'enum_Notifications_type' + */ + await queryInterface.sequelize.query(`ALTER TYPE "enum_Notifications_type" ADD VALUE 'TrendingTrack'`); + }, + down: async (queryInterface, Sequelize) => { + /** + * Remove 'TrendingTrack' from enum 'enum_Notifications_type' + */ + const tableName = 'Notifications'; + const columnName = 'type'; + const enumName = 'enum_Notifications_type'; + const newEnumName = `enum_Notifications_type_new`; + const prevValues = ['Follow', 'RepostTrack', 'RepostPlaylist', 'RepostAlbum', 'FavoriteTrack', + 'FavoritePlaylist', 'FavoriteAlbum', 'CreateTrack', 'CreatePlaylist', 'CreateAlbum', + 'Announcement', 'MilestoneListen', 'MilestoneRepost', 'MilestoneFavorite', 'MilestoneFollow', + 'RemixCreate', 'RemixCosign']; + return queryInterface.sequelize.transaction(async (transaction) => { + await models.Notification.destroy({ + where: { type: { [models.Sequelize.Op.in]: ['TrendingTrack'] } }, + transaction + }); + await queryInterface.sequelize.query(` + CREATE TYPE "${newEnumName}" + AS ENUM ('${prevValues.join('\', \'')}') + `, { transaction }); + // Change column type to the new ENUM TYPE + await queryInterface.sequelize.query(` + ALTER TABLE "${tableName}" + ALTER COLUMN ${columnName} + TYPE "${newEnumName}" + USING ("${columnName}"::text::"${newEnumName}") + `, { transaction }); + // Drop old ENUM + await queryInterface.sequelize.query(` + DROP TYPE "${enumName}" + `, { transaction }); + // Rename new ENUM name + await queryInterface.sequelize.query(` + ALTER TYPE "${newEnumName}" + RENAME TO "${enumName}" + `, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210223044705-create-bot-scores.js b/packages/identity-service/build/sequelize/migrations/20210223044705-create-bot-scores.js new file mode 100644 index 00000000000..a598e4e6989 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210223044705-create-bot-scores.js @@ -0,0 +1,42 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('BotScores', { + id: { + allowNull: false, + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true + }, + walletAddress: { + allowNull: false, + type: Sequelize.STRING + }, + recaptchaScore: { + allowNull: false, + type: Sequelize.DECIMAL + }, + recaptchaContext: { + allowNull: false, + type: Sequelize.STRING + }, + recaptchaHostname: { + allowNull: false, + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + .then(() => queryInterface.addIndex('BotScores', ['walletAddress'])); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('BotScores', ['walletAddress']) + .then(() => queryInterface.dropTable('BotScores')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210511205323-add-user-playlist-updates.js b/packages/identity-service/build/sequelize/migrations/20210511205323-add-user-playlist-updates.js new file mode 100644 index 00000000000..67a13006f84 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210511205323-add-user-playlist-updates.js @@ -0,0 +1,16 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('UserEvents', 'playlistUpdates', { + type: Sequelize.JSONB, + allowNull: true + }, { transaction }); + }); + }, + down: (queryInterface) => { + return queryInterface.sequelize.transaction(async () => { + queryInterface.removeColumn('UserEvents', 'playlistUpdates'); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210707144138-protocol-service-provider.js b/packages/identity-service/build/sequelize/migrations/20210707144138-protocol-service-provider.js new file mode 100644 index 00000000000..fa57474d21c --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210707144138-protocol-service-provider.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('ProtocolServiceProviders', { + wallet: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + minimumDelegationAmount: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface) => { + return queryInterface.dropTable('ProtocolServiceProviders'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210728222706-create-cognito-flows-table.js b/packages/identity-service/build/sequelize/migrations/20210728222706-create-cognito-flows-table.js new file mode 100644 index 00000000000..74e72b6bd5f --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210728222706-create-cognito-flows-table.js @@ -0,0 +1,39 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('CognitoFlows', { + id: { + allowNull: false, + type: Sequelize.STRING, + primaryKey: true + }, + sessionId: { + allowNull: false, + type: Sequelize.STRING + }, + handle: { + allowNull: false, + type: Sequelize.STRING + }, + status: { + allowNull: false, + type: Sequelize.STRING + }, + score: { + allowNull: false, + type: Sequelize.DECIMAL + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface) => { + return queryInterface.dropTable('CognitoFlows'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210813190309-add-tiktok-link.js b/packages/identity-service/build/sequelize/migrations/20210813190309-add-tiktok-link.js new file mode 100644 index 00000000000..54c33180ab5 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210813190309-add-tiktok-link.js @@ -0,0 +1,19 @@ +'use strict'; +/** + * Adds a 'tikTok' field to SocialHandles + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('SocialHandles', 'tikTokHandle', { + type: Sequelize.STRING, + allowNull: true + }, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + queryInterface.removeColumn('SocialHandles', 'tikTokHandle'); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210824180845-add-email-index-users-table.js b/packages/identity-service/build/sequelize/migrations/20210824180845-add-email-index-users-table.js new file mode 100644 index 00000000000..c60af798829 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210824180845-add-email-index-users-table.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addIndex('Users', ['email']); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('Users', ['email']); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210830143757-create-solana-notifications.js b/packages/identity-service/build/sequelize/migrations/20210830143757-create-solana-notifications.js new file mode 100644 index 00000000000..0a9c875bdc6 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210830143757-create-solana-notifications.js @@ -0,0 +1,45 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('SolanaNotifications', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID + }, + type: { + type: Sequelize.ENUM('ChallengeReward') + }, + isRead: { + type: Sequelize.BOOLEAN + }, + isHidden: { + type: Sequelize.BOOLEAN + }, + isViewed: { + type: Sequelize.BOOLEAN + }, + userId: { + type: Sequelize.INTEGER + }, + entityId: { + allowNull: true, + type: Sequelize.INTEGER + }, + slot: { + type: Sequelize.INTEGER + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('SolanaNotifications'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20210830144243-create-solana-notification-actions.js b/packages/identity-service/build/sequelize/migrations/20210830144243-create-solana-notification-actions.js new file mode 100644 index 00000000000..68646611ebc --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20210830144243-create-solana-notification-actions.js @@ -0,0 +1,46 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('SolanaNotificationActions', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + notificationId: { + type: Sequelize.UUID, + allowNull: false, + onDelete: 'RESTRICT', + references: { + model: 'SolanaNotifications', + key: 'id', + as: 'notificationId' + } + }, + slot: { + type: Sequelize.INTEGER, + allowNull: false + }, + actionEntityType: { + type: Sequelize.TEXT, + allowNull: false + }, + actionEntityId: { + type: Sequelize.INTEGER, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('SolanaNotificationActions'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20211025184306-solana-notification-type.js b/packages/identity-service/build/sequelize/migrations/20211025184306-solana-notification-type.js new file mode 100644 index 00000000000..1a9e2fc5cba --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20211025184306-solana-notification-type.js @@ -0,0 +1,43 @@ +'use strict'; +const models = require('../../src/models'); +/** + * Adds enum value `MilestoneListen` to the solana notifications table, column "type" + */ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.query("ALTER TYPE \"enum_SolanaNotifications_type\" ADD VALUE 'MilestoneListen'"); + }, + down: (queryInterface, Sequelize) => { + const tableName = 'SolanaNotifications'; + const columnName = 'type'; + const enumName = 'enum_SolanaNotifications_type'; + const newEnumName = `enum_SolanaNotifications_type_new`; + const prevValues = ['ChallengeReward']; + return queryInterface.sequelize.transaction(async (transaction) => { + await models.Notification.destroy({ + where: { type: { [models.Sequelize.Op.in]: ['MilestoneListen'] } }, + transaction + }); + await queryInterface.sequelize.query(` + CREATE TYPE "${newEnumName}" + AS ENUM ('${prevValues.join('\', \'')}') + `, { transaction }); + // Change column type to the new ENUM TYPE + await queryInterface.sequelize.query(` + ALTER TABLE "${tableName}" + ALTER COLUMN ${columnName} + TYPE "${newEnumName}" + USING ("${columnName}"::text::"${newEnumName}") + `, { transaction }); + // Drop old ENUM + await queryInterface.sequelize.query(` + DROP TYPE "${enumName}" + `, { transaction }); + // Rename new ENUM name + await queryInterface.sequelize.query(` + ALTER TYPE "${newEnumName}" + RENAME TO "${enumName}" + `, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20211123173149-rewards_attester.js b/packages/identity-service/build/sequelize/migrations/20211123173149-rewards_attester.js new file mode 100644 index 00000000000..50e9ef53df8 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20211123173149-rewards_attester.js @@ -0,0 +1,29 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('RewardAttesterValues', { + startingBlock: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + primaryKey: true + }, + offset: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0 + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface) => { + return queryInterface.dropTable('RewardAttesterValues'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20211223183608-add-solana-notification-indexes.js b/packages/identity-service/build/sequelize/migrations/20211223183608-add-solana-notification-indexes.js new file mode 100644 index 00000000000..4921f1852f1 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20211223183608-add-solana-notification-indexes.js @@ -0,0 +1,31 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addIndex('SolanaNotifications', ['userId'], { + transaction, + name: 'idx_solana_notifications_user_id' + }); + await queryInterface.addIndex('SolanaNotificationActions', ['slot'], { + transaction, + name: 'idx_solana_notification_actions_slot' + }); + await queryInterface.addIndex('SolanaNotificationActions', ['notificationId'], { + transaction, + name: 'idx_solana_notification_actions_notif_id' + }); + await queryInterface.addIndex('SolanaNotificationActions', ['notificationId', 'actionEntityType', 'actionEntityId', 'slot'], { + transaction, + name: 'idx_solana_notification_actions_all' + }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex('SolanaNotifications', 'idx_solana_notifications_user_id', { transaction }); + await queryInterface.removeIndex('SolanaNotificationActions', 'idx_solana_notification_actions_slot', { transaction }); + await queryInterface.removeIndex('SolanaNotificationActions', 'idx_solana_notification_actions_notif_id', { transaction }); + await queryInterface.removeIndex('SolanaNotificationActions', 'idx_solana_notification_actions_all', { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220222034002-add-cognito-flow-identities-table.js b/packages/identity-service/build/sequelize/migrations/20220222034002-add-cognito-flow-identities-table.js new file mode 100644 index 00000000000..84c4c02f3ac --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220222034002-add-cognito-flow-identities-table.js @@ -0,0 +1,23 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('CognitoFlowIdentities', { + maskedIdentity: { + allowNull: false, + primaryKey: true, + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface) => { + return queryInterface.dropTable('CognitoFlowIdentities'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220227180347-add-indices.js b/packages/identity-service/build/sequelize/migrations/20220227180347-add-indices.js new file mode 100644 index 00000000000..a44567a6f21 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220227180347-add-indices.js @@ -0,0 +1,21 @@ +'use strict'; +module.exports = { + up: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "users_handle_idx" on "Users" ("handle")`, { transaction }); + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "cognito_flows_handle_idx" on "CognitoFlows" ("handle")`, { transaction }); + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "twitter_users_screen_name_idx" ON "TwitterUsers"(("twitterProfile"->>'screen_name'))`, { transaction }); + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "instagram_users_profile_username_idx" ON "InstagramUsers"(("profile"->>'username'))`, { transaction }); + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "notification_emails_user_id_idx" ON "NotificationEmails" ("userId");`, { transaction }); + }); + }, + down: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "users_handle_idx"`, { transaction }); + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "cognito_flows_handle_idx"`, { transaction }); + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "twitter_users_screen_name_idx"`, { transaction }); + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "instagram_users_profile_username_idx"`, { transaction }); + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "notification_emails_user_id_idx"`, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220310212727-fingerprint.js b/packages/identity-service/build/sequelize/migrations/20220310212727-fingerprint.js new file mode 100644 index 00000000000..2e622dcc57d --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220310212727-fingerprint.js @@ -0,0 +1,40 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Fingerprints', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false + }, + visitorId: { + type: Sequelize.STRING, + allowNull: false + }, + origin: { + type: Sequelize.ENUM('web', 'mobile', 'desktop'), + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }, {}).then(() => queryInterface.addIndex('Fingerprints', ['userId'])).then(() => { + queryInterface.addIndex('Fingerprints', ['visitorId']); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeIndex('Fingerprints', ['visitorId']) + .then(() => queryInterface.removeIndex('Fingerprints', ['userId'])) + .then(() => queryInterface.dropTable('Fingerprints')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220311221027-add-user-ips.js b/packages/identity-service/build/sequelize/migrations/20220311221027-add-user-ips.js new file mode 100644 index 00000000000..c91444e21ce --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220311221027-add-user-ips.js @@ -0,0 +1,27 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('UserIPs', { + handle: { + allowNull: false, + primaryKey: true, + type: Sequelize.STRING + }, + userIP: { + allowNull: false, + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface) => { + return queryInterface.dropTable('UserIPs'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220428223149-add_reactions.js b/packages/identity-service/build/sequelize/migrations/20220428223149-add_reactions.js new file mode 100644 index 00000000000..66fa7dc6251 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220428223149-add_reactions.js @@ -0,0 +1,44 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('Reactions', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + slot: { + type: Sequelize.INTEGER, + allowNull: false + }, + reaction: { + type: Sequelize.INTEGER, + allowNull: false + }, + senderWallet: { + type: Sequelize.STRING, + allowNull: false + }, + entityId: { + type: Sequelize.STRING, + allowNull: false + }, + entityType: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('Reactions'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220509223231-change_reaction_columns.js b/packages/identity-service/build/sequelize/migrations/20220509223231-change_reaction_columns.js new file mode 100644 index 00000000000..68db4957027 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220509223231-change_reaction_columns.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Reactions', 'entityId', 'reactedTo') + .then(() => queryInterface.renameColumn('Reactions', 'entityType', 'reactionType')) + .then(() => queryInterface.renameColumn('Reactions', 'reaction', 'reactionValue')); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Reactions', 'reactedTo', 'entityId') + .then(() => queryInterface.renameColumn('Reactions', 'reactionType', 'entityType')) + .then(() => queryInterface.renameColumn('Reactions', 'reactionValue', 'reaction')); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220512172226-add_notification_json_b.js b/packages/identity-service/build/sequelize/migrations/20220512172226-add_notification_json_b.js new file mode 100644 index 00000000000..7eb452fc3de --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220512172226-add_notification_json_b.js @@ -0,0 +1,40 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + // This migration requires each enum add in it's own query, otherwise Sequelize complains + // that this can't be performed within a transaction + return queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'TipSend'`) + .then(() => queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'TipReceive'`)).then(() => queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'Reaction'`)).then(() => queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'SupporterRankUp'`)).then(() => queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'SupportingRankUp'`)).then(() => queryInterface.addColumn('SolanaNotifications', 'metadata', { + type: Sequelize.JSONB, + allowNull: true + })).then(() => queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "solana_notifications_metadata_tip_tx_signature_idx" ON "SolanaNotifications"(("metadata"->>'tipTxSignature'))`)); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query('DROP INDEX IF EXISTS "solana_notifications_metadata_tip_tx_signature_idx";', { transaction }); + // Recreate the old enum: + // Create a new enum + await queryInterface.sequelize.query(` + CREATE TYPE "enum_SolanaNotifications_type_new" + AS ENUM ('ChallengeReward', 'MilestoneListen'); + `, { transaction }); + // Set the column to the new enum + await queryInterface.sequelize.query(` + ALTER TABLE "SolanaNotifications" + ALTER COLUMN "type" + TYPE "enum_SolanaNotifications_type_new" + USING ("type"::text::"enum_SolanaNotifications_type_new"); + `, { transaction }); + // Drop old enum + await queryInterface.sequelize.query(` + DROP TYPE "enum_SolanaNotifications_type"; + `, { transaction }); + // Rename new enum + await queryInterface.sequelize.query(` + ALTER TYPE "enum_SolanaNotifications_type_new" + RENAME TO "enum_SolanaNotifications_type"; + `, { transaction }); + await queryInterface.removeColumn('SolanaNotifications', 'metadata', { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220609043104-add-track-to-playlist-notification.js b/packages/identity-service/build/sequelize/migrations/20220609043104-add-track-to-playlist-notification.js new file mode 100644 index 00000000000..31dc72ce8a3 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220609043104-add-track-to-playlist-notification.js @@ -0,0 +1,48 @@ +'use strict'; +const models = require('../../src/models'); +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(`ALTER TYPE "enum_Notifications_type" ADD VALUE 'AddTrackToPlaylist'`); + await queryInterface.addColumn('Notifications', 'metadata', { + type: Sequelize.JSONB, + allowNull: true + }, { transaction }); + }); + }, + down: (queryInterface, Sequelize) => { + const tableName = 'Notifications'; + const columnName = 'type'; + const enumName = 'enum_Notifications_type'; + const newEnumName = `enum_Notifications_type_new`; + const prevValues = ['Follow', 'RepostTrack', 'RepostPlaylist', 'RepostAlbum', 'FavoriteTrack', + 'FavoritePlaylist', 'FavoriteAlbum', 'CreateTrack', 'CreatePlaylist', 'CreateAlbum', + 'Announcement', 'MilestoneListen', 'MilestoneRepost', 'MilestoneFavorite', 'MilestoneFollow', 'RemixCreate', 'RemixCosign', 'TrendingTrack']; + return queryInterface.sequelize.transaction(async (transaction) => { + // Delete notifs with this type + await models.Notification.destroy({ + where: { type: { [models.Sequelize.Op.in]: ['AddTrackToPlaylist'] } } + }); + // Remove metadata + await queryInterface.removeColumn('Notifications', 'metadata'); + // Revert enum change + await queryInterface.sequelize.query(` + CREATE TYPE "${newEnumName}" + AS ENUM ('${prevValues.join('\', \'')}') + `, { transaction }); + await queryInterface.sequelize.query(` + ALTER TABLE "${tableName}" + ALTER COLUMN ${columnName} + TYPE "${newEnumName}" + USING ("${columnName}"::text::"${newEnumName}") + `, { transaction }); + await queryInterface.sequelize.query(` + DROP TYPE "${enumName}" + `, { transaction }); + await queryInterface.sequelize.query(` + ALTER TYPE "${newEnumName}" + RENAME TO "${enumName}" + `, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220610002940-add-is-abusive.js b/packages/identity-service/build/sequelize/migrations/20220610002940-add-is-abusive.js new file mode 100644 index 00000000000..54d2b2c0953 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220610002940-add-is-abusive.js @@ -0,0 +1,12 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'isAbusive', { + type: Sequelize.BOOLEAN, + defaultValue: false + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('Users', 'isAbusive'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220711214812-add-deliverability.js b/packages/identity-service/build/sequelize/migrations/20220711214812-add-deliverability.js new file mode 100644 index 00000000000..3fce6da81ae --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220711214812-add-deliverability.js @@ -0,0 +1,12 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'isEmailDeliverable', { + type: Sequelize.BOOLEAN, + defaultValue: true + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('Users', 'isEmailDeliverable'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220809211742-add_supporter_dethroned.js b/packages/identity-service/build/sequelize/migrations/20220809211742-add_supporter_dethroned.js new file mode 100644 index 00000000000..a3c47c523ff --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220809211742-add_supporter_dethroned.js @@ -0,0 +1,37 @@ +'use strict'; +module.exports = { + up: (queryInterface) => { + return queryInterface.sequelize.query(`ALTER TYPE "enum_SolanaNotifications_type" ADD VALUE 'SupporterDethroned'`); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + // Drop any DethronedNotifs + await queryInterface.sequelize.query(` + DELETE FROM "SolanaNotifications" + WHERE "type" = 'SupporterDethroned'; + `, { transaction }); + // Recreate the old enum: + // Create a new enum + await queryInterface.sequelize.query(` + CREATE TYPE "enum_SolanaNotifications_type_new" + AS ENUM ('ChallengeReward', 'MilestoneListen', 'TipSend', 'TipReceive', 'Reaction', 'SupporterRankUp', 'SupportingRankUp'); + `, { transaction }); + // Set the column to the new enum + await queryInterface.sequelize.query(` + ALTER TABLE "SolanaNotifications" + ALTER COLUMN "type" + TYPE "enum_SolanaNotifications_type_new" + USING ("type"::text::"enum_SolanaNotifications_type_new"); + `, { transaction }); + // Drop old enum + await queryInterface.sequelize.query(` + DROP TYPE "enum_SolanaNotifications_type"; + `, { transaction }); + // Rename new enum + await queryInterface.sequelize.query(` + ALTER TYPE "enum_SolanaNotifications_type_new" + RENAME TO "enum_SolanaNotifications_type"; + `, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220811205355-add-abusive-error-code.js b/packages/identity-service/build/sequelize/migrations/20220811205355-add-abusive-error-code.js new file mode 100644 index 00000000000..b0a9c787901 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220811205355-add-abusive-error-code.js @@ -0,0 +1,12 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'isAbusiveErrorCode', { + type: Sequelize.STRING, + defaultValue: null + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('Users', 'isAbusiveErrorCode'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220826063452-create-user-bank-transaction-metadata.js b/packages/identity-service/build/sequelize/migrations/20220826063452-create-user-bank-transaction-metadata.js new file mode 100644 index 00000000000..19ad2610404 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220826063452-create-user-bank-transaction-metadata.js @@ -0,0 +1,37 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable('UserBankTransactionMetadata', { + userId: { + type: Sequelize.INTEGER, + allowNull: false + }, + transactionSignature: { + type: Sequelize.STRING, + primaryKey: true, + allowNull: false + }, + metadata: { + type: Sequelize.JSONB, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }, { transaction }); + await queryInterface.addIndex('UserBankTransactionMetadata', ['userId'], { transaction, name: 'idx_user_bank_transaction_metadata_user_id' }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex('UserBankTransactionMetadata', 'idx_user_bank_transaction_metadata_user_id', { transaction }); + await queryInterface.dropTable('UserBankTransactionMetadata', { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20220912160855-abuse-columns.js b/packages/identity-service/build/sequelize/migrations/20220912160855-abuse-columns.js new file mode 100644 index 00000000000..4f9a63c6d62 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20220912160855-abuse-columns.js @@ -0,0 +1,46 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('Users', 'isBlockedFromRelay', { + type: Sequelize.BOOLEAN, + allowNull: true + }, { transaction }); + await queryInterface.addColumn('Users', 'isBlockedFromNotifications', { + type: Sequelize.BOOLEAN, + allowNull: true + }, { transaction }); + await queryInterface.addColumn('Users', 'appliedRules', { + type: Sequelize.JSONB, + allowNull: true + }, { transaction }); + await queryInterface.sequelize.query('UPDATE "Users" SET "isBlockedFromRelay" = true WHERE "isAbusive" = true AND "isAbusiveErrorCode" != \'9\'', { transaction }); + await queryInterface.sequelize.query('UPDATE "Users" SET "isBlockedFromNotifications" = true WHERE "isAbusive" = true AND "isAbusiveErrorCode" = \'9\'', { transaction }); + // Safe to drop this now since we've moved users + await queryInterface.removeColumn('Users', 'isAbusive', { transaction }); + await queryInterface.removeColumn('Users', 'isAbusiveErrorCode', { + transaction + }); + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeColumn('Users', 'isBlockedFromRelay', { + transaction + }); + await queryInterface.removeColumn('Users', 'isBlockedFromNotifications', { + transaction + }); + await queryInterface.removeColumn('Users', 'appliedRules', { + transaction + }); + await queryInterface.addColumn('Users', 'isAbusive', { + type: Sequelize.BOOLEAN, + defaultValue: false + }); + await queryInterface.addColumn('Users', 'isAbusiveErrorCode', { + type: Sequelize.STRING + }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20221115014540-blocked-from-emails-column.js b/packages/identity-service/build/sequelize/migrations/20221115014540-blocked-from-emails-column.js new file mode 100644 index 00000000000..31f73736aa1 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20221115014540-blocked-from-emails-column.js @@ -0,0 +1,12 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('Users', 'isBlockedFromEmails', { + type: Sequelize.BOOLEAN, + allowNull: true + }); + }, + down: (queryInterface) => { + return queryInterface.removeColumn('Users', 'isBlockedFromEmails'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20221205230946-create-tiktok-user.js b/packages/identity-service/build/sequelize/migrations/20221205230946-create-tiktok-user.js new file mode 100644 index 00000000000..ba8502fc79e --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20221205230946-create-tiktok-user.js @@ -0,0 +1,48 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface + .createTable('TikTokUsers', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + profile: { + type: Sequelize.JSONB, + allowNull: false, + unique: false + }, + verified: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + uuid: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: Sequelize.INTEGER, + allowNull: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + .then(() => queryInterface.addIndex('TikTokUsers', { + fields: ['uuid'], + type: 'UNIQUE' + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('TikTokUsers'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20221219201744-add-indices-to-slot-and-blocknum.js b/packages/identity-service/build/sequelize/migrations/20221219201744-add-indices-to-slot-and-blocknum.js new file mode 100644 index 00000000000..edbb53e02cd --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20221219201744-add-indices-to-slot-and-blocknum.js @@ -0,0 +1,15 @@ +'use strict'; +module.exports = { + up: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "solana_notifications_slot_idx" on "SolanaNotifications" ("slot")`, { transaction }); + await queryInterface.sequelize.query(`CREATE INDEX IF NOT EXISTS "notifications_blocknumber_idx" on "Notifications" ("blocknumber")`, { transaction }); + }); + }, + down: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "solana_notifications_slot_idx"`, { transaction }); + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS "notifications_blocknumber_idx"`, { transaction }); + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230127022759-add-messages-to-browser-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230127022759-add-messages-to-browser-notification-settings.js new file mode 100644 index 00000000000..b6f287233b7 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230127022759-add-messages-to-browser-notification-settings.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('UserNotificationBrowserSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('UserNotificationBrowserSettings', 'messages'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230127023810-add-messages-to-mobile-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230127023810-add-messages-to-mobile-notification-settings.js new file mode 100644 index 00000000000..4e1bec60d33 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230127023810-add-messages-to-mobile-notification-settings.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('UserNotificationMobileSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('UserNotificationMobileSettings', 'messages'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230206073741-drop-pinned-track-id.js b/packages/identity-service/build/sequelize/migrations/20230206073741-drop-pinned-track-id.js new file mode 100644 index 00000000000..919d09d07fc --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230206073741-drop-pinned-track-id.js @@ -0,0 +1,11 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.removeColumn('SocialHandles', 'pinnedTrackId'); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.addColumn('SocialHandles', 'pinnedTrackId', { + type: Sequelize.INTEGER + }); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230415023159-change-default-messages-mobile-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230415023159-change-default-messages-mobile-notification-settings.js new file mode 100644 index 00000000000..3e7ba5c9401 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230415023159-change-default-messages-mobile-notification-settings.js @@ -0,0 +1,21 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationMobileSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationMobileSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationMobileSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationMobileSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + })); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230415023208-change-default-messages-browser-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230415023208-change-default-messages-browser-notification-settings.js new file mode 100644 index 00000000000..6fb9f4f80a4 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230415023208-change-default-messages-browser-notification-settings.js @@ -0,0 +1,21 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationBrowserSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationBrowserSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationBrowserSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationBrowserSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + })); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230518025711-rename-transactions-encodedABI.js b/packages/identity-service/build/sequelize/migrations/20230518025711-rename-transactions-encodedABI.js new file mode 100644 index 00000000000..fa860d79738 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230518025711-rename-transactions-encodedABI.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Transactions', 'encodedABI', 'encodedNonceAndSignature'); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.renameColumn('Transactions', 'encodedNonceAndSignature', 'encodedABI'); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230626231334-default-messages-on-mobile-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230626231334-default-messages-on-mobile-notification-settings.js new file mode 100644 index 00000000000..49385430969 --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230626231334-default-messages-on-mobile-notification-settings.js @@ -0,0 +1,21 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationMobileSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationMobileSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationMobileSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationMobileSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + })); + } +}; diff --git a/packages/identity-service/build/sequelize/migrations/20230626231346-default-messages-on-browser-notification-settings.js b/packages/identity-service/build/sequelize/migrations/20230626231346-default-messages-on-browser-notification-settings.js new file mode 100644 index 00000000000..a40583fc7bd --- /dev/null +++ b/packages/identity-service/build/sequelize/migrations/20230626231346-default-messages-on-browser-notification-settings.js @@ -0,0 +1,21 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationBrowserSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationBrowserSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + })); + }, + down: (queryInterface, Sequelize) => { + return queryInterface + .removeColumn('UserNotificationBrowserSettings', 'messages') + .then(() => queryInterface.addColumn('UserNotificationBrowserSettings', 'messages', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + })); + } +}; diff --git a/packages/identity-service/build/src/analytics.js b/packages/identity-service/build/src/analytics.js new file mode 100644 index 00000000000..ca021c4cff6 --- /dev/null +++ b/packages/identity-service/build/src/analytics.js @@ -0,0 +1,27 @@ +"use strict"; +const amplitude = require('@amplitude/node'); +const config = require('./config'); +class AnalyticsProvider { + constructor() { + const AMPLITUDE_API_KEY = config.get('amplitudeAPIKey'); + try { + this.amplitudeInstance = amplitude.init(AMPLITUDE_API_KEY); + } + catch (e) { + console.log(`Failed to init amplitude with error: ${JSON.stringify(e)}`); + } + } + async track(eventName, userId, properties) { + try { + await this.amplitudeInstance.logEvent({ + event_type: eventName, + event_properties: properties, + user_id: `${userId}` + }); + } + catch (e) { + console.log(`Failed to log amplitude event with error: ${e}`); + } + } +} +module.exports = AnalyticsProvider; diff --git a/packages/identity-service/build/src/announcements.js b/packages/identity-service/build/src/announcements.js new file mode 100644 index 00000000000..58241c6ed55 --- /dev/null +++ b/packages/identity-service/build/src/announcements.js @@ -0,0 +1,27 @@ +"use strict"; +const axios = require('axios'); +const moment = require('moment'); +const config = require('./config.js'); +module.exports.fetchAnnouncements = async () => { + const audiusNotificationUrl = config.get('audiusNotificationUrl'); + const response = await axios.get(`${audiusNotificationUrl}/index.json`); + if (response.data && Array.isArray(response.data.notifications)) { + const announcementsResponse = await Promise.all(response.data.notifications.map(async (notification) => { + const notificationResponse = await axios.get(`${audiusNotificationUrl}/${notification.id}.json`); + return notificationResponse.data; + })); + const announcements = announcementsResponse + .filter((a) => !!a.entityId) + .map((a) => ({ ...a, type: 'Announcement' })); + announcements.sort((a, b) => { + const aDate = moment(a.datePublished); + const bDate = moment(b.datePublished); + return bDate - aDate; + }); + const announcementMap = announcements.reduce((acc, a) => { + acc[a.entityId] = a; + return acc; + }, {}); + return { announcements, announcementMap }; + } +}; diff --git a/packages/identity-service/build/src/apiHelpers.js b/packages/identity-service/build/src/apiHelpers.js new file mode 100644 index 00000000000..2cc3f4ae98d --- /dev/null +++ b/packages/identity-service/build/src/apiHelpers.js @@ -0,0 +1,70 @@ +"use strict"; +const { requestNotExcludedFromLogging } = require('./logging'); +module.exports.handleResponse = (func) => { + return async function (req, res, next) { + try { + const resp = await func(req, res, next); + if (!isValidResponse(resp)) { + throw new Error('Invalid response returned by function'); + } + sendResponse(req, res, resp); + next(); + } + catch (error) { + next(error); + } + }; +}; +const sendResponse = (module.exports.sendResponse = (req, res, resp) => { + const endTime = process.hrtime(req.startTime); + const duration = Math.round(endTime[0] * 1e3 + endTime[1] * 1e-6); + let logger = req.logger.child({ + statusCode: resp.statusCode, + duration + }); + if (resp.statusCode === 200) { + if (requestNotExcludedFromLogging(req.originalUrl)) { + logger.debug('Success'); + } + } + else { + logger = logger.child({ + errorMessage: resp.object.error + }); + logger.error('Error processing request:', resp.object.error); + } + res.status(resp.statusCode).send(resp.object); +}); +const isValidResponse = (module.exports.isValidResponse = (resp) => { + if (!resp || !resp.statusCode || !resp.object) { + return false; + } + return true; +}); +module.exports.successResponse = (obj = {}) => { + return { + statusCode: 200, + object: obj + }; +}; +const errorResponse = (module.exports.errorResponse = (statusCode, message, extra = {}) => { + return { + statusCode: statusCode, + object: { error: message, ...extra } + }; +}); +module.exports.errorResponseRateLimited = (message) => { + return errorResponse(429, message); +}; +module.exports.errorResponseUnauthorized = (message) => { + return errorResponse(401, message); +}; +module.exports.errorResponseForbidden = (message) => { + return errorResponse(403, message); +}; +module.exports.errorResponseBadRequest = (message) => { + return errorResponse(400, message); +}; +module.exports.errorResponseServerError = (message, extra = {}) => { + return errorResponse(500, message, extra); +}; diff --git a/packages/identity-service/build/src/app.js b/packages/identity-service/build/src/app.js new file mode 100644 index 00000000000..fe34f91f381 --- /dev/null +++ b/packages/identity-service/build/src/app.js @@ -0,0 +1,347 @@ +"use strict"; +const express = require('express'); +const bodyParser = require('body-parser'); +const cookieParser = require('cookie-parser'); +const sgMail = require('@sendgrid/mail'); +const { redisClient, Lock } = require('./redis'); +const optimizelySDK = require('@optimizely/optimizely-sdk'); +const Sentry = require('@sentry/node'); +const cluster = require('cluster'); +const config = require('./config.js'); +const txRelay = require('./relay/txRelay'); +const ethTxRelay = require('./relay/ethTxRelay'); +const { runMigrations } = require('./migrationManager'); +const audiusLibsWrapper = require('./audiusLibsInstance'); +const NotificationProcessor = require('./notifications/index.js'); +const { generateWalletLockKey } = require('./relay/txRelay.js'); +const { generateETHWalletLockKey } = require('./relay/ethTxRelay.js'); +const { SlackReporter } = require('./utils/slackReporter'); +const { sendResponse, errorResponseServerError } = require('./apiHelpers'); +const { fetchAnnouncements } = require('./announcements'); +const { logger, loggingMiddleware } = require('./logging'); +const { getRateLimiter, getRateLimiterMiddleware, getRelayBlocklistMiddleware, getRelayRateLimiterMiddleware, isIPWhitelisted, getIP } = require('./rateLimiter.js'); +const cors = require('./corsMiddleware'); +const { getFeatureFlag, FEATURE_FLAGS } = require('./featureFlag'); +const { setupRewardsAttester } = require('./utils/configureAttester'); +const { startRegistrationQueue } = require('./solanaNodeRegistration'); +class App { + constructor(port) { + this.port = port; + this.express = express(); + this.redisClient = redisClient; + this.configureSentry(); + this.configureSendGrid(); + this.optimizelyPromise = null; + this.optimizelyClientInstance = this.configureOptimizely(); + // Async job configuration + this.notificationProcessor = new NotificationProcessor({ + errorHandler: (error) => { + try { + return Sentry.captureException(error); + } + catch (sentryError) { + logger.error(`Received error from Sentry ${sentryError}`); + } + } + }); + // Note: The order of the following functions is IMPORTANT, as it sets the functions + // that process a request in the order applied + this.expressSettings(); + this.setMiddleware(); + this.setRateLimiters(); + this.setRoutes(); + this.setErrorHandler(); + } + async init() { + let server; + await this.getAudiusAnnouncements(); + logger.info("identity init"); + /** + * From the cluster docs - https://nodejs.org/docs/latest-v14.x/api/cluster.html#cluster_cluster + * "A single instance of Node.js runs in a single thread. To take advantage of multi-core systems, + * the user will sometimes want to launch a cluster of Node.js processes to handle the load. + * The cluster module allows easy creation of child processes that all share server ports." + * + * We have the master node in the cluster run migrations and start notifications processor + * The workers start express server processes + */ + if (cluster.isMaster) { + // run all migrations + // this is a stupid solution to a timing bug, because migrations attempt to get run when + // the db port is exposed, not when it's ready to accept incoming connections. the timeout + // attempts to wait until the db is accepting connections + await new Promise((resolve) => setTimeout(resolve, 2000)); + await this.runMigrations(); + // clear POA & ETH relayer keys + await Lock.clearAllLocks(generateWalletLockKey('*')); + await Lock.clearAllLocks(generateETHWalletLockKey('*')); + // if it's a non test run + // 1. start notifications processing + // 2. fork web server worker processes + if (!config.get('isTestRun')) { + // Fork extra web server workers + // note - we can't have more than 1 worker at the moment because POA and ETH relays + // use in memory wallet locks + for (let i = 0; i < config.get('clusterForkProcessCount'); i++) { + cluster.fork({ WORKER_TYPE: 'web_server' }); + } + cluster.on('exit', (worker) => { + logger.info(`Cluster: Worker ${worker.process.pid} died, forking another worker`); + cluster.fork(worker.process.env); + }); + const audiusInstance = await this.configureAudiusInstance(); + cluster.fork({ WORKER_TYPE: 'notifications' }); + await this.configureRewardsAttester(audiusInstance); + await this.configureDiscoveryNodeRegistration(audiusInstance); + await this.configureReporter(); + } + else { + // if it's a test run only start the server + await new Promise((resolve) => { + server = this.express.listen(this.port, resolve); + }); + server.setTimeout(config.get('setTimeout')); + server.timeout = config.get('timeout'); + server.keepAliveTimeout = config.get('keepAliveTimeout'); + server.headersTimeout = config.get('headersTimeout'); + this.express.set('redis', this.redisClient); + logger.info(`Listening on port ${this.port}...`); + } + try { + await txRelay.fundRelayerIfEmpty(); + } + catch (e) { + logger.error(`Failed to fund relayer - ${e}`); + } + try { + await ethTxRelay.fundEthRelayerIfEmpty(); + } + catch (e) { + logger.error(`Failed to fund L1 relayer - ${e}`); + } + return { app: this.express, server }; + } + else { + // if it's not the master worker in the cluster + const audiusInstance = await this.configureAudiusInstance(); + await this.configureReporter(); + if (process.env.WORKER_TYPE === 'notifications') { + await this.notificationProcessor.init(audiusInstance, this.express, this.redisClient); + } + else { + await new Promise((resolve) => { + server = this.express.listen(this.port, resolve); + }); + server.setTimeout(config.get('setTimeout')); + server.timeout = config.get('timeout'); + server.keepAliveTimeout = config.get('keepAliveTimeout'); + server.headersTimeout = config.get('headersTimeout'); + this.express.set('redis', this.redisClient); + logger.info(`Listening on port ${this.port}...`); + return { app: this.express, server }; + } + } + } + configureSendGrid() { + // Configure sendgrid instance + if (config.get('sendgridApiKey')) { + sgMail.setApiKey(config.get('sendgridApiKey')); + } + this.express.set('sendgrid', config.get('sendgridApiKey') ? sgMail : null); + } + configureSentry() { + const dsn = config.get('sentryDSN'); + if (dsn) { + Sentry.init({ + dsn + }); + } + } + configureOptimizely() { + const sdkKey = config.get('optimizelySdkKey'); + const optimizelyClientInstance = optimizelySDK.createInstance({ + sdkKey, + datafileOptions: { + autoUpdate: true, + updateInterval: 5000 // Poll for updates every 5s + } + }); + this.optimizelyPromise = new Promise((resolve) => { + optimizelyClientInstance.onReady().then(() => { + this.express.set('optimizelyClient', optimizelyClientInstance); + resolve(); + }); + }); + return optimizelyClientInstance; + } + configureReporter() { + const slackWormholeErrorReporter = new SlackReporter({ + slackUrl: config.get('errorWormholeReporterSlackUrl'), + childLogger: logger + }); + this.express.set('slackWormholeErrorReporter', slackWormholeErrorReporter); + } + async configureDiscoveryNodeRegistration(libs) { + const childLogger = logger.child({ service: 'Discovery Node Registration' }); + await startRegistrationQueue(libs, childLogger); + } + async configureAudiusInstance() { + await audiusLibsWrapper.init(); + const audiusInstance = audiusLibsWrapper.getAudiusLibs(); + this.express.set('audiusLibs', audiusInstance); + return audiusInstance; + } + async configureRewardsAttester(libs) { + // Await for optimizely config so we know + // whether rewards attestation is enabled, + // returning early if false + await this.optimizelyPromise; + const isEnabled = getFeatureFlag(this.optimizelyClientInstance, FEATURE_FLAGS.REWARDS_ATTESTATION_ENABLED); + if (!isEnabled) { + logger.info('Attestation disabled!'); + return; + } + const attester = await setupRewardsAttester(libs, this.optimizelyClientInstance, this.redisClient); + this.express.set('rewardsAttester', attester); + return attester; + } + async runMigrations() { + logger.info('Executing database migrations...'); + try { + await runMigrations(); + } + catch (err) { + logger.error('Error in migrations: ', err); + process.exit(1); + } + } + expressSettings() { + // https://expressjs.com/en/guide/behind-proxies.html + this.express.set('trust proxy', true); + } + setMiddleware() { + this.express.use(loggingMiddleware); + this.express.use(bodyParser.json({ limit: '1mb' })); + this.express.use(cookieParser()); + this.express.use(cors()); + } + // Create rate limits for listens on a per track per user basis and per track per ip basis + _createRateLimitsForListenCounts(interval, timeInSeconds) { + const listenCountLimiter = getRateLimiter({ + prefix: `listenCountLimiter:::${interval}-track:::`, + expiry: timeInSeconds, + max: config.get(`rateLimitingListensPerTrackPer${interval}`), + skip: function (req) { + const { ip, senderIP } = getIP(req); + const ipToCheck = senderIP || ip; + // Do not apply user-specific rate limits for any whitelisted IP + return isIPWhitelisted(ipToCheck, req); + }, + keyGenerator: function (req) { + const trackId = req.params.id; + const userId = req.body.userId; + return `${trackId}:::${userId}`; + } + }); + const listenCountIPTrackLimiter = getRateLimiter({ + prefix: `listenCountLimiter:::${interval}-ip-track:::`, + expiry: timeInSeconds, + max: config.get(`rateLimitingListensPerIPTrackPer${interval}`), + keyGenerator: function (req) { + const trackId = req.params.id; + const { ip } = getIP(req); + return `${ip}:::${trackId}`; + } + }); + // Create a rate limiter for listens based on IP + const listenCountIPRequestLimiter = getRateLimiter({ + prefix: `listenCountLimiter:::${interval}-ip-exclusive:::`, + expiry: timeInSeconds, + max: config.get(`rateLimitingListensPerIPPer${interval}`), + keyGenerator: function (req) { + const { ip } = getIP(req); + return `${ip}`; + } + }); + return [ + listenCountLimiter, + listenCountIPTrackLimiter, + listenCountIPRequestLimiter + ]; + } + setRateLimiters() { + const authRequestRateLimiter = getRateLimiter({ + prefix: 'authLimiter', + max: config.get('rateLimitingAuthLimit') + }); + // This limiter double dips with the reqLimiter. The 5 requests every hour are also counted here + this.express.use('/authentication/', authRequestRateLimiter); + const twitterRequestRateLimiter = getRateLimiter({ + prefix: 'twitterLimiter', + max: config.get('rateLimitingTwitterLimit') + }); + // This limiter double dips with the reqLimiter. The 5 requests every hour are also counted here + this.express.use('/twitter/', twitterRequestRateLimiter); + const tikTokRequestRateLimiter = getRateLimiter({ + prefix: 'tikTokLimiter', + max: config.get('rateLimitingTikTokLimit') + }); + // This limiter double dips with the reqLimiter. The 5 requests every hour are also counted here + this.express.use('/tiktok/', tikTokRequestRateLimiter); + const ONE_HOUR_IN_SECONDS = 60 * 60; + const [listenCountHourlyLimiter, listenCountHourlyIPTrackLimiter] = this._createRateLimitsForListenCounts('Hour', ONE_HOUR_IN_SECONDS); + const [listenCountDailyLimiter, listenCountDailyIPTrackLimiter, listenCountDailyIPLimiter] = this._createRateLimitsForListenCounts('Day', ONE_HOUR_IN_SECONDS * 24); + const [listenCountWeeklyLimiter, listenCountWeeklyIPTrackLimiter] = this._createRateLimitsForListenCounts('Week', ONE_HOUR_IN_SECONDS * 24 * 7); + // This limiter double dips with the reqLimiter. The 5 requests every hour are also counted here + this.express.use('/tracks/:id/listen', listenCountWeeklyIPTrackLimiter, listenCountWeeklyLimiter, listenCountDailyIPTrackLimiter, listenCountDailyLimiter, listenCountHourlyIPTrackLimiter, listenCountHourlyLimiter, listenCountDailyIPLimiter); + // Eth relay rate limits + // Default to 50 per ip per day and one of 10 per wallet per day + const ethRelayIPRateLimiter = getRateLimiter({ + prefix: 'ethRelayIPRateLimiter', + expiry: ONE_HOUR_IN_SECONDS * 24, + max: config.get('rateLimitingEthRelaysPerIPPerDay'), + skip: function (req) { + return isIPWhitelisted(req.ip, req); + } + }); + const ethRelayWalletRateLimiter = getRateLimiter({ + prefix: `ethRelayWalletRateLimiter`, + expiry: ONE_HOUR_IN_SECONDS * 24, + max: config.get('rateLimitingEthRelaysPerWalletPerDay'), + skip: function (req) { + return isIPWhitelisted(req.ip, req); + }, + keyGenerator: function (req) { + return req.body.senderAddress; + } + }); + this.express.use('/eth_relay', ethRelayWalletRateLimiter, ethRelayIPRateLimiter); + this.express.use('/relay', getRelayBlocklistMiddleware, getRelayRateLimiterMiddleware()); + this.express.use(getRateLimiterMiddleware()); + } + setRoutes() { + // import routes + require('./routes')(this.express); + } + setErrorHandler() { + function errorHandler(err, req, res, next) { + req.logger.error('Internal server error'); + req.logger.error(err.stack); + Sentry.captureException(err); + sendResponse(req, res, errorResponseServerError('Internal server error')); + } + this.express.use(errorHandler); + } + async getAudiusAnnouncements() { + try { + const { announcements, announcementMap } = await fetchAnnouncements(); + this.express.set('announcements', announcements); + this.express.set('announcementMap', announcementMap); + } + catch (err) { + const audiusNotificationUrl = config.get('audiusNotificationUrl'); + logger.error(`Error, unable to get audius announcements from ${audiusNotificationUrl} \n [Err]:`, err); + } + } +} +module.exports = App; diff --git a/packages/identity-service/build/src/audiusLibsInstance.js b/packages/identity-service/build/src/audiusLibsInstance.js new file mode 100644 index 00000000000..2d3b64e0db9 --- /dev/null +++ b/packages/identity-service/build/src/audiusLibsInstance.js @@ -0,0 +1,94 @@ +"use strict"; +const { libs: AudiusLibs } = require('@audius/sdk'); +const { setDefaultWasm } = require('@certusone/wormhole-sdk/lib/cjs/solana/wasm'); +const config = require('./config'); +const registryAddress = config.get('registryAddress'); +const entityManagerAddress = config.get('entityManagerAddress'); +const web3ProviderUrl = config.get('web3Provider'); +// Fixed address of the SPL token program +const SOLANA_TOKEN_ADDRESS = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +class AudiusLibsWrapper { + constructor() { + this.audiusLibsInstance = null; + setDefaultWasm('node'); + } + async init() { + const dataWeb3 = await AudiusLibs.Utils.configureWeb3(web3ProviderUrl, null, false); + if (!dataWeb3) + throw new Error('Web3 incorrectly configured'); + const discoveryProviderWhitelist = config.get('discoveryProviderWhitelist') + ? new Set(config.get('discoveryProviderWhitelist').split(',')) + : null; + const feePayerSecretKeys = config.get('solanaFeePayerWallets') + ? config + .get('solanaFeePayerWallets') + .map((item) => item.privateKey) + .map((key) => Uint8Array.from(key)) + : null; + const solanaWeb3Config = AudiusLibs.configSolanaWeb3({ + solanaClusterEndpoint: config.get('solanaEndpoint'), + mintAddress: config.get('solanaMintAddress'), + usdcMintAddress: config.get('solanaUSDCMintAddress'), + solanaTokenAddress: SOLANA_TOKEN_ADDRESS, + claimableTokenProgramAddress: config.get('solanaClaimableTokenProgramAddress'), + rewardsManagerProgramId: config.get('solanaRewardsManagerProgramId'), + rewardsManagerProgramPDA: config.get('solanaRewardsManagerProgramPDA'), + rewardsManagerTokenPDA: config.get('solanaRewardsManagerTokenPDA'), + // Never use the relay path in identity + useRelay: false, + feePayerSecretKeys, + confirmationTimeout: config.get('solanaConfirmationTimeout') + }); + const wormholeConfig = AudiusLibs.configWormhole({ + rpcHosts: config.get('wormholeRPCHosts'), + solBridgeAddress: config.get('solBridgeAddress'), + solTokenBridgeAddress: config.get('solTokenBridgeAddress'), + ethBridgeAddress: config.get('ethBridgeAddress'), + ethTokenBridgeAddress: config.get('ethTokenBridgeAddress') + }); + const audiusInstance = new AudiusLibs({ + discoveryProviderConfig: { + whitelist: discoveryProviderWhitelist + }, + ethWeb3Config: AudiusLibs.configEthWeb3(config.get('ethTokenAddress'), config.get('ethRegistryAddress'), config.get('ethProviderUrl'), config.get('ethOwnerWallet')), + web3Config: { + registryAddress, + useExternalWeb3: true, + externalWeb3Config: { + web3: dataWeb3, + // this is a stopgap since libs external web3 init requires an ownerWallet + // this is never actually used in the service's libs calls + ownerWallet: config.get('relayerPublicKey') + }, + entityManagerAddress + }, + isServer: true, + captchaConfig: { serviceKey: config.get('recaptchaServiceKey') }, + solanaWeb3Config, + wormholeConfig + }); + await audiusInstance.init(); + this.audiusLibsInstance = audiusInstance; + } + getAudiusLibs() { + return this.audiusLibsInstance; + } + /** + * Async getter for libs. Resolves when libs is initialized. + */ + async getAudiusLibsAsync() { + if (this.audiusLibsInstance) { + return this.audiusLibsInstance; + } + return new Promise((resolve) => { + const i = setInterval(() => { + if (this.audiusLibsInstance) { + clearInterval(i); + resolve(this.audiusLibsInstance); + } + }, 1000); + }); + } +} +const audiusLibsWrapper = new AudiusLibsWrapper(); +module.exports = audiusLibsWrapper; diff --git a/packages/identity-service/build/src/authMiddleware.js b/packages/identity-service/build/src/authMiddleware.js new file mode 100644 index 00000000000..a88a66379fc --- /dev/null +++ b/packages/identity-service/build/src/authMiddleware.js @@ -0,0 +1,140 @@ +"use strict"; +const axios = require('axios'); +const { recoverPersonalSignature } = require('eth-sig-util'); +const { sendResponse, errorResponseBadRequest } = require('./apiHelpers'); +const models = require('./models'); +const audiusLibsWrapper = require('./audiusLibsInstance'); +/** + * queryDiscprovForUserId - Queries the discovery provider for the user w/ the walletaddress + * @param {string} walletAddress + * @returns {object} User Metadata object + */ +const queryDiscprovForUserId = async (walletAddress, handle) => { + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + const response = await axios({ + method: 'get', + url: `${discoveryProvider.discoveryProviderEndpoint}/users`, + params: { + wallet: walletAddress + } + }); + if (!Array.isArray(response.data.data) || !(response.data.data.length >= 1)) { + throw new Error('Unable to retrieve user from discovery provder'); + } + const usersList = response.data.data; + if (usersList.length === 1) { + const [user] = response.data.data; + return user; + } + else { + for (const respUser of usersList) { + if (respUser.handle === handle) { + return respUser; + } + } + } +}; +/** + * Authentication Middleware + * 1) Using the `Encoded-Data-Message` & `Encoded-Data-Signature` header recover the wallet address + * 2) If a user in the `Users` table with the `walletAddress` value, attach that user to the request + * 3) Else query the discovery provider for the user's blockchain userId w/ the wallet address & attach to query + */ +async function authMiddleware(req, res, next) { + try { + const encodedDataMessage = req.get('Encoded-Data-Message'); + const signature = req.get('Encoded-Data-Signature'); + const handle = req.query.handle; + if (!encodedDataMessage) + throw new Error('[Error]: Encoded data missing'); + if (!signature) + throw new Error('[Error]: Encoded data signature missing'); + const walletAddress = recoverPersonalSignature({ + data: encodedDataMessage, + sig: signature + }); + let user = await models.User.findOne({ + where: { walletAddress }, + attributes: [ + 'id', + 'blockchainUserId', + 'walletAddress', + 'createdAt', + 'handle' + ] + }); + if (!user) + throw new Error(`[Error]: no user found for wallet address ${walletAddress}`); + // This overwrites any provisionally set handles (b/c blockchainUserId is never set with those), + // ensuring that the user.handle always represents the latest state on chain + if (!user.blockchainUserId || !user.handle) { + const discprovUser = await queryDiscprovForUserId(walletAddress, handle); + user = await user.update({ + blockchainUserId: discprovUser.user_id, + handle: discprovUser.handle + }); + } + req.user = user; + next(); + } + catch (err) { + const errorResponse = errorResponseBadRequest(`[Error]: The wallet address is not associated with a user id ${err}`); + return sendResponse(req, res, errorResponse); + } +} +/** + * Parameterized version of authentication middleware + * @param {{ + * shouldRespondBadRequest, whether or not to return server error on auth failure + * }: { + * shouldRespondBadRequest: boolean + * }} + * @returns function `authMiddleware` + */ +const parameterizedAuthMiddleware = ({ shouldRespondBadRequest }) => { + return async (req, res, next) => { + try { + const encodedDataMessage = req.get('Encoded-Data-Message'); + const signature = req.get('Encoded-Data-Signature'); + const handle = req.query.handle; + if (!encodedDataMessage) + throw new Error('[Error]: Encoded data missing'); + if (!signature) + throw new Error('[Error]: Encoded data signature missing'); + const walletAddress = recoverPersonalSignature({ + data: encodedDataMessage, + sig: signature + }); + const user = await models.User.findOne({ + where: { walletAddress }, + attributes: [ + 'id', + 'blockchainUserId', + 'walletAddress', + 'createdAt', + 'handle' + ] + }); + if (!user) + throw new Error(`[Error]: no user found for wallet address ${walletAddress}`); + if (!user.blockchainUserId || !user.handle) { + const discprovUser = await queryDiscprovForUserId(walletAddress, handle); + await user.update({ + blockchainUserId: discprovUser.user_id, + handle: discprovUser.handle + }); + } + req.user = user; + next(); + } + catch (err) { + if (shouldRespondBadRequest) { + const errorResponse = errorResponseBadRequest(`[Error]: The wallet address is not associated with a user id: ${err}`); + return sendResponse(req, res, errorResponse); + } + next(); + } + }; +}; +module.exports = authMiddleware; +module.exports.parameterizedAuthMiddleware = parameterizedAuthMiddleware; diff --git a/packages/identity-service/build/src/awsSNS.js b/packages/identity-service/build/src/awsSNS.js new file mode 100644 index 00000000000..ed69fc71802 --- /dev/null +++ b/packages/identity-service/build/src/awsSNS.js @@ -0,0 +1,196 @@ +"use strict"; +const config = require('./config'); +const models = require('./models'); +const { logger } = require('./logging'); +const accessKeyId = config.get('awsAccessKeyId'); +const secretAccessKey = config.get('awsSecretAccessKey'); +// AWS SNS init +const AWS = require('aws-sdk'); +const sns = new AWS.SNS({ + accessKeyId, + secretAccessKey, + region: 'us-west-1' +}); +// the aws sdk doesn't like when you set the function equal to a variable and try to call it +// eg. const func = sns.; func() returns an error, so util.promisify doesn't work +function _promisifySNS(functionName) { + return function (...args) { + return new Promise(function (resolve, reject) { + if (!accessKeyId || !secretAccessKey) { + reject(new Error('Missing SNS config')); + } + sns[functionName](...args, function (err, data) { + if (err) { + logger.debug(`Error sending to SNS: ${err}`); + reject(err); + } + else + resolve(data); + }); + }); + }; +} +/** + * Formats a push notification in a way that's compatible with SNS + * @param {String} message message of push notification + * @param {String} targetARN aws arn address for device + * `arn:aws:sns:us-west-1::endpoint/APNS//` + * @param {Number} badgeCount notification badge count + * @param {any} notification notification object for the push notification + * @param {Boolean=True} playSound should play a sound when it's sent + * @param {String} title title of push notification + */ +function _formatIOSMessage(message, targetARN, badgeCount, notification, playSound = true, title = null) { + let type = null; + if (targetARN.includes('APNS_SANDBOX')) + type = 'APNS_SANDBOX'; + else if (targetARN.includes('APNS')) + type = 'APNS'; + const jsonMessage = { + default: 'You have new notifications in Audius!' + }; + // set iphone specific properties here + if (type) { + const apnsConfig = { + aps: { + alert: `${message}`, + sound: playSound && 'default', + badge: badgeCount + }, + data: notification + }; + if (title) { + apnsConfig.aps.alert = { + title: `${title}`, + body: `${message}` + }; + } + jsonMessage[type] = JSON.stringify(apnsConfig); + } + const params = { + Message: JSON.stringify(jsonMessage) /* required */, + MessageStructure: 'json', + TargetArn: targetARN + }; + return params; +} +/** + * Formats a push notification in a way that's compatible with SNS for android + * @param {String} message message of push notification + * @param {String} targetARN aws arn address for device + * `arn:aws:sns:us-west-1::endpoint/APNS//` + * @param {any} notification notification object for the push notification + * @param {Boolean=True} playSound should play a sound when it's sent + * @param {String} title title of push notification + * NOTE: For reference on https://firebase.google.com/docs/cloud-messaging/http-server-ref + */ +function _formatAndroidMessage(message, targetARN, notification, playSound = true, title = null) { + const type = 'GCM'; + const jsonMessage = { + default: 'You have new notifications in Audius!' + }; + if (type) { + const messageData = { + notification: { + ...(title ? { title } : {}), + body: message, + sound: playSound && 'default' + }, + data: notification + }; + jsonMessage[type] = JSON.stringify(messageData); + } + const params = { + Message: JSON.stringify(jsonMessage) /* required */, + MessageStructure: 'json', + TargetArn: targetARN + }; + return params; +} +const listEndpointsByPlatformApplication = _promisifySNS('listEndpointsByPlatformApplication'); +const createPlatformEndpoint = _promisifySNS('createPlatformEndpoint'); +const publishPromisified = _promisifySNS('publish'); +const deleteEndpoint = _promisifySNS('deleteEndpoint'); +/** + * Actually send the messages from the buffer to SNS + * + * @notice If a device token is invalid attempt to remove it + * @notice never throws error since we never want to stop execution of calling function + * @returns {Number} numSentNotifs + */ +async function drainMessageObject(bufferObj) { + let numSentNotifs = 0; + const { userId, notification } = bufferObj; + const { message, title, playSound } = bufferObj.notificationParams; + // Ensure badge count entry exists for user + await models.PushNotificationBadgeCounts.findOrCreate({ + where: { + userId + } + }); + // Increment entry + const incrementBadgeQuery = await models.PushNotificationBadgeCounts.increment('iosBadgeCount', { + where: { userId } + }); + // Parse the updated value returned from increment + const newBadgeCount = incrementBadgeQuery[0][0][0].iosBadgeCount; + const devices = await models.NotificationDeviceToken.findAll({ + where: { userId } + }); + // If no devices found, short-circuit + if (devices.length === 0) + return numSentNotifs; + // Dispatch to all devices + await Promise.all(devices.map(async (device) => { + const { deviceType, awsARN, deviceToken } = device; + try { + let formattedMessage = null; + if (deviceType === 'ios') { + formattedMessage = _formatIOSMessage(message, awsARN, newBadgeCount, notification, playSound, title); + } + if (deviceType === 'android') { + formattedMessage = _formatAndroidMessage(message, awsARN, notification, playSound, title); + } + if (formattedMessage) { + logger.debug(`Publishing SNS message: ${JSON.stringify(formattedMessage)}`); + await publishPromisified(formattedMessage); + numSentNotifs++; + } + } + catch (e) { + if (e && + e.code && + (e.code === 'EndpointDisabled' || e.code === 'InvalidParameter')) { + try { + // this notification is not deliverable to this device + // remove from deviceTokens table and de-register from AWS + const tokenObj = await models.NotificationDeviceToken.findOne({ + where: { + deviceToken, + userId + } + }); + if (tokenObj) { + // delete the endpoint from AWS SNS + await deleteEndpoint({ EndpointArn: tokenObj.awsARN }); + await tokenObj.destroy(); + } + } + catch (e) { + logger.error('Error removing an outdated record from the NotificationDeviceToken table', e, bufferObj.metadata); + } + } + else { + logger.error('Error sending push notification to device', e); + } + } + })); + return numSentNotifs; +} +module.exports = { + listEndpointsByPlatformApplication, + createPlatformEndpoint, + drainMessageObject, + deleteEndpoint, + publishPromisified +}; diff --git a/packages/identity-service/build/src/captchaMiddleware.js b/packages/identity-service/build/src/captchaMiddleware.js new file mode 100644 index 00000000000..e47a41c7c11 --- /dev/null +++ b/packages/identity-service/build/src/captchaMiddleware.js @@ -0,0 +1,46 @@ +"use strict"; +const config = require('./config'); +const models = require('./models'); +const verifyAndRecordCaptcha = async ({ token, walletAddress, url, logger, captcha }) => { + let score, ok, hostname; + if (token) { + try { + ; + ({ score, ok, hostname } = await captcha.verify(token)); + if (score !== undefined && score !== null && hostname) { + models.BotScores.create({ + walletAddress, + recaptchaScore: score, + recaptchaContext: url, + recaptchaHostname: hostname + }); + } + } + catch (e) { + logger.error(`CAPTCHA - Error with calculating or recording recaptcha score for wallet=${walletAddress}`, e); + } + // TODO: Make middleware return errorResponse later + if (!ok) + logger.warn(`CAPTCHA - Failed captcha with score=${score} for wallet=${walletAddress}`); + } + else { + logger.warn('CAPTCHA - No captcha found on request'); + } +}; +async function captchaMiddleware(req, res, next) { + if (!config.get('recaptchaServiceKey')) { + req.logger.warn(`CAPTCHA - No service key found. Not calculating score at ${req.url} for wallet=${req.body.walletAddress}`); + } + else { + const libs = req.app.get('audiusLibs'); + verifyAndRecordCaptcha({ + token: req.body.token, + walletAddress: req.body.walletAddress || req.body.senderAddress, + url: req.url, + logger: req.logger, + captcha: libs.captcha + }); + } + next(); +} +module.exports = captchaMiddleware; diff --git a/packages/identity-service/build/src/cognitoFlowMiddleware.js b/packages/identity-service/build/src/cognitoFlowMiddleware.js new file mode 100644 index 00000000000..2be40ff2827 --- /dev/null +++ b/packages/identity-service/build/src/cognitoFlowMiddleware.js @@ -0,0 +1,29 @@ +"use strict"; +const { sendResponse, errorResponseBadRequest } = require('./apiHelpers'); +const { isWebhookValid } = require('./utils/cognitoHelpers'); +// set a maximum allowed time drift between the flow completion and +// the webhook transmission by cognito +// this is so that past webhooks have a limited time window to be sent +// https://docs.cognitohq.com/guides#receiving-webhooks +// cognito should send the webhook soon after a user completes the flow +const MAX_TIME_DRIFT_MILLISECONDS = 15 * 60 * 1000; // fifteen minutes +/** + * handle incoming cognito flow webhook + * webhook sent by cognito is signed + * we need to verify the signature to make sure request is not forged + * also we make sure timestamp is within acceptable time range + */ +async function cognitoFlowMiddleware(req, res, next) { + const { headers, url } = req; + if (!isWebhookValid(headers, url)) { + const errorResponse = errorResponseBadRequest('[Error]: The cognito flow webhook is invalid.'); + return sendResponse(req, res, errorResponse); + } + const timeDifferenceMilliseconds = Date.now() - new Date(headers.date).getTime(); + if (timeDifferenceMilliseconds > MAX_TIME_DRIFT_MILLISECONDS) { + const errorResponse = errorResponseBadRequest('[Error]: The cognito flow webhook timestamp is too old.'); + return sendResponse(req, res, errorResponse); + } + next(); +} +module.exports = { cognitoFlowMiddleware, MAX_TIME_DRIFT_MILLISECONDS }; diff --git a/packages/identity-service/build/src/config.js b/packages/identity-service/build/src/config.js new file mode 100644 index 00000000000..b736b655808 --- /dev/null +++ b/packages/identity-service/build/src/config.js @@ -0,0 +1,915 @@ +"use strict"; +const convict = require('convict'); +const fs = require('fs'); +// custom array parsing for arrays passed in via string env vars +convict.addFormat({ + name: 'string-array', + validate: function (val) { + return Array.isArray(val); + }, + coerce: function (val) { + if (!val || val === '') + return {}; + return JSON.parse(val); + } +}); +// Define a schema +const config = convict({ + dbUrl: { + doc: 'Database URL connection string', + format: String, + env: 'dbUrl', + default: null + }, + redisHost: { + doc: 'Redis host name', + format: String, + env: 'redisHost', + default: null + }, + redisPort: { + doc: 'Redis port', + format: 'port', + env: 'redisPort', + default: null + }, + web3Provider: { + doc: 'web3 provider url', + format: String, + env: 'web3Provider', + default: null + }, + acdcChainId: { + doc: 'the chain ID for ACDC', + format: Number, + env: 'acdcChainId', + default: 1000000000001 + }, + nethermindEnabled: { + doc: 'writing to ACDC chain feature flag', + format: Boolean, + env: 'nethermindEnabled', + default: true + }, + nethermindWeb3Provider: { + doc: 'nethermind web3 provider url', + format: String, + env: 'nethermindWeb3Provider', + default: null + }, + secondaryWeb3Provider: { + doc: 'secondary web3 provider url', + format: String, + env: 'secondaryWeb3Provider', + default: null + }, + port: { + doc: 'Port to run service on', + format: 'port', + env: 'port', + default: null + }, + logLevel: { + doc: 'Log level', + format: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'], + env: 'logLevel', + default: 'info' + }, + tikTokAPIKey: { + doc: 'TikTok API key', + format: String, + env: 'tikTokAPIKey', + default: null + }, + tikTokAPISecret: { + doc: 'TikTok API Secret', + format: String, + env: 'tikTokAPISecret', + default: null + }, + tikTokAuthOrigin: { + doc: 'The CORS allowed origin set on the /tikTok/access_token route', + format: String, + env: 'tikTokAuthOrigin', + default: null + }, + twitterAPIKey: { + doc: 'Twitter API key', + format: String, + env: 'twitterAPIKey', + default: null + }, + twitterAPISecret: { + doc: 'Twitter API Secret', + format: String, + env: 'twitterAPISecret', + default: null + }, + instagramAPIKey: { + doc: 'Instagram API Key', + format: String, + env: 'instagramAPIKey', + default: null + }, + instagramAPISecret: { + doc: 'Instagram API Secret', + format: String, + env: 'instagramAPISecret', + default: null + }, + instagramRedirectUrl: { + doc: 'Instagram API Redirect url', + format: String, + env: 'instagramRedirectUrl', + default: null + }, + relayerPrivateKey: { + doc: 'L2 Relayer(used to make relay transactions) private key. The source of the funds when funding wallet.', + format: String, + env: 'relayerPrivateKey', + default: null, + sensitive: true + }, + relayerPublicKey: { + doc: 'L2 Relayer(used to make relay transactions) public key. The source of the funds when funding wallet.', + format: String, + env: 'relayerPublicKey', + default: null + }, + relayerWallets: { + doc: 'L2 Relayer wallet objects to send transactions. Stringified array like[{ publicKey, privateKey}, ...]', + format: 'string-array', + env: 'relayerWallets', + default: null + }, + ethFunderAddress: { + doc: 'L1 Relayer Address. The source of the funds when funding wallets. (Only used in balance_check and eth_balance_check to check if enough funds exist)', + format: String, + env: 'ethFunderAddress', + default: null + }, + ethRelayerWallets: { + doc: 'L1 Relayer wallet objects to send transactions. Stringified array like[{ publicKey, privateKey}, ...]', + format: 'string-array', + env: 'ethRelayerWallets', + default: null + }, + userVerifierPrivateKey: { + doc: 'User verifier(used to write users to chain as isVerified) private key', + format: String, + env: 'userVerifierPrivateKey', + default: null, + sensitive: true + }, + userVerifierPublicKey: { + doc: 'User verifier(used to write users to chain as isVerified) public key', + format: String, + env: 'userVerifierPublicKey', + default: null + }, + blacklisterPrivateKey: { + doc: 'Blacklister(used to write multihashes as blacklisted on chain) private key', + format: String, + env: 'blacklisterPrivateKey', + default: null, + sensitive: true + }, + blacklisterPublicKey: { + doc: 'Blacklister(used to write multihashes as blacklisted on chain) public key', + format: String, + env: 'blacklisterPublicKey', + default: null + }, + blocklistPublicKeyFromRelay: { + doc: 'Blocklist public keys from relay', + format: 'string-array', + env: 'blocklistPublicKeyFromRelay', + default: null + }, + allowlistPublicKeyFromRelay: { + doc: 'Allowlist public keys from relay', + format: 'string-array', + env: 'AllowlistPublicKeyFromRelay', + default: null + }, + rateLimitingAuthLimit: { + doc: 'Auth requests per hour rate limit', + format: 'nat', + env: 'rateLimitingAuthLimit', + default: null + }, + rateLimitingTwitterLimit: { + doc: 'Twitter requests per hour rate limit', + format: 'nat', + env: 'rateLimitingTwitterLimit', + default: null + }, + rateLimitingTikTokLimit: { + doc: 'TikTok requests per hour rate limit', + format: 'nat', + env: 'rateLimitingTikTokLimit', + default: null + }, + rateLimitingListensPerTrackPerHour: { + doc: 'Listens per track per user per Hour', + format: 'nat', + env: 'rateLimitingListensPerTrackPerHour', + default: null + }, + rateLimitingListensPerIPTrackPerHour: { + doc: 'Listens per track per IP per Hour', + format: 'nat', + env: 'rateLimitingListensPerIPTrackPerHour', + default: null + }, + rateLimitingListensPerTrackPerDay: { + doc: 'Listens per track per user per Day', + format: 'nat', + env: 'rateLimitingListensPerTrackPerDay', + default: null + }, + rateLimitingListensPerIPTrackPerDay: { + doc: 'Listens per track per IP per Day', + format: 'nat', + env: 'rateLimitingListensPerIPTrackPerDay', + default: null + }, + rateLimitingListensPerIPPerHour: { + doc: 'Listens per IP per Hour', + format: 'nat', + env: 'rateLimitingListensPerIPPerHour', + default: null + }, + rateLimitingListensPerIPPerDay: { + doc: 'Listens per IP per Day', + format: 'nat', + env: 'rateLimitingListensPerIPPerDay', + default: null + }, + rateLimitingListensPerIPPerWeek: { + doc: 'Listens per IP per Week', + format: 'nat', + env: 'rateLimitingListensPerIPPerWeek', + default: null + }, + rateLimitingEthRelaysPerIPPerDay: { + doc: 'Eth relay operations per IP per day', + format: 'nat', + env: 'rateLimitingEthRelaysPerIPPerDay', + default: 50 + }, + rateLimitingEthRelaysPerWalletPerDay: { + doc: 'Listens per track per IP per Day', + format: 'nat', + env: 'rateLimitingEthRelaysPerWalletPerDay', + default: 10 + }, + rateLimitingListensPerTrackPerWeek: { + doc: 'Listens per track per user per Week', + format: 'nat', + env: 'rateLimitingListensPerTrackPerWeek', + default: null + }, + rateLimitingListensPerIPTrackPerWeek: { + doc: 'Listens per track per IP per Week', + format: 'nat', + env: 'rateLimitingListensPerIPTrackPerWeek', + default: null + }, + rateLimitingListensIPWhitelist: { + doc: 'Regex of IP addresses that should not get rate limited', + format: String, + env: 'rateLimitingListensIPWhitelist', + default: null + }, + endpointRateLimits: { + doc: `A serialized objects of rate limits with the form { + : { + : + [ + { + expiry: , + max: + }, + ... + ], + ... + } + } + `, + format: String, + env: 'endpointRateLimits', + default: '{}' + }, + minimumBalance: { + doc: 'Minimum token balance below which /balance_check fails', + format: Number, + env: 'minimumBalance', + default: null + }, + finalPOABlock: { + doc: 'Last block number on POA', + format: Number, + env: 'finalPOABlock', + nullable: true, + default: null + }, + minimumRelayerBalance: { + doc: 'Minimum token balance for relayer below which /balance_check fails', + format: Number, + env: 'minimumRelayerBalance', + default: null + }, + ethMinimumBalance: { + doc: 'Minimum ETH balance below which /eth_balance_check fails', + format: Number, + env: 'ethMinimumBalance', + default: 0.5 + }, + ethMinimumFunderBalance: { + doc: 'Minimum eth balance for funder below which /eth_balance_check fails', + format: Number, + env: 'ethMinimumFunderBalance', + default: 0.5 + }, + solMinimumBalance: { + doc: 'Minimum SOL balance below which /sol_balance_check fails', + format: Number, + env: 'solMinimumBalance', + default: 1000000000 + }, + sendgridApiKey: { + doc: 'Sendgrid API key used to send emails', + format: String, + env: 'sendgridApiKey', + default: '' + }, + sendgridEmailValidationKey: { + doc: 'Sendgrid API key used to validate emails', + format: String, + env: 'sendgridEmailValidationKey', + default: '' + }, + // loaded through contract-config.json, if an env variable declared, env var takes precendence + registryAddress: { + doc: 'Registry address of contracts deployed on web3Provider', + format: String, + default: null, + env: 'registryAddress' + }, + entityManagerAddress: { + doc: 'EntityManager address deployed on web3Provider', + format: String, + default: '', + env: 'entityManagerAddress' + }, + audiusNotificationUrl: { + doc: 'Url of audius notifications', + format: String, + default: null, + env: 'audiusNotificationUrl' + }, + notificationStartBlock: { + doc: 'First block to start notification indexing from', + format: Number, + default: 0, + env: 'notificationStartBlock' + }, + solanaNotificationStartSlot: { + doc: 'First slot to start solana notification indexing from', + format: Number, + default: 0, + env: 'solanaNotificationStartSlot' + }, + ethTokenAddress: { + doc: 'ethTokenAddress', + format: String, + default: null, + env: 'ethTokenAddress' + }, + ethRegistryAddress: { + doc: 'ethRegistryAddress', + format: String, + default: null, + env: 'ethRegistryAddress' + }, + ethProviderUrl: { + doc: 'ethProviderUrl', + format: String, + default: null, + env: 'ethProviderUrl' + }, + ethOwnerWallet: { + doc: 'ethOwnerWallet', + format: String, + default: null, + env: 'ethOwnerWallet' + }, + isTestRun: { + doc: 'Sets some configs and excludes some processes if this is a test run', + format: Boolean, + default: false, + env: 'isTestRun' + }, + awsAccessKeyId: { + doc: 'AWS access key with SNS permissions', + format: String, + default: null, + env: 'awsAccessKeyId' + }, + awsSecretAccessKey: { + doc: 'AWS access key secret with SNS permissions', + format: String, + default: null, + env: 'awsSecretAccessKey' + }, + awsSNSiOSARN: { + doc: 'AWS ARN for iOS in SNS', + format: String, + default: null, + env: 'awsSNSiOSARN' + }, + awsSNSAndroidARN: { + doc: 'AWS ARN for Android in SNS', + format: String, + default: null, + env: 'awsSNSAndroidARN' + }, + minGasPrice: { + doc: 'minimum gas price; 10 GWei, 10 * POA default gas price', + format: 'nat', + default: 10 * Math.pow(10, 9), + env: 'minGasPrice' + }, + highGasPrice: { + doc: 'max gas price; 25 GWei, 2.5 * minGasPrice', + format: 'nat', + default: 25 * Math.pow(10, 9), + env: 'highGasPrice' + }, + // ganache gas price is extremely high, so we hardcode a lower value (0x09184e72a0 from docs here) + ganacheGasPrice: { + doc: 'ganache gas price', + format: 'nat', + default: 39062500000, + env: 'ganacheGasPrice' + }, + // 1011968 is used by default; 0xf7100 in hex + defaultGasLimit: { + doc: 'default gas limit', + format: String, + default: '0xf7100', + env: 'defaultGasLimit' + }, + browserPushGCMAPIKey: { + doc: 'Google Cloud Messaging Browser Push Key', + format: String, + default: '', + env: 'browserPushGCMAPIKey' + }, + browserPushVapidPublicKey: { + doc: 'Vapid Public Key for browser push notification', + format: String, + default: '', + env: 'browserPushVapidPublicKey' + }, + browserPushVapidPrivateKey: { + doc: 'Vapid Private Key for browser push notifications', + format: String, + default: '', + env: 'browserPushVapidPrivateKey' + }, + apnKeyId: { + doc: 'APN Key ID for safari browser push notifications', + format: String, + default: '', + env: 'apnKeyId' + }, + apnTeamId: { + doc: 'APN Team ID for safari browser push notifications', + format: String, + default: '', + env: 'apnTeamId' + }, + apnAuthKey: { + doc: 'APN Auth Key, read from a string into a file', + format: String, + default: '', + env: 'apnAuthKey' + }, + environment: { + doc: 'Determines running on development, staging, or production', + format: String, + default: 'development', + env: 'environment' + }, + pgConnectionPoolMin: { + doc: 'The max count for the pool of connections', + format: 'nat', + default: 5, + env: 'pgConnectionPoolMin' + }, + pgConnectionPoolMax: { + doc: 'The minimum count for the pool of connections', + format: 'nat', + default: 50, + env: 'pgConnectionPoolMax' + }, + pgConnectionPoolAcquireTimeout: { + doc: 'The maximum time (ms) the pool will try to get the connection before throwing an error', + format: 'nat', + default: 60000, + env: 'pgConnectionPoolAcquireTimeout' + }, + pgConnectionPoolIdleTimeout: { + doc: 'The maximum time (ms) that a connection can be idle before being released', + format: 'nat', + default: 10000, + env: 'pgConnectionPoolIdleTimeout' + }, + setTimeout: { + doc: ` + Sets the timeout value (in ms) for sockets + https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_server_settimeout_msecs_callback + `, + format: 'nat', + env: 'setTimeout', + default: 10 * 60 * 1000 // 10 minutes + }, + timeout: { + doc: ` + Sets the timeout value (in ms) for socket inactivity + https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_server_timeout + `, + format: 'nat', + env: 'timeout', + default: 10 * 60 * 1000 // 10 minutes + }, + keepAliveTimeout: { + doc: ` + Server keep alive timeout + https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_server_keepalivetimeout + `, + format: 'nat', + env: 'keepAliveTimeout', + default: 5000 // node.js default value + }, + headersTimeout: { + doc: ` + Server headers timeout + https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_server_headerstimeout + `, + format: 'nat', + env: 'headersTimeout', + default: 60 * 1000 // 60s - node.js default value + }, + defiPulseApiKey: { + doc: 'API Key used to query eth gas station info', + format: String, + env: 'defiPulseApiKey', + default: '' + }, + ethRelayerProdGasTier: { + doc: 'One of averageGweiHex/fastGweiHex/fastestGweiHex', + format: String, + env: 'ethRelayerProdGasTier', + default: 'fastestGweiHex' + }, + scoreSecret: { + doc: 'The secret necessary to view user captcha and cognito flow scores', + format: String, + env: 'scoreSecret', + default: 'score_secret' + }, + recaptchaServiceKey: { + doc: 'The service key for Google recaptcha v3 API', + format: String, + env: 'recaptchaServiceKey', + default: '' + }, + hCaptchaSecret: { + doc: 'The secret for hCaptcha account verification', + format: String, + env: 'hCaptchaSecret', + default: '' + }, + ipdataAPIKey: { + doc: 'API Key for ipdata', + format: String, + env: 'ipdataAPIKey', + default: '' + }, + cognitoAPISecret: { + doc: 'API Secret for Cognito', + format: String, + env: 'cognitoAPISecret', + default: '' + }, + cognitoAPIKey: { + doc: 'API Key for Cognito', + format: String, + env: 'cognitoAPIKey', + default: '' + }, + cognitoBaseUrl: { + doc: 'Base URL for Cognito API', + format: String, + env: 'cognitoBaseUrl', + default: '' + }, + cognitoTemplateId: { + doc: 'Template for using Cognito Flow API', + format: String, + env: 'cognitoTemplateId', + default: '' + }, + solanaEndpoint: { + doc: 'The Solana RPC endpoint to make requests against', + format: String, + env: 'solanaEndpoint', + default: null + }, + solanaTrackListenCountAddress: { + doc: 'solanaTrackListenCountAddress', + format: String, + default: '', + env: 'solanaTrackListenCountAddress' + }, + solanaAudiusEthRegistryAddress: { + doc: 'solanaAudiusEthRegistryAddress', + format: String, + default: '', + env: 'solanaAudiusEthRegistryAddress' + }, + solanaValidSigner: { + doc: 'solanaValidSigner', + format: String, + default: '', + env: 'solanaValidSigner' + }, + solanaFeePayerWallets: { + doc: 'solanaFeePayerWallets - Stringified array like[{ privateKey: [] },...]', + format: 'string-array', + default: [], + env: 'solanaFeePayerWallets' + }, + solanaSignerPrivateKey: { + doc: 'solanaSignerPrivateKey', + format: String, + default: '', + env: 'solanaSignerPrivateKey' + }, + solanaTxCommitmentLevel: { + doc: 'solanaTxCommitmentLevel', + format: String, + default: 'processed', + env: 'solanaTxCommitmentLevel' + }, + solanaMintAddress: { + doc: 'The address of our SPL token', + format: String, + default: '', + env: 'solanaMintAddress' + }, + solanaClaimableTokenProgramAddress: { + doc: 'The address of our Claimable Token program', + format: String, + default: '', + env: 'solanaClaimableTokenProgramAddress' + }, + solanaRewardsManagerProgramId: { + doc: 'The address of our Rewards Manager program', + format: String, + default: '', + env: 'solanaRewardsManagerProgramId' + }, + solanaRewardsManagerProgramPDA: { + doc: 'The PDA of this Rewards Manager deployment', + format: String, + default: '', + env: 'solanaRewardsManagerProgramPDA' + }, + solanaRewardsManagerTokenPDA: { + doc: 'The PDA for the Rewards Manager token account', + format: String, + default: '', + env: 'solanaRewardsManagerTokenPDA' + }, + solanaConfirmationTimeout: { + doc: 'The timeout used to send solana transactions through solanaWeb3 connection in ms', + format: Number, + default: '60000', + env: 'solanaConfirmationTimeout' + }, + solanaAudiusAnchorDataProgramId: { + doc: 'The address of the anchor audius data program', + format: String, + default: '', + env: 'solanaAudiusAnchorDataProgramId' + }, + rewardsQuorumSize: { + doc: 'How many Discovery Nodes constitute a quorum for disbursing a reward', + format: Number, + default: '2', + env: 'rewardsQuorumSize' + }, + aaoEndpoint: { + doc: 'AAO Endpoint for fetching attestations', + format: String, + default: 'http://anti-abuse-oracle_anti_abuse_oracle_1:8000', + env: 'aaoEndpoint' + }, + aaoAddress: { + doc: 'AAO eth address', + format: String, + default: '', + env: 'aaoAddress' + }, + generalAdmissionAddress: { + doc: 'General admission server address', + format: String, + default: '', + env: 'generalAdmissionAddress' + }, + sentryDSN: { + doc: 'Sentry DSN key', + format: String, + env: 'sentryDSN', + default: '' + }, + ethGasMultiplier: { + doc: 'Constant value to multiply the configured FAST gas price by - in order to optimize tx success', + format: Number, + env: 'ethGasMultiplier', + default: 1.2 + }, + optimizelySdkKey: { + doc: 'Optimizely SDK key to use to fetch remote configuration', + format: String, + env: 'optimizelySdkKey', + default: 'MX4fYBgANQetvmBXGpuxzF' + }, + discoveryProviderWhitelist: { + doc: 'Whitelisted discovery providers to select from (comma-separated)', + format: String, + env: 'discoveryProviderWhitelist', + default: '' + }, + clusterForkProcessCount: { + doc: 'The number of express server processes to initialize in the this app "cluster"', + format: Number, + env: 'clusterForkProcessCount', + default: 1 + }, + minSolanaNotificationSlot: { + doc: 'The slot number to start indexing if no slots defined', + format: Number, + env: 'minSolanaNotificationSlot', + default: 166928009 + }, + successAudioReporterSlackUrl: { + doc: 'The slack url to post messages for success in audio / rewards events', + format: String, + env: 'successAudioReporterSlackUrl', + default: '' + }, + errorAudioReporterSlackUrl: { + doc: 'The slack url to post messages for errors in audio / rewards events', + format: String, + env: 'errorAudioReporterSlackUrl', + default: '' + }, + errorWormholeReporterSlackUrl: { + doc: 'The slack url to post messages for errors in wormhole transfers', + format: String, + env: 'errorWormholeReporterSlackUrl', + default: '' + }, + wormholeRPCHosts: { + doc: 'Wormhole RPC Host', + format: String, + env: 'wormholeRPCHosts', + default: '' + }, + solBridgeAddress: { + doc: 'Sol bridge address for wormhole', + format: String, + env: 'solBridgeAddress', + default: '' + }, + solTokenBridgeAddress: { + doc: 'Sol token bridge address for wormhole', + format: String, + env: 'solTokenBridgeAddress', + default: '' + }, + ethBridgeAddress: { + doc: 'Eth bridge address for wormhole', + format: String, + env: 'ethBridgeAddress', + default: '' + }, + ethTokenBridgeAddress: { + doc: 'Eth token bridge address for wormhole', + format: String, + env: 'ethTokenBridgeAddress', + default: '' + }, + websiteHost: { + doc: 'Audius website host', + format: String, + env: 'websiteHost', + default: 'https://audius.co' + }, + amplitudeAPIKey: { + doc: 'Amplitude API key', + format: String, + env: 'amplitudeAPIKey', + default: '' + }, + cognitoIdentityHashSalt: { + doc: 'Hash salt', + format: String, + env: 'cognitoIdentityHashSalt', + default: '' + }, + cognitoRetrySecret: { + doc: 'The secret necessary to request a retry for the cognito flow', + format: String, + env: 'cognitoRetrySecret', + default: '' + }, + stripeSecretKey: { + doc: 'Secret key for Stripe Crypto On-Ramp Integration', + format: String, + env: 'stripeSecretKey', + default: '' + }, + skipAbuseCheck: { + doc: 'Skip AAO abuse check on relay and notifs', + format: Boolean, + env: 'skipAbuseCheck', + default: false + }, + updateReplicaSetReconfigurationLimit: { + doc: 'The limit of the replica set reconfiguration transactions that we will relay in 10 seconds. This limit is per cluster worker, not service wide', + format: Number, + env: 'updateReplicaSetReconfigurationLimit', + default: 10 + }, + updateReplicaSetWalletWhitelist: { + doc: '`senderAddress` values allowed to make updateReplicaSet calls. Will still adhere to updateReplicaSetReconfigurationLimit. Empty means no whitelist, all addresses allowed.', + format: 'string-array', + env: 'updateReplicaSetWalletWhitelist', + default: '' + }, + ipApiKey: { + doc: 'Key for IPAPI', + format: String, + env: 'ipApiKey', + default: '' + }, + solanaUSDCMintAddress: { + doc: 'Mint address of the USDC token on Solana', + format: String, + env: 'solanaUSDCMintAddress', + default: '' + } +}); +// if you wanted to load a file +// this is lower precendence than env variables, so if registryAddress or ownerWallet env +// variables are defined, they take precendence +// TODO(DM) - remove these defaults +const defaultConfigExists = fs.existsSync('default-config.json'); +if (defaultConfigExists) + config.loadFile('default-config.json'); +const relayRateLimit = fs.existsSync('relay-rate-limit.json'); +if (relayRateLimit) + config.loadFile('relay-rate-limit.json'); +if (fs.existsSync('eth-contract-config.json')) { + // eslint isn't smart enought to know this is a conditional require, so this fails + // on CI where the file doesn't exist. + // eslint-disable-next-line node/no-missing-require + const ethContractConfig = require('../eth-contract-config.json'); + config.load({ + ethTokenAddress: ethContractConfig.audiusTokenAddress, + ethRegistryAddress: ethContractConfig.registryAddress, + ethOwnerWallet: ethContractConfig.ownerWallet, + ethWallets: ethContractConfig.allWallets + }); +} +if (fs.existsSync('aao-config.json')) { + const aaoConfig = require('../aao-config.json'); + console.log('rewards: ' + JSON.stringify(aaoConfig)); + config.load({ + aaoAddress: aaoConfig[0] + }); +} +// the contract-config.json file is used to load registry address locally +// during development +const contractConfigExists = fs.existsSync('contract-config.json'); +if (contractConfigExists) + config.loadFile('contract-config.json'); +// Perform validation and error any properties are not present on schema +config.validate(); +module.exports = config; diff --git a/packages/identity-service/build/src/corsMiddleware.js b/packages/identity-service/build/src/corsMiddleware.js new file mode 100644 index 00000000000..cac869780f9 --- /dev/null +++ b/packages/identity-service/build/src/corsMiddleware.js @@ -0,0 +1,17 @@ +"use strict"; +const cors = require('cors'); +// Need to exclude certain routes from the default CORS config +// because they need custom CORS config (set inline, see tiktok.js) +const excludedRoutes = ['/tiktok/access_token']; +const corsMiddleware = () => { + const defaultCors = cors(); + return (req, res, next) => { + if (excludedRoutes.includes(req.url.toLowerCase())) { + next(); + } + else { + defaultCors(req, res, next); + } + }; +}; +module.exports = corsMiddleware; diff --git a/packages/identity-service/build/src/featureFlag.js b/packages/identity-service/build/src/featureFlag.js new file mode 100644 index 00000000000..f08b0b056eb --- /dev/null +++ b/packages/identity-service/build/src/featureFlag.js @@ -0,0 +1,49 @@ +"use strict"; +const uuidv4 = require('uuid/v4'); +// Declaration of feature flags set in optimizely +const FEATURE_FLAGS = Object.freeze({ + SOLANA_LISTEN_ENABLED_SERVER: 'solana_listen_enabled_server', + SOLANA_SEND_RAW_TRANSACTION: 'solana_send_raw_transaction', + REWARDS_ATTESTATION_ENABLED: 'rewards_attestation_enabled', + REWARDS_NOTIFICATIONS_ENABLED: 'rewards_notifications_enabled', + SOCIAL_PROOF_TO_SEND_AUDIO_ENABLED: 'social_proof_to_send_audio_enabled', + DETECT_ABUSE_ON_RELAY: 'detect_abuse_on_relay', + BLOCK_ABUSE_ON_RELAY: 'block_abuse_on_relay', + TIPPING_ENABLED: 'tipping_enabled', + SUPPORTER_DETHRONED_PUSH_NOTIFS_ENABLED: 'supporter_dethroned_push_notifs_enabled', + READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED: 'read_subscribers_from_discovery_enabled' +}); +// Default values for feature flags while optimizely has not loaded +// Generally, these should be never seen unless variables are +// consumed within a few seconds of server init +const DEFAULTS = Object.freeze({ + [FEATURE_FLAGS.SOLANA_LISTEN_ENABLED_SERVER]: false, + [FEATURE_FLAGS.SOLANA_SEND_RAW_TRANSACTION]: false, + [FEATURE_FLAGS.REWARDS_ATTESTATION_ENABLED]: false, + [FEATURE_FLAGS.REWARDS_NOTIFICATIONS_ENABLED]: false, + [FEATURE_FLAGS.SOCIAL_PROOF_TO_SEND_AUDIO_ENABLED]: true, + [FEATURE_FLAGS.DETECT_ABUSE_ON_RELAY]: false, + [FEATURE_FLAGS.BLOCK_ABUSE_ON_RELAY]: false, + [FEATURE_FLAGS.TIPPING_ENABLED]: false, + [FEATURE_FLAGS.SUPPORTER_DETHRONED_PUSH_NOTIFS_ENABLED]: false, + [FEATURE_FLAGS.READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED]: false +}); +/** + * Fetches a feature flag + * @param {OptimizelyClient?} optimizelyClient + * @param {String} flag FEATURE_FLAGS value + * @param {String} userId the user id to determine whether this feature is + * enabled. By default this is just random, so every call to getFeatureFlag + * will have the same behavior. + * @returns + */ +const getFeatureFlag = (optimizelyClient, flag, userId = uuidv4()) => { + if (!optimizelyClient) { + return DEFAULTS[flag]; + } + return optimizelyClient.isFeatureEnabled(flag, userId); +}; +module.exports = { + getFeatureFlag, + FEATURE_FLAGS +}; diff --git a/packages/identity-service/build/src/index.js b/packages/identity-service/build/src/index.js new file mode 100644 index 00000000000..8d039ba937b --- /dev/null +++ b/packages/identity-service/build/src/index.js @@ -0,0 +1,32 @@ +'use strict'; +// Import libs before anything else becaues it takes a very long time to load. +// Once it's imported once, it'll be in the cache and subsequent imports will be ~instant. +// This first import is slow but makes it easier to debug timing issues since no other code will be slowed down by importing it. +const { libs } = require('@audius/sdk'); +const { setupTracing } = require('./tracer'); +setupTracing(); +const ON_DEATH = require('death'); +const { sequelize } = require('./models'); +const { logger } = require('./logging'); +const config = require('./config'); +const App = require('./app'); +// Global handler for unhandled promise rejections +// TODO: We should remove this once we are confident no unhandled rejections are occurring +process.on('unhandledRejection', (reason, promise) => { + console.log('Unhandled Rejection at:', promise, 'reason:', reason); +}); +const start = async () => { + const port = config.get('port'); + const app = new App(port); + const { server } = await app.init(); + // when app terminates, close down any open DB connections gracefully + ON_DEATH(() => { + // NOTE: log messages emitted here may be swallowed up if using the bunyan CLI (used by + // default in `npm start` command). To see messages emitted after a kill signal, do not + // use the bunyan CLI. + logger.info('Shutting down db and express app...'); + sequelize.close(); + server.close(); + }); +}; +start(); diff --git a/packages/identity-service/build/src/logging.js b/packages/identity-service/build/src/logging.js new file mode 100644 index 00000000000..5a4f3805c9b --- /dev/null +++ b/packages/identity-service/build/src/logging.js @@ -0,0 +1,39 @@ +"use strict"; +const bunyan = require('bunyan'); +const shortid = require('shortid'); +const config = require('./config'); +const logLevel = config.get('logLevel'); +const logger = bunyan.createLogger({ + name: 'audius_identity_service', + streams: [ + { + level: logLevel, + stream: process.stdout + } + ] +}); +logger.info('Loglevel set to:', logLevel); +const excludedRoutes = ['/health_check', '/balance_check']; +function requestNotExcludedFromLogging(url) { + return excludedRoutes.indexOf(url) === -1; +} +function loggingMiddleware(req, res, next) { + const providedRequestID = req.header('X-Request-ID'); + const requestID = providedRequestID || shortid.generate(); + const urlParts = req.url.split('?'); + req.startTime = process.hrtime(); + req.logger = logger.child({ + requestID: requestID, + requestMethod: req.method, + requestHostname: req.hostname, + requestUrl: urlParts[0], + requestQueryParams: urlParts.length > 1 ? urlParts[1] : undefined, + requestIP: req.ip, + requestXForwardedFor: req.headers['x-forwarded-for'] + }); + if (requestNotExcludedFromLogging(req.originalUrl)) { + req.logger.debug('Begin processing request'); + } + next(); +} +module.exports = { logger, loggingMiddleware, requestNotExcludedFromLogging }; diff --git a/packages/identity-service/build/src/migrationManager.js b/packages/identity-service/build/src/migrationManager.js new file mode 100644 index 00000000000..187eb5fa665 --- /dev/null +++ b/packages/identity-service/build/src/migrationManager.js @@ -0,0 +1,34 @@ +"use strict"; +const Umzug = require('umzug'); +const path = require('path'); +const { sequelize } = require('./models'); +function runMigrations() { + const umzug = new Umzug({ + storage: 'sequelize', + storageOptions: { + sequelize: sequelize + }, + migrations: { + params: [sequelize.getQueryInterface(), sequelize.constructor], + path: path.join(__dirname, '../sequelize/migrations') + } + }); + return umzug.up(); +} +async function clearDatabase() { + // clear and recreate database schema, which cascades to all tables and rows in tables + // for use in testing only - will delete all data in the database!! + await sequelize.query('DROP SCHEMA IF EXISTS public CASCADE'); + await sequelize.query('CREATE SCHEMA public'); +} +if (require.main === module) { + runMigrations() + .then(() => { + process.exit(0); + }) + .catch(() => { + console.error('error in running migrations'); + process.exit(1); + }); +} +module.exports = { runMigrations, clearDatabase }; diff --git a/packages/identity-service/build/src/models/InstagramUser.js b/packages/identity-service/build/src/models/InstagramUser.js new file mode 100644 index 00000000000..2a0907d1395 --- /dev/null +++ b/packages/identity-service/build/src/models/InstagramUser.js @@ -0,0 +1,43 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const InstagramUser = sequelize.define('InstagramUser', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + profile: { + type: DataTypes.JSONB, + allowNull: false, + unique: false + }, + accessToken: { + type: DataTypes.STRING, + allowNull: false + }, + verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + uuid: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: DataTypes.INTEGER, + allowNull: true + } + }, { + indexes: [ + { + fields: [`((profile->>'username'))`], + unique: false, + name: 'instagram_users_profile_username_idx' + } + ] + }); + return InstagramUser; +}; diff --git a/packages/identity-service/build/src/models/authentication.js b/packages/identity-service/build/src/models/authentication.js new file mode 100644 index 00000000000..121f04eb8b7 --- /dev/null +++ b/packages/identity-service/build/src/models/authentication.js @@ -0,0 +1,25 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Authentication = sequelize.define('Authentication', { + iv: { + type: DataTypes.STRING, + allowNull: false + }, + cipherText: { + type: DataTypes.STRING, + allowNull: false + }, + // the primary key that external queries will interact with this table + // it's a scrypt hash of the email and password combined with three colons(:) + // and a fixed iv + lookupKey: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + primaryKey: true + } + }, { + paranoid: true + }); + return Authentication; +}; diff --git a/packages/identity-service/build/src/models/botScores.js b/packages/identity-service/build/src/models/botScores.js new file mode 100644 index 00000000000..e5e8e58bcff --- /dev/null +++ b/packages/identity-service/build/src/models/botScores.js @@ -0,0 +1,37 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const BotScores = sequelize.define('BotScores', { + id: { + allowNull: false, + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + walletAddress: { + type: DataTypes.STRING, + allowNull: false, + index: true + }, + recaptchaScore: { + type: DataTypes.DECIMAL, + allowNull: false + }, + recaptchaContext: { + type: DataTypes.STRING, + allowNull: false + }, + recaptchaHostname: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return BotScores; +}; diff --git a/packages/identity-service/build/src/models/cognitoFlowIdentities.js b/packages/identity-service/build/src/models/cognitoFlowIdentities.js new file mode 100644 index 00000000000..9b8e5a89493 --- /dev/null +++ b/packages/identity-service/build/src/models/cognitoFlowIdentities.js @@ -0,0 +1,19 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const CognitoFlowIdentities = sequelize.define('CognitoFlowIdentities', { + maskedIdentity: { + allowNull: false, + primaryKey: true, + type: DataTypes.STRING + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return CognitoFlowIdentities; +}; diff --git a/packages/identity-service/build/src/models/cognitoFlows.js b/packages/identity-service/build/src/models/cognitoFlows.js new file mode 100644 index 00000000000..8e66f0cf0da --- /dev/null +++ b/packages/identity-service/build/src/models/cognitoFlows.js @@ -0,0 +1,36 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const CognitoFlows = sequelize.define('CognitoFlows', { + id: { + allowNull: false, + type: DataTypes.STRING, + primaryKey: true + }, + sessionId: { + type: DataTypes.STRING, + allowNull: false + }, + handle: { + type: DataTypes.STRING, + allowNull: false, + index: true + }, + status: { + type: DataTypes.STRING, + allowNull: false + }, + score: { + type: DataTypes.DECIMAL, + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return CognitoFlows; +}; diff --git a/packages/identity-service/build/src/models/fingerprint.js b/packages/identity-service/build/src/models/fingerprint.js new file mode 100644 index 00000000000..d4f9e7adf46 --- /dev/null +++ b/packages/identity-service/build/src/models/fingerprint.js @@ -0,0 +1,36 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Fingerprints = sequelize.define('Fingerprints', { + id: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + index: true + }, + visitorId: { + type: DataTypes.STRING, + allowNull: false, + index: true + }, + origin: { + type: DataTypes.ENUM({ + values: ['web', 'mobile', 'desktop'], + allowNull: false + }) + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return Fingerprints; +}; diff --git a/packages/identity-service/build/src/models/index.js b/packages/identity-service/build/src/models/index.js new file mode 100644 index 00000000000..2bb2633f185 --- /dev/null +++ b/packages/identity-service/build/src/models/index.js @@ -0,0 +1,33 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const Sequelize = require('sequelize'); +const globalConfig = require('../config'); +const basename = path.basename(__filename); +const db = {}; +const sequelize = new Sequelize(globalConfig.get('dbUrl'), { + logging: false, + operatorsAliases: false, + pool: { + max: globalConfig.get('pgConnectionPoolMax'), + min: globalConfig.get('pgConnectionPoolMin'), + acquire: globalConfig.get('pgConnectionPoolAcquireTimeout'), + idle: globalConfig.get('pgConnectionPoolIdleTimeout') + } +}); +fs.readdirSync(__dirname) + .filter((file) => { + return (file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'); +}) + .forEach((file) => { + const model = sequelize.import(path.join(__dirname, file)); + db[model.name] = model; +}); +Object.keys(db).forEach((modelName) => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); +db.sequelize = sequelize; +db.Sequelize = Sequelize; +module.exports = db; diff --git a/packages/identity-service/build/src/models/notification.js b/packages/identity-service/build/src/models/notification.js new file mode 100644 index 00000000000..24e1f1abe93 --- /dev/null +++ b/packages/identity-service/build/src/models/notification.js @@ -0,0 +1,82 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Notification = sequelize.define('Notification', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + type: { + type: DataTypes.ENUM({ + values: [ + 'Follow', + 'RepostTrack', + 'RepostPlaylist', + 'RepostAlbum', + 'FavoriteTrack', + 'FavoritePlaylist', + 'FavoriteAlbum', + 'CreateTrack', + 'CreatePlaylist', + 'CreateAlbum', + 'Announcement', + 'MilestoneListen', + 'MilestoneRepost', + 'MilestoneFavorite', + 'MilestoneFollow', + 'RemixCreate', + 'RemixCosign', + 'TrendingTrack', + 'ChallengeReward', + 'AddTrackToPlaylist' + ] + }), + allowNull: false + }, + isRead: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isHidden: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isViewed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + entityId: { + // Can be track/album/playlist/user id + type: DataTypes.INTEGER, + allowNull: true + }, + blocknumber: { + type: DataTypes.INTEGER, + allowNull: false + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false + }, + metadata: { + type: DataTypes.JSONB, + allowNull: true + } + }, {}); + Notification.associate = function (models) { + Notification.hasMany(models.NotificationAction, { + sourceKey: 'id', + foreignKey: 'notificationId', + as: 'actions' + }); + }; + return Notification; +}; diff --git a/packages/identity-service/build/src/models/notificationActions.js b/packages/identity-service/build/src/models/notificationActions.js new file mode 100644 index 00000000000..2e4554187ba --- /dev/null +++ b/packages/identity-service/build/src/models/notificationActions.js @@ -0,0 +1,41 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const NotificationAction = sequelize.define('NotificationAction', { + id: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + notificationId: { + type: DataTypes.UUID, + allowNull: false, + onDelete: 'RESTRICT', + references: { + model: 'Notification', + key: 'id', + as: 'notificationId' + } + }, + blocknumber: { + type: DataTypes.INTEGER, + allowNull: false + }, + actionEntityType: { + // TODO: make this enum + type: DataTypes.TEXT, + allowNull: false + }, + actionEntityId: { + type: DataTypes.INTEGER, + allowNull: false + } + }, {}); + NotificationAction.associate = function (models) { + NotificationAction.belongsTo(models.Notification, { + foreignKey: 'notificationId', + targetKey: 'id' + }); + }; + return NotificationAction; +}; diff --git a/packages/identity-service/build/src/models/notificationDeviceTokens.js b/packages/identity-service/build/src/models/notificationDeviceTokens.js new file mode 100644 index 00000000000..ea669e311bd --- /dev/null +++ b/packages/identity-service/build/src/models/notificationDeviceTokens.js @@ -0,0 +1,31 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const NotificationDeviceToken = sequelize.define('NotificationDeviceToken', { + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + deviceToken: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, + deviceType: { + type: DataTypes.ENUM({ + values: ['ios', 'android', 'safari'] + }), + allowNull: false, + defaultValue: true + }, + awsARN: { + type: DataTypes.STRING, + allowNull: true + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, {}); + return NotificationDeviceToken; +}; diff --git a/packages/identity-service/build/src/models/notificationbrowsersubscription.js b/packages/identity-service/build/src/models/notificationbrowsersubscription.js new file mode 100644 index 00000000000..601df82eecb --- /dev/null +++ b/packages/identity-service/build/src/models/notificationbrowsersubscription.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const NotificationBrowserSubscription = sequelize.define('NotificationBrowserSubscription', { + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + endpoint: { + allowNull: false, + type: DataTypes.STRING, + primaryKey: true + }, + p256dhKey: { + allowNull: false, + type: DataTypes.STRING + }, + authKey: { + allowNull: false, + type: DataTypes.STRING + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, {}); + return NotificationBrowserSubscription; +}; diff --git a/packages/identity-service/build/src/models/notificationemail.js b/packages/identity-service/build/src/models/notificationemail.js new file mode 100644 index 00000000000..93dda545c26 --- /dev/null +++ b/packages/identity-service/build/src/models/notificationemail.js @@ -0,0 +1,38 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const NotificationEmail = sequelize.define('NotificationEmail', { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false, + primaryKey: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + index: true + }, + emailFrequency: { + allowNull: false, + type: DataTypes.ENUM('live', 'daily', 'weekly', 'off'), + defaultValue: 'live' + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + NotificationEmail.associate = function () { + // associations can be defined here + }; + return NotificationEmail; +}; diff --git a/packages/identity-service/build/src/models/protocolServiceProvider.js b/packages/identity-service/build/src/models/protocolServiceProvider.js new file mode 100644 index 00000000000..766d0d7de6a --- /dev/null +++ b/packages/identity-service/build/src/models/protocolServiceProvider.js @@ -0,0 +1,23 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const ProtocolServiceProviders = sequelize.define('ProtocolServiceProviders', { + wallet: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, + minimumDelegationAmount: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return ProtocolServiceProviders; +}; diff --git a/packages/identity-service/build/src/models/pushedannouncementnotifications.js b/packages/identity-service/build/src/models/pushedannouncementnotifications.js new file mode 100644 index 00000000000..6a2c2145ec0 --- /dev/null +++ b/packages/identity-service/build/src/models/pushedannouncementnotifications.js @@ -0,0 +1,10 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const PushedAnnouncementNotifications = sequelize.define('PushedAnnouncementNotifications', { + announcementId: DataTypes.STRING + }, {}); + PushedAnnouncementNotifications.associate = function (models) { + // associations can be defined here + }; + return PushedAnnouncementNotifications; +}; diff --git a/packages/identity-service/build/src/models/pushnotificationbadgecounts.js b/packages/identity-service/build/src/models/pushnotificationbadgecounts.js new file mode 100644 index 00000000000..34aee44f154 --- /dev/null +++ b/packages/identity-service/build/src/models/pushnotificationbadgecounts.js @@ -0,0 +1,17 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const PushNotificationBadgeCounts = sequelize.define('PushNotificationBadgeCounts', { + userId: { + type: DataTypes.INTEGER, + primaryKey: true + }, + iosBadgeCount: { + type: DataTypes.INTEGER, + defaultValue: 0 + } + }, {}); + PushNotificationBadgeCounts.associate = function (models) { + // associations can be defined here + }; + return PushNotificationBadgeCounts; +}; diff --git a/packages/identity-service/build/src/models/reaction.js b/packages/identity-service/build/src/models/reaction.js new file mode 100644 index 00000000000..08d7066939e --- /dev/null +++ b/packages/identity-service/build/src/models/reaction.js @@ -0,0 +1,40 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Reactions = sequelize.define('Reactions', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER + }, + slot: { + type: DataTypes.INTEGER, + allowNull: false + }, + reactionValue: { + type: DataTypes.INTEGER, + allowNull: false + }, + senderWallet: { + type: DataTypes.STRING, + allowNull: false + }, + reactedTo: { + type: DataTypes.STRING, + allowNull: false + }, + reactionType: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }); + return Reactions; +}; diff --git a/packages/identity-service/build/src/models/rewardsAttester.js b/packages/identity-service/build/src/models/rewardsAttester.js new file mode 100644 index 00000000000..c6e18ce41d2 --- /dev/null +++ b/packages/identity-service/build/src/models/rewardsAttester.js @@ -0,0 +1,17 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const RewardAttesterValues = sequelize.define('RewardAttesterValues', { + startingBlock: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + primaryKey: true + }, + offset: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + } + }, {}); + return RewardAttesterValues; +}; diff --git a/packages/identity-service/build/src/models/socialhandles.js b/packages/identity-service/build/src/models/socialhandles.js new file mode 100644 index 00000000000..f306376b023 --- /dev/null +++ b/packages/identity-service/build/src/models/socialhandles.js @@ -0,0 +1,31 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const SocialHandles = sequelize.define('SocialHandles', { + handle: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, + twitterHandle: { + allowNull: true, + type: DataTypes.STRING + }, + instagramHandle: { + allowNull: true, + type: DataTypes.STRING + }, + tikTokHandle: { + allowNull: true, + type: DataTypes.STRING + }, + website: { + allowNull: true, + type: DataTypes.STRING + }, + donation: { + allowNull: true, + type: DataTypes.STRING + } + }, {}); + return SocialHandles; +}; diff --git a/packages/identity-service/build/src/models/solanaNotification.js b/packages/identity-service/build/src/models/solanaNotification.js new file mode 100644 index 00000000000..b5da1ba3e9b --- /dev/null +++ b/packages/identity-service/build/src/models/solanaNotification.js @@ -0,0 +1,73 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const SolanaNotification = sequelize.define('SolanaNotification', { + id: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + type: { + type: DataTypes.ENUM({ + values: [ + 'ChallengeReward', + 'MilestoneListen', + 'TipSend', + 'TipReceive', + 'Reaction', + 'SupporterRankUp', + 'SupportingRankUp' + ] + }), + allowNull: false + }, + isRead: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isHidden: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isViewed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + entityId: { + // Can be track/album/playlist/user id + type: DataTypes.INTEGER, + allowNull: true + }, + slot: { + type: DataTypes.INTEGER, + allowNull: false + }, + metadata: { + type: DataTypes.JSONB, + allowNull: true + } + }, { + indexes: [ + { + fields: ["((metadata->'tipTxSignature'))"], + unique: false, + name: 'solana_notifications_metadata_tip_tx_signature_idx' + } + ] + }); + SolanaNotification.associate = function (models) { + SolanaNotification.hasMany(models.SolanaNotificationAction, { + sourceKey: 'id', + foreignKey: 'notificationId', + as: 'actions' + }); + }; + return SolanaNotification; +}; diff --git a/packages/identity-service/build/src/models/solanaNotificationAction.js b/packages/identity-service/build/src/models/solanaNotificationAction.js new file mode 100644 index 00000000000..ac87515852c --- /dev/null +++ b/packages/identity-service/build/src/models/solanaNotificationAction.js @@ -0,0 +1,41 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const SolanaNotificationAction = sequelize.define('SolanaNotificationAction', { + id: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + notificationId: { + type: DataTypes.UUID, + allowNull: false, + onDelete: 'RESTRICT', + references: { + model: 'SolanaNotification', + key: 'id', + as: 'notificationId' + } + }, + slot: { + type: DataTypes.INTEGER, + allowNull: false + }, + actionEntityType: { + // TODO: make this enum + type: DataTypes.TEXT, + allowNull: false + }, + actionEntityId: { + type: DataTypes.INTEGER, + allowNull: false + } + }, {}); + SolanaNotificationAction.associate = function (models) { + SolanaNotificationAction.belongsTo(models.SolanaNotification, { + foreignKey: 'notificationId', + targetKey: 'id' + }); + }; + return SolanaNotificationAction; +}; diff --git a/packages/identity-service/build/src/models/subscriptions.js b/packages/identity-service/build/src/models/subscriptions.js new file mode 100644 index 00000000000..2ecddd692cc --- /dev/null +++ b/packages/identity-service/build/src/models/subscriptions.js @@ -0,0 +1,16 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Subscriptions = sequelize.define('Subscription', { + subscriberId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + } + }, {}); + return Subscriptions; +}; diff --git a/packages/identity-service/build/src/models/tikTokUser.js b/packages/identity-service/build/src/models/tikTokUser.js new file mode 100644 index 00000000000..55ffdbfdfc1 --- /dev/null +++ b/packages/identity-service/build/src/models/tikTokUser.js @@ -0,0 +1,39 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const TikTokUser = sequelize.define('TikTokUser', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + profile: { + type: DataTypes.JSONB, + allowNull: false, + unique: false + }, + verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + uuid: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: DataTypes.INTEGER, + allowNull: true + } + }, { + indexes: [ + { + fields: [`((profile->>'username'))`], + unique: false, + name: 'tiktok_users_profile_username_idx' + } + ] + }); + return TikTokUser; +}; diff --git a/packages/identity-service/build/src/models/tracklistencount.js b/packages/identity-service/build/src/models/tracklistencount.js new file mode 100644 index 00000000000..56d81d2bd8d --- /dev/null +++ b/packages/identity-service/build/src/models/tracklistencount.js @@ -0,0 +1,24 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const TrackListenCount = sequelize.define('TrackListenCount', { + trackId: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: false, + primaryKey: true + }, + listens: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + hour: { + type: DataTypes.DATE, + allowNull: false, + primaryKey: true + } + }, {}); + TrackListenCount.associate = function (models) { + // associations can be defined here + }; + return TrackListenCount; +}; diff --git a/packages/identity-service/build/src/models/transaction.js b/packages/identity-service/build/src/models/transaction.js new file mode 100644 index 00000000000..2580e7031fd --- /dev/null +++ b/packages/identity-service/build/src/models/transaction.js @@ -0,0 +1,38 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const Transaction = sequelize.define('Transaction', { + encodedNonceAndSignature: { + type: DataTypes.TEXT, + allowNull: false, + primaryKey: true + }, + decodedABI: { + type: DataTypes.JSONB, + allowNull: false + }, + receipt: { + type: DataTypes.JSONB, + allowNull: false + }, + contractRegistryKey: { + type: DataTypes.STRING, + allowNull: false + }, + contractFn: { + type: DataTypes.STRING, + allowNull: false + }, + contractAddress: { + type: DataTypes.STRING, + allowNull: false + }, + senderAddress: { + type: DataTypes.STRING, + allowNull: false + } + }, {}); + Transaction.associate = function (models) { + // associations can be defined here + }; + return Transaction; +}; diff --git a/packages/identity-service/build/src/models/twitterUser.js b/packages/identity-service/build/src/models/twitterUser.js new file mode 100644 index 00000000000..dd89d5538e9 --- /dev/null +++ b/packages/identity-service/build/src/models/twitterUser.js @@ -0,0 +1,38 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const TwitterUser = sequelize.define('TwitterUser', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + twitterProfile: { + type: DataTypes.JSONB, + allowNull: false, + unique: false + }, + verified: { + type: DataTypes.BOOLEAN, + allowNull: false + }, + uuid: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + blockchainUserId: { + type: DataTypes.INTEGER, + allowNull: true + } + }, { + indexes: [ + { + fields: [`((twitterProfile->>'screen_name'))`], + unique: false, + name: 'twitter_users_screen_name_idx' + } + ] + }); + return TwitterUser; +}; diff --git a/packages/identity-service/build/src/models/user.js b/packages/identity-service/build/src/models/user.js new file mode 100644 index 00000000000..2fe7ac4a040 --- /dev/null +++ b/packages/identity-service/build/src/models/user.js @@ -0,0 +1,72 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const User = sequelize.define('User', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + blockchainUserId: { + type: DataTypes.INTEGER, + allowNull: true + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + // allowNull is true because associate will set handle later + handle: { + type: DataTypes.STRING, + allowNull: true, + index: true + }, + IP: { + type: DataTypes.STRING, + allowNull: true + }, + timezone: { + type: DataTypes.STRING, + allowNull: true + }, + // allowNull is true because associate will set walletAddress later + walletAddress: { + type: DataTypes.STRING, + allowNull: true + }, + isConfigured: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + isBlockedFromRelay: { + type: DataTypes.BOOLEAN, + allowNull: true + }, + isBlockedFromNotifications: { + type: DataTypes.BOOLEAN, + allowNull: true + }, + isBlockedFromEmails: { + type: DataTypes.BOOLEAN, + allowNull: true + }, + appliedRules: { + type: DataTypes.JSONB, + allowNull: true + }, + isEmailDeliverable: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + // this is the last time we have an activity for this user + // could be updated whenever we relay a tx on behalf of them + lastSeenDate: { + type: DataTypes.DATE, + allowNull: false + } + }, {}); + return User; +}; diff --git a/packages/identity-service/build/src/models/userBankTransactionMetadata.js b/packages/identity-service/build/src/models/userBankTransactionMetadata.js new file mode 100644 index 00000000000..0bae4947666 --- /dev/null +++ b/packages/identity-service/build/src/models/userBankTransactionMetadata.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserBankTransactionMetadata = sequelize.define('UserBankTransactionMetadata', { + userId: { + type: DataTypes.INTEGER + }, + transactionSignature: { + type: DataTypes.STRING, + alllowNull: false, + primaryKey: true + }, + metadata: { + type: DataTypes.JSONB, + alllowNull: false + } + }, { + indexes: [ + { + fields: ['userId'], + name: 'idx_user_bank_transaction_metadata_user_id' + } + ] + }); + UserBankTransactionMetadata.associate = function (models) { + // associations can be defined here + }; + return UserBankTransactionMetadata; +}; diff --git a/packages/identity-service/build/src/models/userIPs.js b/packages/identity-service/build/src/models/userIPs.js new file mode 100644 index 00000000000..14b2deee5ba --- /dev/null +++ b/packages/identity-service/build/src/models/userIPs.js @@ -0,0 +1,23 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserIPs = sequelize.define('UserIPs', { + handle: { + allowNull: false, + type: DataTypes.STRING, + primaryKey: true + }, + userIP: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return UserIPs; +}; diff --git a/packages/identity-service/build/src/models/userNotificationBrowserSettings.js b/packages/identity-service/build/src/models/userNotificationBrowserSettings.js new file mode 100644 index 00000000000..55ced274d6f --- /dev/null +++ b/packages/identity-service/build/src/models/userNotificationBrowserSettings.js @@ -0,0 +1,41 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserNotificationBrowserSettings = sequelize.define('UserNotificationBrowserSettings', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + remixes: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + messages: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, {}); + return UserNotificationBrowserSettings; +}; diff --git a/packages/identity-service/build/src/models/userNotificationMobileSettings.js b/packages/identity-service/build/src/models/userNotificationMobileSettings.js new file mode 100644 index 00000000000..46909b1dfdf --- /dev/null +++ b/packages/identity-service/build/src/models/userNotificationMobileSettings.js @@ -0,0 +1,46 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserNotificationMobileSettings = sequelize.define('UserNotificationMobileSettings', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + announcements: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + remixes: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + messages: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, {}); + return UserNotificationMobileSettings; +}; diff --git a/packages/identity-service/build/src/models/userNotificationSettings.js b/packages/identity-service/build/src/models/userNotificationSettings.js new file mode 100644 index 00000000000..dcea126a86b --- /dev/null +++ b/packages/identity-service/build/src/models/userNotificationSettings.js @@ -0,0 +1,46 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserNotificationSettings = sequelize.define('UserNotificationSettings', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + milestonesAndAchievements: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + reposts: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + announcements: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + followers: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + browserPushNotifications: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + emailFrequency: { + allowNull: false, + type: DataTypes.ENUM('live', 'daily', 'weekly', 'off'), + defaultValue: 'live' + } + }, {}); + return UserNotificationSettings; +}; diff --git a/packages/identity-service/build/src/models/userPlaylistFavorites.js b/packages/identity-service/build/src/models/userPlaylistFavorites.js new file mode 100644 index 00000000000..d5b11e2bb8a --- /dev/null +++ b/packages/identity-service/build/src/models/userPlaylistFavorites.js @@ -0,0 +1,28 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + /** + * An association between users and their (ordered) favorite playlists + * Favorites accepts any Array of type String so that playlists with + * natural blockchain ids can be stored alongside autogenerated playlists. + */ + const UserPlaylistFavorites = sequelize.define('UserPlaylistFavorites', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + favorites: { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: false + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return UserPlaylistFavorites; +}; diff --git a/packages/identity-service/build/src/models/userevents.js b/packages/identity-service/build/src/models/userevents.js new file mode 100644 index 00000000000..4a53ffa05c8 --- /dev/null +++ b/packages/identity-service/build/src/models/userevents.js @@ -0,0 +1,29 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserEvents = sequelize.define('UserEvents', { + walletAddress: { + type: DataTypes.STRING, + primaryKey: true, + references: { model: 'Users', key: 'walletAddress' } + }, + needsRecoveryEmail: { + type: DataTypes.BOOLEAN, + allowNull: true + }, + hasSentDownloadAppEmail: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + hasSignedInNativeMobile: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + playlistUpdates: { + type: DataTypes.JSONB, + allowNull: true + } + }, {}); + return UserEvents; +}; diff --git a/packages/identity-service/build/src/models/usertracklisten.js b/packages/identity-service/build/src/models/usertracklisten.js new file mode 100644 index 00000000000..aa52b2d79eb --- /dev/null +++ b/packages/identity-service/build/src/models/usertracklisten.js @@ -0,0 +1,33 @@ +'use strict'; +module.exports = (sequelize, DataTypes) => { + const UserTrackListen = sequelize.define('UserTrackListen', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + trackId: { + type: DataTypes.INTEGER, + allowNull: false + }, + count: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1 + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, {}); + return UserTrackListen; +}; diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128.png new file mode 100644 index 00000000000..4905e5e5766 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128@2x.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128@2x.png new file mode 100644 index 00000000000..9586e516536 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_128x128@2x.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16.png new file mode 100644 index 00000000000..ffd362a9195 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16@2x.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16@2x.png new file mode 100644 index 00000000000..d2fcd559fe3 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_16x16@2x.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32.png new file mode 100644 index 00000000000..d2fcd559fe3 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32@2x.png b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32@2x.png new file mode 100644 index 00000000000..655126a9086 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/audius.pushpackage/icon.iconsets/icon_32x32@2x.png differ diff --git a/packages/identity-service/build/src/notifications/browserPush/makePushPackage.js b/packages/identity-service/build/src/notifications/browserPush/makePushPackage.js new file mode 100644 index 00000000000..fd26a9f907b --- /dev/null +++ b/packages/identity-service/build/src/notifications/browserPush/makePushPackage.js @@ -0,0 +1,58 @@ +"use strict"; +const fs = require('fs'); +const path = require('path'); +const pushLib = require('safari-push-notifications'); +// Common between all env +const intermediate = fs.readFileSync(path.join(__dirname, './audius.pushpackage/AppleWWDRCA.pem')); +// Differing env +// Locally +// NOTE: safari requires https, so ngrok is used for local development and must be replaced here +// const devConfig = { +// websiteName: 'Audius', +// websitePushID: 'web.co.audius.staging', +// appUrl: 'http://localhost:3000', +// identityUrl: 'https://0ef6caf4.ngrok.io', // Replace with https ngrok link +// appUrls: [], +// cert: fs.readFileSync(path.join(__dirname, '/audius.pushpackage/stagingCert.pem')), +// key: fs.readFileSync(path.join(__dirname, '/audius.pushpackage/stagingKey.pem')), +// output: 'devPushPackage.zip' +// } +// Staging +// const stagingConfig = { +// websiteName: 'Audius', +// websitePushID: 'web.co.audius.staging', +// appUrl: 'https://staging.audius.co', +// identityUrl: 'https://identityservice.staging.audius.co', +// appUrls: ['https://joey.audius.co', 'https://ray.audius.co', 'https://michael.audius.co', 'https://forrest.audius.co'], +// cert: fs.readFileSync(path.join(__dirname, '/audius.pushpackage/stagingCert.pem')), +// key: fs.readFileSync(path.join(__dirname, '/audius.pushpackage/stagingKey.pem')), +// output: 'stagingPushPackage.zip' +// } +const prodConfig = { + websiteName: 'Audius', + websitePushID: 'web.co.audius', + appUrl: 'https://audius.co', + identityUrl: 'https://identityservice.audius.co', + appUrls: [], + cert: fs.readFileSync(path.join(__dirname, './audius.pushpackage/prodCert.pem')), + key: fs.readFileSync(path.join(__dirname, './audius.pushpackage/prodKey.pem')), + output: 'productionPushPackage.zip' +}; +// Change the config to be local / staging / prod +const config = prodConfig; +const websiteJson = pushLib.websiteJSON(config.websiteName, config.websitePushID, [config.appUrl, config.identityUrl, ...config.appUrls], // allowedDomains +`${config.appUrl}/feed?openNotifications=true`, // urlFormatString +1000000000000000, // authenticationToken (zeroFilled to fit 16 chars) +`${config.identityUrl}/push_notifications/safari` // webServiceURL (Must be https!) +); +pushLib + .generatePackage(websiteJson, // The object from before / your own website.json object +path.join(__dirname, '/audius.pushpackage/icon.iconsets'), // Folder containing the iconset +config.cert, // Certificate +config.key, // Private Key +intermediate // Intermediate certificate +) + .pipe(fs.createWriteStream(config.output)) + .on('finish', function () { + console.log('pushPackage.zip is ready.'); +}); diff --git a/packages/identity-service/build/src/notifications/browserPush/productionPushPackage.zip b/packages/identity-service/build/src/notifications/browserPush/productionPushPackage.zip new file mode 100644 index 00000000000..05e17359cf9 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/productionPushPackage.zip differ diff --git a/packages/identity-service/build/src/notifications/browserPush/stagingPushPackage.zip b/packages/identity-service/build/src/notifications/browserPush/stagingPushPackage.zip new file mode 100644 index 00000000000..84133184eb1 Binary files /dev/null and b/packages/identity-service/build/src/notifications/browserPush/stagingPushPackage.zip differ diff --git a/packages/identity-service/build/src/notifications/components/Body.js b/packages/identity-service/build/src/notifications/components/Body.js new file mode 100644 index 00000000000..94ced117361 --- /dev/null +++ b/packages/identity-service/build/src/notifications/components/Body.js @@ -0,0 +1,209 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const react_1 = __importDefault(require("react")); +const Footer_1 = __importDefault(require("./Footer")); +const Notification_1 = __importDefault(require("./notifications/Notification")); +const constants_1 = require("../constants"); +const AudiusImage = () => { + return (Audius Logo); +}; +const WhatYouMissed = () => { + return (What You Missed); +}; +const UnreadNotifications = ({ message }) => (

+ {message} +

); +const getNumberSuffix = (num) => { + if (num === 1) + return 'st'; + else if (num === 2) + return 'nd'; + else if (num === 3) + return 'rd'; + return 'th'; +}; +const snippetMap = { + [constants_1.notificationTypes.Favorite.base](notification) { + const [user] = notification.users; + return `${user.name} favorited your ${notification.entity.type.toLowerCase()} ${notification.entity.name}`; + }, + [constants_1.notificationTypes.Repost.base](notification) { + const [user] = notification.users; + return `${user.name} reposted your ${notification.entity.type.toLowerCase()} ${notification.entity.name}`; + }, + [constants_1.notificationTypes.Follow](notification) { + const [user] = notification.users; + return `${user.name} followed you`; + }, + [constants_1.notificationTypes.Announcement](notification) { + return notification.text; + }, + [constants_1.notificationTypes.Milestone](notification) { + if (notification.entity) { + const entity = notification.entity.type.toLowerCase(); + return `Your ${entity} ${notification.entity.name} has reached over ${notification.value} ${notification.achievement}s`; + } + else { + return `You have reached over ${notification.value} Followers`; + } + }, + [constants_1.notificationTypes.TrendingTrack](notification) { + const rank = notification.rank; + const suffix = getNumberSuffix(rank); + return `Your Track ${notification.entity.title} is ${notification.rank}${suffix} on Trending Right Now!`; + }, + [constants_1.notificationTypes.UserSubscription](notification) { + const [user] = notification.users; + if (notification.entity.type === constants_1.notificationTypes.Track && !isNaN(notification.entity.count) && notification.entity.count > 1) { + return `${user.name} released ${notification.entity.count} new ${notification.entity.type}`; + } + return `${user.name} released a new ${notification.entity.type.toLowerCase()} ${notification.entity.name}`; + }, + [constants_1.notificationTypes.RemixCreate](notification) { + const { parentTrack } = notification; + return `New remix of your track ${parentTrack.title}`; + }, + [constants_1.notificationTypes.RemixCosign](notification) { + const { parentTrackUser, parentTracks } = notification; + const parentTrack = parentTracks.find(t => t.ownerId === parentTrackUser.userId); + return `${parentTrackUser.name} Co-signed your Remix of ${parentTrack.title}`; + }, + [constants_1.notificationTypes.ChallengeReward](notification) { + return `You've earned $AUDIO for completing challenges`; + }, + [constants_1.notificationTypes.AddTrackToPlaylist](notification) { + return `${notification.playlistOwner.name} added ${notification.track.title} to ${notification.playlist.playlist_name}`; + }, + [constants_1.notificationTypes.TipReceive](notification) { + return `${notification.sendingUser.name} sent you a tip of ${notification.amount} $AUDIO`; + }, + [constants_1.notificationTypes.Reaction](notification) { + return `${notification.reactingUser.name} reacted to your tip of ${notification.amount} $AUDIO`; + }, + [constants_1.notificationTypes.SupporterRankUp](notification) { + return `${notification.sendingUser.name} became your #${notification.rank} top supporter`; + }, + [constants_1.notificationTypes.SupportingRankUp](notification) { + return `You're now ${notification.receivingUser.name}'s #${notification.rank} top supporter`; + } +}; +const mapNotification = (notification) => { + switch (notification.type) { + case constants_1.notificationTypes.RemixCreate: { + notification.users = [notification.remixUser]; + return notification; + } + case constants_1.notificationTypes.RemixCosign: { + notification.track = notification.remixTrack; + return notification; + } + default: { + return notification; + } + } +}; +// Generate snippet for email composed of the first three notification texts, +// but limited to 90 characters w/ an ellipsis +const SNIPPET_ELLIPSIS_LENGTH = 90; +const getSnippet = (notifications) => { + const snippet = notifications.slice(0, 3).map(notification => { + return snippetMap[notification.type](notification); + }).join(', '); + if (snippet.length <= SNIPPET_ELLIPSIS_LENGTH) + return snippet; + const indexOfEllipsis = snippet.substring(SNIPPET_ELLIPSIS_LENGTH).indexOf(' ') + SNIPPET_ELLIPSIS_LENGTH; + return `${snippet.substring(0, indexOfEllipsis)} ...`; +}; +const Body = (props) => { + return ( +

+ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + + ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + + ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + + ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + + ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + ` }}/> +

+ + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ {props.notifications.map((notification, ind) => ())} +
+ + + + +
+ + See more on Audius + +
+
+ +
+
+ ); +}; +exports.default = Body; diff --git a/packages/identity-service/build/src/notifications/components/BodyStyles.js b/packages/identity-service/build/src/notifications/components/BodyStyles.js new file mode 100644 index 00000000000..9e97c19cb81 --- /dev/null +++ b/packages/identity-service/build/src/notifications/components/BodyStyles.js @@ -0,0 +1,459 @@ +"use strict"; +const React = require('react'); +const BodyStyles = () => { + return ( + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

Take Audius with you! Download the Audius mobile app and listen to remixes, tracks, and playlists + in incredible quality from anywhere. +

+
+
+ + + +
+ +
+ + + +
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + + +
instagramtwitterdiscord
+
+

© {{copyright_year}} Audius, Inc. All Rights Reserved.

+
+ Tired + of seeing these emails? Update + your notification preferences or + Unsubscribe +
+ +
+
+ + + \ No newline at end of file diff --git a/packages/identity-service/build/src/notifications/emails/recovery.html b/packages/identity-service/build/src/notifications/emails/recovery.html new file mode 100644 index 00000000000..ee5ea5986c1 --- /dev/null +++ b/packages/identity-service/build/src/notifications/emails/recovery.html @@ -0,0 +1,671 @@ + + + + + + + + + + + Password Reset + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+ + 32db45f5-7cff-47e4-942b-6b3712c8c4b0.png + +
+ +
+

Hey @{{handle}},

+

Keep this email safe and don’t share it with anyone.
+ Audius cannot reset your password any other way. +

+

+ Please save this email forever. +

+ + Reset My Password + +
+ +
+ + + + + + +
+ + + + + + + + + + +
+ instagram + twitter + discord + +

Made with ♥︎ in SF & LA

+
+

© {{copyright_year}} Audius, Inc. All Rights Reserved.

+
+ +
+ +
+ + +
+
+ + + \ No newline at end of file diff --git a/packages/identity-service/build/src/notifications/emails/welcome.js b/packages/identity-service/build/src/notifications/emails/welcome.js new file mode 100644 index 00000000000..f623bd02e3f --- /dev/null +++ b/packages/identity-service/build/src/notifications/emails/welcome.js @@ -0,0 +1,891 @@ +const getWelcomeEmail = ({ name, copyrightYear }) => { + return ` + + + + + + + + + + + + + + + +
+ + +
+
+ +
+ +
+ +
+
+ + + +
+
+ + + +
+ +
+ +
+
 
+
+ + + +
+
+ + +
+ + +
+ + +
+ +
+ Welcome to Audius! +
+
 
+
+ Your music. Your way. +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ Hello ${name}, +
+
 
+
+ Welcome to Audius! We're thrilled you're here.

Audius isn't just another music platform - it's a vibrant global community of music lovers. Here, fans and artists come together to create, share, repost, and vibe with the beats that inspire.

At Audius, you're not just a user, you're a part of the music revolution.
+
+
+
+
+ +
+
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+
 
+
+
+
+ + +
+ + +
+
+ + +
+
+ + + +
+ + + +
+ + + +
+
+ +
+
+
+ + + + +
+ + +
+
+ + + +
+ + + +
+ + +
+
+ Start Listening +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ Discover Trending Artists +
+
 
+
+ + +
+
+ Trending artists earn $AUDIO every week! Check out who’s trending on Audius right now. +
+
+
+
+
+
 
+
+ + +
+ + +
+
+ + +
+
+ + + +
+ + + +
 
+ +
+ +
+
+
+ +
+ + +
+ +
+
+ +
+ + +
+
+ + + +
+ + + +
 
+ +
+ +
+
+
+
+
 
+
+
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + + +
+ +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ Start Creating +
+
+
+
 
+
+ + +
+
+ Get out there and start curating your feed. Unlimited uploads and playlists for free - for everyone! +
+
+
+
+
+
 
+
+ +
+
 
+
+ + +
+ + +
+
+ + +
+
+ + + +
+ + + +
+ + + +
+ +
+
+
+ +
+
+
+ +
+
+
+ + + +
+ + +
+
+ + + +
+ + + +
+ + + +
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
 
+
+ + + +
+
+ + +
+ + +
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+
 
+
+
+
+ + +
+ + +
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+
 
+
+
+
+ + +
+
+ © ${copyrightYear} Audius, Inc. All Rights Reserved. +
+
 
+
+ Tired of seeing these emails? Update your notification preferences or + + Unsubscribe + +
+
+
+
+ +
+
+ +
+
+
+ + +` +} + +module.exports = { getWelcomeEmail } diff --git a/packages/identity-service/build/src/notifications/fetchNotificationMetadata.js b/packages/identity-service/build/src/notifications/fetchNotificationMetadata.js new file mode 100644 index 00000000000..cb23ab2f58c --- /dev/null +++ b/packages/identity-service/build/src/notifications/fetchNotificationMetadata.js @@ -0,0 +1,465 @@ +"use strict"; +const moment = require('moment'); +const axios = require('axios'); +const models = require('../models'); +const { notificationTypes: NotificationType } = require('../notifications/constants'); +const Entity = require('../routes/notifications').Entity; +const mergeAudiusAnnoucements = require('../routes/notifications').mergeAudiusAnnoucements; +const { formatEmailNotificationProps } = require('./formatNotificationMetadata'); +const config = require('../config.js'); +const { logger } = require('../logging'); +const USER_NODE_IPFS_GATEWAY = config.get('environment').includes('staging') + ? 'https://usermetadata.staging.audius.co/ipfs/' + : 'https://usermetadata.audius.co/ipfs/'; +const DEFAULT_IMAGE_URL = 'https://download.audius.co/static-resources/email/imageProfilePicEmpty.png'; +const DEFAULT_TRACK_IMAGE_URL = 'https://download.audius.co/static-resources/email/imageTrackEmpty.jpg'; +// The number of users to fetch / display per notification (The displayed number of users) +const USER_FETCH_LIMIT = 10; +/* Merges the notifications with the user announcements in time sorted order (Most recent first). + * + * @param {AudiusLibs} audius Audius Libs instance + * @param {number} userId The blockchain user id of the recipient of the user + * @param {Array} announcements Announcements set on the app + * @param {moment Time} fromTime The moment time object from which to get notifications + * @param {number?} limit The max number of notification to attach in the email + * + * @return {Promise} + */ +const EXCLUDED_EMAIL_SOLANA_NOTIF_TYPES = [NotificationType.TipSend]; +const getLastWeek = () => moment().subtract(7, 'days'); +async function getEmailNotifications(audius, userId, announcements = [], fromTime = getLastWeek(), limit = 5) { + try { + const user = await models.User.findOne({ + where: { blockchainUserId: userId }, + attributes: ['createdAt'] + }); + const { rows: notifications } = await models.Notification.findAndCountAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + timestamp: { + [models.Sequelize.Op.gt]: fromTime.toDate() + } + }, + order: [ + ['timestamp', 'DESC'], + ['entityId', 'ASC'], + [ + { model: models.NotificationAction, as: 'actions' }, + 'createdAt', + 'DESC' + ] + ], + include: [ + { + model: models.NotificationAction, + required: true, + as: 'actions' + } + ], + limit + }); + const { rows: solanaNotifications } = await models.SolanaNotification.findAndCountAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + createdAt: { + [models.Sequelize.Op.gt]: fromTime.toDate() + }, + type: { + [models.Sequelize.Op.notIn]: EXCLUDED_EMAIL_SOLANA_NOTIF_TYPES + } + }, + order: [ + ['createdAt', 'DESC'], + ['entityId', 'ASC'], + [ + { model: models.SolanaNotificationAction, as: 'actions' }, + 'createdAt', + 'DESC' + ] + ], + include: [ + { + model: models.SolanaNotificationAction, + as: 'actions' + } + ], + limit + }); + const notifCountQuery = await models.Notification.findAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + timestamp: { + [models.Sequelize.Op.gt]: fromTime.toDate() + } + }, + include: [ + { + model: models.NotificationAction, + as: 'actions', + required: true, + attributes: [] + } + ], + attributes: [ + [ + models.Sequelize.fn('COUNT', models.Sequelize.col('Notification.id')), + 'total' + ] + ], + group: ['Notification.id'] + }); + const solanaNotifCountQuery = await models.SolanaNotification.findAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + createdAt: { + [models.Sequelize.Op.gt]: fromTime.toDate() + }, + type: { + [models.Sequelize.Op.notIn]: EXCLUDED_EMAIL_SOLANA_NOTIF_TYPES + } + }, + include: [ + { + model: models.SolanaNotificationAction, + as: 'actions', + attributes: [] + } + ], + attributes: [ + [ + models.Sequelize.fn('COUNT', models.Sequelize.col('SolanaNotification.id')), + 'total' + ] + ], + group: ['SolanaNotification.id'] + }); + const notificationCount = notifCountQuery.length + solanaNotifCountQuery.length; + const announcementIds = new Set(announcements.map(({ entityId }) => entityId)); + const filteredNotifications = notifications + .concat(solanaNotifications) + .filter(({ id }) => !announcementIds.has(id)); + const tenDaysAgo = moment().subtract(10, 'days'); + // An announcement is valid if it's + // 1.) created after the user + // 2.) created after "fromTime" which represent the time the last email was sent + // 3.) created within the last 10 days + const validUserAnnouncements = announcements.filter((a) => moment(a.datePublished).isAfter(user.createdAt) && + moment(a.datePublished).isAfter(fromTime) && + moment(a.datePublished).isAfter(tenDaysAgo)); + const userNotifications = mergeAudiusAnnoucements(validUserAnnouncements, filteredNotifications); + let unreadAnnouncementCount = 0; + userNotifications.forEach((notif) => { + if (notif.type === NotificationType.Announcement) { + unreadAnnouncementCount += 1; + } + }); + if (userNotifications.length === 0) { + return [{}, 0]; + } + const finalUserNotifications = userNotifications.slice(0, limit); + const fethNotificationsTime = Date.now(); + const metadata = await fetchNotificationMetadata(audius, [userId], finalUserNotifications, true, true); + const fetchDataDuration = (Date.now() - fethNotificationsTime) / 1000; + logger.info({ job: 'fetchNotificationMetadata', duration: fetchDataDuration }, `fetchNotificationMetadata | get metadata ${fetchDataDuration} sec`); + const notificationsEmailProps = formatEmailNotificationProps(finalUserNotifications, metadata); + return [ + notificationsEmailProps, + notificationCount + unreadAnnouncementCount + ]; + } + catch (err) { + logger.error(err); + } +} +async function fetchNotificationMetadata(audius, userIds = [], notifications, fetchThumbnails = false, +// the structure of the notification data from the database will be different +// in the email flow compared to that which is fetched from the discovery node +isEmailNotif = false) { + const userIdsToFetch = [...userIds]; + const trackIdsToFetch = []; + const collectionIdsToFetch = []; + const fetchTrackRemixParents = []; + for (const notification of notifications) { + switch (notification.type) { + case NotificationType.Follow: + case NotificationType.ChallengeReward: + case NotificationType.TierChange: { + userIdsToFetch.push(...notification.actions + .map(({ actionEntityId }) => actionEntityId) + .slice(0, USER_FETCH_LIMIT)); + break; + } + case NotificationType.Favorite.track: + case NotificationType.Repost.track: { + userIdsToFetch.push(...notification.actions + .map(({ actionEntityId }) => actionEntityId) + .slice(0, USER_FETCH_LIMIT)); + trackIdsToFetch.push(notification.entityId); + break; + } + case NotificationType.Favorite.playlist: + case NotificationType.Favorite.album: + case NotificationType.Repost.playlist: + case NotificationType.Repost.album: { + userIdsToFetch.push(...notification.actions + .map(({ actionEntityId }) => actionEntityId) + .slice(0, USER_FETCH_LIMIT)); + collectionIdsToFetch.push(notification.entityId); + break; + } + case NotificationType.Create.album: + case NotificationType.Create.playlist: { + collectionIdsToFetch.push(notification.entityId); + break; + } + case NotificationType.MilestoneRepost: + case NotificationType.MilestoneFavorite: + case NotificationType.MilestoneListen: { + if (notification.actions[0].actionEntityType === Entity.Track) { + trackIdsToFetch.push(notification.entityId); + } + else { + collectionIdsToFetch.push(notification.entityId); + } + break; + } + case NotificationType.Create.track: { + trackIdsToFetch.push(...notification.actions.map(({ actionEntityId }) => actionEntityId)); + break; + } + case NotificationType.RemixCreate: { + trackIdsToFetch.push(notification.entityId); + for (const action of notification.actions) { + if (action.actionEntityType === Entity.Track) { + trackIdsToFetch.push(action.actionEntityId); + } + else if (action.actionEntityType === Entity.User) { + userIdsToFetch.push(action.actionEntityId); + } + } + break; + } + case NotificationType.RemixCosign: { + trackIdsToFetch.push(notification.entityId); + fetchTrackRemixParents.push(notification.entityId); + for (const action of notification.actions) { + if (action.actionEntityType === Entity.Track) { + trackIdsToFetch.push(action.actionEntityId); + } + else if (action.actionEntityType === Entity.User) { + userIdsToFetch.push(action.actionEntityId); + } + } + break; + } + case NotificationType.TrendingTrack: { + trackIdsToFetch.push(notification.entityId); + break; + } + case NotificationType.AddTrackToPlaylist: { + trackIdsToFetch.push(notification.entityId); + userIdsToFetch.push(notification.metadata.playlistOwnerId); + collectionIdsToFetch.push(notification.metadata.playlistId); + break; + } + case NotificationType.Reaction: { + userIdsToFetch.push(notification.initiator); + if (isEmailNotif) { + userIdsToFetch.push(notification.userId); + userIdsToFetch.push(notification.entityId); + } + break; + } + case NotificationType.SupporterRankUp: { + // Tip sender needed for SupporterRankUp + userIdsToFetch.push(notification.metadata.entity_id); + if (isEmailNotif) { + userIdsToFetch.push(notification.userId); + userIdsToFetch.push(notification.metadata.supportingUserId); + } + break; + } + case NotificationType.SupportingRankUp: { + // Tip recipient needed for SupportingRankUp + userIdsToFetch.push(notification.initiator); + if (isEmailNotif) { + userIdsToFetch.push(notification.userId); + userIdsToFetch.push(notification.metadata.supportedUserId); + } + break; + } + case NotificationType.TipReceive: { + // Fetch the sender of the tip + userIdsToFetch.push(notification.metadata.entity_id); + if (isEmailNotif) { + userIdsToFetch.push(notification.userId); + userIdsToFetch.push(notification.entityId); + } + break; + } + case NotificationType.SupporterDethroned: + // Fetch both the supported user, and the new top supporter + userIdsToFetch.push(notification.metadata.supportingUserId); + userIdsToFetch.push(notification.metadata.newTopSupporterUserId); + break; + } + } + const uniqueTrackIds = [...new Set(trackIdsToFetch)]; + const tracks = []; + // Batch track fetches to avoid large request lines + const trackBatchSize = 100; // use default limit + for (let trackBatchOffset = 0; trackBatchOffset < uniqueTrackIds.length; trackBatchOffset += trackBatchSize) { + const trackBatch = uniqueTrackIds.slice(trackBatchOffset, trackBatchOffset + trackBatchSize); + const tracksResponse = await audius.Track.getTracks( + /** limit */ trackBatch.length, + /** offset */ 0, + /** idsArray */ trackBatch); + tracks.push(...tracksResponse); + } + if (!Array.isArray(tracks)) { + logger.error(`fetchNotificationMetadata | Unable to fetch track ids ${uniqueTrackIds.join(',')}`); + } + const trackMap = tracks.reduce((tm, track) => { + tm[track.track_id] = track; + return tm; + }, {}); + // Fetch the parents of the remix tracks & add to the tracks map + if (fetchTrackRemixParents.length > 0) { + const trackParentIds = fetchTrackRemixParents.reduce((parentTrackIds, remixTrackId) => { + const track = trackMap[remixTrackId]; + const parentIds = track.remix_of && Array.isArray(track.remix_of.tracks) + ? track.remix_of.tracks.map((t) => t.parent_track_id) + : []; + return parentTrackIds.concat(parentIds); + }, []); + const uniqueParentTrackIds = [...new Set(trackParentIds)]; + const parentTracks = await audius.Track.getTracks( + /** limit */ uniqueParentTrackIds.length, + /** offset */ 0, + /** idsArray */ uniqueParentTrackIds); + if (!Array.isArray(parentTracks)) { + logger.error(`fetchNotificationMetadata | Unable to fetch parent track ids ${uniqueParentTrackIds.join(',')}`); + } + parentTracks.forEach((track) => { + trackMap[track.track_id] = track; + }); + } + const uniqueCollectionIds = [...new Set(collectionIdsToFetch)]; + const collections = await audius.Playlist.getPlaylists( + /** limit */ uniqueCollectionIds.length, + /** offset */ 0, + /** idsArray */ uniqueCollectionIds); + if (!Array.isArray(collections)) { + logger.error(`fetchNotificationMetadata | Unable to fetch collection ids ${uniqueCollectionIds.join(',')}`); + } + userIdsToFetch.push(...tracks.map(({ owner_id: id }) => id), ...collections.map(({ playlist_owner_id: id }) => id)); + const uniqueUserIds = [...new Set(userIdsToFetch)]; + let users = await audius.User.getUsers( + /** limit */ uniqueUserIds.length, + /** offset */ 0, + /** idsArray */ uniqueUserIds); + if (!Array.isArray(users)) { + logger.error(`fetchNotificationMetadata | Unable to fetch user ids ${uniqueUserIds.join(',')}`); + } + // Fetch all the social handles and attach to the users - For twitter sharing + const socialHandles = await models.SocialHandles.findAll({ + where: { + handle: users.map(({ handle }) => handle) + } + }); + const twitterHandleMap = socialHandles.reduce((handleMapping, socialHandle) => { + if (socialHandle.twitterHandle) + handleMapping[socialHandle.handle] = socialHandle.twitterHandle; + return handleMapping; + }, {}); + users = await Promise.all(users.map(async (user) => { + if (fetchThumbnails) { + user.thumbnail = await getUserImage(user); + } + if (twitterHandleMap[user.handle]) { + user.twitterHandle = twitterHandleMap[user.handle]; + } + return user; + })); + const collectionMap = collections.reduce((cm, collection) => { + cm[collection.playlist_id] = collection; + return cm; + }, {}); + const userMap = users.reduce((um, user) => { + um[user.user_id] = user; + return um; + }, {}); + if (fetchThumbnails) { + for (const trackId of Object.keys(trackMap)) { + const track = trackMap[trackId]; + track.thumbnail = await getTrackImage(track, userMap); + } + } + return { + tracks: trackMap, + collections: collectionMap, + users: userMap + }; +} +const formatGateway = (creatorNodeEndpoint) => creatorNodeEndpoint + ? `${creatorNodeEndpoint.split(',')[0]}/ipfs/` + : USER_NODE_IPFS_GATEWAY; +const getImageUrl = (cid, gateway, defaultImg) => cid ? `${gateway}${cid}` : defaultImg; +async function getUserImage(user) { + const gateway = formatGateway(user.creator_node_endpoint); + const profilePicture = user.profile_picture_sizes + ? `${user.profile_picture_sizes}/1000x1000.jpg` + : user.profile_picture; + const imageUrl = getImageUrl(profilePicture, gateway, DEFAULT_IMAGE_URL); + if (imageUrl === DEFAULT_IMAGE_URL) { + return imageUrl; + } + try { + await axios({ + method: 'head', + url: imageUrl, + timeout: 5000 + }); + return imageUrl; + } + catch (e) { + return DEFAULT_IMAGE_URL; + } +} +async function getTrackImage(track, usersMap) { + const trackOwnerId = track.owner_id; + const trackOwner = usersMap[trackOwnerId]; + const gateway = formatGateway(trackOwner.creator_node_endpoint); + const trackCoverArt = track.cover_art_sizes + ? `${track.cover_art_sizes}/480x480.jpg` + : track.cover_art; + const imageUrl = getImageUrl(trackCoverArt, gateway, DEFAULT_TRACK_IMAGE_URL); + if (imageUrl === DEFAULT_TRACK_IMAGE_URL) { + return imageUrl; + } + try { + await axios({ + method: 'head', + url: imageUrl, + timeout: 5000 + }); + return imageUrl; + } + catch (e) { + return DEFAULT_TRACK_IMAGE_URL; + } +} +module.exports = getEmailNotifications; +module.exports.fetchNotificationMetadata = fetchNotificationMetadata; diff --git a/packages/identity-service/build/src/notifications/formatNotificationMetadata.js b/packages/identity-service/build/src/notifications/formatNotificationMetadata.js new file mode 100644 index 00000000000..be514ab6a89 --- /dev/null +++ b/packages/identity-service/build/src/notifications/formatNotificationMetadata.js @@ -0,0 +1,517 @@ +"use strict"; +const { notificationTypes: NotificationType } = require('../notifications/constants'); +const Entity = require('../routes/notifications').Entity; +const mapMilestone = require('../routes/notifications').mapMilestone; +const { actionEntityTypes, notificationTypes } = require('./constants'); +const { formatWei, capitalize } = require('./processNotifications/utils'); +const BN = require('bn.js'); +const getRankSuffix = (num) => { + if (num === 1) + return 'st'; + else if (num === 2) + return 'nd'; + else if (num === 3) + return 'rd'; + return 'th'; +}; +const formatFavorite = (notification, metadata, entity) => { + return { + type: NotificationType.Favorite.base, + users: notification.actions.map((action) => { + const userId = action.actionEntityId; + const user = metadata.users[userId]; + if (!user) + return null; + return { + id: user.id, + handle: user.handle, + name: user.name, + image: user.thumbnail + }; + }), + entity + }; +}; +const formatRepost = (notification, metadata, entity) => { + return { + type: NotificationType.Repost.base, + users: notification.actions.map((action) => { + const userId = action.actionEntityId; + const user = metadata.users[userId]; + if (!user) + return null; + return { + id: user.user_id, + handle: user.handle, + name: user.name, + image: user.thumbnail + }; + }), + entity + }; +}; +const formatUserSubscription = (notification, metadata, entity, users) => { + return { + type: NotificationType.UserSubscription, + users, + entity + }; +}; +const formatMilestone = (achievement) => (notification, metadata) => { + return { + type: NotificationType.Milestone, + ...mapMilestone[notification.type], + entity: getMilestoneEntity(notification, metadata), + value: notification.actions[0].actionEntityId, + achievement + }; +}; +function formatTrendingTrack(notification, metadata) { + const trackId = notification.entityId; + const track = metadata.tracks[trackId]; + if (!notification.actions.length === 1) + return null; + const rank = notification.actions[0].actionEntityId; + const type = notification.actions[0].actionEntityType; + const [time, genre] = type.split(':'); + return { + type: NotificationType.TrendingTrack, + entity: track, + rank, + time, + genre + }; +} +function getMilestoneEntity(notification, metadata) { + if (notification.type === NotificationType.MilestoneFollow) + return undefined; + const type = notification.actions[0].actionEntityType; + const entityId = notification.entityId; + const name = type === Entity.Track + ? metadata.tracks[entityId].title + : metadata.collections[entityId].playlist_name; + return { type, name }; +} +function formatFollow(notification, metadata) { + return { + type: NotificationType.Follow, + users: notification.actions.map((action) => { + const userId = action.actionEntityId; + const user = metadata.users[userId]; + if (!user) + return null; + return { + id: userId, + handle: user.handle, + name: user.name, + image: user.thumbnail + }; + }) + }; +} +function formatAnnouncement(notification) { + return { + type: NotificationType.Announcement, + text: notification.shortDescription, + hasReadMore: !!notification.longDescription + }; +} +function formatRemixCreate(notification, metadata) { + const trackId = notification.entityId; + const parentTrackAction = notification.actions.find((action) => action.actionEntityType === actionEntityTypes.Track && + action.actionEntityId !== trackId); + const parentTrackId = parentTrackAction.actionEntityId; + const remixTrack = metadata.tracks[trackId]; + const parentTrack = metadata.tracks[parentTrackId]; + const userId = remixTrack.owner_id; + const parentTrackUserId = parentTrack.owner_id; + return { + type: NotificationType.RemixCreate, + remixUser: metadata.users[userId], + remixTrack, + parentTrackUser: metadata.users[parentTrackUserId], + parentTrack + }; +} +function formatRemixCosign(notification, metadata) { + const trackId = notification.entityId; + const parentTrackUserAction = notification.actions.find((action) => action.actionEntityType === actionEntityTypes.User); + const parentTrackUserId = parentTrackUserAction.actionEntityId; + const remixTrack = metadata.tracks[trackId]; + const parentTracks = remixTrack.remix_of.tracks.map((t) => metadata.tracks[t.parent_track_id]); + return { + type: NotificationType.RemixCosign, + parentTrackUser: metadata.users[parentTrackUserId], + parentTracks, + remixTrack + }; +} +function formatChallengeReward(notification) { + const challengeId = notification.actions[0].actionEntityType; + return { + type: NotificationType.ChallengeReward, + challengeId, + rewardAmount: challengeInfoMap[challengeId].amount + }; +} +function formatAddTrackToPlaylist(notification, metadata) { + return { + type: NotificationType.AddTrackToPlaylist, + track: metadata.tracks[notification.entityId], + playlist: metadata.collections[notification.metadata.playlistId], + playlistOwner: metadata.users[notification.metadata.playlistOwnerId] + }; +} +function formatReaction(notification, metadata) { + const userId = notification.initiator; + const user = metadata.users[userId]; + return { + type: NotificationType.Reaction, + reactingUser: user, + amount: formatWei(new BN(notification.metadata.reacted_to_entity.amount)) + }; +} +// This is different from the above corresponding function +// because it operates on data coming from the database +// as opposed to that coming from the DN. +function formatReactionEmail(notification, extras) { + const { entityId, metadata: { reactedToEntity: { amount } } } = notification; + return { + type: NotificationType.Reaction, + reactingUser: extras.users[entityId], + amount: formatWei(new BN(amount)) + }; +} +function formatTipReceive(notification, metadata) { + const userId = notification.metadata.entity_id; + const user = metadata.users[userId]; + return { + type: NotificationType.TipReceive, + sendingUser: user, + amount: formatWei(new BN(notification.metadata.amount)) + }; +} +// This is different from the above corresponding function +// because it operates on data coming from the database +// as opposed to that coming from the DN. +function formatTipReceiveEmail(notification, extras) { + const { entityId, metadata: { amount } } = notification; + return { + type: NotificationType.TipReceive, + sendingUser: extras.users[entityId], + amount: formatWei(new BN(amount)) + }; +} +function formatSupporterRankUp(notification, metadata) { + // Sending user + const userId = notification.metadata.entity_id; + const user = metadata.users[userId]; + return { + type: NotificationType.SupporterRankUp, + rank: notification.metadata.rank, + sendingUser: user + }; +} +// This is different from the above corresponding function +// because it operates on data coming from the database +// as opposed to that coming from the DN. +function formatSupporterRankUpEmail(notification, extras) { + const { entityId: rank, metadata: { supportingUserId } } = notification; + return { + type: NotificationType.SupporterRankUp, + rank, + sendingUser: extras.users[supportingUserId] + }; +} +function formatSupportingRankUp(notification, metadata) { + // Receiving user + const userId = notification.initiator; + const user = metadata.users[userId]; + return { + type: NotificationType.SupportingRankUp, + rank: notification.metadata.rank, + receivingUser: user + }; +} +function formatSupporterDethroned(notification, metadata) { + return { + type: NotificationType.SupporterDethroned, + receivingUser: notification.initiator, + newTopSupporter: metadata.users[notification.metadata.newTopSupporterUserId], + supportedUser: metadata.users[notification.metadata.supportedUserId], + oldAmount: formatWei(new BN(notification.metadata.oldAmount)), + newAmount: formatWei(new BN(notification.metadata.newAmount)) + }; +} +// This is different from the above corresponding function +// because it operates on data coming from the database +// as opposed to that coming from the DN. +function formatSupportingRankUpEmail(notification, extras) { + const { entityId: rank, metadata: { supportedUserId } } = notification; + return { + type: NotificationType.SupportingRankUp, + rank, + receivingUser: extras.users[supportedUserId] + }; +} +// Copied directly from AudiusClient +const notificationResponseMap = { + [NotificationType.Follow]: formatFollow, + [NotificationType.Favorite.track]: (notification, metadata) => { + const track = metadata.tracks[notification.entityId]; + return formatFavorite(notification, metadata, { + type: Entity.Track, + name: track.title + }); + }, + [NotificationType.Favorite.playlist]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + return formatFavorite(notification, metadata, { + type: Entity.Playlist, + name: collection.playlist_name + }); + }, + [NotificationType.Favorite.album]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + return formatFavorite(notification, metadata, { + type: Entity.Album, + name: collection.playlist_name + }); + }, + [NotificationType.Repost.track]: (notification, metadata) => { + const track = metadata.tracks[notification.entityId]; + return formatRepost(notification, metadata, { + type: Entity.Track, + name: track.title + }); + }, + [NotificationType.Repost.playlist]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + return formatRepost(notification, metadata, { + type: Entity.Playlist, + name: collection.playlist_name + }); + }, + [NotificationType.Repost.album]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + return formatRepost(notification, metadata, { + type: Entity.Album, + name: collection.playlist_name + }); + }, + [NotificationType.Create.track]: (notification, metadata) => { + const trackId = notification.actions[0].actionEntityId; + const track = metadata.tracks[trackId]; + const count = notification.actions.length; + const user = metadata.users[notification.entityId]; + const users = [{ name: user.name, image: user.thumbnail }]; + return formatUserSubscription(notification, metadata, { type: Entity.Track, count, name: track.title }, users); + }, + [NotificationType.Create.album]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + const users = notification.actions.map((action) => { + const userId = action.actionEntityId; + const user = metadata.users[userId]; + return { name: user.name, image: user.thumbnail }; + }); + return formatUserSubscription(notification, metadata, { type: Entity.Album, count: 1, name: collection.playlist_name }, users); + }, + [NotificationType.Create.playlist]: (notification, metadata) => { + const collection = metadata.collections[notification.entityId]; + const users = notification.actions.map((action) => { + const userId = action.actionEntityId; + const user = metadata.users[userId]; + return { name: user.name, image: user.thumbnail }; + }); + return formatUserSubscription(notification, metadata, { type: Entity.Playlist, count: 1, name: collection.playlist_name }, users); + }, + [NotificationType.RemixCreate]: formatRemixCreate, + [NotificationType.RemixCosign]: formatRemixCosign, + [NotificationType.TrendingTrack]: formatTrendingTrack, + [NotificationType.ChallengeReward]: formatChallengeReward, + [NotificationType.Reaction]: formatReaction, + [NotificationType.TipReceive]: formatTipReceive, + [NotificationType.SupporterRankUp]: formatSupporterRankUp, + [NotificationType.SupportingRankUp]: formatSupportingRankUp, + [NotificationType.SupporterDethroned]: formatSupporterDethroned, + [NotificationType.Announcement]: formatAnnouncement, + [NotificationType.MilestoneRepost]: formatMilestone('repost'), + [NotificationType.MilestoneFavorite]: formatMilestone('favorite'), + [NotificationType.MilestoneListen]: formatMilestone('listen'), + [NotificationType.MilestoneFollow]: formatMilestone('follow'), + [NotificationType.AddTrackToPlaylist]: (notification, metadata) => { + return formatAddTrackToPlaylist(notification, metadata); + } +}; +const emailNotificationResponseMap = { + ...notificationResponseMap, + [NotificationType.Reaction]: formatReactionEmail, + [NotificationType.TipReceive]: formatTipReceiveEmail, + [NotificationType.SupporterRankUp]: formatSupporterRankUpEmail, + [NotificationType.SupportingRankUp]: formatSupportingRankUpEmail +}; +const NewFavoriteTitle = 'New Favorite'; +const NewRepostTitle = 'New Repost'; +const NewFollowerTitle = 'New Follower'; +const NewMilestoneTitle = 'Congratulations! 🎉'; +const NewSubscriptionUpdateTitle = 'New Artist Update'; +const TrendingTrackTitle = 'Congrats - You’re Trending! 📈'; +const RemixCreateTitle = 'New Remix Of Your Track ♻️'; +const RemixCosignTitle = 'New Track Co-Sign! 🔥'; +const AddTrackToPlaylistTitle = 'Your track got on a playlist! 💿'; +const TipReceiveTitle = 'You Received a Tip!'; +const DethronedTitle = "👑 You've Been Dethroned!"; +const challengeInfoMap = { + 'profile-completion': { + title: '✅️ Complete your Profile', + amount: 1 + }, + 'listen-streak': { + title: '🎧 Listening Streak: 7 Days', + amount: 1 + }, + 'track-upload': { + title: '🎶 Upload 5 Tracks', + amount: 1 + }, + referrals: { + title: '📨 Invite your Friends', + amount: 1 + }, + referred: { + title: '📨 Invite your Friends', + amount: 1 + }, + 'ref-v': { + title: '📨 Invite your Fans', + amount: 1 + }, + 'connect-verified': { + title: '✅️ Link Verified Accounts', + amount: 5 + }, + 'mobile-install': { + title: '📲 Get the App', + amount: 1 + }, + 'send-first-tip': { + title: '🤑 Send Your First Tip', + amount: 2 + }, + 'first-playlist': { + title: '🎼 Create a Playlist', + amount: 2 + } +}; +const makeReactionTitle = (notification) => `${capitalize(notification.reactingUser.name)} reacted`; +const makeSupportingOrSupporterTitle = (notification) => `#${notification.rank} Top Supporter`; +const notificationResponseTitleMap = { + [NotificationType.Follow]: () => NewFollowerTitle, + [NotificationType.Favorite.track]: () => NewFavoriteTitle, + [NotificationType.Favorite.playlist]: () => NewFavoriteTitle, + [NotificationType.Favorite.album]: () => NewFavoriteTitle, + [NotificationType.Repost.track]: () => NewRepostTitle, + [NotificationType.Repost.playlist]: () => NewRepostTitle, + [NotificationType.Repost.album]: () => NewRepostTitle, + [NotificationType.Create.track]: () => NewSubscriptionUpdateTitle, + [NotificationType.Create.album]: () => NewSubscriptionUpdateTitle, + [NotificationType.Create.playlist]: () => NewSubscriptionUpdateTitle, + [NotificationType.MilestoneListen]: () => NewMilestoneTitle, + [NotificationType.Milestone]: () => NewMilestoneTitle, + [NotificationType.TrendingTrack]: () => TrendingTrackTitle, + [NotificationType.RemixCreate]: () => RemixCreateTitle, + [NotificationType.RemixCosign]: () => RemixCosignTitle, + [NotificationType.ChallengeReward]: (notification) => challengeInfoMap[notification.challengeId].title, + [NotificationType.AddTrackToPlaylist]: () => AddTrackToPlaylistTitle, + [NotificationType.Reaction]: makeReactionTitle, + [NotificationType.TipReceive]: () => TipReceiveTitle, + [NotificationType.SupporterRankUp]: makeSupportingOrSupporterTitle, + [NotificationType.SupportingRankUp]: makeSupportingOrSupporterTitle, + [NotificationType.SupporterDethroned]: () => DethronedTitle +}; +function formatEmailNotificationProps(notifications, extras) { + const emailNotificationProps = notifications.map((notification) => { + const mapNotification = emailNotificationResponseMap[notification.type]; + return mapNotification(notification, extras); + }); + return emailNotificationProps; +} +// TODO (DM) - unify this with the email messages +const pushNotificationMessagesMap = { + [notificationTypes.Favorite.base](notification) { + const [user] = notification.users; + return `${user.name} favorited your ${notification.entity.type.toLowerCase()} ${notification.entity.name}`; + }, + [notificationTypes.Repost.base](notification) { + const [user] = notification.users; + return `${user.name} reposted your ${notification.entity.type.toLowerCase()} ${notification.entity.name}`; + }, + [notificationTypes.Follow](notification) { + const [user] = notification.users; + return `${user.name} followed you`; + }, + [notificationTypes.Announcement.base](notification) { + return notification.text; + }, + [notificationTypes.Milestone](notification) { + if (notification.entity) { + const entity = notification.entity.type.toLowerCase(); + return `Your ${entity} ${notification.entity.name} has reached over ${notification.value.toLocaleString()} ${notification.achievement}s`; + } + else { + return `You have reached over ${notification.value.toLocaleString()} Followers `; + } + }, + [notificationTypes.Create.base](notification) { + const [user] = notification.users; + const type = notification.entity.type.toLowerCase(); + if (notification.entity.type === actionEntityTypes.Track && + !isNaN(notification.entity.count) && + notification.entity.count > 1) { + return `${user.name} released ${notification.entity.count} new ${type}s`; + } + return `${user.name} released a new ${type} ${notification.entity.name}`; + }, + [notificationTypes.RemixCreate](notification) { + return `New remix of your track ${notification.parentTrack.title}: ${notification.remixUser.name} uploaded ${notification.remixTrack.title}`; + }, + [notificationTypes.RemixCosign](notification) { + return `${notification.parentTrackUser.name} Co-Signed your Remix of ${notification.remixTrack.title}`; + }, + [notificationTypes.TrendingTrack](notification) { + const rank = notification.rank; + const rankSuffix = getRankSuffix(rank); + return `Your Track ${notification.entity.title} is ${notification.rank}${rankSuffix} on Trending Right Now! 🍾`; + }, + [notificationTypes.ChallengeReward](notification) { + return notification.challengeId === 'referred' + ? `You’ve received ${challengeInfoMap[notification.challengeId].amount} $AUDIO for being referred! Invite your friends to join to earn more!` + : `You’ve earned ${challengeInfoMap[notification.challengeId].amount} $AUDIO for completing this challenge!`; + }, + [notificationTypes.AddTrackToPlaylist](notification) { + return `${notification.playlistOwner.name} added ${notification.track.title} to their playlist ${notification.playlist.playlist_name}`; + }, + [notificationTypes.Reaction](notification) { + return `${capitalize(notification.reactingUser.name)} reacted to your tip of ${notification.amount} $AUDIO`; + }, + [notificationTypes.SupporterRankUp](notification) { + return `${capitalize(notification.sendingUser.name)} became your #${notification.rank} Top Supporter!`; + }, + [notificationTypes.SupportingRankUp](notification) { + return `You're now ${notification.receivingUser.name}'s #${notification.rank} Top Supporter!`; + }, + [notificationTypes.TipReceive](notification) { + return `${capitalize(notification.sendingUser.name)} sent you a tip of ${notification.amount} $AUDIO`; + }, + [notificationTypes.SupporterDethroned](notification) { + return `${capitalize(notification.newTopSupporter.handle)} dethroned you as ${notification.supportedUser.name}'s #1 Top Supporter! Tip to reclaim your spot?`; + } +}; +module.exports = { + challengeInfoMap, + getRankSuffix, + formatEmailNotificationProps, + notificationResponseMap, + notificationResponseTitleMap, + pushNotificationMessagesMap +}; diff --git a/packages/identity-service/build/src/notifications/index.js b/packages/identity-service/build/src/notifications/index.js new file mode 100644 index 00000000000..71adaeade85 --- /dev/null +++ b/packages/identity-service/build/src/notifications/index.js @@ -0,0 +1,433 @@ +"use strict"; +const Bull = require('bull'); +const config = require('../config.js'); +const models = require('../models'); +const fs = require('fs'); +const { logger } = require('../logging'); +const { indexMilestones } = require('./milestoneProcessing'); +const { updateBlockchainIds, getHighestSlot, getHighestBlockNumber +// calculateTrackListenMilestones, +// calculateTrackListenMilestonesFromDiscovery + } = require('./utils'); +const { processEmailNotifications } = require('./sendNotificationEmails'); +const { processDownloadAppEmail } = require('./sendDownloadAppEmails'); +const { pushAnnouncementNotifications } = require('./pushAnnouncementNotifications'); +const { notificationJobType, solanaNotificationJobType, announcementJobType, unreadEmailJobType, downloadEmailJobType } = require('./constants'); +const { drainPublishedMessages, drainPublishedSolanaMessages } = require('./notificationQueue'); +const emailCachePath = './emailCache'; +const processNotifications = require('./processNotifications/index.js'); +const sendNotifications = require('./sendNotifications/index.js'); +const audiusLibsWrapper = require('../audiusLibsInstance'); +const NOTIFICATION_ANNOUNCEMENTS_INTERVAL_SEC = 30 * 1000; +const NOTIFICATION_JOB_LAST_SUCCESS_KEY = 'notifications:last-success'; +const NOTIFICATION_SOLANA_JOB_LAST_SUCCESS_KEY = 'notifications:solana:last-success'; +const NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY = 'notifications:emails:last-success'; +const NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY = 'notifications:announcements:last-success'; +const NOTIFICATION_DOWNLOAD_EMAIL_JOB_LAST_SUCCESS_KEY = 'notifications:download-emails:last-success'; +// Reference Bull Docs: https://github.com/OptimalBits/bull/blob/develop/REFERENCE.md#queue +const defaultJobOptions = { + removeOnComplete: true, + removeOnFail: true +}; +const bullSettings = { + lockDuration: 60 /** min */ * 60 /** sec */ * 1000 /** ms */, + maxStalledCount: 0 +}; +class NotificationProcessor { + constructor({ errorHandler }) { + this.notifQueue = new Bull(`notification-queue-${Date.now()}`, { + redis: { + port: config.get('redisPort'), + host: config.get('redisHost') + }, + defaultJobOptions, + bullSettings + }); + this.solanaNotifQueue = new Bull(`solana-notification-queue-${Date.now()}`, { + redis: { + port: config.get('redisPort'), + host: config.get('redisHost') + }, + defaultJobOptions + }); + this.emailQueue = new Bull(`email-queue-${Date.now()}`, { + redis: { + port: config.get('redisPort'), + host: config.get('redisHost') + }, + defaultJobOptions + }); + this.announcementQueue = new Bull(`announcement-queue-${Date.now()}`, { + redis: { + port: config.get('redisPort'), + host: config.get('redisHost') + }, + defaultJobOptions + }); + this.downloadEmailQueue = new Bull(`download-email-queue-${Date.now()}`, { + redis: { + port: config.get('redisPort'), + host: config.get('redisHost') + }, + defaultJobOptions + }); + if (errorHandler) { + this.errorHandler = errorHandler; + } + else { + this.errorHandler = () => null; + } + } + /** + * Initialize notification and milestone processing + * 1. Clear the notifQueue and emailQueue + * 2. Update all blockchainId's in the users table where blockchainId is null + * 3. Process notif queue and recursively add notif job on queue after 3 seconds + * Process email queue and recursively add email job on queue after 3 seconds + * @param {Object} audiusLibs libs instance + * @param {Object} expressApp express app context + * @param {Object} redis redis connection + */ + async init(audiusLibs, expressApp, redis) { + // Clear any pending notif jobs + await this.notifQueue.empty(); + await this.emailQueue.empty(); + await this.downloadEmailQueue.empty(); + this.redis = redis; + this.mg = expressApp.get('sendgrid'); + // Index all blockchain ids + this.idUpdateTask = updateBlockchainIds(); + // Notification processing job + // Indexes network notifications + this.notifQueue.process(async (job) => { + let error = null; + const minBlock = job.data.minBlock; + try { + if (!minBlock && minBlock !== 0) + throw new Error('no min block'); + // Re-enable for development as needed + // this.emailQueue.add({ type: 'unreadEmailJob' }) + const oldMaxBlockNumber = await this.redis.get('maxBlockNumber'); + let maxBlockNumber = null; + // Index notifications and milestones + if (minBlock < oldMaxBlockNumber) { + logger.debug('notification queue processing error - tried to process a minBlock < oldMaxBlockNumber', minBlock, oldMaxBlockNumber); + maxBlockNumber = oldMaxBlockNumber; + } + else { + const optimizelyClient = expressApp.get('optimizelyClient'); + maxBlockNumber = await this.indexAll(audiusLibs, optimizelyClient, minBlock, oldMaxBlockNumber); + } + // Update cached max block number + await this.redis.set('maxBlockNumber', maxBlockNumber); + // Record success + await this.redis.set(NOTIFICATION_JOB_LAST_SUCCESS_KEY, new Date().toISOString()); + // Restart job with updated startBlock + await this.notifQueue.add({ + type: notificationJobType, + minBlock: maxBlockNumber + }, { + jobId: `${notificationJobType}:${Date.now()}` + }); + } + catch (e) { + error = e; + logger.error(`Restarting due to error indexing notifications : ${e}`); + this.errorHandler(e); + // Restart job with same startBlock + await this.notifQueue.add({ + type: notificationJobType, + minBlock: minBlock + }, { + jobId: `${notificationJobType}:${Date.now()}` + }); + } + }); + // Solana notification processing job + // Indexes solana notifications + this.solanaNotifQueue.process(async (job) => { + let error = null; + const MIN_SOLANA_SLOT = config.get('minSolanaNotificationSlot'); + const minSlot = Math.max(MIN_SOLANA_SLOT, job.data.minSlot); + try { + if (!minSlot && minSlot !== 0) + throw new Error('no min slot'); + const oldMaxSlot = await this.redis.get('maxSlot'); + let maxSlot = null; + // Index notifications + if (minSlot < oldMaxSlot) { + logger.error('solana notification queue processing error - tried to process a minSlot < oldMaxSlot', minSlot, oldMaxSlot); + maxSlot = oldMaxSlot; + } + else { + const optimizelyClient = expressApp.get('optimizelyClient'); + maxSlot = await this.indexAllSolanaNotifications(audiusLibs, optimizelyClient, minSlot, oldMaxSlot); + // If we got an unexpectedly low maxSlot, use min as max + if (maxSlot < minSlot) { + logger.error('solana notification queue processing error - unexpectedly got maxSlot < minSlot from Discovery, using old minSlot as max', minSlot, oldMaxSlot); + maxSlot = minSlot; + } + } + // Update cached max slot number + await this.redis.set('maxSlot', maxSlot); + // Record success + await this.redis.set(NOTIFICATION_SOLANA_JOB_LAST_SUCCESS_KEY, new Date().toISOString()); + // Restart job with updated starting slot + await this.solanaNotifQueue.add({ + type: solanaNotificationJobType, + minSlot: maxSlot + }, { + jobId: `${solanaNotificationJobType}:${Date.now()}` + }); + } + catch (e) { + error = e; + logger.error(`Restarting due to error indexing solana notifications : ${e}`); + this.errorHandler(e); + // Restart job with same starting slot + await this.solanaNotifQueue.add({ + type: solanaNotificationJobType, + minSlot: minSlot + }, { + jobId: `${solanaNotificationJobType}:${Date.now()}` + }); + } + }); + // Email notification queue + this.emailQueue.process(async (job) => { + logger.info('processEmailNotifications'); + let error = null; + try { + await processEmailNotifications(expressApp, audiusLibs); + await this.redis.set(NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY, new Date().toISOString()); + } + catch (e) { + error = e; + logger.error(`processEmailNotifications - Problem with processing emails: ${e}`); + this.errorHandler(e); + } + await this.emailQueue.add({ type: unreadEmailJobType }, { jobId: `${unreadEmailJobType}:${Date.now()}` }); + }); + // Download Email notification queue + this.downloadEmailQueue.process(async (job) => { + logger.debug('processDownloadEmails'); + let error = null; + try { + await processDownloadAppEmail(expressApp, audiusLibs); + await this.redis.set(NOTIFICATION_DOWNLOAD_EMAIL_JOB_LAST_SUCCESS_KEY, new Date().toISOString()); + } + catch (e) { + error = e; + logger.error(`processDownloadEmails - Problem with processing emails: ${e}`); + this.errorHandler(e); + } + await this.downloadEmailQueue.add({ type: downloadEmailJobType }, { jobId: `${downloadEmailJobType}:${Date.now()}` }); + }); + // Announcement push notifications queue + this.announcementQueue.process(async (job) => { + logger.info('pushAnnouncementNotifications'); + let error = null; + try { + await pushAnnouncementNotifications(); + await this.redis.set(NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY, new Date().toISOString()); + } + catch (e) { + error = e; + logger.error(`pushAnnouncementNotifications - Problem with processing announcements: ${e}`); + this.errorHandler(e); + } + // Delay 30s + await new Promise((resolve) => setTimeout(resolve, NOTIFICATION_ANNOUNCEMENTS_INTERVAL_SEC)); + await this.announcementQueue.add({ type: announcementJobType }, { jobId: `${announcementJobType}:${Date.now()}` }); + }); + // Add initial jobs to the queue + if (!fs.existsSync(emailCachePath)) { + fs.mkdirSync(emailCachePath); + } + const startBlock = await getHighestBlockNumber(); + logger.info(`Starting with block ${startBlock}`); + await this.notifQueue.add({ + minBlock: startBlock, + type: notificationJobType + }, { + jobId: `${notificationJobType}:${Date.now()}` + }); + const startSlot = await getHighestSlot(); + logger.info(`Starting with slot ${startSlot}`); + await this.solanaNotifQueue.add({ + minSlot: startSlot, + type: solanaNotificationJobType + }, { + jobId: `${solanaNotificationJobType}:${Date.now()}` + }); + await this.emailQueue.add({ type: unreadEmailJobType }, { jobId: `${unreadEmailJobType}:${Date.now()}` }); + await this.announcementQueue.add({ type: announcementJobType }, { jobId: `${announcementJobType}:${Date.now()}` }); + await this.downloadEmailQueue.add({ type: downloadEmailJobType }, { jobId: `${downloadEmailJobType}:${Date.now()}` }); + } + /** + * 1. Get the total listens for the most reecently listened to tracks + * 2. Query the discprov for new notifications starting at minBlock + * 3. Combine owner object from discprov with track listen counts + * 4. Process notifications + * 5. Process milestones + * @param {AudiusLibs} audiusLibs + * @param {OptimizelyClient} optimizelyClient + * @param {number} minBlock min start block to start querying discprov for new notifications + * @param {number} oldMaxBlockNumber last max black number seen + */ + async indexAll(audiusLibs, optimizelyClient, minBlock, oldMaxBlockNumber) { + const startDate = Date.now(); + const startTime = process.hrtime(); + let time = startDate; + logger.info(`notifications main indexAll job - minBlock: ${minBlock}, oldMaxBlockNumber: ${oldMaxBlockNumber}, startDate: ${startDate}, startTime: ${new Date()}`); + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + const trackIdOwnersToRequestList = []; + // These track_id get parameters will be used to retrieve track owner info + // This is required since there is no guarantee that there are indeed notifications for this user + // The owner info is then used to target listenCount milestone notifications + // Timeout of 5 minutes + const timeout = 5 /* min */ * 60 /* sec */ * 1000; /* ms */ + const notificationsFromDN = await discoveryProvider.getNotifications(minBlock, trackIdOwnersToRequestList, timeout); + const { info: metadata, owners, milestones } = notificationsFromDN; + let notifications = await filterOutAbusiveUsers(notificationsFromDN.notifications); + // Ensure we don't process any notifs + // that are already in the db + const latestBlock = await models.Notification.max('blocknumber'); + notifications = notifications.filter((n) => n.blocknumber >= latestBlock); + logger.info(`notifications main indexAll job - query notifications from discovery node complete in ${Date.now() - time}ms`); + time = Date.now(); + // Use a single transaction + const tx = await models.sequelize.transaction(); + try { + // Populate owners, used to index in milestone generation + const listenCountWithOwners = []; + // Insert the notifications into the DB to make it easy for users to query for their grouped notifications + await processNotifications(notifications, tx, optimizelyClient); + logger.info(`notifications main indexAll job - processNotifications complete in ${Date.now() - time}ms`); + time = Date.now(); + // Fetch additional metadata from DP, query for the user's notification settings, and send push notifications (mobile/browser) + await sendNotifications(audiusLibs, notifications, tx, optimizelyClient); + logger.info(`notifications main indexAll job - sendNotifications complete in ${Date.now() - time}ms`); + time = Date.now(); + await indexMilestones(milestones, owners, metadata, listenCountWithOwners, audiusLibs, tx); + logger.info(`notifications main indexAll job - indexMilestones complete in ${Date.now() - time}ms`); + time = Date.now(); + // Commit + await tx.commit(); + logger.info(`notifications main indexAll job - dbCommit complete in ${Date.now() - time}ms`); + time = Date.now(); + // actually send out push notifications + const numProcessedNotifs = await drainPublishedMessages(logger, optimizelyClient); + logger.info(`notifications main indexAll job - drainPublishedMessages complete - processed ${numProcessedNotifs} notifs in ${Date.now() - time}ms`); + const endTime = process.hrtime(startTime); + const duration = Math.round(endTime[0] * 1e3 + endTime[1] * 1e-6); + logger.info(`notifications main indexAll job finished - minBlock: ${minBlock}, startDate: ${startDate}, duration: ${duration}, notifications: ${notifications.length}`); + } + catch (e) { + logger.error(`Error indexing notification in ${Date.now() - startDate}ms ${e}`); + logger.error(e.stack); + await tx.rollback(); + } + return metadata.max_block_number; + } + /** + * Doing the solana notification things + * @param {AudiusLibs} audiusLibs + * @param {OptimizelyClient} optimizelyClient + * @param {number} minSlot min slot number to start querying discprov for new notifications + * @param {number} oldMaxSlot last max slot number seen + */ + async indexAllSolanaNotifications(audiusLibs, optimizelyClient, minSlot, oldMaxSlot) { + const startDate = Date.now(); + const startTime = process.hrtime(); + let time = startDate; + const logLabel = 'notifications main indexAllSolanaNotifications job'; + logger.info(`${logLabel} - minSlot: ${minSlot}, oldMaxSlot: ${oldMaxSlot}, startDate: ${startDate}, startTime: ${startTime}`); + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + // Timeout of 2 minutes + const timeout = 2 /* min */ * 60 /* sec */ * 1000; /* ms */ + const notificationsFromDN = await discoveryProvider.getSolanaNotifications(minSlot, timeout); + const metadata = notificationsFromDN.info; + let notifications = await filterOutAbusiveUsers(notificationsFromDN.notifications); + // Ensure we don't process any notifs + // that are already in the db + const latestSlot = await models.SolanaNotification.max('slot'); + notifications = notifications.filter((n) => n.slot >= latestSlot); + logger.info(`${logLabel} - query solana notifications from discovery node complete in ${Date.now() - time}ms`); + time = Date.now(); + // Use a single transaction + const tx = await models.sequelize.transaction(); + try { + // Insert the solana notifications into the DB + const processedNotifications = await processNotifications(notifications, tx, optimizelyClient); + logger.info(`${logLabel} - processNotifications complete in ${Date.now() - time}ms`); + time = Date.now(); + // Fetch additional metadata from DP, query for the user's notification settings, and send push notifications (mobile/browser) + await sendNotifications(audiusLibs, processedNotifications, tx, optimizelyClient); + logger.info(`${logLabel} - sendNotifications complete in ${Date.now() - time}ms`); + time = Date.now(); + // Commit + await tx.commit(); + logger.info(`${logLabel} - dbCommit complete in ${Date.now() - time}ms`); + time = Date.now(); + // actually send out push notifications + const numProcessedNotifs = await drainPublishedSolanaMessages(logger, optimizelyClient); + logger.info(`${logLabel} - drainPublishedSolanaMessages complete - processed ${numProcessedNotifs} notifs in ${Date.now() - time}ms`); + const endTime = process.hrtime(startTime); + const duration = Math.round(endTime[0] * 1e3 + endTime[1] * 1e-6); + logger.info(`${logLabel} finished - minSlot: ${minSlot}, startDate: ${startDate}, duration: ${duration}, notifications: ${notifications.length}`); + } + catch (e) { + logger.error(`Error indexing solana notification ${e}`); + logger.error(e.stack); + await tx.rollback(); + } + return metadata.max_slot_number; + } +} +/** + * Filters out notifications whose initiators are deemed abusive. + * @param {Object[]} notifications + * @returns {Promise} notifications - after having filtered out notifications from bad initiators + */ +async function filterOutAbusiveUsers(notifications) { + const initiatorIds = notifications.map(({ initiator }) => initiator); + const userEntityIds = notifications + .filter(({ metadata }) => metadata && metadata.entity_type === 'user') + .map(({ metadata }) => metadata.entity_id); + const allUserIds = initiatorIds.concat(userEntityIds); + const users = await models.User.findAll({ + where: { + blockchainUserId: { [models.Sequelize.Op.in]: allUserIds } + }, + attributes: [ + 'blockchainUserId', + 'isBlockedFromNotifications', + 'isBlockedFromRelay' + ] + }); + const usersAbuseMap = {}; + users.forEach((user) => { + usersAbuseMap[user.blockchainUserId] = + user.isBlockedFromRelay || user.isBlockedFromNotifications; + }); + const result = notifications.filter((notification) => { + const isInitiatorAbusive = usersAbuseMap[notification.initiator.toString()]; + const isUserEntityAbusive = notification.metadata && + notification.metadata.entity_type === 'user' && + notification.metadata.entity_id && + usersAbuseMap[notification.metadata.entity_id.toString()]; + return !isInitiatorAbusive && !isUserEntityAbusive; + }); + logger.info(`notifications | index.js | Filtered out ${notifications.length - result.length} bad initiators out of ${notifications.length} total.`); + return result; +} +module.exports = NotificationProcessor; +module.exports.NOTIFICATION_JOB_LAST_SUCCESS_KEY = + NOTIFICATION_JOB_LAST_SUCCESS_KEY; +module.exports.NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY = + NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY; +module.exports.NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY = + NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY; +module.exports.NOTIFICATION_DOWNLOAD_EMAIL_JOB_LAST_SUCCESS_KEY = + NOTIFICATION_DOWNLOAD_EMAIL_JOB_LAST_SUCCESS_KEY; diff --git a/packages/identity-service/build/src/notifications/milestoneProcessing.js b/packages/identity-service/build/src/notifications/milestoneProcessing.js new file mode 100644 index 00000000000..cebebc1cec9 --- /dev/null +++ b/packages/identity-service/build/src/notifications/milestoneProcessing.js @@ -0,0 +1,311 @@ +"use strict"; +const models = require('../models'); +const { logger } = require('../logging'); +const { deviceType, notificationTypes, actionEntityTypes } = require('./constants'); +const { publish } = require('./notificationQueue'); +const { shouldNotifyUser } = require('./utils'); +const { fetchNotificationMetadata } = require('./fetchNotificationMetadata'); +const { notificationResponseMap, notificationResponseTitleMap, pushNotificationMessagesMap } = require('./formatNotificationMetadata'); +// Base milestone list shared across all types +// Each type can be configured as needed +const baseMilestoneList = [ + 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 20000, 50000, 100000, 1000000 +]; +const followerMilestoneList = baseMilestoneList; +// Repost milestone list shared across tracks/albums/playlists +const repostMilestoneList = baseMilestoneList; +// Favorite milestone list shared across tracks/albums/playlists +const favoriteMilestoneList = baseMilestoneList; +// Track listen milestone list +const trackListenMilestoneList = baseMilestoneList; +async function indexMilestones(milestones, owners, metadata, listenCounts, audiusLibs, tx) { + // Index follower milestones into notifications table + const timestamp = new Date(); + const blocknumber = metadata.max_block_number; + // Index follower milestones + await updateFollowerMilestones(milestones.follower_counts, blocknumber, timestamp, audiusLibs, tx); + // Index repost milestones + await updateRepostMilestones(milestones.repost_counts, owners, blocknumber, timestamp, audiusLibs, tx); + // Index favorite milestones + await updateFavoriteMilestones(milestones.favorite_counts, owners, blocknumber, timestamp, audiusLibs, tx); +} +/** + * + * Follower Milestones + * + */ +async function updateFollowerMilestones(followerCounts, blocknumber, timestamp, audiusLibs, tx) { + const followersAddedDictionary = followerCounts; + const usersWithNewFollowers = Object.keys(followersAddedDictionary); + const followerMilestoneNotificationType = notificationTypes.MilestoneFollow; + // Parse follower milestones + for (const targetUser of usersWithNewFollowers) { + if (followersAddedDictionary.hasOwnProperty(targetUser)) { + const currentFollowerCount = followersAddedDictionary[targetUser]; + for (let i = followerMilestoneList.length; i >= 0; i--) { + const milestoneValue = followerMilestoneList[i]; + if (currentFollowerCount === milestoneValue) { + // MilestoneFollow + // userId=user achieving milestone + // entityId=milestoneValue, number of followers + // actionEntityType=User + // actionEntityId=milestoneValue, number of followers + await _processMilestone(followerMilestoneNotificationType, targetUser, milestoneValue, actionEntityTypes.User, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } + } +} +/** + * + * Repost Milestones + * + */ +async function updateRepostMilestones(repostCounts, owners, blocknumber, timestamp, audiusLibs, tx) { + const tracksReposted = Object.keys(repostCounts.tracks); + const albumsReposted = Object.keys(repostCounts.albums); + const playlistsReposted = Object.keys(repostCounts.playlists); + const repostMilestoneNotificationType = notificationTypes.MilestoneRepost; + for (const repostedTrackId of tracksReposted) { + const trackOwnerId = owners.tracks[repostedTrackId]; + const trackRepostCount = repostCounts.tracks[repostedTrackId]; + for (let i = repostMilestoneList.length; i >= 0; i--) { + const milestoneValue = repostMilestoneList[i]; + if (trackRepostCount === milestoneValue) { + await _processMilestone(repostMilestoneNotificationType, trackOwnerId, repostedTrackId, actionEntityTypes.Track, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } + for (const repostedAlbumId of albumsReposted) { + const albumOwnerId = owners.albums[repostedAlbumId]; + const albumRepostCount = repostCounts.albums[repostedAlbumId]; + for (let j = repostMilestoneList.length; j >= 0; j--) { + const milestoneValue = repostMilestoneList[j]; + if (albumRepostCount === milestoneValue) { + await _processMilestone(repostMilestoneNotificationType, albumOwnerId, repostedAlbumId, actionEntityTypes.Album, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } + for (const repostedPlaylistId of playlistsReposted) { + const playlistOwnerId = owners.playlists[repostedPlaylistId]; + const playlistRepostCount = repostCounts.playlists[repostedPlaylistId]; + for (let k = repostMilestoneList.length; k >= 0; k--) { + const milestoneValue = repostMilestoneList[k]; + if (playlistRepostCount === milestoneValue) { + await _processMilestone(repostMilestoneNotificationType, playlistOwnerId, repostedPlaylistId, actionEntityTypes.Playlist, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } +} +/** + * + * Favorites Milestones + * + */ +async function updateFavoriteMilestones(favoriteCounts, owners, blocknumber, timestamp, audiusLibs, tx) { + const tracksFavorited = Object.keys(favoriteCounts.tracks); + const albumsFavorited = Object.keys(favoriteCounts.albums); + const playlistsFavorited = Object.keys(favoriteCounts.playlists); + const favoriteMilestoneNotificationType = notificationTypes.MilestoneFavorite; + for (const favoritedTrackId of tracksFavorited) { + const trackOwnerId = owners.tracks[favoritedTrackId]; + const trackFavoriteCount = favoriteCounts.tracks[favoritedTrackId]; + for (let i = favoriteMilestoneList.length; i >= 0; i--) { + const milestoneValue = favoriteMilestoneList[i]; + if (trackFavoriteCount === milestoneValue) { + await _processMilestone(favoriteMilestoneNotificationType, trackOwnerId, favoritedTrackId, actionEntityTypes.Track, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } + for (const favoritedAlbumId of albumsFavorited) { + const albumOwnerId = owners.albums[favoritedAlbumId]; + const albumFavoriteCount = favoriteCounts.albums[favoritedAlbumId]; + for (let j = favoriteMilestoneList.length; j >= 0; j--) { + const milestoneValue = favoriteMilestoneList[j]; + if (albumFavoriteCount === milestoneValue) { + await _processMilestone(favoriteMilestoneNotificationType, albumOwnerId, favoritedAlbumId, actionEntityTypes.Album, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } + for (const favoritedPlaylistId of playlistsFavorited) { + const playlistOwnerId = owners.playlists[favoritedPlaylistId]; + const playlistFavoriteCount = favoriteCounts.playlists[favoritedPlaylistId]; + for (let k = favoriteMilestoneList.length; k >= 0; k--) { + const milestoneValue = favoriteMilestoneList[k]; + if (playlistFavoriteCount === milestoneValue) { + await _processMilestone(favoriteMilestoneNotificationType, playlistOwnerId, favoritedPlaylistId, actionEntityTypes.Playlist, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } +} +/** + * + * Listens Milestones + * + */ +async function updateTrackListenMilestones(listenCounts, blocknumber, timestamp, audiusLibs, tx) { + // eslint-disable-line no-unused-vars + const listensMilestoneNotificationType = notificationTypes.MilestoneListen; + for (const entry of listenCounts) { + const trackListenCount = Number.parseInt(entry.listenCount); + for (let i = trackListenMilestoneList.length; i >= 0; i--) { + const milestoneValue = trackListenMilestoneList[i]; + if (trackListenCount === milestoneValue || + (trackListenCount >= milestoneValue && + trackListenCount <= milestoneValue * 1.1)) { + const trackId = entry.trackId; + const ownerId = entry.owner; + await _processMilestone(listensMilestoneNotificationType, ownerId, trackId, actionEntityTypes.Track, milestoneValue, blocknumber, timestamp, audiusLibs, tx); + break; + } + } + } +} +async function _processMilestone(milestoneType, userId, entityId, entityType, milestoneValue, blocknumber, timestamp, audiusLibs, tx) { + // Skip notification based on user configuration + const { notifyMobile, notifyBrowserPush } = await shouldNotifyUser(userId, 'milestonesAndAchievements'); + let newMilestone = false; + const existingMilestoneQuery = await models.Notification.findAll({ + where: { + userId: userId, + type: milestoneType, + entityId: entityId + }, + include: [ + { + model: models.NotificationAction, + as: 'actions', + where: { + actionEntityType: entityType, + actionEntityId: milestoneValue + } + } + ], + transaction: tx + }); + if (existingMilestoneQuery.length === 0) { + newMilestone = true; + // MilestoneListen/Favorite/Repost + // userId=user achieving milestone + // entityId=Entity reaching milestone, one of track/collection + // actionEntityType=Entity achieving milestone, can be track/collection + // actionEntityId=Milestone achieved + const createMilestoneTx = await models.Notification.create({ + userId: userId, + type: milestoneType, + entityId: entityId, + blocknumber, + timestamp + }, { transaction: tx }); + const notificationId = createMilestoneTx.id; + const notificationAction = await models.NotificationAction.findOne({ + where: { + notificationId, + actionEntityType: entityType, + actionEntityId: milestoneValue, + blocknumber + }, + transaction: tx + }); + if (notificationAction == null) { + await models.NotificationAction.create({ + notificationId, + actionEntityType: entityType, + actionEntityId: milestoneValue, + blocknumber + }, { + transaction: tx + }); + } + logger.info(`processMilestone - Process milestone ${userId}, type ${milestoneType}, entityId ${entityId}, type ${entityType}, milestoneValue ${milestoneValue}`); + // Destroy any unread milestone notifications of this type + entity + const milestonesToBeDeleted = await models.Notification.findAll({ + where: { + userId: userId, + type: milestoneType, + entityId: entityId, + isRead: false + }, + include: [ + { + model: models.NotificationAction, + as: 'actions', + where: { + actionEntityType: entityType, + actionEntityId: { + [models.Sequelize.Op.not]: milestoneValue + } + } + } + ] + }); + if (milestonesToBeDeleted) { + for (const milestoneToDelete of milestonesToBeDeleted) { + logger.info(`Deleting milestone: ${milestoneToDelete.id}`); + let destroyTx = await models.NotificationAction.destroy({ + where: { + notificationId: milestoneToDelete.id + }, + transaction: tx + }); + logger.info(destroyTx); + destroyTx = await models.Notification.destroy({ + where: { + id: milestoneToDelete.id + }, + transaction: tx + }); + logger.info(destroyTx); + } + } + } + // Only send a milestone push notification on the first insert to the DB + if ((notifyMobile || notifyBrowserPush) && newMilestone) { + const notifStub = { + userId: userId, + type: milestoneType, + entityId: entityId, + blocknumber, + timestamp, + actions: [ + { + actionEntityType: entityType, + actionEntityId: milestoneValue, + blocknumber + } + ] + }; + const metadata = await fetchNotificationMetadata(audiusLibs, [milestoneValue], [notifStub]); + const mapNotification = notificationResponseMap[milestoneType]; + try { + const msgGenNotif = { + ...notifStub, + ...mapNotification(notifStub, metadata) + }; + logger.debug('processMilestone - About to generate message for milestones push notification', msgGenNotif, metadata); + const msg = pushNotificationMessagesMap[notificationTypes.Milestone](msgGenNotif); + logger.debug(`processMilestone - message: ${msg}`); + const title = notificationResponseTitleMap[notificationTypes.Milestone](); + const types = []; + if (notifyMobile) + types.push(deviceType.Mobile); + if (notifyBrowserPush) + types.push(deviceType.Browser); + await publish(msg, userId, tx, true, title, types); + } + catch (e) { + // Log on error instead of failing + logger.info(`Error adding push notification to buffer: ${e}. notifStub ${JSON.stringify(notifStub)}`); + } + } +} +module.exports = { + indexMilestones +}; diff --git a/packages/identity-service/build/src/notifications/notificationQueue.js b/packages/identity-service/build/src/notifications/notificationQueue.js new file mode 100644 index 00000000000..49caa090bd9 --- /dev/null +++ b/packages/identity-service/build/src/notifications/notificationQueue.js @@ -0,0 +1,198 @@ +"use strict"; +const { deviceType, notificationTypes } = require('./constants'); +const { drainMessageObject: sendAwsSns } = require('../awsSNS'); +const { sendBrowserNotification, sendSafariNotification } = require('../webPush'); +const racePromiseWithTimeout = require('../utils/racePromiseWithTimeout.js'); +const { getRemoteFeatureVarEnabled, DISCOVERY_NOTIFICATION_MAPPING, MappingVariable } = require('../remoteConfig'); +const SEND_NOTIF_TIMEOUT_MS = 20000; // 20 sec +// TODO (DM) - move this into redis +const pushNotificationQueue = { + PUSH_NOTIFICATIONS_BUFFER: [], + PUSH_SOLANA_NOTIFICATIONS_BUFFER: [], + PUSH_ANNOUNCEMENTS_BUFFER: [] +}; +async function publish(message, userId, tx, playSound = true, title = null, types, notification) { + await addNotificationToBuffer(message, userId, tx, pushNotificationQueue.PUSH_NOTIFICATIONS_BUFFER, playSound, title, types, notification); +} +async function publishSolanaNotification(message, userId, tx, playSound = true, title = null, types, notification) { + await addNotificationToBuffer(message, userId, tx, pushNotificationQueue.PUSH_SOLANA_NOTIFICATIONS_BUFFER, playSound, title, types, notification); +} +async function publishAnnouncement(message, userId, tx, playSound = true, title = null) { + await addNotificationToBuffer(message, userId, tx, pushNotificationQueue.PUSH_ANNOUNCEMENTS_BUFFER, playSound, title); +} +async function addNotificationToBuffer(message, userId, tx, buffer, playSound, title, types, notification) { + const bufferObj = { + userId, + notificationParams: { message, title, playSound }, + types, + notification + }; + const existingEntriesCheck = buffer.filter((entry) => entry.userId === userId && + entry.notificationParams.message === message && + entry.notificationParams.title === title); + // Ensure no dups are added + if (existingEntriesCheck.length > 0) + return; + buffer.push(bufferObj); +} +/** + * Wrapper function to call `notifFn` inside `racePromiseWithTimeout()` + * + * @notice swallows any error to ensure execution continues + * @notice assumes `notifFn` always returns integer indicationg number of sent notifications + * @returns numSentNotifs + */ +async function _sendNotification(notifFn, bufferObj, logger) { + const logPrefix = `[notificationQueue:sendNotification] [${notifFn.name}] [userId ${bufferObj.userId}]`; + let numSentNotifs = 0; + try { + const start = Date.now(); + numSentNotifs = await racePromiseWithTimeout(notifFn(bufferObj), SEND_NOTIF_TIMEOUT_MS, `Timed out in ${SEND_NOTIF_TIMEOUT_MS}ms`); + logger.debug(`${logPrefix} Succeeded in ${Date.now() - start}ms`); + } + catch (e) { + // Swallow error - log and continue + logger.error(`${logPrefix} ERROR ${e.message}`); + } + return numSentNotifs || 0; +} +/** + * Same as Promise.all(items.map(item => task(item))), but it waits for + * the first {batchSize} promises to finish before starting the next batch. + */ +async function promiseAllInBatches(task, optimizelyClient, logger, items, batchSize) { + let position = 0; + let results = []; + while (position < items.length) { + const itemsForBatch = items.slice(position, position + batchSize); + results = [ + ...results, + ...(await Promise.allSettled(itemsForBatch.map((item) => task(optimizelyClient, logger, item)))) + ]; + position += batchSize; + } + return results; +} +const notificaitonTypeMapping = { + announcement: MappingVariable.PushAnnouncement, + [notificationTypes.Follow]: MappingVariable.PushFollow, + [notificationTypes.Repost.playlist]: MappingVariable.PushRepost, + [notificationTypes.Repost.album]: MappingVariable.PushRepost, + [notificationTypes.Repost.track]: MappingVariable.PushRepost, + [notificationTypes.Favorite.playlist]: MappingVariable.PushSave, + [notificationTypes.Favorite.album]: MappingVariable.PushSave, + [notificationTypes.Favorite.track]: MappingVariable.PushSave, + [notificationTypes.Create.track]: MappingVariable.PushCreate, + [notificationTypes.Create.playlist]: MappingVariable.PushCreate, + [notificationTypes.Create.album]: MappingVariable.PushCreate, + [notificationTypes.RemixCreate]: MappingVariable.PushRemix, + [notificationTypes.RemixCosign]: MappingVariable.PushCosign, + [notificationTypes.Milestone]: MappingVariable.PushMilestone, + [notificationTypes.MilestoneFollow]: MappingVariable.PushMilestone, + [notificationTypes.MilestoneRepost]: MappingVariable.PushMilestone, + [notificationTypes.MilestoneFavorite]: MappingVariable.PushMilestone, + [notificationTypes.MilestoneListen]: MappingVariable.PushMilestone, + [notificationTypes.Announcement]: MappingVariable.PushAnnouncement, + [notificationTypes.UserSubscription]: MappingVariable.PushUserSubscription, + [notificationTypes.TrendingTrack]: MappingVariable.PushTrending, + [notificationTypes.ChallengeReward]: MappingVariable.PushChallengeReward, + [notificationTypes.TierChange]: MappingVariable.PushTierChange, + [notificationTypes.PlaylistUpdate]: MappingVariable.PushPlaylistUpdate, + [notificationTypes.Tip]: MappingVariable.PushTip, + [notificationTypes.TipReceive]: MappingVariable.PushTipReceive, + [notificationTypes.TipSend]: MappingVariable.PushTipSend, + [notificationTypes.Reaction]: MappingVariable.PushReaction, + [notificationTypes.SupporterRankUp]: MappingVariable.PushSupporterRankUp, + [notificationTypes.SupportingRankUp]: MappingVariable.PushSupportingRankUp, + [notificationTypes.SupporterDethroned]: MappingVariable.PushSupporterDethroned, + [notificationTypes.AddTrackToPlaylist]: MappingVariable.PushAddTrackToPlaylist +}; +async function processNotification(optimizelyClient, logger, notification) { + let numProcessedNotifs = 0; + const notificationMappingVar = notificaitonTypeMapping[notification.notification.type]; + // NOTE: This flag is used betwen the notifications plugin and identity service + // so, when true, it is enabled in the plugin and disabeld here in identity + const isDisabled = getRemoteFeatureVarEnabled(optimizelyClient, DISCOVERY_NOTIFICATION_MAPPING, notificationMappingVar); + if (isDisabled === true) { + return; + } + if (notification.types.includes(deviceType.Mobile)) { + const numSentNotifs = await _sendNotification(sendAwsSns, notification, logger); + numProcessedNotifs += numSentNotifs; + } + if (notification.types.includes(deviceType.Browser)) { + const numSentNotifsArr = await Promise.all([ + _sendNotification(sendBrowserNotification, notification, logger), + _sendNotification(sendSafariNotification, notification, logger) + ]); + numSentNotifsArr.forEach((numSentNotifs) => { + numProcessedNotifs += numSentNotifs; + }); + } + return numProcessedNotifs; +} +// Number of notitications to process in parallel +const BATCH_SIZE = 20; +async function drainPublishedMessages(logger, optimizelyClient) { + logger.info(`[notificationQueue:drainPublishedMessages] Beginning processing of ${pushNotificationQueue.PUSH_NOTIFICATIONS_BUFFER.length} notifications...`); + const numProcessedNotifications = await promiseAllInBatches(processNotification, optimizelyClient, logger, pushNotificationQueue.PUSH_NOTIFICATIONS_BUFFER, BATCH_SIZE); + const numProcessedNotifs = numProcessedNotifications.reduce((total, val) => total + val, 0); + pushNotificationQueue.PUSH_NOTIFICATIONS_BUFFER = []; + return numProcessedNotifs; +} +async function drainPublishedSolanaMessages(logger, optimizelyClient) { + logger.info(`[notificationQueue:drainPublishedSolanaMessages] Beginning processing of ${pushNotificationQueue.PUSH_SOLANA_NOTIFICATIONS_BUFFER.length} notifications...`); + let numProcessedNotifs = 0; + for (const bufferObj of pushNotificationQueue.PUSH_SOLANA_NOTIFICATIONS_BUFFER) { + const notificationMappingVar = notificaitonTypeMapping[bufferObj.notification.type]; + // NOTE: This flag is used betwen the notifications plugin and identity service + // so, when true, it is enabled in the plugin and disabeld here in identity + const isDisabled = getRemoteFeatureVarEnabled(optimizelyClient, DISCOVERY_NOTIFICATION_MAPPING, notificationMappingVar); + if (isDisabled === true) { + return; + } + if (bufferObj.types.includes(deviceType.Mobile)) { + const numSentNotifs = await _sendNotification(sendAwsSns, bufferObj, logger); + numProcessedNotifs += numSentNotifs; + } + if (bufferObj.types.includes(deviceType.Browser)) { + const numSentNotifsArr = await Promise.all([ + _sendNotification(sendBrowserNotification, bufferObj, logger), + _sendNotification(sendSafariNotification, bufferObj, logger) + ]); + numSentNotifsArr.forEach((numSentNotifs) => { + numProcessedNotifs += numSentNotifs; + }); + } + } + pushNotificationQueue.PUSH_SOLANA_NOTIFICATIONS_BUFFER = []; + return numProcessedNotifs; +} +async function drainPublishedAnnouncements(logger) { + logger.info(`[notificationQueue:drainPublishedAnnouncements] Beginning processing of ${pushNotificationQueue.PUSH_SOLANA_NOTIFICATIONS_BUFFER.length} notifications...`); + let numProcessedNotifs = 0; + for (const bufferObj of pushNotificationQueue.PUSH_ANNOUNCEMENTS_BUFFER) { + const numSentNotifsArr = await Promise.all([ + _sendNotification(sendAwsSns, bufferObj, logger), + _sendNotification(sendBrowserNotification, bufferObj, logger), + _sendNotification(sendSafariNotification, bufferObj, logger) + ]); + numSentNotifsArr.forEach((numSentNotifs) => { + numProcessedNotifs += numSentNotifs; + }); + } + pushNotificationQueue.PUSH_ANNOUNCEMENTS_BUFFER = []; + return numProcessedNotifs; +} +module.exports = { + pushNotificationQueue, + publish, + publishSolanaNotification, + publishAnnouncement, + drainPublishedMessages, + drainPublishedSolanaMessages, + drainPublishedAnnouncements, + processNotification, + promiseAllInBatches, + BATCH_SIZE +}; diff --git a/packages/identity-service/build/src/notifications/processNotifications/addTrackToPlaylistNotification.js b/packages/identity-service/build/src/notifications/processNotifications/addTrackToPlaylistNotification.js new file mode 100644 index 00000000000..871e9ed512f --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/addTrackToPlaylistNotification.js @@ -0,0 +1,73 @@ +"use strict"; +const moment = require('moment'); +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Process track added to playlist notification + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processAddTrackToPlaylistNotification(notifications, tx) { + const validNotifications = []; + for (const notification of notifications) { + const { playlist_id: playlistId, track_id: trackId, track_owner_id: trackOwnerId } = notification.metadata; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const momentTimestamp = moment(timestamp); + const updatedTimestamp = momentTimestamp + .add(1, 's') + .format('YYYY-MM-DD HH:mm:ss'); + let addTrackToPlaylistNotification = await models.Notification.findOne({ + where: { + type: notificationTypes.AddTrackToPlaylist, + userId: trackOwnerId, + entityId: trackId, + metadata: { + playlistOwnerId: notification.initiator, + playlistId, + trackId + }, + blocknumber: notification.blocknumber, + timestamp: updatedTimestamp + }, + transaction: tx + }); + if (addTrackToPlaylistNotification == null) { + addTrackToPlaylistNotification = await models.Notification.create({ + type: notificationTypes.AddTrackToPlaylist, + userId: trackOwnerId, + entityId: trackId, + metadata: { + playlistOwnerId: notification.initiator, + playlistId, + trackId + }, + blocknumber: notification.blocknumber, + timestamp: updatedTimestamp + }, { + transaction: tx + }); + } + const notificationAction = await models.NotificationAction.findOne({ + where: { + notificationId: addTrackToPlaylistNotification.id, + actionEntityType: actionEntityTypes.Track, + actionEntityId: trackId, + blocknumber: notification.blocknumber + }, + transaction: tx + }); + if (notificationAction == null) { + await models.NotificationAction.create({ + notificationId: addTrackToPlaylistNotification.id, + actionEntityType: actionEntityTypes.Track, + actionEntityId: trackId, + blocknumber: notification.blocknumber + }, { + transaction: tx + }); + } + validNotifications.push(addTrackToPlaylistNotification); + } + return validNotifications; +} +module.exports = processAddTrackToPlaylistNotification; diff --git a/packages/identity-service/build/src/notifications/processNotifications/challengeRewardNotification.js b/packages/identity-service/build/src/notifications/processNotifications/challengeRewardNotification.js new file mode 100644 index 00000000000..16c24de72f4 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/challengeRewardNotification.js @@ -0,0 +1,56 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes } = require('../constants'); +/** + * Process challenge reward notifications, note these notifications do not "stack" meaning that + * a notification action will never reference a previously created notification + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processChallengeRewardNotifications(notifications, tx) { + for (const notification of notifications) { + const { challenge_id: challengeId } = notification.metadata; + // Create/Find a Notification and NotificationAction for this event + // NOTE: ChallengeReward Notifications do NOT stack. A new notification is created for each + const slot = notification.slot; + let notificationObj = await models.SolanaNotification.findOne({ + where: { + slot, + type: notificationTypes.ChallengeReward, + userId: notification.initiator + }, + transaction: tx + }); + if (notificationObj == null) { + notificationObj = await models.SolanaNotification.create({ + slot, + type: notificationTypes.ChallengeReward, + userId: notification.initiator + }, { + transaction: tx + }); + } + // TODO: Need to find out is this is needed + const notificationAction = await models.SolanaNotificationAction.findOne({ + where: { + slot, + notificationId: notificationObj.id, + actionEntityType: challengeId, + actionEntityId: notification.initiator + }, + transaction: tx + }); + if (notificationAction == null) { + await models.SolanaNotificationAction.create({ + slot, + notificationId: notificationObj.id, + actionEntityType: challengeId, + actionEntityId: notification.initiator + }, { + transaction: tx + }); + } + } + return notifications; +} +module.exports = processChallengeRewardNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/createNotification.js b/packages/identity-service/build/src/notifications/processNotifications/createNotification.js new file mode 100644 index 00000000000..8d32faffd04 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/createNotification.js @@ -0,0 +1,172 @@ +"use strict"; +const { logger } = require('../../logging'); +const models = require('../../models'); +const { bulkGetSubscribersFromDiscovery, shouldReadSubscribersFromDiscovery } = require('../utils'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +const getNotifType = (entityType) => { + switch (entityType) { + case 'track': + return { + createType: notificationTypes.Create.track, + actionEntityType: actionEntityTypes.Track + }; + case 'album': + return { + createType: notificationTypes.Create.album, + actionEntityType: actionEntityTypes.User + }; + case 'playlist': + return { + createType: notificationTypes.Create.playlist, + actionEntityType: actionEntityTypes.User + }; + default: + return {}; + } +}; +/** + * Batch process create notifications, by bulk insertion in the DB for each + * set of subscribers and dedpupe tracks in collections. + * @param {Array} notifications + * @param {*} tx The DB transcation to attach to DB requests + * @param {*} optimizelyClient Optimizely client for feature flags + */ +async function processCreateNotifications(notifications, tx, optimizelyClient) { + const validNotifications = []; + // If READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED is enabled, bulk fetch all subscriber IDs + // from discovery for the initiators of create notifications. + const readSubscribersFromDiscovery = shouldReadSubscribersFromDiscovery(optimizelyClient); + let userSubscribersMap = {}; + if (readSubscribersFromDiscovery) { + const userIds = new Set(notifications.map((notif) => notif.initiator)); + if (userIds.size > 0) { + userSubscribersMap = await bulkGetSubscribersFromDiscovery(userIds); + } + } + for (const notification of notifications) { + // If the initiator is the main audius account, skip the notification + // NOTE: This is a temp fix to not stall identity service + if (notification.initiator === 51) { + continue; + } + const blocknumber = notification.blocknumber; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const { createType, actionEntityType } = getNotifType(notification.metadata.entity_type); + // Notifications go to all users subscribing to this content uploader + let subscribers = userSubscribersMap[notification.initiator] || []; + if (!readSubscribersFromDiscovery) { + // Query user IDs from subscriptions table + subscribers = await models.Subscription.findAll({ + where: { + userId: notification.initiator + }, + transaction: tx + }); + } + // No operation if no users subscribe to this creator + if (subscribers.length === 0) + continue; + // The notification entity id is the uploader id for tracks + // Each track will added to the notification actions table + // For playlist/albums, the notification entity id is the collection id itself + const notificationEntityId = actionEntityType === actionEntityTypes.Track + ? notification.initiator + : notification.metadata.entity_id; + // Action table entity is trackId for CreateTrack notifications + // Allowing multiple track creates to be associated w/ a single notification for your subscription + // For collections, the entity is the owner id, producing a distinct notification for each + const createdActionEntityId = actionEntityType === actionEntityTypes.Track + ? notification.metadata.entity_id + : notification.metadata.entity_owner_id; + // Query all subscribers for a un-viewed notification - is no un-view notification exists a new one is created + let subscriberIds = subscribers; + if (!readSubscribersFromDiscovery) { + subscriberIds = subscribers.map((s) => s.subscriberId); + } + const unreadSubscribers = await models.Notification.findAll({ + where: { + isViewed: false, + userId: { [models.Sequelize.Op.in]: subscriberIds }, + type: createType, + entityId: notificationEntityId + }, + transaction: tx + }); + const unreadSubscribersUserIds = new Set(unreadSubscribers.map((s) => s.userId)); + const subscriberIdsWithoutNotification = subscriberIds.filter((s) => !unreadSubscribersUserIds.has(s)); + const subscriberIdsWithNotification = subscriberIds.filter((s) => unreadSubscribersUserIds.has(s)); + logger.info(`got unread ${subscriberIdsWithoutNotification.length}`); + if (subscriberIdsWithoutNotification.length > 0) { + // Bulk create notifications for users that do not have new notifications + const createTrackNotifTx = await models.Notification.bulkCreate(subscriberIdsWithoutNotification.map((id) => ({ + isViewed: false, + isRead: false, + isHidden: false, + userId: id, + type: createType, + entityId: notificationEntityId, + blocknumber, + timestamp + }), { transaction: tx })); + const createdNotificationIds = createTrackNotifTx.map((notif) => notif.id); + await models.NotificationAction.bulkCreate(createdNotificationIds.map((notificationId) => ({ + notificationId, + actionEntityType: actionEntityType, + actionEntityId: createdActionEntityId, + blocknumber + })), { transaction: tx }); + } + if (subscriberIdsWithNotification.length > 0) { + // Find existing unread notifications + const createTrackNotifTx = await models.Notification.findAll({ + where: { + userId: { [models.Sequelize.Op.in]: subscriberIdsWithNotification }, + type: createType, + entityId: notificationEntityId, + isViewed: false + } + }); + const createdNotificationIds = createTrackNotifTx.map((notif) => notif.id); + // Append new notification actions to those existing unread notifications + await models.NotificationAction.bulkCreate(createdNotificationIds.map((notificationId) => ({ + notificationId, + actionEntityType: actionEntityType, + actionEntityId: createdActionEntityId, + blocknumber + })), { transaction: tx }); + await models.Notification.update({ + timestamp + }, { + where: { + type: createType, + entityId: notificationEntityId, + id: { [models.Sequelize.Op.in]: createdNotificationIds } + }, + returning: true, + plain: true, + transaction: tx + }); + } + // Dedupe album /playlist notification + if (createType === notificationTypes.Create.album || + createType === notificationTypes.Create.playlist) { + const trackIdObjectList = notification.metadata.collection_content.track_ids; + if (trackIdObjectList.length > 0) { + // Clear duplicate notifications from identity database + for (const entry of trackIdObjectList) { + const trackId = entry.track; + await models.NotificationAction.destroy({ + where: { + actionEntityType: actionEntityTypes.Track, + actionEntityId: trackId + }, + transaction: tx + }); + } + } + } + validNotifications.push(notification); + } + return validNotifications; +} +module.exports = processCreateNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/favoriteNotification.js b/packages/identity-service/build/src/notifications/processNotifications/favoriteNotification.js new file mode 100644 index 00000000000..070664c16b4 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/favoriteNotification.js @@ -0,0 +1,113 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +const getNotifType = (entityType) => { + switch (entityType) { + case 'track': + return notificationTypes.Favorite.track; + case 'album': + return notificationTypes.Favorite.album; + case 'playlist': + return notificationTypes.Favorite.playlist; + default: + return ''; + } +}; +// A notification is unique by it's +// userId (owner of the content favorited), type (track/album/playlist), and entityId (trackId, ect) +const getUniqueNotificationModel = (notif) => `${notif.userId}:${notif.type}:${notif.entityId}`; +const getUniqueNotification = (notif) => `${notif.metadata.entity_owner_id}:${getNotifType(notif.metadata.entity_type)}:${notif.metadata.entity_id}`; +/** + * Batch process favorite notifications, creating a notification (if prev unread) and notification action + * for the owner of the favorited track/playlist/album + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processFavoriteNotifications(notifications, tx) { + // Create a mapping of unique notification by userID, type, and entity id to notification + // This is used if there are multiple favorites of the same track, + // then one notification is made w/ multiple actions + const favoriteNotifs = notifications.reduce((notifs, notif) => { + const key = getUniqueNotification(notif); + notifs[key] = (notifs[key] || []).concat(notif); + return notifs; + }, {}); + // Batch query the DB for non-viewed notification + const selectNotifications = Object.keys(favoriteNotifs).map((notifKey) => { + const notif = favoriteNotifs[notifKey][0]; + return { + isViewed: false, + userId: notif.metadata.entity_owner_id, + type: getNotifType(notif.metadata.entity_type), + entityId: notif.metadata.entity_id + }; + }); + const unreadUserNotifications = await models.Notification.findAll({ + where: { [models.Sequelize.Op.or]: selectNotifications }, + transaction: tx + }); + const notificationModalObjs = unreadUserNotifications; + const unreadNotifications = new Set(unreadUserNotifications.map((notif) => getUniqueNotificationModel(notif))); + // Notifications that are read or not existent, so we need to make new ones + const notificationsToCreate = Object.keys(favoriteNotifs).filter((notif) => !unreadNotifications.has(notif)); + // Insert new notification for notifications that didn't exists / were already viewed + // Favorite - userId=notif target, entityId=track/album/repost id, actionEntityType=User actionEntityId=user who favorited + // As multiple users favorite an entity, NotificationActions are added matching the NotificationId + if (notificationsToCreate.length > 0) { + const createdFavoriteNotifications = await models.Notification.bulkCreate(notificationsToCreate.map((notifKey) => { + const notif = favoriteNotifs[notifKey][0]; + const blocknumber = notif.blocknumber; + const timestamp = Date.parse(notif.timestamp.slice(0, -2)); + return { + type: getNotifType(notif.metadata.entity_type), + isRead: false, + isHidden: false, + isViewed: false, + userId: notif.metadata.entity_owner_id, + entityId: notif.metadata.entity_id, + blocknumber, + timestamp + }; + }), { transaction: tx }); + notificationModalObjs.push(...createdFavoriteNotifications); + } + // Create the notification actions for each notification + for (const notificationModal of notificationModalObjs) { + const notificationId = notificationModal.id; + const notifs = favoriteNotifs[`${notificationModal.userId}:${notificationModal.type}:${notificationModal.entityId}`]; + for (const notif of notifs) { + const blocknumber = notif.blocknumber; + const timestamp = Date.parse(notif.timestamp.slice(0, -2)); + let notifActionCreateTx = await models.NotificationAction.findOne({ + where: { + notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + }, + transaction: tx + }); + if (notifActionCreateTx == null) { + notifActionCreateTx = await models.NotificationAction.create({ + notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + }, { + transaction: tx + }); + // Update Notification table timestamp + await models.Notification.update({ + timestamp + }, { + where: { id: notificationId }, + returning: true, + plain: true, + transaction: tx + }); + } + } + } + return notifications; +} +module.exports = processFavoriteNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/followNotification.js b/packages/identity-service/build/src/notifications/processNotifications/followNotification.js new file mode 100644 index 00000000000..3ae23f704bb --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/followNotification.js @@ -0,0 +1,92 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Batch process follow notifications, creating a notification (if prev unread) and notification action + * for the user that is followed + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processFollowNotifications(notifications, tx) { + // Create a mapping of user ids of users that are followed to the user that follows them + // This is used if there are multiple users following the same user id, then one notification is + // made w/ multiple actions for each of the followers + const userFolloweeNotif = notifications.reduce((followees, notification) => { + const { followee_user_id: foloweeId } = notification.metadata; + followees[foloweeId] = (followees[foloweeId] || []).concat(notification); + return followees; + }, []); + // Array of user ids that are followed + const userTargets = Object.keys(userFolloweeNotif); + // Batch query the DB for non-viewed notification + const unreadUserNotifications = await models.Notification.findAll({ + where: { + isViewed: false, + userId: { [models.Sequelize.Op.in]: userTargets }, + type: notificationTypes.Follow + }, + transaction: tx + }); + const notificationModalObjs = unreadUserNotifications; + const usersWithUnread = new Set(unreadUserNotifications.map((notif) => notif.userId.toString())); + const notificationsToCreate = userTargets.filter((userId) => !usersWithUnread.has(userId)); + if (notificationsToCreate.length > 0) { + // Insert new notification for users that didn't have a notification / have on that is already viewed + const createdFollowNotifications = await models.Notification.bulkCreate(notificationsToCreate.map((userId) => { + const userIdNotifications = userFolloweeNotif[userId]; + const lastNotification = userIdNotifications[userIdNotifications.length - 1]; + const blocknumber = lastNotification.blocknumber; + const timestamp = Date.parse(lastNotification.timestamp.slice(0, -2)); + return { + type: notificationTypes.Follow, + isViewed: false, + isRead: false, + isHidden: false, + userId, + blocknumber, + timestamp + }; + }), { transaction: tx }); + notificationModalObjs.push(...createdFollowNotifications); + } + // Create the notification actions that point to the notification + for (const notificationModal of notificationModalObjs) { + const notificationId = notificationModal.id; + const rawNotifications = userFolloweeNotif[notificationModal.userId]; + for (const notification of rawNotifications) { + const blocknumber = notification.blocknumber; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const notifActionCreateTx = await models.NotificationAction.findOne({ + where: { + notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notification.metadata.follower_user_id, + blocknumber + }, + transaction: tx + }); + if (notifActionCreateTx == null) { + // Insertion into the NotificationActions table + await models.NotificationAction.create({ + notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notification.metadata.follower_user_id, + blocknumber + }, { + transaction: tx + }); + // Update Notification table timestamp + await models.Notification.update({ + timestamp + }, { + where: { id: notificationId }, + returning: true, + plain: true, + transaction: tx + }); + } + } + } + return notifications; +} +module.exports = processFollowNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/index.js b/packages/identity-service/build/src/notifications/processNotifications/index.js new file mode 100644 index 00000000000..535321e388a --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/index.js @@ -0,0 +1,69 @@ +"use strict"; +const { logger } = require('../../logging'); +const { notificationTypes } = require('../constants'); +const processFollowNotifications = require('./followNotification'); +const processRepostNotifications = require('./repostNotification'); +const processFavoriteNotifications = require('./favoriteNotification'); +const processRemixCreateNotifications = require('./remixCreateNotification'); +const processRemixCosignNotifications = require('./remixCosignNotification'); +const processCreateNotifications = require('./createNotification'); +const processPlaylistUpdateNotifications = require('./playlistUpdateNotification'); +const processChallengeRewardNotifications = require('./challengeRewardNotification'); +const processMilestoneListenNotifications = require('./milestoneListenNotification'); +const processTierChangeNotifications = require('./tierChangeNotification'); +const processTipNotification = require('./tipNotification'); +const processReactionNotification = require('./reactionNotification'); +const processSupporterRankChangeNotification = require('./supporterRankChangeNotification'); +const processAddTrackToPlaylistNotification = require('./addTrackToPlaylistNotification'); +// Mapping of Notification type to processing function. +const notificationMapping = { + [notificationTypes.Follow]: processFollowNotifications, + [notificationTypes.Repost.base]: processRepostNotifications, + [notificationTypes.Favorite.base]: processFavoriteNotifications, + [notificationTypes.RemixCreate]: processRemixCreateNotifications, + [notificationTypes.RemixCosign]: processRemixCosignNotifications, + [notificationTypes.Create.base]: processCreateNotifications, + [notificationTypes.PlaylistUpdate]: processPlaylistUpdateNotifications, + [notificationTypes.ChallengeReward]: processChallengeRewardNotifications, + [notificationTypes.MilestoneListen]: processMilestoneListenNotifications, + [notificationTypes.TierChange]: processTierChangeNotifications, + [notificationTypes.Tip]: processTipNotification, + [notificationTypes.Reaction]: processReactionNotification, + [notificationTypes.SupporterRankUp]: processSupporterRankChangeNotification, + [notificationTypes.AddTrackToPlaylist]: processAddTrackToPlaylistNotification +}; +/** + * Write notifications into the DB. Group the notifications by type to be batch processed together + * @param {Array} notifications Array of notifications from DP + * @param {*} tx The transaction to add to each of the DB lookups/inserts/deletes + * @param {*} optimizelyClient Optimizely client for feature flags + */ +async function processNotifications(notifications, tx, optimizelyClient) { + // Group the notifications by type + const notificationCategories = notifications.reduce((categories, notification) => { + if (!categories[notification.type]) { + categories[notification.type] = []; + } + categories[notification.type].push(notification); + return categories; + }, {}); + // Process notification types in parallel + const processedNotifications = await Promise.all(Object.entries(notificationCategories).map(([notifType, notifications]) => { + const processType = notificationMapping[notifType]; + if (processType) { + logger.debug(`Processing: ${notifications.length} notifications of type ${notifType}`); + if (notifType === notificationTypes.Create.base) { + return processType(notifications, tx, optimizelyClient); + } + else { + return processType(notifications, tx); + } + } + else { + logger.error('processNotifications - no handler defined for notification type', notifType); + return []; + } + })); + return processedNotifications.flat(); +} +module.exports = processNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/milestoneListenNotification.js b/packages/identity-service/build/src/notifications/processNotifications/milestoneListenNotification.js new file mode 100644 index 00000000000..7b2fa5684c8 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/milestoneListenNotification.js @@ -0,0 +1,67 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Batch process milestone listen notifications + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processMilestoneListenNotifications(notifications, tx) { + const validNotifications = []; + for (const notification of notifications) { + const trackOwnerId = notification.initiator; + const trackId = notification.metadata.entity_id; + const threshold = notification.metadata.threshold; + const slot = notification.slot; + // Check for existing milestone + const existingMilestoneQuery = await models.SolanaNotification.findAll({ + where: { + userId: trackOwnerId, + type: notificationTypes.MilestoneListen, + entityId: trackId + }, + include: [ + { + model: models.SolanaNotificationAction, + as: 'actions', + where: { + actionEntityType: actionEntityTypes.Track, + actionEntityId: threshold + } + } + ], + transaction: tx + }); + if (existingMilestoneQuery.length === 0) { + const createMilestoneTx = await models.SolanaNotification.create({ + userId: trackOwnerId, + type: notificationTypes.MilestoneListen, + entityId: trackId, + slot + }, { transaction: tx }); + const notificationId = createMilestoneTx.id; + const notificationAction = await models.SolanaNotificationAction.findOne({ + where: { + notificationId, + actionEntityType: actionEntityTypes.Track, + actionEntityId: threshold, + slot + }, + transaction: tx + }); + if (notificationAction == null) { + await models.SolanaNotificationAction.create({ + notificationId, + actionEntityType: actionEntityTypes.Track, + actionEntityId: threshold, + slot + }, { + transaction: tx + }); + } + validNotifications.push(notification); + } + } + return validNotifications; +} +module.exports = processMilestoneListenNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/playlistUpdateNotification.js b/packages/identity-service/build/src/notifications/processNotifications/playlistUpdateNotification.js new file mode 100644 index 00000000000..bd32007146e --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/playlistUpdateNotification.js @@ -0,0 +1,109 @@ +"use strict"; +const { logger } = require('../../logging'); +const moment = require('moment-timezone'); +const models = require('../../models'); +const { sequelize, Sequelize } = require('../../models'); +const { LogTimer } = require('../../utils/logTimer'); +const logPrefix = 'notifications playlist updates'; +/** + * Process playlist update notifications + * upsert lastUpdated and userLastViewed in the DB for each subscriber of a playlist + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processPlaylistUpdateNotifications(notifications, tx) { + /** + * keep track of last playlist updates for each user that favorited playlists + * e.g. { user1: { playlist1: , playlist2: , ... }, ... } + */ + const logTimer = new LogTimer(logPrefix); + const startTime = Date.now(); + logger.info(`${logPrefix} num notifications: ${notifications.length}, start: ${startTime}`); + const userPlaylistUpdatesMap = {}; + notifications.forEach((notification) => { + const { metadata } = notification; + const { entity_id: playlistId, playlist_update_timestamp: playlistUpdatedAt, playlist_update_users: userIds } = metadata; + // If playlist if Hot and New skip playlist update notification + // NOTE: This is a temporary fix + if (playlistId === 4281) { + return; + } + userIds.forEach((userId) => { + if (userPlaylistUpdatesMap[userId]) { + userPlaylistUpdatesMap[userId][playlistId] = playlistUpdatedAt; + } + else { + userPlaylistUpdatesMap[userId] = { [playlistId]: playlistUpdatedAt }; + } + }); + }); + const userIds = Object.keys(userPlaylistUpdatesMap); + logger.info(`${logPrefix} parsed notifications, num user ids: ${userIds.length}, time: ${Date.now() - startTime}ms`); + logTimer.startTime('fetch users and updates'); + // get wallets for all user ids and map each blockchain user id to their wallet + const userIdsAndWallets = await models.User.findAll({ + attributes: ['blockchainUserId', 'walletAddress'], + where: { + blockchainUserId: userIds, + walletAddress: { [models.Sequelize.Op.ne]: null } + }, + transaction: tx + }); + logger.info(`${logPrefix} selected wallets, num wallets: ${userIdsAndWallets.length}, time: ${Date.now() - startTime}ms`); + const userIdToWalletsMap = {}; + for (const { blockchainUserId, walletAddress } of userIdsAndWallets) { + userIdToWalletsMap[blockchainUserId] = walletAddress; + } + // get playlist updates for all wallets and map each wallet to its playlist updates + const userWalletsAndPlaylistUpdates = await models.UserEvents.findAll({ + attributes: ['walletAddress', 'playlistUpdates'], + where: { + walletAddress: Object.values(userIdToWalletsMap) + }, + transaction: tx + }); + logTimer.endTime('fetch users and updates'); + logTimer.startTime('update playlist in user events'); + const userWalletToPlaylistUpdatesMap = {}; + for (const { walletAddress, playlistUpdates } of userWalletsAndPlaylistUpdates) { + userWalletToPlaylistUpdatesMap[walletAddress] = playlistUpdates; + } + logger.info(`${logPrefix} mapped updates, num updates: ${userWalletsAndPlaylistUpdates.length}, time: ${Date.now() - startTime}ms`); + const newUserEvents = userIds + .map((userId) => { + const walletAddress = userIdToWalletsMap[userId]; + if (!walletAddress) + return null; + const dbPlaylistUpdates = userWalletToPlaylistUpdatesMap[walletAddress] || {}; + const fetchedPlaylistUpdates = userPlaylistUpdatesMap[userId]; + Object.keys(fetchedPlaylistUpdates).forEach((playlistId) => { + const fetchedLastUpdated = moment(fetchedPlaylistUpdates[playlistId]).utc(); + dbPlaylistUpdates[playlistId] = { + // in case user favorited this track before and has no UserEvent record of it + userLastViewed: fetchedLastUpdated.subtract(1, 'seconds').valueOf(), + ...dbPlaylistUpdates[playlistId], + lastUpdated: fetchedLastUpdated.valueOf() + }; + }); + return [walletAddress, JSON.stringify(dbPlaylistUpdates)]; + }) + .filter(Boolean); + const results = await sequelize.query(` + INSERT INTO "UserEvents" ("walletAddress", "playlistUpdates", "createdAt", "updatedAt") + VALUES ${newUserEvents.map((_) => '(?,now(),now())').join(',')} + ON CONFLICT ("walletAddress") DO UPDATE + SET "playlistUpdates" = "excluded"."playlistUpdates" + `, { + replacements: newUserEvents, + type: Sequelize.QueryTypes.INSERT + }); + logTimer.endTime('update playlist in user events'); + logTimer.addContext('userIdsAndWallets', userIdsAndWallets.length); + logTimer.addContext('rows', results.length); + logTimer.addContext('newUserEvents', newUserEvents.length); + logTimer.addContext('notifications', notifications.length); + logTimer.addContext('userIds', userIds.length); + logger.info(logTimer.getContext(), `${logPrefix} bulk upserted updates, rows: ${results}, time: ${Date.now() - startTime}ms`); + return notifications; +} +module.exports = processPlaylistUpdateNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/reactionNotification.js b/packages/identity-service/build/src/notifications/processNotifications/reactionNotification.js new file mode 100644 index 00000000000..1f492897dae --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/reactionNotification.js @@ -0,0 +1,57 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes } = require('../constants'); +async function processReactionNotifications(notifications, tx) { + const notifsToReturn = []; + for (const notification of notifications) { + const { slot, initiator: reactorId, metadata: { reaction_value: reactionValue, reacted_to_entity: reactedToEntity, reaction_type: reactionType } } = notification; + // TODO: unhardcode assumptions about the userId receiving the notification, when + // we have additional reaction types. + const existingNotification = await models.SolanaNotification.findOne({ + where: { + type: notificationTypes.Reaction, + userId: reactedToEntity.tip_sender_id, + entityId: reactorId, + metadata: { + reactionType, + reactedToEntity + } + }, + transaction: tx + }); + // In the case that the notification already exists, avoid returning it to prevent + // sending it a second time. Just update or delete the original reaction value. + if (existingNotification) { + if (parseInt(reactionValue) === 0) { + // Destroy reaction if undoing reaction value + await existingNotification.destroy({ transaction: tx }); + } + else { + // Have to recreate the metadata object for save to work properly + existingNotification.metadata = { + ...existingNotification.metadata, + reactionValue + }; + await existingNotification.save({ transaction: tx }); + } + } + else { + notifsToReturn.push(notification); + await models.SolanaNotification.create({ + slot, + type: notificationTypes.Reaction, + userId: reactedToEntity.tip_sender_id, + entityId: reactorId, + metadata: { + reactionType, + reactedToEntity, + reactionValue + } + }, { + transaction: tx + }); + } + } + return notifsToReturn; +} +module.exports = processReactionNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/remixCosignNotification.js b/packages/identity-service/build/src/notifications/processNotifications/remixCosignNotification.js new file mode 100644 index 00000000000..990669024ec --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/remixCosignNotification.js @@ -0,0 +1,63 @@ +"use strict"; +const moment = require('moment'); +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Process cosign notifications, note that a unique cosign event is created only once. + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processCosignNotifications(notifications, tx) { + const validNotifications = []; + for (const notification of notifications) { + const { entity_id: childTrackId, entity_owner_id: childTrackUserId } = notification.metadata; + const parentTrackUserId = notification.initiator; + // Query the Notification/NotificationActions to see if the notification already exists. + const cosignNotifications = await models.Notification.findAll({ + where: { + userId: childTrackUserId, + type: notificationTypes.RemixCosign, + entityId: childTrackId + }, + include: [ + { + model: models.NotificationAction, + as: 'actions', + where: { + actionEntityType: actionEntityTypes.User, + actionEntityId: parentTrackUserId + } + } + ], + transaction: tx + }); + // If this track is already cosigned, ignore this notification + if (cosignNotifications.length > 0) + continue; + const blocknumber = notification.blocknumber; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const momentTimestamp = moment(timestamp); + // Add 1 s to the timestamp so that it appears after the favorite/repost + const updatedTimestamp = momentTimestamp + .add(1, 's') + .format('YYYY-MM-DD HH:mm:ss'); + // Create a new Notification and NotificationAction + // NOTE: Cosign Notifications do NOT stack. A new notification is created every time + const cosignNotification = await models.Notification.create({ + type: notificationTypes.RemixCosign, + userId: childTrackUserId, + entityId: childTrackId, + blocknumber, + timestamp: updatedTimestamp + }, { transaction: tx }); + await models.NotificationAction.create({ + notificationId: cosignNotification.id, + actionEntityType: actionEntityTypes.User, + actionEntityId: parentTrackUserId, + blocknumber + }, { transaction: tx }); + validNotifications.push(notification); + } + return validNotifications; +} +module.exports = processCosignNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/remixCreateNotification.js b/packages/identity-service/build/src/notifications/processNotifications/remixCreateNotification.js new file mode 100644 index 00000000000..5754323c587 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/remixCreateNotification.js @@ -0,0 +1,65 @@ +"use strict"; +const moment = require('moment'); +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Process remix create notifications, note these notifications do not "stack" meaning that + * a notification action will never reference a previously created notification + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processRemixCreateNotifications(notifications, tx) { + for (const notification of notifications) { + const { entity_id: childTrackId, remix_parent_track_user_id: parentTrackUserId, remix_parent_track_id: parentTrackId } = notification.metadata; + // Create/Find a Notification and NotificationAction for this remix create event + // NOTE: RemixCreate Notifications do NOT stack. A new notification is created for each remix creation + const blocknumber = notification.blocknumber; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const momentTimestamp = moment(timestamp); + const updatedTimestamp = momentTimestamp + .add(1, 's') + .format('YYYY-MM-DD HH:mm:ss'); + let notificationObj = await models.Notification.findOne({ + where: { + type: notificationTypes.RemixCreate, + userId: parentTrackUserId, + entityId: childTrackId, + blocknumber, + timestamp: updatedTimestamp + }, + transaction: tx + }); + if (notificationObj == null) { + notificationObj = await models.Notification.create({ + type: notificationTypes.RemixCreate, + userId: parentTrackUserId, + entityId: childTrackId, + blocknumber, + timestamp: updatedTimestamp + }, { + transaction: tx + }); + } + const notificationAction = await models.NotificationAction.findOne({ + where: { + notificationId: notificationObj.id, + actionEntityType: actionEntityTypes.Track, + actionEntityId: parentTrackId, + blocknumber + }, + transaction: tx + }); + if (notificationAction == null) { + await models.NotificationAction.create({ + notificationId: notificationObj.id, + actionEntityType: actionEntityTypes.Track, + actionEntityId: parentTrackId, + blocknumber + }, { + transaction: tx + }); + } + } + return notifications; +} +module.exports = processRemixCreateNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/repostNotification.js b/packages/identity-service/build/src/notifications/processNotifications/repostNotification.js new file mode 100644 index 00000000000..0f5347efc77 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/repostNotification.js @@ -0,0 +1,113 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +const getNotifType = (entityType) => { + switch (entityType) { + case 'track': + return notificationTypes.Repost.track; + case 'album': + return notificationTypes.Repost.album; + case 'playlist': + return notificationTypes.Repost.playlist; + default: + return ''; + } +}; +// A repost notification is unique by it's +// userId (owner of the content resposted), type (track/album/playlist), and entityId (trackId, ect) +const getUniqueNotificationModel = (notif) => `${notif.userId}:${notif.type}:${notif.entityId}`; +const getUniqueNotification = (notif) => `${notif.metadata.entity_owner_id}:${getNotifType(notif.metadata.entity_type)}:${notif.metadata.entity_id}`; +/** + * Batch process repost notifications, creating a notification (if prev unread) and notification action + * for the owner of the reposted track/playlist/album + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processBaseRepostNotifications(notifications, tx) { + // Create a mapping of unique notification by userID, type, and entity id to notification + // This is used if there are multiple favorites of the same track, then one notification is + // made w/ multiple actions + const repostNotifs = notifications.reduce((notifs, notif) => { + const key = getUniqueNotification(notif); + notifs[key] = (notifs[key] || []).concat(notif); + return notifs; + }, {}); + // Batch query the DB for non-viewed notification + const selectNotifications = Object.keys(repostNotifs).map((notifKey) => { + const notif = repostNotifs[notifKey][0]; + return { + isViewed: false, + userId: notif.metadata.entity_owner_id, + type: getNotifType(notif.metadata.entity_type), + entityId: notif.metadata.entity_id + }; + }); + const unreadUserNotifications = await models.Notification.findAll({ + where: { [models.Sequelize.Op.or]: selectNotifications }, + transaction: tx + }); + const notificationModalObjs = unreadUserNotifications; + const unreadNotifications = new Set(unreadUserNotifications.map((notif) => getUniqueNotificationModel(notif))); + // Create new notifications because an unviewed notification does not exist + const notificationsToCreate = Object.keys(repostNotifs).filter((notif) => !unreadNotifications.has(notif)); + // Insert new notification for notifications that didn't exists / were already viewed + // Repost - userId=notif target, entityId=track/album/repost id, actionEntityType=User actionEntityId=user who reposted + // As multiple users repost an entity, NotificationActions are added matching the NotificationId + if (notificationsToCreate.length > 0) { + const createdRepostNotifications = await models.Notification.bulkCreate(notificationsToCreate.map((notifKey) => { + const notif = repostNotifs[notifKey][0]; + const blocknumber = notif.blocknumber; + const timestamp = Date.parse(notif.timestamp.slice(0, -2)); + return { + type: getNotifType(notif.metadata.entity_type), + isRead: false, + isHidden: false, + isViewed: false, + userId: notif.metadata.entity_owner_id, + entityId: notif.metadata.entity_id, + blocknumber, + timestamp + }; + }), { transaction: tx }); + notificationModalObjs.push(...createdRepostNotifications); + } + // Create the notification actions for each notification + for (const notificationModal of notificationModalObjs) { + const notificationId = notificationModal.id; + const notifs = repostNotifs[`${notificationModal.userId}:${notificationModal.type}:${notificationModal.entityId}`]; + for (const notif of notifs) { + const blocknumber = notif.blocknumber; + const timestamp = Date.parse(notif.timestamp.slice(0, -2)); + let notifActionCreateTx = await models.NotificationAction.findOne({ + where: { + notificationId: notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + }, + transaction: tx + }); + if (notifActionCreateTx == null) { + notifActionCreateTx = await models.NotificationAction.create({ + notificationId: notificationId, + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + }, { + transaction: tx + }); + // Update Notification table timestamp + await models.Notification.update({ + timestamp + }, { + where: { id: notificationId }, + returning: true, + plain: true, + transaction: tx + }); + } + } + } + return notifications; +} +module.exports = processBaseRepostNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/supporterRankChangeNotification.js b/packages/identity-service/build/src/notifications/processNotifications/supporterRankChangeNotification.js new file mode 100644 index 00000000000..dff37feeace --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/supporterRankChangeNotification.js @@ -0,0 +1,124 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes } = require('../constants'); +const { decodeHashId } = require('../utils'); +const notificationUtils = require('../utils'); +async function processSupporterRankChangeNotification(notifications, tx) { + const notificationsToAdd = []; + for (const notification of notifications) { + const { slot, initiator: receiverUserId, metadata: { entity_id: senderUserId, rank } } = notification; + // If this is a new top supporter, see who just became dethroned + if (rank === 1) { + const supporters = await notificationUtils.getSupporters(receiverUserId); + const isSingleSupporter = supporters.length < 2; + if (!isSingleSupporter) { + const topSupporterId = decodeHashId(supporters[0].sender.id); + const dethronedUserId = decodeHashId(supporters[1].sender.id); + // Ensure that the top supporter on DN is the top supporter that caused this index + const isDiscoveryUpToDate = topSupporterId === senderUserId; + // Ensure that you don't get dethroned for a tie + const isTie = topSupporterId === dethronedUserId; + if (isDiscoveryUpToDate && !isTie) { + // Create the notif model for the DB + const dethronedNotification = await models.SolanaNotification.findOne({ + where: { + slot, + type: notificationTypes.SupporterDethroned, + userId: dethronedUserId, + entityId: 2, + metadata: { + supportedUserId: receiverUserId, + newTopSupporterUserId: topSupporterId, + oldAmount: supporters[1].amount, + newAmount: supporters[0].amount + } + }, + transaction: tx + }); + if (dethronedNotification == null) { + await models.SolanaNotification.create({ + slot, + type: notificationTypes.SupporterDethroned, + userId: dethronedUserId, + entityId: 2, + metadata: { + supportedUserId: receiverUserId, + newTopSupporterUserId: topSupporterId, + oldAmount: supporters[1].amount, + newAmount: supporters[0].amount + } + }, { + transaction: tx + }); + } + // Create a fake notif from discovery for further processing down the pipeline + notificationsToAdd.push({ + slot, + type: notificationTypes.SupporterDethroned, + initiator: dethronedUserId, + metadata: { + supportedUserId: receiverUserId, + newTopSupporterUserId: topSupporterId, + oldAmount: supporters[1].amount, + newAmount: supporters[0].amount + } + }); + } + } + } + // SupportingRankUp sent to the user who is supporting + const senderNotification = await models.SolanaNotification.findOne({ + where: { + slot, + type: notificationTypes.SupportingRankUp, + userId: senderUserId, + entityId: rank, + metadata: { + supportedUserId: receiverUserId + } + }, + transaction: tx + }); + if (senderNotification == null) { + await models.SolanaNotification.create({ + slot, + type: notificationTypes.SupportingRankUp, + userId: senderUserId, + entityId: rank, + metadata: { + supportedUserId: receiverUserId + } + }, { + transaction: tx + }); + } + // SupporterRankUp sent to the user being supported + const receiverNotification = await models.SolanaNotification.findOrCreate({ + where: { + slot, + type: notificationTypes.SupporterRankUp, + userId: receiverUserId, + entityId: rank, + metadata: { + supportingUserId: senderUserId + } + }, + transaction: tx + }); + if (receiverNotification == null) { + await models.SolanaNotification.create({ + slot, + type: notificationTypes.SupporterRankUp, + userId: receiverUserId, + entityId: rank, + metadata: { + supportingUserId: senderUserId + } + }, { + transaction: tx + }); + } + } + return notifications.concat(notificationsToAdd); +} +module.exports = processSupporterRankChangeNotification; diff --git a/packages/identity-service/build/src/notifications/processNotifications/tierChangeNotification.js b/packages/identity-service/build/src/notifications/processNotifications/tierChangeNotification.js new file mode 100644 index 00000000000..0ea04d6d5b5 --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/tierChangeNotification.js @@ -0,0 +1,65 @@ +"use strict"; +const moment = require('moment'); +const models = require('../../models'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +/** + * Process tier change notifications, note these notifications do not "stack" meaning that + * a notification action will never reference a previously created notification + * @param {Array} notifications + * @param {*} tx The DB transaction to attach to DB requests + */ +async function processTierChangeNotifications(notifications, tx) { + for (const notification of notifications) { + const { tier } = notification.metadata; + // Create/Find a Notification and NotificationAction for this event + // NOTE: TierChange Notifications do NOT stack. A new notification is created for each tier change + const blocknumber = notification.blocknumber; + const timestamp = Date.parse(notification.timestamp.slice(0, -2)); + const momentTimestamp = moment(timestamp); + const updatedTimestamp = momentTimestamp + .add(1, 's') + .format('YYYY-MM-DD HH:mm:ss'); + let notificationObj = await models.Notification.findOne({ + where: { + blocknumber, + tier, + timestamp: updatedTimestamp, + type: notificationTypes.TierChange, + userId: notification.initiator + }, + transaction: tx + }); + if (notificationObj == null) { + notificationObj = await models.Notification.create({ + blocknumber, + tier, + timestamp: updatedTimestamp, + type: notificationTypes.TierChange, + userId: notification.initiator + }, { + transaction: tx + }); + } + const notificationAction = await models.NotificationAction.findOne({ + where: { + notificationId: notificationObj.id, + actionEntityType: actionEntityTypes.User, + actionEntityId: notification.initiator, + blocknumber + }, + transaction: tx + }); + if (notificationAction == null) { + await models.NotificationAction.create({ + notificationId: notificationObj.id, + actionEntityType: actionEntityTypes.User, + actionEntityId: notification.initiator, + blocknumber + }, { + transaction: tx + }); + } + } + return notifications; +} +module.exports = processTierChangeNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/tipNotification.js b/packages/identity-service/build/src/notifications/processNotifications/tipNotification.js new file mode 100644 index 00000000000..3cabb1c885f --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/tipNotification.js @@ -0,0 +1,66 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes } = require('../constants'); +async function processTipNotifications(notifications, tx) { + for (const notification of notifications) { + const { slot, initiator: receiverId, metadata: { amount, entity_id: senderId, tx_signature: tipTxSignature } } = notification; + const senderNotification = await models.SolanaNotification.findOne({ + where: { + slot, + type: notificationTypes.TipSend, + userId: senderId, + entityId: receiverId, + metadata: { + tipTxSignature, + amount + } + }, + transaction: tx + }); + if (senderNotification == null) { + // Create the sender notif + await models.SolanaNotification.create({ + slot, + type: notificationTypes.TipSend, + userId: senderId, + entityId: receiverId, + metadata: { + tipTxSignature, + amount + } + }, { + transaction: tx + }); + } + const receiverNotification = await models.SolanaNotification.findOne({ + where: { + slot, + type: notificationTypes.TipReceive, + userId: receiverId, + entityId: senderId, + metadata: { + tipTxSignature, + amount + } + }, + transaction: tx + }); + if (receiverNotification == null) { + // Create the receiver notif + await models.SolanaNotification.create({ + slot, + type: notificationTypes.TipReceive, + userId: receiverId, + entityId: senderId, + metadata: { + tipTxSignature, + amount + } + }, { + transaction: tx + }); + } + } + return notifications; +} +module.exports = processTipNotifications; diff --git a/packages/identity-service/build/src/notifications/processNotifications/utils.js b/packages/identity-service/build/src/notifications/processNotifications/utils.js new file mode 100644 index 00000000000..30cd4c5164b --- /dev/null +++ b/packages/identity-service/build/src/notifications/processNotifications/utils.js @@ -0,0 +1,44 @@ +"use strict"; +const BN = require('bn.js'); +const WEI = new BN('1000000000000000000'); +const formatNumberCommas = (num) => { + const parts = num.toString().split('.'); + return (parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + + (parts[1] ? '.' + parts[1] : '')); +}; +const trimRightZeros = (number) => { + return number.replace(/(\d)0+$/gm, '$1'); +}; +// Copied from AudiusClient +const formatWei = (amount, shouldTruncate = false, significantDigits = 4) => { + const aud = amount.div(WEI); + const wei = amount.sub(aud.mul(WEI)); + if (wei.isZero()) { + return formatNumberCommas(aud.toString()); + } + const decimals = wei.toString().padStart(18, '0'); + let trimmed = `${aud}.${trimRightZeros(decimals)}`; + if (shouldTruncate) { + let [before, after] = trimmed.split('.'); + // If we have only zeros, just lose the decimal + after = after.substr(0, significantDigits); + if (parseInt(after) === 0) { + trimmed = before; + } + else { + trimmed = `${before}.${after}`; + } + } + return formatNumberCommas(trimmed); +}; +const capitalize = (str) => { + if (!str) + return str; + if (str.length === 1) + return str[0].toUpperCase(); + return `${str[0].toUpperCase()}${str.slice(1, str.length)}`; +}; +module.exports = { + capitalize, + formatWei +}; diff --git a/packages/identity-service/build/src/notifications/pushAnnouncementNotifications.js b/packages/identity-service/build/src/notifications/pushAnnouncementNotifications.js new file mode 100644 index 00000000000..bf75880c386 --- /dev/null +++ b/packages/identity-service/build/src/notifications/pushAnnouncementNotifications.js @@ -0,0 +1,70 @@ +"use strict"; +const models = require('../models'); +const { logger } = require('../logging'); +const { publishAnnouncement, drainPublishedAnnouncements } = require('./notificationQueue'); +const axios = require('axios'); +const config = require('../config.js'); +const audiusNotificationUrl = config.get('audiusNotificationUrl'); +async function pushAnnouncementNotifications() { + const time = Date.now(); + // Read-only tx + const tx = await models.sequelize.transaction(); + try { + const requestUrl = `${audiusNotificationUrl}/index-mobile.json`; + logger.info(`pushAnnouncementNotifications - ${requestUrl}`); + const response = await axios.get(requestUrl); + if (response.data && Array.isArray(response.data.notifications)) { + // TODO: Worth slicing? + for (const notif of response.data.notifications) { + await processAnnouncement(notif, tx); + } + } + // Commit + await tx.commit(); + // Drain pending announcements + const numProcessedNotifs = await drainPublishedAnnouncements(logger); + logger.info(`pushAnnouncementNotifications - processed ${numProcessedNotifs} notifs in ${Date.now() - time}ms`); + } + catch (e) { + logger.error(`pushAnnouncementNotifications error: ${e}`); + await tx.rollback(); // abort the tx + } +} +async function processAnnouncement(notif, tx) { + if (notif.type !== 'announcement') { + return; + } + const pushedNotifRecord = await models.PushedAnnouncementNotifications.findAll({ + where: { + announcementId: notif.id + } + }); + const pendingNotificationPush = pushedNotifRecord.length === 0; + if (!pendingNotificationPush) { + return; + } + await _pushAnnouncement(notif, tx); +} +async function _pushAnnouncement(notif, tx) { + const notifUrl = `${audiusNotificationUrl}/${notif.id}.json`; + const response = await axios.get(notifUrl); + const details = response.data; + const msg = details.shortDescription; + const title = details.title; + // Push notification to all users with a valid device token at this time + const validDeviceRecords = await models.NotificationDeviceToken.findAll({ + where: { + enabled: true + } + }); + await Promise.all(validDeviceRecords.map(async (device) => { + const userId = device.userId; + logger.info(`Sending ${notif.id} to ${userId}`); + await publishAnnouncement(msg, userId, tx, true, title); + })); + // Update database record with notification id + await models.PushedAnnouncementNotifications.create({ + announcementId: notif.id + }); +} +module.exports = { pushAnnouncementNotifications }; diff --git a/packages/identity-service/build/src/notifications/renderEmail/Body.js b/packages/identity-service/build/src/notifications/renderEmail/Body.js new file mode 100644 index 00000000000..1ab6feeb5f3 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/Body.js @@ -0,0 +1,408 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _react = _interopRequireDefault(require('react')); +const _Footer = _interopRequireDefault(require('./Footer')); +const _Notification = _interopRequireDefault(require('./notifications/Notification')); +const _constants = require('../constants'); +let _snippetMap; +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +function _extends() { + _extends = + Object.assign || + function (target) { + for (let i = 1; i < arguments.length; i++) { + const source = arguments[i]; + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + return target; + }; + return _extends.apply(this, arguments); +} +function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } + else { + obj[key] = value; + } + return obj; +} +function _slicedToArray(arr, i) { + return (_arrayWithHoles(arr) || + _iterableToArrayLimit(arr, i) || + _unsupportedIterableToArray(arr, i) || + _nonIterableRest()); +} +function _nonIterableRest() { + throw new TypeError('Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.'); +} +function _unsupportedIterableToArray(o, minLen) { + if (!o) + return; + if (typeof o === 'string') + return _arrayLikeToArray(o, minLen); + let n = Object.prototype.toString.call(o).slice(8, -1); + if (n === 'Object' && o.constructor) + n = o.constructor.name; + if (n === 'Map' || n === 'Set') + return Array.from(o); + if (n === 'Arguments' || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) + return _arrayLikeToArray(o, minLen); +} +function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) + len = arr.length; + for (var i = 0, arr2 = new Array(len); i < len; i++) { + arr2[i] = arr[i]; + } + return arr2; +} +function _iterableToArrayLimit(arr, i) { + if (typeof Symbol === 'undefined' || !(Symbol.iterator in Object(arr))) + return; + const _arr = []; + let _n = true; + let _d = false; + let _e; + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + if (i && _arr.length === i) + break; + } + } + catch (err) { + _d = true; + _e = err; + } + finally { + try { + if (!_n && _i.return != null) + _i.return(); + } + finally { + if (_d) + throw _e; + } + } + return _arr; +} +function _arrayWithHoles(arr) { + if (Array.isArray(arr)) + return arr; +} +const AudiusImage = function AudiusImage() { + return /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://gallery.mailchimp.com/f351897a27ff0a641b8acd9ab/images/b1070e55-9487-4acb-abce-e755484cce46.png', + style: { + maxWidth: '240px', + margin: '27px auto' + }, + alt: 'Audius Logo' + }); +}; +const WhatYouMissed = function WhatYouMissed() { + return /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/whatYouMissed.png', + style: { + maxWidth: '490px', + margin: '0px auto 7px' + }, + alt: 'What You Missed' + }); +}; +const UnreadNotifications = function UnreadNotifications(_ref) { + const message = _ref.message; + return /* #__PURE__ */ _react.default.createElement('p', { + className: 'avenir', + style: { + color: 'rgba(133,129,153,0.5)', + fontSize: '16px', + fontWeight: 500, + letterSpacing: '0.02px', + textAlign: 'center', + margin: '0px auto 24px' + } + }, message); +}; +const getNumberSuffix = function getNumberSuffix(num) { + if (num === 1) + return 'st'; + else if (num === 2) + return 'nd'; + else if (num === 3) + return 'rd'; + return 'th'; +}; +const snippetMap = ((_snippetMap = {}), + _defineProperty(_snippetMap, _constants.notificationTypes.Favorite.base, function (notification) { + const _notification$users = _slicedToArray(notification.users, 1); + const user = _notification$users[0]; + return '' + .concat(user.name, ' favorited your ') + .concat(notification.entity.type.toLowerCase(), ' ') + .concat(notification.entity.name); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.Repost.base, function (notification) { + const _notification$users2 = _slicedToArray(notification.users, 1); + const user = _notification$users2[0]; + return '' + .concat(user.name, ' reposted your ') + .concat(notification.entity.type.toLowerCase(), ' ') + .concat(notification.entity.name); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.Follow, function (notification) { + const _notification$users3 = _slicedToArray(notification.users, 1); + const user = _notification$users3[0]; + return ''.concat(user.name, ' followed you'); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.Announcement, function (notification) { + return notification.text; + }), + _defineProperty(_snippetMap, _constants.notificationTypes.Milestone, function (notification) { + if (notification.entity) { + const entity = notification.entity.type.toLowerCase(); + return 'Your ' + .concat(entity, ' ') + .concat(notification.entity.name, ' has reached over ') + .concat(notification.value, ' ') + .concat(notification.achievement, 's'); + } + else { + return 'You have reached over '.concat(notification.value, ' Followers'); + } + }), + _defineProperty(_snippetMap, _constants.notificationTypes.TrendingTrack, function (notification) { + const rank = notification.rank; + const suffix = getNumberSuffix(rank); + return 'Your Track ' + .concat(notification.entity.title, ' is ') + .concat(notification.rank) + .concat(suffix, ' on Trending Right Now!'); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.UserSubscription, function (notification) { + const _notification$users4 = _slicedToArray(notification.users, 1); + const user = _notification$users4[0]; + if (notification.entity.type === _constants.notificationTypes.Track && + !isNaN(notification.entity.count) && + notification.entity.count > 1) { + return '' + .concat(user.name, ' released ') + .concat(notification.entity.count, ' new ') + .concat(notification.entity.type); + } + return '' + .concat(user.name, ' released a new ') + .concat(notification.entity.type.toLowerCase(), ' ') + .concat(notification.entity.name); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.RemixCreate, function (notification) { + const parentTrack = notification.parentTrack; + return 'New remix of your track '.concat(parentTrack.title); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.RemixCosign, function (notification) { + const parentTrackUser = notification.parentTrackUser; + const parentTracks = notification.parentTracks; + const parentTrack = parentTracks.find(function (t) { + return t.ownerId === parentTrackUser.userId; + }); + return '' + .concat(parentTrackUser.name, ' Co-signed your Remix of ') + .concat(parentTrack.title); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.ChallengeReward, function (notification) { + return "You've earned $AUDIO for completing challenges"; + }), + _defineProperty(_snippetMap, _constants.notificationTypes.AddTrackToPlaylist, function (notification) { + return '' + .concat(notification.playlistOwner.name, ' added ') + .concat(notification.track.title, ' to ') + .concat(notification.playlist.playlist_name); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.TipReceive, function (notification) { + return '' + .concat(notification.sendingUser.name, ' sent you a tip of ') + .concat(notification.amount, ' $AUDIO'); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.Reaction, function (notification) { + return '' + .concat(notification.reactingUser.name, ' reacted to your tip of ') + .concat(notification.amount, ' $AUDIO'); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.SupporterRankUp, function (notification) { + return '' + .concat(notification.sendingUser.name, ' became your #') + .concat(notification.rank, ' top supporter'); + }), + _defineProperty(_snippetMap, _constants.notificationTypes.SupportingRankUp, function (notification) { + return "You're now " + .concat(notification.receivingUser.name, "'s #") + .concat(notification.rank, ' top supporter'); + }), + _snippetMap); +const mapNotification = function mapNotification(notification) { + switch (notification.type) { + case _constants.notificationTypes.RemixCreate: { + notification.users = [notification.remixUser]; + return notification; + } + case _constants.notificationTypes.RemixCosign: { + notification.track = notification.remixTrack; + return notification; + } + default: { + return notification; + } + } +}; // Generate snippet for email composed of the first three notification texts, +// but limited to 90 characters w/ an ellipsis +const SNIPPET_ELLIPSIS_LENGTH = 90; +const getSnippet = function getSnippet(notifications) { + const snippet = notifications + .slice(0, 3) + .map(function (notification) { + return snippetMap[notification.type](notification); + }) + .join(', '); + if (snippet.length <= SNIPPET_ELLIPSIS_LENGTH) + return snippet; + const indexOfEllipsis = snippet.substring(SNIPPET_ELLIPSIS_LENGTH).indexOf(' ') + + SNIPPET_ELLIPSIS_LENGTH; + return ''.concat(snippet.substring(0, indexOfEllipsis), ' ...'); +}; +const Body = function Body(props) { + return /* #__PURE__ */ _react.default.createElement('body', { + bgcolor: '#FFFFFF', + style: { + backgroundColor: '#FFFFFF' + } + }, + /* #__PURE__ */ _react.default.createElement('p', { + style: { + display: 'none', + fontSize: '1px', + color: '#333333', + lineHeight: '1px', + maxHeight: '0px', + maxWidth: '0px', + opacity: 0, + overflow: 'hidden' + }, + dangerouslySetInnerHTML: { + __html: ''.concat(getSnippet(props.notifications), '\n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n \n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n \n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n \n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n \n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n \n ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌\n ') + } + }), + /* #__PURE__ */ _react.default.createElement('center', null, + /* #__PURE__ */ _react.default.createElement('table', { + align: 'center', + border: '0', + cellpadding: '0', + cellspacing: '0', + width: '100%', + id: 'bodyTable', + bgcolor: '#FFFFFF', + style: { + backgroundColor: '#FFFFFF' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + align: 'center', + valign: 'top', + id: 'bodyCell' + }, + /* #__PURE__ */ _react.default.createElement(AudiusImage, null))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + align: 'center', + valign: 'top', + id: 'bodyCell' + }, + /* #__PURE__ */ _react.default.createElement(WhatYouMissed, null))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + align: 'center', + valign: 'top', + id: 'bodyCell' + }, + /* #__PURE__ */ _react.default.createElement(UnreadNotifications, { + message: props.subject + }))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + align: 'center', + valign: 'top', + id: 'bodyCell', + style: { + borderRadius: '4px', + maxWidth: '396px', + marginBottom: '32px' + } + }, props.notifications.map(function (notification, ind) { + return /* #__PURE__ */ _react.default.createElement(_Notification.default, _extends({ + key: ind + }, mapNotification(notification))); + }))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + align: 'center', + valign: 'top', + id: 'bodyCell', + style: { + padding: '24px 0px 32px', + width: '100%' + } + }, + /* #__PURE__ */ _react.default.createElement('table', { + cellspacing: '0', + cellpadding: '0', + style: { + margin: '0px auto' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + style: { + borderRadius: '17px', + margin: '0px auto' + }, + bgcolor: '#7E1BCC' + }, + /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://audius.co/feed?openNotifications=true', + target: '_blank', + style: { + padding: '8px 24px', + fontSize: '14px', + color: '#ffffff', + textDecoration: 'none', + fontWeight: 'bold', + display: 'inline-block' + } + }, 'See more on Audius')))))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + style: { + paddingBottom: '25px' + } + }, + /* #__PURE__ */ _react.default.createElement(_Footer.default, { + copyrightYear: props.copyrightYear + })))))); +}; +const _default = Body; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/BodyStyles.js b/packages/identity-service/build/src/notifications/renderEmail/BodyStyles.js new file mode 100644 index 00000000000..d5768ace4bb --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/BodyStyles.js @@ -0,0 +1,11 @@ +'use strict'; +const React = require('react'); +const BodyStyles = function BodyStyles() { + return /* #__PURE__ */ React.createElement('style', { + type: 'text/css', + dangerouslySetInnerHTML: { + __html: '\n html {\n margin: 0;\n padding: 0;\n }\n\n body {\n margin: 0;\n padding: 0;\n }\n\n p {\n margin: 1em 0;\n padding: 0;\n }\n\n a {\n color: #8F2CE8;\n }\n\n table {\n border-collapse: collapse;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n display: block;\n margin: 0;\n padding: 0;\n }\n\n img,\n a img {\n border: 0;\n height: auto;\n outline: none;\n text-decoration: none;\n }\n\n h2 {\n color: #858199;\n font-size: 32px;\n font-weight: bold;\n letter-spacing: .02px;\n text-align: center;\n margin: 0 auto;\n }\n\n h3 {\n color: #C3C0CC;\n font-size: 16px;\n font-weight: bold;\n letter-spacing: .02px;\n line-height: 25px;\n text-align: center;\n /* margin-bottom: 10px; */\n }\n\n .pink {\n color: #E000DB;\n }\n\n .purple {\n color: #7E1BCC;\n }\n\n body,\n #bodyTable,\n #bodyCell {\n margin: 0;\n padding: 0;\n width: 100%;\n background: #FFFFFF;\n font-family: "Avenir Next LT Pro", Helvetica, Arial, sans-serif;\n }\n\n #outlook a {\n padding: 0;\n }\n\n img {\n -ms-interpolation-mode: bicubic;\n }\n\n table {\n mso-table-lspace: 0;\n mso-table-rspace: 0;\n }\n\n .ReadMsgBody {\n width: 100%;\n }\n\n .ExternalClass {\n width: 100%;\n }\n\n p,\n a,\n li,\n td,\n blockquote {\n mso-line-height-rule: exactly;\n }\n\n p,\n a,\n li,\n td,\n body,\n table,\n blockquote {\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n }\n\n .button {\n background: #E000DB;\n font-size: 24px !important;\n color: #FFFFFF !important;\n font-weight: 700 !important;\n letter-spacing: 0.03px !important;\n text-decoration: none !important;\n user-select: none;\n cursor: pointer;\n line-height: 29px;\n border-radius: 8px;\n text-align: center;\n display: inline-block;\n padding: 16px 32px;\n }\n\n .ExternalClass,\n .ExternalClass p,\n .ExternalClass td,\n .ExternalClass div,\n .ExternalClass span,\n .ExternalClass font {\n line-height: 100%;\n }\n\n .templateImage {\n height: auto;\n max-width: 564px;\n }\n\n #footerContent {\n padding-top: 27px;\n padding-bottom: 18px;\n }\n\n #templateBody {\n padding-right: 94px;\n padding-left: 94px;\n }\n\n .notificationsContainer {\n max-width: 396px;\n margin-bottom: 32px;\n }\n \n .seeMoreOnAudius {\n padding: 8px 24px;\n border-radius: 17px;\n background-color: #7E1BCC;\n margin-bottom: 0px auto 32px;\n color: #FFFFFF;\n font-family: "Avenir Next LT Pro";\n font-size: 14px;\n font-weight: bold;\n letter-spacing: 0.15px;\n text-align: center;\n }\n\n \n .footerContainer {\n width: 100%;\n }\n\n .footerContainer table {\n table-layout: fixed;\n }\n\n #templateHeader {\n background-color: #FFFFFF;\n background-image: none;\n background-repeat: no-repeat;\n background-position: center;\n background-size: cover;\n border-top: 0;\n border-bottom: 0;\n padding-bottom: 0;\n border-radius: 8px 8px 0px 0px;\n }\n\n #templateHeader a,\n #templateHeader p a {\n color: #237A91;\n font-weight: normal;\n text-decoration: underline;\n }\n\n #templateBody {\n background-color: #FFFFFF;\n margin-bottom: 44px;\n padding-bottom: 36px;\n height: 100%;\n width: 100%;\n max-width: 720px;\n border-radius: 0 0 8px 8px;\n box-shadow: 0 16px 20px 0 rgba(232, 228, 234, 0.5);\n }\n\n #templateBody,\n #templateBody p {\n color: #858199;\n font-size: 18px;\n letter-spacing: .02px;\n line-height: 25px;\n text-align: center;\n }\n\n #templateBody a,\n #templateBody p a {\n color: #7E1BCC;\n font-weight: normal;\n text-decoration: underline;\n }\n\n #templateFooter {\n border-top: 1px solid #DAD9E0;\n width: 100%;\n padding: 0;\n background-color: #fbfbfc;\n }\n\n #socialBar img {\n height: 24px;\n width: 24px;\n padding: 0 20px;\n }\n #socialBar a:first-child img {\n padding: 0 20px 0 0;\n }\n\n #socialBar,\n #socialBar p {\n color: #C2C0CC;\n font-size: 16px;\n font-weight: bold;\n letter-spacing: .2px;\n text-align: center;\n }\n\n #templateFooter,\n #templateFooter p {\n color: #858199;\n font-family: "Gilroy", "Avenir Next LT Pro", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 20px;\n text-align: center;\n }\n\n #templateFooter a,\n #templateFooter p a {\n vertical-align: bottom;\n color: #656565;\n font-weight: normal;\n text-decoration: underline;\n }\n\n #utilityBar {\n border: 0;\n padding-top: 9px;\n padding-bottom: 9px;\n }\n\n #utilityBar,\n #utilityBar p {\n color: #C2C0CC;\n font-size: 12px;\n line-height: 150%;\n text-align: center;\n }\n\n #utilityBar a,\n #utilityBar p a {\n color: #C2C0CC;\n font-weight: normal;\n text-decoration: underline;\n }\n\n .arrowIcon {\n height:8px;\n width:8px;\n margin-left:4px;\n display:inline-block;\n opacity: 0.5;\n }\n\n .arrowIcon path {\n fill: #858199;\n }\n\n @media (max-width: 720px) {\n\n body,\n table,\n td,\n p,\n a,\n li,\n blockquote {\n -webkit-text-size-adjust: none !important;\n }\n }\n\n @media (max-width: 720px) {\n .button {\n padding: 16px 24px;\n }\n }\n\n @media (max-width: 720px) {\n body {\n width: 100% !important;\n min-width: 100% !important;\n }\n }\n\n @media (max-width: 720px) {\n .templateImage {\n width: 100% !important;\n }\n }\n\n @media (max-width: 720px) {\n .columnContainer {\n max-width: 100% !important;\n width: 100% !important;\n }\n }\n\n @media (max-width: 720px) {\n .mobileHide {\n display: none;\n }\n }\n\n @media (max-width: 720px) {\n .utilityLink {\n display: block;\n padding: 9px 0;\n }\n }\n\n @media (max-width: 720px) {\n h1 {\n font-size: 22px !important;\n line-height: 175% !important;\n }\n }\n\n @media (max-width: 720px) {\n h4 {\n font-size: 16px !important;\n line-height: 175% !important;\n }\n }\n\n @media (max-width: 720px) {\n p {\n font-size: 16px !important;\n }\n }\n\n @media (max-width: 720px) {\n\n #templateHeader,\n #templateHeader p {\n font-size: 16px !important;\n line-height: 150% !important;\n }\n }\n\n @media (max-width: 720px) {\n #bodyCell {\n text-align: center;\n }\n }\n\n @media (max-width: 720px) {\n .templateContainer {\n display: table !important;\n max-width: 384px !important;\n width: 80% !important;\n margin-left: auto !important;\n margin-right: auto !important;\n }\n }\n\n @media (max-width: 720px) {\n #templateBody {\n padding-right: 13px !important;\n padding-left: 13px !important;\n }\n }\n\n @media (max-width: 720px) {\n\n #templateBody,\n #templateBody p {\n font-size: 16px !important;\n line-height: 150% !important;\n }\n }\n\n @media (max-width: 720px) {\n .footerContainer table td {\n /* display: block; */\n }\n }\n\n @media (max-width: 720px) {\n\n #templateFooter,\n #templateFooter p {\n font-size: 14px !important;\n line-height: 150% !important;\n }\n }\n\n @media (max-width: 720px) {\n\n #socialBar,\n #socialBar p {\n font-size: 14px !important;\n line-height: 150% !important;\n }\n }\n\n @media (max-width: 720px) {\n\n #utilityBar,\n #utilityBar p {\n font-size: 14px !important;\n line-height: 150% !important;\n }\n }\n ' + } + }); +}; +module.exports = BodyStyles; diff --git a/packages/identity-service/build/src/notifications/renderEmail/Comment.js b/packages/identity-service/build/src/notifications/renderEmail/Comment.js new file mode 100644 index 00000000000..3d816c38f4f --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/Comment.js @@ -0,0 +1,25 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _server = _interopRequireDefault(require('react-dom/server')); +const _react = _interopRequireDefault(require('react')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const WrapComponent = function WrapComponent(props) { + const renderedChildren = _server.default.renderToStaticMarkup(props.children); + return /* #__PURE__ */ _react.default.createElement('div', { + dangerouslySetInnerHTML: { + __html: '\n \n ') + .concat(renderedChildren, '\n \n ') + } + }); +}; +const _default = WrapComponent; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/FontStyles.js b/packages/identity-service/build/src/notifications/renderEmail/FontStyles.js new file mode 100644 index 00000000000..a15521a29a5 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/FontStyles.js @@ -0,0 +1,16 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const React = require('react'); +const FontStyles = function FontStyles() { + return /* #__PURE__ */ React.createElement('style', { + type: 'text/css', + dangerouslySetInnerHTML: { + __html: "\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 100;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-UltLt.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-UltLt.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 400;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-Regular.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-Regular.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 500;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-Medium.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-Medium.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 600;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-Demi-Bold.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-Demi-Bold.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 700;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-Bold.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-Bold.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Avenir Next LT Pro';\n font-style: normal;\n font-weight: 900;\n src: url(https://download.audius.co/fonts/AvenirNextLTPro-Heavy.ttf) format(\"truetype\"), url(https://download.audius.co/fonts/AvenirNextLTPro-Heavy.otf) format(\"opentype\");\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 950;\n src: url(https://download.audius.co/fonts/Gilroy-Black.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Black.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 900;\n src: url(https://download.audius.co/fonts/Gilroy-Heavy.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Heavy.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 800;\n src: url(https://download.audius.co/fonts/Gilroy-ExtraBold.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-ExtraBold.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 700;\n src: url(https://download.audius.co/fonts/Gilroy-Bold.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Bold.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 600;\n src: url(https://download.audius.co/fonts/Gilroy-SemiBold.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-SemiBold.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 500;\n src: url(https://download.audius.co/fonts/Gilroy-Medium.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Medium.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 400;\n src: url(https://download.audius.co/fonts/Gilroy-Regular.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Regular.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 300;\n src: url(https://download.audius.co/fonts/Gilroy-Light.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Light.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 200;\n src: url(https://download.audius.co/fonts/Gilroy-UltraLight.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-UltraLight.otf) format('opentype')\n }\n\n @font-face {\n font-family: 'Gilroy';\n font-style: normal;\n font-weight: 100;\n src: url(https://download.audius.co/fonts/Gilroy-Thin.ttf) format('truetype'), url(https://download.audius.co/fonts/Gilroy-Thin.otf) format('opentype')\n }\n\n .gilroy { \n font-family: 'Gilroy';\n }\n .avenir {\n font-family: 'Avenir Next LT Pro';\n }\n\n" + } + }); +}; +const _default = FontStyles; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/Footer.js b/packages/identity-service/build/src/notifications/renderEmail/Footer.js new file mode 100644 index 00000000000..199020e913b --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/Footer.js @@ -0,0 +1,150 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _react = _interopRequireDefault(require('react')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const iconStyle = { + height: '24px', + width: '24px', + padding: '0px 24px' +}; +const InstagramLink = function InstagramLink() { + return /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://www.instagram.com/audiusmusic/' + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/iconInsta.png', + alt: 'instagram', + style: iconStyle + })); +}; +const TwitterLink = function TwitterLink() { + return /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://twitter.com/AudiusProject' + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/iconTwitter.png', + alt: 'twitter', + style: iconStyle + })); +}; +const DiscordLink = function DiscordLink() { + return /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://discordapp.com/invite/yNUg2e2' + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/iconDiscord.png', + alt: 'discord', + style: iconStyle + })); +}; +const MadeWithLove = function MadeWithLove() { + return /* #__PURE__ */ _react.default.createElement('div', { + className: 'gilroy', + style: { + textAlign: 'center', + color: '#858199', + fontSize: '14px' + } + }, 'Made with ', + /* #__PURE__ */ _react.default.createElement('span', { + style: { + color: '#7E1BCC' + } + }, '\u2665\uFE0E'), ' in SF & LA'); +}; +const AllRightsReserved = function AllRightsReserved(_ref) { + const copyrightYear = _ref.copyrightYear; + return /* #__PURE__ */ _react.default.createElement('div', { + className: 'gilroy', + style: { + textAlign: 'center', + color: '#858199', + fontSize: '14px' + } + }, '\xA9 ', copyrightYear, ' Audius, Inc. All Rights Reserved.'); +}; +const Unsubscribe = function Unsubscribe() { + return /* #__PURE__ */ _react.default.createElement('div', { + className: 'gilroy', + style: { + textAlign: 'center', + color: '#858199', + fontSize: '14px' + } + }, 'Tired of seeing these emails? ', + /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://audius.co/settings', + class: 'utilityLink', + style: { + textDecorationColor: '#858199' + } + }, + /* #__PURE__ */ _react.default.createElement('span', { + style: { + color: '#858199' + } + }, 'Update your notification preferences')), + /* #__PURE__ */ _react.default.createElement('span', { + class: 'mobileHide' + })); +}; +const Footer = function Footer(props) { + return /* #__PURE__ */ _react.default.createElement('table', { + border: '0', + cellpadding: '0', + cellspacing: '0', + style: { + margin: '0px auto', + height: 'auto', + paddingBotton: '25px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'center', + id: 'socialBar', + style: { + textAlign: 'center', + padding: '25px 0px 20px' + } + }, + /* #__PURE__ */ _react.default.createElement(InstagramLink, null), + /* #__PURE__ */ _react.default.createElement(TwitterLink, null), + /* #__PURE__ */ _react.default.createElement(DiscordLink, null))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'center', + style: { + textAlign: 'center', + padding: '0px 0px 8px', + margin: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement(MadeWithLove, null))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + style: { + textAlign: 'center', + verticalAlign: 'center', + height: 'auto', + padding: '0px 0px 12px', + margin: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement(AllRightsReserved, { + copyrightYear: props.copyrightYear + }))), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'top', + id: 'utilityBar' + }, + /* #__PURE__ */ _react.default.createElement(Unsubscribe, null)))); +}; +const _default = Footer; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/Head.js b/packages/identity-service/build/src/notifications/renderEmail/Head.js new file mode 100644 index 00000000000..22e4b1759e5 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/Head.js @@ -0,0 +1,38 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _react = _interopRequireDefault(require('react')); +const _BodyStyles = _interopRequireDefault(require('./BodyStyles')); +const _FontStyles = _interopRequireDefault(require('./FontStyles')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const Head = function Head(props) { + return /* #__PURE__ */ _react.default.createElement('div', null, + /* #__PURE__ */ _react.default.createElement('div', { + dangerouslySetInnerHTML: { + __html: '\n \n ' + } + }), + /* #__PURE__ */ _react.default.createElement('meta', { + charset: 'UTF-8' + }), + /* #__PURE__ */ _react.default.createElement('meta', { + 'http-equiv': 'x-ua-compatible', + content: 'IE=edge' + }), + /* #__PURE__ */ _react.default.createElement('meta', { + name: 'viewport', + content: 'width=device-width, initial-scale=1' + }), + /* #__PURE__ */ _react.default.createElement('meta', { + name: 'x-apple-disable-message-reformatting' + }), + /* #__PURE__ */ _react.default.createElement('title', null, props.title), + /* #__PURE__ */ _react.default.createElement(_FontStyles.default, null), + /* #__PURE__ */ _react.default.createElement(_BodyStyles.default, null)); +}; +const _default = Head; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/index.js b/packages/identity-service/build/src/notifications/renderEmail/index.js new file mode 100644 index 00000000000..066c41b2b43 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/index.js @@ -0,0 +1,20 @@ +'use strict'; +const _server = _interopRequireDefault(require('react-dom/server')); +const _react = _interopRequireDefault(require('react')); +const _Head = _interopRequireDefault(require('./Head')); +const _Body = _interopRequireDefault(require('./Body')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const NotificationEmail = function NotificationEmail(props) { + return /* #__PURE__ */ _react.default.createElement('html', null, + /* #__PURE__ */ _react.default.createElement(_Head.default, { + title: props.title + }), + /* #__PURE__ */ _react.default.createElement(_Body.default, props)); +}; +const renderNotificationsEmail = function renderNotificationsEmail(props) { + return _server.default.renderToString( + /* #__PURE__ */ _react.default.createElement(NotificationEmail, props)); +}; +module.exports = renderNotificationsEmail; diff --git a/packages/identity-service/build/src/notifications/renderEmail/notifications/Icons.js b/packages/identity-service/build/src/notifications/renderEmail/notifications/Icons.js new file mode 100644 index 00000000000..885e5586b16 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/notifications/Icons.js @@ -0,0 +1,77 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.TrebleClefIcon = + exports.MoneyMouthFaceIcon = + exports.MultipleMusicalNotesIcon = + exports.MobilePhoneWithArrowIcon = + exports.HeadphoneIcon = + exports.IncomingEnvelopeIcon = + exports.WhiteHeavyCheckMarkIcon = + void 0; +const _react = _interopRequireDefault(require('react')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const iconStyles = { + height: '16px', + maxWidth: '16px', + margin: '0px 8px 0px 0px' +}; +const WhiteHeavyCheckMarkIcon = function WhiteHeavyCheckMarkIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Heavy Check Mark Icon', + src: 'https://download.audius.co/static-resources/email/white-heavy-check-mark.png' + }); +}; +exports.WhiteHeavyCheckMarkIcon = WhiteHeavyCheckMarkIcon; +const IncomingEnvelopeIcon = function IncomingEnvelopeIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Incoming Envelope', + src: 'https://download.audius.co/static-resources/email/incoming-envelope.png' + }); +}; +exports.IncomingEnvelopeIcon = IncomingEnvelopeIcon; +const HeadphoneIcon = function HeadphoneIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Headphone Icon', + src: 'https://download.audius.co/static-resources/email/headphone.png' + }); +}; +exports.HeadphoneIcon = HeadphoneIcon; +const MobilePhoneWithArrowIcon = function MobilePhoneWithArrowIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Mobile Phone With Arrow Icon', + src: 'https://download.audius.co/static-resources/email/mobile-phone-with-arrow.png' + }); +}; +exports.MobilePhoneWithArrowIcon = MobilePhoneWithArrowIcon; +const MultipleMusicalNotesIcon = function MultipleMusicalNotesIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Multiple Musical Notes Icon', + src: 'https://download.audius.co/static-resources/email/multiple-musical-notes.png' + }); +}; +exports.MultipleMusicalNotesIcon = MultipleMusicalNotesIcon; +const MoneyMouthFaceIcon = function MoneyMouthFaceIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Money Face Icon', + src: 'https://download.audius.co/static-resources/email/money-mouth-face.png' + }); +}; +exports.MoneyMouthFaceIcon = MoneyMouthFaceIcon; +const TrebleClefIcon = function TrebleClefIcon() { + return /* #__PURE__ */ _react.default.createElement('img', { + style: iconStyles, + alt: 'Treble Clef Icon', + src: 'https://download.audius.co/static-resources/email/treble-clef.png' + }); +}; +exports.TrebleClefIcon = TrebleClefIcon; diff --git a/packages/identity-service/build/src/notifications/renderEmail/notifications/MultiUserHeader.js b/packages/identity-service/build/src/notifications/renderEmail/notifications/MultiUserHeader.js new file mode 100644 index 00000000000..db7936fd472 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/notifications/MultiUserHeader.js @@ -0,0 +1,94 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _react = _interopRequireDefault(require('react')); +const _utils = require('./utils'); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const MAX_USERS = 9; +const MultiUserHeader = function MultiUserHeader(_ref) { + const users = _ref.users; + const hasExtra = users.length > MAX_USERS; + return /* #__PURE__ */ _react.default.createElement('table', { + align: 'center', + border: '0', + width: '100%', + cellpadding: '0', + cellspacing: '0', + style: { + margin: '0px', + padding: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'top', + className: 'headerNotification', + height: '100%', + width: '100%', + style: { + padding: '0px 0px 12px 0px', + borderBottom: '1px solid #F2F2F4' + } + }, + /* #__PURE__ */ _react.default.createElement('table', { + align: 'center', + border: '0', + cellpadding: '0', + cellspacing: '0', + style: { + margin: '0px', + padding: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, users.slice(0, MAX_USERS).map(function (user) { + return /* #__PURE__ */ _react.default.createElement('td', { + colSpan: '1' + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: user.image, + style: { + height: '32px', + width: '32px', + borderRadius: '50%', + marginRight: '5px' + }, + alt: user.name, + title: user.name + })); + }), hasExtra && + /* #__PURE__ */ _react.default.createElement('td', { + colSpan: '1' + }, + /* #__PURE__ */ _react.default.createElement('table', { + align: 'center', + border: '0', + cellpadding: '0', + cellspacing: '0', + width: '100%', + style: { + margin: '0px', + padding: '0px', + height: '34px', + width: '34px', + borderCollapse: 'separate', + borderRadius: '50%', + border: '1px solid #C2C0CC' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + className: 'avenir', + style: { + textAlign: 'center', + color: '#AAA7B8', + fontSize: '11px', + fontWeight: 'bold' + } + }, '+'.concat((0, _utils.formatCount)(users.length - MAX_USERS))))))))))); +}; +const _default = MultiUserHeader; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/notifications/Notification.js b/packages/identity-service/build/src/notifications/renderEmail/notifications/Notification.js new file mode 100644 index 00000000000..0f3cfe7d9d7 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/notifications/Notification.js @@ -0,0 +1,632 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = + exports.getTrackLink = + exports.getEntity = + exports.getUsers = + void 0; +const _react = _interopRequireDefault(require('react')); +const _formatNotificationMetadata = require('../../formatNotificationMetadata'); +const _NotificationBody = _interopRequireDefault(require('./NotificationBody')); +const _Icons = require('./Icons'); +const _constants = require('../../constants'); +const _utils = require('../../processNotifications/utils'); +let _notificationMap; +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +function _extends() { + _extends = + Object.assign || + function (target) { + for (let i = 1; i < arguments.length; i++) { + const source = arguments[i]; + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + return target; + }; + return _extends.apply(this, arguments); +} +function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } + else { + obj[key] = value; + } + return obj; +} +function _slicedToArray(arr, i) { + return (_arrayWithHoles(arr) || + _iterableToArrayLimit(arr, i) || + _unsupportedIterableToArray(arr, i) || + _nonIterableRest()); +} +function _nonIterableRest() { + throw new TypeError('Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.'); +} +function _unsupportedIterableToArray(o, minLen) { + if (!o) + return; + if (typeof o === 'string') + return _arrayLikeToArray(o, minLen); + let n = Object.prototype.toString.call(o).slice(8, -1); + if (n === 'Object' && o.constructor) + n = o.constructor.name; + if (n === 'Map' || n === 'Set') + return Array.from(o); + if (n === 'Arguments' || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) + return _arrayLikeToArray(o, minLen); +} +function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) + len = arr.length; + for (var i = 0, arr2 = new Array(len); i < len; i++) { + arr2[i] = arr[i]; + } + return arr2; +} +function _iterableToArrayLimit(arr, i) { + if (typeof Symbol === 'undefined' || !(Symbol.iterator in Object(arr))) + return; + const _arr = []; + let _n = true; + let _d = false; + let _e; + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + if (i && _arr.length === i) + break; + } + } + catch (err) { + _d = true; + _e = err; + } + finally { + try { + if (!_n && _i.return != null) + _i.return(); + } + finally { + if (_d) + throw _e; + } + } + return _arr; +} +function _arrayWithHoles(arr) { + if (Array.isArray(arr)) + return arr; +} +const challengeRewardsConfig = { + referred: { + title: 'Invite your Friends', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.IncomingEnvelopeIcon, null) + }, + referrals: { + title: 'Invite your Friends', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.IncomingEnvelopeIcon, null) + }, + 'ref-v': { + title: 'Invite your Fans', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.IncomingEnvelopeIcon, null) + }, + 'connect-verified': { + title: 'Link Verified Accounts', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.WhiteHeavyCheckMarkIcon, null) + }, + 'listen-streak': { + title: 'Listening Streak: 7 Days', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.HeadphoneIcon, null) + }, + 'mobile-install': { + title: 'Get the Audius Mobile App', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.MobilePhoneWithArrowIcon, null) + }, + 'profile-completion': { + title: 'Complete Your Profile', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.WhiteHeavyCheckMarkIcon, null) + }, + 'track-upload': { + title: 'Upload 3 Tracks', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.MultipleMusicalNotesIcon, null) + }, + 'send-first-tip': { + title: 'Send Your First Tip', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.MoneyMouthFaceIcon, null) + }, + 'first-playlist': { + title: 'Create a Playlist', + icon: /* #__PURE__ */ _react.default.createElement(_Icons.TrebleClefIcon, null) + } +}; +const EntityType = Object.freeze({ + Track: 'Track', + Album: 'Album', + Playlist: 'Playlist' +}); +const HighlightText = function HighlightText(_ref) { + const text = _ref.text; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'avenir', + style: { + color: '#7E1BCC', + fontSize: '14px', + fontWeight: '500' + } + }, text); +}; +const BodyText = function BodyText(_ref2) { + const text = _ref2.text; + const className = _ref2.className; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'avenir '.concat(className), + style: { + color: '#858199', + fontSize: '14px', + fontWeight: '500' + } + }, text); +}; +const getUsers = function getUsers(users) { + const _users = _slicedToArray(users, 1); + const firstUser = _users[0]; + if (users.length > 1) { + const userCount = users.length - 1; + return /* #__PURE__ */ _react.default.createElement(_react.default.Fragment, null, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: firstUser.name + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' and ' + .concat(userCount.toLocaleString(), ' other') + .concat(users.length > 2 ? 's' : '') + })); + } + return /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: firstUser.name + }); +}; +exports.getUsers = getUsers; +const getEntity = function getEntity(entity) { + if (entity.type === EntityType.Track) { + return /* #__PURE__ */ _react.default.createElement(_react.default.Fragment, null, ' ', + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'track ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: entity.name + }), ' '); + } + else if (entity.type === EntityType.Album) { + return /* #__PURE__ */ _react.default.createElement(_react.default.Fragment, null, ' ', + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'album ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: entity.name + }), ' '); + } + else if (entity.type === EntityType.Playlist) { + return /* #__PURE__ */ _react.default.createElement(_react.default.Fragment, null, ' ', + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'playlist ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: entity.name + }), ' '); + } +}; +exports.getEntity = getEntity; +const notificationMap = ((_notificationMap = {}), + _defineProperty(_notificationMap, _constants.notificationTypes.Favorite.base, function (notification) { + const user = getUsers(notification.users); + const entity = getEntity(notification.entity); + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, user, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' favorited your ' + }), entity); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.Repost.base, function (notification) { + const user = getUsers(notification.users); + const entity = getEntity(notification.entity); + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, user, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' reposted your ' + }), entity); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.Follow, function (notification) { + const user = getUsers(notification.users); + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, user, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' followed you' + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.Announcement, function (notification) { + return /* #__PURE__ */ _react.default.createElement(BodyText, { + className: 'notificationText', + text: notification.text + }); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.Milestone, function (notification) { + if (notification.entity) { + const entity = notification.entity.type.toLowerCase(); + const highlight = notification.entity.name; + const count = notification.value; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'Your '.concat(entity, ' ') + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: highlight + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' has reached over ' + .concat(count.toLocaleString(), ' ') + .concat(notification.achievement, 's') + })); + } + else { + return /* #__PURE__ */ _react.default.createElement(BodyText, { + className: 'notificationText', + text: 'You have reached over '.concat(notification.value, ' Followers ') + }); + } + }), + _defineProperty(_notificationMap, _constants.notificationTypes.TrendingTrack, function (notification) { + const highlight = notification.entity.title; + const rank = notification.rank; + const rankSuffix = (0, _formatNotificationMetadata.getRankSuffix)(rank); + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'Your Track ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: highlight + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' is ' + .concat(rank) + .concat(rankSuffix, ' on Trending Right Now! \uD83C\uDF7E') + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.UserSubscription, function (notification) { + const _notification$users = _slicedToArray(notification.users, 1); + const user = _notification$users[0]; + if (notification.entity.type === _constants.notificationTypes.Track && + !isNaN(notification.entity.count) && + notification.entity.count > 1) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: user.name + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' released ' + .concat(notification.entity.count, ' new ') + .concat(notification.entity.type) + })); + } + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: user.name + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' released a new ' + .concat(notification.entity.type, ' ') + .concat(notification.entity.name) + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.RemixCreate, function (notification) { + const remixUser = notification.remixUser; + const remixTrack = notification.remixTrack; + const parentTrackUser = notification.parentTrackUser; + const parentTrack = notification.parentTrack; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: remixTrack.title + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' by ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: remixUser.name + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.RemixCosign, function (notification) { + const parentTrackUser = notification.parentTrackUser; + const parentTracks = notification.parentTracks; + const parentTrack = parentTracks.find(function (t) { + return t.owner_id === parentTrackUser.user_id; + }); + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: parentTrackUser.name + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' Co-signed your Remix of ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: parentTrack.title + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.ChallengeReward, function (notification) { + const rewardAmount = notification.rewardAmount; + const _challengeRewardsConf = challengeRewardsConfig[notification.challengeId]; + const title = _challengeRewardsConf.title; + const icon = _challengeRewardsConf.icon; + let bodyText; + if (notification.challengeId === 'referred') { + bodyText = 'You\u2019ve received '.concat(rewardAmount, ' $AUDIO for being referred! Invite your friends to join to earn more!'); + } + else { + bodyText = 'You\u2019ve earned '.concat(rewardAmount, ' $AUDIO for completing this challenge!'); + } + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement('table', { + cellspacing: '0', + cellpadding: '0', + style: { + marginBottom: '4px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', null, icon), + /* #__PURE__ */ _react.default.createElement('td', null, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: title + })))), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: bodyText + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.AddTrackToPlaylist, function (notification) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: notification.playlistOwner.name + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' added your track ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: notification.track.title + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' to their playlist ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: notification.playlist.playlist_name + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.Reaction, function (notification) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: (0, _utils.capitalize)(notification.reactingUser.name) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' reacted to your tip of ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: notification.amount + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' $AUDIO' + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.SupporterRankUp, function (notification) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: (0, _utils.capitalize)(notification.sendingUser.name) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' became your ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: '#'.concat(notification.rank) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' Top Supporter!' + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.SupportingRankUp, function (notification) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: "You're now " + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: (0, _utils.capitalize)(notification.receivingUser.name) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: "'s " + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: '#'.concat(notification.rank) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' Top Supporter!' + })); + }), + _defineProperty(_notificationMap, _constants.notificationTypes.TipReceive, function (notification) { + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: (0, _utils.capitalize)(notification.sendingUser.name) + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' sent you a tip of ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: notification.amount + }), + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: ' $AUDIO' + })); + }), + _notificationMap); +const getMessage = function getMessage(notification) { + const getNotificationMessage = notificationMap[notification.type]; + if (!getNotificationMessage) + return null; + return getNotificationMessage(notification); +}; +const getTitle = function getTitle(notification) { + switch (notification.type) { + case _constants.notificationTypes.RemixCreate: { + const parentTrack = notification.parentTrack; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(BodyText, { + text: 'New remix of your track ' + }), + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: parentTrack.title + })); + } + default: + return null; + } +}; +const getTrackMessage = function getTrackMessage(notification) { + switch (notification.type) { + case _constants.notificationTypes.RemixCosign: { + const remixTrack = notification.remixTrack; + return /* #__PURE__ */ _react.default.createElement('span', { + className: 'notificationText' + }, + /* #__PURE__ */ _react.default.createElement(HighlightText, { + text: remixTrack.title + })); + } + default: + return null; + } +}; +const getTrackLink = function getTrackLink(track) { + return 'https://audius.co/'.concat(track.route_id, '-').concat(track.track_id); +}; +exports.getTrackLink = getTrackLink; +const getTwitter = function getTwitter(notification) { + switch (notification.type) { + case _constants.notificationTypes.RemixCreate: { + const parentTrack = notification.parentTrack; + const parentTrackUser = notification.parentTrackUser; + const remixUser = notification.remixUser; + const remixTrack = notification.remixTrack; + const twitterHandle = parentTrackUser.twitterHandle + ? '@'.concat(parentTrackUser.twitterHandle) + : parentTrackUser.name; + const text = 'New remix of ' + .concat(parentTrack.title, ' by ') + .concat(twitterHandle, ' on @AudiusProject #Audius'); + const url = getTrackLink(remixTrack); + return { + message: 'Share With Your Friends', + href: 'http://twitter.com/share?url=' + .concat(encodeURIComponent(url), '&text=') + .concat(encodeURIComponent(text)) + }; + } + case _constants.notificationTypes.RemixCosign: { + const parentTracks = notification.parentTracks; + const _parentTrackUser = notification.parentTrackUser; + const _remixTrack = notification.remixTrack; + const _parentTrack = parentTracks.find(function (t) { + return t.owner_id === _parentTrackUser.user_id; + }); + const _url = getTrackLink(_remixTrack); + const _twitterHandle = _parentTrackUser.twitterHandle + ? '@'.concat(_parentTrackUser.twitterHandle) + : _parentTrackUser.name; + const _text = 'My remix of ' + .concat(_parentTrack.title, ' was Co-Signed by ') + .concat(_twitterHandle, ' on @AudiusProject #Audius'); + return { + message: 'Share With Your Friends', + href: 'http://twitter.com/share?url=' + .concat(encodeURIComponent(_url), '&text=') + .concat(encodeURIComponent(_text)) + }; + } + case _constants.notificationTypes.TrendingTrack: { + const rank = notification.rank; + const entity = notification.entity; + const _url2 = getTrackLink(entity); + const rankSuffix = (0, _formatNotificationMetadata.getRankSuffix)(rank); + const _text2 = 'My track ' + .concat(entity.title, ' is trending ') + .concat(rank) + .concat(rankSuffix, ' on @AudiusProject! #AudiusTrending #Audius'); + return { + message: 'Share this Milestone', + href: 'http://twitter.com/share?url=' + .concat(encodeURIComponent(_url2), '&text=') + .concat(encodeURIComponent(_text2)) + }; + } + case _constants.notificationTypes.ChallengeReward: { + const _text3 = 'I earned $AUDIO for completing challenges on @AudiusProject #AudioRewards'; + return { + message: 'Share this with your fans', + href: 'http://twitter.com/share?text='.concat(encodeURIComponent(_text3)) + }; + } + default: + return null; + } +}; +const Notification = function Notification(props) { + const message = getMessage(props); + const title = getTitle(props); + const trackMessage = getTrackMessage(props); + const twitter = getTwitter(props); + return /* #__PURE__ */ _react.default.createElement(_NotificationBody.default, _extends({}, props, { + title: title, + message: message, + trackMessage: trackMessage, + twitter: twitter + })); +}; +const _default = Notification; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/notifications/NotificationBody.js b/packages/identity-service/build/src/notifications/renderEmail/notifications/NotificationBody.js new file mode 100644 index 00000000000..efaad067571 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/notifications/NotificationBody.js @@ -0,0 +1,296 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.default = void 0; +const _react = _interopRequireDefault(require('react')); +const _MultiUserHeader = _interopRequireDefault(require('./MultiUserHeader')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +const UserImage = function UserImage(_ref) { + const user = _ref.user; + return /* #__PURE__ */ _react.default.createElement('img', { + src: user.image || user.thumbnail, + style: { + height: '32px', + width: '32px', + borderRadius: '50%' + } + }); +}; +const TrackImage = function TrackImage(_ref2) { + const track = _ref2.track; + return /* #__PURE__ */ _react.default.createElement('img', { + src: track.image || track.thumbnail, + style: { + height: '42px', + width: '42px', + borderRadius: '3px' + } + }); +}; +const AnnouncementHeader = function AnnouncementHeader() { + return /* #__PURE__ */ _react.default.createElement(_react.default.Fragment, null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '1', + valign: 'center', + style: { + padding: '16px 16px 0px 16px' + } + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/announcement.png', + style: { + height: '32px', + width: '32px', + borderRadius: '50%', + display: 'block' + }, + alt: 'Announcement', + titile: 'Announcement' + })), + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '11', + style: { + width: '100%', + padding: '16px 0px 0px' + } + }, + /* #__PURE__ */ _react.default.createElement('span', { + className: 'avenir', + style: { + color: '#858199', + fontSize: '16px', + fontWeight: 'bold' + } + }, 'Announcement'))); +}; +const OpenAudiusLink = function OpenAudiusLink() { + return /* #__PURE__ */ _react.default.createElement('a', { + className: 'avenir', + style: { + width: '337px', + color: '#C2C0CC', + fontSize: '12px', + fontWeight: 600, + lineHeight: '10px' + } + }, 'Open Audius', + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/iconArrow.png', + style: { + height: '8px', + width: '8px', + marginLeft: '4px', + display: 'inline-block' + }, + alt: 'Arrow Right', + titile: 'Arrow Right', + className: 'arrowIcon' + })); +}; +const WrapLink = function WrapLink(props) { + return /* #__PURE__ */ _react.default.createElement('a', { + href: 'https://audius.co/feed?openNotifications=true', + style: { + textDecoration: 'none' + } + }, props.children); +}; +const Body = function Body(props) { + const hasUsers = Array.isArray(props.users); + const hasMultiUser = hasUsers && props.users.length > 1; + return /* #__PURE__ */ _react.default.createElement('table', { + border: '0', + cellpadding: '0', + cellspacing: '0', + style: { + borderCollapse: 'separate', + border: '1px solid rgba(133,129,153,0.2)', + borderColor: 'rgba(133,129,153,0.2)', + borderRadius: '4px', + height: 'auto', + width: '100%', + maxWidth: '396px', + marginBottom: '8px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', null, + /* #__PURE__ */ _react.default.createElement(WrapLink, null, + /* #__PURE__ */ _react.default.createElement('table', { + border: '0', + cellpadding: '0', + cellspacing: '0', + style: { + borderCollapse: 'separate', + height: 'auto', + width: '100%' + } + }, props.type === 'Announcement' && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement(AnnouncementHeader, null)), props.title && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '12', + valign: 'center', + style: { + padding: '16px 16px 0px', + paddingTop: '16px', + borderRadius: '4px' + } + }, props.title)), hasMultiUser && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '12', + valign: 'center', + style: { + padding: '16px 16px 0px', + paddingTop: props.type === 'Announcement' || props.title + ? '8px' + : '16px', + borderRadius: '4px' + } + }, + /* #__PURE__ */ _react.default.createElement(_MultiUserHeader.default, { + users: props.users + }))), + /* #__PURE__ */ _react.default.createElement('tr', null, hasUsers && + !hasMultiUser && + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '1', + valign: 'center', + style: { + padding: '12px 0px 8px 16px', + width: '60px' + } + }, + /* #__PURE__ */ _react.default.createElement(UserImage, { + user: props.users[0] + })), + /* #__PURE__ */ _react.default.createElement('td', { + colspan: hasUsers && !hasMultiUser ? 11 : 12, + valign: 'center', + style: { + padding: '12px 16px 8px', + paddingLeft: hasUsers && !hasMultiUser ? '12px' : '16px', + paddingTop: props.title + ? '8px' + : hasUsers && !hasMultiUser + ? '12px' + : '16px', + width: '100%' + } + }, props.message)), props.trackMessage && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '1', + valign: 'center', + style: { + padding: '6px 0px 8px 16px', + width: '60px' + } + }, + /* #__PURE__ */ _react.default.createElement(TrackImage, { + track: props.track + })), + /* #__PURE__ */ _react.default.createElement('td', { + colspan: 11, + valign: 'center', + style: { + padding: '6px 16px 8px', + paddingLeft: '12px', + width: '100%' + } + }, props.trackMessage)), props.twitter && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '12', + style: { + padding: '4px 0px 16px 16px', + borderRadius: '4px' + } + }, + /* #__PURE__ */ _react.default.createElement('a', { + href: props.twitter.href, + target: '_blank', + style: { + textDecoration: 'none' + } + }, + /* #__PURE__ */ _react.default.createElement('table', { + cellspacing: '0', + cellpadding: '0', + style: { + margin: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + style: { + borderRadius: '4px', + padding: '4px 8px', + margin: '0px' + }, + bgcolor: '#1BA1F1' + }, + /* #__PURE__ */ _react.default.createElement('table', { + cellspacing: '0', + cellpadding: '0', + style: { + margin: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'center', + style: { + margin: '0px' + } + }, + /* #__PURE__ */ _react.default.createElement('img', { + src: 'https://download.audius.co/static-resources/email/iconTwitterWhite.png', + alt: 'twitter', + style: { + height: '18px', + width: '18px', + padding: '0px', + marginRight: '8px', + verticalAlign: 'text-bottom' + } + })), + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'center', + style: { + margin: '0px', + fontSize: '14px', + fontWeight: '500', + color: '#ffffff', + textDecoration: 'none' + } + }, props.twitter.message))))))))), props.hasReadMore && + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + colspan: '12', + valign: 'center', + className: 'avenir', + style: { + padding: '0px 16px 14px', + color: '#7E1BCC', + fontSize: '14px', + fontWeight: '500' + } + }, 'Read More')), + /* #__PURE__ */ _react.default.createElement('tr', null, + /* #__PURE__ */ _react.default.createElement('td', { + valign: 'center', + colspan: '12', + style: { + padding: '0px 16px 14px' + } + }, + /* #__PURE__ */ _react.default.createElement(OpenAudiusLink, null)))))))); +}; +const _default = Body; +exports.default = _default; diff --git a/packages/identity-service/build/src/notifications/renderEmail/notifications/utils.js b/packages/identity-service/build/src/notifications/renderEmail/notifications/utils.js new file mode 100644 index 00000000000..6faf7219230 --- /dev/null +++ b/packages/identity-service/build/src/notifications/renderEmail/notifications/utils.js @@ -0,0 +1,45 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { + value: true +}); +exports.formatCount = void 0; +const _numeral = _interopRequireDefault(require('numeral')); +function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} +/** + * The format for counting numbers should be 4 characters if possible (3 numbers and 1 Letter) without trailing 0 + * ie. + * 375 => 375 + * 4,210 => 4.21K + * 56,010 => 56K + * 443,123 => 443K + * 4,001,000 => 4M Followers + */ +const formatCount = function formatCount(count) { + if (count >= 1000) { + const countStr = count.toString(); + if (countStr.length % 3 === 0) { + return (0, _numeral.default)(count).format('0a').toUpperCase(); + } + else if (countStr.length % 3 === 1 && countStr[2] !== '0') { + return (0, _numeral.default)(count).format('0.00a').toUpperCase(); + } + else if (countStr.length % 3 === 1 && countStr[1] !== '0') { + return (0, _numeral.default)(count).format('0.0a').toUpperCase(); + } + else if (countStr.length % 3 === 2 && countStr[2] !== '0') { + return (0, _numeral.default)(count).format('0.0a').toUpperCase(); + } + else { + return (0, _numeral.default)(count).format('0a').toUpperCase(); + } + } + else if (!count) { + return '0'; + } + else { + return ''.concat(count); + } +}; +exports.formatCount = formatCount; diff --git a/packages/identity-service/build/src/notifications/sendDownloadAppEmails.js b/packages/identity-service/build/src/notifications/sendDownloadAppEmails.js new file mode 100644 index 00000000000..95ed0f92f85 --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendDownloadAppEmails.js @@ -0,0 +1,94 @@ +"use strict"; +const path = require('path'); +const moment = require('moment-timezone'); +const handlebars = require('handlebars'); +const models = require('../models'); +const { logger } = require('../logging'); +const fs = require('fs'); +const getEmailTemplate = (path) => handlebars.compile(fs.readFileSync(path).toString()); +const downloadAppTemplatePath = path.resolve(__dirname, './emails/downloadMobileApp.html'); +const downloadAppTemplate = getEmailTemplate(downloadAppTemplatePath); +async function processDownloadAppEmail(expressApp, audiusLibs) { + try { + logger.info(`${new Date()} - processDownloadAppEmail`); + const sg = expressApp.get('sendgrid'); + if (sg === null) { + logger.error('sendgrid not configured'); + return; + } + // Get all users who have not signed in mobile and not been sent native mobile email within 2 days + const now = moment(); + const twoDaysAgo = now.clone().subtract(2, 'days').format(); + const fiveDaysAgo = now.clone().subtract(5, 'days').format(); + const emailUsersWalletAddress = await models.UserEvents.findAll({ + attributes: ['walletAddress'], + where: { + hasSignedInNativeMobile: false, + hasSentDownloadAppEmail: false, + createdAt: { + [models.Sequelize.Op.lte]: twoDaysAgo, + [models.Sequelize.Op.gt]: fiveDaysAgo + } + } + }).map((x) => x.walletAddress); + const emailUsers = await models.User.findAll({ + attributes: ['handle', 'walletAddress', 'email', 'isEmailDeliverable'], + where: { walletAddress: emailUsersWalletAddress } + }); + logger.debug(`processDownloadAppEmail - ${emailUsers.length} 2 day old users who have not signed in mobile`); + for (const userToEmail of emailUsers) { + if (!userToEmail.isEmailDeliverable) { + logger.info(`Unable to deliver download app email to ${userToEmail.handle} ${userToEmail.email}`); + continue; + } + const userEmail = userToEmail.email; + const sent = await renderAndSendDownloadAppEmail(sg, userEmail); + if (sent) { + await models.sequelize.query(` + INSERT INTO "UserEvents" ("walletAddress", "hasSentDownloadAppEmail", "createdAt", "updatedAt") + VALUES (:walletAddress, :hasSentDownloadAppEmail, now(), now()) + ON CONFLICT ("walletAddress") + DO + UPDATE SET "hasSentDownloadAppEmail" = :hasSentDownloadAppEmail; + `, { + replacements: { + walletAddress: userToEmail.walletAddress, + hasSentDownloadAppEmail: true + } + }); + } + } + } + catch (e) { + logger.error('Error processing download app email notifications'); + logger.error(e); + } +} +// Master function to render and send email for a given userId +async function renderAndSendDownloadAppEmail(sg, userEmail) { + try { + logger.info(`render and send download app email: ${userEmail}`); + const copyrightYear = new Date().getFullYear().toString(); + const downloadAppHtml = downloadAppTemplate({ + copyright_year: copyrightYear + }); + const emailParams = { + from: 'The Audius Team ', + to: userEmail, + bcc: ['forrest@audius.co'], + html: downloadAppHtml, + subject: 'Audius Is Better On The Go 📱', + asm: { + groupId: 19141 // id of unsubscribe group at https://mc.sendgrid.com/unsubscribe-groups + } + }; + // Send email + await sg.send(emailParams); + return true; + } + catch (e) { + logger.error(`Error in renderAndSendDownloadAppEmail ${e}`); + return false; + } +} +module.exports = { processDownloadAppEmail }; diff --git a/packages/identity-service/build/src/notifications/sendNotificationEmails.js b/packages/identity-service/build/src/notifications/sendNotificationEmails.js new file mode 100644 index 00000000000..80215bac702 --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendNotificationEmails.js @@ -0,0 +1,423 @@ +"use strict"; +const path = require('path'); +const uuidv4 = require('uuid/v4'); +const moment = require('moment-timezone'); +const models = require('../models'); +const { logger } = require('../logging'); +const fs = require('fs'); +const renderEmail = require('./renderEmail'); +const getEmailNotifications = require('./fetchNotificationMetadata'); +const emailCachePath = './emailCache'; +const notificationUtils = require('./utils'); +const { notificationTypes, dayInHours, weekInHours } = require('./constants'); +const { getRemoteFeatureVarEnabled, NOTIFICATIONS_EMAIL_PLUGIN, EmailPluginMappings } = require('../remoteConfig'); +// Sendgrid object +let sg; +const EmailFrequency = notificationUtils.EmailFrequency; +const loggingContext = { + job: 'processEmailNotifications' +}; +const getUserIdsWithUnseenNotifications = async ({ userIds, gtTimeStamp }) => { + const [notificationUserIds, solanaNotificationUserIds] = await Promise.all([ + models.Notification.findAll({ + attributes: ['userId'], + where: { + isViewed: false, + userId: { [models.Sequelize.Op.in]: userIds }, + timestamp: { [models.Sequelize.Op.gt]: gtTimeStamp } + }, + group: ['userId'] + }), + models.SolanaNotification.findAll({ + attributes: ['userId'], + where: { + isViewed: false, + userId: { [models.Sequelize.Op.in]: userIds }, + createdAt: { [models.Sequelize.Op.gt]: gtTimeStamp } + }, + group: ['userId'] + }) + ]); + return notificationUserIds + .concat(solanaNotificationUserIds) + .map((x) => x.userId); +}; +const DEFAULT_TIMEZONE = 'America/Los_Angeles'; +const DEFAULT_EMAIL_FREQUENCY = EmailFrequency.LIVE; +const Results = Object.freeze({ + USER_TURNED_OFF: 'USER_TURNED_OFF', + USER_BLOCKED: 'USER_BLOCKED', + SHOULD_SKIP: 'SHOULD_SKIP', + ERROR: 'ERROR', + SENT: 'SENT' +}); +async function processEmailNotifications(expressApp, audiusLibs) { + try { + logger.info(loggingContext, `${new Date()} - processEmailNotifications`); + sg = expressApp.get('sendgrid'); + if (sg === null) { + logger.error('processEmailNotifications - Sendgrid not configured'); + return; + } + const optimizelyClient = expressApp.get('optimizelyClient'); + const isLiveEmailDisabled = getRemoteFeatureVarEnabled(optimizelyClient, NOTIFICATIONS_EMAIL_PLUGIN, EmailPluginMappings.Live); + const isScheduledEmailDisabled = getRemoteFeatureVarEnabled(optimizelyClient, NOTIFICATIONS_EMAIL_PLUGIN, EmailPluginMappings.Scheduled); + let liveEmailUsers = []; + let dailyEmailUsers = []; + let weeklyEmailUsers = []; + if (!isLiveEmailDisabled) { + liveEmailUsers = await models.UserNotificationSettings.findAll({ + attributes: ['userId'], + where: { emailFrequency: EmailFrequency.LIVE } + }).map((x) => x.userId); + } + if (!isScheduledEmailDisabled) { + dailyEmailUsers = await models.UserNotificationSettings.findAll({ + attributes: ['userId'], + where: { emailFrequency: EmailFrequency.DAILY } + }).map((x) => x.userId); + weeklyEmailUsers = await models.UserNotificationSettings.findAll({ + attributes: ['userId'], + where: { emailFrequency: EmailFrequency.WEEKLY } + }).map((x) => x.userId); + } + logger.info({ + ...loggingContext, + liveEmailUsers: liveEmailUsers.length, + dailyEmailUsers: dailyEmailUsers.length, + weeklyEmailUsers: weeklyEmailUsers.length, + isLiveEmailDisabled, + isScheduledEmailDisabled + }, `processEmailNotifications - Fetched users`); + const currentTime = moment.utc(); + const now = moment(); + const dayAgo = now.clone().subtract(1, 'days'); + const weekAgo = now.clone().subtract(7, 'days'); + const appAnnouncements = expressApp.get('announcements').filter((a) => { + const announcementDate = moment(a.datePublished); + const timeSinceAnnouncement = moment + .duration(currentTime.diff(announcementDate)) + .asHours(); + // If the announcement is too old filter it out, it's not necessary to process. + return timeSinceAnnouncement < weekInHours * 1.5; + }); + // For each announcement, we generate a list of valid users + // Based on the user's email frequency + const liveUsersWithPendingAnnouncements = []; + const dailyUsersWithPendingAnnouncements = []; + const weeklyUsersWithPendingAnnouncements = []; + const timeBeforeAnnouncementsLoop = Date.now(); + logger.info(loggingContext, `processEmailNotifications | time before looping over announcements | ${timeBeforeAnnouncementsLoop} | ${appAnnouncements.length} announcements`); + for (const announcement of appAnnouncements) { + const announcementDate = moment(announcement.datePublished); + const timeSinceAnnouncement = moment + .duration(currentTime.diff(announcementDate)) + .asHours(); + const announcementEntityId = announcement.entityId; + const id = announcement.id; + const usersCreatedBeforeAnnouncement = await models.User.findAll({ + attributes: ['blockchainUserId'], + where: { + createdAt: { [models.Sequelize.Op.lt]: moment(announcementDate) } + } + }).map((x) => x.blockchainUserId); + const userIdsToExcludeForAnnouncement = await models.Notification.findAll({ + attributes: ['userId'], + where: { + isViewed: true, + userId: { + [models.Sequelize.Op.in]: usersCreatedBeforeAnnouncement + }, + type: notificationTypes.Announcement, + entityId: announcementEntityId + } + }); + const userIdSetToExcludeForAnnouncement = new Set(userIdsToExcludeForAnnouncement.map((u) => u.userId)); + const relevantUserIdsForAnnouncement = usersCreatedBeforeAnnouncement.filter((userId) => !userIdSetToExcludeForAnnouncement.has(userId)); + const timeBeforeUserAnnouncementsLoop = Date.now(); + logger.info(loggingContext, `processEmailNotifications | time before looping over users for announcement id ${id}, entity id ${announcementEntityId} | ${timeBeforeUserAnnouncementsLoop} | ${usersCreatedBeforeAnnouncement.length} users`); + for (const user of relevantUserIdsForAnnouncement) { + if (liveEmailUsers.includes(user)) { + // As an added safety check, only process if the announcement was made in the last hour + if (timeSinceAnnouncement < 1) { + logger.info(`processEmailNotifications | Announcements - ${id} | Live user ${user}`); + liveUsersWithPendingAnnouncements.push(user); + } + } + else if (dailyEmailUsers.includes(user)) { + if (timeSinceAnnouncement < dayInHours * 1.5) { + logger.info(`processEmailNotifications | Announcements - ${id} | Daily user ${user}, <1 day`); + dailyUsersWithPendingAnnouncements.push(user); + } + } + else if (weeklyEmailUsers.includes(user)) { + if (timeSinceAnnouncement < weekInHours * 1.5) { + logger.info(`processEmailNotifications | Announcements - ${id} | Weekly user ${user}, <1 week`); + weeklyUsersWithPendingAnnouncements.push(user); + } + } + } + const timeAfterUserAnnouncementsLoop = Date.now(); + logger.info(loggingContext, `processEmailNotifications | time after looping over users for announcement id ${id}, entity id ${announcementEntityId} | ${timeAfterUserAnnouncementsLoop} | time elapsed is ${timeAfterUserAnnouncementsLoop - timeBeforeUserAnnouncementsLoop} | ${usersCreatedBeforeAnnouncement.length} users`); + } + const timeAfterAnnouncementsLoop = Date.now(); + const announcementDurationSec = (timeAfterAnnouncementsLoop - timeBeforeAnnouncementsLoop) / 1000; + logger.info({ ...loggingContext, announcementDuration: announcementDurationSec }, `processEmailNotifications | time after looping over announcements | ${timeAfterAnnouncementsLoop} | time elapsed is ${announcementDurationSec} | ${appAnnouncements.length} announcements`); + const pendingNotificationUsers = new Set(); + // Add users with pending announcement notifications + liveUsersWithPendingAnnouncements.forEach((item) => pendingNotificationUsers.add(item)); + dailyUsersWithPendingAnnouncements.forEach((item) => pendingNotificationUsers.add(item)); + weeklyUsersWithPendingAnnouncements.forEach((item) => pendingNotificationUsers.add(item)); + // Query users with pending notifications grouped by frequency + // Over fetch users here, they will get dropped later on if they have 0 notifications + // to process. + // We could be more precise here by looking at the last sent email for each user + // but that query would be more expensive than just finding extra users here and then + // dropping them. + const liveEmailUsersWithUnseenNotifications = await getUserIdsWithUnseenNotifications({ + userIds: liveEmailUsers, + gtTimeStamp: dayAgo + }); + liveEmailUsersWithUnseenNotifications.forEach((item) => pendingNotificationUsers.add(item)); + const dailyEmailUsersWithUnseeenNotifications = await getUserIdsWithUnseenNotifications({ + userIds: dailyEmailUsers, + gtTimeStamp: dayAgo + }); + dailyEmailUsersWithUnseeenNotifications.forEach((item) => pendingNotificationUsers.add(item)); + const weeklyEmailUsersWithUnseeenNotifications = await getUserIdsWithUnseenNotifications({ + userIds: weeklyEmailUsers, + gtTimeStamp: weekAgo + }); + weeklyEmailUsersWithUnseeenNotifications.forEach((item) => pendingNotificationUsers.add(item)); + logger.info(loggingContext, `processEmailNotifications - Live Email Users: ${liveEmailUsersWithUnseenNotifications}, Daily Email Users: ${dailyEmailUsersWithUnseeenNotifications}, Weekly Email Users: ${weeklyEmailUsersWithUnseeenNotifications}`); + // All users with notifications, including announcements + const allUsersWithUnseenNotifications = [...pendingNotificationUsers]; + const userInfo = await models.User.findAll({ + where: { + blockchainUserId: { + [models.Sequelize.Op.in]: allUsersWithUnseenNotifications + }, + isEmailDeliverable: true + } + }); + const userNotificationSettings = await models.UserNotificationSettings.findAll({ + where: { + userId: { + [models.Sequelize.Op.in]: allUsersWithUnseenNotifications + } + } + }); + const userFrequencyMapping = userNotificationSettings.reduce((acc, setting) => { + acc[setting.userId] = setting.emailFrequency; + return acc; + }, {}); + const timeBeforeUserEmailLoop = Date.now(); + logger.info(loggingContext, `processEmailNotifications | time before looping over users to send notification email | ${timeBeforeUserEmailLoop} | ${userInfo.length} users`); + const currentUtcTime = moment.utc(); + const chuckSize = 20; + const results = []; + for (let chunk = 0; chunk * chuckSize < userInfo.length; chunk += 1) { + const start = chunk * chuckSize; + const end = (chunk + 1) * chuckSize; + const chunkResults = await Promise.all(userInfo.slice(start, end).map(async (user) => { + try { + let { email: userEmail, blockchainUserId: userId, timezone, isBlockedFromEmails } = user; + if (isBlockedFromEmails) { + return { + result: Results.USER_BLOCKED, + error: `User with id ${userId} and email ${userEmail} is blocked from receiving emails` + }; + } + if (timezone === null) { + timezone = DEFAULT_TIMEZONE; + } + const frequency = userFrequencyMapping[userId] || DEFAULT_EMAIL_FREQUENCY; + if (frequency === EmailFrequency.OFF) { + logger.info(`processEmailNotifications | Bypassing email for user ${userId}`); + return { result: Results.USER_TURNED_OFF }; + } + const userTime = currentUtcTime.tz(timezone); + const startOfUserDay = userTime.clone().startOf('day'); + const hrsSinceStartOfDay = moment + .duration(userTime.diff(startOfUserDay)) + .asHours(); + const latestUserEmail = await models.NotificationEmail.findOne({ + where: { + userId + }, + order: [['timestamp', 'DESC']] + }); + let lastSentTimestamp; + if (latestUserEmail) { + // When testing, noticed that it's possible for the latest user email timestamp + // to be milliseconds behind the notifications createdAt timestamp, + // which results in those notifications not being returned from the query. + // We subtract 1 second from the latest user email timestamp to ensure + // that all the email notification records that we care about. + lastSentTimestamp = moment(latestUserEmail.timestamp).subtract(1, 'seconds'); + } + else { + lastSentTimestamp = moment(0); // EPOCH + } + const shouldSend = notificationUtils.shouldSendEmail(frequency, currentUtcTime, lastSentTimestamp, hrsSinceStartOfDay); + if (!shouldSend) { + logger.info(`processEmailNotifications | Bypassing email for user ${userId}`); + return { result: Results.NOT_SENT }; + } + let startTime; + if (frequency === EmailFrequency.LIVE) { + startTime = lastSentTimestamp; + } + else if (frequency === EmailFrequency.DAILY) { + startTime = dayAgo; + } + else if (frequency === EmailFrequency.WEEKLY) { + startTime = weekAgo; + } + else { + return { + result: Results.ERROR, + error: `Frequency is not valid ${frequency}` + }; + } + const sent = await renderAndSendNotificationEmail(userId, userEmail, appAnnouncements, frequency, startTime, audiusLibs); + if (!sent) { + // sent could be undefined, in which case there was no email sending failure, rather the user had 0 email notifications to be sent + if (sent === false) { + return { result: Results.ERROR, error: 'Unable to send email' }; + } + return { + result: Results.SHOULD_SKIP, + error: 'No notifications to send in email' + }; + } + await models.NotificationEmail.create({ + userId, + emailFrequency: frequency, + timestamp: currentUtcTime + }); + return { result: Results.SENT }; + } + catch (e) { + return { result: Results.ERROR, error: e.toString() }; + } + })); + results.push(...chunkResults); + } + const aggregatedResults = results.reduce((acc, response) => { + if (response.result in acc) { + acc[response.result] += 1; + } + else { + acc[response.result] = 1; + } + if (response.result === Results.ERROR) { + logger.info({ job: processEmailNotifications }, response.error.toString()); + } + return acc; + }, {}); + const timeAfterUserEmailLoop = Date.now(); + const totalDuration = (timeAfterUserEmailLoop - timeBeforeUserEmailLoop) / 1000; + logger.info({ + job: processEmailNotifications, + duration: totalDuration, + ...aggregatedResults + }, `processEmailNotifications | time after looping over users to send notification email | ${timeAfterUserEmailLoop} | time elapsed is ${totalDuration} | ${userInfo.length} users`); + } + catch (e) { + logger.error('processEmailNotifications | Error processing email notifications'); + logger.error(e); + } +} +// Master function to render and send email for a given userId +async function renderAndSendNotificationEmail(userId, userEmail, announcements, frequency, startTime, audiusLibs) { + try { + logger.debug(`renderAndSendNotificationEmail | ${userId}, ${userEmail}, ${frequency}, from ${startTime}`); + const timeBeforeEmailNotifications = Date.now(); + const [notificationProps, notificationCount] = await getEmailNotifications(audiusLibs, userId, announcements, startTime, 5); + const timeAfterEmailNotifications = Date.now(); + const getEmailDuration = (timeAfterEmailNotifications - timeBeforeEmailNotifications) / 1000; + logger.debug(`renderAndSendNotificationEmail | time after getEmailNotifications | ${timeAfterEmailNotifications} | time elapsed is ${getEmailDuration} | ${notificationCount} unread notifications`); + const emailSubject = `${notificationCount} unread notification${notificationCount > 1 ? 's' : ''} on Audius`; + if (notificationCount === 0) { + logger.debug(`renderAndSendNotificationEmail | 0 notifications detected for user ${userId}, bypassing email`); + return; + } + const renderProps = { + copyrightYear: new Date().getFullYear().toString() + }; + renderProps.notifications = notificationProps; + if (frequency === 'live') { + renderProps.title = `Email - ${userEmail}`; + } + else if (frequency === 'daily') { + renderProps.title = `Daily Email - ${userEmail}`; + } + else if (frequency === 'weekly') { + renderProps.title = `Weekly Email - ${userEmail}`; + } + const now = moment(); + const dayAgo = now.clone().subtract(1, 'days'); + const weekAgo = now.clone().subtract(7, 'days'); + const formattedDayAgo = dayAgo.format('MMMM Do YYYY'); + const shortWeekAgoFormat = weekAgo.format('MMMM Do'); + const liveSubjectFormat = `${notificationCount} unread notification${notificationCount > 1 ? 's' : ''}`; + const weeklySubjectFormat = `${notificationCount} unread notification${notificationCount > 1 ? 's' : ''} from ${shortWeekAgoFormat} - ${formattedDayAgo}`; + const dailySubjectFormat = `${notificationCount} unread notification${notificationCount > 1 ? 's' : ''} from ${formattedDayAgo}`; + let subject; + if (frequency === EmailFrequency.LIVE) { + subject = liveSubjectFormat; + } + else if (frequency === EmailFrequency.DAILY) { + subject = dailySubjectFormat; + } + else { + subject = weeklySubjectFormat; + } + renderProps.subject = subject; + const notifHtml = renderEmail(renderProps); + const emailParams = { + from: 'Audius ', + to: `${userEmail}`, + bcc: 'audius-email-test@audius.co', + html: notifHtml, + subject: emailSubject, + asm: { + groupId: 19141 // id of unsubscribe group at https://mc.sendgrid.com/unsubscribe-groups + } + }; + // Send email + await sendEmail(emailParams); + // Cache on file system + await cacheEmail({ renderProps, emailParams }); + const totalDuration = (Date.now() - timeBeforeEmailNotifications) / 1000; + logger.info({ + job: 'renderAndSendNotificationEmail', + totalDuration, + getEmailDuration + }, `renderAndSendNotificationEmail | ${userId}, ${userEmail}, in ${totalDuration} sec`); + return true; + } + catch (e) { + logger.error(`Error in renderAndSendNotificationEmail ${e.stack}`); + return false; + } +} +async function cacheEmail(cacheParams) { + try { + const uuid = uuidv4(); + const timestamp = moment().valueOf(); + const fileName = `${uuid}-${timestamp.toString()}.json`; + const filePath = path.join(emailCachePath, fileName); + await fs.promises.writeFile(filePath, JSON.stringify(cacheParams)); + } + catch (e) { + logger.error(`Error in cacheEmail ${e}`); + } +} +async function sendEmail(emailParams) { + if (sg !== null) { + await sg.send(emailParams); + } +} +module.exports = { renderAndSendNotificationEmail, processEmailNotifications }; diff --git a/packages/identity-service/build/src/notifications/sendNotifications/formatNotification.js b/packages/identity-service/build/src/notifications/sendNotifications/formatNotification.js new file mode 100644 index 00000000000..d60bac431ec --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendNotifications/formatNotification.js @@ -0,0 +1,406 @@ +"use strict"; +const models = require('../../models'); +const { bulkGetSubscribersFromDiscovery, shouldReadSubscribersFromDiscovery } = require('../utils'); +const { logger } = require('../../logging'); +const { notificationTypes, actionEntityTypes } = require('../constants'); +const notificationUtils = require('./utils'); +const shouldNotifyUser = (userId, prop, settings) => { + const userNotification = { notifyMobile: false, notifyBrowserPush: false }; + if (!(userId in settings)) + return userNotification; + if ('mobile' in settings[userId]) { + userNotification.mobile = settings[userId].mobile[prop]; + } + if ('browser' in settings[userId]) { + userNotification.browser = settings[userId].browser[prop]; + } + return userNotification; +}; +const getRepostType = (type) => { + switch (type) { + case 'track': + return notificationTypes.Repost.track; + case 'album': + return notificationTypes.Repost.album; + case 'playlist': + return notificationTypes.Repost.playlist; + default: + return ''; + } +}; +const getFavoriteType = (type) => { + switch (type) { + case 'track': + return notificationTypes.Favorite.track; + case 'album': + return notificationTypes.Favorite.album; + case 'playlist': + return notificationTypes.Favorite.playlist; + default: + return ''; + } +}; +let subscriberPushNotifications = []; +async function formatNotifications(notifications, notificationSettings, tx, optimizelyClient) { + // If READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED is enabled, bulk fetch all subscriber IDs + // from discovery for the initiators of create notifications. + const readSubscribersFromDiscovery = shouldReadSubscribersFromDiscovery(optimizelyClient); + let userSubscribersMap = {}; + if (readSubscribersFromDiscovery) { + const userIds = new Set(notifications.reduce((filtered, notif) => { + if (notif.type === notificationTypes.Create.base) { + filtered.push(notif.initiator); + } + return filtered; + }, [])); + if (userIds.size > 0) { + userSubscribersMap = await bulkGetSubscribersFromDiscovery(userIds); + } + } + // Loop through notifications to get the formatted notification + const formattedNotifications = []; + for (const notif of notifications) { + // blocknumber parsed for all notification types + const blocknumber = notif.blocknumber; + // Handle the 'follow' notification type + if (notif.type === notificationTypes.Follow) { + const notificationTarget = notif.metadata.followee_user_id; + const shouldNotify = shouldNotifyUser(notificationTarget, 'followers', notificationSettings); + if (shouldNotify.mobile || shouldNotify.browser) { + const formattedFollow = { + ...notif, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.metadata.follower_user_id, + blocknumber + } + ] + }; + formattedNotifications.push(formattedFollow); + } + } + // Handle the 'repost' notification type + // track/album/playlist + if (notif.type === notificationTypes.Repost.base) { + const notificationTarget = notif.metadata.entity_owner_id; + const shouldNotify = shouldNotifyUser(notificationTarget, 'reposts', notificationSettings); + if (shouldNotify.mobile || shouldNotify.browser) { + const formattedRepost = { + ...notif, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + } + ], + entityId: notif.metadata.entity_id, + // we're going to overwrite this property so fetchNotificationMetadata can use it + type: getRepostType(notif.metadata.entity_type) + }; + formattedNotifications.push(formattedRepost); + } + } + // Handle the 'favorite' notification type, track/album/playlist + if (notif.type === notificationTypes.Favorite.base) { + const notificationTarget = notif.metadata.entity_owner_id; + const shouldNotify = shouldNotifyUser(notificationTarget, 'favorites', notificationSettings); + if (shouldNotify.mobile || shouldNotify.browser) { + const formattedFavorite = { + ...notif, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + } + ], + entityId: notif.metadata.entity_id, + // we're going to overwrite this property so fetchNotificationMetadata can use it + type: getFavoriteType(notif.metadata.entity_type) + }; + formattedNotifications.push(formattedFavorite); + } + } + // Handle the 'remix create' notification type + if (notif.type === notificationTypes.RemixCreate) { + const notificationTarget = notif.metadata.remix_parent_track_user_id; + const shouldNotify = shouldNotifyUser(notificationTarget, 'remixes', notificationSettings); + if (shouldNotify.mobile || shouldNotify.browser) { + const formattedRemixCreate = { + ...notif, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.metadata.remix_parent_track_user_id, + blocknumber + }, + { + actionEntityType: actionEntityTypes.Track, + actionEntityId: notif.metadata.entity_id, + blocknumber + }, + { + actionEntityType: actionEntityTypes.Track, + actionEntityId: notif.metadata.remix_parent_track_id, + blocknumber + } + ], + entityId: notif.metadata.entity_id, + type: notificationTypes.RemixCreate + }; + formattedNotifications.push(formattedRemixCreate); + } + } + // Handle the remix cosign notification type + if (notif.type === notificationTypes.RemixCosign) { + const formattedRemixCosign = { + ...notif, + entityId: notif.metadata.entity_id, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + }, + { + actionEntityType: actionEntityTypes.Track, + actionEntityId: notif.metadata.entity_id, + blocknumber + } + ], + type: notificationTypes.RemixCosign + }; + formattedNotifications.push(formattedRemixCosign); + } + // Handle 'challenge reward' notification type + if (notif.type === notificationTypes.ChallengeReward) { + const formattedRewardNotification = { + ...notif, + challengeId: notif.metadata.challenge_id, + actions: [ + { + actionEntityType: notif.metadata.challenge_id, + actionEntityId: notif.initiator, + slot: notif.slot + } + ], + type: notificationTypes.ChallengeReward + }; + formattedNotifications.push(formattedRewardNotification); + } + // Handle 'listen milestone' notification type + if (notif.type === notificationTypes.MilestoneListen) { + const notificationTarget = notif.initiator; + const shouldNotify = shouldNotifyUser(notificationTarget, 'milestonesAndAchievements', notificationSettings); + if (shouldNotify.mobile || shouldNotify.browser) { + const formattedListenMilstoneNotification = { + ...notif, + entityId: notif.metadata.entity_id, + type: notificationTypes.MilestoneListen, + actions: [ + { + actionEntityType: actionEntityTypes.Track, + actionEntityId: notif.metadata.threshold + } + ] + }; + formattedNotifications.push(formattedListenMilstoneNotification); + } + } + // Handle 'tier change' notification type + if (notif.type === notificationTypes.TierChange) { + const formattedTierChangeNotification = { + ...notif, + tier: notif.metadata.tier, + actions: [ + { + actionEntityType: actionEntityTypes.User, + actionEntityId: notif.initiator, + blocknumber + } + ], + type: notificationTypes.TierChange + }; + formattedNotifications.push(formattedTierChangeNotification); + } + // Handle the 'create' notification type, track/album/playlist + if (notif.type === notificationTypes.Create.base) { + const subscribers = userSubscribersMap[notif.initiator] || []; + await _processCreateNotifications(notif, tx, readSubscribersFromDiscovery, subscribers); + } + // Handle the 'track added to playlist' notification type + if (notif.type === notificationTypes.AddTrackToPlaylist) { + const formattedAddTrackToPlaylistNotification = { + ...notif, + actions: [ + { + actionEntityType: actionEntityTypes.Track, + actionTrackId: notif.metadata.track_id, + blocknumber + } + ], + metadata: { + trackOwnerId: notif.metadata.track_owner_id, + playlistOwnerId: notif.initiator, + playlistId: notif.metadata.playlist_id + }, + entityId: notif.metadata.track_id, + type: notificationTypes.AddTrackToPlaylist + }; + formattedNotifications.push(formattedAddTrackToPlaylistNotification); + } + if (notif.type === notificationTypes.Reaction) { + const formattedReactionNotification = { + ...notif + }; + formattedNotifications.push(formattedReactionNotification); + } + if (notif.type === notificationTypes.SupporterRankUp) { + // Need to create two notifs + const supportingRankUp = { + ...notif, + type: notificationTypes.SupportingRankUp + }; + const supporterRankUp = { + ...notif, + type: notificationTypes.SupporterRankUp + }; + formattedNotifications.push(supportingRankUp); + formattedNotifications.push(supporterRankUp); + } + if (notif.type === notificationTypes.Tip) { + // For tip, need sender userId, and amount + const formattedTipNotification = { + ...notif, + type: notificationTypes.TipReceive + }; + formattedNotifications.push(formattedTipNotification); + } + if (notif.type === notificationTypes.SupporterDethroned) { + formattedNotifications.push({ ...notif }); + } + } + const [formattedCreateNotifications, users] = await _processSubscriberPushNotifications(); + formattedNotifications.push(...formattedCreateNotifications); + return { notifications: formattedNotifications, users: [...users] }; +} +async function _processSubscriberPushNotifications() { + const filteredFormattedCreateNotifications = []; + const users = []; + const currentTime = Date.now(); + for (let i = 0; i < subscriberPushNotifications.length; i++) { + const entry = subscriberPushNotifications[i]; + const timeSince = currentTime - entry.time; + if (timeSince > notificationUtils.getPendingCreateDedupeMs()) { + filteredFormattedCreateNotifications.push(entry); + users.push(entry.initiator); + entry.pending = false; + } + } + subscriberPushNotifications = subscriberPushNotifications.filter((x) => x.pending); + return [filteredFormattedCreateNotifications, users]; +} +async function _processCreateNotifications(notif, tx, readSubscribersFromDiscovery, subscribersFromDiscovery) { + const blocknumber = notif.blocknumber; + let createType = null; + let actionEntityType = null; + switch (notif.metadata.entity_type) { + case 'track': + createType = notificationTypes.Create.track; + actionEntityType = actionEntityTypes.Track; + break; + case 'album': + createType = notificationTypes.Create.album; + actionEntityType = actionEntityTypes.User; + break; + case 'playlist': + createType = notificationTypes.Create.playlist; + actionEntityType = actionEntityTypes.User; + break; + default: + throw new Error('Invalid create type'); + } + // If the initiator is the main audius account, skip the notification + // NOTE: This is a temp fix to not stall identity service + if (notif.initiator === 51) { + return []; + } + // Notifications go to all users subscribing to this track uploader + let subscribers = subscribersFromDiscovery; + if (!readSubscribersFromDiscovery) { + // Query user IDs from subscriptions table + subscribers = await models.Subscription.findAll({ + where: { + userId: notif.initiator + }, + transaction: tx + }); + } + // No operation if no users subscribe to this creator + if (subscribers.length === 0) { + return []; + } + // The notification entity id is the uploader id for tracks + // Each track will added to the notification actions table + // For playlist/albums, the notification entity id is the collection id itself + const notificationEntityId = actionEntityType === actionEntityTypes.Track + ? notif.initiator + : notif.metadata.entity_id; + // Action table entity is trackId for CreateTrack notifications + // Allowing multiple track creates to be associated w/ a single notif for your subscription + // For collections, the entity is the owner id, producing a distinct notif for each + const createdActionEntityId = actionEntityType === actionEntityTypes.Track + ? notif.metadata.entity_id + : notif.metadata.entity_owner_id; + // Create notification for each subscriber + const formattedNotifications = subscribers.map((s) => { + // send push notification to each subscriber + return { + ...notif, + actions: [ + { + actionEntityType: actionEntityType, + actionEntityId: createdActionEntityId, + blocknumber + } + ], + entityId: notificationEntityId, + time: Date.now(), + pending: true, + // Add notification for this user indicating the uploader has added a track + subscriberId: readSubscribersFromDiscovery ? s : s.subscriberId, + // we're going to overwrite this property so fetchNotificationMetadata can use it + type: createType + }; + }); + subscriberPushNotifications.push(...formattedNotifications); + // Dedupe album /playlist notification + if (createType === notificationTypes.Create.album || + createType === notificationTypes.Create.playlist) { + const trackIdObjectList = notif.metadata.collection_content.track_ids; + const trackIdsArray = trackIdObjectList.map((x) => x.track); + if (trackIdObjectList.length > 0) { + // Clear duplicate push notifications in local queue + let dupeFound = false; + for (let i = 0; i < subscriberPushNotifications.length; i++) { + const pushNotif = subscriberPushNotifications[i]; + const type = pushNotif.type; + if (type === notificationTypes.Create.track) { + const pushActionEntityId = pushNotif.metadata.entity_id; + // Check if this pending notification includes a duplicate track + if (trackIdsArray.includes(pushActionEntityId)) { + logger.debug(`Found dupe push notif ${type}, trackId: ${pushActionEntityId}`); + dupeFound = true; + subscriberPushNotifications[i].pending = false; + } + } + } + if (dupeFound) { + subscriberPushNotifications = subscriberPushNotifications.filter((x) => x.pending); + } + } + } +} +module.exports = formatNotifications; diff --git a/packages/identity-service/build/src/notifications/sendNotifications/index.js b/packages/identity-service/build/src/notifications/sendNotifications/index.js new file mode 100644 index 00000000000..3146c904de5 --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendNotifications/index.js @@ -0,0 +1,141 @@ +"use strict"; +const models = require('../../models'); +const { notificationTypes } = require('../constants'); +const { fetchNotificationMetadata } = require('../fetchNotificationMetadata'); +const formatNotifications = require('./formatNotification'); +const publishNotifications = require('./publishNotifications'); +function getUserIdsToNotify(notifications) { + return notifications.reduce((userIds, notification) => { + // Add user id from notification based on notification type + switch (notification.type) { + case notificationTypes.Follow: + return userIds.concat(notification.metadata.followee_user_id); + case notificationTypes.Repost.base: + return userIds.concat(notification.metadata.entity_owner_id); + case notificationTypes.Favorite.base: + return userIds.concat(notification.metadata.entity_owner_id); + case notificationTypes.RemixCreate: + return userIds.concat(notification.metadata.remix_parent_track_user_id); + case notificationTypes.AddTrackToPlaylist: + return userIds.concat(notification.metadata.track_owner_id); + case notificationTypes.ChallengeReward: + case notificationTypes.MilestoneListen: + case notificationTypes.TierChange: + return userIds.concat(notification.initiator); + case notificationTypes.Tip: { + const receiverId = notification.initiator; + return userIds.concat(receiverId); + } + case notificationTypes.Reaction: + // Specifically handle tip reactions + if (notification.metadata.reaction_type !== 'tip') { + return userIds; + } + // For reactions, add the tip_sender_id in the reacted_to_entity + return userIds.concat(notification.metadata.reacted_to_entity.tip_sender_id); + case notificationTypes.SupporterRankUp: { + // For SupporterRankUp, need to send notifs to both supporting and supported users + const supportingId = notification.metadata.entity_id; + const supportedId = notification.initiator; + return userIds.concat([supportingId, supportedId]); + } + case notificationTypes.SupporterDethroned: + return userIds.concat(notification.initiator); + default: + return userIds; + } + }, []); +} +/** + * Given an array of user ids, get the users' mobile & browser push notification settings + * @param {Array} userIdsToNotify List of user ids to retrieve the settings for + * @param {*} tx + */ +const getUserNotificationSettings = async (userIdsToNotify, tx) => { + const userNotificationSettings = {}; + if (userIdsToNotify.length === 0) { + return userNotificationSettings; + } + // fetch user's with registered browser devices + const userNotifSettingsMobile = await models.sequelize.query(` + SELECT * + FROM "UserNotificationMobileSettings" settings + WHERE + settings."userId" in (:userIds) AND + settings."userId" in ( + SELECT "userId" + FROM "NotificationDeviceTokens" device + WHERE + device.enabled AND + device."deviceType" in ('ios', 'android') + ) + `, { + replacements: { + userIds: userIdsToNotify + }, + model: models.UserNotificationMobileSettings, + mapToModel: true, + transaction: tx + }); + // Batch fetch mobile push notifications for userIds + userNotifSettingsMobile.forEach((settings) => { + userNotificationSettings[settings.userId] = { mobile: settings }; + }); + // Batch fetch browser push notifications for userIds + const userNotifBrowserPushSettings = await models.sequelize.query(` + SELECT * + FROM "UserNotificationBrowserSettings" settings + WHERE + settings."userId" in (:userIds) AND ( + settings."userId" in ( + SELECT "userId" + FROM "NotificationDeviceTokens" device + WHERE + device.enabled AND + device."deviceType" in ('safari') + ) OR + settings."userId" in ( + SELECT "userId" + FROM "NotificationBrowserSubscriptions" device + WHERE device.enabled + ) + ); + `, { + replacements: { userIds: userIdsToNotify }, + model: models.UserNotificationBrowserSettings, + mapToModel: true, + transaction: tx + }); + userNotifBrowserPushSettings.forEach((settings) => { + userNotificationSettings[settings.userId] = { + ...(userNotificationSettings[settings.userId] || {}), + browser: settings + }; + }); + return userNotificationSettings; +}; +/** + * Fetches all users to send a push notification, gets their users' push notifications settings, + * populates notifications with extra data from DP, and adds the notification to the queue + * @param {Object} audiusLibs Instance of audius libs + * @param {Array} notifications Array of notifications from DP + * @param {*} tx The DB transaction to add to every DB query + * @param {*} optimizelyClient Optimizely client for feature flags + */ +async function sendNotifications(audiusLibs, notifications, tx, optimizelyClient) { + // Parse the notification to grab the user ids that we want to notify + const userIdsToNotify = getUserIdsToNotify(notifications); + // Using the userIds to notify, check the DB for their notification settings + const userNotificationSettings = await getUserNotificationSettings(userIdsToNotify, tx); + // Format the notifications, so that the extra information needed to build the notification is in a standard format + const { notifications: formattedNotifications, users } = await formatNotifications(notifications, userNotificationSettings, tx, optimizelyClient); + // Get the metadata for the notifications - users/tracks/playlists from DP that are in the notification + const metadata = await fetchNotificationMetadata(audiusLibs, users, formattedNotifications); + // using the metadata, populate the notifications, and push them to the publish queue + await publishNotifications(formattedNotifications, metadata, userNotificationSettings, tx, optimizelyClient); +} +module.exports = sendNotifications; +module.exports.getUserIdsToNotify = getUserIdsToNotify; +module.exports.getUserNotificationSettings = getUserNotificationSettings; +module.exports.formatNotifications = formatNotifications; +module.exports.fetchNotificationMetadata = fetchNotificationMetadata; diff --git a/packages/identity-service/build/src/notifications/sendNotifications/publishNotifications.js b/packages/identity-service/build/src/notifications/sendNotifications/publishNotifications.js new file mode 100644 index 00000000000..4c589793aff --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendNotifications/publishNotifications.js @@ -0,0 +1,219 @@ +"use strict"; +const { deviceType, notificationTypes } = require('../constants'); +const models = require('../../models'); +const { notificationResponseMap, notificationResponseTitleMap, pushNotificationMessagesMap } = require('../formatNotificationMetadata'); +const { publish, publishSolanaNotification } = require('../notificationQueue'); +const { getFeatureFlag, FEATURE_FLAGS } = require('../../featureFlag'); +const { logger } = require('../../logging'); +// Maps a notification type to it's base notification +const getPublishNotifBaseType = (notification) => { + switch (notification.type) { + case notificationTypes.Follow: + return notificationTypes.Follow; + case notificationTypes.Repost.track: + case notificationTypes.Repost.playlist: + case notificationTypes.Repost.album: + case notificationTypes.Repost.base: + return notificationTypes.Repost.base; + case notificationTypes.Favorite.track: + case notificationTypes.Favorite.playlist: + case notificationTypes.Favorite.album: + case notificationTypes.Favorite.base: + return notificationTypes.Favorite.base; + case notificationTypes.RemixCreate: + return notificationTypes.RemixCreate; + case notificationTypes.RemixCosign: + return notificationTypes.RemixCosign; + case notificationTypes.Create.track: + case notificationTypes.Create.playlist: + case notificationTypes.Create.album: + case notificationTypes.Create.base: + return notificationTypes.Create.base; + case notificationTypes.ChallengeReward: + return notificationTypes.ChallengeReward; + case notificationTypes.MilestoneListen: + case notificationTypes.MilestoneFavorite: + case notificationTypes.MilestoneFollow: + case notificationTypes.MilestoneRepost: + return notificationTypes.Milestone; + case notificationTypes.TierChange: + return notificationTypes.TierChange; + case notificationTypes.AddTrackToPlaylist: + return notificationTypes.AddTrackToPlaylist; + case notificationTypes.Reaction: + return notificationTypes.Reaction; + case notificationTypes.TipReceive: + return notificationTypes.TipReceive; + case notificationTypes.SupporterRankUp: + return notificationTypes.SupporterRankUp; + case notificationTypes.SupportingRankUp: + return notificationTypes.SupportingRankUp; + case notificationTypes.SupporterDethroned: + return notificationTypes.SupporterDethroned; + } +}; +const solanaNotificationBaseTypes = [ + notificationTypes.ChallengeReward, + notificationTypes.MilestoneListen, + notificationTypes.TipReceive, + notificationTypes.Reaction, + notificationTypes.SupporterRankUp, + notificationTypes.SupportingRankUp, + notificationTypes.SupporterDethroned +]; +// Gets the userId that a notification should be sent to based off the notification's base type +const getPublishUserId = (notif, baseType) => { + if (baseType === notificationTypes.Follow) + return notif.metadata.followee_user_id; + else if (baseType === notificationTypes.Repost.base) + return notif.metadata.entity_owner_id; + else if (baseType === notificationTypes.Favorite.base) + return notif.metadata.entity_owner_id; + else if (baseType === notificationTypes.RemixCreate) + return notif.metadata.remix_parent_track_user_id; + else if (baseType === notificationTypes.RemixCosign) + return notif.metadata.entity_owner_id; + else if (baseType === notificationTypes.Create.base) + return notif.subscriberId; + else if (baseType === notificationTypes.ChallengeReward) + return notif.initiator; + else if (baseType === notificationTypes.Milestone) + return notif.initiator; + else if (baseType === notificationTypes.TierChange) + return notif.initiator; + else if (baseType === notificationTypes.AddTrackToPlaylist) + return notif.metadata.trackOwnerId; + else if (baseType === notificationTypes.Reaction) + return notif.metadata.reacted_to_entity.tip_sender_id; + else if (baseType === notificationTypes.SupporterRankUp) + return notif.initiator; + else if (baseType === notificationTypes.SupportingRankUp) + return notif.metadata.entity_id; + else if (baseType === notificationTypes.TipReceive) + return notif.initiator; + else if (baseType === notificationTypes.SupporterDethroned) + return notif.initiator; +}; +// Notification types that always get send a notification, regardless of settings +const alwaysSendNotifications = [ + notificationTypes.RemixCosign, + notificationTypes.Create.base, + notificationTypes.Create.track, + notificationTypes.Create.playlist, + notificationTypes.Create.album, + notificationTypes.ChallengeReward, + notificationTypes.AddTrackToPlaylist, + notificationTypes.Reaction, + notificationTypes.TipReceive, + notificationTypes.SupporterRankUp, + notificationTypes.SupportingRankUp, + notificationTypes.SupporterDethroned +]; +const mapNotificationBaseTypeToSettings = { + [notificationTypes.Follow]: 'followers', + [notificationTypes.Repost.base]: 'reposts', + [notificationTypes.Favorite.base]: 'favorites', + [notificationTypes.RemixCreate]: 'remixes', + [notificationTypes.Milestone]: 'milestonesAndAchievements' +}; +/** + * Gets the publish types: mobile, browser, both, or neither + * given a userId, the notification type and their settings + * @param {number} userId + * @param {string} baseNotificationType + * @param {Object} userNotificationSettings + */ +const getPublishTypes = (userId, baseNotificationType, userNotificationSettings) => { + if (alwaysSendNotifications.includes(baseNotificationType)) { + return [deviceType.Mobile, deviceType.Browser]; + } + const userSettings = userNotificationSettings[userId]; + const types = []; + const settingKey = mapNotificationBaseTypeToSettings[baseNotificationType]; + if (userSettings && userSettings.mobile && userSettings.mobile[settingKey]) + types.push(deviceType.Mobile); + if (userSettings && userSettings.browser && userSettings.browser[settingKey]) + types.push(deviceType.Browser); + return types; +}; +/** + * Checks if a notification type is enabled with optimizely + * @param {string} notificationType + * @param {*} optimizelyClient Optimizely client + */ +const shouldFilterOutNotification = (notificationType, optimizelyClient) => { + if (!optimizelyClient) { + return false; + } + if (notificationType === notificationTypes.ChallengeReward) { + return !getFeatureFlag(optimizelyClient, FEATURE_FLAGS.REWARDS_NOTIFICATIONS_ENABLED); + } + if ([ + notificationTypes.TipReceive, + notificationTypes.Reaction, + notificationTypes.SupporterRankUp, + notificationTypes.SupportingRankUp + ].includes(notificationType)) { + return !getFeatureFlag(optimizelyClient, FEATURE_FLAGS.TIPPING_ENABLED); + } + if (notificationType === notificationTypes.SupporterDethroned) { + return !getFeatureFlag(optimizelyClient, FEATURE_FLAGS.SUPPORTER_DETHRONED_PUSH_NOTIFS_ENABLED); + } + return false; +}; +/** + * Takes a list of notifications, populates them with extra metadata, checks their notification settings + * and publishes it to the notification queue to be sent out. + * @param {Array} notifications + * @param {Object} metadata Metadata of all the users/tracks/collections needed for populating the notifications + * @param {Object} userNotificationSettings A map of userID to their mobile & browser notification settings + * @param {*} tx Transction for DB queries + */ +const publishNotifications = async (notifications, metadata, userNotificationSettings, tx, optimizelyClient) => { + const initiators = models.User.findAll({ + where: { + blockchainUserId: notifications.map((notif) => notif.initiator) + } + }); + const initiatorMap = initiators.reduce((acc, initiator) => { + acc[initiator.blockchainUserId] = initiator; + return acc; + }, {}); + for (const notification of notifications) { + const mapNotification = notificationResponseMap[notification.type]; + const populatedNotification = { + ...notification, + ...mapNotification(notification, metadata) + }; + const publishNotifType = getPublishNotifBaseType(notification); + const msg = pushNotificationMessagesMap[publishNotifType](populatedNotification); + const title = notificationResponseTitleMap[notification.type](populatedNotification); + const userId = getPublishUserId(notification, publishNotifType); + const types = getPublishTypes(userId, publishNotifType, userNotificationSettings); + const initiatorUserId = notification.initiator; + // Don't publish events for deactivated users + const isReceiverDeactivated = metadata.users[userId] && metadata.users[userId].is_deactivated; + const initiatingUser = initiatorMap[initiatorUserId]; + const isInitiatorAbusive = initiatorMap[initiatorUserId] && + (initiatingUser.isBlockedFromRelay || + initiatingUser.isBlockedFromNotifications); + if (isReceiverDeactivated) { + continue; + } + if (isInitiatorAbusive) { + logger.info(`publishNotifications | notification initiator with user id ${initiatorUserId} is abusive, skipping...`); + continue; + } + const shouldFilter = shouldFilterOutNotification(notification.type, optimizelyClient); + if (shouldFilter) { + continue; + } + if (solanaNotificationBaseTypes.includes(notification.type)) { + await publishSolanaNotification(msg, userId, tx, true, title, types, notification); + } + else { + await publish(msg, userId, tx, true, title, types, notification); + } + } +}; +module.exports = publishNotifications; diff --git a/packages/identity-service/build/src/notifications/sendNotifications/utils.js b/packages/identity-service/build/src/notifications/sendNotifications/utils.js new file mode 100644 index 00000000000..7d536eac29a --- /dev/null +++ b/packages/identity-service/build/src/notifications/sendNotifications/utils.js @@ -0,0 +1,15 @@ +"use strict"; +// Debouncing time for track notification being removed by playlist/album notif. +// When an artist uploads an album (playlist), the tracks for the album are usually uploaded first. +// We don't want to notify a user for each of those tracks and then notify the user for the +// creation of the album, so we debounce the track creation notifications for some number of +// seconds to allow for the case an album or playlist shows up. That album or playlist replaces +// all the track notifications that occurred over the debounce. +// As a TODO, we should implement track => playlist or track => album tracking so this is a non-issue. +const PENDING_CREATE_DEDUPE_MS = 3 * 60 * 1000; +const getPendingCreateDedupeMs = () => { + return PENDING_CREATE_DEDUPE_MS; +}; +module.exports = { + getPendingCreateDedupeMs +}; diff --git a/packages/identity-service/build/src/notifications/trendingTrackProcessing.js b/packages/identity-service/build/src/notifications/trendingTrackProcessing.js new file mode 100644 index 00000000000..acff0b1fa01 --- /dev/null +++ b/packages/identity-service/build/src/notifications/trendingTrackProcessing.js @@ -0,0 +1,211 @@ +"use strict"; +const axios = require('axios'); +const moment = require('moment'); +const { sampleSize, isEqual } = require('lodash'); +const models = require('../models'); +const { logger } = require('../logging'); +const { deviceType, notificationTypes } = require('./constants'); +const { publish } = require('./notificationQueue'); +const { decodeHashId, shouldNotifyUser } = require('./utils'); +const { fetchNotificationMetadata } = require('./fetchNotificationMetadata'); +const { notificationResponseMap, notificationResponseTitleMap, pushNotificationMessagesMap } = require('./formatNotificationMetadata'); +const audiusLibsWrapper = require('../audiusLibsInstance'); +const { getRemoteVar, REMOTE_VARS } = require('../remoteConfig'); +const TRENDING_TIME = Object.freeze({ + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year' +}); +const TRENDING_GENRE = Object.freeze({ + ALL: 'all' +}); +const getTimeGenreActionType = (time, genre) => `${time}:${genre}`; +// The minimum time in hrs between notifications +const TRENDING_INTERVAL_HOURS = 3; +// The highest rank for which a notification will be sent +const MAX_TOP_TRACK_RANK = 10; +// The number of discovery nodes to test and verify that trending +// is consistent +const NUM_DISCOVERY_NODES_FOR_CONSENSUS = 3; +let SELECTED_DISCOVERY_NODES = null; +setInterval(async () => { + SELECTED_DISCOVERY_NODES = await getDiscoveryNodes(); +}, 5 * 60 * 60 /* re-run on the 5-minute */); +const getDiscoveryNodes = async () => { + const libs = await audiusLibsWrapper.getAudiusLibsAsync(); + const discoveryNodes = await libs.discoveryProvider.serviceSelector.findAll(); + logger.debug(`Updating discovery nodes for trendingTrackProcessing to ${discoveryNodes}`); + return sampleSize(discoveryNodes, NUM_DISCOVERY_NODES_FOR_CONSENSUS); +}; +async function getTrendingTracks(trendingExperiment, discoveryNodes) { + const results = await Promise.all(discoveryNodes.map(async (discoveryNode) => { + try { + // The owner info is then used to target listenCount milestone notifications + const params = new URLSearchParams(); + params.append('time', TRENDING_TIME.WEEK); + params.append('limit', MAX_TOP_TRACK_RANK); + const baseUrl = `${discoveryNode}/v1/full/tracks/trending`; + const url = trendingExperiment + ? `${baseUrl}/${trendingExperiment}` + : `${baseUrl}`; + const trendingTracksResponse = await axios({ + method: 'get', + url, + params, + timeout: 10000 + }); + const trendingTracks = trendingTracksResponse.data.data.map((track, idx) => ({ + trackId: decodeHashId(track.id), + rank: idx + 1, + userId: decodeHashId(track.user.id) + })); + const blocknumber = trendingTracksResponse.data.latest_indexed_block; + return { trendingTracks, blocknumber }; + } + catch (err) { + logger.error(`Unable to fetch trending tracks: ${err}`); + return null; + } + })); + // Make sure we had no errors + if (results.some((res) => res === null)) { + logger.error(`Unable to fetch trending tracks from all nodes`); + return null; + } + // Make sure trending is consistent between nodes + const { trendingTracks, blocknumber } = results[0]; + for (const result of results.slice(1)) { + const { trendingTracks: otherTrendingTracks } = result; + if (!isEqual(trendingTracks, otherTrendingTracks)) { + const ids = trendingTracks.map((t) => t.trackId); + const otherIds = otherTrendingTracks.map((t) => t.trackId); + logger.error(`Trending results diverged ${ids} versus ${otherIds}`); + return null; + } + } + logger.debug(`Trending results converged with ${trendingTracks.map((t) => t.trackId)}`); + return { trendingTracks, blocknumber }; +} +/** + * For each of the trending tracks + * check if the track meets the constraints to become a notification + * - If the trending track was not already created in the past 3 hrs + * - The trending track should be new or move up in rank ie. from rank 4 => rank 1 + * Insert the notification and notificationAction into the DB + * Check the user's notification settings, and if enabled, send a push notification + * @param {AudiusLibs} audiusLibs Audius Libs instance + * @param {number} blocknumber Blocknumber of the discovery provider + * @param {Array<{ trackId: number, rank: number, userId: number }>} trendingTracks Array of the trending tracks + * @param {*} tx DB transaction + */ +async function processTrendingTracks(audiusLibs, blocknumber, trendingTracks, tx) { + const now = moment(); + for (let idx = 0; idx < trendingTracks.length; idx += 1) { + const { rank, trackId, userId } = trendingTracks[idx]; + const { notifyMobile, notifyBrowserPush } = await shouldNotifyUser(userId, 'milestonesAndAchievements'); + // Check if the notification was previously created + const existingTrendingTracks = await models.Notification.findAll({ + where: { + userId: userId, + type: notificationTypes.TrendingTrack, + entityId: trackId + }, + include: [ + { + model: models.NotificationAction, + as: 'actions' + } + ], + order: [['timestamp', 'DESC']], + limit: 1, + transaction: tx + }); + if (existingTrendingTracks.length > 0) { + const previousRank = existingTrendingTracks[0].actions[0].actionEntityId; + const previousCreated = moment(existingTrendingTracks[0].timestamp); + const duration = moment.duration(now.diff(previousCreated)).asHours(); + // If the user was notified of the trending track within the last TRENDING_INTERVAL_HOURS skip + // If the new rank is not less than the old rank, skip + // ie. Skip if track moved from #2 trending to #3 trending or stayed the same + if (duration < TRENDING_INTERVAL_HOURS || previousRank <= rank) { + // Skip the insertion of the notification into the DB + // This trending track does not meet the constraints + continue; + } + } + const actionEntityType = getTimeGenreActionType(TRENDING_TIME.WEEK, TRENDING_GENRE.ALL); + const trendingTrackNotification = await models.Notification.create({ + userId: userId, + type: notificationTypes.TrendingTrack, + entityId: trackId, + blocknumber, + timestamp: now + }, { transaction: tx }); + const notificationId = trendingTrackNotification.id; + await models.NotificationAction.create({ + notificationId, + actionEntityType, + actionEntityId: rank, + blocknumber + }, { transaction: tx }); + if (notifyMobile || notifyBrowserPush) { + const notifStub = { + userId: userId, + type: notificationTypes.TrendingTrack, + entityId: trackId, + blocknumber, + timestamp: now, + actions: [ + { + actionEntityType, + actionEntityId: rank, + blocknumber + } + ] + }; + const metadata = await fetchNotificationMetadata(audiusLibs, [], [notifStub]); + const mapNotification = notificationResponseMap[notificationTypes.TrendingTrack]; + try { + const msgGenNotif = { + ...notifStub, + ...mapNotification(notifStub, metadata) + }; + logger.debug('processTrendingTrack - About to generate message for trending track milestone push notification', msgGenNotif, metadata); + const msg = pushNotificationMessagesMap[notificationTypes.TrendingTrack](msgGenNotif); + logger.debug(`processTrendingTrack - message: ${msg}`); + const title = notificationResponseTitleMap[notificationTypes.TrendingTrack](); + const types = []; + if (notifyMobile) + types.push(deviceType.Mobile); + if (notifyBrowserPush) + types.push(deviceType.Browser); + await publish(msg, userId, tx, true, title, types); + } + catch (e) { + // Log on error instead of failing + logger.error(`Error adding trending track push notification to buffer: ${e}. ${JSON.stringify({ rank, trackId, userId })}`); + } + } + } +} +async function indexTrendingTracks(audiusLibs, optimizelyClient, tx) { + try { + const trendingExperiment = getRemoteVar(optimizelyClient, REMOTE_VARS.TRENDING_EXPERIMENT); + if (!SELECTED_DISCOVERY_NODES) + return; + const { trendingTracks, blocknumber } = await getTrendingTracks(trendingExperiment, SELECTED_DISCOVERY_NODES); + await processTrendingTracks(audiusLibs, blocknumber, trendingTracks, tx); + } + catch (err) { + logger.error(`Unable to process trending track notifications: ${err.message}`); + } +} +module.exports = { + TRENDING_TIME, + TRENDING_GENRE, + getTimeGenreActionType, + indexTrendingTracks, + getTrendingTracks, + processTrendingTracks +}; diff --git a/packages/identity-service/build/src/notifications/utils.js b/packages/identity-service/build/src/notifications/utils.js new file mode 100644 index 00000000000..07b4f19d9b9 --- /dev/null +++ b/packages/identity-service/build/src/notifications/utils.js @@ -0,0 +1,317 @@ +"use strict"; +const axios = require('axios'); +const moment = require('moment-timezone'); +const Hashids = require('hashids/cjs'); +const { dayInHours, weekInHours } = require('./constants'); +const models = require('../models'); +const config = require('../config'); +const { logger } = require('../logging'); +const audiusLibsWrapper = require('../audiusLibsInstance'); +const { getFeatureFlag, FEATURE_FLAGS } = require('../featureFlag'); +// default configs +const startBlock = config.get('notificationStartBlock'); +const startSlot = config.get('solanaNotificationStartSlot'); +// Number of tracks to fetch for new listens on each poll +const trackListenMilestonePollCount = 100; +/** + * For any users missing blockchain id, here we query the values from discprov and fill them in + */ +async function updateBlockchainIds() { + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + const usersWithoutBlockchainId = await models.User.findAll({ + attributes: ['walletAddress', 'handle'], + where: { blockchainUserId: null } + }); + for (const updateUser of usersWithoutBlockchainId) { + try { + const walletAddress = updateUser.walletAddress; + logger.info(`Updating user with wallet ${walletAddress}`); + const response = await axios({ + method: 'get', + url: `${discoveryProvider.discoveryProviderEndpoint}/users`, + params: { + wallet: walletAddress + } + }); + if (response.data.data.length === 1) { + const respUser = response.data.data[0]; + const missingUserId = respUser.user_id; + const missingHandle = respUser.handle; + const updateObject = { blockchainUserId: missingUserId }; + if (updateUser.handle === null) { + updateObject.handle = missingHandle; + } + await models.User.update(updateObject, { where: { walletAddress } }); + logger.info(`Updated wallet ${walletAddress} to blockchainUserId: ${missingUserId}, ${updateUser.handle}`); + continue; + } + for (const respUser of response.data.data) { + // Only update if handles match + if (respUser.handle === updateUser.handle) { + const missingUserId = respUser.user_id; + await models.User.update({ blockchainUserId: missingUserId }, { where: { walletAddress, handle: updateUser.handle } }); + logger.info(`Updated wallet ${walletAddress} to blockchainUserId: ${missingUserId}, ${updateUser.handle}`); + const userSettings = await models.UserNotificationSettings.findOne({ + where: { userId: missingUserId } + }); + if (userSettings == null) { + await models.UserNotificationSettings.create({ + userId: missingUserId + }); + } + } + } + } + catch (e) { + logger.error('Error in updateBlockchainIds', e); + } + } +} +/** + * Queries the discovery provider and returns n most listened to tracks, with the + * total listen count for each of those tracks + * This includes track listens writen to Solana + * + * @returns Array [{trackId, listenCount}, trackId, listenCount] + */ +async function calculateTrackListenMilestonesFromDiscovery(discoveryProvider) { + // Pull listen count notification data from discovery provider + const timeout = 2 /* min */ * 60 /* sec */ * 1000; /* ms */ + const trackListenMilestones = await discoveryProvider.getTrackListenMilestones(timeout); + const listenCountBody = trackListenMilestones.data; + const parsedListenCounts = []; + for (const key in listenCountBody) { + parsedListenCounts.push({ + trackId: key, + listenCount: listenCountBody[key] + }); + } + return parsedListenCounts; +} +/** + * Queries the discovery provider and returns all subscribers for each user in + * userIds. + * + * @param {Set} userIds to fetch subscribers for + * @returns Object {userId: Array[subscriberIds]} + */ +async function bulkGetSubscribersFromDiscovery(userIds) { + const userSubscribersMap = {}; + if (userIds.size === 0) { + return userSubscribersMap; + } + try { + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + const ids = [...userIds].map((id) => encodeHashId(id)); + const response = await axios.post(`${discoveryProvider.discoveryProviderEndpoint}/v1/full/users/subscribers`, { ids: ids }); + const userSubscribers = response.data.data; + userSubscribers.forEach((entry) => { + const encodedUserId = entry.user_id; + const encodedSubscriberIds = entry.subscriber_ids; + const userId = decodeHashId(encodedUserId); + const subscriberIds = encodedSubscriberIds.map((id) => decodeHashId(id)); + userSubscribersMap[userId] = subscriberIds; + }); + return userSubscribersMap; + } + catch (e) { + logger.error('Error when fetching subscribers from discovery', e); + return {}; + } +} +/** + * Checks whether to retrieve subscribers from discovery DB using + * the READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED feature flag. + * + * @returns Boolean + */ +const shouldReadSubscribersFromDiscovery = (optimizelyClient) => { + if (!optimizelyClient) { + return false; + } + return getFeatureFlag(optimizelyClient, FEATURE_FLAGS.READ_SUBSCRIBERS_FROM_DISCOVERY_ENABLED); +}; +/** + * For the n most recently listened to tracks, return the all time listen counts for those tracks + * where n is `trackListenMilestonePollCount + * + * @returns Array [{trackId, listenCount}, trackId, listenCount}] + */ +async function calculateTrackListenMilestones() { + const recentListenCountQuery = { + attributes: [ + [models.Sequelize.col('trackId'), 'trackId'], + [models.Sequelize.fn('max', models.Sequelize.col('hour')), 'hour'] + ], + order: [[models.Sequelize.col('hour'), 'DESC']], + group: ['trackId'], + limit: trackListenMilestonePollCount + }; + // Distinct tracks + const res = await models.TrackListenCount.findAll(recentListenCountQuery); + const tracksListenedTo = res.map((listenEntry) => listenEntry.trackId); + // Total listens query + const totalListens = { + attributes: [ + [models.Sequelize.col('trackId'), 'trackId'], + [ + models.Sequelize.fn('date_trunc', 'millennium', models.Sequelize.col('hour')), + 'date' + ], + [models.Sequelize.fn('sum', models.Sequelize.col('listens')), 'listens'] + ], + group: ['trackId', 'date'], + order: [[models.Sequelize.col('listens'), 'DESC']], + where: { + trackId: { [models.Sequelize.Op.in]: tracksListenedTo } + } + }; + // Map of listens + const totalListenQuery = await models.TrackListenCount.findAll(totalListens); + const processedTotalListens = totalListenQuery.map((x) => { + return { trackId: x.trackId, listenCount: x.listens }; + }); + return processedTotalListens; +} +/** + * Get max block from the NotificationAction table + * @returns Integer highestBlockNumber + */ +async function getHighestBlockNumber() { + let highestBlockNumber = await models.NotificationAction.max('blocknumber'); + if (!highestBlockNumber) { + highestBlockNumber = startBlock; + } + const date = new Date(); + logger.info(`Highest block: ${highestBlockNumber} - ${date}`); + return highestBlockNumber; +} +/** + * Get max slot from the SolanaNotificationAction table + * @returns Integer highestSlot + */ +async function getHighestSlot() { + let highestSlot = await models.SolanaNotificationAction.max('slot'); + if (!highestSlot) + highestSlot = startSlot; + const date = new Date(); + logger.info(`Highest slot: ${highestSlot} - ${date}`); + return highestSlot; +} +/** + * Checks the user notification settings for both regular and push notifications and + * returns if they should be notified according to their settings + * + * @param {Integer} notificationTarget userId that we want to send a notification to + * @param {String} prop property name in the settings object + * @param {Object} tx sequelize tx (optional) + * @returns Object { notifyWeb: Boolean, notifyMobile: Boolean} + */ +async function shouldNotifyUser(notificationTarget, prop, tx = null) { + // mobile + const mobileQuery = { where: { userId: notificationTarget } }; + if (tx) + mobileQuery.transaction = tx; + const userNotifSettingsMobile = await models.UserNotificationMobileSettings.findOne(mobileQuery); + const notifyMobile = (userNotifSettingsMobile && userNotifSettingsMobile[prop]) || false; + // browser push notifications + const browserPushQuery = { where: { userId: notificationTarget } }; + if (tx) + browserPushQuery.transaction = tx; + const userNotifBrowserPushSettings = await models.UserNotificationBrowserSettings.findOne(browserPushQuery); + const notifyBrowserPush = (userNotifBrowserPushSettings && userNotifBrowserPushSettings[prop]) || + false; + return { notifyMobile, notifyBrowserPush }; +} +/* We use a JS implementation of the the HashIds protocol (http://hashids.org) + * to obfuscate our monotonically increasing int IDs as + * strings in our consumable API. + * + * Discovery provider uses a python implementation of the same protocol + * to encode and decode IDs. + */ +const HASH_SALT = 'azowernasdfoia'; +const MIN_LENGTH = 5; +const hashids = new Hashids(HASH_SALT, MIN_LENGTH); +/** Encodes an int ID into a string. */ +function encodeHashId(id) { + return hashids.encode([id]); +} +/** Decodes a string id into an int. Returns null if an invalid ID. */ +function decodeHashId(id) { + const ids = hashids.decode(id); + if (!ids.length) + return null; + return ids[0]; +} +const EmailFrequency = Object.freeze({ + OFF: 'off', + LIVE: 'live', + DAILY: 'daily', + WEEKLY: 'weekly' +}); +const MAX_HOUR_TIME_DIFFERENCE = 2; +/** + * Checks if the user should recieve an email base on notification settings and time + * If setting is live then send email + * If email was never sent to user then send email + * If ~1 day has passed for daily frequency, or ~1 week has passed for weekly frequency then send email + * @param {EmailFrequency} frequency live | daily | weekly + * @param {moment} currentUtcTime moment datetime + * @param {moment} lastSentTimestamp moment datetime + * @param {number} hrsSinceStartOfDay + * @returns boolean + */ +const shouldSendEmail = (frequency, currentUtcTime, lastSentTimestamp, hrsSinceStartOfDay) => { + if (frequency === EmailFrequency.OFF) + return false; + if (frequency === EmailFrequency.LIVE) + return true; + // If this is the first email, then it should render + if (!lastSentTimestamp) + return true; + const isValidFrequency = [ + EmailFrequency.DAILY, + EmailFrequency.WEEKLY + ].includes(frequency); + const timeSinceEmail = moment + .duration(currentUtcTime.diff(lastSentTimestamp)) + .asHours(); + const timeThreshold = (frequency === 'daily' ? dayInHours : weekInHours) - 1; + const hasValidTimeDifference = hrsSinceStartOfDay < MAX_HOUR_TIME_DIFFERENCE; + return (hasValidTimeDifference && + isValidFrequency && + timeSinceEmail >= timeThreshold); +}; +const getSupporters = async (receiverUserId) => { + const encodedReceiverId = encodeHashId(receiverUserId); + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + const url = `${discoveryProvider.discoveryProviderEndpoint}/v1/full/users/${encodedReceiverId}/supporters`; + try { + const response = await axios({ + method: 'get', + url + }); + return response.data.data; + } + catch (e) { + console.error(`Error fetching supporters for user: ${receiverUserId}: ${e}`); + return []; + } +}; +module.exports = { + encodeHashId, + decodeHashId, + updateBlockchainIds, + calculateTrackListenMilestones, + calculateTrackListenMilestonesFromDiscovery, + bulkGetSubscribersFromDiscovery, + getSupporters, + shouldReadSubscribersFromDiscovery, + getHighestBlockNumber, + getHighestSlot, + shouldNotifyUser, + EmailFrequency, + shouldSendEmail, + MAX_HOUR_TIME_DIFFERENCE +}; diff --git a/packages/identity-service/build/src/rateLimiter.js b/packages/identity-service/build/src/rateLimiter.js new file mode 100644 index 00000000000..c41a5315506 --- /dev/null +++ b/packages/identity-service/build/src/rateLimiter.js @@ -0,0 +1,243 @@ +"use strict"; +const Redis = require('ioredis'); +const RedisStore = require('rate-limit-redis'); +const config = require('./config.js'); +const rateLimit = require('express-rate-limit'); +const express = require('express'); +const { isIPFromContentNode } = require('./utils/contentNodeIPCheck'); +const redisClient = new Redis(config.get('redisPort'), config.get('redisHost'), { showFriendlyErrorStack: config.get('environment') !== 'production' }); +const sigUtil = require('eth-sig-util'); +const { generators } = require('@audius/sdk/src/data-contracts/signatureSchemas.js'); +const models = require('./models'); +const { libs } = require('@audius/sdk'); +const { errorResponseRateLimited, sendResponse } = require('./apiHelpers.js'); +const AudiusABIDecoder = libs.AudiusABIDecoder; +const DEFAULT_EXPIRY = 60 * 60; // one hour in seconds +const isIPWhitelisted = (ip, req) => { + // If the IP is either something in the regex whitelist or it is from + // a known content node, return true + const whitelistRegex = config.get('rateLimitingListensIPWhitelist'); + const isWhitelisted = whitelistRegex && !!ip.match(whitelistRegex); + let isFromContentNode = false; + try { + isFromContentNode = isIPFromContentNode(ip, req); + } + catch (e) { + // Log out and continue if for some reason signature validation threw + req.logger.error(e); + } + // Don't return early so we can see logs for both paths + req.logger.debug(`isIPWhitelisted - isWhitelisted: ${isWhitelisted}, isFromContentNode: ${isFromContentNode}`); + return isWhitelisted || isFromContentNode; +}; +const getIP = (req) => { + // Gets the IP for rate-limiting based on X-Forwarded-For headers + // Algorithm: + // If 1 header or no headers: + // We are not running behind a proxy, something is probably wonky, use req.ip (leftmost) + // If > 1 headers: + // This assumes two proxies (some outer proxy like cloudflare and then some proxy like a load balancer) + // Rightmost header is the outer proxy + // Rightmost - 1 header is either a creator node OR the actual user + // If creator node, use Rightmost - 2 (since creator node will pass this along) + // Else, use Rightmost - 1 since it's the actual user + const ip = req.ip; + const forwardedFor = req.get('X-Forwarded-For'); + // This shouldn't ever happen since Identity will always be behind a proxy + if (!forwardedFor) { + return { ip, isWhitelisted: true }; + } + const headers = forwardedFor.split(','); + // headers length == 1 means that we are not running behind normal 2 layer proxy (probably locally), + // We can just use req.ip which corresponds to the best guess forward-for that was added if any + if (headers.length === 1) { + return { ip }; + } + // Length is at least 2, length - 1 would be the outermost proxy, so length - 2 is the "sender" + // either the actual user or a content node + const senderIP = headers[headers.length - 2]; + const isWhitelisted = isIPWhitelisted(senderIP, req); + if (isWhitelisted) { + const forwardedIP = headers[headers.length - 3]; + if (!forwardedIP) { + return { ip: senderIP, senderIP, isWhitelisted }; + } + return { ip: forwardedIP, senderIP, isWhitelisted }; + } + return { ip: senderIP, senderIP, isWhitelisted }; +}; +let endpointRateLimits = {}; +try { + endpointRateLimits = JSON.parse(config.get('endpointRateLimits')); +} +catch (e) { + console.error('Failed to parse endpointRateLimits!'); +} +const getReqKeyGenerator = (options = {}) => (req) => { + const { query = [], body = [], withIp = true } = options; + let key = withIp ? getIP(req).ip : ''; + if (req.query && query.length > 0) { + query.forEach((queryKey) => { + if (queryKey in req.query) { + key = key.concat(req.query[queryKey]); + } + }); + } + if (req.body && body.length > 0) { + body.forEach((paramKey) => { + if (paramKey in req.body) { + key = key.concat(req.body[paramKey]); + } + }); + } + return key; +}; +const onLimitReached = (req, res, options) => { + req.logger.warn(req.rateLimit, `Rate Limit Hit`); +}; +/** + * A generic endpoint rate limiter + * @param {object} config + * @param {string} config.prefix redis cache key prefix + * @param {number?} config.max maximum number of requests + * @param {expiry?} config.expiry time period of the rate limiter + * @param {(req: Request) => string?} config.keyGenerator redis cache + * key suffix (can use the request object) + * @param {boolean?} config.skip if true, limiter is avoided + */ +const getRateLimiter = ({ prefix, max, expiry = DEFAULT_EXPIRY, keyGenerator = (req) => getIP(req).ip, handler, message, skip }) => { + return rateLimit({ + store: new RedisStore({ + client: redisClient, + prefix: `rate:${prefix}`, + expiry + }), + max, + skip, + keyGenerator, + handler, + message, + onLimitReached + }); +}; +/** + * Create an express router to attach the rate-limiting middleware + */ +const validRouteMethods = ['get', 'post', 'put', 'delete']; +const getRateLimiterMiddleware = () => { + const router = express.Router(); + for (const route in endpointRateLimits) { + for (const method in endpointRateLimits[route]) { + if (validRouteMethods.includes(method)) { + const routeMiddleware = endpointRateLimits[route][method].map((limit) => { + const { expiry, max, options = {} } = limit; + const keyGenerator = getReqKeyGenerator(options); + return getRateLimiter({ + prefix: `${route}:${method}:${expiry}:${max}:`, + expiry, + max, + keyGenerator + }); + }); + router[method](route, routeMiddleware); + } + } + } + return router; +}; +const getEntityManagerActionKey = (encodedABI) => { + const decodedABI = decodeABI(encodedABI); + let key = decodedABI.action + decodedABI.entityType; + return key; +}; +const decodeABI = (encodedABI) => { + const decodedABI = AudiusABIDecoder.decodeMethod('EntityManager', encodedABI); + const mapping = {}; + // map without leading underscore in _userId + decodedABI.params.forEach((param) => { + mapping[param.name.substring(1)] = param.value; + }); + return mapping; +}; +const recoverSigner = (encodedABI) => { + const decodedABI = decodeABI(encodedABI); + const data = generators.getManageEntityData(config.get('acdcChainId'), config.get('entityManagerAddress'), decodedABI.userId, decodedABI.entityType, decodedABI.entityId, decodedABI.action, decodedABI.metadata, decodedABI.nonce); + return sigUtil.recoverTypedSignature({ data, sig: decodedABI.subjectSig }); +}; +const rateLimitMessage = 'Too many requests, please try again later'; +const getRelayBlocklistMiddleware = (req, res, next) => { + const signer = recoverSigner(req.body.encodedABI); + req.body.signer = signer; + const blocklist = config.get('blocklistPublicKeyFromRelay'); + if (blocklist && blocklist.includes(signer)) { + sendResponse(req, res, errorResponseRateLimited({ + message: rateLimitMessage + })); + } + next(); +}; +const getRelayRateLimiterMiddleware = () => { + return getRateLimiter({ + windowMs: 60 * 60 * 1000, + prefix: `relayWalletRateLimiter`, + max: async function (req) { + const key = getEntityManagerActionKey(req.body.encodedABI); + const signer = recoverSigner(req.body.encodedABI); + let limit = config.get(key); + req.user = await models.User.findOne({ + where: { walletAddress: signer }, + attributes: [ + 'id', + 'blockchainUserId', + 'walletAddress', + 'handle', + 'isBlockedFromRelay', + 'isBlockedFromNotifications', + 'isBlockedFromEmails', + 'appliedRules' + ] + }); + const allowlist = config.get('allowlistPublicKeyFromRelay'); + if (req.user) { + limit = limit['owner']; + req.isFromApp = false; + } + else { + if (allowlist && allowlist.includes(signer)) { + limit = limit['allowlist']; + } + else { + limit = limit['app']; + } + req.isFromApp = true; + } + return limit; + }, + keyGenerator: function (req) { + const key = getEntityManagerActionKey(req.body.encodedABI); + const signer = recoverSigner(req.body.encodedABI); + return ':::' + key + ':' + signer; + }, + handler: (req, res) => { + try { + const key = getEntityManagerActionKey(req.body.encodedABI); + const signer = recoverSigner(req.body.encodedABI); + req.logger.error({ _signer: signer, isApp: req.isFromApp }, `Rate limited sender ${signer} performing ${key}`); + } + catch (error) { + req.logger.error(`Cannot relay without sender address`); + } + sendResponse(req, res, errorResponseRateLimited({ + message: rateLimitMessage + })); + } + }); +}; +module.exports = { + getIP, + isIPWhitelisted, + getRateLimiter, + getRateLimiterMiddleware, + getRelayBlocklistMiddleware, + getRelayRateLimiterMiddleware +}; diff --git a/packages/identity-service/build/src/redis.js b/packages/identity-service/build/src/redis.js new file mode 100644 index 00000000000..ad95974c332 --- /dev/null +++ b/packages/identity-service/build/src/redis.js @@ -0,0 +1,68 @@ +"use strict"; +const Redis = require('ioredis'); +const config = require('./config.js'); +const redisClient = new Redis(config.get('redisPort'), config.get('redisHost'), { showFriendlyErrorStack: config.get('environment') !== 'production' }); +const { logger } = require('./logging'); +/** + * Generic locking class with the ability to set, get and clear + * Primarily used in POA and ETH relay transactions to lock + * relay wallets during a transaction + */ +class Lock { + /** + * Set lock for a key in redis + * @param {String} key redis key + * @returns true if lock is set, false if lock is not set + */ + static async setLock(key) { + const response = await redisClient.setnx(key, 1); + if (response) + return true; + else + return false; + } + /** + * Get if a lock exists in redis + * @param {String} key redis key for lock + * @returns true if lock exists, false if lock doesn't exist + */ + static async getLock(key) { + const response = await redisClient.get(key); + if (response) + return true; + else + return false; + } + static async clearLock(key) { + redisClient.del(key); + } + /** + * Clears all locks that match a redis key pattern + * @param {String} keyPattern redis key for lock, must include '*' to select all records for a pattern + */ + static async clearAllLocks(keyPattern) { + const stream = redisClient.scanStream({ + match: keyPattern + }); + const multi = redisClient.multi({ pipeline: true }); + return new Promise((resolve, reject) => { + stream.on('data', (resultKeys) => { + for (let i = 0; i < resultKeys.length; i++) { + multi.del(resultKeys[i]); + } + }); + stream.on('end', async () => { + await multi.exec(); + resolve(); + }); + stream.on('error', async (e) => { + logger.error(`Error deleting all values from Redis`, e); + reject(e); + }); + }); + } +} +module.exports = { + redisClient, + Lock +}; diff --git a/packages/identity-service/build/src/relay/ethTxRelay.js b/packages/identity-service/build/src/relay/ethTxRelay.js new file mode 100644 index 00000000000..e1caba7939e --- /dev/null +++ b/packages/identity-service/build/src/relay/ethTxRelay.js @@ -0,0 +1,205 @@ +"use strict"; +const EthereumWallet = require('ethereumjs-wallet'); +const EthereumTx = require('ethereumjs-tx'); +const axios = require('axios'); +const config = require('../config'); +const { ethWeb3 } = require('../web3'); +const { logger } = require('../logging'); +const { Lock } = require('../redis'); +const ENVIRONMENT = config.get('environment'); +const DEFAULT_GAS_LIMIT = config.get('defaultGasLimit'); +const GANACHE_GAS_PRICE = config.get('ganacheGasPrice'); +// L1 relayer wallets +const ethRelayerWallets = config.get('ethRelayerWallets'); // { publicKey, privateKey } +const generateETHWalletLockKey = (publicKey) => `ETH_RELAYER_WALLET:${publicKey}`; +async function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +// Calculates index into eth relayer addresses +const getEthRelayerWalletIndex = (walletAddress) => { + const walletParsedInteger = parseInt(walletAddress, 16); + return walletParsedInteger % ethRelayerWallets.length; +}; +// Select from the list of eth relay wallet addresses +// Return the public key that will be used to relay this address +const queryEthRelayerWallet = (walletAddress) => { + return ethRelayerWallets[getEthRelayerWalletIndex(walletAddress)].publicKey; +}; +// Query current balance for a given relayer public key +const getEthRelayerFunds = async (walletPublicKey) => { + return ethWeb3.eth.getBalance(walletPublicKey); +}; +const selectEthWallet = async (walletPublicKey, reqLogger) => { + reqLogger.info(`L1 txRelay - Acquiring lock for ${walletPublicKey}`); + const ethWalletIndex = getEthRelayerWalletIndex(walletPublicKey); + const selectedRelayerWallet = ethRelayerWallets[ethWalletIndex]; + while ((await Lock.setLock(generateETHWalletLockKey(selectedRelayerWallet.publicKey))) !== true) { + await delay(200); + } + reqLogger.info(`L1 txRelay - Locking ${selectedRelayerWallet.publicKey}, index=${ethWalletIndex}}`); + return { + selectedEthRelayerWallet: selectedRelayerWallet, + ethWalletIndex + }; +}; +// Relay a transaction to the ethereum network +const sendEthTransaction = async (req, txProps, reqBodySHA) => { + const { contractAddress, encodedABI, senderAddress, gasLimit } = txProps; + // Calculate relayer from senderAddress + const { selectedEthRelayerWallet, ethWalletIndex } = await selectEthWallet(senderAddress, logger); + req.logger.info(`L1 txRelay - selected relayerPublicWallet=${selectedEthRelayerWallet.publicKey}`); + const ethGasPriceInfo = await getProdGasInfo(req.app.get('redis'), req.logger); + // Select the 'fast' gas price + let ethRelayGasPrice = ethGasPriceInfo[config.get('ethRelayerProdGasTier')]; + ethRelayGasPrice = + ethRelayGasPrice * parseFloat(config.get('ethGasMultiplier')); + let resp; + try { + resp = await createAndSendEthTransaction({ + publicKey: selectedEthRelayerWallet.publicKey, + privateKey: selectedEthRelayerWallet.privateKey + }, contractAddress, '0x00', ethWeb3, req.logger, ethRelayGasPrice, gasLimit, encodedABI); + } + catch (e) { + req.logger.error('L1 txRelay - Error in relay', e); + throw e; + } + finally { + req.logger.info(`L1 txRelay - Unlocking ${ethRelayerWallets[ethWalletIndex].publicKey}, index=${ethWalletIndex}}`); + // Unlock wallet + await Lock.clearLock(generateETHWalletLockKey(ethRelayerWallets[ethWalletIndex].publicKey)); + } + req.logger.info(`L1 txRelay - success, req:${reqBodySHA}, sender:${senderAddress}`); + return resp; +}; +const estimateEthTransactionGas = async (senderAddress, to, data) => { + const ethWalletIndex = getEthRelayerWalletIndex(senderAddress); + const selectedRelayerWallet = ethRelayerWallets[ethWalletIndex]; + const toChecksumAddress = ethWeb3.utils.toChecksumAddress; + const estimatedGas = await ethWeb3.eth.estimateGas({ + from: toChecksumAddress(selectedRelayerWallet.publicKey), + to: toChecksumAddress(to), + data + }); + return estimatedGas; +}; +const createAndSendEthTransaction = async (sender, receiverAddress, value, web3, logger, gasPrice, gasLimit = null, data = null) => { + const privateKeyBuffer = Buffer.from(sender.privateKey, 'hex'); + const walletAddress = EthereumWallet.fromPrivateKey(privateKeyBuffer); + const address = walletAddress.getAddressString(); + if (address !== sender.publicKey.toLowerCase()) { + throw new Error(`L1 txRelay - Invalid relayerPublicKey found. Expected ${sender.publicKey.toLowerCase()}, found ${address}`); + } + const nonce = await web3.eth.getTransactionCount(address); + let txParams = { + nonce: web3.utils.toHex(nonce), + gasPrice, + gasLimit: gasLimit ? web3.utils.numberToHex(gasLimit) : DEFAULT_GAS_LIMIT, + to: receiverAddress, + value: web3.utils.toHex(value) + }; + logger.info(`L1 txRelay - Final params: ${JSON.stringify(txParams)}`); + if (data) { + txParams = { ...txParams, data }; + } + const tx = new EthereumTx(txParams); + tx.sign(privateKeyBuffer); + const signedTx = '0x' + tx.serialize().toString('hex'); + logger.info(`L1 txRelay - sending a transaction for sender ${sender.publicKey} to ${receiverAddress}, gasPrice ${parseInt(gasPrice, 16)}, gasLimit ${DEFAULT_GAS_LIMIT}, nonce ${nonce}`); + const receipt = await web3.eth.sendSignedTransaction(signedTx); + return { txHash: receipt.transactionHash, txParams }; +}; +// Query mainnet ethereum gas prices +/* + Sample call:https://data-api.defipulse.com/api/v1/egs/api/ethgasAPI.json?api-key=some_key +*/ +const getProdGasInfo = async (redis, logger) => { + if (ENVIRONMENT === 'development') { + return { + fastGweiHex: GANACHE_GAS_PRICE, + averageGweiHex: GANACHE_GAS_PRICE, + fastestGweiHex: GANACHE_GAS_PRICE + }; + } + const prodGasPriceKey = 'eth-gas-prod-price-info'; + let gasInfo = await redis.get(prodGasPriceKey); + if (!gasInfo) { + logger.info(`Redis cache miss, querying remote`); + let prodGasInfo; + const defiPulseKey = config.get('defiPulseApiKey'); + if (defiPulseKey !== '') { + logger.info(`L1 txRelay querying ethGas with apiKey`); + prodGasInfo = await axios({ + method: 'get', + url: `https://data-api.defipulse.com/api/v1/egs/api/ethgasAPI.json?api-key=${defiPulseKey}` + }); + } + else { + prodGasInfo = await axios({ + method: 'get', + url: 'https://ethgasstation.info/api/ethgasAPI.json' + }); + } + const { fast, fastest, safeLow, average } = prodGasInfo.data; + gasInfo = { fast, fastest, safeLow, average }; + // Convert returned values into gwei to be used during relay and cache + // Must divide by 10 to get gwei price (Math.pow(10, 9) -> Math.pow(10, 8)) + // https://docs.ethgasstation.info/gas-price + gasInfo.fastGwei = parseInt(gasInfo.fast) * Math.pow(10, 8); + gasInfo.fastestGwei = parseInt(gasInfo.fastest) * Math.pow(10, 8); + gasInfo.averageGwei = parseInt(gasInfo.average) * Math.pow(10, 8); + gasInfo.fastGweiHex = ethWeb3.utils.numberToHex(gasInfo.fastGwei); + gasInfo.fastestGweiHex = ethWeb3.utils.numberToHex(gasInfo.fastestGwei); + gasInfo.averageGweiHex = ethWeb3.utils.numberToHex(gasInfo.averageGwei); + gasInfo.cachedResponse = false; + redis.set(prodGasPriceKey, JSON.stringify(gasInfo), 'EX', 30); + logger.info(`L1 txRelay - Updated gasInfo: ${JSON.stringify(gasInfo)}`); + } + else { + gasInfo = JSON.parse(gasInfo); + gasInfo.cachedResponse = true; + } + return gasInfo; +}; +/** + * Fund L1 wallets as necessary to facilitate multiple relayers + */ +const fundEthRelayerIfEmpty = async () => { + const minimumBalance = ethWeb3.utils.toWei(config.get('ethMinimumBalance').toString(), 'ether'); + for (const ethWallet of ethRelayerWallets) { + const ethWalletPublicKey = ethWallet.publicKey; + const balance = await ethWeb3.eth.getBalance(ethWalletPublicKey); + logger.info(`L1 txRelay - balance for ethWalletPublicKey ${ethWalletPublicKey}: ${balance}, minimumBalance: ${minimumBalance}`); + const validBalance = parseInt(balance) >= minimumBalance; + if (ENVIRONMENT === 'development') { + if (!validBalance) { + const account = (await ethWeb3.eth.getAccounts())[0]; // local acc is unlocked and does not need private key + logger.info(`L1 txRelay - transferring funds [${minimumBalance}] from ${account} to wallet ${ethWalletPublicKey}`); + await ethWeb3.eth.sendTransaction({ + from: account, + to: ethWalletPublicKey, + value: minimumBalance + }); + logger.info(`L1 txRelay - transferred funds [${minimumBalance}] from ${account} to wallet ${ethWalletPublicKey}`); + } + else { + logger.info(`L1 txRelay - ${ethWalletPublicKey} has valid balance ${balance}, minimum:${minimumBalance}`); + } + } + else { + // In non-development environments, ethRelay wallets must be funded prior to deployment of this service + // Automatic funding in L1 environment is TBD + logger.info(`L1 txRelay - ${ethWalletPublicKey} below minimum balance`); + throw new Error(`Invalid balance for ethRelayer account ${ethWalletPublicKey}. Found ${balance}, required minimumBalance ${minimumBalance}`); + } + } +}; +module.exports = { + estimateEthTransactionGas, + fundEthRelayerIfEmpty, + sendEthTransaction, + queryEthRelayerWallet, + getEthRelayerFunds, + getProdGasInfo, + generateETHWalletLockKey +}; diff --git a/packages/identity-service/build/src/relay/txRelay.js b/packages/identity-service/build/src/relay/txRelay.js new file mode 100644 index 00000000000..1d6ba8a0960 --- /dev/null +++ b/packages/identity-service/build/src/relay/txRelay.js @@ -0,0 +1,467 @@ +"use strict"; +const EthereumWallet = require('ethereumjs-wallet'); +const EthereumTx = require('ethereumjs-tx'); +const Accounts = require('web3-eth-accounts'); +const models = require('../models'); +const config = require('../config'); +const { logger } = require('../logging'); +const { Lock } = require('../redis'); +const RelayReporter = require('../utils/relayReporter'); +const { libs } = require('@audius/sdk'); +const AudiusABIDecoder = libs.AudiusABIDecoder; +const { primaryWeb3, nethermindWeb3, secondaryWeb3 } = require('../web3'); +// L2 relayerWallets +const relayerWallets = config.get('relayerWallets'); // { publicKey, privateKey } +const ENVIRONMENT = config.get('environment'); +const MIN_GAS_PRICE = config.get('minGasPrice'); +const HIGH_GAS_PRICE = config.get('highGasPrice'); +const GANACHE_GAS_PRICE = config.get('ganacheGasPrice'); +const DEFAULT_GAS_LIMIT = config.get('defaultGasLimit'); +const UPDATE_REPLICA_SET_RECONFIGURATION_LIMIT = config.get('updateReplicaSetReconfigurationLimit'); +const UPDATE_REPLICA_SET_WALLET_WHITELIST = config.get('updateReplicaSetWalletWhitelist'); +const transactionRateLimiter = { + updateReplicaSetReconfiguration: 0 +}; +setInterval(() => { + transactionRateLimiter.updateReplicaSetReconfiguration = 0; +}, 10000); +async function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +const generateWalletLockKey = (publicKey) => `POA_RELAYER_WALLET:${publicKey}`; +async function getGasPrice(logger, web3) { + let gasPrice = parseInt(await web3.eth.getGasPrice()); + if (isNaN(gasPrice) || gasPrice > HIGH_GAS_PRICE) { + logger.info('txRelay - gas price was not defined or was greater than HIGH_GAS_PRICE', gasPrice); + gasPrice = GANACHE_GAS_PRICE; + } + else if (gasPrice === 0) { + logger.info('txRelay - gas price was zero', gasPrice); + // If the gas is zero, the txn will likely never get mined. + gasPrice = MIN_GAS_PRICE; + } + else if (gasPrice < MIN_GAS_PRICE) { + logger.info('txRelay - gas price was less than MIN_GAS_PRICE', gasPrice); + gasPrice = MIN_GAS_PRICE; + } + gasPrice = '0x' + gasPrice.toString(16); + return gasPrice; +} +/** Attempt to send transaction to primary web3 provider, if that fails try secondary */ +const sendTransaction = async (req, resetNonce = false, txProps, reqBodySHA) => { + let resp = null; + try { + resp = await sendTransactionInternal(req, primaryWeb3, txProps, reqBodySHA); + } + catch (e) { + req.logger.error(`txRelay - sendTransaction Error - ${e}. Retrying with secondary web3.`); + resp = await sendTransactionInternal(req, secondaryWeb3, txProps, reqBodySHA); + } + return resp; +}; +const sendTransactionInternal = async (req, web3, txProps, reqBodySHA) => { + let { contractRegistryKey, contractAddress, nethermindContractAddress, encodedABI, nethermindEncodedABI, senderAddress, gasLimit } = txProps; + const redis = req.app.get('redis'); + const startTransactionLatency = new Date().getTime(); + const reporter = new RelayReporter({ + // report analytics everywhere except local + shouldReportAnalytics: config.get('environment') !== 'development' + }); + const user = await models.User.findOne({ + where: { walletAddress: req.body.senderAddress }, + attributes: ['blockchainUserId'] + }); + const userId = user && user.blockchainUserId ? user.blockchainUserId : 'unknown'; + reporter.reportStart({ + userId, + contractAddress, + nethermindContractAddress, + senderAddress + }); + // SEND to both nethermind and POA + // sendToNethermindOnly indicates relay should respond with that receipt + const currentBlock = await web3.eth.getBlockNumber(); + const finalPOABlock = config.get('finalPOABlock'); + let sendToNethermindOnly = finalPOABlock + ? currentBlock > finalPOABlock + : false; + // force staging to use nethermind since it hasn't surpassed finalPOABlock + // prod will surpass + if (!config.get('nethermindEnabled')) { + // nulling this will disable nethermind relays + nethermindContractAddress = null; + } + if (config.get('environment') === 'staging' || + config.get('environment') === 'production') { + sendToNethermindOnly = true; + } + const contractName = contractRegistryKey.charAt(0).toUpperCase() + contractRegistryKey.slice(1); // uppercase the first letter + const decodedABI = AudiusABIDecoder.decodeMethod(contractName, encodedABI); + const nonce = decodedABI.params.find((param) => param.name === '_nonce').value; + const sig = decodedABI.params.find((param) => param.name === '_subjectSig').value; + const hashedData = web3.utils.keccak256(nonce + sig); + const existingTx = await models.Transaction.findOne({ + where: { + encodedNonceAndSignature: hashedData // this should always be unique + } + }); + // if this transaction has already been submitted before and succeeded, send this receipt + if (existingTx) { + return existingTx.receipt; + } + filterReplicaSetUpdates(decodedABI, senderAddress); + // will be set later. necessary for code outside scope of try block + let txReceipt; + let txParams; + let redisLogParams; + let wallet = await selectWallet(); + // If all wallets are currently in use, keep iterating until a wallet is freed up + while (!wallet) { + await delay(200); + wallet = await selectWallet(); + } + await redis.zadd('relayTxAttempts', Math.floor(Date.now() / 1000), JSON.stringify({ + date: Math.floor(Date.now() / 1000), + reqBodySHA, + senderAddress + })); + req.logger.info(`L2 - txRelay - selected wallet ${wallet.publicKey} for sender ${senderAddress}`); + // relay stats object that gets filled out as relay occurs + const relayStats = { + poa: { + isRecipient: false, + txSubmissionTime: null + }, + nethermind: { + isRecipient: false, + txSubmissionTime: null + } + }; + try { + req.logger.info(`L2 - txRelay - selected wallet ${wallet.publicKey} for sender ${senderAddress}`); + // send to POA + // PROD doesn't have sendToNethermindOnly and should default to POA + // STAGE defaults to nethermind but can send to POA when it has both addresses + const relayPromises = []; + if (!sendToNethermindOnly) { + relayStats.poa.isRecipient = true; + relayPromises.push(createAndSendTransaction(wallet, contractAddress, '0x00', web3, req.logger, gasLimit, encodedABI)); + } + // send to nethermind + // PROD doesn't have sendToNethermindOnly and only sends to nethermind when it has both addresses + // STAGE defaults to nethermind + if (sendToNethermindOnly) { + if (!nethermindContractAddress) { + nethermindContractAddress = contractAddress; + nethermindEncodedABI = encodedABI; + } + relayStats.nethermind.isRecipient = true; + relayPromises.push(relayToNethermindWithTimeout(nethermindEncodedABI, nethermindContractAddress, gasLimit)); + } + const relayTxs = await Promise.allSettled(relayPromises); + const end = new Date().getTime(); + const totalTransactionLatency = end - startTransactionLatency; + logger.info(`L2 - txRelay - relays settled ${JSON.stringify(relayTxs)}`); + if (relayTxs.length === 1) { + txParams = relayTxs[0].value.txParams; + txReceipt = relayTxs[0].value.receipt; + // infer tx type and populate time + if (relayStats.nethermind.isRecipient) { + relayStats.nethermind.txSubmissionTime = + relayTxs[0].value.timeToComplete; + reporter.reportSuccess({ + chain: 'acdc', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.nethermind.txSubmissionTime + }); + } + else { + relayStats.poa.txSubmissionTime = relayTxs[0].value.timeToComplete; + reporter.reportSuccess({ + chain: 'poa', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.poa.txSubmissionTime + }); + } + } + else if (relayTxs.length === 2) { + const [poaTx, nethermindTx] = relayTxs.map((result) => result?.value); + logger.info(`txRelay - poaTx: ${JSON.stringify(poaTx?.txParams)} | nethermindTx: ${JSON.stringify(nethermindTx?.txParams)}`); + if (sendToNethermindOnly) { + txParams = nethermindTx.txParams; + txReceipt = nethermindTx.receipt; + } + else { + txParams = poaTx.txParams; + txReceipt = poaTx.receipt; + } + // populate both, we want stats if relay went to both chains + relayStats.nethermind.txSubmissionTime = nethermindTx.timeToComplete; + relayStats.poa.txSubmissionTime = poaTx.timeToComplete; + reporter.reportSuccess({ + chain: 'poa', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.poa.txSubmissionTime + }); + reporter.reportSuccess({ + chain: 'acdc', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.nethermind.txSubmissionTime + }); + } + redisLogParams = { + date: Math.floor(Date.now() / 1000), + reqBodySHA, + txParams, + senderAddress, + nonce: txParams.nonce, + relayStats, + totalTransactionLatency + }; + req.logger.info(`L2 - txRelay - sending a transaction for wallet ${wallet.publicKey} to ${senderAddress}, req ${reqBodySHA}, gasPrice ${parseInt(txParams.gasPrice, 16)}, gasLimit ${gasLimit}, nonce ${txParams.nonce}`); + await redis.zadd('relayTxSuccesses', Math.floor(Date.now() / 1000), JSON.stringify(redisLogParams)); + await redis.hset('txHashToSenderAddress', txReceipt.transactionHash, senderAddress); + } + catch (e) { + req.logger.error('L2 - txRelay - Error in relay', e); + await redis.zadd('relayTxFailures', Math.floor(Date.now() / 1000), JSON.stringify(redisLogParams)); + const end = new Date().getTime(); + const totalTransactionLatency = end - startTransactionLatency; + reporter.reportError({ + chain: 'poa', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.poa.txSubmissionTime, + errMsg: e.toString() + }); + reporter.reportError({ + chain: 'acdc', + userId, + contractAddress, + nethermindContractAddress, + senderAddress, + totalTime: totalTransactionLatency, + txSubmissionTime: relayStats.nethermind.txSubmissionTime, + errMsg: e.toString() + }); + throw e; + } + finally { + await Lock.clearLock(generateWalletLockKey(wallet.publicKey)); + } + req.logger.info(`L2 - txRelay - success, req ${reqBodySHA}`); + await models.Transaction.create({ + contractRegistryKey: contractRegistryKey, + contractFn: decodedABI.name, + contractAddress: contractAddress, + senderAddress: senderAddress, + encodedNonceAndSignature: hashedData, + decodedABI: decodedABI, + receipt: txReceipt + }); + return txReceipt; +}; +/** + * Rate limit replica set reconfiguration transactions + * the available wallets using mod + * + * A reconfiguration (as opposed to a first time selection) will have an + * _oldPrimaryId value of "0" + */ +const filterReplicaSetUpdates = (decodedABI, senderAddress) => { + let isReplicaSetTransaction = false; + let isFirstReplicaSetConfig = false; + if (decodedABI.name === 'updateReplicaSet') { + // TODO remove legacy replica set updates + isFirstReplicaSetConfig = decodedABI.params.find((param) => param.name === '_oldPrimaryId' && param.value === '0'); + } + else if (decodedABI.name === 'manageEntity') { + isReplicaSetTransaction = decodedABI.params.find((param) => param.name === '_entityType' && param.value === 'UserReplicaSet'); + // isFirstReplicaSetConfig must be false + // EntityManager create user actions include the initial replica set + } + if (isReplicaSetTransaction && !isFirstReplicaSetConfig) { + transactionRateLimiter.updateReplicaSetReconfiguration += 1; + if (transactionRateLimiter.updateReplicaSetReconfiguration > + UPDATE_REPLICA_SET_RECONFIGURATION_LIMIT) { + throw new Error('updateReplicaSet rate limit reached'); + } + if (UPDATE_REPLICA_SET_WALLET_WHITELIST.length > 0 && + !UPDATE_REPLICA_SET_WALLET_WHITELIST.includes(senderAddress)) { + throw new Error(`Sender ${senderAddress} not allowed to make updateReplicaSet calls`); + } + } +}; +/** + * Randomly select a wallet with a random offset. Circularly iterate through all + * the available wallets using mod + * + * e.g. If there are 5 wallets available and the offset is at 2, iterate + * in the order 2, 3, 4, 0, 1, and use const count to iterate through + * all the available number of wallets + */ +const selectWallet = async () => { + let i = Math.floor(Math.random() * relayerWallets.length); // random offset + let count = 0; // num wallets to iterate through + while (count++ < relayerWallets.length) { + const wallet = relayerWallets[i++ % relayerWallets.length]; + try { + const locked = await Lock.setLock(generateWalletLockKey(wallet.publicKey)); + if (locked) + return wallet; + } + catch (e) { + logger.error('Error selecting POA wallet for txRelay, reselecting', e); + } + } +}; +/** + * Fund L2 wallets as necessary to facilitate multiple relayers + */ +const fundRelayerIfEmpty = async () => { + const minimumBalance = primaryWeb3.utils.toWei(config.get('minimumBalance').toString(), 'ether'); + for (const wallet of relayerWallets) { + let balance = await primaryWeb3.eth.getBalance(wallet.publicKey); + logger.info('L2 - Attempting to fund wallet', wallet.publicKey, parseInt(balance)); + if (parseInt(balance) < minimumBalance) { + logger.info(`L2 - Relay account below minimum expected. Attempting to fund ${wallet.publicKey}`); + if (ENVIRONMENT === 'development') { + const account = (await primaryWeb3.eth.getAccounts())[0]; // local acc is unlocked and does not need private key + logger.info(`L2 - transferring funds [${minimumBalance}] from ${account} to wallet ${wallet.publicKey}`); + await primaryWeb3.eth.sendTransaction({ + from: account, + to: wallet.publicKey, + value: minimumBalance + }); + } + else { + logger.info(`relayerPublicKey: ${config.get('relayerPublicKey')}`); + logger.info(`L2 - transferring funds [${minimumBalance}] from ${config.get('relayerPublicKey')} to wallet ${wallet.publicKey}`); + const { receipt } = await createAndSendTransaction({ + publicKey: config.get('relayerPublicKey'), + privateKey: config.get('relayerPrivateKey') + }, wallet.publicKey, minimumBalance, primaryWeb3, logger); + logger.info(`L2 - the transaction receipt ${JSON.stringify(receipt)}`); + } + balance = await getRelayerFunds(wallet.publicKey); + logger.info('L2 - Balance of relay account:', wallet.publicKey, primaryWeb3.utils.fromWei(balance.toString(), 'ether'), 'eth'); + } + } +}; +// Send transaction using provided web3 object +const createAndSendTransaction = async (sender, receiverAddress, value, web3, logger, gasLimit = null, data = null) => { + const privateKeyBuffer = Buffer.from(sender.privateKey, 'hex'); + const walletAddress = EthereumWallet.fromPrivateKey(privateKeyBuffer); + const address = walletAddress.getAddressString(); + if (address !== sender.publicKey.toLowerCase()) { + throw new Error('Invalid relayerPublicKey'); + } + const start = new Date().getTime(); + const gasPrice = await getGasPrice(logger, web3); + const nonce = await web3.eth.getTransactionCount(address); + gasLimit = gasLimit ? web3.utils.numberToHex(gasLimit) : DEFAULT_GAS_LIMIT; + let txParams = { + nonce: web3.utils.toHex(nonce), + gasPrice, + gasLimit, + to: receiverAddress, + value: web3.utils.toHex(value) + }; + logger.info(`Final params: ${JSON.stringify(txParams)}`); + if (data) { + txParams = { ...txParams, data }; + } + const tx = new EthereumTx(txParams); + tx.sign(privateKeyBuffer); + const signedTx = '0x' + tx.serialize().toString('hex'); + logger.info(`txRelay - sending a transaction for sender ${sender.publicKey} to ${receiverAddress}, gasPrice ${parseInt(gasPrice, 16)}, gasLimit ${DEFAULT_GAS_LIMIT}, nonce ${nonce}`); + const end = new Date().getTime(); + const took = end - start; + logger.info(`createAndSendTransaction ok txhash: ${signedTx.transactionHash} took: ${took}`); + const receipt = await web3.eth.sendSignedTransaction(signedTx); + return { receipt, txParams, timeToComplete: took }; +}; +// +// Relay txn to nethermind +// +async function relayToNethermindWithTimeout(encodedABI, contractAddress, gasLimit) { + return Promise.race([ + relayToNethermind(encodedABI, contractAddress, gasLimit), + new Promise((resolve, reject) => setTimeout(() => { + const timeoutMessage = `txRelay - relayToNethermind timed out`; + reject(new Error(timeoutMessage)); + }, 10000)) + ]); +} +let inFlight = 0; +async function relayToNethermind(encodedABI, contractAddress, gasLimit) { + logger.info(`txRelay - relayToNethermind input params: ${encodedABI} ${contractAddress} ${gasLimit}`); + // generate a new private key per transaction (gas is free) + const accounts = new Accounts(config.get('nethermindWeb3Provider')); + const wallet = accounts.create(); + const privateKey = wallet.privateKey.substring(2); + const start = new Date().getTime(); + try { + const toChecksumAddress = nethermindWeb3.utils.toChecksumAddress; + const nethermindGasLimit = await nethermindWeb3.eth.estimateGas({ + from: toChecksumAddress(wallet.address), + to: toChecksumAddress(contractAddress), + data: encodedABI + }); + const transaction = { + to: contractAddress, + value: 0, + gas: nethermindGasLimit, + gasPrice: 0, + data: encodedABI + }; + const signedTx = await nethermindWeb3.eth.accounts.signTransaction(transaction, privateKey); + inFlight++; + const myDepth = inFlight; + logger.info(`relayToNethermind sending txhash: ${signedTx.transactionHash} num: ${myDepth}`); + const receipt = await nethermindWeb3.eth.sendSignedTransaction(signedTx.rawTransaction); + logger.info(`txRelay - relayToNethermind receipt: ${JSON.stringify(receipt)}`); + receipt.blockNumber += config.get('finalPOABlock'); + const end = new Date().getTime(); + const took = end - start; + inFlight--; + logger.info(`relayToNethermind ok txhash: ${signedTx.transactionHash} num: ${myDepth} took: ${took} pending: ${inFlight}`); + return { + txParams: transaction, + receipt, + timeToComplete: took + }; + } + catch (err) { + logger.info('relayToNethermind error:', err.toString()); + } +} +const getRelayerFunds = async (walletPublicKey) => { + return primaryWeb3.eth.getBalance(walletPublicKey); +}; +module.exports = { + selectWallet, + sendTransaction, + getRelayerFunds, + fundRelayerIfEmpty, + generateWalletLockKey +}; diff --git a/packages/identity-service/build/src/remoteConfig.js b/packages/identity-service/build/src/remoteConfig.js new file mode 100644 index 00000000000..954513fdc01 --- /dev/null +++ b/packages/identity-service/build/src/remoteConfig.js @@ -0,0 +1,86 @@ +"use strict"; +const { logger } = require('./logging'); +const REMOTE_CONFIG_FEATURE = 'remote_config'; +const DISCOVERY_NOTIFICATION_MAPPING = 'discovery_notification_mapping'; +const MappingVariable = { + PushRepost: 'push_repost', + PushSave: 'push_save', + PushRemix: 'push_remix', + PushCosign: 'push_cosign', + PushAddTrackToPlaylist: 'push_add_track_to_playlist', + PushFollow: 'push_follow', + PushMilestone: 'push_milestone', + PushMilestoneFollowerCount: 'push_milestone_follower_count', + PushSupporterRankUp: 'push_supporter_rank_up', + PushSupportingRankUp: 'push_supporting_rank_up', + PushSupporterDethroned: 'push_supporter_dethroned', + PushTipReceive: 'push_tip_receive', + PushTipSend: 'push_tip_send', + PushChallengeReward: 'push_challenge_reward', + PushTrackAddedToPlaylist: 'push_track_added_to_playlist', + PushCreate: 'push_create', + PushTrending: 'push_trending', + PushAnnouncement: 'push_announcement', + PushReaction: 'push_reaction' +}; +const NOTIFICATIONS_EMAIL_PLUGIN = 'notification_email_plugin'; +const EmailPluginMappings = { + Live: 'live', + Scheduled: 'scheduled' +}; +// Declaration of remote config variables set in optimizely +const REMOTE_VARS = Object.freeze({ + TRENDING_EXPERIMENT: 'TRENDING_EXPERIMENT', + CHALLENGE_IDS_DENY_LIST: 'CHALLENGE_IDS_DENY_LIST', + REWARDS_ATTESTATION_ENDPOINTS: 'REWARDS_ATTESTATION_ENDPOINTS', + ORACLE_ENDPOINT: 'ORACLE_ENDPOINT', + ORACLE_ETH_ADDRESS: 'ORACLE_ETH_ADDRESS', + ATTESTER_DELAY_SEC: 'ATTESTER_DELAY_SEC', + ATTESTER_PARALLELIZATION: 'ATTESTER_PARALLELIZATION' +}); +// Default values for remote vars while optimizely has not loaded +// Generally, these should be never seen unless variables are +// consumed within a few seconds of server init +const DEFAULTS = Object.freeze({ + [REMOTE_VARS.TRENDING_EXPERIMENT]: null, + [REMOTE_VARS.CHALLENGE_IDS_DENY_LIST]: [], + [REMOTE_VARS.ATTESTER_DELAY_SEC]: 60, + [REMOTE_VARS.ATTESTER_PARALLELIZATION]: 2 +}); +// Use a dummy user id since remote config is enabled by default +// for all users +const DUMMY_USER_ID = 'ANONYMOUS_USER'; +/** + * Fetches a remote variable + * @param {OptimizelyClient?} optimizelyClient + * @param {String} variable REMOTE_VARS value + * @returns + */ +const getRemoteVar = (optimizelyClient, variable) => { + if (!optimizelyClient) { + return DEFAULTS[variable]; + } + return optimizelyClient.getFeatureVariable(REMOTE_CONFIG_FEATURE, variable, DUMMY_USER_ID); +}; +/** + * Fetches a remote feature variable + * @param {OptimizelyClient?} optimizelyClient + * @param {String} variable REMOTE_FEATURE value + * @param {String} variable REMOTE_VARS value + * @returns + */ +const getRemoteFeatureVarEnabled = (optimizelyClient, feature, variable) => { + if (!optimizelyClient) { + return DEFAULTS[variable]; + } + return optimizelyClient.getFeatureVariableBoolean(feature, variable, DUMMY_USER_ID); +}; +module.exports = { + getRemoteVar, + REMOTE_VARS, + getRemoteFeatureVarEnabled, + DISCOVERY_NOTIFICATION_MAPPING, + MappingVariable, + NOTIFICATIONS_EMAIL_PLUGIN, + EmailPluginMappings +}; diff --git a/packages/identity-service/build/src/routes/authentication.js b/packages/identity-service/build/src/routes/authentication.js new file mode 100644 index 00000000000..9dc5d2fc550 --- /dev/null +++ b/packages/identity-service/build/src/routes/authentication.js @@ -0,0 +1,67 @@ +"use strict"; +const models = require('../models'); +const { handleResponse, successResponse, errorResponseBadRequest } = require('../apiHelpers'); +module.exports = function (app) { + /** + * This signup function writes the encryption values from the user's browser(iv, cipherText, lookupKey) + * into the Authentications table and the email to the Users table. This is the first step in the + * authentication process + */ + app.post('/authentication', handleResponse(async (req, res, next) => { + // body should contain {iv, cipherText, lookupKey} + const body = req.body; + if (body && body.iv && body.cipherText && body.lookupKey) { + try { + const transaction = await models.sequelize.transaction(); + // Check if an existing record exists but is soft deleted (since the Authentication model is 'paranoid' + // Setting the option paranoid to true searches both soft-deleted and non-deleted objects + // https://sequelize.org/master/manual/paranoid.html + // https://sequelize.org/master/class/lib/model.js~Model.html#static-method-findAll + const existingRecord = await models.Authentication.findOne({ + where: { lookupKey: body.lookupKey }, + paranoid: false + }); + if (!existingRecord) { + await models.Authentication.create({ + iv: body.iv, + cipherText: body.cipherText, + lookupKey: body.lookupKey + }, { transaction }); + } + else if (existingRecord.isSoftDeleted()) { + await existingRecord.restore({ transaction }); + } + const oldLookupKey = body.oldLookupKey; + if (oldLookupKey && oldLookupKey !== body.lookupKey) { + await models.Authentication.destroy({ where: { lookupKey: oldLookupKey } }, { transaction }); + } + await transaction.commit(); + return successResponse(); + } + catch (err) { + req.logger.error('Error signing up a user', err); + return errorResponseBadRequest('Error signing up a user'); + } + } + else + return errorResponseBadRequest('Missing one of the required fields: iv, cipherText, lookupKey'); + })); + app.get('/authentication', handleResponse(async (req, res, next) => { + const queryParams = req.query; + if (queryParams && queryParams.lookupKey) { + const lookupKey = queryParams.lookupKey; + const existingUser = await models.Authentication.findOne({ + where: { lookupKey } + }); + if (existingUser) { + return successResponse(existingUser); + } + else { + return errorResponseBadRequest('No auth record found for provided lookupKey.'); + } + } + else { + return errorResponseBadRequest('Missing queryParam lookupKey.'); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/cognito.js b/packages/identity-service/build/src/routes/cognito.js new file mode 100644 index 00000000000..653062c87df --- /dev/null +++ b/packages/identity-service/build/src/routes/cognito.js @@ -0,0 +1,288 @@ +"use strict"; +const { handleResponse, successResponse, errorResponseForbidden, errorResponseServerError } = require('../apiHelpers'); +const { logger } = require('../logging'); +const models = require('../models'); +const authMiddleware = require('../authMiddleware'); +const { cognitoFlowMiddleware, MAX_TIME_DRIFT_MILLISECONDS } = require('../cognitoFlowMiddleware'); +const { sign, createCognitoHeaders, createMaskedCognitoIdentity } = require('../utils/cognitoHelpers'); +const axios = require('axios'); +const axiosHttpAdapter = require('axios/lib/adapters/http'); +const config = require('../config'); +module.exports = function (app) { + app.get('/cognito_signature', authMiddleware, handleResponse(async (req) => { + const { walletAddress, handle } = req.user; + logger.info(`cognito_signature | Creating signature for: wallet '${walletAddress}', handle '${handle}'`); + try { + const signature = sign(handle); + return successResponse({ signature }); + } + catch (e) { + logger.error(e); + return errorResponseServerError({ + message: e.message + }); + } + })); + /** + * doc for webhook receiver implementation: https://docs.cognitohq.com/guides + * cognito's webhook post request body will have the following format + * + * { + * "id": "some-id", + * "timestamp": "some-timestamp", + * "event": "flow_session.status.updated", // could be another event but we mostly care about flow_session.status.updated + * "data": { + * "object": "flow_session", + * "id": "some-session-id", + * "status": "failed", // or 'success' + * "step": null, // or some step if event is flow_session.step.updated + * "customer_reference": "some-customer-unique-persistent-id-eg-handle", + * "_meta": "This API format is not v1.0 and is subject to change." + * }, + * "environment": "live" // or 'sandbox' + * } + */ + app.post('/cognito_webhook/flow', cognitoFlowMiddleware, handleResponse(async (req) => { + const { id, event, data } = req.body; + // if event is not of type status, meaning the event denotes of a step in the flow, but not the completion + // then return 200 without further processing + if (event !== 'flow_session.status.updated') + return successResponse({}); + const { id: sessionId, customer_reference: handle, status } = data; + const transaction = await models.sequelize.transaction(); + try { + // check that this identity has not already been used by another account before proceeding to save the score + let cognitoIdentityAlreadyExists = false; + if (status === 'success') { + const baseUrl = config.get('cognitoBaseUrl'); + const path = `/flow_sessions/${sessionId}`; + const method = 'GET'; + const body = ''; + const headers = createCognitoHeaders({ path, method, body }); + const url = `${baseUrl}${path}`; + const flowSessionResponse = await axios({ + adapter: axiosHttpAdapter, + url, + method, + headers + }); + // https://cognitohq.com/docs/reference#flow_get_flow_session + // user is always present + // documentary_verification is nullable but always present + const { user: userInfo, documentary_verification: documentaryVerification } = flowSessionResponse.data; + // id_number is always present but nullable + // phone is always present but nullable + // date_of_birth is always present but nullable + // address is always present but nullable + // name is always present but nullable + const { id_number: idNumber, phone, date_of_birth: dob, address, name } = userInfo; + const nameLowercased = name + ? // if name is not null, then first and last are always present according to api + { + first: (name.first && name.first.toLowerCase()) || '', + last: (name.last && name.last.toLowerCase()) || '' + } + : null; + // make cognito identities unique on: + // - phone number, or + // - combination of date of birth and name, or + // - id number + // - if no id number, then we assume some sort of combination of phone, dob, address, and name + const identities = []; + if (phone) { + identities.push(phone); + } + if (dob && name) { + // legacy check against dob and name + identities.push(JSON.stringify({ dob, name })); + // deduping against lowercased names + identities.push(JSON.stringify({ dob, name: nameLowercased })); + } + if (documentaryVerification && + documentaryVerification.status === 'success') { + // if document verification is not null, then status and documents are always present + // within each document, the status is always present, and the extracted_data is always present but nullable + const successfullyExtractedIdNumbers = documentaryVerification.documents + .filter((document) => document.status === 'success' && + document.extracted_data && + document.extracted_data.id_number) + .map((document) => document.extracted_data.id_number); + successfullyExtractedIdNumbers.forEach((item) => identities.push(item)); + } + if (idNumber) { + const { value, category, type } = idNumber; + // reason why we did not JSON.stringify here is because + // there are already users whose masked identities were based on this format + identities.push(`${value}::${category}::${type}`); + } + else { + // if webhook does not include id number, then we are expecting a few of the items below. + // this is left here for backwards compatibility as it was the original lightning check + // before we checked for dob and name + identities.push(JSON.stringify({ phone, dob, address, name })); + } + const identitySet = new Set(identities); + const maskedIdentities = [...identitySet].map(createMaskedCognitoIdentity); + const records = await models.CognitoFlowIdentities.findAll({ + where: { + maskedIdentity: { [models.Sequelize.Op.in]: maskedIdentities } + } + }); + if (records.length) { + logger.info(`cognito_webhook flow | this identity has already been used previously | sessionId: ${sessionId}, handle: ${handle}`); + cognitoIdentityAlreadyExists = true; + } + else { + const now = Date.now(); + const toCreate = maskedIdentities.map((maskedIdentity) => ({ + maskedIdentity, + createdAt: now, + updatedAt: now + })); + await models.CognitoFlowIdentities.bulkCreate(toCreate, { + transaction + }); + } + } + // only save cognito flow for user if status is 'success' or 'failed' + // otherwise when e.g. a flow session retry is requested for a user, then + // the 'canceled' webhook will be consumed and saved unintentionally + if (['success', 'failed'].includes(status)) { + await models.CognitoFlows.create({ + id, + sessionId, + handle, + status, + // score of 1 if 'success' and no other account has previously used this same cognito identiy, otherwise 0 + // so it is possible to get a status of success yet a score of 0 because this identity has already been associated to another account + score: Number(!cognitoIdentityAlreadyExists && status === 'success') + }, { transaction }); + } + await transaction.commit(); + // cognito flow requires the receiver to respond with 200, otherwise it'll retry with exponential backoff + return successResponse({}); + } + catch (err) { + await transaction.rollback(); + logger.error(`Failed to consume cognito flow webhook for user handle ${handle} for session id ${sessionId}`); + logger.error(`The full webhook error payload for user handle ${handle} is: ${JSON.stringify(err)}`); + return errorResponseServerError(err.message); + } + })); + /** + * This endpoint is not programatically called. + * It exists in case we want to request a flow session retry for a handle + * in case our webhook receiver runs into an issue + */ + app.post('/cognito_retry/:handle', handleResponse(async (req) => { + const handle = req.params.handle; + if (req.headers['x-cognito-retry'] !== config.get('cognitoRetrySecret')) { + return errorResponseForbidden(`Not permissioned to retry flow session for user handle ${handle}`); + } + try { + const record = await models.CognitoFlows.findOne({ where: { handle } }); + // only request flow retry if no current passing score for handle + // because there should be no need to redo the flow if score is already passing + // also, it would otherwise be possible that the new flow will pass but the unique identity check will have a collision + if (record && record.score === 1) { + logger.info(`cognito_retry | Not requesting flow session retry for handle ${handle} because user already passed cognito`); + return successResponse({}); + } + const baseUrl = config.get('cognitoBaseUrl'); + const templateId = config.get('cognitoTemplateId'); + const path = '/flow_sessions/retry'; + const method = 'POST'; + const body = JSON.stringify({ + customer_reference: handle, + template_id: templateId, + strategy: 'reset' + }); + const headers = createCognitoHeaders({ path, method, body }); + const url = `${baseUrl}${path}`; + await axios({ + adapter: axiosHttpAdapter, + url, + method, + headers, + data: body + }); + // remove record if failing flow record exists + // otherwise the existing record will be the one taken into account before even hitting the flow + if (record) { + await models.CognitoFlows.destroy({ where: { handle } }); + } + logger.info(`cognito_retry | Successfully requested a flow session retry for user handle ${handle}`); + return successResponse({}); + } + catch (err) { + logger.error(`cognito_retry | Failed request to retry flow session for user handle ${handle} with error message: ${err.message}`); + logger.error(`cognito_retry | The full retry error payload for user handle ${handle} is: ${JSON.stringify(err)}`); + return errorResponseServerError(err.message); + } + })); + /** + * Returns whether a recent cognito entry exists for a given handle. + * This is so that the client can poll this endpoint to check whether + * or not to proceed with a reward claim retry. + */ + app.get('/cognito_recent_exists/:handle', handleResponse(async (req) => { + const handle = req.params.handle; + const records = await models.CognitoFlows.findAll({ + where: { handle }, + order: [['updatedAt', 'DESC']], + limit: 1 + }); + if (records.length) { + const timeDifferenceMilliseconds = Date.now() - new Date(records[0].updatedAt).getTime(); + return successResponse({ + exists: timeDifferenceMilliseconds <= MAX_TIME_DRIFT_MILLISECONDS + }); + } + return successResponse({ exists: false }); + })); + /** + * Gets the shareable_url for a Flow, for use with a webview on mobile + * https://cognitohq.com/docs/flow/mobile-integration + */ + app.post('/cognito_flow', authMiddleware, handleResponse(async (req) => { + const baseUrl = config.get('cognitoBaseUrl'); + const templateId = config.get('cognitoTemplateId'); + const { user: { handle } } = req; + const path = '/flow_sessions?idempotent=true'; + const method = 'POST'; + const body = JSON.stringify({ + shareable: true, + template_id: templateId, + user: { + customer_reference: handle + } + }); + const headers = createCognitoHeaders({ path, method, body }); + const url = `${baseUrl}${path}`; + try { + const response = await axios({ + adapter: axiosHttpAdapter, + url, + method, + headers, + data: body + }); + return successResponse({ shareable_url: response.data.shareable_url }); + } + catch (err) { + logger.error(`Request failed to Cognito. Request=${JSON.stringify({ + url, + method, + headers, + body + })} Error=${err.message}`); + if (err && + err.response && + err.response.data && + err.response.data.errors) { + logger.error(`Cognito returned errors: ${JSON.stringify(err.response.data.errors)}`); + } + return errorResponseServerError(err.message); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/ethRelay.js b/packages/identity-service/build/src/routes/ethRelay.js new file mode 100644 index 00000000000..be2433e1ffa --- /dev/null +++ b/packages/identity-service/build/src/routes/ethRelay.js @@ -0,0 +1,47 @@ +"use strict"; +const { handleResponse, sendResponse, successResponse, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const ethTxRelay = require('../relay/ethTxRelay'); +const crypto = require('crypto'); +module.exports = function (app) { + // Relay operations to main ethereum chain + app.post('/eth_relay', async (req, res, next) => { + const body = req.body; + if (body && body.contractAddress && body.senderAddress && body.encodedABI) { + // send tx + const reqBodySHA = crypto + .createHash('sha256') + .update(JSON.stringify(req.body)) + .digest('hex'); + try { + const txProps = { + contractAddress: body.contractAddress, + encodedABI: body.encodedABI, + senderAddress: body.senderAddress, + gasLimit: body.gasLimit || null + }; + const resp = await ethTxRelay.sendEthTransaction(req, txProps, reqBodySHA); + return sendResponse(req, res, successResponse({ resp })); + } + catch (e) { + req.logger.error('Error in transaction:', e.message, reqBodySHA); + return sendResponse(req, res, errorResponseServerError(`Something caused the transaction to fail for payload ${reqBodySHA}, ${e.message}`)); + } + } + else { + return sendResponse(req, res, errorResponseServerError('Missing one of the required fields: contractRegistryKey, contractAddress, senderAddress, encodedABI')); + } + }); + // Query which returns public key of associated relayer wallet for a given address + app.get('/eth_relayer', handleResponse(async (req, res, next) => { + const { wallet } = req.query; + if (!wallet) + return errorResponseBadRequest('Please provide a wallet'); + const selectedEthWallet = await ethTxRelay.queryEthRelayerWallet(wallet); + return successResponse({ selectedEthWallet }); + })); + // Serves latest state of production gas tracking on identity service + app.get('/eth_gas_price', handleResponse(async (req, res, next) => { + const gasInfo = await ethTxRelay.getProdGasInfo(req.app.get('redis'), req.logger); + return successResponse(gasInfo); + })); +}; diff --git a/packages/identity-service/build/src/routes/fp.js b/packages/identity-service/build/src/routes/fp.js new file mode 100644 index 00000000000..29db765b6b9 --- /dev/null +++ b/packages/identity-service/build/src/routes/fp.js @@ -0,0 +1,58 @@ +"use strict"; +const { handleResponse, successResponse, errorResponseBadRequest, errorResponse } = require('../apiHelpers'); +const models = require('../models'); +const { logger } = require('../logging'); +const { getDeviceIDCountForUserId } = require('../utils/fpHelpers'); +module.exports = function (app) { + app.post('/fp/webhook', handleResponse(async (req) => { + const { visitorId, linkedId: userId, requestId, tag } = req.body; + logger.info(`Received FP webhook: visitorId ${visitorId}, userId ${userId}, requestId: ${requestId}`); + const origin = tag && tag.origin; + if (!origin || !visitorId || !userId || !requestId) { + logger.error(`Invalid arguments to /fp/webhook: ${req.body}`); + // Return 200 so webhook doesn't retry + return successResponse(); + } + const now = Date.now(); + try { + await models.Fingerprints.create({ + userId, + visitorId, + origin, + createdAt: now, + updatedAt: now + }); + } + catch (e) { + logger.error(`Error persisting fingerprint for userId: ${userId}: ${e}`); + } + return successResponse(); + })); + app.get('/fp', handleResponse(async (req) => { + const { userId, origin } = req.query; + if (!userId || !origin || !['web', 'mobile', 'desktop'].includes(origin)) + return errorResponseBadRequest(); + try { + const count = (await models.Fingerprints.findAll({ + where: { + userId, + origin + } + })).length; + return successResponse({ count }); + } + catch (e) { + return errorResponse(`Something went wrong fetching fingerprint for user ${userId}: ${JSON.stringify(e)}`); + } + })); + app.get('/fp/counts/:userId', handleResponse(async (req) => { + const userId = req.params.userId; + try { + const counts = await getDeviceIDCountForUserId(userId); + return successResponse({ counts }); + } + catch (e) { + return errorResponse(`Something went wrong fetching fp counts: ${JSON.stringify(e)}`); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/healthCheck.js b/packages/identity-service/build/src/routes/healthCheck.js new file mode 100644 index 00000000000..b10d67b3ff1 --- /dev/null +++ b/packages/identity-service/build/src/routes/healthCheck.js @@ -0,0 +1,351 @@ +"use strict"; +const config = require('../config.js'); +const models = require('../models'); +const { handleResponse, successResponse, errorResponseServerError } = require('../apiHelpers'); +const { sequelize } = require('../models'); +const { getRelayerFunds, fundRelayerIfEmpty } = require('../relay/txRelay'); +const { getEthRelayerFunds } = require('../relay/ethTxRelay'); +const solanaWeb3 = require('@solana/web3.js'); +const Web3 = require('web3'); +const audiusLibsWrapper = require('../audiusLibsInstance'); +const { NOTIFICATION_JOB_LAST_SUCCESS_KEY, NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY, NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY } = require('../notifications/index.js'); +const axios = require('axios'); +const moment = require('moment'); +const { REDIS_ATTESTER_STATE } = require('../utils/configureAttester.js'); +// Defaults used in relay health check endpoint +const RELAY_HEALTH_ACCOUNTS = new Set(config.get('relayerWallets').map((wallet) => wallet.publicKey)); +const ETH_RELAY_HEALTH_ACCOUNTS = new Set(config.get('ethRelayerWallets').map((wallet) => wallet.publicKey)); +module.exports = function (app) { + /** + * Relay health check endpoint. Takes the query params startBlock, endBlock, maxTransactions, and maxErrors. + * If those query params are not specified, use default values. + */ + /* + There are a few scenarios where a health check should return unhealthy + 1. Some number of relays are failing for some number of users + To solve this, traverse the blocks for Audius transactions and count failures + for users. If it's greater than some threshold, return error + 2. Relays are not being sent / sent but not acknowledged by blockchain + */ + app.get('/health_check/relay', handleResponse(async (req, res) => { + const start = Date.now(); + const redis = req.app.get('redis'); + const maxErrors = parseInt(req.query.maxErrors); + const minTransactions = parseInt(req.query.minTransactions); + const isVerbose = req.query.verbose || false; + const maxRelayLatency = parseInt(req.query.maxRelayLatency); + let isError = false; + // delete old entries from set in redis + const epochTenMinutesAgo = Math.floor(Date.now() / 1000) - 600; + await redis.zremrangebyscore('relayTxAttempts', '-inf', epochTenMinutesAgo); + await redis.zremrangebyscore('relayTxFailures', '-inf', epochTenMinutesAgo); + await redis.zremrangebyscore('relayTxSuccesses', '-inf', epochTenMinutesAgo); + // check if there have been any attempts in the time window that we processed the block health check + const attemptedTxsInRedis = await redis.zrange('relayTxAttempts', '0', '-1'); + const successfulTxsInRedis = await redis.zrange('relayTxSuccesses', '0', '-1'); + const failureTxsInRedis = await redis.zrange('relayTxFailures', '0', '-1'); + if (minTransactions && successfulTxsInRedis.length < minTransactions) { + isError = true; + } + if (maxErrors && failureTxsInRedis.length > maxErrors) { + isError = true; + } + for (const tx of successfulTxsInRedis) { + const parsedTx = JSON.parse(tx); + if (maxRelayLatency && + parsedTx.totalTransactionLatency / 1000 > maxRelayLatency) { + isError = true; + break; + } + } + const serverResponse = { + redis: { + attemptedTxsCount: attemptedTxsInRedis.length, + successfulTxsCount: successfulTxsInRedis.length, + failureTxsCount: failureTxsInRedis.length + }, + healthCheckComputeTime: Date.now() - start + }; + if (isVerbose) { + serverResponse.redis = { + ...serverResponse.redis, + attemptedTxsInRedis, + successfulTxsInRedis, + failureTxsInRedis + }; + } + if (isError) + return errorResponseServerError(serverResponse); + else + return successResponse(serverResponse); + })); + app.get('/health_check', handleResponse(async (req, res) => { + // for now we just check db connectivity + await sequelize.query('SELECT 1', { type: sequelize.QueryTypes.SELECT }); + // get connected discprov via libs + const audiusLibsInstance = req.app.get('audiusLibs'); + return successResponse({ + healthy: true, + git: process.env.GIT_SHA, + selectedDiscoveryProvider: audiusLibsInstance.discoveryProvider.discoveryProviderEndpoint + }); + })); + app.get('/health_check/poa', handleResponse(async (req, res) => { + return successResponse({ + finalPOABlock: config.get('finalPOABlock') + }); + })); + app.get('/balance_check', handleResponse(async (req, res) => { + let { minimumBalance, minimumRelayerBalance } = req.query; + minimumBalance = parseFloat(minimumBalance || config.get('minimumBalance')); + minimumRelayerBalance = parseFloat(minimumRelayerBalance || config.get('minimumRelayerBalance')); + const belowMinimumBalances = []; + let balances = []; + // run fundRelayerIfEmpty so it'll auto top off any accounts below the threshold + try { + await fundRelayerIfEmpty(); + } + catch (err) { + req.logger.error(`Failed to fund relayer with error: ${err}`); + } + balances = await Promise.all([...RELAY_HEALTH_ACCOUNTS].map(async (account) => { + const balance = parseFloat(Web3.utils.fromWei(await getRelayerFunds(account), 'ether')); + if (balance < minimumBalance) { + belowMinimumBalances.push({ account, balance }); + } + return { account, balance }; + })); + const relayerPublicKey = config.get('relayerPublicKey'); + const relayerBalance = parseFloat(Web3.utils.fromWei(await getRelayerFunds(relayerPublicKey), 'ether')); + const relayerAboveMinimum = relayerBalance >= minimumRelayerBalance; + // no accounts below minimum balance + if (!belowMinimumBalances.length && relayerAboveMinimum) { + return successResponse({ + above_balance_minimum: true, + minimum_balance: minimumBalance, + balances: balances, + relayer: { + wallet: relayerPublicKey, + balance: relayerBalance, + above_balance_minimum: relayerAboveMinimum + } + }); + } + else { + return errorResponseServerError({ + above_balance_minimum: false, + minimum_balance: minimumBalance, + balances: balances, + below_minimum_balance: belowMinimumBalances, + relayer: { + wallet: relayerPublicKey, + balance: relayerBalance, + above_balance_minimum: relayerAboveMinimum + } + }); + } + })); + app.get('/eth_balance_check', handleResponse(async (req, res) => { + let { minimumBalance, minimumFunderBalance } = req.query; + minimumBalance = parseFloat(minimumBalance || config.get('ethMinimumBalance')); + minimumFunderBalance = parseFloat(minimumFunderBalance || config.get('ethMinimumFunderBalance')); + const funderAddress = config.get('ethFunderAddress'); + const funderBalance = parseFloat(Web3.utils.fromWei(await getEthRelayerFunds(funderAddress), 'ether')); + const funderAboveMinimum = funderBalance >= minimumFunderBalance; + const belowMinimumBalances = []; + const balances = await Promise.all([...ETH_RELAY_HEALTH_ACCOUNTS].map(async (account) => { + const balance = parseFloat(Web3.utils.fromWei(await getEthRelayerFunds(account), 'ether')); + if (balance < minimumBalance) { + belowMinimumBalances.push({ account, balance }); + } + return { account, balance }; + })); + const balanceResponse = { + minimum_balance: minimumBalance, + balances: balances, + funder: { + wallet: funderAddress, + balance: funderBalance, + above_balance_minimum: funderAboveMinimum + } + }; + // no accounts below minimum balance + if (!belowMinimumBalances.length && funderAboveMinimum) { + return successResponse({ + above_balance_minimum: true, + ...balanceResponse + }); + } + else { + return errorResponseServerError({ + above_balance_minimum: false, + below_minimum_balance: belowMinimumBalances, + ...balanceResponse + }); + } + })); + app.get('/sol_balance_check', handleResponse(async (req, res) => { + const minimumBalance = parseFloat(req.query.minimumBalance || config.get('solMinimumBalance')); + const solanaFeePayerWallets = config.get('solanaFeePayerWallets'); + const libs = req.app.get('audiusLibs'); + const connection = libs.solanaWeb3Manager.connection; + const solanaFeePayerBalances = {}; + const belowMinimumBalances = []; + if (solanaFeePayerWallets) { + await Promise.all([...solanaFeePayerWallets].map(async (wallet) => { + const feePayerPubKey = solanaWeb3.Keypair.fromSecretKey(Uint8Array.from(wallet.privateKey)).publicKey; + const feePayerBase58 = feePayerPubKey.toBase58(); + const balance = await connection.getBalance(feePayerPubKey); + if (balance < minimumBalance) { + belowMinimumBalances.push({ wallet: feePayerBase58, balance }); + } + solanaFeePayerBalances[feePayerBase58] = balance; + return { wallet: feePayerBase58, balance }; + })); + } + const solanaFeePayerBalancesArr = Object.keys(solanaFeePayerBalances).map((key) => [key, solanaFeePayerBalances[key]]); + if (belowMinimumBalances.length === 0) { + return successResponse({ + above_balance_minimum: true, + minimum_balance: minimumBalance, + balances: solanaFeePayerBalancesArr + }); + } + return errorResponseServerError({ + above_balance_minimum: false, + minimum_balance: minimumBalance, + belowMinimumBalances, + balances: solanaFeePayerBalancesArr + }); + })); + app.get('/notification_check', handleResponse(async (req, res) => { + let { maxBlockDifference, maxDrift } = req.query; + maxBlockDifference = maxBlockDifference || 100; + let highestBlockNumber = await models.NotificationAction.max('blocknumber'); + if (!highestBlockNumber) { + highestBlockNumber = config.get('notificationStartBlock'); + } + req.logger.info(`notifications_check | Running notifications_check, comparing blockNumber ${highestBlockNumber}`); + const redis = req.app.get('redis'); + const maxFromRedis = await redis.get('maxBlockNumber'); + if (maxFromRedis) { + highestBlockNumber = parseInt(maxFromRedis); + } + // Get job success timestamps + const notificationJobLastSuccess = await redis.get(NOTIFICATION_JOB_LAST_SUCCESS_KEY); + const notificationEmailsJobLastSuccess = await redis.get(NOTIFICATION_EMAILS_JOB_LAST_SUCCESS_KEY); + const notificationAnnouncementsJobLastSuccess = await redis.get(NOTIFICATION_ANNOUNCEMENTS_JOB_LAST_SUCCESS_KEY); + const { discoveryProvider } = audiusLibsWrapper.getAudiusLibs(); + req.logger.info(`notifications_check | Making notification_check request on ${discoveryProvider} at ${discoveryProvider.discoveryProviderEndpoint}`); + const body = (await axios({ + method: 'get', + url: `${discoveryProvider.discoveryProviderEndpoint}/health_check` + })).data; + req.logger.info(`notifications_check | Received notification_check response ${body} on ${discoveryProvider.discoveryProviderEndpoint}`); + const discProvDbHighestBlock = body.data.db.number; + const notifBlockDiff = discProvDbHighestBlock - highestBlockNumber; + const resp = { + discProv: body.data, + identity: highestBlockNumber, + notifBlockDiff: notifBlockDiff, + notificationJobLastSuccess, + notificationEmailsJobLastSuccess, + notificationAnnouncementsJobLastSuccess + }; + // Test if last runs were recent enough + let withinBounds = true; + if (maxDrift) { + const cutoff = moment().subtract(maxDrift, 'seconds'); + const isWithinBounds = (key) => key ? moment(key).isAfter(cutoff) : true; + withinBounds = + isWithinBounds(notificationJobLastSuccess) && + isWithinBounds(notificationEmailsJobLastSuccess) && + isWithinBounds(notificationAnnouncementsJobLastSuccess); + req.logger.info(`notifications_check | isWithinBounds is ${withinBounds} and notifBlockDiff is ${notifBlockDiff}`); + } + if (!withinBounds || notifBlockDiff > maxBlockDifference) { + req.logger.info(`notifications_check | Returning a 500 because we are out of bounds or notifBlockDiff is too large`); + return errorResponseServerError(resp); + } + return successResponse(resp); + })); + /** + * Exposes current and max db connection stats. + * Returns error if db connection threshold exceeded, else success. + */ + app.get('/db_check', handleResponse(async (req, res) => { + const verbose = req.query.verbose === 'true'; + const maxConnections = config.get('pgConnectionPoolMax'); + let numConnections = 0; + let connectionInfo = null; + let activeConnections = null; + let idleConnections = null; + // Get number of open DB connections + const numConnectionsQuery = await sequelize.query("SELECT numbackends from pg_stat_database where datname = 'audius_centralized_service'"); + if (numConnectionsQuery && + numConnectionsQuery[0] && + numConnectionsQuery[0][0] && + numConnectionsQuery[0][0].numbackends) { + numConnections = numConnectionsQuery[0][0].numbackends; + } + // Get detailed connection info + const connectionInfoQuery = await sequelize.query("select wait_event_type, wait_event, state, query from pg_stat_activity where datname = 'audius_centralized_service'"); + if (connectionInfoQuery && connectionInfoQuery[0]) { + connectionInfo = connectionInfoQuery[0]; + activeConnections = connectionInfo.filter((conn) => conn.state === 'active').length; + idleConnections = connectionInfo.filter((conn) => conn.state === 'idle').length; + } + const resp = { + git: process.env.GIT_SHA, + connectionStatus: { + total: numConnections, + active: activeConnections, + idle: idleConnections + }, + maxConnections + }; + if (verbose) { + resp.connectionInfo = connectionInfo; + } + return numConnections >= maxConnections + ? errorResponseServerError(resp) + : successResponse(resp); + })); + /** + * Healthcheck the rewards attester + * Accepts optional query params `maxDrift` and `maxSuccessDrift`, which + * correspond to the last seen attempted challenge attestation, and the last sucessful + * attestation, respectively. + */ + app.get('/rewards_check', handleResponse(async (req, res) => { + const { maxDrift, maxSuccessDrift, maxActionDrift } = req.query; + const redis = req.app.get('redis'); + let state = await redis.get(REDIS_ATTESTER_STATE); + if (!state) { + return errorResponseServerError('No last state'); + } + state = JSON.parse(state); + const { lastChallengeTime, lastSuccessChallengeTime, phase, lastActionTime } = state; + const lastChallengeDelta = lastChallengeTime + ? (Date.now() - lastChallengeTime) / 1000 + : Number.POSITIVE_INFINITY; + const lastSuccessChallengeDelta = lastSuccessChallengeTime + ? (Date.now() - lastSuccessChallengeTime) / 1000 + : Number.POSITIVE_INFINITY; + const lastActionDelta = lastActionTime + ? (Date.now() - lastActionTime) / 1000 + : Number.POSITIVE_INFINITY; + // Only use the deltas if the corresponding drift parameter exists + const healthyMaxDrift = !maxDrift || lastChallengeDelta < maxDrift; + const healthySuccessDrift = !maxSuccessDrift || lastSuccessChallengeDelta < maxSuccessDrift; + const healthyActionDrift = !maxActionDrift || lastActionDelta < maxActionDrift; + const isHealthy = healthyMaxDrift && healthySuccessDrift && healthyActionDrift; + const resp = { + phase, + lastChallengeDelta, + lastSuccessChallengeDelta, + lastActionDelta + }; + return (isHealthy ? successResponse : errorResponseServerError)(resp); + })); +}; diff --git a/packages/identity-service/build/src/routes/idSignals.js b/packages/identity-service/build/src/routes/idSignals.js new file mode 100644 index 00000000000..c3f73b12290 --- /dev/null +++ b/packages/identity-service/build/src/routes/idSignals.js @@ -0,0 +1,100 @@ +"use strict"; +const config = require('../config'); +const { handleResponse, successResponse, errorResponseForbidden, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const models = require('../models'); +const { QueryTypes } = require('sequelize'); +const userHandleMiddleware = require('../userHandleMiddleware'); +const authMiddleware = require('../authMiddleware'); +const { getDeviceIDCountForUserId } = require('../utils/fpHelpers'); +const { getIP, recordIP } = require('../utils/antiAbuse'); +module.exports = function (app) { + app.get('/id_signals', userHandleMiddleware, handleResponse(async (req) => { + if (req.headers['x-score'] !== config.get('scoreSecret')) { + return errorResponseForbidden('Not permissioned to view scores.'); + } + const handle = req.query.handle; + if (!handle) + return errorResponseBadRequest('Please provide handle'); + const [captchaScores, cognitoFlowScores, socialHandles, twitterUser, instagramUser, tikTokUser, deviceUserCount, userIPRecord, handleSimilarity] = await Promise.all([ + models.sequelize.query(`select "Users"."blockchainUserId" as "userId", "BotScores"."recaptchaScore" as "score", "BotScores"."recaptchaContext" as "context", "BotScores"."updatedAt" as "updatedAt" + from + "Users" inner join "BotScores" on "Users"."walletAddress" = "BotScores"."walletAddress" + where + "Users"."handle" = :handle`, { + replacements: { handle }, + type: QueryTypes.SELECT + }), + models.sequelize.query(`select "Users"."blockchainUserId" as "userId", "CognitoFlows"."score" as "score" + from + "Users" inner join "CognitoFlows" on "Users"."handle" = "CognitoFlows"."handle" + where + "Users"."handle" = :handle`, { + replacements: { handle }, + type: QueryTypes.SELECT + }), + models.SocialHandles.findOne({ + where: { handle } + }), + models.TwitterUser.findOne({ + where: { + // Twitter stores case sensitive screen names + 'twitterProfile.screen_name': handle, + verified: true + } + }), + models.InstagramUser.findOne({ + where: { + // Instagram does not store case sensitive screen names + 'profile.username': handle.toLowerCase(), + verified: true + } + }), + models.TikTokUser.findOne({ + where: { + // TikTok does not store case sensitive screen names + 'profile.display_name': handle.toLowerCase(), + verified: true + } + }), + getDeviceIDCountForUserId(req.user.blockchainUserId), + models.UserIPs.findOne({ where: { handle } }), + models.sequelize.query(`select count(*) from "Users" where "handle" SIMILAR TO :handle;`, { + replacements: { + handle: `[0-9]*${handle.replace(/(^\d*|\d*$)/g, '')}[0-9]*` + }, + type: QueryTypes.SELECT + }) + ]); + const response = { + captchaScores, + cognitoFlowScores, + socialSignals: {}, + deviceUserCount, + userIP: userIPRecord && userIPRecord.userIP, + emailAddress: req.user.email, + handleSimilarity: handleSimilarity[0]?.count ?? 0 + }; + if (socialHandles) { + response.socialSignals = { + ...socialHandles.dataValues, + twitterVerified: !!twitterUser, + instagramVerified: !!instagramUser, + tikTokVerified: !!tikTokUser + }; + } + return successResponse(response); + })); + app.post('/record_ip', authMiddleware, handleResponse(async (req) => { + const { blockchainUserId, handle } = req.user; + try { + const userIP = getIP(req); + req.logger.info(`idSignals | record_ip | User IP is ${userIP} for user with id ${blockchainUserId} and handle ${handle}`); + await recordIP(userIP, handle); + return successResponse({ userIP }); + } + catch (e) { + req.logger.error(`idSignals | record_ip | Failed to record IP for user ${handle}`); + return errorResponseServerError(`Failed to record IP for user ${handle}`); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/index.js b/packages/identity-service/build/src/routes/index.js new file mode 100644 index 00000000000..ba73cc1574b --- /dev/null +++ b/packages/identity-service/build/src/routes/index.js @@ -0,0 +1,13 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +module.exports = function (app) { + const basename = path.basename(__filename); + fs.readdirSync(__dirname) + .filter((file) => { + return (file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'); + }) + .forEach((file) => { + require(path.join(__dirname, file))(app); + }); +}; diff --git a/packages/identity-service/build/src/routes/instagram.js b/packages/identity-service/build/src/routes/instagram.js new file mode 100644 index 00000000000..932bd1df4b5 --- /dev/null +++ b/packages/identity-service/build/src/routes/instagram.js @@ -0,0 +1,169 @@ +"use strict"; +const request = require('request'); +const retry = require('async-retry'); +const config = require('../config.js'); +const models = require('../models'); +const txRelay = require('../relay/txRelay'); +const { handleResponse, successResponse, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const generalAdmissionAddress = config.get('generalAdmissionAddress'); +/** + * This file contains the instagram endpoints for oauth + * For official documentation on the instagram oauth flow check out their site + * https://www.instagram.com/developer/authentication/ + */ +module.exports = function (app) { + /** + * The first leg of the Instagram Oauth. Given an oauth code, it first + * validates that the user owns the claimed account. Then it sends a + * request to go pull the instagram graph API full user data. + */ + app.post('/instagram', handleResponse(async (req, res, next) => { + const { code } = req.body; + const reqObj = { + method: 'post', + url: 'https://api.instagram.com/oauth/access_token', + form: { + client_id: config.get('instagramAPIKey'), + client_secret: config.get('instagramAPISecret'), + grant_type: 'authorization_code', + redirect_uri: config.get('instagramRedirectUrl'), + code + } + }; + try { + const res = await doRequest(reqObj); + const authAccessToken = JSON.parse(res); + const { access_token: accessToken } = authAccessToken; + const instagramAPIUser = await doRequest({ + method: 'get', + url: 'https://graph.instagram.com/me', + qs: { + fields: 'id,username,account_type', + access_token: accessToken + } + }); + const igUser = JSON.parse(instagramAPIUser); + if (igUser.error) { + return errorResponseBadRequest(new Error(igUser.error.message)); + } + const existingInstagramUser = await models.InstagramUser.findOne({ + where: { + uuid: igUser.username, + blockchainUserId: { + [models.Sequelize.Op.not]: null + } + } + }); + if (existingInstagramUser) { + return errorResponseBadRequest(`Another Audius profile has already been authenticated with Instagram user @${igUser.username}!`); + } + // Fetch the instagram full profile + const igProfileReqObj = { + method: 'get', + url: `${generalAdmissionAddress}/social/instagram/${igUser.username}` + }; + try { + const instagramProfileRes = await retry(async () => doRequest(igProfileReqObj), { retries: 3 }); + const instagramProfile = JSON.parse(instagramProfileRes); + // Store the access token, user id, and current profile for user in db + await models.InstagramUser.upsert({ + uuid: igUser.username, + profile: instagramProfile, + verified: instagramProfile.is_verified, + accessToken + }); + return successResponse(instagramProfile); + } + catch (err) { + return errorResponseBadRequest(err); + } + } + catch (err) { + return errorResponseBadRequest(err); + } + })); + /** + * After the user finishes onboarding in the client app and has a blockchain userId, we need to associate + * the blockchainUserId with the instagram profile + */ + app.post('/instagram/associate', handleResponse(async (req, res, next) => { + const { uuid, userId, handle } = req.body; + const audiusLibsInstance = req.app.get('audiusLibs'); + try { + const instagramObj = await models.InstagramUser.findOne({ + where: { uuid } + }); + const user = await models.User.findOne({ + where: { handle } + }); + const isUnassociated = instagramObj && !instagramObj.blockchainUserId; + const handlesMatch = instagramObj && + instagramObj.profile.username.toLowerCase() === + user.handle.toLowerCase(); + // only set blockchainUserId if not already set + if (isUnassociated && handlesMatch) { + instagramObj.blockchainUserId = userId; + // if the user is verified, write to chain, otherwise skip to next step + if (instagramObj.verified) { + const [encodedABI, contractAddress] = await audiusLibsInstance.User.updateIsVerified(userId, config.get('userVerifierPrivateKey')); + const senderAddress = config.get('userVerifierPublicKey'); + try { + const txProps = { + contractRegistryKey: 'EntityManager', + contractAddress: contractAddress, + encodedABI: encodedABI, + senderAddress: senderAddress, + gasLimit: null + }; + await txRelay.sendTransaction(req, false, txProps, 'instagramVerified'); + } + catch (e) { + return errorResponseBadRequest(e); + } + } + const socialHandle = await models.SocialHandles.findOne({ + where: { handle } + }); + if (socialHandle) { + socialHandle.instagramHandle = instagramObj.profile.username; + await socialHandle.save(); + } + else if (instagramObj.profile && instagramObj.profile.username) { + await models.SocialHandles.create({ + handle, + instagramHandle: instagramObj.profile.username + }); + } + // the final step is to save userId to db and respond to request + try { + await instagramObj.save(); + return successResponse(); + } + catch (e) { + return errorResponseBadRequest(e); + } + } + else { + req.logger.error('Instagram profile does not exist or userId has already been set', instagramObj); + return errorResponseBadRequest('Instagram profile does not exist or userId has already been set'); + } + } + catch (err) { + return errorResponseBadRequest(err); + } + })); +}; +/** + * Since request is a callback based API, we need to wrap it in a promise to make it async/await compliant + * @param {Object} reqObj construct request object compatible with `request` module + */ +function doRequest(reqObj) { + return new Promise(function (resolve, reject) { + request(reqObj, function (err, r, body) { + if (err) + reject(err); + else + resolve(body); + }); + }); +} diff --git a/packages/identity-service/build/src/routes/location.js b/packages/identity-service/build/src/routes/location.js new file mode 100644 index 00000000000..79ce22dfc9f --- /dev/null +++ b/packages/identity-service/build/src/routes/location.js @@ -0,0 +1,33 @@ +"use strict"; +const axios = require('axios'); +const { getIP } = require('../utils/antiAbuse'); +const { errorResponseBadRequest, handleResponse, successResponse, errorResponse } = require('../apiHelpers'); +const config = require('../config'); +const { logger } = require('../logging'); +const IP_API_KEY = config.get('ipdataAPIKey'); +module.exports = function (app) { + app.get('/location', handleResponse(async (req) => { + let ip = getIP(req); + if (!ip) { + return errorResponseBadRequest('Unexpectedly no IP'); + } + if (ip.startsWith('::ffff:')) { + ip = ip.slice(7); + } + const url = `https://api.ipdata.co/${ip}`; + try { + const res = await axios({ + method: 'get', + url, + params: { + 'api-key': IP_API_KEY + } + }); + return successResponse({ ...res.data, in_eu: res.data.is_eu }); + } + catch (e) { + logger.error(`Got error in location: ${e.response?.data}`); + return errorResponse(e.response?.status, e.response?.data); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/notifications.js b/packages/identity-service/build/src/routes/notifications.js new file mode 100644 index 00000000000..21948a20fa7 --- /dev/null +++ b/packages/identity-service/build/src/routes/notifications.js @@ -0,0 +1,793 @@ +"use strict"; +const moment = require('moment'); +const { handleResponse, successResponse, errorResponseBadRequest } = require('../apiHelpers'); +const models = require('../models'); +const authMiddleware = require('../authMiddleware'); +const { fetchAnnouncements } = require('../announcements'); +const { notificationTypes: NotificationType } = require('../notifications/constants'); +const ClientNotificationTypes = new Set([ + NotificationType.Follow, + NotificationType.Repost.base, + NotificationType.Favorite.base, + NotificationType.Announcement, + NotificationType.UserSubscription, + NotificationType.Milestone, + NotificationType.TrendingTrack, + NotificationType.ChallengeReward, + NotificationType.TierChange, + NotificationType.AddTrackToPlaylist, + NotificationType.TipSend, + NotificationType.TipReceive, + NotificationType.Reaction, + NotificationType.SupportingRankUp, + NotificationType.SupporterRankUp +]); +const Entity = Object.freeze({ + Track: 'Track', + Playlist: 'Playlist', + Album: 'Album', + User: 'User' +}); +const Achievement = Object.freeze({ + Listens: 'Listens', + Reposts: 'Reposts', + Favorite: 'Favorites', + Trending: 'Trending', + Plays: 'Plays', + Followers: 'Followers' +}); +const formatUserSubscriptionCollection = (entityType) => (notification) => { + return { + ...getCommonNotificationsFields(notification), + entityType, + entityOwnerId: notification.actions[0].actionEntityId, + entityIds: [notification.entityId], + userId: notification.actions[0].actionEntityId, + type: NotificationType.UserSubscription + }; +}; +const formatUserSubscriptionTrack = (notification) => { + return { + ...getCommonNotificationsFields(notification), + entityType: Entity.Track, + entityOwnerId: notification.entityId, + entityIds: notification.actions.map((action) => action.actionEntityId), + userId: notification.entityId, + type: NotificationType.UserSubscription + }; +}; +const formatFavorite = (entityType) => (notification) => { + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.Favorite.base, + entityType, + entityId: notification.entityId, + userIds: notification.actions.map((action) => action.actionEntityId) + }; +}; +const formatRepost = (entityType) => (notification) => { + return { + ...getCommonNotificationsFields(notification), + entityType, + entityId: notification.entityId, + type: NotificationType.Repost.base, + userIds: notification.actions.map((action) => action.actionEntityId) + }; +}; +const formatFollow = (notification) => { + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.Follow, + userIds: notification.actions.map((action) => action.actionEntityId) + }; +}; +const formatAnnouncement = (notification, announcements) => { + const announcementIdx = announcements.findIndex((a) => a.entityId === notification.entityId); + if (announcementIdx === -1) + return null; + const announcement = announcements[announcementIdx]; + return { + ...getCommonNotificationsFields(notification), + ...announcement, + id: announcement.entityId, + timestamp: announcement.datePublished, + type: NotificationType.Announcement + }; +}; +const formatUnreadAnnouncement = (announcement) => { + return { + ...announcement, + id: announcement.entityId, + isHidden: false, + isRead: false, + timestamp: announcement.datePublished, + type: NotificationType.Announcement + }; +}; +const mapMilestone = { + [NotificationType.MilestoneRepost]: { + achievement: Achievement.Reposts + }, + [NotificationType.MilestoneFavorite]: { + achievement: Achievement.Favorite + }, + [NotificationType.MilestoneListen]: { + achievement: Achievement.Listens + }, + [NotificationType.MilestoneFollow]: { + achievement: Achievement.Followers + } +}; +const formatMilestone = (notification) => { + return { + ...getCommonNotificationsFields(notification), + ...mapMilestone[notification.type], + type: NotificationType.Milestone, + entityType: notification.actions[0].actionEntityType, + entityId: notification.entityId, + value: notification.actions[0].actionEntityId + }; +}; +const formatRemixCreate = (notification) => { + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.RemixCreate, + parentTrackId: notification.actions[0].actionEntityId, + childTrackId: notification.entityId + }; +}; +const formatRemixCosign = (notification) => { + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.RemixCosign, + parentTrackUserId: notification.actions[0].actionEntityId, + childTrackId: notification.entityId + }; +}; +const formatTrendingTrack = (notification) => { + const [time, genre] = notification.actions[0].actionEntityType.split(':'); + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.TrendingTrack, + entityType: Entity.Track, + entityId: notification.entityId, + rank: notification.actions[0].actionEntityId, + time, + genre + }; +}; +const formatChallengeReward = (notification) => { + return { + ...getCommonNotificationsFields(notification), + type: NotificationType.ChallengeReward, + challengeId: notification.actions[0].actionEntityType + }; +}; +const formatTipSend = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + amount: notification.metadata.amount, + entityId: notification.entityId, + entityType: Entity.User +}); +const formatTipReceive = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + amount: notification.metadata.amount, + reactionValue: notification.metadata.reactionValue, + entityId: notification.entityId, + tipTxSignature: notification.metadata.tipTxSignature, + entityType: Entity.User +}); +const formatSupportingRankUp = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + entityId: notification.metadata.supportedUserId, + rank: notification.entityId, + entityType: Entity.User +}); +const formatSupporterDethroned = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + entityType: Entity.User, + entityId: notification.metadata.newTopSupporterUserId, + supportedUserId: notification.metadata.supportedUserId, + newAmount: notification.metadata.newAmount, + oldAmount: notification.metadata.oldAmount +}); +const formatSupporterRankUp = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + entityId: notification.metadata.supportingUserId, + rank: notification.entityId, + entityType: Entity.User +}); +const formatReaction = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + entityId: notification.entityId, + reactionType: notification.metadata.reactionType, + reactionValue: notification.metadata.reactionValue, + reactedToEntity: notification.metadata.reactedToEntity, + entityType: Entity.User +}); +const formatAddTrackToPlaylist = (notification) => ({ + ...getCommonNotificationsFields(notification), + type: notification.type, + playlistId: notification.metadata.playlistId, + playlistOwnerId: notification.metadata.playlistOwnerId, + trackId: notification.metadata.trackId +}); +const getCommonNotificationsFields = (notification) => ({ + id: notification.id, + isHidden: notification.isHidden, + isRead: notification.isRead, + isViewed: notification.isViewed, + timestamp: notification.timestamp || notification.createdAt +}); +const notificationResponseMap = { + [NotificationType.Follow]: formatFollow, + [NotificationType.Favorite.track]: formatFavorite(Entity.Track), + [NotificationType.Favorite.playlist]: formatFavorite(Entity.Playlist), + [NotificationType.Favorite.album]: formatFavorite(Entity.Album), + [NotificationType.Repost.track]: formatRepost(Entity.Track), + [NotificationType.Repost.playlist]: formatRepost(Entity.Playlist), + [NotificationType.Repost.album]: formatRepost(Entity.Album), + [NotificationType.Create.track]: formatUserSubscriptionTrack, + [NotificationType.Create.album]: formatUserSubscriptionCollection(Entity.Album), + [NotificationType.Create.playlist]: formatUserSubscriptionCollection(Entity.Playlist), + [NotificationType.Announcement]: formatAnnouncement, + [NotificationType.MilestoneRepost]: formatMilestone, + [NotificationType.MilestoneFavorite]: formatMilestone, + [NotificationType.MilestoneListen]: formatMilestone, + [NotificationType.MilestoneFollow]: formatMilestone, + [NotificationType.RemixCreate]: formatRemixCreate, + [NotificationType.RemixCosign]: formatRemixCosign, + [NotificationType.TrendingTrack]: formatTrendingTrack, + [NotificationType.ChallengeReward]: formatChallengeReward, + [NotificationType.TipReceive]: formatTipReceive, + [NotificationType.TipSend]: formatTipSend, + [NotificationType.Reaction]: formatReaction, + [NotificationType.SupporterRankUp]: formatSupporterRankUp, + [NotificationType.SupportingRankUp]: formatSupportingRankUp, + [NotificationType.SupporterDethroned]: formatSupporterDethroned, + [NotificationType.AddTrackToPlaylist]: formatAddTrackToPlaylist +}; +/* Merges the notifications with the user announcements in time sorted order (Most recent first). + * + * @param {Array} notifications Notifications return from the db query w/ the actions + * @param {Array} announcements Announcements set on the app + * + * @return {Array} The merged & nsorted notificaitons/annoucnements + */ +function mergeAudiusAnnoucements(announcements, notifications) { + const allNotifications = announcements.concat(notifications); + allNotifications.sort((a, b) => { + const aDate = moment(a.datePublished || a.timestamp || a.createdAt); + const bDate = moment(b.datePublished || b.timestamp || b.createdAt); + return bDate - aDate; + }); + return allNotifications; +} +/* Merges the notifications with the user announcements in time sorted order. + * Formats each notification to be send to the client. + * Counts the total number of unread notifications + * + * @param {Array} notifications Notifications return from the db query w/ the actions + * @param {Array} announcements Announcements set on the app + * + * @return {object} The sorted & formated notificaitons/annoucnements and the total unread count for notifs & announcements + */ +const formatNotifications = (notifications, announcements) => { + const userAnnouncements = [...announcements]; + const userNotifications = notifications + .map((notification) => { + const mapResponse = notificationResponseMap[notification.type]; + if (mapResponse) + return mapResponse(notification, userAnnouncements); + }) + .filter(Boolean); + const notifIds = userNotifications.reduce((acc, notif) => { + acc[notif.id] = true; + return acc; + }, {}); + const unreadAnnouncements = userAnnouncements + .filter((a) => !notifIds[a.entityId]) + .map(formatUnreadAnnouncement); + return mergeAudiusAnnoucements(unreadAnnouncements, userNotifications); +}; +/** + * Clear badge counts for a given user + * @param {Integer} userId + * @param {Object} logger + */ +async function clearBadgeCounts(userId, logger) { + try { + await models.PushNotificationBadgeCounts.update({ + iosBadgeCount: 0 + }, { + where: { + userId + } + }); + } + catch (e) { + logger.error(`Failed to clear badge counts for user ${userId}`); + } +} +module.exports = function (app) { + /* + * Fetches the notifications for the specified userId + * urlQueryParam: {number} limit Max number of notifications to return, Cannot exceed 100 + * urlQueryParam: {number?} timeOffset A timestamp reference offset for fetch notification before this date + * urlQueryParam: {boolean?} withRewards A boolean to fetch notifications with challenge rewards + * + * TODO: Validate userId + * NOTE: The `createdDate` param can/should be changed to the user sending their wallet & + * finding the created date from the users table + */ + app.get('/notifications', authMiddleware, handleResponse(async (req) => { + const limit = parseInt(req.query.limit); + const timeOffset = req.query.timeOffset + ? moment(req.query.timeOffset) + : moment(); + const { blockchainUserId: userId, createdAt, walletAddress } = req.user; + const createdDate = moment(createdAt); + if (!timeOffset.isValid()) { + return errorResponseBadRequest(`Invalid Date params`); + } + const filterNotificationTypes = []; + let filterSolanaNotificationTypes = []; + if (req.query.withRewards !== 'true') { + filterSolanaNotificationTypes.push(NotificationType.ChallengeReward); + } + if (req.query.withTips !== 'true') { + filterSolanaNotificationTypes = [ + ...filterNotificationTypes, + NotificationType.TipReceive, + NotificationType.TipSend, + NotificationType.Reaction, + NotificationType.SupporterRankUp, + NotificationType.SupportingRankUp + ]; + } + if (req.query.withSupporterDethroned !== 'true') { + filterSolanaNotificationTypes.push(NotificationType.SupporterDethroned); + } + const queryFilter = filterNotificationTypes.length > 0 + ? { + type: { [models.Sequelize.Op.notIn]: filterNotificationTypes } + } + : {}; + const solanaQueryFilter = filterSolanaNotificationTypes.length > 0 + ? { + type: { + [models.Sequelize.Op.notIn]: filterSolanaNotificationTypes + } + } + : {}; + req.logger.warn({ filterNotificationTypes }); + if (isNaN(limit) || limit > 100) { + return errorResponseBadRequest(`Limit and offset number be integers with a max limit of 100`); + } + try { + const notifications = await models.Notification.findAll({ + where: { + userId, + isHidden: false, + ...queryFilter, + timestamp: { + [models.Sequelize.Op.lt]: timeOffset.toDate() + } + }, + order: [ + ['timestamp', 'DESC'], + ['entityId', 'ASC'] + ], + include: [ + { + model: models.NotificationAction, + required: true, + as: 'actions' + } + ], + limit + }); + const solanaNotifications = await models.SolanaNotification.findAll({ + where: { + userId, + isHidden: false, + ...solanaQueryFilter, + createdAt: { + [models.Sequelize.Op.lt]: timeOffset.toDate() + } + }, + order: [ + ['createdAt', 'DESC'], + ['entityId', 'ASC'] + ], + include: [ + { + model: models.SolanaNotificationAction, + required: false, + as: 'actions' + } + ], + limit + }); + let unViewedCount = await models.Notification.findAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + ...queryFilter + }, + include: [ + { + model: models.NotificationAction, + as: 'actions', + required: true, + attributes: [] + } + ], + attributes: [ + [ + models.Sequelize.fn('COUNT', models.Sequelize.col('Notification.id')), + 'total' + ] + ], + group: ['Notification.id'] + }); + const unViewedSolanaCount = await models.SolanaNotification.findAll({ + where: { + userId, + isViewed: false, + isRead: false, + isHidden: false, + ...queryFilter + }, + include: [ + { + model: models.SolanaNotificationAction, + as: 'actions', + required: false, + attributes: [] + } + ], + attributes: [ + [ + models.Sequelize.fn('COUNT', models.Sequelize.col('SolanaNotification.id')), + 'total' + ] + ], + group: ['SolanaNotification.id'] + }); + unViewedCount = unViewedCount.length + unViewedSolanaCount.length; + const viewedAnnouncements = await models.Notification.findAll({ + where: { userId, isViewed: true, type: NotificationType.Announcement } + }); + const viewedAnnouncementCount = viewedAnnouncements.length; + const filteredViewedAnnouncements = viewedAnnouncements + .filter((a) => moment(a.createdAt).isAfter(createdDate)) + .filter((a) => timeOffset.isAfter(moment(a.createdAt))); + const announcements = app.get('announcements'); + const validUserAnnouncements = announcements.filter((a) => moment(a.datePublished).isAfter(createdDate)); + const announcementsAfterFilter = validUserAnnouncements.filter((a) => timeOffset.isAfter(moment(a.datePublished))); + const unreadAnnouncementCount = validUserAnnouncements.length - viewedAnnouncementCount; + const userNotifications = formatNotifications(notifications + .concat(solanaNotifications) + .concat(filteredViewedAnnouncements), announcementsAfterFilter); + let playlistUpdates = []; + if (walletAddress) { + const result = await models.UserEvents.findOne({ + attributes: ['playlistUpdates'], + where: { walletAddress } + }); + const playlistUpdatesResult = result && result.playlistUpdates; + if (playlistUpdatesResult) { + const thirtyDaysAgo = moment().utc().subtract(30, 'days').valueOf(); + playlistUpdates = Object.keys(playlistUpdatesResult) + .filter((playlistId) => playlistUpdatesResult[playlistId].userLastViewed >= + thirtyDaysAgo && + playlistUpdatesResult[playlistId].lastUpdated >= + thirtyDaysAgo && + playlistUpdatesResult[playlistId].userLastViewed < + playlistUpdatesResult[playlistId].lastUpdated) + .map((id) => parseInt(id)) + .filter(Boolean); + } + } + return successResponse({ + message: 'success', + notifications: userNotifications.slice(0, limit), + totalUnread: unreadAnnouncementCount + unViewedCount, + playlistUpdates + }); + } + catch (err) { + req.logger.error(`[Error] Unable to retrieve notifications for user: ${userId}`, err); + return errorResponseBadRequest({ + message: `[Error] Unable to retrieve notifications for user: ${userId}` + }); + } + })); + /* + * Sets a user's notifcation as read or hidden + * postBody: {number?} notificationType The type of the notification to be marked as read (Only used for Announcements) + * postBody: {number?} notificationID The id of the notification to be marked as read (Only used for Announcements) + * postBody: {number} isRead Identitifies if the notification is to be marked as read + * postBody: {number} isHidden Identitifies if the notification is to be marked as hidden + * + * TODO: Validate userId + */ + app.post('/notifications', authMiddleware, handleResponse(async (req, res, next) => { + const { notificationId, notificationType, isRead, isHidden } = req.body; + const userId = req.user.blockchainUserId; + if (typeof notificationType !== 'string' || + !ClientNotificationTypes.has(notificationType) || + (typeof isRead !== 'boolean' && typeof isHidden !== 'boolean')) { + return errorResponseBadRequest('Invalid request body'); + } + try { + if (notificationType === NotificationType.Announcement) { + const announcementMap = app.get('announcementMap'); + const announcement = announcementMap[notificationId]; + if (!announcement) + return errorResponseBadRequest('[Error] Invalid notification id'); + const [notification, isCreated] = await models.Notification.findOrCreate({ + where: { + type: notificationType, + userId, + entityId: announcement.entityId + }, + defaults: { + isViewed: true, + isRead: true, + isHidden, + blocknumber: 0, + timestamp: announcement.datePublished + } + }); + if (!isCreated && + (notification.isRead !== isRead || + notification.isHidden !== isHidden)) { + await notification.update({ + isViewed: true, + ...(typeof isRead === 'boolean' ? { isRead } : {}), + ...(typeof isHidden === 'boolean' ? { isHidden } : {}) + }); + } + return successResponse({ message: 'success' }); + } + else { + const update = { isViewed: true, isRead: true }; + if (isHidden !== undefined) + update.isHidden = isHidden; + await models.Notification.update(update, { + where: { id: notificationId } + }); + await models.SolanaNotification.update(update, { + where: { id: notificationId } + }); + return successResponse({ message: 'success' }); + } + } + catch (err) { + return errorResponseBadRequest({ + message: `[Error] Unable to mark notification as read/hidden` + }); + } + })); + /* + * Marks all of a user's notifications as viewed & optionally is read & inserts rows for announcements + * postBody: {bool?} isRead Identitifies if the notification is to be marked as read + * + */ + app.post('/notifications/all', authMiddleware, handleResponse(async (req, res, next) => { + const { isRead, isViewed, clearBadges } = req.body; + const { createdAt, blockchainUserId: userId } = req.user; + const createdDate = moment(createdAt); + if (!createdDate.isValid() || + (typeof isRead !== 'boolean' && typeof isViewed !== 'boolean')) { + return errorResponseBadRequest('Invalid request body'); + } + try { + const update = { + isViewed: true, + ...(typeof isRead !== 'undefined' ? { isRead } : {}) + }; + await models.Notification.update(update, { where: { userId } }); + await models.SolanaNotification.update(update, { where: { userId } }); + const announcementMap = app.get('announcementMap'); + const unreadAnnouncementIds = Object.keys(announcementMap).reduce((acc, id) => { + if (moment(announcementMap[id].datePublished).isAfter(createdDate)) + acc[id] = false; + return acc; + }, {}); + const readAnnouncementIds = await models.Notification.findAll({ + where: { + type: NotificationType.Announcement, + userId + }, + attributes: ['entityId'] + }); + for (const announcementId of readAnnouncementIds) { + delete unreadAnnouncementIds[announcementId.entityId]; + } + const unreadAnnouncements = Object.keys(unreadAnnouncementIds).map((id) => announcementMap[id]); + await models.Notification.bulkCreate(unreadAnnouncements.map((announcement) => ({ + type: NotificationType.Announcement, + entityId: announcement.entityId, + ...update, + isHidden: false, + userId, + blocknumber: 0, + timestamp: announcement.datePublished + }))); + if (clearBadges) { + await clearBadgeCounts(userId, req.logger); + } + return successResponse({ message: 'success' }); + } + catch (err) { + return errorResponseBadRequest({ + message: `[Error] Unable to mark notification as read/hidden` + }); + } + })); + /* + * Clears a user's notification badge count to 0 + */ + app.post('/notifications/clear_badges', authMiddleware, handleResponse(async (req, res, next) => { + const { blockchainUserId: userId } = req.user; + try { + await clearBadgeCounts(userId, req.logger); + return successResponse({ message: 'success' }); + } + catch (err) { + return errorResponseBadRequest({ + message: `[Error] Unable to clear user badges for userID: ${userId}` + }); + } + })); + /** + * @deprecated + * Updates fields for a user's settings (or creates the settings w/ db defaults if not created) + * postBody: {object} settings Identitifies if the notification is to be marked as read + * + */ + app.post('/notifications/settings', authMiddleware, handleResponse(async (req, res, next) => { + const { settings } = req.body; + if (typeof settings === 'undefined') { + return errorResponseBadRequest('Invalid request body'); + } + try { + await models.UserNotificationSettings.upsert({ + userId: req.user.blockchainUserId, + ...settings + }); + return successResponse({ message: 'success' }); + } + catch (err) { + return errorResponseBadRequest({ + message: `[Error] Unable to create/update notification settings for user: ${req.user.blockchainUserId}` + }); + } + })); + /** + * @deprecated + * Fetches the settings for a given userId + */ + app.get('/notifications/settings', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + try { + await models.sequelize.query(` + INSERT INTO "UserNotificationSettings" ("userId", "updatedAt", "createdAt") + VALUES (:userId, now(), now()) + ON CONFLICT + DO NOTHING; + `, { + replacements: { userId } + }); + const settings = await models.UserNotificationSettings.findOne({ + where: { userId }, + attributes: [ + 'favorites', + 'reposts', + 'milestonesAndAchievements', + 'announcements', + 'followers', + 'browserPushNotifications', + 'emailFrequency' + ] + }); + return successResponse({ settings }); + } + catch (err) { + return errorResponseBadRequest({ + message: `[Error] Unable to retrieve notification settings for user: ${userId}` + }); + } + })); + /* + * Refreshes the announcements stored in the application + */ + app.post('/announcements', handleResponse(async (req, res, next) => { + try { + const { announcements, announcementMap } = await fetchAnnouncements(); + app.set('announcements', announcements); + app.set('announcementMap', announcementMap); + return successResponse({ msg: 'Updated announcements' }); + } + catch (err) { + return errorResponseBadRequest({ + message: `Failed to update announcements - ${err}` + }); + } + })); + /* + * Sets or removes a user subscription + * postBody: {number} userId The user ID of the subscribed to user + * postBody: {boolean} isSubscribed If the user is subscribing or unsubscribing + * + * TODO: Validate that the userId is a valid userID + */ + app.post('/notifications/subscription', authMiddleware, handleResponse(async (req, res, next) => { + const { userId, isSubscribed } = req.body; + const subscriberId = req.user.blockchainUserId; + if (typeof userId !== 'number' || + typeof subscriberId !== 'number' || + userId === subscriberId) { + return errorResponseBadRequest('Invalid request body'); + } + if (isSubscribed) { + await models.sequelize.query(` + INSERT INTO "Subscriptions" ("subscriberId", "userId", "updatedAt", "createdAt") + VALUES (:subscriberId, :userId, now(), now()) + ON CONFLICT + DO NOTHING; + `, { + replacements: { subscriberId, userId } + }); + } + else { + await models.Subscription.destroy({ + where: { subscriberId, userId } + }); + } + return successResponse({ message: 'success' }); + })); + /* + * Returns if a user subscription exists + * urlQueryParam: {Array} userId The user ID(s) of the subscribed to user + */ + app.get('/notifications/subscription', authMiddleware, handleResponse(async (req, res, next) => { + const usersIdsParam = Array.isArray(req.query.userId) + ? req.query.userId.map((id) => parseInt(id)) + : [parseInt(req.query.userId)]; + const usersIds = usersIdsParam.filter((id) => !isNaN(id)); + if (usersIds.length === 0) + return errorResponseBadRequest('Invalid request parameters'); + const subscriptions = await models.Subscription.findAll({ + where: { + subscriberId: req.user.blockchainUserId, + userId: { + [models.Sequelize.Op.in]: usersIds + } + } + }); + const initSubscribers = usersIds.reduce((acc, id) => { + acc[id] = { isSubscribed: false }; + return acc; + }, {}); + const users = subscriptions.reduce((subscribers, subscription) => { + subscribers[subscription.userId] = { isSubscribed: true }; + return subscribers; + }, initSubscribers); + return successResponse({ users }); + })); +}; +module.exports.mergeAudiusAnnoucements = mergeAudiusAnnoucements; +module.exports.mapMilestone = mapMilestone; +module.exports.Entity = Entity; diff --git a/packages/identity-service/build/src/routes/protocol.js b/packages/identity-service/build/src/routes/protocol.js new file mode 100644 index 00000000000..514f123b5af --- /dev/null +++ b/packages/identity-service/build/src/routes/protocol.js @@ -0,0 +1,66 @@ +"use strict"; +const express = require('express'); +const { recoverPersonalSignature } = require('eth-sig-util'); +const { handleResponse, successResponse, errorResponseBadRequest, errorResponse: formatResponse, sendResponse } = require('../apiHelpers'); +const models = require('../models'); +const protocolRouter = express.Router(); +protocolRouter.get('/:wallet/delegation/minimum', handleResponse(async (req, res, next) => { + const { wallet } = req.params; + const serviceProvider = await models.ProtocolServiceProviders.findOne({ + where: { wallet: wallet.toLowerCase() } + }); + if (serviceProvider === null) { + return formatResponse(404, 'Minimum delegation amount not set'); + } + return successResponse({ + wallet, + minimumDelegationAmount: serviceProvider.minimumDelegationAmount + }); +})); +async function serviceProviderAuthMiddleware(req, res, next) { + try { + const encodedDataMessage = req.get('Encoded-Data-Message'); + const signature = req.get('Encoded-Data-Signature'); + if (!encodedDataMessage) + throw new Error('[Error]: Encoded data missing'); + if (!signature) + throw new Error('[Error]: Encoded data signature missing'); + const wallet = recoverPersonalSignature({ + data: encodedDataMessage, + sig: signature + }); + req.authedWallet = wallet; + next(); + } + catch (err) { + req.logger.warn(`${err}`); + const errorResponse = errorResponseBadRequest('[Error]: '); + return sendResponse(req, res, errorResponse); + } +} +protocolRouter.post('/:wallet/delegation/minimum', serviceProviderAuthMiddleware, handleResponse(async (req, res, next) => { + const { wallet } = req.params; + if (wallet.toLowerCase() !== req.authedWallet.toLowerCase()) { + return errorResponseBadRequest(`[Error]: Not authenticated, unable to change minimun delegation`); + } + const { minimumDelegationAmount } = req.body; + if (!minimumDelegationAmount || + typeof minimumDelegationAmount !== 'string') { + return errorResponseBadRequest(`Bad Request: Must pass request body field 'minimumDelegationAmount' as string in wei`); + } + const isNumeric = /^\d+$/.test(minimumDelegationAmount); + if (!isNumeric) { + return errorResponseBadRequest(`Bad Request: 'minimumDelegationAmount' must be a string consisting of only numbers`); + } + await models.ProtocolServiceProviders.upsert({ + wallet, + minimumDelegationAmount + }); + return successResponse({ + wallet, + minimumDelegationAmount: minimumDelegationAmount + }); +})); +module.exports = function (app) { + app.use('/protocol', protocolRouter); +}; diff --git a/packages/identity-service/build/src/routes/push_notifications.js b/packages/identity-service/build/src/routes/push_notifications.js new file mode 100644 index 00000000000..eb80bbd3da6 --- /dev/null +++ b/packages/identity-service/build/src/routes/push_notifications.js @@ -0,0 +1,365 @@ +"use strict"; +const { handleResponse, successResponse, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const authMiddleware = require('../authMiddleware'); +const models = require('../models'); +const config = require('../config'); +const { createPlatformEndpoint, deleteEndpoint } = require('../awsSNS'); +const path = require('path'); +const fs = require('fs'); +const iOSSNSParams = { + PlatformApplicationArn: config.get('awsSNSiOSARN') +}; +const androidSNSParams = { + PlatformApplicationArn: config.get('awsSNSAndroidARN') +}; +const IOS = 'ios'; +const ANDROID = 'android'; +const SAFARI = 'safari'; +const DEVICE_TYPES = new Set([IOS, ANDROID, SAFARI]); +// A signed Push Pacakge zip is required for safari browser push notifications +let pushPackageName = ''; +const environment = config.get('environment'); +if (environment === 'development') { + pushPackageName = 'devPushPackage.zip'; +} +else if (environment === 'staging') { + pushPackageName = 'stagingPushPackage.zip'; +} +else { + pushPackageName = 'productionPushPackage.zip'; +} +const pushPackagePath = path.join(__dirname, `../notifications/browserPush/${pushPackageName}`); +/** + * Checks if a browser Push API subscription is valid for notifications + * @params {Object} subscription + */ +const isValidBrowserSubscription = (subscription) => { + return (subscription && + subscription.endpoint && + subscription.keys && + subscription.keys.p256dh && + subscription.keys.auth); +}; +module.exports = function (app) { + /** + * Get the settings for mobile push notifications for a user + */ + app.get('/push_notifications/settings', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + if (!userId) + return errorResponseBadRequest(`Did not pass in a valid userId`); + try { + const settings = await models.UserNotificationMobileSettings.findOne({ + where: { userId } + }); + return successResponse({ settings }); + } + catch (e) { + req.logger.error(`Unable to find push notification settings for userId: ${userId}`, e); + return errorResponseServerError(`Unable to find push notification settings for userId: ${userId}, Error: ${e.message}`); + } + })); + /** + * Create or update mobile push notification settings + * POST body contains {userId, settings: {favorites, milestonesAndAchievements, reposts, announcements, followers}} + */ + app.post('/push_notifications/settings', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { settings } = req.body; + if (!userId) + return errorResponseBadRequest(`Did not pass in a valid userId`); + try { + // pseudo-upsert without sequlize magic + const userSettings = await models.UserNotificationMobileSettings.findOne({ + where: { userId } + }); + if (userSettings) { + await userSettings.update({ ...settings }); + } + else { + await models.UserNotificationMobileSettings.create({ + userId, + ...settings + }); + } + return successResponse(); + } + catch (e) { + req.logger.error(`Unable to create or update push notification settings for userId: ${userId}`, e); + return errorResponseServerError(`Unable to create or update push notification settings for userId: ${userId}, Error: ${e.message}`); + } + })); + /** + * Register a device token + * POST body contains {deviceToken: , deviceType: ios/android/safari } + */ + app.post('/push_notifications/device_token', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { deviceToken, deviceType } = req.body; + if (!DEVICE_TYPES.has(deviceType)) { + return errorResponseBadRequest('Attempting to register an invalid deviceType'); + } + if (!deviceToken || !userId) { + return errorResponseBadRequest('Did not pass in a valid deviceToken or userId for device token registration'); + } + try { + // Build the aws sns platform params based on device type + let params = { Token: deviceToken }; + if (deviceType === IOS) + params = { ...iOSSNSParams, ...params }; + else if (deviceType === ANDROID) + params = { ...androidSNSParams, ...params }; + // If native moblie (ios/android), register the device with aws sns + if (deviceType !== SAFARI) { + const awsARN = (await createPlatformEndpoint(params)).EndpointArn; + await models.NotificationDeviceToken.upsert({ + deviceToken, + deviceType, + userId, + awsARN + }); + } + else { + await models.NotificationDeviceToken.upsert({ + deviceToken, + deviceType, + userId + }); + } + return successResponse(); + } + catch (e) { + req.logger.error(`Unable to register device token for userId: ${userId} on ${deviceType}`, e); + return errorResponseServerError(`Unable to register device token for userId: ${userId} on ${deviceType}, Error: ${e.message}`); + } + })); + /** + * Remove a device token from the device token table + * POST body contains {deviceToken} + */ + app.post('/push_notifications/device_token/deregister', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { deviceToken } = req.body; + if (!deviceToken) { + return errorResponseBadRequest('Did not pass in a valid deviceToken or userId for device token registration'); + } + let tokenDeleted = false; + try { + // delete device token + const tokenObj = await models.NotificationDeviceToken.findOne({ + where: { + deviceToken, + userId + } + }); + if (tokenObj) { + // delete the endpoint from AWS SNS + if (tokenObj.awsARN) + await deleteEndpoint({ EndpointArn: tokenObj.awsARN }); + await tokenObj.destroy(); + tokenDeleted = true; + } + return successResponse({ tokenDeleted }); + } + catch (e) { + req.logger.error(`Unable to deregister device token for deviceToken: ${deviceToken}`, e); + return errorResponseServerError(`Unable to deregister device token for deviceToken: ${deviceToken}`, e.message); + } + })); + /** + * Checks if a device token/type exists for a userId + * Get query must include {deviceToken: , deviceType: ios/android/safari } + */ + app.get('/push_notifications/device_token/enabled', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { deviceToken, deviceType } = req.query; + if (!DEVICE_TYPES.has(deviceType)) { + return errorResponseBadRequest('Attempting to check for an invalid deviceType'); + } + if (!deviceToken || !userId) { + return errorResponseBadRequest('Did not pass in a valid deviceToken or userId for device token check'); + } + try { + const notificationDeviceToken = await models.NotificationDeviceToken.findOne({ + where: { + deviceToken, + deviceType, + userId + } + }); + const enabled = (notificationDeviceToken && notificationDeviceToken.enabled) || false; + return successResponse({ enabled }); + } + catch (e) { + req.logger.error(`Unable to register device token for userId: ${userId} on ${deviceType}`, e); + return errorResponseServerError(`Unable to register device token for userId: ${userId} on ${deviceType}, Error: ${e.message}`); + } + })); + /** + * Get the settings for browser push notifications for a user + */ + app.get('/push_notifications/browser/settings', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + if (!userId) + return errorResponseBadRequest(`Did not pass in a valid userId`); + try { + const [settings] = await models.UserNotificationBrowserSettings.findOrCreate({ + where: { userId } + }); + return successResponse({ settings }); + } + catch (e) { + req.logger.error(`Unable to find browser push notification settings for userId: ${userId}`, e); + return errorResponseServerError(`Unable to find browser push notification settings for userId: ${userId}, Error: ${e.message}`); + } + })); + /** + * Create or update browser push notification settings + * POST body contains {userId, settings: {favorites, milestonesAndAchievements, reposts, followers}} + */ + app.post('/push_notifications/browser/settings', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { settings } = req.body; + if (!userId) + return errorResponseBadRequest(`Did not pass in a valid userId`); + try { + const browserSettings = await models.UserNotificationBrowserSettings.upsert({ + ...settings, + userId + }); + return successResponse({ settings: browserSettings }); + } + catch (e) { + req.logger.error(`Unable to create or update browser push notification settings for userId: ${userId}`, e); + return errorResponseServerError(`Unable to create or update browser push notification settings for userId: ${userId}, Error: ${e.message}`); + } + })); + /* + * Returns if a user browser subscription exists and is enabled + */ + app.get('/push_notifications/browser/enabled', authMiddleware, handleResponse(async (req, res, next) => { + const userId = req.user.blockchainUserId; + const { endpoint } = req.query; + if (!endpoint) { + return errorResponseBadRequest('Invalid request parameters, endpoint required'); + } + try { + const subscription = await models.NotificationBrowserSubscription.findOne({ + where: { userId, endpoint }, + attributes: ['enabled'] + }); + const enabled = (subscription && subscription.enabled) || false; + return successResponse({ enabled }); + } + catch (e) { + return errorResponseServerError(`Unable to get browser push notificaiton enabled`, e.message); + } + })); + /* + * Creates/Updates a user browser notification bscription exists + */ + app.post('/push_notifications/browser/register', authMiddleware, handleResponse(async (req, res, next) => { + const { subscription, enabled = true } = req.body; + if (!isValidBrowserSubscription(subscription)) { + return errorResponseBadRequest('Invalid request parameters'); + } + try { + await models.NotificationBrowserSubscription.upsert({ + userId: req.user.blockchainUserId, + endpoint: subscription.endpoint, + p256dhKey: subscription.keys.p256dh, + authKey: subscription.keys.auth, + enabled + }); + return successResponse(); + } + catch (e) { + return errorResponseServerError(`Unable to save browser push notificaiton subscription`, e.message); + } + })); + /* + * Deletes the browser notification subscription. + */ + app.post('/push_notifications/browser/deregister', handleResponse(async (req, res, next) => { + const { subscription } = req.body; + if (!isValidBrowserSubscription(subscription)) { + return errorResponseBadRequest('Invalid request parameters'); + } + try { + await models.NotificationBrowserSubscription.destroy({ + where: { + endpoint: subscription.endpoint, + p256dhKey: subscription.keys.p256dh, + authKey: subscription.keys.auth + } + }); + return successResponse(); + } + catch (e) { + return errorResponseServerError(`Unable to deregister push browser subscription`, e.message); + } + })); + /* + * Downloads the signed safari web push package for authentication. + */ + app.post('/push_notifications/safari/:version/pushPackages/:websitePushID', (req, res) => { + try { + res.writeHead(200, { + 'Content-Type': 'application/zip' + }); + const readStream = fs.createReadStream(pushPackagePath); + readStream.pipe(res); + } + catch (e) { + return errorResponseServerError(`Unable to send safari push package`, e.message); + } + }); + /* + * Registering or Updating Device Permission Policy + * When users first grant permission, or later change their permission levels for your website, a POST request is sent to the following URL: + * NOTE: the deviceToken is also accessible in the client, and sent as part of the device_token/register endpoint w/ additional data + */ + app.post('/push_notifications/safari/:version/devices/:deviceToken/registrations/:websitePushID', handleResponse(async (req, res, next) => { + try { + return successResponse({}); + } + catch (e) { + return errorResponseServerError(`Unable to save safari browser push notificaiton subscription`, e.message); + } + })); + /* + * Forgetting Device Permission Policy + * If a user removes permission of a website in Safari preferences, a DELETE request is sent to the following URL: + * This is done by the safari browser, but the client redundently send a request to device_token/deregister + */ + app.delete('/push_notifications/safari/:version/devices/:deviceToken/registrations/:websitePushID', handleResponse(async (req, res, next) => { + const { deviceToken } = req.body; + if (!deviceToken) { + return errorResponseBadRequest('Did not pass in a valid deviceToken or userId for device token registration'); + } + try { + // delete device token + await models.NotificationDeviceToken.destroy({ + where: { deviceToken } + }); + return successResponse(); + } + catch (e) { + return errorResponseServerError(`Unable to delete browser push notificaiton devicetoken`, e.message); + } + })); + /* + * Logging Errors + * If an error occurs, a POST request is sent to the following URL: + */ + app.post('/push_notifications/safari/:version/log', handleResponse(async (req, res, next) => { + // TODO: Download website package + req.logger.info(JSON.stringify(req.body, null, '')); + try { + return successResponse(); + } + catch (e) { + return errorResponseServerError(`Unable to log safari push notification`, e.message); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/reactions.js b/packages/identity-service/build/src/routes/reactions.js new file mode 100644 index 00000000000..fdb2ba3833f --- /dev/null +++ b/packages/identity-service/build/src/routes/reactions.js @@ -0,0 +1,103 @@ +"use strict"; +const { handleResponse, errorResponseBadRequest, errorResponseServerError, successResponse } = require('../apiHelpers'); +const authMiddleware = require('../authMiddleware'); +const models = require('../models'); +const { logger } = require('../logging'); +const { default: Axios } = require('axios'); +const MAX_REACTIONS_PER_FETCH = 100; +const handleReaction = async ({ senderWallet, reactionType, reactedTo, libs, reactionValue }) => { + const { solanaWeb3Manager } = libs; + const currentSlot = await solanaWeb3Manager.getSlot(); + // Get tips on DN to ensure reactions only to received tips + const { discoveryProviderEndpoint } = libs.discoveryProvider; + const url = `${discoveryProviderEndpoint}/v1/full/tips`; + const resp = await Axios({ + method: 'get', + url, + params: { + tx_signatures: reactedTo + } + }); + const tips = resp.data.data; + if (tips.length !== 1) { + // Can't react to something that doesn't exist + throw new Error(`No tip for tx_id ${reactedTo}`); + } + const { erc_wallet: tipReceiverWallet } = tips[0].receiver; + if (tipReceiverWallet.toLowerCase() !== senderWallet.toLowerCase()) { + throw new Error(`Can't react unless user was the tip recipient`); + } + const now = Date.now(); + await models.Reactions.create({ + slot: currentSlot, + reactionValue, + senderWallet, + reactionType, + reactedTo, + createdAt: now, + updatedAt: now + }); +}; +const getReactions = async ({ startIndex, limit }) => { + const reactions = await models.Reactions.findAll({ + where: { + id: { [models.Sequelize.Op.gte]: startIndex } + }, + order: [[models.Sequelize.col('id'), 'ASC']], + limit + }); + return reactions; +}; +module.exports = function (app) { + /** + * POST a new reaction, represented by a numeric ID (reaction) and reactedTo (entity being reacted upon) + */ + app.post('/reactions', authMiddleware, handleResponse(async (req, res, next) => { + // Validation + const senderWallet = req.user.walletAddress; + const { reactedTo, reactionValue } = req.body; + if (!senderWallet || !reactedTo || reactionValue === undefined) + return errorResponseBadRequest(`Missing argument: ${JSON.stringify({ + senderWallet, + reactedTo, + reactionValue + })}`); + const parsedReaction = parseInt(reactionValue); + if (isNaN(parsedReaction)) + return errorResponseBadRequest('Invalid reaction type'); + const libs = req.app.get('audiusLibs'); + try { + logger.info(`Creating reaction ${reactionValue} for reactedTo: ${reactedTo}`); + await handleReaction({ + senderWallet, + reactedTo, + reactionValue: parsedReaction, + reactionType: 'tip', + libs + }); + return successResponse(); + } + catch (e) { + logger.error(`Caught error trying to create reaction ${reactionValue} for id: ${reactedTo}: ${e}`); + return errorResponseServerError('Something went wrong'); + } + })); + /** + * Get all reactions with ID >= startIndex + */ + app.get('/reactions', handleResponse(async (req, res, next) => { + let { startIndex, limit } = req.query; + startIndex = startIndex || 0; + limit = Math.min(MAX_REACTIONS_PER_FETCH, limit || MAX_REACTIONS_PER_FETCH); + try { + const reactions = await getReactions({ startIndex, limit }); + return successResponse({ + reactions + }); + } + catch (e) { + logger.error(`Caught error trying to get reactions: ${e}`); + return errorResponseServerError('Something went wrong'); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/recovery.js b/packages/identity-service/build/src/routes/recovery.js new file mode 100644 index 00000000000..3993636bcf0 --- /dev/null +++ b/packages/identity-service/build/src/routes/recovery.js @@ -0,0 +1,100 @@ +"use strict"; +const { handleResponse, successResponse, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const { recoverPersonalSignature } = require('eth-sig-util'); +const models = require('../models'); +const handlebars = require('handlebars'); +const fs = require('fs'); +const path = require('path'); +const config = require('../config.js'); +const WEBSITE_HOST = config.get('websiteHost'); +const recoveryTemplate = handlebars.compile(fs + .readFileSync(path.resolve(__dirname, '../notifications/emails/recovery.html')) + .toString()); +const toQueryStr = (obj) => { + return ('?' + + Object.keys(obj) + .map((key) => { + return key + '=' + encodeURIComponent(obj[key]); + }) + .join('&')); +}; +module.exports = function (app) { + /** + * Send recovery information to the requested account + */ + app.post('/recovery', handleResponse(async (req, res, next) => { + const sg = req.app.get('sendgrid'); + if (!sg) { + req.logger.error('Missing sendgrid api key'); + // Short-circuit if no api key provided, but do not error + return successResponse({ + msg: 'No sendgrid API Key found', + status: true + }); + } + const { login, data, signature, handle } = req.body; + if (!login) { + return errorResponseBadRequest('Please provide valid login information'); + } + if (!data || !signature) { + return errorResponseBadRequest('Please provide data and signature'); + } + if (!handle) { + return errorResponseBadRequest('Please provide a handle'); + } + const walletFromSignature = recoverPersonalSignature({ + data: data, + sig: signature + }); + const existingUser = await models.User.findOne({ + where: { + walletAddress: walletFromSignature + } + }); + if (!existingUser) { + return errorResponseBadRequest('Invalid signature provided, no user found'); + } + if (!existingUser.isEmailDeliverable) { + req.logger.info(`Unable to deliver recovery email to ${existingUser.handle} ${existingUser.email}`); + return successResponse({ + msg: 'Recovery email forbidden', + status: true + }); + } + const email = existingUser.email; + const recoveryParams = { + warning: 'RECOVERY_DO_NOT_SHARE', + login: login, + email: email + }; + const recoveryLink = WEBSITE_HOST + toQueryStr(recoveryParams); + const copyrightYear = new Date().getFullYear().toString(); + const context = { + recovery_link: recoveryLink, + handle: handle, + copyright_year: copyrightYear + }; + const recoveryHtml = recoveryTemplate(context); + const emailParams = { + from: 'Audius Recovery ', + to: `${email}`, + subject: 'Save This Email: Audius Password Recovery', + html: recoveryHtml, + asm: { + groupId: 19141 // id of unsubscribe group at https://mc.sendgrid.com/unsubscribe-groups + } + }; + try { + await sg.send(emailParams); + await models.UserEvents.update({ needsRecoveryEmail: false }, { + where: { + walletAddress: walletFromSignature + } + }); + return successResponse({ status: true }); + } + catch (e) { + return errorResponseServerError(e); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/relay.js b/packages/identity-service/build/src/routes/relay.js new file mode 100644 index 00000000000..480343a5cd7 --- /dev/null +++ b/packages/identity-service/build/src/routes/relay.js @@ -0,0 +1,125 @@ +"use strict"; +const crypto = require('crypto'); +const { handleResponse, successResponse, errorResponseBadRequest, errorResponseForbidden, errorResponseServerError } = require('../apiHelpers'); +const txRelay = require('../relay/txRelay'); +const captchaMiddleware = require('../captchaMiddleware'); +const { detectAbuse } = require('../utils/antiAbuse'); +const { getFeatureFlag, FEATURE_FLAGS } = require('../featureFlag'); +const models = require('../models'); +const { getIP } = require('../utils/antiAbuse'); +const { libs } = require('@audius/sdk'); +const config = require('../config.js'); +module.exports = function (app) { + app.post('/relay', captchaMiddleware, handleResponse(async (req, res, next) => { + const body = req.body; + const redis = req.app.get('redis'); + // TODO: Use auth middleware to derive this + const user = req.user; + let optimizelyClient; + let detectAbuseOnRelay = false; + let blockAbuseOnRelay = false; + try { + optimizelyClient = req.app.get('optimizelyClient'); + // only detect/block abuse from owner wallets + detectAbuseOnRelay = + getFeatureFlag(optimizelyClient, FEATURE_FLAGS.DETECT_ABUSE_ON_RELAY) && !req.isFromApp; + blockAbuseOnRelay = + getFeatureFlag(optimizelyClient, FEATURE_FLAGS.BLOCK_ABUSE_ON_RELAY) && !req.isFromApp; + } + catch (error) { + req.logger.error(`failed to retrieve optimizely feature flag for ${FEATURE_FLAGS.DETECT_ABUSE_ON_RELAY} or ${FEATURE_FLAGS.BLOCK_ABUSE_ON_RELAY}: ${error}`); + } + // Handle abusive users + const userFlaggedAsAbusive = user && + (user.isBlockedFromRelay || + user.isBlockedFromNotifications || + user.isBlockedFromEmails); + if (blockAbuseOnRelay && user && userFlaggedAsAbusive) { + // allow previously abusive users to redeem themselves for next relays + if (detectAbuseOnRelay) { + const reqIP = getIP(req); + detectAbuse(user, reqIP); // fired & forgotten + } + // Only reject relay for users explicitly blocked from relay + if (user.isBlockedFromRelay) { + return errorResponseForbidden(`Forbidden ${user.appliedRules}`); + } + } + let txProps; + if (body && + body.contractRegistryKey && + body.contractAddress && + body.senderAddress && + body.encodedABI) { + // fire and forget update handle if necessary for early anti-abuse measures + ; + (async () => { + try { + if (!user) + return; + const useProvisionalHandle = !user.handle && !user.blockchainUserId; + if (detectAbuseOnRelay && body.handle && useProvisionalHandle) { + user.handle = body.handle; + await user.save(); + const reqIP = getIP(req); + // Perform an abbreviated check here, b/c we + // won't have all the requried info on DN for a full check + detectAbuse(user, reqIP, true /* abbreviated */); + } + } + catch (e) { + req.logger.error(`Error setting provisional handle for user ${user.wallet}: ${e.message}`); + } + })(); + // send tx + let receipt; + const reqBodySHA = crypto + .createHash('sha256') + .update(JSON.stringify(req.body)) + .digest('hex'); + try { + txProps = { + contractRegistryKey: body.contractRegistryKey, + contractAddress: body.contractAddress, + nethermindContractAddress: body.nethermindContractAddress, + encodedABI: body.encodedABI, + nethermindEncodedABI: body.nethermindEncodedAbi, + senderAddress: body.signer, + gasLimit: body.gasLimit || null + }; + // When EntityManager is enabled for replica sets, throw error for URSM + // Fallback to EntityManager + if (txProps.contractRegistryKey === 'UserReplicaSetManager') { + const decodedABI = libs.AudiusABIDecoder.decodeMethod(txProps.contractRegistryKey, txProps.encodedABI); + if (decodedABI.name === 'updateReplicaSet') { + throw new Error('Cannot relay UserReplicaSetManager transactions when EntityManager is enabled'); + } + } + receipt = await txRelay.sendTransaction(req, false, // resetNonce + txProps, reqBodySHA); + } + catch (e) { + if (e.message.includes('nonce')) { + req.logger.warn('Nonce got out of sync, resetting. Original error message: ', e.message); + // this is a retry in case we get an error about the nonce being out of sync + // the last parameter is an optional bool that forces a nonce reset + receipt = await txRelay.sendTransaction(req, true, // resetNonce + txProps, reqBodySHA); + // no need to return success response here, regular code execution will continue after catch() + } + else { + // if the tx fails, store it in redis with a 24 hour expiration + await redis.setex(`failedTx:${reqBodySHA}`, 60 /* seconds */ * 60 /* minutes */ * 24 /* hours */, JSON.stringify(req.body)); + req.logger.error('Error in transaction:', e.message, reqBodySHA); + return errorResponseServerError(`Something caused the transaction to fail for payload ${reqBodySHA}, ${e.message}`); + } + } + if (user && detectAbuseOnRelay) { + const reqIP = getIP(req); + detectAbuse(user, reqIP); // fired & forgotten + } + return successResponse({ receipt: receipt }); + } + return errorResponseBadRequest('Missing one of the required fields: contractRegistryKey, contractAddress, senderAddress, encodedABI'); + })); +}; diff --git a/packages/identity-service/build/src/routes/rewards.js b/packages/identity-service/build/src/routes/rewards.js new file mode 100644 index 00000000000..f15ce0e2ea5 --- /dev/null +++ b/packages/identity-service/build/src/routes/rewards.js @@ -0,0 +1,74 @@ +"use strict"; +const express = require('express'); +const { handleResponse, successResponse, errorResponseServerError } = require('../apiHelpers'); +const config = require('../config.js'); +const { RewardsReporter } = require('../utils/rewardsReporter'); +const rewardsRouter = express.Router(); +const handleResult = async ({ status, userId, challengeId, amount, error, phase, reporter, specifier, reason }) => { + switch (status) { + case 'success': + await reporter.reportSuccess({ userId, challengeId, amount, specifier }); + break; + case 'failure': + await reporter.reportFailure({ + userId, + challengeId, + amount, + error, + phase, + specifier + }); + break; + case 'retry': + await reporter.reportRetry({ + userId, + challengeId, + amount, + error, + phase, + specifier + }); + break; + case 'rejection': + await reporter.reportAAORejection({ + userId, + challengeId, + amount, + error, + specifier, + reason + }); + break; + default: + throw new Error('Bad status code'); + } +}; +rewardsRouter.post('/attestation_result', handleResponse(async (req) => { + const { status, userId, challengeId, amount, error, phase, source, specifier, reason } = req.body; + const reporter = new RewardsReporter({ + successSlackUrl: config.get('successAudioReporterSlackUrl'), + errorSlackUrl: config.get('errorAudioReporterSlackUrl'), + source, + shouldReportAnalytics: false + }); + try { + await handleResult({ + status, + userId, + challengeId, + amount, + error, + phase, + reporter, + specifier, + reason + }); + return successResponse(); + } + catch (e) { + return errorResponseServerError(e.message); + } +})); +module.exports = function (app) { + app.use('/rewards', rewardsRouter); +}; diff --git a/packages/identity-service/build/src/routes/scores.js b/packages/identity-service/build/src/routes/scores.js new file mode 100644 index 00000000000..339a3892b71 --- /dev/null +++ b/packages/identity-service/build/src/routes/scores.js @@ -0,0 +1,68 @@ +"use strict"; +const config = require('../config'); +const { handleResponse, successResponse, errorResponseForbidden, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const models = require('../models'); +const { QueryTypes } = require('sequelize'); +const userHandleMiddleware = require('../userHandleMiddleware'); +const authMiddleware = require('../authMiddleware'); +const { verify } = require('hcaptcha'); +const HCAPTCHA_SECRET = config.get('hCaptchaSecret'); +module.exports = function (app) { + app.get('/scores', userHandleMiddleware, handleResponse(async (req) => { + if (req.headers['x-score'] !== config.get('scoreSecret')) { + return errorResponseForbidden('Not permissioned to view scores.'); + } + const handle = req.query.handle; + if (!handle) + return errorResponseBadRequest('Please provide handle'); + const captchaScores = await models.sequelize.query(`select "Users"."blockchainUserId" as "userId", "BotScores"."recaptchaScore" as "score", "BotScores"."recaptchaContext" as "context", "BotScores"."updatedAt" as "updatedAt" + from + "Users" inner join "BotScores" on "Users"."walletAddress" = "BotScores"."walletAddress" + where + "Users"."handle" = :handle`, { + replacements: { handle }, + type: QueryTypes.SELECT + }); + const cognitoFlowScores = await models.sequelize.query(`select "Users"."blockchainUserId" as "userId", "CognitoFlows"."score" as "score" + from + "Users" inner join "CognitoFlows" on "Users"."handle" = "CognitoFlows"."handle" + where + "Users"."handle" = :handle`, { + replacements: { handle }, + type: QueryTypes.SELECT + }); + return successResponse({ captchaScores, cognitoFlowScores }); + })); + app.post('/score/hcaptcha', authMiddleware, handleResponse(async (req) => { + const user = req.user; + const token = req.body.token; + if (!token) { + return errorResponseBadRequest('Please provide hCaptcha token.'); + } + try { + const { success, hostname } = await verify(HCAPTCHA_SECRET, token); + if (!success) { + return errorResponseServerError('hCaptcha Verification failed.'); + } + // save hCaptcha score for user in BotScores + await models.BotScores.create({ + walletAddress: user.walletAddress, + // hCaptcha scores represent risk scores + // the score values go from 0 to 1 + // the higher the score the worse it is + // e.g. 0 is 'safe' and 1 is 'bot' + // given we are using the free publisher tier of hCaptcha, we only get the binary success/fail and no score value + // therefore we use the score of 1 to denote that verification was ok and remain consistent with our other scores + recaptchaScore: 1, + // the recaptcha context is very important because that's how we know whether the score is from hCaptcha + recaptchaContext: 'hCaptcha', + recaptchaHostname: hostname + }); + return successResponse({}); + } + catch (err) { + console.error(err); + return errorResponseServerError(err.message); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/socialHandles.js b/packages/identity-service/build/src/routes/socialHandles.js new file mode 100644 index 00000000000..239b9bb813b --- /dev/null +++ b/packages/identity-service/build/src/routes/socialHandles.js @@ -0,0 +1,83 @@ +"use strict"; +const { handleResponse, successResponse, errorResponseBadRequest } = require('../apiHelpers'); +const authMiddleware = require('../authMiddleware'); +const models = require('../models'); +module.exports = function (app) { + app.get('/social_handles', handleResponse(async (req, res, next) => { + const { handle } = req.query; + if (!handle) + return errorResponseBadRequest('Please provide handle'); + const socialHandles = await models.SocialHandles.findOne({ + where: { handle } + }); + const twitterUser = await models.TwitterUser.findOne({ + where: { + // Twitter stores case sensitive screen names + 'twitterProfile.screen_name': handle, + verified: true + } + }); + const instagramUser = await models.InstagramUser.findOne({ + where: { + // Instagram does not store case sensitive screen names + 'profile.username': handle.toLowerCase(), + verified: true + } + }); + const tikTokUser = await models.TikTokUser.findOne({ + where: { + // TikTok does not store case sensitive screen names + 'profile.display_name': handle.toLowerCase(), + verified: true + } + }); + if (socialHandles) { + return successResponse({ + ...socialHandles.dataValues, + twitterVerified: !!twitterUser, + instagramVerified: !!instagramUser, + tikTokVerified: !!tikTokUser + }); + } + else + return successResponse(); + })); + app.post('/social_handles', authMiddleware, handleResponse(async (req, res, next) => { + let { twitterHandle, instagramHandle, tikTokHandle, website, donation } = req.body; + const handle = req.user.handle; + const socialHandles = await models.SocialHandles.findOne({ + where: { handle } + }); + // If twitterUser is verified, audiusHandle must match twitterHandle. + const twitterUser = await models.TwitterUser.findOne({ + where: { + 'twitterProfile.screen_name': twitterHandle, + verified: true + } + }); + if (twitterUser) { + twitterHandle = handle; + } + if (socialHandles) { + await socialHandles.update({ + handle, + twitterHandle, + instagramHandle, + tikTokHandle, + website, + donation + }); + } + else { + await models.SocialHandles.create({ + handle, + twitterHandle, + instagramHandle, + tikTokHandle, + website, + donation + }); + } + return successResponse(); + })); +}; diff --git a/packages/identity-service/build/src/routes/solana.js b/packages/identity-service/build/src/routes/solana.js new file mode 100644 index 00000000000..04982250bef --- /dev/null +++ b/packages/identity-service/build/src/routes/solana.js @@ -0,0 +1,100 @@ +"use strict"; +const express = require('express'); +const crypto = require('crypto'); +const { parameterizedAuthMiddleware } = require('../authMiddleware'); +const { handleResponse, successResponse, errorResponseServerError } = require('../apiHelpers'); +const { getFeePayerKeypair } = require('../solana-client'); +const { isSendInstruction, areRelayAllowedInstructions, doesUserHaveSocialProof } = require('../utils/relayHelpers'); +const { getFeatureFlag, FEATURE_FLAGS } = require('../featureFlag'); +const { PublicKey, TransactionInstruction } = require('@solana/web3.js'); +const solanaRouter = express.Router(); +// Check that an instruction has all the necessary data +const isValidInstruction = (instr) => { + if (!instr || !Array.isArray(instr.keys) || !instr.programId || !instr.data) + return false; + if (!instr.keys.every((key) => !!key.pubkey)) + return false; + return true; +}; +solanaRouter.post('/relay', parameterizedAuthMiddleware({ shouldResponseBadRequest: false }), handleResponse(async (req, res, next) => { + const redis = req.app.get('redis'); + const libs = req.app.get('audiusLibs'); + // Get configs + let optimizelyClient; + let socialProofRequiredToSend = true; + try { + optimizelyClient = req.app.get('optimizelyClient'); + socialProofRequiredToSend = getFeatureFlag(optimizelyClient, FEATURE_FLAGS.SOCIAL_PROOF_TO_SEND_AUDIO_ENABLED); + } + catch (error) { + req.logger.error(`failed to retrieve optimizely feature flag for socialProofRequiredToSend: ${error}`); + } + // Unpack instructions + let { instructions = [], skipPreflight, feePayerOverride, signatures = [], retry = true, recentBlockhash } = req.body; + // Allowed relay checks + const isRelayAllowed = await areRelayAllowedInstructions(instructions, optimizelyClient); + if (!isRelayAllowed) { + return errorResponseServerError(`Invalid relay instructions`, { + error: `Invalid relay instructions` + }); + } + // Social proof checks + if (socialProofRequiredToSend && isSendInstruction(instructions)) { + if (!req.user) { + return errorResponseServerError(`User has no auth record`, { + error: 'No auth record' + }); + } + const userHasSocialProof = await doesUserHaveSocialProof(req.user); + if (!userHasSocialProof) { + const handle = req.user.handle || ''; + return errorResponseServerError(`User ${handle} is missing social proof`, { error: 'Missing social proof' }); + } + } + const reqBodySHA = crypto + .createHash('sha256') + .update(JSON.stringify({ instructions })) + .digest('hex'); + instructions = instructions.filter(isValidInstruction).map((instr) => { + const keys = instr.keys.map((key) => ({ + pubkey: new PublicKey(key.pubkey), + isSigner: key.isSigner, + isWritable: key.isWritable + })); + return new TransactionInstruction({ + keys, + programId: new PublicKey(instr.programId), + data: Buffer.from(instr.data) + }); + }); + const transactionHandler = libs.solanaWeb3Manager.transactionHandler; + const { res: transactionSignature, error, errorCode } = await transactionHandler.handleTransaction({ + recentBlockhash, + signatures: (signatures || []).map((s) => ({ + ...s, + signature: Buffer.from(s.signature.data) + })), + instructions, + skipPreflight, + feePayerOverride, + retry + }); + if (error) { + // if the tx fails, store it in redis with a 24 hour expiration + await redis.setex(`solanaFailedTx:${reqBodySHA}`, 60 /* seconds */ * 60 /* minutes */ * 24 /* hours */, JSON.stringify(req.body)); + req.logger.error('Error in solana transaction:', error, reqBodySHA); + const errorString = `Something caused the solana transaction to fail for payload ${reqBodySHA}`; + return errorResponseServerError(errorString, { errorCode, error }); + } + return successResponse({ transactionSignature }); +})); +solanaRouter.get('/random_fee_payer', handleResponse(async () => { + const feePayerAccount = getFeePayerKeypair(false); + if (!feePayerAccount) { + return errorResponseServerError('There is no fee payer.'); + } + return successResponse({ feePayer: feePayerAccount.publicKey.toString() }); +})); +module.exports = function (app) { + app.use('/solana', solanaRouter); +}; diff --git a/packages/identity-service/build/src/routes/stripe.js b/packages/identity-service/build/src/routes/stripe.js new file mode 100644 index 00000000000..9b88219d9ba --- /dev/null +++ b/packages/identity-service/build/src/routes/stripe.js @@ -0,0 +1,54 @@ +"use strict"; +const { handleResponse, successResponse, errorResponse, errorResponseBadRequest } = require('../apiHelpers'); +const config = require('../config'); +const axios = require('axios'); +const axiosHttpAdapter = require('axios/lib/adapters/http'); +const { logger } = require('../logging'); +const { getIP } = require('../utils/antiAbuse'); +const createAuthHeader = () => { + const secretKey = config.get('stripeSecretKey'); + return { + Authorization: `Basic ${Buffer.from(secretKey + ':', 'utf-8').toString('base64')}` + }; +}; +module.exports = function (app) { + app.post('/stripe/session', handleResponse(async (req) => { + const { destinationWallet, amount, destinationCurrency } = req.body; + if (!destinationWallet || !amount || !destinationCurrency) { + return errorResponseBadRequest('Missing input param'); + } + if (destinationCurrency !== 'sol' && destinationCurrency !== 'usdc') { + return errorResponseBadRequest('Invalid destination currency: only support sol and usdc'); + } + const urlEncodedData = new URLSearchParams({ + customer_wallet_address: destinationWallet, + 'transaction_details[wallet_address]': destinationWallet, + 'transaction_details[supported_destination_networks][]': 'solana', + 'transaction_details[supported_destination_currencies][]': 'sol', + 'transaction_details[destination_network]': 'solana', + 'transaction_details[destination_currency]': destinationCurrency, + 'transaction_details[destination_exchange_amount]': amount, + customer_ip_address: getIP(req) + }); + urlEncodedData.append('transaction_details[supported_destination_currencies[]', 'usdc'); + try { + const req = { + adapter: axiosHttpAdapter, + url: 'https://api.stripe.com/v1/crypto/onramp_sessions', + method: 'POST', + headers: { + // Required form-urlencoded for the Stripe API + 'Content-Type': 'application/x-www-form-urlencoded', + ...createAuthHeader() + }, + data: urlEncodedData + }; + const response = await axios(req); + return successResponse(response.data); + } + catch (e) { + logger.error('Failed to create Stripe session:', e.response.data); + return errorResponse(e.response.status, 'Failed to create Stripe session'); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/tiktok.js b/packages/identity-service/build/src/routes/tiktok.js new file mode 100644 index 00000000000..212553ef6ba --- /dev/null +++ b/packages/identity-service/build/src/routes/tiktok.js @@ -0,0 +1,158 @@ +"use strict"; +const axios = require('axios'); +const cors = require('cors'); +const models = require('../models'); +const config = require('../config.js'); +const txRelay = require('../relay/txRelay'); +const { handleResponse, successResponse, errorResponseBadRequest } = require('../apiHelpers'); +/** + * This file contains the TikTok endpoints for oauth + * + * See: https://developers.tiktok.com/doc/login-kit-web + */ +module.exports = function (app) { + app.get('/tiktok', handleResponse(async (req, res, next) => { + const { redirectUrl } = req.query; + const csrfState = Math.random().toString(36).substring(7); + res.cookie('csrfState', csrfState, { maxAge: 600000 }); + let url = 'https://open-api.tiktok.com/platform/oauth/connect/'; + url += `?client_key=${config.get('tikTokAPIKey')}`; + url += '&scope=user.info.basic'; + url += '&response_type=code'; + url += `&redirect_uri=${redirectUrl || config.get('tikTokAuthOrigin')}`; + url += '&state=' + csrfState; + res.redirect(url); + })); + const accessTokenCorsOptions = { + credentials: true, + origin: true + }; + app.options('/tiktok/access_token', cors(accessTokenCorsOptions)); + app.post('/tiktok/access_token', cors(accessTokenCorsOptions), handleResponse(async (req, res, next) => { + const { code, state } = req.body; + const { csrfState } = req.cookies; + if (!state || !csrfState || state !== csrfState) { + return errorResponseBadRequest('Invalid state'); + } + let urlAccessToken = 'https://open-api.tiktok.com/oauth/access_token/'; + urlAccessToken += '?client_key=' + config.get('tikTokAPIKey'); + urlAccessToken += '&client_secret=' + config.get('tikTokAPISecret'); + urlAccessToken += '&code=' + code; + urlAccessToken += '&grant_type=authorization_code'; + try { + // Fetch user's accessToken + const accessTokenResponse = await axios.post(urlAccessToken); + const { data: { access_token: accessToken, error_code: errorCode, description: errorMessage } } = accessTokenResponse.data; + if (errorCode) { + return errorResponseBadRequest(`Received error from tiktok oauth: ${errorCode} ${errorMessage}`); + } + // Fetch TikTok user from the TikTok API + const userResponse = await axios.post(`https://open-api.tiktok.com/user/info/?access_token=${accessToken}`, { + fields: [ + 'open_id', + 'username', + 'display_name', + 'profile_deep_link', + 'is_verified' + ] + }); + const { data, error } = userResponse.data; + if (error.code) { + return errorResponseBadRequest(error.message); + } + const { user: tikTokUser } = data; + const existingTikTokUser = await models.TikTokUser.findOne({ + where: { + uuid: tikTokUser.open_id, + blockchainUserId: { + [models.Sequelize.Op.not]: null + } + } + }); + if (existingTikTokUser) { + return errorResponseBadRequest(`Another Audius profile has already been authenticated with TikTok user @${tikTokUser.username}!`); + } + else { + // Store the user id, and current profile for user in db + await models.TikTokUser.upsert({ + uuid: tikTokUser.open_id, + profile: tikTokUser, + verified: tikTokUser.is_verified + }); + } + return successResponse(accessTokenResponse.data); + } + catch (err) { + return errorResponseBadRequest(err); + } + })); + /** + * After the user finishes onboarding in the client app and has a blockchain userId, we need to associate + * the blockchainUserId with the tiktok profile so we can write the verified flag on chain + */ + app.post('/tiktok/associate', handleResponse(async (req, res, next) => { + const { uuid, userId, handle } = req.body; + const audiusLibsInstance = req.app.get('audiusLibs'); + try { + const tikTokObj = await models.TikTokUser.findOne({ + where: { uuid: uuid } + }); + const user = await models.User.findOne({ + where: { handle } + }); + const isUnassociated = tikTokObj && !tikTokObj.blockchainUserId; + const handlesMatch = tikTokObj && + tikTokObj.profile.username.toLowerCase() === user.handle.toLowerCase(); + // only set blockchainUserId if not already set + if (isUnassociated && handlesMatch) { + tikTokObj.blockchainUserId = userId; + // if the user is verified, write to chain, otherwise skip to next step + if (tikTokObj.verified) { + const [encodedABI, contractAddress] = await audiusLibsInstance.User.updateIsVerified(userId, config.get('userVerifierPrivateKey')); + const senderAddress = config.get('userVerifierPublicKey'); + try { + const txProps = { + contractRegistryKey: 'EntityManager', + contractAddress: contractAddress, + encodedABI: encodedABI, + senderAddress: senderAddress, + gasLimit: null + }; + await txRelay.sendTransaction(req, false, txProps, 'tikTokVerified'); + } + catch (e) { + return errorResponseBadRequest(e); + } + } + const socialHandle = await models.SocialHandles.findOne({ + where: { handle } + }); + if (socialHandle) { + socialHandle.tikTokHandle = tikTokObj.profile.username; + await socialHandle.save(); + } + else if (tikTokObj.profile && tikTokObj.profile.username) { + await models.SocialHandles.create({ + handle, + tikTokHandle: tikTokObj.profile.username + }); + } + // the final step is to save userId to db and respond to request + try { + await tikTokObj.save(); + return successResponse(); + } + catch (e) { + return errorResponseBadRequest(e); + } + } + else { + req.logger.error(`TikTok profile does not exist or userId has already been set for uuid: ${uuid}`, tikTokObj); + return errorResponseBadRequest('TikTok profile does not exist or userId has already been set'); + } + } + catch (err) { + return errorResponseBadRequest(err); + } + })); +}; diff --git a/packages/identity-service/build/src/routes/trackListens.js b/packages/identity-service/build/src/routes/trackListens.js new file mode 100644 index 00000000000..997a9d8adde --- /dev/null +++ b/packages/identity-service/build/src/routes/trackListens.js @@ -0,0 +1,599 @@ +"use strict"; +const Sequelize = require('sequelize'); +const moment = require('moment-timezone'); +const retry = require('async-retry'); +const uuidv4 = require('uuid/v4'); +const axios = require('axios'); +const { getIP } = require('../rateLimiter'); +const models = require('../models'); +const { handleResponse, successResponse, errorResponseBadRequest, errorResponseServerError } = require('../apiHelpers'); +const { logger } = require('../logging'); +const authMiddleware = require('../authMiddleware'); +const { createTrackListenInstructions, getFeePayerKeypair } = require('../solana-client'); +const config = require('../config.js'); +function trimToHour(date) { + date.setMinutes(0); + date.setSeconds(0); + date.setUTCMilliseconds(0); + return date; +} +const oneDayInMs = 24 * 60 * 60 * 1000; +const oneWeekInMs = oneDayInMs * 7; +const oneMonthInMs = oneDayInMs * 30; +const oneYearInMs = oneMonthInMs * 12; +// Limit / offset related constants +const defaultLimit = 100; +const minLimit = 1; +const maxLimit = 500; +const defaultOffset = 0; +const minOffset = 0; +// Duration for listen tracking redis keys prior to expiry is 1 week (in seconds) +const redisTxTrackingExpirySeconds = oneWeekInMs / 1000; +const getPaginationVars = (limit, offset) => { + if (!limit) + limit = defaultLimit; + if (!offset) + offset = defaultOffset; + const boundedLimit = Math.min(Math.max(limit, minLimit), maxLimit); + const boundedOffset = Math.max(offset, minOffset); + return { limit: boundedLimit, offset: boundedOffset }; +}; +const parseTimeframe = (inputTime) => { + switch (inputTime) { + case 'day': + case 'week': + case 'month': + case 'year': + case 'millennium': + break; + default: + inputTime = undefined; + } + // Allow default empty value + if (inputTime === undefined) { + inputTime = 'millennium'; + } + return inputTime; +}; +const getTrackListens = async (idList, timeFrame = undefined, startTime = undefined, endTime = undefined, limit = undefined, offset = undefined) => { + if (idList !== undefined && !Array.isArray(idList)) { + return errorResponseBadRequest('Invalid id list provided. Please provide an array of track IDs'); + } + let boundariesRequested = false; + try { + if (startTime !== undefined && endTime !== undefined) { + startTime = Date.parse(startTime); + endTime = Date.parse(endTime); + boundariesRequested = true; + } + } + catch (e) { + logger.error(e); + } + // Allow default empty value + if (timeFrame === undefined) { + timeFrame = 'millennium'; + } + const dbQuery = { + attributes: [ + [models.Sequelize.col('trackId'), 'trackId'], + [ + models.Sequelize.fn('date_trunc', timeFrame, models.Sequelize.col('hour')), + 'date' + ], + [models.Sequelize.fn('sum', models.Sequelize.col('listens')), 'listens'] + ], + group: ['trackId', 'date'], + order: [[models.Sequelize.col('listens'), 'DESC']], + where: {} + }; + if (idList && idList.length > 0) { + dbQuery.where.trackId = { [models.Sequelize.Op.in]: idList }; + } + if (limit) { + dbQuery.limit = limit; + } + if (offset) { + dbQuery.offset = offset; + } + if (boundariesRequested) { + dbQuery.where.hour = { + [models.Sequelize.Op.gte]: startTime, + [models.Sequelize.Op.lte]: endTime + }; + } + const listenCounts = await models.TrackListenCount.findAll(dbQuery); + const output = {}; + for (let i = 0; i < listenCounts.length; i++) { + const currentEntry = listenCounts[i]; + const values = currentEntry.dataValues; + const date = values.date.toISOString(); + const listens = parseInt(values.listens); + currentEntry.dataValues.listens = listens; + const trackId = values.trackId; + if (!output.hasOwnProperty(date)) { + output[date] = {}; + output[date].utcMilliseconds = values.date.getTime(); + output[date].totalListens = 0; + output[date].trackIds = []; + output[date].listenCounts = []; + } + output[date].totalListens += listens; + if (!output[date].trackIds.includes(trackId)) { + output[date].trackIds.push(trackId); + } + output[date].listenCounts.push(currentEntry); + output[date].timeFrame = timeFrame; + } + return output; +}; +const getTrendingTracks = async (idList, timeFrame, limit, offset) => { + if (idList !== undefined && !Array.isArray(idList)) { + return errorResponseBadRequest('Invalid id list provided. Please provide an array of track IDs'); + } + const dbQuery = { + attributes: [ + 'trackId', + [models.Sequelize.fn('sum', models.Sequelize.col('listens')), 'listens'] + ], + group: ['trackId'], + order: [ + [models.Sequelize.col('listens'), 'DESC'], + [models.Sequelize.col('trackId'), 'DESC'] + ], + where: {} + }; + // If id list present, add filter + if (idList) { + dbQuery.where.trackId = { [models.Sequelize.Op.in]: idList }; + } + const currentHour = trimToHour(new Date()); + switch (timeFrame) { + case 'day': { + const oneDayBefore = new Date(currentHour.getTime() - oneDayInMs); + dbQuery.where.hour = { [models.Sequelize.Op.gte]: oneDayBefore }; + break; + } + case 'week': { + const oneWeekBefore = new Date(currentHour.getTime() - oneWeekInMs); + dbQuery.where.hour = { [models.Sequelize.Op.gte]: oneWeekBefore }; + break; + } + case 'month': { + const oneMonthBefore = new Date(currentHour.getTime() - oneMonthInMs); + dbQuery.where.hour = { [models.Sequelize.Op.gte]: oneMonthBefore }; + break; + } + case 'year': { + const oneYearBefore = new Date(currentHour.getTime() - oneYearInMs); + dbQuery.where.hour = { [models.Sequelize.Op.gte]: oneYearBefore }; + break; + } + case 'millennium': { + dbQuery.where.hour = { [models.Sequelize.Op.gte]: new Date(0) }; + break; + } + case undefined: + break; + default: + return errorResponseBadRequest('Invalid time parameter provided, use day/week/month/year or no parameter'); + } + if (limit) { + dbQuery.limit = limit; + } + if (offset) { + dbQuery.offset = offset; + } + const listenCounts = await models.TrackListenCount.findAll(dbQuery); + const parsedListenCounts = []; + const seenTrackIds = []; + listenCounts.forEach((elem) => { + parsedListenCounts.push({ + trackId: elem.trackId, + listens: parseInt(elem.listens) + }); + seenTrackIds.push(elem.trackId); + }); + return parsedListenCounts; +}; +/** + * Generate the redis keys required for tracking listen submission vs success + * @param {string} hour formatted as such - 2022-01-25T21:00:00.000Z + */ +const getTrackingListenKeys = (hour) => { + return { + submission: `listens-tx-submission::${hour}`, + success: `listens-tx-success::${hour}` + }; +}; +/** + * Initialize a key that expires after a certain number of seconds + * @param {Object} redis connection + * @param {String} key that will be initialized + * @param {number} seconds number of seconds after which the key will expire + */ +const initializeExpiringRedisKey = async (redis, key, expiry) => { + const value = await redis.get(key); + if (!value) { + await redis.set(key, 0, 'ex', expiry); + } +}; +const TRACKING_LISTEN_SUBMISSION_KEY = 'listens-tx-submission-ts'; +const TRACKING_LISTEN_SUCCESS_KEY = 'listens-tx-success-ts'; +module.exports = function (app) { + app.get('/tracks/listen/solana/status', handleResponse(async (req, res) => { + const redis = req.app.get('redis'); + const results = await redis.keys('listens-tx-*'); + // Expected percent success + const { percent = 0.9, cutoffMinutes = 60 } = req.query; + const hourlyResponseData = {}; + // Example key format = listens-tx-success::2022-01-25T21:00:00.000Z + for (const entry of results) { + const split = entry.split('::'); + if (split.length >= 2) { + const hourSuffix = split[1]; + const trackingRedisKeys = getTrackingListenKeys(hourSuffix); + if (!hourlyResponseData.hasOwnProperty(hourSuffix)) { + hourlyResponseData[hourSuffix] = { + submission: Number(await redis.get(trackingRedisKeys.submission)), + success: Number(await redis.get(trackingRedisKeys.success)), + time: new Date(hourSuffix) + }; + } + } + } + // Clean up time series entries that are greater than 1 week old + const oldestExpireMillis = Date.now() - redisTxTrackingExpirySeconds * 1000; + await redis.zremrangebyscore(TRACKING_LISTEN_SUBMISSION_KEY, 0, oldestExpireMillis); + await redis.zremrangebyscore(TRACKING_LISTEN_SUCCESS_KEY, 0, oldestExpireMillis); + const totalSuccessCount = await redis.zcount(TRACKING_LISTEN_SUCCESS_KEY, 0, Number.MAX_SAFE_INTEGER); + const totalSubmissionCount = await redis.zcount(TRACKING_LISTEN_SUBMISSION_KEY, 0, Number.MAX_SAFE_INTEGER); + const totalPercentSuccess = totalSubmissionCount === 0 + ? 1 + : totalSuccessCount / totalSubmissionCount; + // Sort response in descending time order + const sortedHourlyData = Object.keys(hourlyResponseData) + .sort((a, b) => new Date(b) - new Date(a)) + .map((key) => hourlyResponseData[key]); + // Calculate success of submissions before the cutoff + const now = Date.now(); + const nowPlusEntropy = now + 9; // Account for the fact that each date has a random UUID appended to it + const nowMinusCutoff = now - cutoffMinutes * 60 * 1000; + const recentSuccessCount = await redis.zcount(TRACKING_LISTEN_SUCCESS_KEY, nowMinusCutoff, nowPlusEntropy); + const recentSubmissionCount = await redis.zcount(TRACKING_LISTEN_SUBMISSION_KEY, nowMinusCutoff, nowPlusEntropy); + const recentSuccessPercent = recentSubmissionCount === 0 + ? 1 + : recentSuccessCount / recentSubmissionCount; + const recentInfo = { + recentSubmissionCount, + recentSuccessCount, + recentSuccessPercent, + cutoffTimestamp: trimToHour(new Date(nowMinusCutoff)).toISOString() + }; + const resp = { + totalPercentSuccess, + totalSuccessCount, + totalSubmissionCount, + sortedHourlyData, + recentInfo + }; + if (recentSuccessPercent < percent) { + return errorResponseBadRequest(resp); + } + return successResponse(resp); + })); + app.post('/tracks/:id/listen', handleResponse(async (req, res) => { + const libs = req.app.get('audiusLibs'); + const connection = libs.solanaWeb3Manager.connection; + const solanaWeb3 = libs.solanaWeb3Manager.solanaWeb3; + const redis = req.app.get('redis'); + const trackId = parseInt(req.params.id); + const userId = req.body.userId; + if (!userId || !trackId) { + return errorResponseBadRequest('Must include user id and valid track id'); + } + const timeout = req.body.timeout || 60000; + const currentHour = trimToHour(new Date()); + // Dedicated listen flow + const suffix = currentHour.toISOString(); + const entropy = uuidv4(); + const { ip, isWhitelisted } = getIP(req); + req.logger.info(`TrackListen userId=${userId} ip=${ip} isWhitelisted=${isWhitelisted}`); + // TODO uncomment this when we get a reliable client IP + // if (!isWhitelisted) { + // // skip any client requests since + // // content nodes also log a listen + // return successResponse() + // } + // Example key format = listens-tx-success::2022-01-25T21:00:00.000Z + const trackingRedisKeys = getTrackingListenKeys(suffix); + await initializeExpiringRedisKey(redis, trackingRedisKeys.submission, redisTxTrackingExpirySeconds); + await initializeExpiringRedisKey(redis, trackingRedisKeys.success, redisTxTrackingExpirySeconds); + req.logger.info(`TrackListen tx submission, forwardedIP=${ip} trackId=${trackId} userId=${userId}, ${JSON.stringify(trackingRedisKeys)}`); + await redis.incr(trackingRedisKeys.submission); + await redis.zadd(TRACKING_LISTEN_SUBMISSION_KEY, Date.now(), Date.now() + entropy); + let location; + try { + const url = `https://api.ipdata.co/${ip}?api-key=${config.get('ipdataAPIKey')}`; + const locationResponse = (await axios.get(url)).data; + location = { + city: locationResponse.city, + region: locationResponse.region, + country: locationResponse.country_name + }; + } + catch (e) { + req.logger.error(`TrackListen location fetch failed: ${e}, trackId=${trackId} userId=${userId}, ${JSON.stringify(trackingRedisKeys)}`); + location = {}; + } + try { + const instructions = await createTrackListenInstructions({ + privateKey: config.get('solanaSignerPrivateKey'), + userId: userId.toString(), + trackId: trackId.toString(), + source: 'relay', + location, + connection + }); + const feePayerAccount = getFeePayerKeypair(false); + req.logger.info(`TrackListen tx submission, trackId=${trackId} userId=${userId} - sendRawTransaction`); + const transactionHandler = libs.solanaWeb3Manager.transactionHandler; + const { res: solTxSignature, error } = await transactionHandler.handleTransaction({ + instructions, + skipPreflight: false, + feePayerOverride: feePayerAccount, + retry: true + }); + if (error) { + return errorResponseServerError(`TrackListens tx error, trackId=${trackId} userId=${userId} : ${error}`); + } + req.logger.info(`TrackListen tx confirmed, ${solTxSignature} userId=${userId}, trackId=${trackId} `); + // Increment success tracker + await redis.incr(trackingRedisKeys.success); + await redis.zadd(TRACKING_LISTEN_SUCCESS_KEY, Date.now(), Date.now() + entropy); + return successResponse({ + solTxSignature + }); + } + catch (e) { + // This should never happen + return errorResponseServerError(`TrackListens tx error, trackId=${trackId} userId=${userId} : ${e}`); + } + })); + /* + * Return listen history for a given user + * tracks/history/ + * - tracks w/ recorded listen event sorted by date listened + * + * GET query parameters (optional): + * userId (int) - userId of the requester + * limit (int) - limits number of results w/ a max of 100 + * offset (int) - offset results + */ + app.get('/tracks/history', handleResponse(async (req, res) => { + const userId = parseInt(req.query.userId); + const limit = isNaN(req.query.limit) + ? 100 + : Math.min(parseInt(req.query.limit), 100); + const offset = isNaN(req.query.offset) ? 0 : parseInt(req.query.offset); + if (!userId) { + return errorResponseBadRequest('Must include user id'); + } + const trackListens = await models.UserTrackListen.findAll({ + where: { userId }, + order: [['updatedAt', 'DESC']], + attributes: ['trackId', 'updatedAt'], + limit, + offset + }); + return successResponse({ + tracks: trackListens.map((track) => ({ + trackId: track.trackId, + listenDate: track.updatedAt + })) + }); + })); + /* + * Return track listen history grouped by a specific time frame + * tracks/listens/ + * - all tracks, sorted by play count + * + * tracks/listens/