From 574636451dfc567beddf0217dc64dae792f78cf4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 6 Jul 2023 19:49:03 +0800 Subject: [PATCH] update api to accommodate rss feed subscriptions --- packages/api/package.json | 1 + packages/api/src/entity/subscription.ts | 4 +- packages/api/src/generated/graphql.ts | 14 ++- packages/api/src/generated/schema.graphql | 12 +- .../api/src/resolvers/subscriptions/index.ts | 114 +++++++++++++----- packages/api/src/schema.ts | 15 ++- packages/api/src/services/subscriptions.ts | 38 +++--- packages/api/test/db.ts | 35 +++--- .../api/test/resolvers/subscriptions.test.ts | 23 ++-- 9 files changed, 173 insertions(+), 83 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index ec47f1bcc..a1a042dc3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -83,6 +83,7 @@ "pg": "^8.3.3", "postgrator": "^4.2.0", "private-ip": "^2.3.3", + "rss-parser": "^3.13.0", "sanitize-html": "^2.3.2", "search-query-parser": "^1.6.0", "snake-case": "^3.0.3", diff --git a/packages/api/src/entity/subscription.ts b/packages/api/src/entity/subscription.ts index a0e311a19..3fa5a600c 100644 --- a/packages/api/src/entity/subscription.ts +++ b/packages/api/src/entity/subscription.ts @@ -29,9 +29,9 @@ export class Subscription { }) status!: SubscriptionStatus - @ManyToOne(() => NewsletterEmail) + @ManyToOne(() => NewsletterEmail, { nullable: true }) @JoinColumn({ name: 'newsletter_email_id' }) - newsletterEmail!: NewsletterEmail + newsletterEmail?: NewsletterEmail | null @Column('text', { nullable: true }) description?: string diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index fa49a1a0f..051bae52b 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1574,12 +1574,13 @@ export type MutationSetWebhookArgs = { export type MutationSubscribeArgs = { - name: Scalars['String']; + input: SubscribeInput; }; export type MutationUnsubscribeArgs = { name: Scalars['String']; + subscriptionId?: InputMaybe; }; @@ -1869,6 +1870,7 @@ export type QuerySharedArticleArgs = { export type QuerySubscriptionsArgs = { sort?: InputMaybe; + type?: InputMaybe; }; @@ -2697,6 +2699,12 @@ export enum SubscribeErrorCode { Unauthorized = 'UNAUTHORIZED' } +export type SubscribeInput = { + name?: InputMaybe; + subscriptionType?: InputMaybe; + url?: InputMaybe; +}; + export type SubscribeResult = SubscribeError | SubscribeSuccess; export type SubscribeSuccess = { @@ -3695,6 +3703,7 @@ export type ResolversTypes = { String: ResolverTypeWrapper; SubscribeError: ResolverTypeWrapper; SubscribeErrorCode: SubscribeErrorCode; + SubscribeInput: SubscribeInput; SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess']; SubscribeSuccess: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; @@ -4102,6 +4111,7 @@ export type ResolversParentTypes = { SortParams: SortParams; String: Scalars['String']; SubscribeError: SubscribeError; + SubscribeInput: SubscribeInput; SubscribeResult: ResolversParentTypes['SubscribeError'] | ResolversParentTypes['SubscribeSuccess']; SubscribeSuccess: SubscribeSuccess; Subscription: {}; @@ -5123,7 +5133,7 @@ export type MutationResolvers>; setUserPersonalization?: Resolver>; setWebhook?: Resolver>; - subscribe?: Resolver>; + subscribe?: Resolver>; unsubscribe?: Resolver>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 433c99924..8a2ebbeff 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1151,8 +1151,8 @@ type Mutation { setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! - subscribe(name: String!): SubscribeResult! - unsubscribe(name: String!): UnsubscribeResult! + subscribe(input: SubscribeInput!): SubscribeResult! + unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult! updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult! updateLabel(input: UpdateLabelInput!): UpdateLabelResult! @@ -1308,7 +1308,7 @@ type Query { search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult! sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! - subscriptions(sort: SortParams): SubscriptionsResult! + subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult! typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult! updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult! user(userId: ID, username: String): UserResult! @@ -2051,6 +2051,12 @@ enum SubscribeErrorCode { UNAUTHORIZED } +input SubscribeInput { + name: String + subscriptionType: SubscriptionType + url: String +} + union SubscribeResult = SubscribeError | SubscribeSuccess type SubscribeSuccess { diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index a7aa15392..e4a112e15 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -1,4 +1,4 @@ -import { ILike } from 'typeorm' +import Parser from 'rss-parser' import { Subscription } from '../../entity/subscription' import { User } from '../../entity/user' import { getRepository } from '../../entity/utils' @@ -16,6 +16,7 @@ import { SubscriptionsErrorCode, SubscriptionsSuccess, SubscriptionStatus, + SubscriptionType, UnsubscribeError, UnsubscribeErrorCode, UnsubscribeSuccess, @@ -25,11 +26,13 @@ import { analytics } from '../../utils/analytics' import { authorized } from '../../utils/helpers' import { createImageProxyUrl } from '../../utils/imageproxy' +const parser = new Parser() + export const subscriptionsResolver = authorized< SubscriptionsSuccess, SubscriptionsError, QuerySubscriptionsArgs ->(async (_obj, { sort }, { claims: { uid }, log }) => { +>(async (_obj, { sort, type: subscriptionType }, { claims: { uid }, log }) => { log.info('subscriptionsResolver') analytics.track({ @@ -41,7 +44,8 @@ export const subscriptionsResolver = authorized< }) try { - const sortBy = sort?.by === SortBy.UpdatedTime ? 'updatedAt' : 'createdAt' + const sortBy = + sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt' const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC' const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { @@ -52,10 +56,11 @@ export const subscriptionsResolver = authorized< const subscriptions = await getRepository(Subscription) .createQueryBuilder('subscription') - .innerJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail') + .leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail') .where({ user: { id: uid }, status: SubscriptionStatus.Active, + type: subscriptionType || SubscriptionType.Newsletter, // default to newsletter }) .orderBy('subscription.' + sortBy, sortOrder) .getMany() @@ -64,7 +69,7 @@ export const subscriptionsResolver = authorized< subscriptions: subscriptions.map((s) => ({ ...s, icon: s.icon && createImageProxyUrl(s.icon, 128, 128), - newsletterEmail: s.newsletterEmail.address, + newsletterEmail: s.newsletterEmail?.address, })), } } catch (error) { @@ -79,7 +84,7 @@ export const unsubscribeResolver = authorized< UnsubscribeSuccess, UnsubscribeError, MutationUnsubscribeArgs ->(async (_, { name }, { claims: { uid }, log }) => { +>(async (_, { name, subscriptionId }, { claims: { uid }, log }) => { log.info('unsubscribeResolver') try { @@ -90,13 +95,20 @@ export const unsubscribeResolver = authorized< } } - const subscription = await getRepository(Subscription) + const queryBuilder = getRepository(Subscription) .createQueryBuilder('subscription') - .innerJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail') + .leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail') .where({ user: { id: uid } }) - .andWhere('LOWER(name) = LOWER(:name)', { name }) // case insensitive - .getOne() + if (subscriptionId) { + // if subscriptionId is provided, ignore name + queryBuilder.andWhere({ id: subscriptionId }) + } else { + // if subscriptionId is not provided, use name for old clients + queryBuilder.andWhere({ name }) + } + + const subscription = await queryBuilder.getOne() if (!subscription) { return { errorCodes: [UnsubscribeErrorCode.NotFound], @@ -128,7 +140,7 @@ export const unsubscribeResolver = authorized< return { subscription: { ...subscription, - newsletterEmail: subscription.newsletterEmail.address, + newsletterEmail: subscription.newsletterEmail?.address, }, } } catch (error) { @@ -143,7 +155,7 @@ export const subscribeResolver = authorized< SubscribeSuccess, SubscribeError, MutationSubscribeArgs ->(async (_, { name }, { claims: { uid }, log }) => { +>(async (_, { input }, { claims: { uid }, log }) => { log.info('subscribeResolver') try { @@ -154,10 +166,13 @@ export const subscribeResolver = authorized< } } + // find existing subscription const subscription = await getRepository(Subscription).findOneBy({ - name: ILike(name), + url: input.url || undefined, + name: input.name || undefined, user: { id: uid }, status: SubscriptionStatus.Active, + type: input.subscriptionType || SubscriptionType.Rss, // default to rss }) if (subscription) { return { @@ -165,34 +180,69 @@ export const subscribeResolver = authorized< } } - 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, + ...input, env: env.server.apiEnv, }, }) + // create new newsletter subscription + if (input.name && input.subscriptionType === SubscriptionType.Newsletter) { + const subscribeHandler = getSubscribeHandler(input.name) + if (!subscribeHandler) { + return { + errorCodes: [SubscribeErrorCode.NotFound], + } + } + + const newSubscriptions = await subscribeHandler.handleSubscribe( + uid, + input.name + ) + if (!newSubscriptions) { + return { + errorCodes: [SubscribeErrorCode.BadRequest], + } + } + + return { + subscriptions: newSubscriptions.map((s) => ({ + ...s, + newsletterEmail: s.newsletterEmail?.address, + })), + } + } + + // create new rss subscription + if (input.url) { + // validate rss feed + const feed = await parser.parseURL(input.url) + + const newSubscription = await getRepository(Subscription).save({ + name: feed.title, + url: input.url, + user: { id: uid }, + type: SubscriptionType.Rss, + description: feed.description, + icon: feed.image?.url, + }) + + return { + subscriptions: [ + { + ...newSubscription, + newsletterEmail: null, + }, + ], + } + } + + log.info('missing url or name') return { - subscriptions: newSubscriptions.map((s) => ({ - ...s, - newsletterEmail: s.newsletterEmail.address, - })), + errorCodes: [SubscribeErrorCode.BadRequest], } } catch (error) { log.error('failed to subscribe', error) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 5ac31e3bb..3a4be3243 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2486,6 +2486,12 @@ const schema = gql` ALREADY_EXISTS } + input SubscribeInput { + url: String + name: String + subscriptionType: SubscriptionType + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2547,8 +2553,8 @@ const schema = gql` deleteLabel(id: ID!): DeleteLabelResult! setLabels(input: SetLabelsInput!): SetLabelsResult! generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! - unsubscribe(name: String!): UnsubscribeResult! - subscribe(name: String!): SubscribeResult! + unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult! + subscribe(input: SubscribeInput!): SubscribeResult! addPopularRead(name: String!): AddPopularReadResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! deleteWebhook(id: ID!): DeleteWebhookResult! @@ -2628,7 +2634,10 @@ const schema = gql` includeContent: Boolean format: String ): SearchResult! - subscriptions(sort: SortParams): SubscriptionsResult! + subscriptions( + sort: SortParams + type: SubscriptionType + ): SubscriptionsResult! sendInstallInstructions: SendInstallInstructionsResult! webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! diff --git a/packages/api/src/services/subscriptions.ts b/packages/api/src/services/subscriptions.ts index a43b22dc7..5eadd22ea 100644 --- a/packages/api/src/services/subscriptions.ts +++ b/packages/api/src/services/subscriptions.ts @@ -131,26 +131,30 @@ export const saveSubscription = async ({ } export const unsubscribe = async (subscription: Subscription) => { - let unsubscribed = false - if (subscription.unsubscribeMailTo) { - // unsubscribe by sending email - unsubscribed = await sendUnsubscribeEmail( - subscription.unsubscribeMailTo, - subscription.newsletterEmail.address - ) - } - // TODO: find a good way to unsubscribe by url if email fails or not provided - // because it often requires clicking a button on the page to unsubscribe + // unsubscribe from newsletter + if (subscription.type === SubscriptionType.Newsletter) { + let unsubscribed = false - if (!unsubscribed) { - // update subscription status to unsubscribed if failed to unsubscribe - console.log('Failed to unsubscribe', subscription.id) - return getRepository(Subscription).update(subscription.id, { - status: SubscriptionStatus.Unsubscribed, - }) + if (subscription.unsubscribeMailTo && subscription.newsletterEmail) { + // unsubscribe by sending email + unsubscribed = await sendUnsubscribeEmail( + subscription.unsubscribeMailTo, + subscription.newsletterEmail.address + ) + } + // TODO: find a good way to unsubscribe by url if email fails or not provided + // because it often requires clicking a button on the page to unsubscribe + + if (!unsubscribed) { + // update subscription status to unsubscribed if failed to unsubscribe + console.log('Failed to unsubscribe', subscription.id) + return getRepository(Subscription).update(subscription.id, { + status: SubscriptionStatus.Unsubscribed, + }) + } } - // delete the subscription if successfully unsubscribed + // delete the subscription if successfully unsubscribed or it's an rss feed await getRepository(Subscription).delete(subscription.id) } diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index 37d1b90d6..773b57167 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -1,20 +1,20 @@ import Postgrator from 'postgrator' -import { User } from '../src/entity/user' -import { Profile } from '../src/entity/profile' -import { Page } from '../src/entity/page' -import { Link } from '../src/entity/link' -import { Reminder } from '../src/entity/reminder' -import { NewsletterEmail } from '../src/entity/newsletter_email' -import { UserDeviceToken } from '../src/entity/user_device_tokens' -import { Label } from '../src/entity/label' -import { Subscription } from '../src/entity/subscription' -import { AppDataSource } from '../src/server' -import { getRepository, setClaims } from '../src/entity/utils' -import { createUser } from '../src/services/create_user' -import { SnakeNamingStrategy } from 'typeorm-naming-strategies' -import { SubscriptionStatus } from '../src/generated/graphql' -import { Integration } from '../src/entity/integration' import { FindOptionsWhere } from 'typeorm' +import { SnakeNamingStrategy } from 'typeorm-naming-strategies' +import { Integration } from '../src/entity/integration' +import { Label } from '../src/entity/label' +import { Link } from '../src/entity/link' +import { NewsletterEmail } from '../src/entity/newsletter_email' +import { Page } from '../src/entity/page' +import { Profile } from '../src/entity/profile' +import { Reminder } from '../src/entity/reminder' +import { Subscription } from '../src/entity/subscription' +import { User } from '../src/entity/user' +import { UserDeviceToken } from '../src/entity/user_device_tokens' +import { getRepository, setClaims } from '../src/entity/utils' +import { SubscriptionStatus, SubscriptionType } from '../src/generated/graphql' +import { AppDataSource } from '../src/server' +import { createUser } from '../src/services/create_user' const runMigrations = async () => { const migrationDirectory = __dirname + '/../../db/migrations' @@ -199,7 +199,8 @@ export const createTestSubscription = async ( name: string, newsletterEmail?: NewsletterEmail, status = SubscriptionStatus.Active, - unsubscribeMailTo?: string + unsubscribeMailTo?: string, + subscriptionType = SubscriptionType.Newsletter ): Promise => { return getRepository(Subscription).save({ user, @@ -207,6 +208,8 @@ export const createTestSubscription = async ( newsletterEmail, status, unsubscribeMailTo, + lastFetchedAt: new Date(), + type: subscriptionType, }) } diff --git a/packages/api/test/resolvers/subscriptions.test.ts b/packages/api/test/resolvers/subscriptions.test.ts index d4843c362..a809128ea 100644 --- a/packages/api/test/resolvers/subscriptions.test.ts +++ b/packages/api/test/resolvers/subscriptions.test.ts @@ -6,7 +6,10 @@ import { NewsletterEmail } from '../../src/entity/newsletter_email' import { Subscription } from '../../src/entity/subscription' import { User } from '../../src/entity/user' import { getRepository } from '../../src/entity/utils' -import { SubscriptionStatus } from '../../src/generated/graphql' +import { + SubscriptionStatus, + SubscriptionType, +} from '../../src/generated/graphql' import { UNSUBSCRIBE_EMAIL_TEXT } from '../../src/services/subscriptions' import * as sendEmail from '../../src/utils/sendEmail' import { createTestSubscription, createTestUser, deleteTestUser } from '../db' @@ -35,7 +38,7 @@ describe('Subscriptions API', () => { confirmationCode: 'test', }) - // create testing subscriptions + // create testing newsletter subscriptions const sub1 = await createTestSubscription(user, 'sub_1', newsletterEmail) const sub2 = await createTestSubscription(user, 'sub_2', newsletterEmail) // create a unsubscribed subscription @@ -45,8 +48,15 @@ describe('Subscriptions API', () => { newsletterEmail, SubscriptionStatus.Unsubscribed ) - // create a subscription without a newsletter email - await createTestSubscription(user, 'sub_4') + // create an rss feed subscription + await createTestSubscription( + user, + 'sub_4', + undefined, + SubscriptionStatus.Active, + undefined, + SubscriptionType.Rss + ) subscriptions = [sub2, sub1] }) @@ -143,10 +153,7 @@ describe('Subscriptions API', () => { sinon.fake.resolves(true) ) - const res = await graphqlRequest( - query(name.toUpperCase()), - authToken - ).expect(200) + const res = await graphqlRequest(query(name), authToken).expect(200) expect(res.body.data.unsubscribe.subscription).to.eql({ id: subscription.id,