From b7496db56c855ecd1e50d650cecbfa3ff09c6767 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 9 Nov 2023 15:53:44 +0800 Subject: [PATCH] add following handler to save following item --- packages/api/src/generated/graphql.ts | 108 +----------------- packages/api/src/generated/schema.graphql | 50 +------- packages/api/src/resolvers/following/index.ts | 86 +++++++------- packages/api/src/routers/svc/following.ts | 71 ++++++++++++ packages/api/src/schema.ts | 50 +------- packages/api/src/server.ts | 2 + .../src/services/create_page_save_request.ts | 7 +- packages/api/src/services/library_item.ts | 43 ++++--- packages/api/src/services/save_page.ts | 1 + packages/db/migrations/0146.do.following.sql | 5 + .../db/migrations/0146.undo.following.sql | 2 + packages/rss-handler/src/index.ts | 4 +- 12 files changed, 158 insertions(+), 271 deletions(-) create mode 100644 packages/api/src/routers/svc/following.ts diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index a28aebb33..6208561bd 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -23,8 +23,8 @@ export type AddFollowingToLibraryError = { }; export enum AddFollowingToLibraryErrorCode { + AlreadyExists = 'ALREADY_EXISTS', BadRequest = 'BAD_REQUEST', - NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } @@ -855,26 +855,6 @@ export type FiltersSuccess = { filters: Array; }; -export type Following = { - __typename?: 'Following'; - SharedAt: Scalars['Date']; - author?: Maybe; - createdAt: Scalars['Date']; - description?: Maybe; - hiddenAt?: Maybe; - id: Scalars['ID']; - image?: Maybe; - links?: Maybe; - previewContent?: Maybe; - publishedAt?: Maybe; - seenAt?: Maybe; - sharedBy: Scalars['String']; - sharedSource: Scalars['String']; - title: Scalars['String']; - updatedAt: Scalars['Date']; - url: Scalars['String']; -}; - export type GenerateApiKeyError = { __typename?: 'GenerateApiKeyError'; errorCodes: Array; @@ -1359,7 +1339,6 @@ export type Mutation = { saveArticleReadingProgress: SaveArticleReadingProgressResult; saveFile: SaveResult; saveFilter: SaveFilterResult; - saveFollowing: SaveFollowingResult; savePage: SaveResult; saveUrl: SaveResult; setBookmarkArticle: SetBookmarkArticleResult; @@ -1561,11 +1540,6 @@ export type MutationSaveFilterArgs = { }; -export type MutationSaveFollowingArgs = { - input: SaveFollowingInput; -}; - - export type MutationSavePageArgs = { input: SavePageInput; }; @@ -2261,36 +2235,6 @@ export type SaveFilterSuccess = { filter: Filter; }; -export type SaveFollowingError = { - __typename?: 'SaveFollowingError'; - errorCodes: Array; -}; - -export enum SaveFollowingErrorCode { - BadRequest = 'BAD_REQUEST', - Unauthorized = 'UNAUTHORIZED' -} - -export type SaveFollowingInput = { - author?: InputMaybe; - description?: InputMaybe; - links?: InputMaybe; - previewContent?: InputMaybe; - publishedAt?: InputMaybe; - sharedAt: Scalars['Date']; - sharedBy: Scalars['String']; - sharedSource: Scalars['String']; - title: Scalars['String']; - url: Scalars['String']; -}; - -export type SaveFollowingResult = SaveFollowingError | SaveFollowingSuccess; - -export type SaveFollowingSuccess = { - __typename?: 'SaveFollowingSuccess'; - following: Following; -}; - export type SavePageInput = { clientRequestId: Scalars['ID']; labels?: InputMaybe>; @@ -3616,7 +3560,6 @@ export type ResolversTypes = { FiltersResult: ResolversTypes['FiltersError'] | ResolversTypes['FiltersSuccess']; FiltersSuccess: ResolverTypeWrapper; Float: ResolverTypeWrapper; - Following: ResolverTypeWrapper; GenerateApiKeyError: ResolverTypeWrapper; GenerateApiKeyErrorCode: GenerateApiKeyErrorCode; GenerateApiKeyInput: GenerateApiKeyInput; @@ -3782,11 +3725,6 @@ export type ResolversTypes = { SaveFilterInput: SaveFilterInput; SaveFilterResult: ResolversTypes['SaveFilterError'] | ResolversTypes['SaveFilterSuccess']; SaveFilterSuccess: ResolverTypeWrapper; - SaveFollowingError: ResolverTypeWrapper; - SaveFollowingErrorCode: SaveFollowingErrorCode; - SaveFollowingInput: SaveFollowingInput; - SaveFollowingResult: ResolversTypes['SaveFollowingError'] | ResolversTypes['SaveFollowingSuccess']; - SaveFollowingSuccess: ResolverTypeWrapper; SavePageInput: SavePageInput; SaveResult: ResolversTypes['SaveError'] | ResolversTypes['SaveSuccess']; SaveSuccess: ResolverTypeWrapper; @@ -4109,7 +4047,6 @@ export type ResolversParentTypes = { FiltersResult: ResolversParentTypes['FiltersError'] | ResolversParentTypes['FiltersSuccess']; FiltersSuccess: FiltersSuccess; Float: Scalars['Float']; - Following: Following; GenerateApiKeyError: GenerateApiKeyError; GenerateApiKeyInput: GenerateApiKeyInput; GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess']; @@ -4239,10 +4176,6 @@ export type ResolversParentTypes = { SaveFilterInput: SaveFilterInput; SaveFilterResult: ResolversParentTypes['SaveFilterError'] | ResolversParentTypes['SaveFilterSuccess']; SaveFilterSuccess: SaveFilterSuccess; - SaveFollowingError: SaveFollowingError; - SaveFollowingInput: SaveFollowingInput; - SaveFollowingResult: ResolversParentTypes['SaveFollowingError'] | ResolversParentTypes['SaveFollowingSuccess']; - SaveFollowingSuccess: SaveFollowingSuccess; SavePageInput: SavePageInput; SaveResult: ResolversParentTypes['SaveError'] | ResolversParentTypes['SaveSuccess']; SaveSuccess: SaveSuccess; @@ -5010,26 +4943,6 @@ export type FiltersSuccessResolvers; }; -export type FollowingResolvers = { - SharedAt?: Resolver; - author?: Resolver, ParentType, ContextType>; - createdAt?: Resolver; - description?: Resolver, ParentType, ContextType>; - hiddenAt?: Resolver, ParentType, ContextType>; - id?: Resolver; - image?: Resolver, ParentType, ContextType>; - links?: Resolver, ParentType, ContextType>; - previewContent?: Resolver, ParentType, ContextType>; - publishedAt?: Resolver, ParentType, ContextType>; - seenAt?: Resolver, ParentType, ContextType>; - sharedBy?: Resolver; - sharedSource?: Resolver; - title?: Resolver; - updatedAt?: Resolver; - url?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type GenerateApiKeyErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5396,7 +5309,6 @@ export type MutationResolvers>; saveFile?: Resolver>; saveFilter?: Resolver>; - saveFollowing?: Resolver>; savePage?: Resolver>; saveUrl?: Resolver>; setBookmarkArticle?: Resolver>; @@ -5756,20 +5668,6 @@ export type SaveFilterSuccessResolvers; }; -export type SaveFollowingErrorResolvers = { - errorCodes?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type SaveFollowingResultResolvers = { - __resolveType: TypeResolveFn<'SaveFollowingError' | 'SaveFollowingSuccess', ParentType, ContextType>; -}; - -export type SaveFollowingSuccessResolvers = { - following?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type SaveResultResolvers = { __resolveType: TypeResolveFn<'SaveError' | 'SaveSuccess', ParentType, ContextType>; }; @@ -6554,7 +6452,6 @@ export type Resolvers = { FiltersError?: FiltersErrorResolvers; FiltersResult?: FiltersResultResolvers; FiltersSuccess?: FiltersSuccessResolvers; - Following?: FollowingResolvers; GenerateApiKeyError?: GenerateApiKeyErrorResolvers; GenerateApiKeyResult?: GenerateApiKeyResultResolvers; GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers; @@ -6665,9 +6562,6 @@ export type Resolvers = { SaveFilterError?: SaveFilterErrorResolvers; SaveFilterResult?: SaveFilterResultResolvers; SaveFilterSuccess?: SaveFilterSuccessResolvers; - SaveFollowingError?: SaveFollowingErrorResolvers; - SaveFollowingResult?: SaveFollowingResultResolvers; - SaveFollowingSuccess?: SaveFollowingSuccessResolvers; SaveResult?: SaveResultResolvers; SaveSuccess?: SaveSuccessResolvers; SearchError?: SearchErrorResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index fcb09a256..c987d21e0 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -5,8 +5,8 @@ type AddFollowingToLibraryError { } enum AddFollowingToLibraryErrorCode { + ALREADY_EXISTS BAD_REQUEST - NOT_FOUND UNAUTHORIZED } @@ -758,25 +758,6 @@ type FiltersSuccess { filters: [Filter!]! } -type Following { - SharedAt: Date! - author: String - createdAt: Date! - description: String - hiddenAt: Date - id: ID! - image: String - links: JSON - previewContent: String - publishedAt: Date - seenAt: Date - sharedBy: String! - sharedSource: String! - title: String! - updatedAt: Date! - url: String! -} - type GenerateApiKeyError { errorCodes: [GenerateApiKeyErrorCode!]! } @@ -1221,7 +1202,6 @@ type Mutation { saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! saveFile(input: SaveFileInput!): SaveResult! saveFilter(input: SaveFilterInput!): SaveFilterResult! - saveFollowing(input: SaveFollowingInput!): SaveFollowingResult! savePage(input: SavePageInput!): SaveResult! saveUrl(input: SaveUrlInput!): SaveResult! setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult! @@ -1715,34 +1695,6 @@ type SaveFilterSuccess { filter: Filter! } -type SaveFollowingError { - errorCodes: [SaveFollowingErrorCode!]! -} - -enum SaveFollowingErrorCode { - BAD_REQUEST - UNAUTHORIZED -} - -input SaveFollowingInput { - author: String - description: String - links: JSON - previewContent: String - publishedAt: Date - sharedAt: Date! - sharedBy: String! - sharedSource: String! - title: String! - url: String! -} - -union SaveFollowingResult = SaveFollowingError | SaveFollowingSuccess - -type SaveFollowingSuccess { - following: Following! -} - input SavePageInput { clientRequestId: ID! labels: [CreateLabelInput!] diff --git a/packages/api/src/resolvers/following/index.ts b/packages/api/src/resolvers/following/index.ts index d7ea0129d..b4f3073bd 100644 --- a/packages/api/src/resolvers/following/index.ts +++ b/packages/api/src/resolvers/following/index.ts @@ -9,16 +9,16 @@ import { FeedsErrorCode, FeedsSuccess, MutationAddFollowingToLibraryArgs, - MutationSaveFollowingArgs, QueryFeedsArgs, - SaveFollowingError, - SaveFollowingSuccess, } from '../../generated/graphql' import { feedRepository } from '../../repository/feed' import { createPageSaveRequest } from '../../services/create_page_save_request' -import { createFollowing } from '../../services/library_item' +import { updateLibraryItem } from '../../services/library_item' import { analytics } from '../../utils/analytics' -import { authorized } from '../../utils/helpers' +import { + authorized, + libraryItemToArticleSavingRequest, +} from '../../utils/helpers' export const feedsResolve = authorized< FeedsSuccess, @@ -72,33 +72,6 @@ export const feedsResolve = authorized< } }) -export const saveFollowingResolver = authorized< - SaveFollowingSuccess, - SaveFollowingError, - MutationSaveFollowingArgs ->(async (_, { input }, { uid }) => { - analytics.track({ - userId: uid, - event: 'save_following', - properties: { - url: input.url, - }, - }) - - const newItem = await createFollowing(input, uid) - - return { - __typename: 'SaveFollowingSuccess', - following: { - ...newItem, - url: newItem.originalUrl, - SharedAt: new Date(input.sharedAt), - sharedBy: input.sharedBy, - sharedSource: input.sharedSource, - }, - } -}) - export const addFollowingToLibraryResolver = authorized< AddFollowingToLibrarySuccess, AddFollowingToLibraryError, @@ -117,7 +90,6 @@ export const addFollowingToLibraryResolver = authorized< where: { id, sharedAt: Not(IsNull()), - isInLibrary: false, }, relations: ['user'], }) @@ -125,22 +97,48 @@ export const addFollowingToLibraryResolver = authorized< if (!item) { return { - errorCodes: [AddFollowingToLibraryErrorCode.NotFound], + errorCodes: [AddFollowingToLibraryErrorCode.Unauthorized], } } - const articleSavingRequest = await createPageSaveRequest({ - userId: uid, - url: item.originalUrl, - articleSavingRequestId: id, - priority: 'high', - publishedAt: item.publishedAt || undefined, - savedAt: item.savedAt || undefined, - pubsub, - }) + if (item.isInLibrary) { + return { + errorCodes: [AddFollowingToLibraryErrorCode.AlreadyExists], + } + } + + // if the content is not fetched yet, create a page save request + if (!item.readableContent) { + const articleSavingRequest = await createPageSaveRequest({ + userId: uid, + url: item.originalUrl, + articleSavingRequestId: id, + priority: 'high', + publishedAt: item.publishedAt || undefined, + pubsub, + }) + + return { + __typename: 'AddFollowingToLibrarySuccess', + articleSavingRequest, + } + } + + const updatedItem = await updateLibraryItem( + item.id, + { + isInLibrary: true, + savedAt: new Date(), + }, + uid, + pubsub + ) return { __typename: 'AddFollowingToLibrarySuccess', - articleSavingRequest, + articleSavingRequest: libraryItemToArticleSavingRequest( + item.user, + updatedItem + ), } }) diff --git a/packages/api/src/routers/svc/following.ts b/packages/api/src/routers/svc/following.ts new file mode 100644 index 000000000..2bcba9f86 --- /dev/null +++ b/packages/api/src/routers/svc/following.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import express from 'express' +import { saveFeedItemInFollowing } from '../../services/library_item' +import { logger } from '../../utils/logger' + +type SharedSource = 'feed' | 'newsletter' | 'user' + +export interface SaveFollowingItemRequest { + userIds: string[] + title: string + url: string + itemId: string + sharedAt: Date + sharedBy: string + sharedSource: SharedSource + author?: string + description?: string + links?: any + previewContent?: string + publishedAt?: Date + savedAt?: Date +} + +function isSaveFollowingItemRequest( + body: any +): body is SaveFollowingItemRequest { + return ( + 'userIds' in body && + 'sharedAt' in body && + 'sharedBy' in body && + 'sharedSource' in body && + 'url' in body && + 'itemId' in body && + 'title' in body + ) +} + +export function followingServiceRouter() { + const router = express.Router() + + router.post('/save', async (req, res) => { + logger.info('save following item request', req.body) + + if (req.query.token !== process.env.PUBSUB_VERIFICATION_TOKEN) { + console.log('query does not include valid token') + return res.sendStatus(403) + } + + if (!isSaveFollowingItemRequest(req.body)) { + console.error('Invalid request body', req.body) + return res.status(400).send('INVALID_REQUEST_BODY') + } + + if (req.body.sharedSource === 'feed') { + logger.info('saving feed item') + + const result = await saveFeedItemInFollowing(req.body) + if (result.identifiers.length === 0) { + logger.error('error saving feed item in following') + return res.status(500).send('ERROR_SAVING_FEED_ITEM') + } + + logger.info('feed item saved in following') + return res.sendStatus(200) + } + + res.sendStatus(200) + }) + + return router +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index c794d0ad0..07e107448 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2649,53 +2649,6 @@ const schema = gql` author: String } - union SaveFollowingResult = SaveFollowingSuccess | SaveFollowingError - - type SaveFollowingSuccess { - following: Following! - } - - type Following { - id: ID! - title: String! - url: String! - author: String - image: String - description: String - seenAt: Date - createdAt: Date! - updatedAt: Date! - publishedAt: Date - hiddenAt: Date - SharedAt: Date! - sharedBy: String! - links: JSON - previewContent: String - sharedSource: String! - } - - type SaveFollowingError { - errorCodes: [SaveFollowingErrorCode!]! - } - - enum SaveFollowingErrorCode { - UNAUTHORIZED - BAD_REQUEST - } - - input SaveFollowingInput { - url: String! - title: String! - author: String - description: String - publishedAt: Date - sharedSource: String! - links: JSON - previewContent: String - sharedBy: String! - sharedAt: Date! - } - union AddFollowingToLibraryResult = AddFollowingToLibrarySuccess | AddFollowingToLibraryError @@ -2711,7 +2664,7 @@ const schema = gql` enum AddFollowingToLibraryErrorCode { UNAUTHORIZED BAD_REQUEST - NOT_FOUND + ALREADY_EXISTS } # Mutations @@ -2817,7 +2770,6 @@ const schema = gql` updateSubscription( input: UpdateSubscriptionInput! ): UpdateSubscriptionResult! - saveFollowing(input: SaveFollowingInput!): SaveFollowingResult! addFollowingToLibrary(id: ID!): AddFollowingToLibraryResult! } diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 1209deb9e..888383e66 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -25,6 +25,7 @@ import { pageRouter } from './routers/page_router' import { contentServiceRouter } from './routers/svc/content' import { emailsServiceRouter } from './routers/svc/emails' import { emailAttachmentRouter } from './routers/svc/email_attachment' +import { followingServiceRouter } from './routers/svc/following' import { integrationsServiceRouter } from './routers/svc/integrations' import { linkServiceRouter } from './routers/svc/links' import { newsletterServiceRouter } from './routers/svc/newsletters' @@ -125,6 +126,7 @@ export const createApp = (): { app.use('/svc/pubsub/user', userServiceRouter()) // app.use('/svc/reminders', remindersServiceRouter()) app.use('/svc/email-attachment', emailAttachmentRouter()) + app.use('/svc/following', followingServiceRouter()) if (env.dev.isLocal) { app.use('/local/debug', localDebugRouter()) diff --git a/packages/api/src/services/create_page_save_request.ts b/packages/api/src/services/create_page_save_request.ts index bec2f2329..9553165b4 100644 --- a/packages/api/src/services/create_page_save_request.ts +++ b/packages/api/src/services/create_page_save_request.ts @@ -130,8 +130,11 @@ export const createPageSaveRequest = async ({ pubsub ) } - // reset state to processing - if (libraryItem.state !== LibraryItemState.Processing) { + // reset state to processing if in following + if ( + libraryItem.state !== LibraryItemState.Processing && + !libraryItem.sharedAt + ) { libraryItem = await updateLibraryItem( libraryItem.id, { diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 8f462849c..38178c340 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -4,10 +4,12 @@ import { EntityLabel } from '../entity/entity_label' import { Highlight } from '../entity/highlight' import { Label } from '../entity/label' import { LibraryItem, LibraryItemState } from '../entity/library_item' -import { BulkActionType, SaveFollowingInput } from '../generated/graphql' +import { BulkActionType } from '../generated/graphql' import { createPubSubClient, EntityType } from '../pubsub' import { authTrx, getColumns } from '../repository' import { libraryItemRepository } from '../repository/library_item' +import { SaveFollowingItemRequest } from '../routers/svc/following' +import { SetClaimsRole } from '../utils/dictionary' import { wordsCount } from '../utils/helpers' import { DateFilter, @@ -567,25 +569,30 @@ export const createLibraryItem = async ( return newLibraryItem } -export const createFollowing = async ( - input: SaveFollowingInput, - userId: string -): Promise => { - return createLibraryItem( - { - ...input, - originalUrl: input.url, - isInLibrary: false, - state: LibraryItemState.Succeeded, - wordCount: 0, - user: { id: userId }, - sharedAt: new Date(input.sharedAt), - sharedSource: input.sharedSource, - sharedBy: input.sharedBy, +export const saveFeedItemInFollowing = (input: SaveFollowingItemRequest) => { + return authTrx( + async (tx) => { + const libraryItems: QueryDeepPartialEntity[] = + input.userIds.map((userId) => ({ + ...input, + user: { id: userId }, + isInLibrary: false, + originalUrl: input.url, + subscription: input.sharedBy, + })) + + return tx + .getRepository(LibraryItem) + .createQueryBuilder() + .insert() + .values(libraryItems) + .orIgnore() // ignore if the item already exists + .returning('*') + .execute() }, - userId, undefined, - true + undefined, + SetClaimsRole.ADMIN ) } diff --git a/packages/api/src/services/save_page.ts b/packages/api/src/services/save_page.ts index 6e6b7b954..a70d34693 100644 --- a/packages/api/src/services/save_page.ts +++ b/packages/api/src/services/save_page.ts @@ -144,6 +144,7 @@ export const savePage = async ( ...itemToSave, id: undefined, slug: undefined, + isInLibrary: true, } as QueryDeepPartialEntity, user.id ) diff --git a/packages/db/migrations/0146.do.following.sql b/packages/db/migrations/0146.do.following.sql index 318aa3b59..54d877fd4 100755 --- a/packages/db/migrations/0146.do.following.sql +++ b/packages/db/migrations/0146.do.following.sql @@ -18,6 +18,11 @@ ALTER TABLE omnivore.library_item ADD COLUMN shared_source text, ADD COLUMN is_in_library boolean NOT NULL DEFAULT true; +CREATE POLICY library_item_admin_policy on omnivore.library_item + FOR ALL + TO omnivore_admin + USING (true); + CREATE TABLE omnivore.feed ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), title text NOT NULL, diff --git a/packages/db/migrations/0146.undo.following.sql b/packages/db/migrations/0146.undo.following.sql index e134e80a2..3716f588b 100755 --- a/packages/db/migrations/0146.undo.following.sql +++ b/packages/db/migrations/0146.undo.following.sql @@ -6,6 +6,8 @@ BEGIN; DROP TABLE omnivore.feed; +DROP policy library_item_admin_policy ON omnivore.library_item; + ALTER TABLE omnivore.library_item DROP COLUMN hidden_at, DROP COLUMN shared_at, diff --git a/packages/rss-handler/src/index.ts b/packages/rss-handler/src/index.ts index 68a6f9e6a..2d3efa45a 100644 --- a/packages/rss-handler/src/index.ts +++ b/packages/rss-handler/src/index.ts @@ -161,12 +161,12 @@ const createFollowingTask = async ( item: Item ) => { const input = { - userId, + userIds: [userId], url: item.link, title: item.title, author: item.creator, description: item.summary, - sharedSource: 'rss-feeder', + sharedSource: 'feed', previewContent: item.content, sharedBy: feedUrl, savedAt: item.isoDate,