mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Added initial Offers module
refs https://github.com/TryGhost/Team/issues/1083 This is the initial scaffolding for setting up Offers in Ghost
This commit is contained in:
parent
b03221401e
commit
5674036902
17 changed files with 855 additions and 0 deletions
10
ghost/offers/.eslintrc.js
Normal file
10
ghost/offers/.eslintrc.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
requireConfigFile: false
|
||||
},
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
21
ghost/offers/LICENSE
Normal file
21
ghost/offers/LICENSE
Normal file
|
@ -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.
|
34
ghost/offers/README.md
Normal file
34
ghost/offers/README.md
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Offers
|
||||
|
||||
## Install
|
||||
|
||||
`npm install @tryghost/offers --save`
|
||||
|
||||
or
|
||||
|
||||
`yarn add @tryghost/offers`
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
This is a mono repository, managed with [lerna](https://lernajs.io/).
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `yarn` to install top-level dependencies.
|
||||
|
||||
|
||||
## Run
|
||||
|
||||
- `yarn dev`
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
- `yarn lint` run just eslint
|
||||
- `yarn test` run lint and tests
|
||||
|
||||
|
27
ghost/offers/index.js
Normal file
27
ghost/offers/index.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
const OfferRepository = require('./lib/OfferRepository');
|
||||
const OffersAPI = require('./lib/OffersAPI');
|
||||
|
||||
class OffersModule {
|
||||
/**
|
||||
* @param {OffersAPI} offersAPI
|
||||
*/
|
||||
constructor(offersAPI) {
|
||||
this.api = offersAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('@tryghost/express-dynamic-redirects')} deps.redirectManager
|
||||
* @param {import('@tryghost/members-stripe-service')} deps.stripeAPIService
|
||||
* @param {any} deps.OfferModel
|
||||
*
|
||||
* @returns {OffersModule}
|
||||
*/
|
||||
static create(deps) {
|
||||
const repository = new OfferRepository(deps.OfferModel, deps.stripeAPIService, deps.redirectManager);
|
||||
const offersAPI = new OffersAPI(repository);
|
||||
return new OffersModule(offersAPI);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OffersModule;
|
14
ghost/offers/jsconfig.json
Normal file
14
ghost/offers/jsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"include": [
|
||||
"index.js",
|
||||
"lib/**/*.js"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"moduleResolution": "node",
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitAny": true
|
||||
}
|
||||
}
|
311
ghost/offers/lib/Offer.js
Normal file
311
ghost/offers/lib/Offer.js
Normal file
|
@ -0,0 +1,311 @@
|
|||
const errors = require('./errors');
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
/**
|
||||
* @typedef {object} OfferProps
|
||||
* @prop {ObjectID} id
|
||||
* @prop {string} name
|
||||
* @prop {string} code
|
||||
* @prop {string} display_title
|
||||
* @prop {string} display_description
|
||||
* @prop {'month'|'year'} cadence
|
||||
* @prop {'percent'|'amount'} type
|
||||
* @prop {number} amount
|
||||
* @prop {string} currency
|
||||
* @prop {OfferTier} tier
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} UniqueChecker
|
||||
* @prop {(code: string) => Promise<boolean>} isUniqueCode
|
||||
* @prop {(code: string) => Promise<boolean>} isUniqueName
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} TierProps
|
||||
* @prop {ObjectID} id
|
||||
* @prop {string} name
|
||||
*/
|
||||
|
||||
class OfferTier {
|
||||
get id() {
|
||||
return this.props.id.toHexString();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TierProps} props
|
||||
*/
|
||||
constructor(props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @returns {OfferTier}
|
||||
*/
|
||||
static create(data) {
|
||||
let id;
|
||||
|
||||
if (data.id instanceof ObjectID) {
|
||||
id = data.id;
|
||||
} else if (typeof data.id === 'string') {
|
||||
id = new ObjectID(data.id);
|
||||
} else {
|
||||
id = new ObjectID();
|
||||
}
|
||||
|
||||
const name = data.name;
|
||||
|
||||
return new OfferTier({
|
||||
id,
|
||||
name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Offer {
|
||||
get id() {
|
||||
return this.props.id.toHexString();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
get code() {
|
||||
return this.props.code;
|
||||
}
|
||||
|
||||
get currency() {
|
||||
return this.props.currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {UniqueChecker} uniqueChecker
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateCode(code, uniqueChecker) {
|
||||
if (code === this.props.code) {
|
||||
return;
|
||||
}
|
||||
if (!await uniqueChecker.isUniqueCode(code)) {
|
||||
throw new errors.InvalidOfferCode({
|
||||
message: 'Offer `code` must be unique.'
|
||||
});
|
||||
}
|
||||
this.changed.code.push(this.props.code);
|
||||
this.props.code = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {UniqueChecker} uniqueChecker
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateName(name, uniqueChecker) {
|
||||
if (name === this.props.name) {
|
||||
return;
|
||||
}
|
||||
if (!await uniqueChecker.isUniqueName(name)) {
|
||||
throw new errors.InvalidOfferNameError({
|
||||
message: 'Offer `name` must be unique.'
|
||||
});
|
||||
}
|
||||
this.changed.name.push(this.props.name);
|
||||
this.props.name = name;
|
||||
}
|
||||
|
||||
get oldCodes() {
|
||||
return this.changed.code;
|
||||
}
|
||||
|
||||
get codeChanged() {
|
||||
return this.changed.code.length > 0;
|
||||
}
|
||||
|
||||
get displayTitle() {
|
||||
return this.props.display_title;
|
||||
}
|
||||
|
||||
set displayTitle(value) {
|
||||
this.props.display_title = value;
|
||||
}
|
||||
|
||||
get displayDescription() {
|
||||
return this.props.display_description;
|
||||
}
|
||||
|
||||
set displayDescription(value) {
|
||||
this.props.display_description = value;
|
||||
}
|
||||
|
||||
get tier() {
|
||||
return this.props.tier;
|
||||
}
|
||||
|
||||
get cadence() {
|
||||
return this.props.cadence;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.props.type;
|
||||
}
|
||||
|
||||
get amount() {
|
||||
if (this.type === 'percent' && this.props.amount === 314) {
|
||||
return 3.14;
|
||||
}
|
||||
return this.props.amount;
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return !!this.options.isNew;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OfferProps} props
|
||||
* @param {object} options
|
||||
* @param {boolean} options.isNew
|
||||
*/
|
||||
constructor(props, options) {
|
||||
/** @private */
|
||||
this.props = props;
|
||||
/** @private */
|
||||
this.options = options;
|
||||
/** @private */
|
||||
this.changed = {
|
||||
code: [],
|
||||
name: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @param {UniqueChecker} uniqueChecker
|
||||
* @returns {Promise<Offer>}
|
||||
*/
|
||||
static async create(data, uniqueChecker) {
|
||||
let isNew = false;
|
||||
let id;
|
||||
|
||||
if (data.id instanceof ObjectID) {
|
||||
id = data.id;
|
||||
} else if (typeof data.id === 'string') {
|
||||
id = new ObjectID(data.id);
|
||||
} else {
|
||||
id = new ObjectID();
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
if (!data.name || typeof data.name !== 'string') {
|
||||
throw new errors.InvalidOfferNameError({
|
||||
message: 'Offer `name` must be a string.'
|
||||
});
|
||||
}
|
||||
|
||||
if (data.name.length > 191) {
|
||||
throw new errors.InvalidOfferNameError({
|
||||
message: 'Offer `name` can be a maximum of 191 characters.'
|
||||
});
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (!await uniqueChecker.isUniqueName(data.name)) {
|
||||
throw new errors.InvalidOfferNameError({
|
||||
message: 'Offer `name` must be unique.'
|
||||
});
|
||||
}
|
||||
}
|
||||
const name = data.name;
|
||||
|
||||
if (!data.display_title || typeof data.display_title !== 'string') {
|
||||
throw new errors.InvalidOfferDisplayTitle({
|
||||
message: 'Offer `display_title` must be a string.'
|
||||
});
|
||||
}
|
||||
|
||||
if (data.display_title.length > 191) {
|
||||
throw new errors.InvalidOfferDisplayTitle({
|
||||
message: 'Offer `display_title` can be a maximum of 191 characters.'
|
||||
});
|
||||
}
|
||||
const title = data.display_title;
|
||||
|
||||
if (!data.display_description || typeof data.display_description !== 'string') {
|
||||
throw new errors.InvalidOfferDisplayDescription({
|
||||
message: 'Offer `display_description` must be a string.'
|
||||
});
|
||||
}
|
||||
|
||||
if (data.display_description.length > 191) {
|
||||
throw new errors.InvalidOfferDisplayDescription({
|
||||
message: 'Offer `display_description` can be a maximum of 191 characters.'
|
||||
});
|
||||
}
|
||||
const description = data.display_description;
|
||||
|
||||
if (isNew) {
|
||||
if (!await uniqueChecker.isUniqueCode(data.code)) {
|
||||
throw new errors.InvalidOfferCode({
|
||||
message: 'Offer `code` must be unique.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const code = data.code;
|
||||
|
||||
if (data.type !== 'percent') {
|
||||
throw new errors.InvalidOfferType({
|
||||
message: 'Offer `type` must be "percent".'
|
||||
});
|
||||
}
|
||||
|
||||
const type = data.type;
|
||||
|
||||
if (data.type === 'percent') {
|
||||
if (data.amount < 0 || data.amount > 100 && data.amount !== 314) {
|
||||
throw new errors.InvalidOfferAmount({
|
||||
message: 'Offer `amount` must be an integer between 0 and 100.'
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(data.amount)) {
|
||||
throw new errors.InvalidOfferAmount({
|
||||
message: 'Offer `amount` must be an integer between 0 and 100.'
|
||||
});
|
||||
}
|
||||
}
|
||||
const amount = data.amount;
|
||||
|
||||
if (data.cadence !== 'month' && data.cadence !== 'year') {
|
||||
throw new errors.InvalidOfferCadence({
|
||||
message: 'Offer `cadence` must be one of "month" or "year".'
|
||||
});
|
||||
}
|
||||
|
||||
const cadence = data.cadence;
|
||||
const currency = data.currency;
|
||||
|
||||
const tier = OfferTier.create(data.tier);
|
||||
|
||||
return new Offer({
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
display_title: title,
|
||||
display_description: description,
|
||||
type,
|
||||
amount,
|
||||
cadence,
|
||||
currency,
|
||||
tier
|
||||
}, {isNew});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Offer;
|
52
ghost/offers/lib/OfferMapper.js
Normal file
52
ghost/offers/lib/OfferMapper.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @typedef {import('./Offer')} Offer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} OfferDTO
|
||||
* @prop {string} id
|
||||
* @prop {string} name
|
||||
* @prop {string} code
|
||||
*
|
||||
* @prop {string} display_title
|
||||
* @prop {string} display_description
|
||||
*
|
||||
* @prop {'percent'|'amount'} type
|
||||
*
|
||||
* @prop {'month'|'year'} cadence
|
||||
* @prop {number} amount
|
||||
*
|
||||
* @prop {boolean} currency_restriction
|
||||
* @prop {string} currency
|
||||
*
|
||||
* @prop {object} tier
|
||||
* @prop {string} tier.id
|
||||
* @prop {string} tier.name
|
||||
*/
|
||||
|
||||
class OfferMapper {
|
||||
/**
|
||||
* @param {Offer} offer
|
||||
* @returns {OfferDTO}
|
||||
*/
|
||||
static toDTO(offer) {
|
||||
return {
|
||||
id: offer.id,
|
||||
name: offer.name,
|
||||
code: offer.code,
|
||||
display_title: offer.displayTitle,
|
||||
display_description: offer.displayDescription,
|
||||
type: offer.type,
|
||||
cadence: offer.cadence,
|
||||
amount: offer.amount,
|
||||
currency_restriction: offer.type === 'amount',
|
||||
currency: offer.type === 'amount' ? offer.currency : null,
|
||||
tier: {
|
||||
id: offer.tier.id,
|
||||
name: offer.tier.name
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OfferMapper;
|
156
ghost/offers/lib/OfferRepository.js
Normal file
156
ghost/offers/lib/OfferRepository.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
const Offer = require('./Offer');
|
||||
|
||||
/**
|
||||
* @typedef {object} OfferRepositoryOptions
|
||||
* @prop {import('knex').Transaction} transacting
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @returns {Offer.OfferProps}
|
||||
*/
|
||||
function toDomain(json) {
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
code: json.code,
|
||||
display_title: json.portal_title,
|
||||
display_description: json.portal_description,
|
||||
type: json.discount_type,
|
||||
amount: json.discount_amount,
|
||||
cadence: json.interval,
|
||||
currency: json.currency,
|
||||
tier: {
|
||||
id: json.product.id,
|
||||
name: json.product.name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class OfferRepository {
|
||||
/**
|
||||
* @param {{forge: (data: object) => import('bookshelf').Model<Offer.OfferProps>}} OfferModel
|
||||
* @param {import('@tryghost/members-stripe-service')} stripeAPIService
|
||||
* @param {import('@tryghost/express-dynamic-redirects')} redirectManager
|
||||
*/
|
||||
constructor(OfferModel, stripeAPIService, redirectManager) {
|
||||
/** @private */
|
||||
this.OfferModel = OfferModel;
|
||||
/** @private */
|
||||
this.stripeAPIService = stripeAPIService;
|
||||
/** @private */
|
||||
this.redirectManager = redirectManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {(t: import('knex').Transaction) => Promise<T>} cb
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
async createTransaction(cb) {
|
||||
return this.OfferModel.transaction(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {OfferRepositoryOptions} options
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async existsByName(name, options) {
|
||||
const model = await this.OfferModel.findOne({name}, options);
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {OfferRepositoryOptions} options
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async existsByCode(code, options) {
|
||||
const model = await this.OfferModel.findOne({code}, options);
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {OfferRepositoryOptions} options
|
||||
* @returns {Promise<Offer>}
|
||||
*/
|
||||
async getById(id, options) {
|
||||
const model = await this.OfferModel.findOne({id}, {
|
||||
...options,
|
||||
withRelated: ['product']
|
||||
});
|
||||
|
||||
const json = model.toJSON();
|
||||
|
||||
return Offer.create(toDomain(json));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OfferRepositoryOptions} options
|
||||
* @returns {Promise<Offer[]>}
|
||||
*/
|
||||
async getAll(options) {
|
||||
const models = await this.OfferModel.findAll({
|
||||
...options,
|
||||
withRelated: ['product']
|
||||
});
|
||||
return Promise.all(models.toJSON().map(toDomain).map(Offer.create));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Offer} offer
|
||||
* @param {OfferRepositoryOptions} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(offer, options) {
|
||||
const model = this.OfferModel.forge({
|
||||
id: offer.id,
|
||||
name: offer.name,
|
||||
code: offer.code,
|
||||
portal_title: offer.displayTitle,
|
||||
portal_description: offer.displayDescription,
|
||||
discount_type: offer.type,
|
||||
discount_amount: offer.amount,
|
||||
interval: offer.cadence,
|
||||
product_id: offer.tier.id,
|
||||
duration: 'once'
|
||||
});
|
||||
|
||||
if (offer.codeChanged) {
|
||||
offer.oldCodes.forEach((code) => {
|
||||
this.redirectManager.removeRedirect(code);
|
||||
});
|
||||
this.redirectManager.addRedirect(offer.code, `/#/portal/offers/${offer.id}`, {
|
||||
permanent: false
|
||||
});
|
||||
}
|
||||
|
||||
if (offer.isNew) {
|
||||
/** @type {import('stripe').Stripe.CouponCreateParams} */
|
||||
const coupon = {
|
||||
name: offer.name,
|
||||
duration: 'once'
|
||||
};
|
||||
|
||||
if (offer.type === 'percent') {
|
||||
coupon.percent_off = offer.amount;
|
||||
}
|
||||
|
||||
const couponData = await this.stripeAPIService.createCoupon(coupon);
|
||||
model.set('stripe_coupon_id', couponData.id);
|
||||
await model.save(null, {method: 'insert', ...options});
|
||||
} else {
|
||||
await model.save(null, {method: 'update', ...options});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OfferRepository;
|
86
ghost/offers/lib/OffersAPI.js
Normal file
86
ghost/offers/lib/OffersAPI.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
const Offer = require('./Offer');
|
||||
const OfferMapper = require('./OfferMapper');
|
||||
const UniqueChecker = require('./UniqueChecker');
|
||||
|
||||
class OffersAPI {
|
||||
/**
|
||||
* @param {import('./OfferRepository')} repository
|
||||
*/
|
||||
constructor(repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
*
|
||||
* @returns {Promise<OfferMapper.OfferDTO>}
|
||||
*/
|
||||
async createOffer(data) {
|
||||
return this.repository.createTransaction(async (transaction) => {
|
||||
const options = {transacting: transaction};
|
||||
const uniqueChecker = new UniqueChecker(this.repository, transaction);
|
||||
|
||||
const offer = await Offer.create(data, uniqueChecker);
|
||||
|
||||
await this.repository.save(offer, options);
|
||||
|
||||
return OfferMapper.toDTO(offer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
* @param {string} data.id
|
||||
* @param {string} [data.name]
|
||||
* @param {string} [data.title]
|
||||
* @param {string} [data.description]
|
||||
* @param {string} [data.code]
|
||||
*
|
||||
* @returns {Promise<OfferMapper.OfferDTO>}
|
||||
*/
|
||||
async updateOffer(data) {
|
||||
return await this.repository.createTransaction(async (transaction) => {
|
||||
const options = {transacting: transaction};
|
||||
const uniqueChecker = new UniqueChecker(this.repository, transaction);
|
||||
|
||||
const offer = await this.repository.getById(data.id, options);
|
||||
|
||||
if (data.name) {
|
||||
offer.updateName(data.name, uniqueChecker);
|
||||
}
|
||||
|
||||
if (data.code) {
|
||||
offer.updateCode(data.code, uniqueChecker);
|
||||
}
|
||||
|
||||
if (data.title) {
|
||||
offer.displayTitle = data.title;
|
||||
}
|
||||
|
||||
if (data.description) {
|
||||
offer.displayDescription = data.description;
|
||||
}
|
||||
|
||||
await this.repository.save(offer, options);
|
||||
|
||||
transaction.commit();
|
||||
|
||||
return OfferMapper.toDTO(offer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<OfferMapper.OfferDTO[]>}
|
||||
*/
|
||||
async listOffers() {
|
||||
return await this.repository.createTransaction(async (transaction) => {
|
||||
const options = {transacting: transaction};
|
||||
|
||||
const offers = await this.repository.getAll(options);
|
||||
|
||||
return offers.map(OfferMapper.toDTO);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OffersAPI;
|
30
ghost/offers/lib/UniqueChecker.js
Normal file
30
ghost/offers/lib/UniqueChecker.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
class UniqueChecker {
|
||||
/**
|
||||
* @param {import('./OfferRepository')} repository
|
||||
* @param {import('knex').Transaction} transaction
|
||||
*/
|
||||
constructor(repository, transaction) {
|
||||
this.repository = repository;
|
||||
this.options = {
|
||||
transacting: transaction
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isUniqueCode(code) {
|
||||
return await this.repository.existsByCode(code, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isUniqueName(name) {
|
||||
return await this.repository.existsByName(name, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UniqueChecker;
|
37
ghost/offers/lib/errors.js
Normal file
37
ghost/offers/lib/errors.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
const {GhostError} = require('@tryghost/errors');
|
||||
|
||||
class InvalidPropError extends GhostError {
|
||||
static message = 'Invalid Offer property';
|
||||
/** @param {any} options */
|
||||
constructor(options) {
|
||||
super({
|
||||
statusCode: 400
|
||||
});
|
||||
this.errorType = this.constructor.name;
|
||||
this.message = options.message || this.constructor.message;
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidOfferNameError extends InvalidPropError {
|
||||
static message = 'Invalid offer name!';
|
||||
}
|
||||
class InvalidOfferDisplayTitle extends InvalidPropError {}
|
||||
class InvalidOfferDisplayDescription extends InvalidPropError {}
|
||||
class InvalidOfferCode extends InvalidPropError {}
|
||||
class InvalidOfferType extends InvalidPropError {}
|
||||
class InvalidOfferAmount extends InvalidPropError {}
|
||||
class InvalidOfferCurrency extends InvalidPropError {}
|
||||
class InvalidOfferTierName extends InvalidPropError {}
|
||||
class InvalidOfferCadence extends InvalidPropError {}
|
||||
|
||||
module.exports = {
|
||||
InvalidOfferNameError,
|
||||
InvalidOfferDisplayTitle,
|
||||
InvalidOfferDisplayDescription,
|
||||
InvalidOfferCode,
|
||||
InvalidOfferType,
|
||||
InvalidOfferAmount,
|
||||
InvalidOfferCurrency,
|
||||
InvalidOfferCadence,
|
||||
InvalidOfferTierName
|
||||
};
|
29
ghost/offers/package.json
Normal file
29
ghost/offers/package.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@tryghost/members-offers",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Members/tree/main/packages/offers",
|
||||
"author": "Ghost Foundation",
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "echo \"Implement me!\"",
|
||||
"test": "NODE_ENV=testing c8 --check-coverage mocha './test/**/*.test.js'",
|
||||
"lint": "eslint . --ext .js --cache",
|
||||
"posttest": "yarn lint"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.15.7",
|
||||
"c8": "7.9.0",
|
||||
"mocha": "9.1.2",
|
||||
"should": "13.2.3",
|
||||
"sinon": "11.1.2"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
6
ghost/offers/test/.eslintrc.js
Normal file
6
ghost/offers/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
10
ghost/offers/test/hello.test.js
Normal file
10
ghost/offers/test/hello.test.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Switch these lines once there are useful utils
|
||||
// const testUtils = require('./utils');
|
||||
require('./utils');
|
||||
|
||||
describe('Hello world', function () {
|
||||
it('Runs a test', function () {
|
||||
// TODO: Write me!
|
||||
'hello'.should.eql('hello');
|
||||
});
|
||||
});
|
11
ghost/offers/test/utils/assertions.js
Normal file
11
ghost/offers/test/utils/assertions.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Custom Should Assertions
|
||||
*
|
||||
* Add any custom assertions to this file.
|
||||
*/
|
||||
|
||||
// Example Assertion
|
||||
// should.Assertion.add('ExampleAssertion', function () {
|
||||
// this.params = {operator: 'to be a valid Example Assertion'};
|
||||
// this.obj.should.be.an.Object;
|
||||
// });
|
11
ghost/offers/test/utils/index.js
Normal file
11
ghost/offers/test/utils/index.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Test Utilities
|
||||
*
|
||||
* Shared utils for writing tests
|
||||
*/
|
||||
|
||||
// Require overrides - these add globals for tests
|
||||
require('./overrides');
|
||||
|
||||
// Require assertions - adds custom should assertions
|
||||
require('./assertions');
|
10
ghost/offers/test/utils/overrides.js
Normal file
10
ghost/offers/test/utils/overrides.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
// This file is required before any test is run
|
||||
|
||||
// Taken from the should wiki, this is how to make should global
|
||||
// Should is a global in our eslint test config
|
||||
global.should = require('should').noConflict();
|
||||
should.extend();
|
||||
|
||||
// Sinon is a simple case
|
||||
// Sinon is a global in our eslint test config
|
||||
global.sinon = require('sinon');
|
Loading…
Add table
Reference in a new issue