mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #638 from omnivore-app/feature/subscription-api
Feature/subscription api
This commit is contained in:
commit
17e6f4091d
7 changed files with 299 additions and 23 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=¬ify=true&optIn=false`,
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: '{"Money Stuff":true}',
|
||||
}
|
||||
)
|
||||
|
||||
return ['Money Stuff']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue