diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 031652ed1..fd414ea98 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -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; }; +export type SubscribeError = { + __typename?: 'SubscribeError'; + errorCodes: Array; +}; + +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; +}; + export type Subscription = { __typename?: 'Subscription'; createdAt: Scalars['Date']; @@ -2293,6 +2318,10 @@ export type ResolversTypes = { SortOrder: SortOrder; SortParams: SortParams; String: ResolverTypeWrapper; + SubscribeError: ResolverTypeWrapper; + SubscribeErrorCode: SubscribeErrorCode; + SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess']; + SubscribeSuccess: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; SubscriptionsError: ResolverTypeWrapper; 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>; setUserPersonalization?: Resolver>; signup?: Resolver>; + subscribe?: Resolver>; unsubscribe?: Resolver>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; @@ -3548,6 +3581,20 @@ export type SignupSuccessResolvers; }; +export type SubscribeErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SubscribeResultResolvers = { + __resolveType: TypeResolveFn<'SubscribeError' | 'SubscribeSuccess', ParentType, ContextType>; +}; + +export type SubscribeSuccessResolvers = { + subscriptions?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SubscriptionResolvers = { createdAt?: SubscriptionResolver; description?: SubscriptionResolver, "description", ParentType, ContextType>; @@ -3927,6 +3974,9 @@ export type Resolvers = { SignupError?: SignupErrorResolvers; SignupResult?: SignupResultResolvers; SignupSuccess?: SignupSuccessResolvers; + SubscribeError?: SubscribeErrorResolvers; + SubscribeResult?: SubscribeResultResolvers; + SubscribeSuccess?: SubscribeSuccessResolvers; Subscription?: SubscriptionResolvers; SubscriptionsError?: SubscriptionsErrorResolvers; SubscriptionsResult?: SubscriptionsResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 679b986f9..a399f9dbd 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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 diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 6bd83fe61..a5b52fe1c 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -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'), } diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index 7936b25e5..6ee982f6d 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -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], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index e6e62eb21..f910324de 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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 diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 3fd01b841..ec06d6a9e 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -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 } diff --git a/packages/api/src/services/subscriptions.ts b/packages/api/src/services/subscriptions.ts index 20d74b0f3..f0d7997ac 100644 --- a/packages/api/src/services/subscriptions.ts +++ b/packages/api/src/services/subscriptions.ts @@ -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 { + 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 => { + 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 { + return Promise.all([]) + } +} + +class AxiosEssentialsHandler extends SubscribeHandler { + async _subscribe(email: string): Promise { + 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 { + 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 { + 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 { + await axios.put( + `https://login.bloomberg.com/api/newsletters/update?email=${encodeURIComponent( + email + )}&source=¬ify=true&optIn=false`, + { + headers: { + 'content-type': 'application/json', + }, + body: '{"Money Stuff":true}', + } + ) + + return ['Money Stuff'] + } +}