diff --git a/packages/commonwealth/package.json b/packages/commonwealth/package.json index a07fdbb6901..fe720fb1b2e 100644 --- a/packages/commonwealth/package.json +++ b/packages/commonwealth/package.json @@ -21,6 +21,7 @@ "dump-db-limit": "yarn run dump-db && psql $(heroku config:get CW_READ_DB -a commonwealth-beta) -a -f limited_dump.sql", "dump-db-local": "pg_dump -U commonwealth --verbose --no-privileges --no-owner -f local_save.dump", "e2e-start-server": "ETH_RPC=e2e-test yarn start", + "emit-notification": "ts-node --project tsconfig.json server/scripts/emitTestNotification.ts", "format": "prettier --ignore-path ../../.prettierignore --config ../../.prettierrc.json --write .", "gen-e2e": "npx playwright codegen", "heroku-postbuild": "NODE_OPTIONS=--max-old-space-size=$(../../scripts/get-max-old-space-size.sh) webpack --config webpack/webpack.prod.config.js --progress && yarn build-consumer", @@ -264,10 +265,21 @@ "velocity": "^0.7.2", "vm-browserify": "^1.1.2", "ws": "^7.4.6", + "yargs": "^17.7.2", "zustand": "^4.3.8" }, "devDependencies": { "@aave/aave-token": "^1.0.4", + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-class-properties": "^7.1.0", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.16.7", + "@babel/polyfill": "^7.2.5", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.5", + "@babel/register": "^7.4.0", "@istanbuljs/nyc-config-typescript": "^0.1.3", "@openzeppelin/contracts": "^2.4.0", "@openzeppelin/contracts-governance": "npm:@openzeppelin/contracts@^4.3.2", @@ -289,16 +301,7 @@ "@types/pg-format": "^1.0.2", "@types/rascal": "^10.0.4", "@types/react-beautiful-dnd": "^13.1.3", - "@babel/core": "^7.2.2", - "@babel/plugin-proposal-async-generator-functions": "^7.2.0", - "@babel/plugin-proposal-class-properties": "^7.1.0", - "@babel/plugin-syntax-dynamic-import": "^7.0.0", - "@babel/plugin-transform-react-jsx": "^7.16.7", - "@babel/polyfill": "^7.2.5", - "@babel/preset-env": "^7.20.2", - "@babel/preset-react": "^7.18.6", - "@babel/preset-typescript": "^7.21.5", - "@babel/register": "^7.4.0", + "@types/yargs": "^17.0.24", "babel-eslint": "^10.0.3", "babel-loader": "^8.0.4", "babel-plugin-transform-scss": "^1.2.0", @@ -306,43 +309,43 @@ "chai-as-promised": "^7.1.1", "chai-http": "^4.3.0", "chromatic": "^6.17.4", + "copy-webpack-plugin": "^4.6.0", "css-loader": "^2.1.0", + "css-minimizer-webpack-plugin": "^4.2.2", "esbuild-loader": "^2.16.0", - "fast-sass-loader": "^2.0.1", - "file-loader": "^6.2.0", - "style-loader": "^0.23.1", - "ignore-loader": "^0.1.2", "eslint-import-resolver-webpack": "^0.12.1", "eslint-plugin-react-hooks": "^4.6.0", "faker": "^4.1.0", + "fast-sass-loader": "^2.0.1", + "file-loader": "^6.2.0", "ganache-cli": "^6.9.1", + "html-webpack-inject-attributes-plugin": "^1.0.6", + "html-webpack-plugin": "^5.5.0", + "ignore-loader": "^0.1.2", "mocha": "^6.2.2", "mock-express-request": "^0.2.2", "nyc": "^15.1.0", + "optimize-css-assets-webpack-plugin": "^5.0.3", "playwright-test": "^8.2.0", "process": "^0.11.10", "sinon": "^15.0.4", "source-map-support": "^0.5.21", "storybook": "7.0.9", + "style-loader": "^0.23.1", "sync-request": "^6.1.0", "ts-mocha": "^6.0.0", "ts-node-dev": "^2.0.0", "tslint": "^5.13.0", - "webpack-dev-server": "^3.1.14", - "copy-webpack-plugin": "^4.6.0", - "css-minimizer-webpack-plugin": "^4.2.2", - "html-webpack-inject-attributes-plugin": "^1.0.6", - "html-webpack-plugin": "^5.5.0", - "optimize-css-assets-webpack-plugin": "^5.0.3", "webpack": "^5.75.0", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.10.0", "webpack-deduplication-plugin": "^0.0.8", "webpack-dev-middleware": "^5.3.3", + "webpack-dev-server": "^3.1.14", "webpack-hot-middleware": "^2.25.2", "webpack-merge": "^5.8.0" }, "optionalDependencies": { "esbuild-darwin-64": "^0.15.12" } -} \ No newline at end of file +} diff --git a/packages/commonwealth/server/scripts/emitTestNotification.ts b/packages/commonwealth/server/scripts/emitTestNotification.ts new file mode 100644 index 00000000000..d4b2261e44f --- /dev/null +++ b/packages/commonwealth/server/scripts/emitTestNotification.ts @@ -0,0 +1,328 @@ +import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs'; +import models from '../database'; +import { NotificationCategories } from 'common-common/src/types'; +import Sequelize, { Transaction } from 'sequelize'; +import { NotificationInstance } from '../models/notification'; +import { SubscriptionInstance } from '../models/subscription'; +import { factory, formatFilename } from 'common-common/src/logging'; +import emitNotifications from '../util/emitNotifications'; + +const log = factory.getLogger(formatFilename(__filename)); + +enum SupportedNotificationChains { + dydx = 'dydx', + osmosis = 'osmosis', +} + +enum SupportedNotificationSpaces { + stgdao = 'stgdao.eth', +} + +const randomInt = () => Math.floor(Math.random() * (2 ** 31 - 1)) + 1; +const propCreateBlock = randomInt(); +const ceNotifications = { + [SupportedNotificationChains.dydx]: { + queued: 0, + id: randomInt(), + block_number: propCreateBlock, + event_data: { + id: randomInt(), + kind: 'proposal-created', + values: ['0'], + targets: ['0xE710CEd57456D3A16152c32835B5FB4E72D9eA5b'], + endBlock: propCreateBlock + 33_000, + executor: '0x64c7d40c07EFAbec2AafdC243bF59eaF2195c6dc', + ipfsHash: + '0x5aca381042cb641c1000126d5a183c38b17492eb60a86910973d0c3f1e867f43', + proposer: '0xB933AEe47C438f22DE0747D57fc239FE37878Dd1', + strategy: '0x90Dfd35F4a0BB2d30CDf66508085e33C353475D9', + calldatas: ['randomcalldatas'], + signatures: ['transfer(address,address,uint256)'], + startBlock: propCreateBlock + 7_000, + }, + network: 'aave', + chain: 'dydx', + updated_at: '2023-06-19T11:50:52.308Z', + created_at: '2023-06-19T11:50:52.262Z', + }, +}; + +const startTime = randomInt(); +const randomString = Array.from( + { length: 64 }, + () => 'abcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 36)] +).join(''); +const snapshotNotifications = { + [SupportedNotificationSpaces.stgdao]: { + eventType: 'proposal/created', + space: SupportedNotificationSpaces.stgdao, + id: `0x${randomString}`, + title: 'Test Snapshot Proposal Title', + body: 'Test snapshot proposal body', + choices: ['Yes', 'No', 'Abstain'], + start: startTime, + expire: startTime + 450_000, + }, +}; + +async function getExistingNotifications( + transaction: Transaction, + chainId?: string, + snapshotId?: string +): Promise { + let existingNotifications: NotificationInstance[]; + if (chainId) { + log.info(`Replacing a real notification for chain: ${chainId}`); + existingNotifications = await models.Notification.findAll({ + where: { + category_id: NotificationCategories.ChainEvent, + chain_id: chainId, + }, + order: Sequelize.literal(`RANDOM()`), + limit: 1, + transaction, + }); + } else { + log.info(`Replacing a real notification for snapshot space: ${snapshotId}`); + existingNotifications = await models.Notification.findAll({ + where: { + category_id: NotificationCategories.SnapshotProposal, + [Sequelize.Op.and]: [ + Sequelize.literal( + `notification_data::jsonb ->> 'space' = '${snapshotId}'` + ), + ], + }, + order: Sequelize.literal('RANDOM()'), + limit: 1, + transaction, + }); + } + + return existingNotifications; +} + +async function setupNotification( + transaction: Transaction, + mockNotification: boolean, + chainId?: string, + snapshotId?: string +): Promise { + if (!chainId && !snapshotId) { + throw new Error('Must provide either a chainId or a snapshotId'); + } + + let existingNotifications: NotificationInstance[]; + if (mockNotification && chainId) { + // handles the case where a mock notification is emitted multiple times + // this is necessary because chain-event notifications have a unique constraint on the id + const existingCeMockNotif = await models.Notification.findAll({ + where: { + category_id: NotificationCategories.ChainEvent, + chain_id: chainId, + chain_event_id: ceNotifications[chainId].id, + [Sequelize.Op.and]: [ + Sequelize.literal( + `notification_data::jsonb -> 'event_data' ->> 'id' = '${ceNotifications[chainId].event_data.id}'` + ), + ], + }, + transaction, + }); + + if (existingCeMockNotif.length > 0) + existingNotifications = existingCeMockNotif; + // if the mock does not already exist then we can just return the mock data, so it is emitted as a brand new notif + else return JSON.stringify(ceNotifications[chainId]); + } else if (mockNotification && snapshotId) { + return JSON.stringify(snapshotNotifications[snapshotId]); + } + + if (!existingNotifications) { + existingNotifications = await getExistingNotifications( + transaction, + chainId, + snapshotId + ); + } + + if (existingNotifications.length === 0) { + let msg: string; + if (chainId) { + msg = + `No existing chain-event notification found for ${chainId}. ` + + `Please use a chain id that has existing notifications e.g. dydx`; + } else { + msg = + `No existing snapshot-proposal notification found for ${snapshotId}. ` + + `Please use a snapshot id that has existing notifications e.g. stgdao.eth`; + } + throw new Error(msg); + } + + const existingNotif = existingNotifications[0]; + log.info(`Replacing existing notification with id ${existingNotif.id}`); + + await models.NotificationsRead.destroy({ + where: { + notification_id: existingNotif.id, + }, + transaction, + }); + await existingNotif.destroy({ transaction }); + log.info(`Deleted the existing notification and notifications read.`); + + const newNotifData = existingNotif.toJSON(); + + // const result = await models.Notification.create(newNotifData, { transaction }); + // log.info(`Created a new (replicated) notification with id ${result.id}`); + return newNotifData.notification_data; +} + +async function main() { + const argv = await yargs(hideBin(process.argv)).options({ + chain_id: { + alias: 'c', + type: 'string', + demandOption: false, + conflicts: 'snapshot_id', + description: + 'Name of chain to generate a test chain-event notification for', + }, + snapshot_id: { + alias: 's', + type: 'string', + demandOption: false, + conflicts: 'chain', + description: + 'Name of the snapshot space to generate a test snapshot-proposal notification for', + }, + wallet_address: { + alias: 'w', + type: 'string', + demandOption: false, + conflicts: 'user_id', + description: + 'Wallet address of the user to generate a test notification for', + }, + user_id: { + alias: 'u', + type: 'number', + demandOption: false, + conflicts: 'wallet_address', + description: 'User id of the user to generate a test notification for', + }, + mock_notification: { + alias: 'm', + type: 'boolean', + demandOption: true, + default: false, + description: + 'Whether to create a mock notification or use a real existing one. ' + + 'A mock notification will not link to a real chain-event or snapshot-proposal.', + }, + }).argv; + + const transaction = await models.sequelize.transaction(); + let notifData: string; + try { + let userId: number; + if (argv.user_id) { + userId = argv.user_id; + } else { + const address = await models.Address.findOne({ + where: { + address: argv.wallet_address, + }, + transaction, + }); + + if (!address) { + log.error( + 'Wallet address not found. ' + + 'Make sure the given address is an address you have used to login before.' + ); + process.exit(1); + } else { + userId = address.user_id; + } + } + + let result: [SubscriptionInstance, boolean]; + if (argv.chain_id) { + result = await models.Subscription.findOrCreate({ + where: { + subscriber_id: userId, + chain_id: argv.chain_id, + category_id: NotificationCategories.ChainEvent, + }, + transaction, + }); + } else { + result = await models.Subscription.findOrCreate({ + where: { + subscriber_id: userId, + snapshot_id: argv.snapshot_id, + category_id: NotificationCategories.SnapshotProposal, + }, + transaction, + }); + } + log.info(`Found or created a subscription with id: ${result[0].id}`); + + notifData = await setupNotification( + transaction, + argv.mock_notification, + argv.chain_id, + argv.snapshot_id + ); + + await transaction.commit(); + } catch (e) { + await transaction.rollback(); + throw e; + } + + if (!notifData) { + throw new Error('No notification data found'); + } + + await emitNotifications(models, { + categoryId: argv.chain_id + ? NotificationCategories.ChainEvent + : NotificationCategories.SnapshotProposal, + data: JSON.parse(notifData), + }); + + log.info('Notification successfully emitted'); +} + +/** + * In order to execute this script on Frack, Frick, Beta, or any Heroku environment you must run + * the yarn script (yarn emit-notification) on a Heroku one-off dyno. + * To run a one-off dyno use `heroku run bash -a [app-name]`. + * + * If the mock (`-m`) option is used multiple times in succession, the same notification data will be re-emitted. + * This may make it appear like no new notification has been created but closer inspection of the notification ID + * will make it clear that a new notification was created with the same data. Additionally, emitting a non-mocked + * notification means the script picks a random notification (with a matching chain ID or snapshot space). This means + * if a mock notification was created first, we cannot guarantee that the non-mocked notification links to a real + * chain event. Therefore, the default is to emit a non-mocked notification and a mocked notification should only be + * used in rare circumstances such as in local testing when implementing a new chain event or snapshot notification + * type. + * + * Example usage: `yarn emit-notification -c dydx -w [your-wallet-address]`. This finds a random old dydx + * chain-event notification and re-emits it as if it were a brand new notification. Since it replaces an old + * (but real) notification, it links to a real proposal. + */ +if (require.main === module) { + main() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.log('Failed to emit a notification:', err); + process.exit(1); + }); +} diff --git a/wiki/Package-Scripts.md b/wiki/Package-Scripts.md index d0c405fc35c..3338d64e448 100644 --- a/wiki/Package-Scripts.md +++ b/wiki/Package-Scripts.md @@ -379,6 +379,14 @@ Description: Starts the app server with the ETH_RPC env variable set to “e2e-t Contributor: Kurtis Assad +## emit-notification + +Definition: `ts-node --project tsconfig.json server/scripts/emitTestNotification.ts` + +Description: Emits a chain-event or snapshot notification. Run `yarn emit-notification --help` to see options. + +Contributor: Timothee Legros + ## gen-e2e Definition: `npx playwright codegen` @@ -592,4 +600,4 @@ Description: Runs `yarn start` and `yarn start-consumer` (i.e., the main app ser Definition: `ts-node --project ./tsconfig.consumer.json server/CommonwealthConsumer/CommonwealthConsumer.ts run-as-script` -Description: Runs `CommonwealthConsumer.ts` script, which consumes & processes RabbitMQ messages from external apps and services. See script file for more complete documentation. \ No newline at end of file +Description: Runs `CommonwealthConsumer.ts` script, which consumes & processes RabbitMQ messages from external apps and services. See script file for more complete documentation. diff --git a/yarn.lock b/yarn.lock index c0340ffae08..1949920f7a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7001,6 +7001,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.24": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^17.0.8": version "17.0.13" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" @@ -27242,6 +27249,19 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"