const ghostBookshelf = require('./base'); const crypto = require('crypto'); const _ = require('lodash'); const config = require('../../shared/config'); const {gravatar} = require('../lib/image'); const Member = ghostBookshelf.Model.extend({ tableName: 'members', defaults() { return { status: 'free', uuid: crypto.randomUUID(), transient_id: crypto.randomUUID(), email_count: 0, email_opened_count: 0, enable_comment_notifications: true }; }, filterExpansions() { return [{ key: 'label', replacement: 'labels.slug' }, { key: 'labels', replacement: 'labels.slug' }, { key: 'product', replacement: 'products.slug' }, { key: 'products', replacement: 'products.slug' }, { key: 'tier', replacement: 'products.slug' }, { key: 'tiers', replacement: 'products.slug' }, { key: 'tier_id', replacement: 'products.id' },{ key: 'newsletters', replacement: 'newsletters.slug' }, { key: 'signup', replacement: 'signups.attribution_id' }, { key: 'conversion', replacement: 'conversions.attribution_id' }, { key: 'opened_emails.post_id', replacement: 'emails.post_id', // Currently we cannot expand on values such as null or a string in mongo-knex // But the line below is essentially the same as: `email_recipients.opened_at:-null` expansion: 'email_recipients.opened_at:>=0' }, { key: 'offer_redemptions', replacement: 'offer_redemptions.offer_id' }]; }, filterRelations() { return { labels: { tableName: 'labels', type: 'manyToMany', joinTable: 'members_labels', joinFrom: 'member_id', joinTo: 'label_id' }, products: { tableName: 'products', type: 'manyToMany', joinTable: 'members_products', joinFrom: 'member_id', joinTo: 'product_id' }, newsletters: { tableName: 'newsletters', type: 'manyToMany', joinTable: 'members_newsletters', joinFrom: 'member_id', joinTo: 'newsletter_id' }, subscriptions: { tableName: 'members_stripe_customers_subscriptions', tableNameAs: 'subscriptions', type: 'manyToMany', joinTable: 'members_stripe_customers', joinFrom: 'member_id', joinTo: 'customer_id', joinToForeign: 'customer_id' }, signups: { tableName: 'members_created_events', tableNameAs: 'signups', type: 'oneToOne', joinFrom: 'member_id' }, conversions: { tableName: 'members_subscription_created_events', tableNameAs: 'conversions', type: 'oneToOne', joinFrom: 'member_id' }, clicked_links: { tableName: 'redirects', tableNameAs: 'clicked_links', type: 'manyToMany', joinTable: 'members_click_events', joinFrom: 'member_id', joinTo: 'redirect_id' }, emails: { tableName: 'emails', tableNameAs: 'emails', type: 'manyToMany', joinTable: 'email_recipients', joinFrom: 'member_id', joinTo: 'email_id' }, feedback: { tableName: 'members_feedback', tableNameAs: 'feedback', type: 'oneToOne', joinFrom: 'member_id' }, offer_redemptions: { tableName: 'offer_redemptions', type: 'oneToOne', joinFrom: 'member_id' } }; }, relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients', 'newsletters'], // do not delete email_recipients records when a member is destroyed. Recipient // records are used for analytics and historical records relationshipConfig: { products: { editable: true }, labels: { editable: true }, email_recipients: { destroyRelated: false } }, relationshipBelongsTo: { products: 'products', newsletters: 'newsletters', labels: 'labels', stripeCustomers: 'members_stripe_customers', email_recipients: 'email_recipients', offers: 'offers' }, productEvents() { return this.hasMany('MemberProductEvent', 'member_id', 'id') .query('orderBy', 'created_at', 'DESC'); }, products() { return this.belongsToMany('Product', 'members_products', 'member_id', 'product_id') .withPivot('sort_order', 'expiry_at') .query('orderBy', 'sort_order', 'ASC') .query((qb) => { // avoids bookshelf adding a `DISTINCT` to the query // we know the result set will already be unique and DISTINCT hurts query performance qb.columns('products.*', 'expiry_at'); }); }, newsletters() { return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id') .query('orderBy', 'newsletters.sort_order', 'ASC') .query((qb) => { // avoids bookshelf adding a `DISTINCT` to the query // we know the result set will already be unique and DISTINCT hurts query performance qb.columns('newsletters.*'); }); }, offerRedemptions() { return this.hasMany('OfferRedemption', 'member_id', 'id') .query('orderBy', 'created_at', 'DESC'); }, labels: function labels() { return this.belongsToMany('Label', 'members_labels', 'member_id', 'label_id') .withPivot('sort_order') .query('orderBy', 'sort_order', 'ASC') .query((qb) => { // avoids bookshelf adding a `DISTINCT` to the query // we know the result set will already be unique and DISTINCT hurts query performance qb.columns('labels.*'); }); }, stripeCustomers() { return this.hasMany('MemberStripeCustomer', 'member_id', 'id'); }, stripeSubscriptions() { return this.belongsToMany( 'StripeCustomerSubscription', 'members_stripe_customers', 'member_id', 'customer_id', 'id', 'customer_id' ); }, email_recipients() { return this.hasMany('EmailRecipient', 'member_id', 'id'); }, async updateTierExpiry(products = [], options = {}) { for (const product of products) { if (product?.id) { const expiry = product.expiry_at ? new Date(product.expiry_at) : null; const queryOptions = _.extend({}, options, { query: {where: {product_id: product.id}} }); await this.products().updatePivot({expiry_at: expiry}, queryOptions); } } }, serialize(options) { const defaultSerializedObject = ghostBookshelf.Model.prototype.serialize.call(this, options); if (defaultSerializedObject.stripeSubscriptions) { defaultSerializedObject.subscriptions = defaultSerializedObject.stripeSubscriptions; delete defaultSerializedObject.stripeSubscriptions; } return defaultSerializedObject; }, emitChange: function emitChange(event, options) { const eventToTrigger = 'member' + '.' + event; ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options); }, onCreated: function onCreated(model, options) { ghostBookshelf.Model.prototype.onCreated.apply(this, arguments); model.emitChange('added', options); }, onUpdated: function onUpdated(model, options) { ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments); model.emitChange('edited', options); }, onDestroyed: function onDestroyed(model, options) { ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments); model.emitChange('deleted', options); }, onDestroying: function onDestroyed(model) { ghostBookshelf.Model.prototype.onDestroying.apply(this, arguments); this.handleAttachedModels(model); }, onSaving: function onSaving(model, attr, options) { let labelsToSave = []; if (_.isUndefined(this.get('labels'))) { this.unset('labels'); return; } // CASE: detect lowercase/uppercase label slugs if (!_.isUndefined(this.get('labels')) && !_.isNull(this.get('labels'))) { labelsToSave = []; // and deduplicate upper/lowercase tags _.each(this.get('labels'), function each(item) { item.name = item.name && item.name.trim(); for (let i = 0; i < labelsToSave.length; i = i + 1) { if (labelsToSave[i].name && item.name && labelsToSave[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) { return; } } labelsToSave.push(item); }); this.set('labels', labelsToSave); } this.handleAttachedModels(model); // CASE: Detect existing labels with same case-insensitive name and replace return ghostBookshelf.model('Label') .findAll(Object.assign({ columns: ['id', 'name'] }, _.pick(options, 'transacting'))) .then((labels) => { labelsToSave.forEach((label) => { let existingLabel = labels.find((lab) => { return label.name.toLowerCase() === lab.get('name').toLowerCase(); }); label.name = (existingLabel && existingLabel.get('name')) || label.name; label.id = (existingLabel && existingLabel.id) || label.id; }); model.set('labels', labelsToSave); }); }, handleAttachedModels: function handleAttachedModels(model) { /** * @NOTE: * Bookshelf only exposes the object that is being detached on `detaching`. * For the reason above, `detached` handler is using the scope of `detaching` * to access the models that are not present in `detached`. */ model.related('labels').once('detaching', function onDetaching(collection, label) { model.related('labels').once('detached', function onDetached(detachedCollection, response, options) { label.emitChange('detached', options); model.emitChange('label.detached', options); }); }); model.related('labels').once('attaching', function onDetaching(collection, labels) { model.related('labels').once('attached', function onDetached(detachedCollection, response, options) { labels.forEach((label) => { label.emitChange('attached', options); model.emitChange('label.attached', options); }); }); }); }, /** * The base model keeps only the columns, which are defined in the schema. * We have to add the relations on top, otherwise bookshelf-relations * has no access to the nested relations, which should be updated. */ permittedAttributes: function permittedAttributes() { let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments); this.relationships.forEach((key) => { filteredKeys.push(key); }); return filteredKeys; }, /** * We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always * receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid * that event listeners have to re-fetch a resource. This function is used in the context of inserting * and updating resources. We won't return the relations by default for now. */ defaultRelations: function defaultRelations(methodName, options) { if (['edit', 'add', 'destroy'].indexOf(methodName) !== -1) { options.withRelated = _.union(['labels'], options.withRelated || []); } return options; }, searchQuery: function searchQuery(queryBuilder, query) { queryBuilder.where(function () { this.where('members.name', 'like', `%${query}%`) .orWhere('members.email', 'like', `%${query}%`); }); }, orderRawQuery(field, direction) { if (field === 'email_open_rate') { return { orderByRaw: `members.email_open_rate IS NOT NULL DESC, members.email_open_rate ${direction}` }; } }, toJSON(unfilteredOptions) { const options = Member.filterOptions(unfilteredOptions, 'toJSON'); const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); // Inject a computed avatar url. Uses gravatar's default ?d= query param // to serve a blank image if there is no gravatar for the member's email. // Will not use gravatar if privacy.useGravatar is false in config attrs.avatar_image = null; if (attrs.email && !config.isPrivacyDisabled('useGravatar')) { attrs.avatar_image = gravatar.url(attrs.email, {size: 250, default: 'blank'}); } return attrs; } }, { /** * Returns an array of keys permitted in a method's `options` hash, depending on the current method. * @param {String} methodName The name of the method to check valid options for. * @return {Array} Keys allowed in the `options` hash of the model's method. */ permittedOptions: function permittedOptions(methodName) { let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); if (['findPage', 'findAll'].includes(methodName)) { options = options.concat(['search']); } return options; }, add(data, unfilteredOptions = {}) { if (!unfilteredOptions.transacting) { return ghostBookshelf.transaction((transacting) => { return this.add(data, Object.assign({transacting}, unfilteredOptions)); }); } return ghostBookshelf.Model.add.call(this, data, unfilteredOptions).then(async (member) => { if (data.products) { await member.updateTierExpiry(data.products, _.pick(unfilteredOptions, 'transacting')); } return member; }); }, edit(data, unfilteredOptions = {}) { if (!unfilteredOptions.transacting) { return ghostBookshelf.transaction((transacting) => { return this.edit(data, Object.assign({transacting}, unfilteredOptions)); }); } return ghostBookshelf.Model.edit.call(this, data, unfilteredOptions).then(async (member) => { if (data.products) { await member.updateTierExpiry(data.products, _.pick(unfilteredOptions, 'transacting')); } return member; }); }, destroy(unfilteredOptions = {}) { if (!unfilteredOptions.transacting) { return ghostBookshelf.transaction((transacting) => { return this.destroy(Object.assign({transacting}, unfilteredOptions)); }); } return ghostBookshelf.Model.destroy.call(this, unfilteredOptions); }, getLabelRelations(data, unfilteredOptions = {}) { const query = ghostBookshelf.knex('members_labels') .select('id') .where('label_id', data.labelId) .whereIn('member_id', data.memberIds); if (unfilteredOptions.transacting) { query.transacting(unfilteredOptions.transacting); } return query; }, fetchAllSubscribed(unfilteredOptions = {}) { // we use raw queries instead of model relationships because model hydration is expensive const query = ghostBookshelf.knex('members_newsletters') .join('newsletters', 'members_newsletters.newsletter_id', '=', 'newsletters.id') .join('members', 'members_newsletters.member_id', '=', 'members.id') .where({ 'newsletters.status': 'active', 'members.email_disabled': false }) .distinct('member_id as id'); if (unfilteredOptions.transacting) { query.transacting(unfilteredOptions.transacting); } return query; } }); const Members = ghostBookshelf.Collection.extend({ model: Member }); module.exports = { Member: ghostBookshelf.model('Member', Member), Members: ghostBookshelf.collection('Members', Members) };