From f18b2e88fe9507d47077d3086680a6a36c1c100c Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Wed, 1 Feb 2023 13:32:16 +0700 Subject: [PATCH 1/3] feat: create migration for old discoupon Signed-off-by: vanpho93 --- package.json | 2 +- .../api-plugin-promotions-coupons/index.js | 2 + .../migrations/2.js | 194 ++++++++++++++++++ .../migrations/getCurrentShopTime.js | 31 +++ .../migrations/index.js | 13 ++ .../migrations/migrationsNamespace.js | 1 + .../api-plugin-promotions/src/preStartup.js | 4 + 7 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/api-plugin-promotions-coupons/migrations/2.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/index.js create mode 100644 packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js diff --git a/package.json b/package.json index 776e2a88500..3be3f071141 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "engineStrict": true, "scripts": { - "start:dev": "npm run start:dev -w apps/reaction", + "start:dev": "pnpm --filter=reaction run start:dev", "start:meteor-blaze-app": "npm run start -w=apps/meteor-blaze-app", "build:packages": "pnpm -r run build", "test": "pnpm -r run test", diff --git a/packages/api-plugin-promotions-coupons/index.js b/packages/api-plugin-promotions-coupons/index.js index d7ea8b28c59..ff1789c8e87 100644 --- a/packages/api-plugin-promotions-coupons/index.js +++ b/packages/api-plugin-promotions-coupons/index.js @@ -1,3 +1,5 @@ import register from "./src/index.js"; +export { default as migrations } from "./migrations/index.js"; + export default register; diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js new file mode 100644 index 00000000000..396a128a2b9 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -0,0 +1,194 @@ +/* eslint-disable no-await-in-loop */ +import Random from "@reactioncommerce/random"; +import getCurrentShopTime from "./getCurrentShopTime.js"; + +/** + * @summary returns an auto-incrementing integer id for a specific entity + * @param {Object} db - The db instance + * @param {String} shopId - The shop ID + * @param {String} entity - The entity (normally a collection) that you are tracking the ID for + * @return {Promise} - The auto-incrementing ID to use + */ +async function incrementSequence(db, shopId, entity) { + const { value: { value } } = await db.collection("Sequences").findOneAndUpdate( + { shopId, entity }, + { $inc: { value: 1 } }, + { returnDocument: "after" } + ); + return value; +} + +/** + * @summary Migration current discounts v2 to version 2 + * @param {Object} db MongoDB `Db` instance + * @return {undefined} + */ +async function migrationDiscounts(db) { + const discounts = await db.collection("Discounts").find({}, { _id: 1 }).toArray(); + + // eslint-disable-next-line require-jsdoc + function getDiscountCalculationType(discount) { + if (discount.calculation.method === "discount") return "percentage"; + if (discount.calculation.method === "shipping") return "shipping"; + if (discount.calculation.method === "sale") return "flat"; + return "fixed"; + } + + for (const { _id } of discounts) { + const discount = await db.collection("Discounts").findOne({ _id }); + const promotionId = Random.id(); + + const now = new Date(); + const shopTime = await getCurrentShopTime(db); + + // eslint-disable-next-line no-await-in-loop + await db.collection("Promotions").insertOne({ + _id: promotionId, + shopId: discount.shopId, + name: discount.code, + label: discount.code, + description: discount.code, + promotionType: "order-discount", + actions: [ + { + actionKey: "discounts", + actionParameters: { + discountType: discount.discountType === "sale" ? "order" : "item", + discountCalculationType: getDiscountCalculationType(discount), + discountValue: Number(discount.discount) + } + } + ], + triggers: [ + { + triggerKey: "coupons", + triggerParameters: { + conditions: { + all: [ + { + fact: "totalItemAmount", + operator: "greaterThanInclusive", + value: 0 + } + ] + } + } + } + ], + enabled: discount.conditions.enabled, + stackability: { + key: "all", + parameters: {} + }, + triggerType: "explicit", + state: "active", + startDate: shopTime[discount.shopId], + createdAt: now, + updatedAt: now, + referenceId: await incrementSequence(db, discount.shopId, "Promotions") + }); + + const couponId = Random.id(); + await db.collection("Coupons").insertOne({ + _id: couponId, + shopId: discount.shopId, + promotionId, + name: discount.code, + code: discount.code, + canUseInStore: false, + usedCount: 0, + expirationDate: null, + createdAt: now, + updatedAt: now, + maxUsageTimesPerUser: discount.conditions.accountLimit, + maxUsageTimes: discount.conditions.redemptionLimit, + discountId: discount._id + }); + } +} + +/** + * @summary Migration current discount to promotion and coupon + * @param {Object} db - The db instance + * @param {String} discountId - The discount ID + * @returns {Object} - The promotion + */ +async function getPromotionByDiscountId(db, discountId) { + const coupon = await db.collection("Coupons").findOne({ discountId }); + if (!coupon) return null; + const promotion = await db.collection("Promotions").findOne({ _id: coupon.promotionId }); + if (!promotion) return null; + + promotion.relatedCoupon = { + couponId: coupon._id, + couponCode: coupon.code + }; + + return promotion; +} + +/** + * @summary Migration current cart v1 to version 2 + * @param {Object} db - The db instance + * @returns {undefined} + */ +async function migrateCart(db) { + const carts = await db.collection("Cart").find({}, { _id: 1 }).toArray(); + + for (const { _id } of carts) { + const cart = await db.findOne({ _id }); + if (cart.version && cart.version === 2) continue; + + if (!cart.billing) continue; + + if (!cart.appliedPromotions) cart.appliedPromotions = []; + + for (const billing of cart.billing) { + if (!billing.data || !billing.data.discountId) continue; + const promotion = await getPromotionByDiscountId(db, billing.data.discountId); + cart.appliedPromotions.push(promotion); + } + + cart.version = 2; + await db.collection("Cart").updateOne({ _id: cart._id }, { $set: cart }); + } +} + +/** + * @summary Performs migration up from previous data version + * @param {Object} context Migration context + * @param {Object} context.db MongoDB `Db` instance + * @param {Function} context.progress A function to report progress, takes percent + * number as argument. + * @return {undefined} + */ +async function up({ db, progress }) { + try { + await migrationDiscounts(db); + } catch (err) { + throw new Error("Failed to migrate discounts", err.message); + } + + progress(50); + + try { + await migrateCart(db); + } catch (err) { + throw new Error("Failed to migrate cart", err.message); + } + progress(100); +} + +/** + * @summary Performs migration down from previous data version + * @param {Object} context Migration context + * @param {Object} context.db MongoDB `Db` instance + * @param {Function} context.progress A function to report progress, takes percent + * number as argument. + * @return {undefined} + */ +async function down({ progress }) { + progress(100); +} + +export default { down, up }; diff --git a/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js b/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js new file mode 100644 index 00000000000..2b2bcd738ce --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/getCurrentShopTime.js @@ -0,0 +1,31 @@ +/** + * @summary if no data in cache, repopulate + * @param {Object} db - The db instance + * @return {Promise<{Object}>} - The shop timezone object after pushing data to cache + */ +async function populateCache(db) { + const Shops = db.collection("Shops"); + const shopTzObject = {}; + const shops = await Shops.find({}).toArray(); + for (const shop of shops) { + const { _id: shopId } = shop; + shopTzObject[shopId] = shop.timezone; + } + return shopTzObject; +} + +/** + * @summary get the current time in the shops timezone + * @param {Object} db - The db instance + * @return {Promise<{Object}>} - Object of shops and their current time in their timezone + */ +export default async function getCurrentShopTime(db) { + const shopTzData = await populateCache(db); + const shopNow = {}; + for (const shop of Object.keys(shopTzData)) { + const now = new Date().toLocaleString("en-US", { timeZone: shopTzData[shop] }); + const nowDate = new Date(now); + shopNow[shop] = nowDate; + } + return shopNow; +} diff --git a/packages/api-plugin-promotions-coupons/migrations/index.js b/packages/api-plugin-promotions-coupons/migrations/index.js new file mode 100644 index 00000000000..d6ef9ab5586 --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/index.js @@ -0,0 +1,13 @@ +import { migrationsNamespace } from "./migrationsNamespace.js"; +import migration2 from "./2.js"; + +export default { + tracks: [ + { + namespace: migrationsNamespace, + migrations: { + 2: migration2 + } + } + ] +}; diff --git a/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js b/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js new file mode 100644 index 00000000000..7e4d90470cc --- /dev/null +++ b/packages/api-plugin-promotions-coupons/migrations/migrationsNamespace.js @@ -0,0 +1 @@ +export const migrationsNamespace = "promotion-coupons"; diff --git a/packages/api-plugin-promotions/src/preStartup.js b/packages/api-plugin-promotions/src/preStartup.js index 483926a8ff5..4f2eb28409e 100644 --- a/packages/api-plugin-promotions/src/preStartup.js +++ b/packages/api-plugin-promotions/src/preStartup.js @@ -22,6 +22,10 @@ function extendCartSchema(context) { const { simpleSchemas: { Cart, Promotion } } = context; // we get this here rather then importing it to get the extended version Cart.extend({ + "version": { + type: Number, + optional: true + }, "appliedPromotions": { type: Array, optional: true From 919caa1d537920047c7a871ff02e787b6add06db Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Thu, 9 Feb 2023 13:47:49 +0700 Subject: [PATCH 2/3] fix: add migration down method Signed-off-by: vanpho93 --- package.json | 2 +- .../migrations/2.js | 23 +++++++++++++++++-- .../package.json | 2 +- .../src/preStartup.js | 23 +++++++++++++++++++ .../src/schemas/schema.graphql | 3 +++ .../src/simpleSchemas.js | 4 ++++ pnpm-lock.yaml | 11 +++++---- 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 3be3f071141..776e2a88500 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "engineStrict": true, "scripts": { - "start:dev": "pnpm --filter=reaction run start:dev", + "start:dev": "npm run start:dev -w apps/reaction", "start:meteor-blaze-app": "npm run start -w=apps/meteor-blaze-app", "build:packages": "pnpm -r run build", "test": "pnpm -r run test", diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js index 396a128a2b9..b1917d4b24f 100644 --- a/packages/api-plugin-promotions-coupons/migrations/2.js +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -166,7 +166,7 @@ async function up({ db, progress }) { try { await migrationDiscounts(db); } catch (err) { - throw new Error("Failed to migrate discounts", err.message); + throw new Error(`Failed to migrate discounts: ${err.message}`); } progress(50); @@ -187,7 +187,26 @@ async function up({ db, progress }) { * number as argument. * @return {undefined} */ -async function down({ progress }) { +async function down({ db, progress }) { + const coupons = await db.collection("Coupons").find( + { discountId: { $exists: true } }, + { _id: 1, promotionId: 1 } + ).toArray(); + + const couponIds = coupons.map((coupon) => coupon._id); + await db.collection("Coupons").remove({ _id: { $in: couponIds } }); + + const promotionIds = coupons.map((coupon) => coupon.promotionId); + await db.collection("Promotions").remove({ _id: { $in: promotionIds } }); + + const carts = await db.collection("Cart").find({ version: 2 }, { _id: 1 }).toArray(); + for (const { _id } of carts) { + const cart = await db.collection("Cart").findOne({ _id }); + cart.appliedPromotions.length = 0; + cart.version = 1; + await db.collection("Cart").updateOne({ _id: cart._id }, { $set: cart }); + } + progress(100); } diff --git a/packages/api-plugin-promotions-coupons/package.json b/packages/api-plugin-promotions-coupons/package.json index c92577b8387..03cee59902a 100644 --- a/packages/api-plugin-promotions-coupons/package.json +++ b/packages/api-plugin-promotions-coupons/package.json @@ -26,6 +26,7 @@ "sideEffects": false, "dependencies": { "@reactioncommerce/api-utils": "^1.16.9", + "@reactioncommerce/db-version-check": "workspace:^1.0.0", "@reactioncommerce/logger": "^1.1.3", "@reactioncommerce/random": "^1.0.2", "@reactioncommerce/reaction-error": "^1.0.1", @@ -34,7 +35,6 @@ "lodash": "^4.17.21", "simpl-schema": "^1.12.2" }, - "devDependencies": {}, "scripts": { "lint": "npm run lint:eslint", "lint:eslint": "eslint .", diff --git a/packages/api-plugin-promotions-coupons/src/preStartup.js b/packages/api-plugin-promotions-coupons/src/preStartup.js index 8a59a5ae2e1..6a71c42388e 100644 --- a/packages/api-plugin-promotions-coupons/src/preStartup.js +++ b/packages/api-plugin-promotions-coupons/src/preStartup.js @@ -1,7 +1,11 @@ import _ from "lodash"; import SimpleSchema from "simpl-schema"; +import doesDatabaseVersionMatch from "@reactioncommerce/db-version-check"; +import { migrationsNamespace } from "../migrations/migrationsNamespace.js"; import { CouponTriggerCondition, CouponTriggerParameters } from "./simpleSchemas.js"; +const expectedVersion = 2; + /** * @summary This is a preStartup function that is called before the app starts up. * @param {Object} context - The application context @@ -39,4 +43,23 @@ export default async function preStartupPromotionCoupon(context) { Cart.extend({ "appliedPromotions.$": copiedPromotion }); + + const setToExpectedIfMissing = async () => { + const anyDiscount = await context.collections.Discounts.findOne(); + return !anyDiscount; + }; + const ok = await doesDatabaseVersionMatch({ + // `db` is a Db instance from the `mongodb` NPM package, + // such as what is returned when you do `client.db()` + db: context.app.db, + // These must match one of the namespaces and versions + // your package exports in the `migrations` named export + expectedVersion, + namespace: migrationsNamespace, + setToExpectedIfMissing + }); + + if (!ok) { + throw new Error(`Database needs migrating. The "${migrationsNamespace}" namespace must be at version ${expectedVersion}. See docs for more information on migrations: https://github.com/reactioncommerce/api-migrations`); + } } diff --git a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql index ea943c7c465..13180ab101d 100644 --- a/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql +++ b/packages/api-plugin-promotions-coupons/src/schemas/schema.graphql @@ -34,6 +34,9 @@ type Coupon { "Coupon updated time" updatedAt: Date! + + "Related discount ID" + discountId: ID } extend type Promotion { diff --git a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js index e3a0b6c9deb..26e2eeef401 100644 --- a/packages/api-plugin-promotions-coupons/src/simpleSchemas.js +++ b/packages/api-plugin-promotions-coupons/src/simpleSchemas.js @@ -57,6 +57,10 @@ export const Coupon = new SimpleSchema({ }, updatedAt: { type: Date + }, + discountId: { + type: String, + optional: true } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bd2bd419dc..ef2798f5048 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1081.0 + '@snyk/protect': 1.1100.0 graphql: 14.7.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -1061,6 +1061,7 @@ importers: packages/api-plugin-promotions-coupons: specifiers: '@reactioncommerce/api-utils': ^1.16.9 + '@reactioncommerce/db-version-check': workspace:^1.0.0 '@reactioncommerce/logger': ^1.1.3 '@reactioncommerce/random': ^1.0.2 '@reactioncommerce/reaction-error': ^1.0.1 @@ -1070,6 +1071,7 @@ importers: simpl-schema: ^1.12.2 dependencies: '@reactioncommerce/api-utils': link:../api-utils + '@reactioncommerce/db-version-check': link:../db-version-check '@reactioncommerce/logger': link:../logger '@reactioncommerce/random': link:../random '@reactioncommerce/reaction-error': link:../reaction-error @@ -4861,8 +4863,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1081.0: - resolution: {integrity: sha512-V+4DJPLorQph9j78PB3qpxOEREzXHJN/txg2Cxn2EGw+7IWOPPeLgUb4jO+tjVVmqMYmrvohMDQKErcjIxVqVg==} + /@snyk/protect/1.1100.0: + resolution: {integrity: sha512-nbdLbao8fqrgeWwO+RQj3Bdz0qoSeuYElHNjmH0fMiKJ1/MxR8tcma8P2xHXkDW6wYbYlUznf8gGhxAXDBe+wg==} engines: {node: '>=10'} hasBin: true dev: false @@ -9281,7 +9283,7 @@ packages: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: graphql: 14.7.0 - tslib: 2.4.0 + tslib: 2.4.1 /graphql-tools/4.0.5_graphql@14.7.0: resolution: {integrity: sha512-kQCh3IZsMqquDx7zfIGWBau42xe46gmqabwYkpPlCLIjcEY1XK+auP7iGRD9/205BPyoQdY8hT96MPpgERdC9Q==} @@ -14195,7 +14197,6 @@ packages: /tslib/2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} - dev: false /tsutils/3.21.0_typescript@2.9.2: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} From f2a2321a3661d0cf53743e5c21dc2ac219beb112 Mon Sep 17 00:00:00 2001 From: vanpho93 Date: Fri, 10 Feb 2023 09:26:37 +0700 Subject: [PATCH 3/3] fix: migration up error Signed-off-by: vanpho93 --- packages/api-plugin-promotions-coupons/migrations/2.js | 4 ++-- pnpm-lock.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api-plugin-promotions-coupons/migrations/2.js b/packages/api-plugin-promotions-coupons/migrations/2.js index b1917d4b24f..3187006898c 100644 --- a/packages/api-plugin-promotions-coupons/migrations/2.js +++ b/packages/api-plugin-promotions-coupons/migrations/2.js @@ -136,7 +136,7 @@ async function migrateCart(db) { const carts = await db.collection("Cart").find({}, { _id: 1 }).toArray(); for (const { _id } of carts) { - const cart = await db.findOne({ _id }); + const cart = await db.collection("Cart").findOne({ _id }); if (cart.version && cart.version === 2) continue; if (!cart.billing) continue; @@ -174,7 +174,7 @@ async function up({ db, progress }) { try { await migrateCart(db); } catch (err) { - throw new Error("Failed to migrate cart", err.message); + throw new Error(`Failed to migrate cart: ${err.message}`); } progress(100); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2798f5048..66b686bacde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: '@reactioncommerce/file-collections-sa-gridfs': link:../../packages/file-collections-sa-gridfs '@reactioncommerce/logger': link:../../packages/logger '@reactioncommerce/random': link:../../packages/random - '@snyk/protect': 1.1100.0 + '@snyk/protect': 1.1081.0 graphql: 14.7.0 nodemailer: 6.8.0 semver: 6.3.0 @@ -4863,8 +4863,8 @@ packages: '@sinonjs/commons': 1.8.3 dev: false - /@snyk/protect/1.1100.0: - resolution: {integrity: sha512-nbdLbao8fqrgeWwO+RQj3Bdz0qoSeuYElHNjmH0fMiKJ1/MxR8tcma8P2xHXkDW6wYbYlUznf8gGhxAXDBe+wg==} + /@snyk/protect/1.1081.0: + resolution: {integrity: sha512-V+4DJPLorQph9j78PB3qpxOEREzXHJN/txg2Cxn2EGw+7IWOPPeLgUb4jO+tjVVmqMYmrvohMDQKErcjIxVqVg==} engines: {node: '>=10'} hasBin: true dev: false