Ghost/ghost/members-api/lib/MembersAPI.js
Ronald Langeveld e0787b4e83
Added specific newsletter support for bulk unsubscribes (#15742)
closes https://github.com/TryGhost/Team/issues/2013

Added support to bulk unsubscribe a selected (filtered) list on members from specific, selected newsletters.
2022-11-16 14:29:00 +07:00

365 lines
11 KiB
JavaScript

const {Router} = require('express');
const body = require('body-parser');
const MagicLink = require('@tryghost/magic-link');
const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const PaymentsService = require('@tryghost/members-payments');
const TokenService = require('./services/token');
const GeolocationSerice = require('./services/geolocation');
const MemberBREADService = require('./services/member-bread');
const MemberRepository = require('./repositories/member');
const EventRepository = require('./repositories/event');
const ProductRepository = require('./repositories/product');
const RouterController = require('./controllers/router');
const MemberController = require('./controllers/member');
const WellKnownController = require('./controllers/well-known');
module.exports = function MembersAPI({
tokenConfig: {
issuer,
privateKey,
publicKey
},
auth: {
allowSelfSignup = () => true,
getSigninURL,
tokenProvider
},
mail: {
transporter,
getText,
getHTML,
getSubject
},
models: {
EmailRecipient,
StripeCustomer,
StripeCustomerSubscription,
Member,
MemberNewsletter,
MemberCancelEvent,
MemberSubscribeEvent,
MemberLoginEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberProductEvent,
MemberEmailChangeEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
Offer,
OfferRedemption,
StripeProduct,
StripePrice,
Product,
Settings,
Comment,
MemberFeedback
},
tiersService,
stripeAPIService,
offersAPI,
labsService,
newslettersService,
memberAttributionService
}) {
const tokenService = new TokenService({
privateKey,
publicKey,
issuer
});
const productRepository = new ProductRepository({
Product,
Settings,
StripeProduct,
StripePrice,
stripeAPIService
});
const memberRepository = new MemberRepository({
stripeAPIService,
tokenService,
newslettersService,
labsService,
productRepository,
Member,
MemberNewsletter,
MemberCancelEvent,
MemberSubscribeEventModel: MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberEmailChangeEvent,
MemberStatusEvent,
MemberProductEvent,
OfferRedemption,
StripeCustomer,
StripeCustomerSubscription,
offerRepository: offersAPI.repository
});
const eventRepository = new EventRepository({
EmailRecipient,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,
MemberPaymentEvent,
MemberStatusEvent,
MemberLoginEvent,
MemberCreatedEvent,
SubscriptionCreatedEvent,
MemberLinkClickEvent,
MemberFeedback,
Comment,
labsService,
memberAttributionService
});
const memberBREADService = new MemberBREADService({
offersAPI,
memberRepository,
emailService: {
async sendEmailWithMagicLink({email, requestedType}) {
return sendEmailWithMagicLink({
email,
requestedType,
options: {
forceEmailType: true
}
});
}
},
labsService,
stripeService: stripeAPIService,
memberAttributionService
});
const geolocationService = new GeolocationSerice();
const magicLinkService = new MagicLink({
transporter,
tokenProvider,
getSigninURL,
getText,
getHTML,
getSubject
});
const paymentsService = new PaymentsService({
StripeProduct,
StripePrice,
StripeCustomer,
Offer,
offersAPI,
stripeAPIService
});
const memberController = new MemberController({
memberRepository,
productRepository,
paymentsService,
tiersService,
StripePrice,
tokenService,
sendEmailWithMagicLink
});
const routerController = new RouterController({
offersAPI,
paymentsService,
tiersService,
memberRepository,
StripePrice,
allowSelfSignup,
magicLinkService,
stripeAPIService,
tokenService,
sendEmailWithMagicLink,
memberAttributionService,
labsService
});
const wellKnownController = new WellKnownController({
tokenService
});
const users = memberRepository;
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
let type = requestedType;
if (!options.forceEmailType) {
const member = await users.get({email});
if (member) {
type = 'signin';
} else if (type !== 'subscribe') {
type = 'signup';
}
}
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
}
/**
*
* @param {string} email
* @param {'signin'|'signup'} type When you specify 'signin' this will prevent the creation of a new member if no member is found with the provided email
* @param {*} [tokenData] Optional token data to add to the token
* @returns
*/
function getMagicLink(email, type, tokenData = {}) {
return magicLinkService.getMagicLink({
tokenData: {email, ...tokenData},
type
});
}
async function getTokenDataFromMagicLinkToken(token) {
return await magicLinkService.getDataFromToken(token);
}
async function getMemberDataFromMagicLinkToken(token) {
const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token);
if (!email) {
return null;
}
const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
if (member) {
await MemberLoginEvent.add({member_id: member.id});
if (oldEmail && (!type || type === 'updateEmail')) {
// user exists but wants to change their email address
await users.update({email}, {id: member.id});
return getMemberIdentityData(email);
}
return member;
}
// Note: old tokens can still have a missing type (we can remove this after a couple of weeks)
if (type && !['signup', 'subscribe'].includes(type)) {
// Don't allow sign up
// Note that we use the type from inside the magic token so this behaviour can't be changed
return null;
}
let geolocation;
if (reqIp) {
try {
geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(reqIp));
} catch (err) {
logging.warn(err);
// no-op, we don't want to stop anything working due to
// geolocation lookup failing
}
}
const newMember = await users.create({name, email, labels, newsletters, attribution, geolocation});
await MemberLoginEvent.add({member_id: newMember.id});
return getMemberIdentityData(email);
}
async function getMemberIdentityData(email) {
return memberBREADService.read({email});
}
async function getMemberIdentityToken(email) {
const member = await getMemberIdentityData(email);
if (!member) {
return null;
}
return tokenService.encodeIdentityToken({sub: member.email});
}
async function setMemberGeolocationFromIp(email, ip) {
if (!email || !ip) {
throw new errors.IncorrectUsageError({
message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
});
}
// toJSON() is needed here otherwise users.update() will pick methods off
// the model object rather than data and fail to edit correctly
const member = (await users.get({email})).toJSON();
if (!member) {
throw new errors.NotFoundError({
message: `Member with email address ${email} does not exist`
});
}
// max request time is 500ms so shouldn't slow requests down too much
let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
if (geolocation) {
await users.update({geolocation}, {id: member.id});
}
return getMemberIdentityData(email);
}
const forwardError = fn => async (req, res, next) => {
try {
await fn(req, res, next);
} catch (err) {
next(err);
}
};
const middleware = {
sendMagicLink: Router().use(
body.json(),
forwardError((req, res) => routerController.sendMagicLink(req, res))
),
createCheckoutSession: Router().use(
body.json(),
forwardError((req, res) => routerController.createCheckoutSession(req, res))
),
createCheckoutSetupSession: Router().use(
body.json(),
forwardError((req, res) => routerController.createCheckoutSetupSession(req, res))
),
updateEmailAddress: Router().use(
body.json(),
forwardError((req, res) => memberController.updateEmailAddress(req, res))
),
updateSubscription: Router({mergeParams: true}).use(
body.json(),
forwardError((req, res) => memberController.updateSubscription(req, res))
),
wellKnown: Router()
.get('/jwks.json',
(req, res) => wellKnownController.getPublicKeys(req, res)
)
};
const getPublicConfig = function () {
return Promise.resolve({
publicKey,
issuer
});
};
const bus = new (require('events').EventEmitter)();
bus.emit('ready');
return {
middleware,
getMemberDataFromMagicLinkToken,
getMemberIdentityToken,
getMemberIdentityData,
setMemberGeolocationFromIp,
getPublicConfig,
bus,
sendEmailWithMagicLink,
getMagicLink,
members: users,
memberBREADService,
events: eventRepository,
productRepository,
// Test helpers
getTokenDataFromMagicLinkToken
};
};