diff --git a/.env.example b/.env.example index eb697730b..a2e94e1bd 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ FRONTEND_HOST=http://localhost:8082 API_HOST="http://localhost:3000" PAYPAL_HOST="https://api.sandbox.paypal.com" +SLACK_WEBHOOK_URL= FACEBOOK_ID=123 FACEBOOK_SECRET=123 diff --git a/.gitignore b/.gitignore index f9424c4c5..65241b461 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ Thumbs.db # Configuration of database and password/tokens/idSercure .env docker-compose.override.yml +.claude/ diff --git a/frontend/src/components/design-library/atoms/buttons/issue-actions-by-role/issue-actions-by-role.tsx b/frontend/src/components/design-library/atoms/buttons/issue-actions-by-role/issue-actions-by-role.tsx index ede07d5b3..3e6e7a951 100644 --- a/frontend/src/components/design-library/atoms/buttons/issue-actions-by-role/issue-actions-by-role.tsx +++ b/frontend/src/components/design-library/atoms/buttons/issue-actions-by-role/issue-actions-by-role.tsx @@ -33,6 +33,8 @@ interface IssueActionsProps { fetchTask: (taskId: number) => void syncTask: (taskId: number) => void taskSolutionCompleted: boolean + validateCoupon?: (code: string, originalOrderPrice: number) => void + couponStoreState?: any } const IssueActionsByRole = ({ @@ -61,7 +63,9 @@ const IssueActionsByRole = ({ listWallets, wallets, fetchTask, - syncTask + syncTask, + validateCoupon, + couponStoreState }: IssueActionsProps) => { const { data } = issue @@ -98,6 +102,8 @@ const IssueActionsByRole = ({ fetchTask={fetchTask} syncTask={syncTask} price={data?.price || 0} + validateCoupon={validateCoupon} + couponStoreState={couponStoreState} /> ) }, diff --git a/frontend/src/components/design-library/atoms/inputs/fields/user-role-field/user-role-field.tsx b/frontend/src/components/design-library/atoms/inputs/fields/user-role-field/user-role-field.tsx index 9a9a2ede1..97aee7b0b 100644 --- a/frontend/src/components/design-library/atoms/inputs/fields/user-role-field/user-role-field.tsx +++ b/frontend/src/components/design-library/atoms/inputs/fields/user-role-field/user-role-field.tsx @@ -5,15 +5,17 @@ import Checkboxes from '../../checkboxes/checkboxes' import { FormattedMessage } from 'react-intl' const UserRoleField = ({ roles, onChange }) => { - const { data, completed } = roles + const { data = [], completed = false } = roles || {} const checkBoxes = useMemo( () => - data.map((role) => ({ - label: role.label, - name: role.name, - value: role.id - })), + Array.isArray(data) + ? data.map((role) => ({ + label: role.label, + name: role.name, + value: role.id + })) + : [], [data] ) diff --git a/frontend/src/components/design-library/molecules/drawers/issue-payment-drawer/issue-payment-drawer.tsx b/frontend/src/components/design-library/molecules/drawers/issue-payment-drawer/issue-payment-drawer.tsx index 4adb39d34..a8c5cb017 100644 --- a/frontend/src/components/design-library/molecules/drawers/issue-payment-drawer/issue-payment-drawer.tsx +++ b/frontend/src/components/design-library/molecules/drawers/issue-payment-drawer/issue-payment-drawer.tsx @@ -45,7 +45,9 @@ function IssuePaymentDrawer({ listWallets, wallets, fetchTask, - syncTask + syncTask, + validateCoupon, + couponStoreState }: any) { const intl = useIntl() @@ -152,6 +154,8 @@ function IssuePaymentDrawer({ task={task?.data} plan={plan} onClose={onClose} + validateCoupon={validateCoupon} + couponStoreState={couponStoreState} /> ) }, diff --git a/frontend/src/components/design-library/molecules/sections/issue-sidebar/issue-sidebar.tsx b/frontend/src/components/design-library/molecules/sections/issue-sidebar/issue-sidebar.tsx index d928960a2..2ae92eba6 100644 --- a/frontend/src/components/design-library/molecules/sections/issue-sidebar/issue-sidebar.tsx +++ b/frontend/src/components/design-library/molecules/sections/issue-sidebar/issue-sidebar.tsx @@ -51,6 +51,8 @@ const IssueSidebar = ({ wallets, fetchTask, syncTask, + validateCoupon, + couponStoreState, fetchCustomer }) => { const intl = useIntl() @@ -294,6 +296,8 @@ const IssueSidebar = ({ wallets={wallets} fetchTask={fetchTask} syncTask={syncTask} + validateCoupon={validateCoupon} + couponStoreState={couponStoreState} /> { } if (!stripe || !elements) { - props.addNotification('payment.message.error', 'Stripe not initialized') + if (props.addNotification && typeof props.addNotification === 'function') { + props.addNotification('payment.message.error', 'Stripe not initialized') + } else { + console.error('Stripe not initialized and addNotification is not available') + } setCheckoutFormState((prev) => ({ ...prev, paymentRequested: false })) return } @@ -95,7 +99,11 @@ const CheckoutForm = (props) => { } } catch (e) { console.log('Error creating token or processing payment:', e) - props.addNotification('payment.message.error', e.message || 'Error processing payment') + if (props.addNotification && typeof props.addNotification === 'function') { + props.addNotification('payment.message.error', e.message || 'Error processing payment') + } else { + console.error('Payment error:', e.message || 'Error processing payment') + } setCheckoutFormState((prev) => ({ ...prev, paymentRequested: false })) } } @@ -148,7 +156,11 @@ const CheckoutForm = (props) => { } const applyCoupon = () => { - props.validateCoupon(couponState.coupon, price) + if (props.validateCoupon && typeof props.validateCoupon === 'function') { + props.validateCoupon(couponState.coupon, price) + } else { + console.error('validateCoupon is not available') + } } const logged = checkoutFormState.authenticated diff --git a/frontend/src/components/design-library/organisms/layouts/page-layouts/issue-page-layout/issue-page-layout.tsx b/frontend/src/components/design-library/organisms/layouts/page-layouts/issue-page-layout/issue-page-layout.tsx index 46b463e50..a3d1bc4f8 100644 --- a/frontend/src/components/design-library/organisms/layouts/page-layouts/issue-page-layout/issue-page-layout.tsx +++ b/frontend/src/components/design-library/organisms/layouts/page-layouts/issue-page-layout/issue-page-layout.tsx @@ -32,7 +32,9 @@ const IssuePage = ({ listWallets, wallets, fetchTask, - syncTask + syncTask, + validateCoupon, + couponStoreState }) => { return ( @@ -74,6 +76,8 @@ const IssuePage = ({ fetchTask={fetchTask} syncTask={syncTask} fetchCustomer={fetchCustomer} + validateCoupon={validateCoupon} + couponStoreState={couponStoreState} /> diff --git a/frontend/src/components/design-library/pages/private-pages/issue-pages/issue-private-page/issue-private-page.tsx b/frontend/src/components/design-library/pages/private-pages/issue-pages/issue-private-page/issue-private-page.tsx index 760790c0a..da8bd61ef 100644 --- a/frontend/src/components/design-library/pages/private-pages/issue-pages/issue-private-page/issue-private-page.tsx +++ b/frontend/src/components/design-library/pages/private-pages/issue-pages/issue-private-page/issue-private-page.tsx @@ -30,7 +30,9 @@ const IssuePrivatePage = ({ listWallets, wallets, fetchTask, - syncTask + syncTask, + validateCoupon, + couponStoreState }) => { return ( ) } diff --git a/frontend/src/components/design-library/pages/public-pages/issue-public-page/issue-public-page.tsx b/frontend/src/components/design-library/pages/public-pages/issue-public-page/issue-public-page.tsx index 34f1fc75d..cdb066cc3 100644 --- a/frontend/src/components/design-library/pages/public-pages/issue-public-page/issue-public-page.tsx +++ b/frontend/src/components/design-library/pages/public-pages/issue-public-page/issue-public-page.tsx @@ -30,7 +30,9 @@ const IssuePublicPage = ({ listWallets, wallets, fetchTask, - syncTask + syncTask, + validateCoupon, + couponStoreState }) => { return ( ) } diff --git a/frontend/src/containers/task-private.js b/frontend/src/containers/task-private.js index aadc507b3..faf87ecd7 100644 --- a/frontend/src/containers/task-private.js +++ b/frontend/src/containers/task-private.js @@ -34,6 +34,7 @@ import { detailOrder, listOrders } from '../actions/orderActions' +import { validateCoupon } from '../actions/couponActions' import { fetchWallet, listWallets } from '../actions/walletActions' import { getTaskSolution, @@ -62,7 +63,8 @@ const mapStateToProps = (state, ownProps) => { order: state.order, customer: state.customer, wallets: state.wallets, - wallet: state.wallet + wallet: state.wallet, + couponStoreState: { ...state.couponReducer } } } @@ -115,7 +117,8 @@ const mapDispatchToProps = (dispatch, ownProps) => { fetchPullRequestData: (owner, repositoryName, pullRequestId, taskId) => dispatch(fetchPullRequestData(owner, repositoryName, pullRequestId, taskId)), cleanPullRequestDataState: () => dispatch(cleanPullRequestDataState()), - fetchAccount: () => dispatch(fetchAccount()) + fetchAccount: () => dispatch(fetchAccount()), + validateCoupon: (code, originalOrderPrice) => dispatch(validateCoupon(code, originalOrderPrice)) // For account menu and bottom bar props // signOut and getInfo provided by profile container } diff --git a/frontend/src/containers/task.js b/frontend/src/containers/task.js index 4693ed045..d24bea500 100644 --- a/frontend/src/containers/task.js +++ b/frontend/src/containers/task.js @@ -37,6 +37,7 @@ import { detailOrder, listOrders } from '../actions/orderActions' +import { validateCoupon } from '../actions/couponActions' import { fetchWallet, listWallets } from '../actions/walletActions' import { getTaskSolution, @@ -69,7 +70,8 @@ const mapStateToProps = (state, ownProps) => { order: state.order, customer: state.customer, wallets: state.wallets, - wallet: state.wallet + wallet: state.wallet, + couponStoreState: { ...state.couponReducer } } } @@ -125,6 +127,8 @@ const mapDispatchToProps = (dispatch, ownProps) => { dispatch(fetchPullRequestData(owner, repositoryName, pullRequestId, taskId)), cleanPullRequestDataState: () => dispatch(cleanPullRequestDataState()), fetchAccount: () => dispatch(fetchAccount()), + validateCoupon: (code, originalOrderPrice) => + dispatch(validateCoupon(code, originalOrderPrice)), // For account menu and bottom bar props signOut: () => dispatch(logOut()), getInfo: () => dispatch(getInfoAction()) diff --git a/package.json b/package.json index 1bb5c6e68..fac715f4d 100644 --- a/package.json +++ b/package.json @@ -15,23 +15,23 @@ "migrate:test:dev": "cross-env NODE_ENV=test tsx src/migrate.ts up", "create:db:dev": "sequelize db:create --config src/config/config.json --env development", "create:db:test:dev": "sequelize db:create --config src/config/config.json --env test", - "seed": "TYPE=seed node dist/migrate.js up", - "seed:dev": "TYPE=seed tsx src/migrate.ts up", - "seed:test": "TYPE=seed NODE_ENV=test node dist/migrate.js up", - "seed:test:dev": "TYPE=seed NODE_ENV=test tsx src/migrate.ts up", + "seed": "cross-env TYPE=seed node dist/migrate.js up", + "seed:dev": "cross-env TYPE=seed tsx src/migrate.ts up", + "seed:test": "cross-env TYPE=seed NODE_ENV=test node dist/migrate.js up", + "seed:test:dev": "cross-env TYPE=seed NODE_ENV=test tsx src/migrate.ts up", "seed:windows:dev": "cross-env TYPE=seed tsx src/migrate.ts up", "seed:test:windows:dev": "cross-env TYPE=seed NODE_ENV=test tsx src/migrate.ts up", - "migrate:test:rollback": "NODE_ENV=test node migrate.js prev", - "migrate:test:rollback:dev": "NODE_ENV=test tsx src/migrate.ts prev", + "migrate:test:rollback": "cross-env NODE_ENV=test node migrate.js prev", + "migrate:test:rollback:dev": "cross-env NODE_ENV=test tsx src/migrate.ts prev", "rollback": "node dist/migrate.js prev", "rollback:dev": "tsx src/migrate.ts down", - "rollback:test": "NODE_ENV=test node dist/migrate.js prev", - "rollback:test:dev": "NODE_ENV=test tsx src/migrate.ts prev", + "rollback:test": "cross-env NODE_ENV=test node dist/migrate.js prev", + "rollback:test:dev": "cross-env NODE_ENV=test tsx src/migrate.ts prev", "reset": "node dist/migrate.js reset-hard", "reset:dev": "tsx src/migrate.ts reset-hard", - "reset:test": "NODE_ENV=test node dist/migrate.js reset-hard", - "reset:test:dev": "NODE_ENV=test tsx src/migrate.ts reset-hard", - "report": "NODE_ENV=production node dist/scripts/reports/reports.js", + "reset:test": "cross-env NODE_ENV=test node dist/migrate.js reset-hard", + "reset:test:dev": "cross-env NODE_ENV=test tsx src/migrate.ts reset-hard", + "report": "cross-env NODE_ENV=production node dist/scripts/reports/reports.js", "report:dev": "tsx src/scripts/reports/reports.js", "build": "tsc -p tsconfig.json", "start:dev": "nodemon --watch src --ext ts,js --exec \"tsx src/index.ts\"", diff --git a/src/config/secrets.js b/src/config/secrets.js index 34771c82c..90d458b90 100644 --- a/src/config/secrets.js +++ b/src/config/secrets.js @@ -4,7 +4,7 @@ if (process.env.NODE_ENV !== 'production') { const databaseDev = { username: 'postgres', - password: 'postgres', + password: process.env.DB_PASSWORD || process.env.POSTGRES_PASSWORD || 'postgres', database: 'gitpay_dev', host: '127.0.0.1', port: 5432, @@ -14,7 +14,7 @@ const databaseDev = { const databaseTest = { username: 'postgres', - password: 'postgres', + password: process.env.DB_PASSWORD || process.env.POSTGRES_PASSWORD || 'postgres', database: 'gitpay_test', host: '127.0.0.1', port: 5432, @@ -66,7 +66,8 @@ const bitbucket = { const slack = { token: process.env.SLACK_TOKEN, - channelId: process.env.SLACK_CHANNEL_ID + channelId: process.env.SLACK_CHANNEL_ID, + webhookUrl: process.env.SLACK_WEBHOOK_URL } const mailchimp = { diff --git a/src/migrate.ts b/src/migrate.ts index 38acb8ca0..cc46a0404 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -4,6 +4,10 @@ import { Umzug, SequelizeStorage } from 'umzug' import { Sequelize } from 'sequelize' import secrets from './config/secrets' +// Get the src directory - works with both tsx and compiled code +// @ts-ignore - __dirname is available at runtime +const srcDir = typeof __dirname !== 'undefined' ? __dirname : path.resolve(process.cwd(), 'src') + const env = process.env.NODE_ENV || 'development' const database_env = { @@ -51,9 +55,10 @@ sequelize.query('SELECT current_database();').then(([res]: any) => { const isSeed = process.env.TYPE === 'seed' -const baseDir = isSeed - ? path.join(__dirname, './db/seeders') - : path.join(__dirname, './db/migrations') +const baseDir = isSeed ? path.resolve(srcDir, 'db/seeders') : path.resolve(srcDir, 'db/migrations') + +// Convert Windows backslashes to forward slashes for glob pattern +const globPath = baseDir.replace(/\\/g, '/') + '/*.{ts,js}' const umzug = new Umzug({ context: { @@ -65,7 +70,7 @@ const umzug = new Umzug({ storage: new SequelizeStorage({ sequelize }), migrations: { - glob: path.join(baseDir, '*.{ts,js}'), + glob: globPath, resolve: ({ name, path: filePath, context }) => { if (!filePath) { diff --git a/src/models/planSchema.js b/src/models/planSchema.js index 8071f6fd0..4fa72668f 100644 --- a/src/models/planSchema.js +++ b/src/models/planSchema.js @@ -1,5 +1,9 @@ module.exports = (sequelize, DataTypes) => { const PlanSchema = sequelize.define('PlanSchema', { + plan: { + type: DataTypes.STRING, + allowNull: false + }, name: DataTypes.STRING, description: DataTypes.STRING, fee: DataTypes.DECIMAL, diff --git a/src/modules/orders/orderAuthorize.js b/src/modules/orders/orderAuthorize.js index dc63d7752..762c41311 100644 --- a/src/modules/orders/orderAuthorize.js +++ b/src/modules/orders/orderAuthorize.js @@ -3,6 +3,7 @@ const Promise = require('bluebird') const requestPromise = require('request-promise') const models = require('../../models') const comment = require('../bot/comment') +const slack = require('../shared/slack') module.exports = Promise.method(function orderAuthorize(orderParameters) { return requestPromise({ @@ -59,10 +60,17 @@ module.exports = Promise.method(function orderAuthorize(orderParameters) { return Promise.all([ models.User.findByPk(orderData.userId), models.Task.findByPk(orderData.TaskId) - ]).spread((user, task) => { + ]).then(async ([user, task]) => { if (orderData.paid) { comment(orderData, task) PaymentMail.success(user, task, orderData.amount) + + // Send Slack notification for PayPal payment completion + const orderDataForNotification = { + amount: orderData.amount, + currency: orderData.currency || 'USD' + } + await slack.notifyBounty(task, orderDataForNotification, user, 'PayPal payment') } else { PaymentMail.error(user.dataValues, task, orderData.amount) } diff --git a/src/modules/orders/orderBuilds.js b/src/modules/orders/orderBuilds.js index 11ad63f51..5b71dd061 100644 --- a/src/modules/orders/orderBuilds.js +++ b/src/modules/orders/orderBuilds.js @@ -1,4 +1,3 @@ -const Promise = require('bluebird') const models = require('../../models') const requestPromise = require('request-promise') const URLSearchParams = require('url-search-params') @@ -7,6 +6,7 @@ const Decimal = require('decimal.js') const stripe = require('../shared/stripe/stripe')() const Sendmail = require('../mail/mail') const userCustomerCreate = require('../users/userCustomerCreate') +const slack = require('../shared/slack') module.exports = async function orderBuilds(orderParameters) { const { source_id, source_type, currency, provider, amount, email, userId, taskId, plan } = @@ -83,6 +83,8 @@ module.exports = async function orderBuilds(orderParameters) { } }) + // Create invoice item (line item on the invoice) + // Note: We don't store the invoice item ID, only the invoice ID, as the invoice item is part of the invoice const invoiceItem = await stripe.invoiceItems.create({ customer: orderParameters.customer_id, currency: 'usd', @@ -97,6 +99,11 @@ module.exports = async function orderBuilds(orderParameters) { } }) + // Verify invoice item was created successfully + if (!invoiceItem || !invoiceItem.id) { + throw new Error('Failed to create invoice item') + } + const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id) Sendmail.success( { ...orderUser, email: orderParameters.email }, @@ -204,10 +211,15 @@ module.exports = async function orderBuilds(orderParameters) { } }) - const currentBalance = wallet.balance - const enoughBalance = new Decimal(currentBalance).greaterThanOrEqualTo( - new Decimal(orderParameters.amount) - ) + if (!wallet) { + throw new Error(`Wallet with id ${orderParameters.walletId} not found`) + } + + // Wallet balance is calculated from WalletOrders via afterFind hook + // The balance field is updated by the hook after findOne + // Convert to Decimal for comparison (balance is a string after hook processing) + const currentBalance = new Decimal(wallet.balance || '0.00') + const enoughBalance = currentBalance.greaterThanOrEqualTo(new Decimal(orderParameters.amount)) if (!enoughBalance) { throw new Error( @@ -229,6 +241,26 @@ module.exports = async function orderBuilds(orderParameters) { } ) + // Send Slack notification for wallet payment (paid immediately) + // Note: This only runs for wallet payments that complete successfully + // Reload order with associations to ensure Task and User are available + const orderWithAssociations = await models.Order.findByPk(orderCreated.dataValues.id, { + include: [models.Task, models.User] + }) + + if (orderWithAssociations && orderWithAssociations.Task && orderWithAssociations.User) { + const orderData = { + amount: orderCreated.dataValues.amount, + currency: orderCreated.dataValues.currency || 'USD' + } + await slack.notifyBounty( + orderWithAssociations.Task, + orderData, + orderWithAssociations.User, + 'wallet payment' + ) + } + return orderUpdated } diff --git a/src/modules/orders/orderUpdateAfterStripe.js b/src/modules/orders/orderUpdateAfterStripe.js index 80a2f7def..5b194e246 100644 --- a/src/modules/orders/orderUpdateAfterStripe.js +++ b/src/modules/orders/orderUpdateAfterStripe.js @@ -1,6 +1,7 @@ const Promise = require('bluebird') const PaymentMail = require('../mail/payment') const models = require('../../models') +const slack = require('../shared/slack') module.exports = Promise.method( function orderUpdateAfterStripe(order, charge, card, orderParameters, user, task, couponFull) { @@ -21,11 +22,21 @@ module.exports = Promise.method( id: order.dataValues.id } }) - .then((updatedOrder) => { + .then(async (_updatedOrder) => { if (orderParameters.plan === 'full') { PaymentMail.support(user, task, order) } PaymentMail.success(user, task, order.amount) + + // Send Slack notification for new bounty payment + if (orderPayload.paid && orderPayload.status === 'succeeded') { + const orderData = { + amount: order.amount || orderParameters.amount, + currency: order.currency || orderParameters.currency || 'USD' + } + await slack.notifyBounty(task, orderData, user, 'Stripe payment') + } + if (task.dataValues.assigned) { const assignedId = task.dataValues.assigned return models.Assign.findByPk(assignedId, { diff --git a/src/modules/shared/slack/index.js b/src/modules/shared/slack/index.js new file mode 100644 index 000000000..9570fa934 --- /dev/null +++ b/src/modules/shared/slack/index.js @@ -0,0 +1,179 @@ +const requestPromise = require('request-promise') +const secrets = require('../../../config/secrets') + +const sendSlackMessage = async (payload) => { + const webhookUrl = process.env.SLACK_WEBHOOK_URL || secrets.slack?.webhookUrl + + if (!webhookUrl) return false + + try { + await requestPromise({ + method: 'POST', + uri: webhookUrl, + body: payload, + json: true + }) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error('Slack notification failed:', errorMessage) + return false + } +} + +const formatCurrency = (amount, currency = 'USD') => { + const numAmount = parseFloat(amount) + if (isNaN(numAmount)) return '$0.00' + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(numAmount) +} + +const notifyNewIssue = async (task, user) => { + if (!task?.id) return false + + if (!shouldNotifyForTask(task)) return false + + const username = user?.username || user?.name || 'Unknown' + + return await sendSlackMessage({ + username: 'Gitpay', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New issue imported' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${task.title || 'Untitled Issue'}*` + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: task.description || 'No description provided.' + } + } + ], + attachments: [ + { + color: '#047651', + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Imported by *${username}*` + } + ] + } + ] + } + ] + }) +} + +const notifyBountyOnSlack = async (task, order, user) => { + if (!task?.id || !order?.amount) return false + + const username = user?.username || user?.name || 'Unknown' + const amount = formatCurrency(order.amount, order.currency) + + return await sendSlackMessage({ + username: 'Gitpay', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New bounty added' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${amount}*` + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${task.title || 'Untitled Task'}*` + } + } + ], + attachments: [ + { + color: '#047651', + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Bounty set by *${username}*` + } + ] + } + ] + } + ] + }) +} + +const shouldNotifyForTask = (task) => { + if (!task) return false + + // Handle both Sequelize model instances and plain objects + const taskData = task.dataValues || task + return !(taskData.not_listed === true || taskData.private === true) +} + +const notifyBounty = async (task, orderData, user, context = 'payment') => { + // Check if task and user exist + if (!task || !user) { + return false + } + + // Check privacy settings + if (!shouldNotifyForTask(task)) { + return false + } + + // Extract data values if Sequelize model instances + const taskData = task.dataValues || task + const userData = user.dataValues || user + + // Prepare order data + const order = { + amount: orderData.amount, + currency: orderData.currency || 'USD' + } + + try { + await notifyBountyOnSlack(taskData, order, userData) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error(`Error sending Slack notification for new bounty (${context}):`, errorMessage) + return false + } +} + +module.exports = { + notifyNewIssue, + notifyBountyOnSlack, + shouldNotifyForTask, + notifyBounty +} diff --git a/src/modules/shared/slack/index.ts b/src/modules/shared/slack/index.ts new file mode 100644 index 000000000..18c3a62e3 --- /dev/null +++ b/src/modules/shared/slack/index.ts @@ -0,0 +1,200 @@ +import requestPromise from 'request-promise' +import secrets from '../../../config/secrets' +import type { Task, User, OrderData, SlackMessagePayload } from './types' + +const sendSlackMessage = async (payload: SlackMessagePayload): Promise => { + const webhookUrl = process.env.SLACK_WEBHOOK_URL || secrets.slack?.webhookUrl + + if (!webhookUrl) { + return false + } + + try { + await requestPromise({ + method: 'POST', + uri: webhookUrl, + body: payload, + json: true + }) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error('Slack notification failed:', errorMessage) + return false + } +} + +const formatCurrency = (amount: number | string, currency: string = 'USD'): string => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + + if (isNaN(numAmount)) { + return '$0.00' + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(numAmount) +} + +export const notifyNewIssue = async ( + task: Task | null | undefined, + user: User | null | undefined +): Promise => { + if (!task?.id) { + return false + } + + if (!shouldNotifyForTask(task)) { + return false + } + + const username = user?.username || user?.name || 'Unknown' + + return await sendSlackMessage({ + username: 'Gitpay', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New issue imported' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${task.title || 'Untitled Issue'}*` + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: task.description || 'No description provided.' + } + } + ], + attachments: [ + { + color: '#047651', + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Imported by *${username}*` + } + ] + } + ] + } + ] + }) +} + +export const notifyBountyOnSlack = async ( + task: Task | null | undefined, + order: OrderData | null | undefined, + user: User | null | undefined +): Promise => { + if (!task?.id || !order?.amount) { + return false + } + + const username = user?.username || user?.name || 'Unknown' + const amount = formatCurrency(order.amount, order.currency) + + return await sendSlackMessage({ + username: 'Gitpay', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'New bounty added' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${amount}*` + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${task.title || 'Untitled Task'}*` + } + } + ], + attachments: [ + { + color: '#047651', + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Bounty set by *${username}*` + } + ] + } + ] + } + ] + }) +} + +export const shouldNotifyForTask = ( + task: Task | { dataValues?: Task } | null | undefined +): boolean => { + if (!task) { + return false + } + + // Handle both Sequelize model instances and plain objects + const taskData = (task as { dataValues?: Task }).dataValues || (task as Task) + return !(taskData.not_listed === true || taskData.private === true) +} + +export const notifyBounty = async ( + task: Task | { dataValues?: Task } | null | undefined, + orderData: OrderData | null | undefined, + user: User | { dataValues?: User } | null | undefined, + context: string = 'payment' +): Promise => { + // Check if task and user exist + if (!task || !user) { + return false + } + + // Check privacy settings + if (!shouldNotifyForTask(task)) { + return false + } + + // Extract data values if Sequelize model instances + const taskData = (task as { dataValues?: Task }).dataValues || (task as Task) + const userData = (user as { dataValues?: User }).dataValues || (user as User) + + // Prepare order data + const order: OrderData = { + amount: orderData?.amount || 0, + currency: orderData?.currency || 'USD' + } + + try { + await notifyBountyOnSlack(taskData, order, userData) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error(`Error sending Slack notification for new bounty (${context}):`, errorMessage) + return false + } +} diff --git a/src/modules/shared/slack/types.ts b/src/modules/shared/slack/types.ts new file mode 100644 index 000000000..87825342c --- /dev/null +++ b/src/modules/shared/slack/types.ts @@ -0,0 +1,45 @@ +/** + * Type definitions for Slack notification module + */ + +export interface Task { + id?: number + title?: string + description?: string + not_listed?: boolean + private?: boolean +} + +export interface User { + id?: number + username?: string + name?: string +} + +export interface OrderData { + amount: number | string + currency?: string +} + +export interface SlackMessagePayload { + username: string + blocks: SlackBlock[] + attachments?: SlackAttachment[] +} + +export interface SlackBlock { + type: string + text?: { + type: string + text: string + } + elements?: Array<{ + type: string + text: string + }> +} + +export interface SlackAttachment { + color: string + blocks: SlackBlock[] +} diff --git a/src/modules/tasks/taskBuilds.js b/src/modules/tasks/taskBuilds.js index 7435cc1e7..ce61d3a41 100644 --- a/src/modules/tasks/taskBuilds.js +++ b/src/modules/tasks/taskBuilds.js @@ -10,6 +10,7 @@ const userExists = require('../users').userExists // const userOrganizations = require('../users/userOrganizations') const project = require('../projectHelpers') const issueAddedComment = require('../bot/issueAddedComment') +const slack = require('../shared/slack') module.exports = Promise.method(async function taskBuilds(taskParameters) { const repoUrl = taskParameters.url @@ -99,14 +100,13 @@ module.exports = Promise.method(async function taskBuilds(taskParameters) { const taskData = task.dataValues const userData = await task.getUser() - try { - if (userData.receiveNotifications) { - TaskMail.new(userData, taskData) - } - issueAddedComment(task) - } catch (e) { - console.log('error on send email and post', e) + if (userData.receiveNotifications) { + TaskMail.new(userData, taskData) } + + issueAddedComment(task) + slack.notifyNewIssue(taskData, userData) + return { ...taskData, ProjectId: taskData.ProjectId } }) }) diff --git a/src/modules/users/userBuilds.js b/src/modules/users/userBuilds.js index 1aaab6695..93fc0bc2d 100644 --- a/src/modules/users/userBuilds.js +++ b/src/modules/users/userBuilds.js @@ -30,7 +30,7 @@ module.exports = Promise.method(async function userBuilds(userParameters) { if (selectedTypeIds && selectedTypeIds.length > 0) { await user.setTypes(selectedTypeIds) const userWithTypes = await models.User.findByPk(id, { - include: { model: models.Type } + include: { model: models.Type, as: 'Types' } }) return userWithTypes } diff --git a/src/modules/webhooks/chargeSucceeded.js b/src/modules/webhooks/chargeSucceeded.js index 6812dfab3..ae78d6ed5 100644 --- a/src/modules/webhooks/chargeSucceeded.js +++ b/src/modules/webhooks/chargeSucceeded.js @@ -1,6 +1,7 @@ const models = require('../../models') const i18n = require('i18n') const SendMail = require('../mail/mail') +const slack = require('../shared/slack') const sendEmailSuccess = (event, paid, status, order, req, res) => { return models.User.findOne({ @@ -30,7 +31,7 @@ const sendEmailSuccess = (event, paid, status, order, req, res) => { }) } -const updateOrder = (event, paid, status, req, res) => { +const updateOrder = async (event, paid, status, req, res) => { return models.Order.update( { paid: paid, @@ -44,8 +45,31 @@ const updateOrder = (event, paid, status, req, res) => { returning: true } ) - .then((order) => { + .then(async (order) => { if (order[0]) { + // Send Slack notification if payment succeeded + if (paid && status === 'succeeded') { + const orderUpdated = await models.Order.findOne({ + where: { + id: order[1][0].dataValues.id + }, + include: [models.Task, models.User] + }) + + if (orderUpdated) { + const orderData = { + amount: orderUpdated.amount, + currency: orderUpdated.currency || 'USD' + } + await slack.notifyBounty( + orderUpdated.Task, + orderData, + orderUpdated.User, + 'Stripe charge succeeded' + ) + } + } + return sendEmailSuccess(event, paid, status, order, req, res) } }) diff --git a/src/modules/webhooks/chargeUpdated.js b/src/modules/webhooks/chargeUpdated.js index 88d0299a4..40b4e2113 100644 --- a/src/modules/webhooks/chargeUpdated.js +++ b/src/modules/webhooks/chargeUpdated.js @@ -2,6 +2,7 @@ const models = require('../../models') const i18n = require('i18n') const moment = require('moment') const SendMail = require('../mail/mail') +const slack = require('../shared/slack') module.exports = async function chargeUpdated(event, paid, status, req, res) { if (event?.data?.object?.source?.id) { @@ -18,14 +19,14 @@ module.exports = async function chargeUpdated(event, paid, status, req, res) { returning: true } ) - .then((order) => { + .then(async (order) => { if (order[0]) { return models.User.findOne({ where: { id: order[1][0].dataValues.userId } }) - .then((user) => { + .then(async (user) => { if (user) { if (paid && status === 'succeeded') { const language = user.language || 'en' @@ -37,6 +38,27 @@ module.exports = async function chargeUpdated(event, paid, status, req, res) { amount: event.data.object.amount / 100 }) ) + + // Send Slack notification for charge update payment completion + const orderUpdated = await models.Order.findOne({ + where: { + id: order[1][0].dataValues.id + }, + include: [models.Task, models.User] + }) + + if (orderUpdated) { + const orderData = { + amount: orderUpdated.amount, + currency: orderUpdated.currency || 'USD' + } + await slack.notifyBounty( + orderUpdated.Task, + orderData, + orderUpdated.User, + 'Stripe charge updated' + ) + } } } return res.status(200).json(event) diff --git a/src/modules/webhooks/invoicePaymentSucceeded.js b/src/modules/webhooks/invoicePaymentSucceeded.js index 6665e29e7..ac0c0f94e 100644 --- a/src/modules/webhooks/invoicePaymentSucceeded.js +++ b/src/modules/webhooks/invoicePaymentSucceeded.js @@ -5,6 +5,7 @@ const SendMail = require('../mail/mail') const WalletMail = require('../mail/wallet') const stripe = require('../shared/stripe/stripe')() const { FAILED_REASON, CURRENCIES, formatStripeAmount } = require('./constants') +const slack = require('../shared/slack') module.exports = async function invoicePaymentSucceeded(event, req, res) { return models.User.findOne({ @@ -35,7 +36,29 @@ module.exports = async function invoicePaymentSucceeded(event, req, res) { }, returning: true } - ).then((order) => { + ).then(async (order) => { + // Send Slack notification for invoice payment completion + if (order[0] && order[1].length) { + const orderUpdated = await models.Order.findOne({ + where: { + id: order[1][0].dataValues.id + }, + include: [models.Task, models.User] + }) + + if (orderUpdated && orderUpdated.Task && orderUpdated.User) { + const orderData = { + amount: orderUpdated.amount, + currency: orderUpdated.currency || 'USD' + } + await slack.notifyBounty( + orderUpdated.Task, + orderData, + orderUpdated.User, + 'Stripe invoice payment succeeded' + ) + } + } return res.status(200).json(event) }) } diff --git a/src/modules/webhooks/invoiceUpdated.js b/src/modules/webhooks/invoiceUpdated.js index 9d0a0eb84..d1d54ad56 100644 --- a/src/modules/webhooks/invoiceUpdated.js +++ b/src/modules/webhooks/invoiceUpdated.js @@ -5,6 +5,7 @@ const SendMail = require('../mail/mail') const WalletMail = require('../mail/wallet') const stripe = require('../shared/stripe/stripe')() const { FAILED_REASON, CURRENCIES, formatStripeAmount } = require('./constants') +const slack = require('../shared/slack') module.exports = async function invoiceUpdated(event, req, res) { // eslint-disable-next-line no-case-declarations @@ -62,7 +63,7 @@ module.exports = async function invoiceUpdated(event, req, res) { const userAssigned = userAssign.dataValues.User.dataValues const userTask = orderUpdated.User.dataValues if (orderUpdated) { - if (orderUpdated.status === 'paid') { + if (orderUpdated.status === 'paid' && orderUpdated.paid) { const userAssignedlanguage = userAssigned.language || 'en' i18n.setLocale(userAssignedlanguage) SendMail.success( @@ -81,6 +82,18 @@ module.exports = async function invoiceUpdated(event, req, res) { amount: order[1][0].dataValues.amount }) ) + + // Send Slack notification for invoice payment completion + const orderData = { + amount: orderUpdated.amount, + currency: orderUpdated.currency || 'USD' + } + await slack.notifyBounty( + orderUpdated.Task, + orderData, + orderUpdated.User, + 'Stripe invoice payment' + ) } } return res.status(200).json(event) diff --git a/test/api/task/taskCreateAndPostToSlack.test.ts b/test/api/task/taskCreateAndPostToSlack.test.ts new file mode 100644 index 000000000..50f205beb --- /dev/null +++ b/test/api/task/taskCreateAndPostToSlack.test.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai' +import nock from 'nock' +import request from 'supertest' +import api from '../../../src/server' +import { registerAndLogin, truncateModels } from '../../helpers' +import Models from '../../../src/models' +const secrets = require('../../../src/config/secrets') +const getSingleIssue = require('../../data/github/github.issue.get') +const getSingleRepo = require('../../data/github/github.repository.get') + +const agent = request.agent(api) as any +const models = Models as any + +const setupGitHubMocks = (issueNumber: number) => { + nock('https://api.github.com') + .persist() + .get(`/repos/worknenjoy/gitpay/issues/${issueNumber}`) + .query({ client_id: secrets.github.id, client_secret: secrets.github.secret }) + .reply(200, getSingleIssue.issue) + + nock('https://api.github.com') + .persist() + .get('/repos/worknenjoy/gitpay') + .query({ client_id: secrets.github.id, client_secret: secrets.github.secret }) + .reply(200, getSingleRepo.repo) + + nock('https://api.github.com') + .persist() + .get('/repos/worknenjoy/gitpay/languages') + .query({ client_id: secrets.github.id, client_secret: secrets.github.secret }) + .reply(200, { JavaScript: 100000, HTML: 50000 }) + + // Mock GitHub user API call (used when checking for company_owner role) + nock('https://api.github.com') + .persist() + .get('/users/worknenjoy') + .query({ client_id: secrets.github.id, client_secret: secrets.github.secret }) + .reply(200, { login: 'worknenjoy', email: null }) +} + +describe('Task Creation and Slack Notifications', () => { + beforeEach(async () => { + await truncateModels(models.Task) + await truncateModels(models.User) + await truncateModels(models.Project) + }) + + afterEach(() => { + nock.cleanAll() + delete process.env.SLACK_WEBHOOK_URL + }) + + it('should not call Slack methods when task is created with not_listed set to true', async () => { + // Setup GitHub API mocks + setupGitHubMocks(999) + + // Mock Slack webhook (following slack.test.js pattern) + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + const slackWebhook = nock('https://hooks.slack.com') + .post('/services/TEST/WEBHOOK/URL') + .reply(200, 'ok') + + const user = await registerAndLogin(agent) + const { headers } = user || {} + + await agent + .post('/tasks/create') + .send({ + url: 'https://github.com/worknenjoy/gitpay/issues/999', + provider: 'github', + not_listed: true + }) + .set('Authorization', headers.authorization) + .expect('Content-Type', /json/) + .expect(200) + + // Assert that Slack webhook was not called + expect(slackWebhook.isDone()).to.equal(false) + }) + + it('should not call Slack methods when task is created with private set to true', async () => { + // Setup GitHub API mocks + setupGitHubMocks(998) + + // Mock Slack webhook (following slack.test.js pattern) + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + const slackWebhook = nock('https://hooks.slack.com') + .post('/services/TEST/WEBHOOK/URL') + .reply(200, 'ok') + + const user = await registerAndLogin(agent) + const { headers } = user || {} + + await agent + .post('/tasks/create') + .send({ + url: 'https://github.com/worknenjoy/gitpay/issues/998', + provider: 'github', + private: true + }) + .set('Authorization', headers.authorization) + .expect('Content-Type', /json/) + .expect(200) + + // Assert that Slack webhook was not called + expect(slackWebhook.isDone()).to.equal(false) + }) + + it('should call Slack methods when task is created as public', async () => { + // Setup GitHub API mocks + setupGitHubMocks(997) + + // Mock Slack webhook (following slack.test.js pattern) + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + const slackWebhook = nock('https://hooks.slack.com') + .post('/services/TEST/WEBHOOK/URL') + .reply(200, 'ok') + + const user = await registerAndLogin(agent) + const { headers, body: currentUser } = user || {} + + await agent + .post('/tasks/create') + .send({ + url: 'https://github.com/worknenjoy/gitpay/issues/997', + provider: 'github' + }) + .set('Authorization', headers.authorization) + .expect('Content-Type', /json/) + .expect(200) + + // Assert that Slack webhook was called + expect(slackWebhook.isDone()).to.equal(true) + }) +}) diff --git a/test/helpers/index.js b/test/helpers/index.js index 9889c157f..53cfd7f56 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -1,10 +1,11 @@ const models = require('../../src/models') -const testEmail = `teste+${Math.random() * 100}@gmail.com` const testPassword = 'test12345678' const testName = 'Test' +const generateTestEmail = () => `teste+${Math.random()}@gmail.com` + const register = (agent, params = {}) => { - params.email = params.email || testEmail + params.email = params.email || generateTestEmail() params.password = params.password || testPassword params.confirmPassword = params.password || testPassword params.name = params.name || testName @@ -17,7 +18,7 @@ const register = (agent, params = {}) => { } const login = (agent, params = {}) => { - params.username = params.email || testEmail + params.username = params.email params.password = params.password || testPassword return agent.post('/authorize/local').send(params).type('form') } diff --git a/test/order.test.js b/test/order.test.js index f2cf4eaca..eff3dbb7e 100644 --- a/test/order.test.js +++ b/test/order.test.js @@ -13,6 +13,7 @@ const plan = require('../src/models/plan') const stripe = require('../src/modules/shared/stripe/stripe')() const customerData = require('./data/stripe/stripe.customer') const invoiceData = require('./data/stripe/stripe.invoice.basic') +const { notifyBountyOnSlack } = require('../src/modules/shared/slack') describe('Orders', () => { beforeEach(async () => { @@ -22,6 +23,29 @@ describe('Orders', () => { await truncateModels(models.Order) await truncateModels(models.Transfer) await truncateModels(models.Wallet) + + // PlanSchemas are required by order creation for "open source" plan. + // They are not truncated above, so ensure they exist deterministically. + await models.PlanSchema.findOrCreate({ + where: { plan: 'open source', name: 'Open Source - default', feeType: 'charge' }, + defaults: { + plan: 'open source', + name: 'Open Source - default', + description: 'open source', + fee: 8, + feeType: 'charge' + } + }) + await models.PlanSchema.findOrCreate({ + where: { plan: 'open source', name: 'Open Source - no fee', feeType: 'charge' }, + defaults: { + plan: 'open source', + name: 'Open Source - no fee', + description: 'open source with no fee', + fee: 0, + feeType: 'charge' + } + }) }) afterEach(async () => { nock.cleanAll() @@ -64,24 +88,205 @@ describe('Orders', () => { expect(res.body.amount).to.equal('200') }) - describe('Order with Plan', () => { - let PlanSchema - beforeEach(async () => { - PlanSchema = await models.PlanSchema.build({ - plan: 'open source', - name: 'Open Source - default', - description: 'open source', - fee: 8, - feeType: 'charge' + it('should not call notifyNewBounty when order is created for a task with not_listed set to true', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + const slackSpy = chai.spy.on(slackModule, 'notifyBountyOnSlack') + const orderBuilds = require('../src/modules/orders').orderBuilds + + try { + const user = await registerAndLogin(agent) + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/1', + userId: user.body.id, + title: 'Test Task', + not_listed: true + }) + + await orderBuilds({ + source_id: '12345', + currency: 'BRL', + amount: 200, + email: 'testing@gitpay.me', + userId: user.body.id, + taskId: task.id }) - PlanSchema = await models.PlanSchema.build({ - plan: 'open source', - name: 'Open Source - no fee', - description: 'open source with no fee', - fee: 0, - feeType: 'charge' + + expect(slackSpy).to.not.have.been.called() + } finally { + chai.spy.restore(slackModule, 'notifyBountyOnSlack') + } + }) + + it('should not call notifyNewBounty when order is created for a task with private set to true', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + const slackSpy = chai.spy.on(slackModule, 'notifyBountyOnSlack') + const orderBuilds = require('../src/modules/orders').orderBuilds + + try { + const user = await registerAndLogin(agent) + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/2', + userId: user.body.id, + title: 'Test Task', + private: true }) - }) + + await orderBuilds({ + source_id: '12346', + currency: 'BRL', + amount: 200, + email: 'testing@gitpay.me', + userId: user.body.id, + taskId: task.id + }) + + expect(slackSpy).to.not.have.been.called() + } finally { + chai.spy.restore(slackModule, 'notifyBountyOnSlack') + } + }) + + it('should not call notifyNewBounty when order is created (notification only on payment completion)', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + const slackSpy = chai.spy.on(slackModule, 'notifyBountyOnSlack') + const orderBuilds = require('../src/modules/orders').orderBuilds + + try { + const user = await registerAndLogin(agent) + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/3', + userId: user.body.id, + title: 'Test Task', + not_listed: false, + private: false + }) + + await orderBuilds({ + source_id: '12347', + currency: 'BRL', + amount: 200, + email: 'testing@gitpay.me', + userId: user.body.id, + taskId: task.id + }) + + // Notification should NOT be called when order is created, only when payment completes + expect(slackSpy).to.not.have.been.called() + } finally { + chai.spy.restore(slackModule, 'notifyBountyOnSlack') + } + }) + + it('should call notifyNewBounty when wallet payment completes for a public task', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + // Spy on notifyBounty since that's what's actually called + const slackSpy = chai.spy.on(slackModule, 'notifyBounty') + const orderBuilds = require('../src/modules/orders').orderBuilds + + try { + const user = await registerAndLogin(agent) + const wallet = await models.Wallet.create({ + name: 'Test Wallet', + balance: 0, + userId: user.body.id + }) + // Create a WalletOrder to give the wallet balance (wallet balance is calculated from WalletOrders) + // The wallet balance is calculated as: sum of paid WalletOrders - sum of succeeded wallet Orders + await models.WalletOrder.create({ + walletId: wallet.id, + amount: 500, + currency: 'USD', + status: 'paid', + paid: true + }) + // Reload wallet to trigger afterFind hook and recalculate balance + await wallet.reload() + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/4', + userId: user.body.id, + title: 'Test Task', + not_listed: false, + private: false + }) + + await orderBuilds({ + walletId: wallet.id, + currency: 'USD', + amount: 200, + provider: 'wallet', + source_type: 'wallet-funds', + userId: user.body.id, + taskId: task.id + }) + + // Notification should be called when wallet payment completes + expect(slackSpy).to.have.been.called() + // Verify it was called with correct order data and context + const call = slackSpy.__spy.calls[0] + expect(Number(call[1].amount)).to.equal(200) + expect(call[1].currency).to.equal('USD') + expect(call[3]).to.equal('wallet payment') + } finally { + chai.spy.restore(slackModule, 'notifyBounty') + } + }) + + it('should not call notifyNewBounty when wallet payment completes for a private task', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + // Spy on notifyBountyOnSlack - the internal function that sends to Slack + const slackSpy = chai.spy.on(slackModule, 'notifyBountyOnSlack') + const orderBuilds = require('../src/modules/orders').orderBuilds + + try { + const user = await registerAndLogin(agent) + const wallet = await models.Wallet.create({ + name: 'Test Wallet', + balance: 0, + userId: user.body.id + }) + // Create a WalletOrder to give the wallet balance (wallet balance is calculated from WalletOrders) + // The wallet balance is calculated as: sum of paid WalletOrders - sum of succeeded wallet Orders + await models.WalletOrder.create({ + walletId: wallet.id, + amount: 500, + currency: 'USD', + status: 'paid', + paid: true + }) + // Reload wallet to trigger afterFind hook and recalculate balance + await wallet.reload() + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/5', + userId: user.body.id, + title: 'Test Task', + private: true + }) + + await orderBuilds({ + walletId: wallet.id, + currency: 'USD', + amount: 200, + provider: 'wallet', + source_type: 'wallet-funds', + userId: user.body.id, + taskId: task.id + }) + + // Notification should NOT be sent to Slack for private tasks + expect(slackSpy).to.not.have.been.called() + } finally { + chai.spy.restore(slackModule, 'notifyBountyOnSlack') + } + }) + + describe('Order with Plan', () => { + let PlanSchema + beforeEach(async () => {}) it('should create a new order with a plan', async () => { const user = await registerAndLogin(agent) const res = await agent @@ -732,6 +937,138 @@ describe('Orders', () => { expect(transferRes.body.currency).to.equal('BRL') expect(transferRes.body.amount).to.equal('200') }) + describe('PayPal payment notifications', () => { + it('should call notifyNewBounty when PayPal payment completes for a public task', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + // Spy on notifyBounty since that's what's actually called + const slackSpy = chai.spy.on(slackModule, 'notifyBounty') + const orderAuthorize = require('../src/modules/orders').orderAuthorize + + // Mock PayPal API responses + nock(process.env.PAYPAL_HOST || 'https://api.sandbox.paypal.com') + .post('/v1/oauth2/token') + .reply(200, JSON.stringify({ access_token: 'test_token' })) + + nock(process.env.PAYPAL_HOST || 'https://api.sandbox.paypal.com') + .post(/\/v2\/checkout\/orders\/.*\/authorize/) + .reply(200, JSON.stringify({ + id: 'TEST_ORDER_ID', + purchase_units: [{ + payments: { + authorizations: [{ + id: 'TEST_AUTH_ID' + }] + } + }] + })) + + try { + const user = await registerAndLogin(agent) + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/6', + userId: user.body.id, + title: 'Test Task', + not_listed: false, + private: false + }) + + const order = await models.Order.create({ + source_id: 'TEST_ORDER_ID', + currency: 'USD', + amount: 200, + provider: 'paypal', + userId: user.body.id, + TaskId: task.id, + token: 'TEST_TOKEN', + status: 'open', + paid: false + }) + + const result = await orderAuthorize({ + token: 'TEST_TOKEN', + PayerID: 'TEST_PAYER_ID' + }) + + // Verify the order was marked as paid + const updatedOrder = await models.Order.findOne({ + where: { token: 'TEST_TOKEN' } + }) + expect(updatedOrder.paid).to.be.true + + // Notification should be called when PayPal payment completes + expect(slackSpy).to.have.been.called() + // Verify it was called with correct order data and context + const call = slackSpy.__spy.calls[0] + expect(Number(call[1].amount)).to.equal(200) + expect(call[1].currency).to.equal('USD') + expect(call[3]).to.equal('PayPal payment') + } finally { + chai.spy.restore(slackModule, 'notifyBounty') + nock.cleanAll() + } + }) + + it('should not call notifyNewBounty when PayPal payment completes for a private task', async () => { + chai.use(spies) + const slackModule = require('../src/modules/shared/slack') + // Spy on notifyBountyOnSlack - the internal function that sends to Slack + const slackSpy = chai.spy.on(slackModule, 'notifyBountyOnSlack') + const orderAuthorize = require('../src/modules/orders').orderAuthorize + + // Mock PayPal API responses + nock(process.env.PAYPAL_HOST || 'https://api.sandbox.paypal.com') + .post('/v1/oauth2/token') + .reply(200, JSON.stringify({ access_token: 'test_token' })) + + nock(process.env.PAYPAL_HOST || 'https://api.sandbox.paypal.com') + .post(/\/v2\/checkout\/orders\/.*\/authorize/) + .reply(200, JSON.stringify({ + id: 'TEST_ORDER_ID', + purchase_units: [{ + payments: { + authorizations: [{ + id: 'TEST_AUTH_ID' + }] + } + }] + })) + + try { + const user = await registerAndLogin(agent) + const task = await models.Task.create({ + url: 'https://github.com/test/repo/issues/7', + userId: user.body.id, + title: 'Test Task', + private: true + }) + + const order = await models.Order.create({ + source_id: 'TEST_ORDER_ID', + currency: 'USD', + amount: 200, + provider: 'paypal', + userId: user.body.id, + TaskId: task.id, + token: 'TEST_TOKEN_2', + status: 'open', + paid: false + }) + + await orderAuthorize({ + token: 'TEST_TOKEN_2', + PayerID: 'TEST_PAYER_ID' + }) + + // Notification should NOT be sent to Slack for private tasks + expect(slackSpy).to.not.have.been.called() + } finally { + chai.spy.restore(slackModule, 'notifyBountyOnSlack') + nock.cleanAll() + } + }) + }) + it('should refund order', async () => { const stripeRefund = { id: 're_1J2Yal2eZvKYlo2C0qvW9j8D', diff --git a/test/slack.test.js b/test/slack.test.js new file mode 100644 index 000000000..c7b1a89cd --- /dev/null +++ b/test/slack.test.js @@ -0,0 +1,55 @@ +const expect = require('chai').expect +const nock = require('nock') +const { notifyNewIssue, notifyBountyOnSlack } = require('../src/modules/shared/slack') + +describe('Slack Notifications', () => { + beforeEach(() => { + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/TEST/WEBHOOK/URL' + }) + + afterEach(() => { + nock.cleanAll() + delete process.env.SLACK_WEBHOOK_URL + }) + + describe('New Issue Notifications', () => { + it('should send Slack notification when new issue is imported', async () => { + const slackWebhook = nock('https://hooks.slack.com') + .post('/services/TEST/WEBHOOK/URL') + .reply(200, 'ok') + + await notifyNewIssue( + { id: 1, title: 'Test Issue', description: 'Test description' }, + { username: 'testuser' } + ) + + expect(slackWebhook.isDone()).to.be.true + }) + + it('should handle missing task data gracefully', async () => { + await notifyNewIssue(null, { username: 'test' }) + await notifyNewIssue({}, { username: 'test' }) + }) + }) + + describe('New Bounty Notifications', () => { + it('should send Slack notification when new bounty is added', async () => { + const slackWebhook = nock('https://hooks.slack.com') + .post('/services/TEST/WEBHOOK/URL') + .reply(200, 'ok') + + await notifyBountyOnSlack( + { id: 1, title: 'Test Task' }, + { amount: 100, currency: 'USD' }, + { username: 'testuser' } + ) + + expect(slackWebhook.isDone()).to.be.true + }) + + it('should handle missing order data gracefully', async () => { + await notifyBountyOnSlack({ id: 1, title: 'Test' }, null, { username: 'test' }) + await notifyBountyOnSlack({ id: 1, title: 'Test' }, {}, { username: 'test' }) + }) + }) +}) \ No newline at end of file diff --git a/test/user.test.js b/test/user.test.js index 57a7d8bd5..fe5ae5916 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -171,18 +171,29 @@ describe('Users', () => { }) }) it('register with user Types', async () => { + // Ensure the types exist, then use their real IDs (avoid relying on seed order/IDs) + const { Type } = require('../src/models') + const [funding] = await Type.findOrCreate({ where: { name: 'funding' }, defaults: { name: 'funding' } }) + const [contributor] = await Type.findOrCreate({ + where: { name: 'contributor' }, + defaults: { name: 'contributor' } + }) + const res = await agent .post('/auth/register') - .send({ email: 'teste4343434322222@gmail.com', password: 'test', Types: ['1', '2'] }) + .send({ + email: 'teste4343434322222@gmail.com', + password: 'test', + Types: [String(funding.id), String(contributor.id)] + }) .expect('Content-Type', /json/) .expect(200) expect(res.statusCode).to.equal(200) expect(res.body).to.exist expect(res.body.Types).to.exist - expect(res.body.Types[0].id).to.equal(1) - expect(res.body.Types[0].name).to.equal('funding') - expect(res.body.Types[1].id).to.equal(2) - expect(res.body.Types[1].name).to.equal('contributor') + const typeNames = (res.body.Types || []).map((t) => t.name) + expect(typeNames).to.include('funding') + expect(typeNames).to.include('contributor') }) })