Merge pull request #638 from omnivore-app/feature/subscription-api

Feature/subscription api
This commit is contained in:
Jackson Harper 2022-05-20 15:34:48 -07:00 committed by GitHub
commit 17e6f4091d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 299 additions and 23 deletions

View file

@ -817,6 +817,7 @@ export type Mutation = {
setShareHighlight: SetShareHighlightResult;
setUserPersonalization: SetUserPersonalizationResult;
signup: SignupResult;
subscribe: SubscribeResult;
unsubscribe: UnsubscribeResult;
updateHighlight: UpdateHighlightResult;
updateHighlightReply: UpdateHighlightReplyResult;
@ -990,6 +991,11 @@ export type MutationSignupArgs = {
};
export type MutationSubscribeArgs = {
name: Scalars['String'];
};
export type MutationUnsubscribeArgs = {
name: Scalars['String'];
};
@ -1643,6 +1649,25 @@ export type SortParams = {
order?: InputMaybe<SortOrder>;
};
export type SubscribeError = {
__typename?: 'SubscribeError';
errorCodes: Array<SubscribeErrorCode>;
};
export enum SubscribeErrorCode {
AlreadySubscribed = 'ALREADY_SUBSCRIBED',
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SubscribeResult = SubscribeError | SubscribeSuccess;
export type SubscribeSuccess = {
__typename?: 'SubscribeSuccess';
subscriptions: Array<Subscription>;
};
export type Subscription = {
__typename?: 'Subscription';
createdAt: Scalars['Date'];
@ -2293,6 +2318,10 @@ export type ResolversTypes = {
SortOrder: SortOrder;
SortParams: SortParams;
String: ResolverTypeWrapper<Scalars['String']>;
SubscribeError: ResolverTypeWrapper<SubscribeError>;
SubscribeErrorCode: SubscribeErrorCode;
SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess'];
SubscribeSuccess: ResolverTypeWrapper<SubscribeSuccess>;
Subscription: ResolverTypeWrapper<{}>;
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
SubscriptionsErrorCode: SubscriptionsErrorCode;
@ -2550,6 +2579,9 @@ export type ResolversParentTypes = {
SignupSuccess: SignupSuccess;
SortParams: SortParams;
String: Scalars['String'];
SubscribeError: SubscribeError;
SubscribeResult: ResolversParentTypes['SubscribeError'] | ResolversParentTypes['SubscribeSuccess'];
SubscribeSuccess: SubscribeSuccess;
Subscription: {};
SubscriptionsError: SubscriptionsError;
SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
@ -3198,6 +3230,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
signup?: Resolver<ResolversTypes['SignupResult'], ParentType, ContextType, RequireFields<MutationSignupArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'name'>>;
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
@ -3548,6 +3581,20 @@ export type SignupSuccessResolvers<ContextType = ResolverContext, ParentType ext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscribeErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscribeError'] = ResolversParentTypes['SubscribeError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SubscribeErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscribeResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscribeResult'] = ResolversParentTypes['SubscribeResult']> = {
__resolveType: TypeResolveFn<'SubscribeError' | 'SubscribeSuccess', ParentType, ContextType>;
};
export type SubscribeSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscribeSuccess'] = ResolversParentTypes['SubscribeSuccess']> = {
subscriptions?: Resolver<Array<ResolversTypes['Subscription']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
@ -3927,6 +3974,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SignupError?: SignupErrorResolvers<ContextType>;
SignupResult?: SignupResultResolvers<ContextType>;
SignupSuccess?: SignupSuccessResolvers<ContextType>;
SubscribeError?: SubscribeErrorResolvers<ContextType>;
SubscribeResult?: SubscribeResultResolvers<ContextType>;
SubscribeSuccess?: SubscribeSuccessResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>;
SubscriptionsError?: SubscriptionsErrorResolvers<ContextType>;
SubscriptionsResult?: SubscriptionsResultResolvers<ContextType>;

View file

@ -727,6 +727,7 @@ type Mutation {
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult!
signup(input: SignupInput!): SignupResult!
subscribe(name: String!): SubscribeResult!
unsubscribe(name: String!): UnsubscribeResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
@ -1229,6 +1230,23 @@ input SortParams {
order: SortOrder
}
type SubscribeError {
errorCodes: [SubscribeErrorCode!]!
}
enum SubscribeErrorCode {
ALREADY_SUBSCRIBED
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
union SubscribeResult = SubscribeError | SubscribeSuccess
type SubscribeSuccess {
subscriptions: [Subscription!]!
}
type Subscription {
createdAt: Date!
description: String

View file

@ -65,6 +65,7 @@ import {
setShareHighlightResolver,
setUserPersonalizationResolver,
signupResolver,
subscribeResolver,
subscriptionsResolver,
unsubscribeResolver,
updateHighlightResolver,
@ -145,6 +146,7 @@ export const functionResolvers = {
setLabels: setLabelsResolver,
generateApiKey: generateApiKeyResolver,
unsubscribe: unsubscribeResolver,
subscribe: subscribeResolver,
},
Query: {
me: getMeUserResolver,
@ -554,4 +556,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('Subscriptions'),
...resultResolveTypeResolver('Unsubscribe'),
...resultResolveTypeResolver('UpdateLabel'),
...resultResolveTypeResolver('Subscribe'),
}

View file

@ -1,9 +1,13 @@
import { authorized } from '../../utils/helpers'
import {
MutationSubscribeArgs,
MutationUnsubscribeArgs,
QuerySubscriptionsArgs,
SortBy,
SortOrder,
SubscribeError,
SubscribeErrorCode,
SubscribeSuccess,
SubscriptionsError,
SubscriptionsErrorCode,
SubscriptionsSuccess,
@ -17,7 +21,8 @@ import { env } from '../../env'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
import { Subscription } from '../../entity/subscription'
import { unsubscribe } from '../../services/subscriptions'
import { getSubscribeHandler, unsubscribe } from '../../services/subscriptions'
import { ILike } from 'typeorm'
export const subscriptionsResolver = authorized<
SubscriptionsSuccess,
@ -70,12 +75,16 @@ export const unsubscribeResolver = authorized<
log.info('unsubscribeResolver')
try {
const subscription = await getRepository(Subscription).findOne({
where: {
name,
user: { id: uid },
},
relations: ['user'],
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UnsubscribeErrorCode.Unauthorized],
}
}
const subscription = await getRepository(Subscription).findOneBy({
name: ILike(name),
user: { id: uid },
})
if (!subscription) {
return {
@ -83,12 +92,6 @@ export const unsubscribeResolver = authorized<
}
}
if (subscription.user.id !== uid) {
return {
errorCodes: [UnsubscribeErrorCode.Unauthorized],
}
}
// if subscription is already unsubscribed, throw error
if (subscription.status === SubscriptionStatus.Unsubscribed) {
return {
@ -102,7 +105,18 @@ export const unsubscribeResolver = authorized<
}
}
return { subscription: await unsubscribe(subscription) }
const unsubscribed = await unsubscribe(subscription)
analytics.track({
userId: uid,
event: 'unsubscribed',
properties: {
name,
env: env.server.apiEnv,
},
})
return { subscription: unsubscribed }
} catch (error) {
log.error('failed to unsubscribe', error)
return {
@ -110,3 +124,63 @@ export const unsubscribeResolver = authorized<
}
}
})
export const subscribeResolver = authorized<
SubscribeSuccess,
SubscribeError,
MutationSubscribeArgs
>(async (_, { name }, { claims: { uid }, log }) => {
log.info('subscribeResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SubscribeErrorCode.Unauthorized],
}
}
const subscription = await getRepository(Subscription).findOneBy({
name: ILike(name),
user: { id: uid },
status: SubscriptionStatus.Active,
})
if (subscription) {
return {
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
}
}
const subscribeHandler = getSubscribeHandler(name)
if (!subscribeHandler) {
return {
errorCodes: [SubscribeErrorCode.NotFound],
}
}
const newSubscriptions = await subscribeHandler.handleSubscribe(uid, name)
if (!newSubscriptions) {
return {
errorCodes: [SubscribeErrorCode.BadRequest],
}
}
analytics.track({
userId: uid,
event: 'subscribed',
properties: {
name,
env: env.server.apiEnv,
},
})
return {
subscriptions: newSubscriptions,
}
} catch (error) {
log.error('failed to subscribe', error)
return {
errorCodes: [SubscribeErrorCode.BadRequest],
}
}
})

View file

@ -1511,6 +1511,23 @@ const schema = gql`
UNSUBSCRIBE_METHOD_NOT_FOUND
}
union SubscribeResult = SubscribeSuccess | SubscribeError
type SubscribeSuccess {
subscriptions: [Subscription!]!
}
type SubscribeError {
errorCodes: [SubscribeErrorCode!]!
}
enum SubscribeErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
ALREADY_SUBSCRIBED
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1573,6 +1590,7 @@ const schema = gql`
setLabels(input: SetLabelsInput!): SetLabelsResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
unsubscribe(name: String!): UnsubscribeResult!
subscribe(name: String!): SubscribeResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed

View file

@ -86,20 +86,17 @@ export const saveNewsletterEmail = async (
// sends push notification
const deviceTokens = await getDeviceTokensByUserId(newsletterEmail.user.id)
if (!deviceTokens) {
console.log('Device tokens not set:', newsletterEmail.user.id)
return true
}
if (deviceTokens.length) {
const multicastMessage = messageForLink(page, deviceTokens)
await sendMulticastPushNotifications(
newsletterEmail.user.id,
multicastMessage,
'newsletter'
)
}
const multicastMessage = messageForLink(page, deviceTokens)
await sendMulticastPushNotifications(
newsletterEmail.user.id,
multicastMessage,
'newsletter'
)
return true
}

View file

@ -3,6 +3,8 @@ import { getRepository } from '../entity/utils'
import { SubscriptionStatus } from '../generated/graphql'
import { sendEmail } from '../utils/sendEmail'
import axios from 'axios'
import { NewsletterEmail } from '../entity/newsletter_email'
import { createNewsletterEmail } from './newsletters'
const sendUnsubscribeEmail = async (
unsubscribeMailTo: string,
@ -105,3 +107,117 @@ export const unsubscribeAll = async (
console.log('Failed to unsubscribe all', error)
}
}
export const getSubscribeHandler = (name: string): SubscribeHandler | null => {
switch (name.toLowerCase()) {
case 'axios_essentials':
return new AxiosEssentialsHandler()
case 'morning_brew':
return new MorningBrewHandler()
case 'milk_road':
return new MilkRoadHandler()
case 'money_stuff':
return new MoneyStuffHandler()
default:
return null
}
}
export class SubscribeHandler {
async handleSubscribe(
userId: string,
name: string
): Promise<Subscription[] | null> {
try {
const newsletterEmail =
(await getRepository(NewsletterEmail).findOneBy({
user: { id: userId },
})) || (await createNewsletterEmail(userId))
// subscribe to newsletter service
const subscribedNames = await this._subscribe(newsletterEmail.address)
if (subscribedNames.length === 0) {
console.log('Failed to get subscribe response', name)
return null
}
// create new subscriptions in db
const newSubscriptions = subscribedNames.map(
(name: string): Promise<Subscription> => {
return getRepository(Subscription).save({
name,
newsletterEmail: newsletterEmail.address,
user: { id: userId },
status: SubscriptionStatus.Active,
})
}
)
return Promise.all(newSubscriptions)
} catch (error) {
console.log('Failed to handleSubscribe', error)
return null
}
}
async _subscribe(email: string): Promise<string[]> {
return Promise.all([])
}
}
class AxiosEssentialsHandler extends SubscribeHandler {
async _subscribe(email: string): Promise<string[]> {
await axios.post('https://api.axios.com/api/render/readers/unauth-sub/', {
headers: {
'content-type': 'application/json',
},
body: `{"lists":["newsletter_axiosam","newsletter_axiospm","newsletter_axiosfinishline"],"user_vars":{"source":"axios","medium":null,"campaign":null,"term":null,"content":null,"page":"webflow-newsletters-all"},"email":"${email}"`,
})
return ['Axios AM', 'Axios PM', 'Axios Finish Line']
}
}
class MorningBrewHandler extends SubscribeHandler {
async _subscribe(email: string): Promise<string[]> {
await axios.post('https://singularity.morningbrew.com/graphql', {
headers: {
'content-type': 'application/json',
},
body: `{"operationName":"CreateUserSubscription","variables":{"signupCreateInput":{"email":"${email}","kid":null,"gclid":null,"utmCampaign":"mb","utmMedium":"website","utmSource":"hero-module","utmContent":null,"utmTerm":null,"requestPath":"https://www.morningbrew.com/daily","uiModule":"hero-module"},"signupCreateVerticalSlug":"daily"},"query":"mutation CreateUserSubscription($signupCreateInput: SignupCreateInput!, $signupCreateVerticalSlug: String!) {\\n signupCreate(input: $signupCreateInput, verticalSlug: $signupCreateVerticalSlug) {\\n user {\\n accessToken\\n email\\n hasSeenOnboarding\\n referralCode\\n verticalSubscriptions {\\n isActive\\n vertical {\\n slug\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n isNewSubscription\\n fromAffiliate\\n subscriptionId\\n __typename\\n }\\n}\\n"}`,
})
return ['Morning Brew']
}
}
class MilkRoadHandler extends SubscribeHandler {
async _subscribe(email: string): Promise<string[]> {
await axios.post('https://www.milkroad.com/subscriptions', {
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: `email=${encodeURIComponent(email)}&commit=Subscribe`,
})
return ['Milk Road']
}
}
class MoneyStuffHandler extends SubscribeHandler {
async _subscribe(email: string): Promise<string[]> {
await axios.put(
`https://login.bloomberg.com/api/newsletters/update?email=${encodeURIComponent(
email
)}&source=&notify=true&optIn=false`,
{
headers: {
'content-type': 'application/json',
},
body: '{"Money Stuff":true}',
}
)
return ['Money Stuff']
}
}