diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 051bae52b..dab3bc754 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1292,6 +1292,7 @@ export type Mutation = { updatePage: UpdatePageResult; updateReminder: UpdateReminderResult; updateSharedComment: UpdateSharedCommentResult; + updateSubscription: UpdateSubscriptionResult; updateUser: UpdateUserResult; updateUserProfile: UpdateUserProfileResult; uploadFileRequest: UploadFileRequestResult; @@ -1619,6 +1620,11 @@ export type MutationUpdateSharedCommentArgs = { }; +export type MutationUpdateSubscriptionArgs = { + input: UpdateSubscriptionInput; +}; + + export type MutationUpdateUserArgs = { input: UpdateUserInput; }; @@ -2995,6 +3001,31 @@ export type UpdateSharedCommentSuccess = { sharedComment: Scalars['String']; }; +export type UpdateSubscriptionError = { + __typename?: 'UpdateSubscriptionError'; + errorCodes: Array; +}; + +export enum UpdateSubscriptionErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type UpdateSubscriptionInput = { + description?: InputMaybe; + id: Scalars['ID']; + lastFetchedAt?: InputMaybe; + name?: InputMaybe; +}; + +export type UpdateSubscriptionResult = UpdateSubscriptionError | UpdateSubscriptionSuccess; + +export type UpdateSubscriptionSuccess = { + __typename?: 'UpdateSubscriptionSuccess'; + subscription: Subscription; +}; + export type UpdateUserError = { __typename?: 'UpdateUserError'; errorCodes: Array; @@ -3759,6 +3790,11 @@ export type ResolversTypes = { UpdateSharedCommentInput: UpdateSharedCommentInput; UpdateSharedCommentResult: ResolversTypes['UpdateSharedCommentError'] | ResolversTypes['UpdateSharedCommentSuccess']; UpdateSharedCommentSuccess: ResolverTypeWrapper; + UpdateSubscriptionError: ResolverTypeWrapper; + UpdateSubscriptionErrorCode: UpdateSubscriptionErrorCode; + UpdateSubscriptionInput: UpdateSubscriptionInput; + UpdateSubscriptionResult: ResolversTypes['UpdateSubscriptionError'] | ResolversTypes['UpdateSubscriptionSuccess']; + UpdateSubscriptionSuccess: ResolverTypeWrapper; UpdateUserError: ResolverTypeWrapper; UpdateUserErrorCode: UpdateUserErrorCode; UpdateUserInput: UpdateUserInput; @@ -4154,6 +4190,10 @@ export type ResolversParentTypes = { UpdateSharedCommentInput: UpdateSharedCommentInput; UpdateSharedCommentResult: ResolversParentTypes['UpdateSharedCommentError'] | ResolversParentTypes['UpdateSharedCommentSuccess']; UpdateSharedCommentSuccess: UpdateSharedCommentSuccess; + UpdateSubscriptionError: UpdateSubscriptionError; + UpdateSubscriptionInput: UpdateSubscriptionInput; + UpdateSubscriptionResult: ResolversParentTypes['UpdateSubscriptionError'] | ResolversParentTypes['UpdateSubscriptionSuccess']; + UpdateSubscriptionSuccess: UpdateSubscriptionSuccess; UpdateUserError: UpdateUserError; UpdateUserInput: UpdateUserInput; UpdateUserProfileError: UpdateUserProfileError; @@ -5142,6 +5182,7 @@ export type MutationResolvers>; updateReminder?: Resolver>; updateSharedComment?: Resolver>; + updateSubscription?: Resolver>; updateUser?: Resolver>; updateUserProfile?: Resolver>; uploadFileRequest?: Resolver>; @@ -5938,6 +5979,20 @@ export type UpdateSharedCommentSuccessResolvers; }; +export type UpdateSubscriptionErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdateSubscriptionResultResolvers = { + __resolveType: TypeResolveFn<'UpdateSubscriptionError' | 'UpdateSubscriptionSuccess', ParentType, ContextType>; +}; + +export type UpdateSubscriptionSuccessResolvers = { + subscription?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateUserErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6405,6 +6460,9 @@ export type Resolvers = { UpdateSharedCommentError?: UpdateSharedCommentErrorResolvers; UpdateSharedCommentResult?: UpdateSharedCommentResultResolvers; UpdateSharedCommentSuccess?: UpdateSharedCommentSuccessResolvers; + UpdateSubscriptionError?: UpdateSubscriptionErrorResolvers; + UpdateSubscriptionResult?: UpdateSubscriptionResultResolvers; + UpdateSubscriptionSuccess?: UpdateSubscriptionSuccessResolvers; UpdateUserError?: UpdateUserErrorResolvers; UpdateUserProfileError?: UpdateUserProfileErrorResolvers; UpdateUserProfileResult?: UpdateUserProfileResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 8a2ebbeff..11766f7b8 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1160,6 +1160,7 @@ type Mutation { updatePage(input: UpdatePageInput!): UpdatePageResult! updateReminder(input: UpdateReminderInput!): UpdateReminderResult! updateSharedComment(input: UpdateSharedCommentInput!): UpdateSharedCommentResult! + updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult! updateUser(input: UpdateUserInput!): UpdateUserResult! updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! uploadFileRequest(input: UploadFileRequestInput!): UploadFileRequestResult! @@ -2323,6 +2324,29 @@ type UpdateSharedCommentSuccess { sharedComment: String! } +type UpdateSubscriptionError { + errorCodes: [UpdateSubscriptionErrorCode!]! +} + +enum UpdateSubscriptionErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input UpdateSubscriptionInput { + description: String + id: ID! + lastFetchedAt: Date + name: String +} + +union UpdateSubscriptionResult = UpdateSubscriptionError | UpdateSubscriptionSuccess + +type UpdateSubscriptionSuccess { + subscription: Subscription! +} + type UpdateUserError { errorCodes: [UpdateUserErrorCode!]! } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 49e9b6c2e..559673b21 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -5,6 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { getShareInfoForArticle } from '../datalayer/links/share_info' import { getPageByParam } from '../elastic/pages' +import { Subscription } from '../entity/subscription' import { Article, ArticleHighlightsInput, @@ -109,6 +110,7 @@ import { updateReminderResolver, updateSharedCommentResolver, updatesSinceResolver, + updateSubscriptionResolver, updateUserProfileResolver, updateUserResolver, uploadFileRequestResolver, @@ -206,6 +208,7 @@ export const functionResolvers = { bulkAction: bulkActionResolver, importFromIntegration: importFromIntegrationResolver, setFavoriteArticle: setFavoriteArticleResolver, + updateSubscription: updateSubscriptionResolver, }, Query: { me: getMeUserResolver, @@ -573,6 +576,16 @@ export const functionResolvers = { return item.pageType || PageType.Unknown }, }, + Subscription: { + newsletterEmail(subscription: Subscription) { + return subscription.newsletterEmail?.address + }, + icon(subscription: Subscription) { + return ( + subscription.icon && createImageProxyUrl(subscription.icon, 128, 128) + ) + }, + }, ...resultResolveTypeResolver('Login'), ...resultResolveTypeResolver('LogOut'), ...resultResolveTypeResolver('GoogleSignup'), @@ -662,4 +675,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('BulkAction'), ...resultResolveTypeResolver('ImportFromIntegration'), ...resultResolveTypeResolver('SetFavoriteArticle'), + ...resultResolveTypeResolver('UpdateSubscription'), } diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index e4a112e15..378b0d111 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -6,6 +6,7 @@ import { env } from '../../env' import { MutationSubscribeArgs, MutationUnsubscribeArgs, + MutationUpdateSubscriptionArgs, QuerySubscriptionsArgs, SortBy, SortOrder, @@ -20,16 +21,25 @@ import { UnsubscribeError, UnsubscribeErrorCode, UnsubscribeSuccess, + UpdateSubscriptionError, + UpdateSubscriptionErrorCode, + UpdateSubscriptionSuccess, } from '../../generated/graphql' import { getSubscribeHandler, unsubscribe } from '../../services/subscriptions' +import { Merge } from '../../util' import { analytics } from '../../utils/analytics' import { authorized } from '../../utils/helpers' -import { createImageProxyUrl } from '../../utils/imageproxy' + +type PartialSubscription = Omit const parser = new Parser() -export const subscriptionsResolver = authorized< +export type SubscriptionsSuccessPartial = Merge< SubscriptionsSuccess, + { subscriptions: PartialSubscription[] } +> +export const subscriptionsResolver = authorized< + SubscriptionsSuccessPartial, SubscriptionsError, QuerySubscriptionsArgs >(async (_obj, { sort, type: subscriptionType }, { claims: { uid }, log }) => { @@ -66,11 +76,7 @@ export const subscriptionsResolver = authorized< .getMany() return { - subscriptions: subscriptions.map((s) => ({ - ...s, - icon: s.icon && createImageProxyUrl(s.icon, 128, 128), - newsletterEmail: s.newsletterEmail?.address, - })), + subscriptions, } } catch (error) { log.error(error) @@ -80,8 +86,12 @@ export const subscriptionsResolver = authorized< } }) -export const unsubscribeResolver = authorized< +export type UnsubscribeSuccessPartial = Merge< UnsubscribeSuccess, + { subscription: PartialSubscription } +> +export const unsubscribeResolver = authorized< + UnsubscribeSuccessPartial, UnsubscribeError, MutationUnsubscribeArgs >(async (_, { name, subscriptionId }, { claims: { uid }, log }) => { @@ -122,8 +132,12 @@ export const unsubscribeResolver = authorized< } } - if (!subscription.unsubscribeMailTo && !subscription.unsubscribeHttpUrl) { - log.info('No unsubscribe method found') + if ( + subscription.type === SubscriptionType.Newsletter && + !subscription.unsubscribeMailTo && + !subscription.unsubscribeHttpUrl + ) { + log.info('No unsubscribe method found for newsletter subscription') } await unsubscribe(subscription) @@ -138,10 +152,7 @@ export const unsubscribeResolver = authorized< }) return { - subscription: { - ...subscription, - newsletterEmail: subscription.newsletterEmail?.address, - }, + subscription, } } catch (error) { log.error('failed to unsubscribe', error) @@ -151,8 +162,12 @@ export const unsubscribeResolver = authorized< } }) -export const subscribeResolver = authorized< +export type SubscribeSuccessPartial = Merge< SubscribeSuccess, + { subscriptions: PartialSubscription[] } +> +export const subscribeResolver = authorized< + SubscribeSuccessPartial, SubscribeError, MutationSubscribeArgs >(async (_, { input }, { claims: { uid }, log }) => { @@ -209,10 +224,7 @@ export const subscribeResolver = authorized< } return { - subscriptions: newSubscriptions.map((s) => ({ - ...s, - newsletterEmail: s.newsletterEmail?.address, - })), + subscriptions: newSubscriptions, } } @@ -231,12 +243,7 @@ export const subscribeResolver = authorized< }) return { - subscriptions: [ - { - ...newSubscription, - newsletterEmail: null, - }, - ], + subscriptions: [newSubscription], } } @@ -251,3 +258,65 @@ export const subscribeResolver = authorized< } } }) + +export type UpdateSubscriptionSuccessPartial = Merge< + UpdateSubscriptionSuccess, + { subscription: PartialSubscription } +> +export const updateSubscriptionResolver = authorized< + UpdateSubscriptionSuccessPartial, + UpdateSubscriptionError, + MutationUpdateSubscriptionArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('updateSubscriptionResolver') + + try { + analytics.track({ + userId: uid, + event: 'update_subscription', + properties: { + ...input, + env: env.server.apiEnv, + }, + }) + + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [UpdateSubscriptionErrorCode.Unauthorized], + } + } + + // find existing subscription + const subscription = await getRepository(Subscription).findOneBy({ + id: input.id, + user: { id: uid }, + status: SubscriptionStatus.Active, + }) + if (!subscription) { + log.info('subscription not found') + return { + errorCodes: [UpdateSubscriptionErrorCode.NotFound], + } + } + + // update subscription + const updatedSubscription = await getRepository(Subscription).save({ + id: input.id, + name: input.name || undefined, + description: input.description || undefined, + lastFetchedAt: input.lastFetchedAt + ? new Date(input.lastFetchedAt) + : undefined, + }) + + return { + subscription: updatedSubscription, + } + } catch (error) { + log.error('failed to update subscription', error) + return { + errorCodes: [UpdateSubscriptionErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 3a4be3243..4c72d7d3a 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2492,6 +2492,31 @@ const schema = gql` subscriptionType: SubscriptionType } + input UpdateSubscriptionInput { + id: ID! + name: String + description: String + lastFetchedAt: Date + } + + union UpdateSubscriptionResult = + UpdateSubscriptionSuccess + | UpdateSubscriptionError + + type UpdateSubscriptionSuccess { + subscription: Subscription! + } + + type UpdateSubscriptionError { + errorCodes: [UpdateSubscriptionErrorCode!]! + } + + enum UpdateSubscriptionErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2590,6 +2615,9 @@ const schema = gql` ): BulkActionResult! importFromIntegration(integrationId: ID!): ImportFromIntegrationResult! setFavoriteArticle(id: ID!): SetFavoriteArticleResult! + updateSubscription( + input: UpdateSubscriptionInput! + ): UpdateSubscriptionResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed