diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index c0abb1a54..34093b5f1 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -22,6 +22,7 @@ import { import { client, INDEX_ALIAS } from './index' import { EntityType } from '../datalayer/pubsub' import { ResponseError } from '@elastic/elasticsearch/lib/errors' +import { BulkActionType } from '../generated/graphql' const appendQuery = (body: SearchBody, query: string): void => { body.query.bool.should.push({ @@ -644,3 +645,72 @@ export const searchAsYouType = async ( return [] } } + +export const updatePagesAsync = async ( + userId: string, + action: BulkActionType, + args?: PageSearchArgs +): Promise => { + // default action is archive + let must_not = [ + { + exists: { + field: 'archivedAt', + }, + }, + ] + let params: Record = { archivedAt: new Date() } + if (action === BulkActionType.Delete) { + must_not = [] + params = { state: ArticleSavingRequestStatus.Deleted } + } + // get update field + const field = Object.keys(params)[0] + + try { + const { body } = await client.updateByQuery({ + index: INDEX_ALIAS, + conflicts: 'proceed', + wait_for_completion: false, + body: { + query: { + bool: { + filter: [ + { + term: { + userId, + }, + }, + { + terms: { + state: [ + ArticleSavingRequestStatus.Succeeded, + ArticleSavingRequestStatus.Failed, + ], + }, + }, + ], + must_not, + }, + }, + script: { + source: `ctx._source.${field} = params.${field}`, + lang: 'painless', + params, + }, + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (body.failures?.length > 0) { + console.log('failed to update pages in elastic', body.failures) + return null + } + + console.log('update pages task started', body.task) + return body.task as string + } catch (e) { + console.log('failed to update pages in elastic', e) + return null + } +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index f6cfc874c..161a0dca3 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -218,6 +218,27 @@ export type ArticlesSuccess = { pageInfo: PageInfo; }; +export type BulkActionError = { + __typename?: 'BulkActionError'; + errorCodes: Array; +}; + +export enum BulkActionErrorCode { + Unauthorized = 'UNAUTHORIZED' +} + +export type BulkActionResult = BulkActionError | BulkActionSuccess; + +export type BulkActionSuccess = { + __typename?: 'BulkActionSuccess'; + success: Scalars['Boolean']; +}; + +export enum BulkActionType { + Archive = 'ARCHIVE', + Delete = 'DELETE' +} + export enum ContentReader { Pdf = 'PDF', Web = 'WEB' @@ -1164,6 +1185,7 @@ export type MoveLabelSuccess = { export type Mutation = { __typename?: 'Mutation'; addPopularRead: AddPopularReadResult; + bulkAction: BulkActionResult; createArticle: CreateArticleResult; createArticleSavingRequest: CreateArticleSavingRequestResult; createGroup: CreateGroupResult; @@ -1237,6 +1259,12 @@ export type MutationAddPopularReadArgs = { }; +export type MutationBulkActionArgs = { + action: BulkActionType; + query?: InputMaybe; +}; + + export type MutationCreateArticleArgs = { input: CreateArticleInput; }; @@ -3202,6 +3230,11 @@ export type ResolversTypes = { ArticlesResult: ResolversTypes['ArticlesError'] | ResolversTypes['ArticlesSuccess']; ArticlesSuccess: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; + BulkActionError: ResolverTypeWrapper; + BulkActionErrorCode: BulkActionErrorCode; + BulkActionResult: ResolversTypes['BulkActionError'] | ResolversTypes['BulkActionSuccess']; + BulkActionSuccess: ResolverTypeWrapper; + BulkActionType: BulkActionType; ContentReader: ContentReader; CreateArticleError: ResolverTypeWrapper; CreateArticleErrorCode: CreateArticleErrorCode; @@ -3672,6 +3705,9 @@ export type ResolversParentTypes = { ArticlesResult: ResolversParentTypes['ArticlesError'] | ResolversParentTypes['ArticlesSuccess']; ArticlesSuccess: ArticlesSuccess; Boolean: Scalars['Boolean']; + BulkActionError: BulkActionError; + BulkActionResult: ResolversParentTypes['BulkActionError'] | ResolversParentTypes['BulkActionSuccess']; + BulkActionSuccess: BulkActionSuccess; CreateArticleError: CreateArticleError; CreateArticleInput: CreateArticleInput; CreateArticleResult: ResolversParentTypes['CreateArticleError'] | ResolversParentTypes['CreateArticleSuccess']; @@ -4186,6 +4222,20 @@ export type ArticlesSuccessResolvers; }; +export type BulkActionErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type BulkActionResultResolvers = { + __resolveType: TypeResolveFn<'BulkActionError' | 'BulkActionSuccess', ParentType, ContextType>; +}; + +export type BulkActionSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateArticleErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4871,6 +4921,7 @@ export type MoveLabelSuccessResolvers = { addPopularRead?: Resolver>; + bulkAction?: Resolver>; createArticle?: Resolver>; createArticleSavingRequest?: Resolver>; createGroup?: Resolver>; @@ -5905,6 +5956,9 @@ export type Resolvers = { ArticlesError?: ArticlesErrorResolvers; ArticlesResult?: ArticlesResultResolvers; ArticlesSuccess?: ArticlesSuccessResolvers; + BulkActionError?: BulkActionErrorResolvers; + BulkActionResult?: BulkActionResultResolvers; + BulkActionSuccess?: BulkActionSuccessResolvers; CreateArticleError?: CreateArticleErrorResolvers; CreateArticleResult?: CreateArticleResultResolvers; CreateArticleSavingRequestError?: CreateArticleSavingRequestErrorResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 4154541a5..34968394b 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -179,6 +179,25 @@ type ArticlesSuccess { pageInfo: PageInfo! } +type BulkActionError { + errorCodes: [BulkActionErrorCode!]! +} + +enum BulkActionErrorCode { + UNAUTHORIZED +} + +union BulkActionResult = BulkActionError | BulkActionSuccess + +type BulkActionSuccess { + success: Boolean! +} + +enum BulkActionType { + ARCHIVE + DELETE +} + enum ContentReader { PDF WEB @@ -1036,6 +1055,7 @@ type MoveLabelSuccess { type Mutation { addPopularRead(name: String!): AddPopularReadResult! + bulkAction(action: BulkActionType!, query: String): BulkActionResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! createGroup(input: CreateGroupInput!): CreateGroupResult! diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index e7ba636a9..a7cd6c906 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -9,12 +9,16 @@ import { ArticleErrorCode, ArticlesError, ArticleSuccess, + BulkActionError, + BulkActionErrorCode, + BulkActionSuccess, ContentReader, CreateArticleError, CreateArticleErrorCode, CreateArticleSuccess, FeedArticle, InputMaybe, + MutationBulkActionArgs, MutationCreateArticleArgs, MutationSaveArticleReadingProgressArgs, MutationSetBookmarkArticleArgs, @@ -95,6 +99,7 @@ import { searchAsYouType, searchPages, updatePage, + updatePagesAsync, } from '../../elastic/pages' import { searchHighlights } from '../../elastic/highlights' import { saveSearchHistory } from '../../services/search_history' @@ -1043,6 +1048,34 @@ export const updatesSinceResolver = authorized< } ) +export const bulkActionResolver = authorized< + BulkActionSuccess, + BulkActionError, + MutationBulkActionArgs +>(async (_parent, { action }, { claims: { uid }, log }) => { + log.info('bulkActionResolver') + + if (!uid) { + log.error('bulkActionResolver', { error: 'Unauthorized' }) + return { errorCodes: [BulkActionErrorCode.Unauthorized] } + } + + analytics.track({ + userId: uid, + event: 'BulkAction', + properties: { + env: env.server.apiEnv, + }, + }) + + // TODO: get search filters from query + + // start a task to update pages + const taskId = await updatePagesAsync(uid, action) + + return { success: !!taskId } +}) + const getUpdateReason = (page: Page, since: Date) => { if (page.state === ArticleSavingRequestStatus.Deleted) { return UpdateReason.Deleted diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index ea5ff9ecf..e1d7e6c9a 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -24,6 +24,7 @@ import { addPopularReadResolver, apiKeysResolver, articleSavingRequestResolver, + bulkActionResolver, createArticleResolver, createArticleSavingRequestResolver, createGroupResolver, @@ -201,6 +202,7 @@ export const functionResolvers = { leaveGroup: leaveGroupResolver, uploadImportFile: uploadImportFileResolver, markEmailAsItem: markEmailAsItemResolver, + bulkAction: bulkActionResolver, }, Query: { me: getMeUserResolver, @@ -664,4 +666,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('UploadImportFile'), ...resultResolveTypeResolver('RecentEmails'), ...resultResolveTypeResolver('MarkEmailAsItem'), + ...resultResolveTypeResolver('BulkAction'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 64915652d..5d4353c0b 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2373,6 +2373,25 @@ const schema = gql` NOT_FOUND } + enum BulkActionType { + DELETE + ARCHIVE + } + + union BulkActionResult = BulkActionSuccess | BulkActionError + + type BulkActionSuccess { + success: Boolean! + } + + type BulkActionError { + errorCodes: [BulkActionErrorCode!]! + } + + enum BulkActionErrorCode { + UNAUTHORIZED + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2462,6 +2481,7 @@ const schema = gql` contentType: String! ): UploadImportFileResult! markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! + bulkAction(query: String, action: BulkActionType!): BulkActionResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index a539101a4..77fd10fad 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -11,6 +11,7 @@ import 'mocha' import { User } from '../../src/entity/user' import chaiString from 'chai-string' import { + BulkActionType, SyncUpdatedItemEdge, UpdateReason, UploadFileStatus, @@ -1084,4 +1085,87 @@ describe('Article API', () => { ) }) }) + + describe('BulkAction API', () => { + const bulkActionQuery = (action: BulkActionType) => ` + mutation { + bulkAction (action: ${action}) { + ... on BulkActionSuccess { + success + } + ... on BulkActionError { + errorCodes + } + } + } + ` + + before(async () => { + // Create some test pages + for (let i = 0; i < 5; i++) { + await createPage( + { + id: '', + hash: '', + userId: user.id, + pageType: i == 0 ? PageType.Article : PageType.File, + title: 'test page', + content: '', + slug: '', + createdAt: new Date(), + updatedAt: new Date(), + readingProgressPercent: 0, + readingProgressAnchorIndex: 0, + url: '', + savedAt: new Date(), + state: + i == 0 + ? ArticleSavingRequestStatus.Failed + : ArticleSavingRequestStatus.Succeeded, + }, + ctx + ) + } + }) + + after(async () => { + // Delete all pages + await deletePagesByParam({ userId: user.id }, ctx) + }) + + context('when action is Archive', () => { + it('archives all pages', async () => { + const res = await graphqlRequest( + bulkActionQuery(BulkActionType.Archive), + authToken + ).expect(200) + expect(res.body.data.bulkAction.success).to.be.true + // Wait for the archive to finish + await setTimeout(async () => { + const pages = await graphqlRequest(searchQuery(), authToken).expect( + 200 + ) + expect(pages.body.data.search.pageInfo.totalCount).to.eql(0) + }, 1000) + }) + }) + + context('when action is Delete', () => { + it('deletes all pages', async () => { + const res = await graphqlRequest( + bulkActionQuery(BulkActionType.Delete), + authToken + ).expect(200) + expect(res.body.data.bulkAction.success).to.be.true + // Wait for the delete to finish + await setTimeout(async () => { + const pages = await graphqlRequest( + searchQuery('in:all'), + authToken + ).expect(200) + expect(pages.body.data.search.pageInfo.totalCount).to.eql(0) + }, 1000) + }) + }) + }) })