From 660292809911a38c88c4439968840876ec1f5e88 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Mon, 12 Apr 2021 15:30:17 +0100 Subject: [PATCH] Added initial ProductRepository implementation (#259) refs https://github.com/TryGhost/Team/issues/616 WIP but needs to be released so we can start using it as a dependency --- ghost/product-repository/.eslintrc.js | 6 + ghost/product-repository/LICENSE | 21 ++ ghost/product-repository/ProductRepository.js | 195 ++++++++++++++++++ ghost/product-repository/README.md | 3 + ghost/product-repository/package.json | 26 +++ ghost/product-repository/test/.eslintrc.js | 6 + .../test/ProductRepository.test.js | 12 ++ ghost/product-repository/tsconfig.json | 10 + 8 files changed, 279 insertions(+) create mode 100644 ghost/product-repository/.eslintrc.js create mode 100644 ghost/product-repository/LICENSE create mode 100644 ghost/product-repository/ProductRepository.js create mode 100644 ghost/product-repository/README.md create mode 100644 ghost/product-repository/package.json create mode 100644 ghost/product-repository/test/.eslintrc.js create mode 100644 ghost/product-repository/test/ProductRepository.test.js create mode 100644 ghost/product-repository/tsconfig.json diff --git a/ghost/product-repository/.eslintrc.js b/ghost/product-repository/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/product-repository/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/product-repository/LICENSE b/ghost/product-repository/LICENSE new file mode 100644 index 0000000000..366ae5f624 --- /dev/null +++ b/ghost/product-repository/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2021 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/product-repository/ProductRepository.js b/ghost/product-repository/ProductRepository.js new file mode 100644 index 0000000000..c3c1df946e --- /dev/null +++ b/ghost/product-repository/ProductRepository.js @@ -0,0 +1,195 @@ +/** + * @typedef {object} ProductModel + */ + +class ProductRepository { + /** + * @param {object} deps + * @param {any} deps.Product + * @param {any} deps.StripeProduct + * @param {any} deps.StripePrice + * @param {import('@tryghost/members-api/lib/services/stripe-api')} deps.stripeAPIService + */ + constructor({ + Product, + StripeProduct, + StripePrice, + stripeAPIService + }) { + this._Product = Product; + this._StripeProduct = StripeProduct; + this._StripePrice = StripePrice; + this._stripeAPIService = stripeAPIService; + } + + /** + * Retrieves a Product by either stripe_product_id, stripe_price_id, id or slug + * + * @param {{stripe_product_id: string} | {stripe_price_id: string} | {id: string} | {slug: string}} data + * @param {object} options + * + * @returns {Promise} + */ + async get(data, options) { + if ('stripe_product_id' in data) { + const stripeProduct = await this._StripeProduct.findOne({ + stripe_product_id: data.stripe_product_id + }, options); + + if (!stripeProduct) { + return null; + } + + return await stripeProduct.related('product').fetch(options); + } + + if ('stripe_price_id' in data) { + const stripePrice = await this._StripePrice.findOne({ + stripe_price_id: data.stripe_price_id + }, options); + + if (!stripePrice) { + return null; + } + + const stripeProduct = await stripePrice.related('stripeProduct').fetch(options); + + if (!stripeProduct) { + return null; + } + + return await stripeProduct.related('product').fetch(options); + } + + if ('id' in data) { + return await this._Product.findOne({id: data.id}, options); + } + + if ('slug' in data) { + return await this._Product.findOne({slug: data.slug}, options); + } + + throw new Error('Missing id, slug, stripe_product_id or stripe_price_id from data'); + } + + /** + * Creates a product from a name + * + * @param {object} data + * @param {string} data.name + * + * @param {object} options + * + * @returns {Promise} + **/ + async create(data, options) { + const productData = { + name: data.name + }; + + const product = await this._Product.add(productData, options); + + if (this._stripeAPIService.configured) { + const stripeProduct = await this._stripeAPIService.createProduct({ + name: productData.name + }); + + await this._StripeProduct.add({ + product_id: product.id, + stripe_product_id: stripeProduct.id + }, options); + + await product.related('stripeProducts').fetch(options); + } + + return product; + } + + /** + * Updates a product by id + * + * @param {object} data + * @param {string} data.id + * @param {string} data.name + * + * @param {object} data.stripe_price + * @param {string} data.stripe_price.nickname + * @param {string} data.stripe_price.currency + * @param {number} data.stripe_price.amount + * @param {'recurring'|'one-time'} data.stripe_price.type + * @param {string | null} data.stripe_price.interval + * @param {string?} data.stripe_price.stripe_product_id + * + * @param {object} options + * + * @returns {Promise} + **/ + async update(data, options) { + const productData = { + name: data.name + }; + + const product = await this._Product.edit(productData, { + ...options, + id: data.id + }); + + if (this._stripeAPIService.configured && data.stripe_price) { + await product.related('stripeProducts').fetch(options); + + if (!product.related('stripeProducts').first()) { + const stripeProduct = await this._stripeAPIService.createProduct({ + name: productData.name + }); + + await this._StripeProduct.add({ + product_id: product.id, + stripe_product_id: stripeProduct.id + }, options); + + await product.related('stripeProducts').fetch(options); + } + + const defaultStripeProduct = product.related('stripeProducts').first(); + const productId = data.stripe_price.stripe_product_id; + const stripeProduct = productId ? + await this._StripeProduct.findOne({stripe_product_id: productId}, options) : defaultStripeProduct; + + const price = await this._stripeAPIService.createPrice({ + product: defaultStripeProduct.stripe_product_id, + active: true, + nickname: data.stripe_price.nickname, + currency: data.stripe_price.currency, + amount: data.stripe_price.amount, + type: data.stripe_price.type, + interval: data.stripe_price.interval + }); + + await this._StripePrice.add({ + stripe_price_id: price.id, + stripe_product_id: stripeProduct.stripe_product_id + }, options); + + await product.related('stripePrices').fetch(options); + } + + return product; + } + + /** + * Returns a paginated list of Products + * + * @params {object} options + * + * @returns {Promise<{data: ProductModel[], meta: object}>} + **/ + async list(options) { + return this._Product.findPage(options); + } + + async destroy() { + throw new Error('Cannot destroy products, yet...'); + } +} + +module.exports = ProductRepository; diff --git a/ghost/product-repository/README.md b/ghost/product-repository/README.md new file mode 100644 index 0000000000..0a6ccb653e --- /dev/null +++ b/ghost/product-repository/README.md @@ -0,0 +1,3 @@ +# Product Repository + +This module is designed to be used inside the `@tryghost/members-api` module. It is not for public use. diff --git a/ghost/product-repository/package.json b/ghost/product-repository/package.json new file mode 100644 index 0000000000..8fc8564138 --- /dev/null +++ b/ghost/product-repository/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tryghost/product-repository", + "version": "0.1.0", + "repository": "https://github.com/TryGhost/Members/tree/master/packages/product-repository", + "author": "Ghost Foundation", + "license": "MIT", + "main": "ProductRespository.js", + "scripts": { + "test": "NODE_ENV=testing mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "ProductRepository.js" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/mocha": "^8.2.2", + "mocha": "^8.3.2", + "should": "^13.2.3", + "typescript": "^4.2.4" + }, + "dependencies": {} +} diff --git a/ghost/product-repository/test/.eslintrc.js b/ghost/product-repository/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/product-repository/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/product-repository/test/ProductRepository.test.js b/ghost/product-repository/test/ProductRepository.test.js new file mode 100644 index 0000000000..6256df0134 --- /dev/null +++ b/ghost/product-repository/test/ProductRepository.test.js @@ -0,0 +1,12 @@ +const should = require('should'); +const ProductRepository = require('../ProductRepository'); + +describe('ProductRespository', function () { + it('Has create, update, get, list, destroy method', function () { + should.exist(ProductRepository.prototype.create); + should.exist(ProductRepository.prototype.update); + should.exist(ProductRepository.prototype.get); + should.exist(ProductRepository.prototype.list); + should.exist(ProductRepository.prototype.destroy); + }); +}); diff --git a/ghost/product-repository/tsconfig.json b/ghost/product-repository/tsconfig.json new file mode 100644 index 0000000000..ca819cea94 --- /dev/null +++ b/ghost/product-repository/tsconfig.json @@ -0,0 +1,10 @@ +{ + "include": ["index.js", "lib/**/*", "test/**/*"], + "compilerOptions": { + "checkJs": true, + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types" + } +}