diff --git a/backend/package.json b/backend/package.json index 1f4ea67af..76174b52c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "license": "Apache-2.0", "private": false, "scripts": { - "build": "tsc --build && mkdir -p build/src/emails/templates/ && cp -r src/emails/templates/* build/src/emails/templates/ && mkdir -p build/src/locales/ && cp -r src/locales/*.json build/src/locales/", + "build": "tsc --build && mkdirp build/src/emails/templates/ && ncp src/emails/templates build/src/emails/templates && mkdirp build/src/locales/ && ncp src/locales build/src/locales", "clean": "tsc --build --clean", "start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js", "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css --exec ts-node -r tsconfig-paths/register src/index.ts", @@ -80,7 +80,9 @@ "ts-jest": "^27.0.5", "ts-node": "^10.0.0", "tsconfig-paths": "^3.14.0", - "typescript": "^4.3.4" + "typescript": "^4.3.4", + "mkdirp": "^3.0.1", + "ncp": "^2.0.0" }, "nodemonConfig": { "ignore": [ diff --git a/backend/src/emails/sendEmailVariants.test.ts b/backend/src/emails/Email.builder.test.ts similarity index 81% rename from backend/src/emails/sendEmailVariants.test.ts rename to backend/src/emails/Email.builder.test.ts index 3340a361d..b30e4835b 100644 --- a/backend/src/emails/sendEmailVariants.test.ts +++ b/backend/src/emails/Email.builder.test.ts @@ -2,43 +2,17 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Connection } from '@dbTools/typeorm' -import { ApolloServerTestClient } from 'apollo-server-testing' import { Decimal } from 'decimal.js-light' -import { testEnvironment } from '@test/helpers' import { logger, i18n as localization } from '@test/testSetup' import { CONFIG } from '@/config' import { sendEmailTranslated } from './sendEmailTranslated' -import { - sendAddedContributionMessageEmail, - sendAccountActivationEmail, - sendAccountMultiRegistrationEmail, - sendContributionConfirmedEmail, - sendContributionDeniedEmail, - sendContributionDeletedEmail, - sendResetPasswordEmail, - sendTransactionLinkRedeemedEmail, - sendTransactionReceivedEmail, -} from './sendEmailVariants' - -let con: Connection -let testEnv: { - mutate: ApolloServerTestClient['mutate'] - query: ApolloServerTestClient['query'] - con: Connection -} - -beforeAll(async () => { - testEnv = await testEnvironment(logger, localization) - con = testEnv.con -}) - -afterAll(async () => { - await con.close() -}) +import { User } from '@entity/User' +import { UserContact } from '@entity/UserContact' +import { Contribution } from '@entity/Contribution' +import { EmailBuilder, EmailType } from './Email.builder' jest.mock('./sendEmailTranslated', () => { const originalModule = jest.requireActual('./sendEmailTranslated') @@ -51,18 +25,34 @@ jest.mock('./sendEmailTranslated', () => { describe('sendEmailVariants', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let result: any + const recipientUser = User.create() + recipientUser.firstName = 'Peter' + recipientUser.lastName = 'Lustig' + recipientUser.language = 'en' + const recipientUserContact = UserContact.create() + recipientUserContact.email = 'peter@lustig.de' + recipientUser.emailContact = recipientUserContact + + const senderUser = User.create() + senderUser.firstName = 'Bibi' + senderUser.lastName = 'Bloxberg' + const senderUserContact = UserContact.create() + senderUserContact.email = 'bibi@bloxberg.de' + + const contribution = Contribution.create() + contribution.memo = 'My contribution.' + contribution.amount = new Decimal(23.54) + + const emailBuilder = new EmailBuilder() describe('sendAddedContributionMessageEmail', () => { beforeAll(async () => { - result = await sendAddedContributionMessageEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setSender(senderUser) + .setRecipient(recipientUser) + .setContribution(contribution) + .setType(EmailType.ADDED_CONTRIBUTION_MESSAGE) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -114,14 +104,13 @@ describe('sendEmailVariants', () => { describe('sendAccountActivationEmail', () => { beforeAll(async () => { - result = await sendAccountActivationEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - activationLink: 'http://localhost/checkEmail/6627633878930542284', - timeDurationObject: { hours: 23, minutes: 30 }, - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setActivationLink('http://localhost/checkEmail/6627633878930542284') + .setTimeDurationObject({ hours: 23, minutes: 30 }) + .setType(EmailType.ACCOUNT_ACTIVATION) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -172,12 +161,10 @@ describe('sendEmailVariants', () => { describe('sendAccountMultiRegistrationEmail', () => { beforeAll(async () => { - result = await sendAccountMultiRegistrationEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setType(EmailType.ACCOUNT_MULTI_REGISTRATION) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -226,16 +213,12 @@ describe('sendEmailVariants', () => { describe('sendContributionConfirmedEmail', () => { beforeAll(async () => { - result = await sendContributionConfirmedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - contributionAmount: new Decimal(23.54), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_CONFIRMED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -288,15 +271,12 @@ describe('sendEmailVariants', () => { describe('sendContributionDeniedEmail', () => { beforeAll(async () => { - result = await sendContributionDeniedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_DENIED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -348,15 +328,12 @@ describe('sendEmailVariants', () => { describe('sendContributionDeletedEmail', () => { beforeAll(async () => { - result = await sendContributionDeletedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - contributionMemo: 'My contribution.', - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setContribution(contribution) + .setType(EmailType.CONTRIBUTION_DELETED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -408,14 +385,12 @@ describe('sendEmailVariants', () => { describe('sendResetPasswordEmail', () => { beforeAll(async () => { - result = await sendResetPasswordEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - resetLink: 'http://localhost/reset-password/3762660021544901417', - timeDurationObject: { hours: 23, minutes: 30 }, - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setResetLink('http://localhost/reset-password/3762660021544901417') + .setTimeDurationObject({ hours: 23, minutes: 30 }) + .setType(EmailType.RESET_PASSWORD) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -466,17 +441,12 @@ describe('sendEmailVariants', () => { describe('sendTransactionLinkRedeemedEmail', () => { beforeAll(async () => { - result = await sendTransactionLinkRedeemedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - senderEmail: 'bibi@bloxberg.de', - transactionMemo: 'You deserve it! 🙏🏼', - transactionAmount: new Decimal(17.65), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setTransaction(new Decimal(17.65), 'You deserve it! 🙏🏼') + .setType(EmailType.TRANSACTION_LINK_REDEEMED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { @@ -530,16 +500,12 @@ describe('sendEmailVariants', () => { describe('sendTransactionReceivedEmail', () => { beforeAll(async () => { - result = await sendTransactionReceivedEmail({ - firstName: 'Peter', - lastName: 'Lustig', - email: 'peter@lustig.de', - language: 'en', - senderFirstName: 'Bibi', - senderLastName: 'Bloxberg', - senderEmail: 'bibi@bloxberg.de', - transactionAmount: new Decimal(37.4), - }) + result = await emailBuilder + .setRecipient(recipientUser) + .setSender(senderUser) + .setTransactionAmount(new Decimal(37.4)) + .setType(EmailType.TRANSACTION_RECEIVED) + .sendEmail() }) describe('calls "sendEmailTranslated"', () => { diff --git a/backend/src/emails/Email.builder.ts b/backend/src/emails/Email.builder.ts index 7e137cd48..0129e2ed8 100644 --- a/backend/src/emails/Email.builder.ts +++ b/backend/src/emails/Email.builder.ts @@ -1,5 +1,4 @@ import { Contribution } from '@entity/Contribution' -import { Transaction } from '@entity/Transaction' import { User } from '@entity/User' import { CONFIG } from '@/config' @@ -8,6 +7,7 @@ import { TimeDuration } from '@/util/time' import { decimalSeparatorByLanguage, resetInterface } from '@/util/utilities' import { sendEmailTranslated } from './sendEmailTranslated' +import { Decimal } from 'decimal.js-light' export interface EmailLocals { firstName: string @@ -27,6 +27,7 @@ export interface EmailLocals { resetLink?: string transactionMemo?: string transactionAmount?: string + contributionMemoUpdated?: string [key: string]: string | TimeDuration | undefined } @@ -109,7 +110,12 @@ export class EmailBuilder { this.checkIfFieldsSet(['senderFirstName', 'senderLastName', 'contributionMemo']) break case EmailType.CONTRIBUTION_CHANGED_BY_MODERATOR: - // this.checkIfFieldsSet(['']) + this.checkIfFieldsSet([ + 'contributionMemoUpdated', + 'senderFirstName', + 'senderLastName', + 'contributionMemo', + ]) break case EmailType.RESET_PASSWORD: this.checkIfFieldsSet(['resetLink', 'timeDurationObject', 'resendLink']) @@ -193,15 +199,27 @@ export class EmailBuilder { return this } - public setTransaction(transaction: Transaction): this { - this.locals.transactionMemo = transaction.memo + public setUpdatedContributionMemo(updatedMemo: string): this { + this.locals.contributionMemoUpdated = updatedMemo + return this + } + + public setTransaction(amount: Decimal, memo: string): this { + this.setTransactionMemo(memo) + this.setTransactionAmount(amount) + return this + } + + public setTransactionAmount(amount: Decimal): this { if (!this.locals.locale || this.locals.locale === '') { throw new LogError('missing locale please call setRecipient before') } - this.locals.transactionAmount = decimalSeparatorByLanguage( - transaction.amount, - this.locals.locale, - ) + this.locals.transactionAmount = decimalSeparatorByLanguage(amount, this.locals.locale) + return this + } + + public setTransactionMemo(memo: string): this { + this.locals.transactionMemo = memo return this } diff --git a/backend/src/emails/templates/contributionChangedByModerator/html.pug b/backend/src/emails/templates/contributionChangedByModerator/html.pug new file mode 100644 index 000000000..ed29864ec --- /dev/null +++ b/backend/src/emails/templates/contributionChangedByModerator/html.pug @@ -0,0 +1,10 @@ +extend ../layout.pug + +block content + h2= t('emails.contributionChangedByModerator.title') + .text-block + include ../includes/salutation.pug + p= t('emails.contributionChangedByModerator.commonGoodContributionConfirmed', { contributionMemo, senderFirstName, senderLastName, contributionMemoUpdated }) + .content + include ../includes/contributionDetailsCTA.pug + include ../includes/doNotReply.pug \ No newline at end of file diff --git a/backend/src/emails/templates/contributionChangedByModerator/subject.pug b/backend/src/emails/templates/contributionChangedByModerator/subject.pug new file mode 100644 index 000000000..791cee555 --- /dev/null +++ b/backend/src/emails/templates/contributionChangedByModerator/subject.pug @@ -0,0 +1 @@ += t('emails.contributionChangedByModerator.subject') diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 9dabe1193..775260c84 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -22,11 +22,7 @@ import { OpenCreation } from '@model/OpenCreation' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { RIGHTS } from '@/auth/RIGHTS' -import { - sendContributionConfirmedEmail, - sendContributionDeletedEmail, - sendContributionDeniedEmail, -} from '@/emails/sendEmailVariants' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { EVENT_CONTRIBUTION_CREATE, EVENT_CONTRIBUTION_DELETE, @@ -50,7 +46,6 @@ import { getUserCreation, validateContribution, getOpenCreations } from './util/ import { findContributions } from './util/findContributions' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' -import { EmailBuilder, EmailType } from '@/emails/Email.builder' @Resolver() export class ContributionResolver { diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 8d35708a6..ba259e601 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -22,6 +22,7 @@ import { User } from '@model/User' import { RIGHTS } from '@/auth/RIGHTS' import { CONFIG } from '@/config' +import { EmailBuilder, EmailType } from '@/emails/Email.builder' import { sendTransactionLinkRedeemedEmail, sendTransactionReceivedEmail, @@ -180,28 +181,21 @@ export const executeTransaction = async ( } finally { await queryRunner.release() } - void sendTransactionReceivedEmail({ - firstName: recipient.firstName, - lastName: recipient.lastName, - email: recipient.emailContact.email, - language: recipient.language, - senderFirstName: sender.firstName, - senderLastName: sender.lastName, - senderEmail: sender.emailContact.email, - transactionAmount: amount, - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(recipient) + .setSender(sender) + .setTransactionAmount(amount) + .setType(EmailType.TRANSACTION_RECEIVED) + .sendEmail() + if (transactionLink) { - void sendTransactionLinkRedeemedEmail({ - firstName: sender.firstName, - lastName: sender.lastName, - email: sender.emailContact.email, - language: sender.language, - senderFirstName: recipient.firstName, - senderLastName: recipient.lastName, - senderEmail: recipient.emailContact.email, - transactionAmount: amount, - transactionMemo: memo, - }) + void emailBuilder + .setRecipient(sender) + .setSender(recipient) + .setTransaction(amount, memo) + .setType(EmailType.TRANSACTION_LINK_REDEEMED) + .sendEmail() } logger.info(`finished executeTransaction successfully`) } finally { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index c1561b523..b7c64eed9 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -387,15 +387,13 @@ export class UserResolver { }) logger.info('optInCode for', email, user.emailContact) - - void sendResetPasswordEmail({ - firstName: user.firstName, - lastName: user.lastName, - email, - language: user.language, - resetLink: activationLink(user.emailContact.emailVerificationCode), - timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME), - }) + const emailBuilder = new EmailBuilder() + void emailBuilder + .setRecipient(user) + .setResetLink(activationLink(user.emailContact.emailVerificationCode)) + .setTimeDurationObject(getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME)) + .setType(EmailType.RESET_PASSWORD) + .sendEmail() logger.info(`forgotPassword(${email}) successful...`) await EVENT_EMAIL_FORGOT_PASSWORD(user) diff --git a/backend/src/locales/de.json b/backend/src/locales/de.json index 349c5089e..4bf9fc8be 100644 --- a/backend/src/locales/de.json +++ b/backend/src/locales/de.json @@ -26,6 +26,11 @@ "contribution": { "toSeeContributionsAndMessages": "Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü „Schöpfen“ auf den Tab „Meine Beiträge“." }, + "contributionChangedByModerator": { + "commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} geändert und lautet jetzt „{contributionMemoUpdated}“", + "subject": "Dein Gemeinwohl-Beitrag wurde geändert", + "title": "Dein Gemeinwohl-Beitrag wurde geändert" + }, "contributionConfirmed": { "commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} bestätigt. Es wurden deinem Gradido-Konto {amountGDD} GDD gutgeschrieben.", "subject": "Dein Gemeinwohl-Beitrag wurde bestätigt", diff --git a/backend/yarn.lock b/backend/yarn.lock index 84553d73e..66e4a0687 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -3679,7 +3679,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== "gradido-database@file:../database": - version "1.22.0" + version "2.0.0" dependencies: "@types/uuid" "^8.3.4" cross-env "^7.0.3" @@ -5280,6 +5280,11 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + moo@^0.5.0, moo@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" @@ -5371,6 +5376,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ncp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== + nearley@^2.20.1: version "2.20.1" resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" diff --git a/database/package.json b/database/package.json index efb310a5a..d065233bf 100644 --- a/database/package.json +++ b/database/package.json @@ -8,7 +8,7 @@ "license": "Apache-2.0", "private": false, "scripts": { - "build": "mkdir -p build/src/config/ && cp src/config/*.txt build/src/config/ && tsc --build", + "build": "mkdirp build/src/config/ && ncp src/config build/src/config && tsc --build", "clean": "tsc --build --clean", "up": "cross-env TZ=UTC node build/src/index.js up", "down": "cross-env TZ=UTC node build/src/index.js down", @@ -35,7 +35,9 @@ "eslint-plugin-security": "^1.7.1", "prettier": "^2.8.7", "ts-node": "^10.2.1", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "mkdirp": "^3.0.1", + "ncp": "^2.0.0" }, "dependencies": { "@types/uuid": "^8.3.4", diff --git a/database/yarn.lock b/database/yarn.lock index ac35e1eaa..d8a0d6ffb 100644 --- a/database/yarn.lock +++ b/database/yarn.lock @@ -1718,6 +1718,11 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -1778,6 +1783,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ncp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"