update api to accommodate rss feed subscriptions

This commit is contained in:
Hongbo Wu 2023-07-06 19:49:03 +08:00
parent 7075375e2b
commit 574636451d
9 changed files with 173 additions and 83 deletions

View file

@ -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",

View file

@ -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

View file

@ -1574,12 +1574,13 @@ export type MutationSetWebhookArgs = {
export type MutationSubscribeArgs = {
name: Scalars['String'];
input: SubscribeInput;
};
export type MutationUnsubscribeArgs = {
name: Scalars['String'];
subscriptionId?: InputMaybe<Scalars['ID']>;
};
@ -1869,6 +1870,7 @@ export type QuerySharedArticleArgs = {
export type QuerySubscriptionsArgs = {
sort?: InputMaybe<SortParams>;
type?: InputMaybe<SubscriptionType>;
};
@ -2697,6 +2699,12 @@ export enum SubscribeErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type SubscribeInput = {
name?: InputMaybe<Scalars['String']>;
subscriptionType?: InputMaybe<SubscriptionType>;
url?: InputMaybe<Scalars['String']>;
};
export type SubscribeResult = SubscribeError | SubscribeSuccess;
export type SubscribeSuccess = {
@ -3695,6 +3703,7 @@ export type ResolversTypes = {
String: ResolverTypeWrapper<Scalars['String']>;
SubscribeError: ResolverTypeWrapper<SubscribeError>;
SubscribeErrorCode: SubscribeErrorCode;
SubscribeInput: SubscribeInput;
SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess'];
SubscribeSuccess: ResolverTypeWrapper<SubscribeSuccess>;
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<ContextType = ResolverContext, ParentType extends
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'name'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
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'>>;

View file

@ -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 {

View file

@ -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)

View file

@ -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!

View file

@ -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)
}

View file

@ -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<Subscription> => {
return getRepository(Subscription).save({
user,
@ -207,6 +208,8 @@ export const createTestSubscription = async (
newsletterEmail,
status,
unsubscribeMailTo,
lastFetchedAt: new Date(),
type: subscriptionType,
})
}

View file

@ -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,