From b1899e340d8f272f057d1a85b1fc1ad969f989bb Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sun, 3 Sep 2023 22:53:34 +0800 Subject: [PATCH] replace library item --- packages/api/src/entity/library_item.ts | 7 +- packages/api/src/repository/index.ts | 44 +- packages/api/src/repository/library_item.ts | 10 - packages/api/src/repository/profile.ts | 4 + packages/api/src/repository/upload_file.ts | 8 +- packages/api/src/resolvers/api_key/index.ts | 17 +- packages/api/src/resolvers/article/index.ts | 898 ++++++------------ .../resolvers/article_saving_request/index.ts | 72 +- packages/api/src/resolvers/filters/index.ts | 2 +- .../api/src/resolvers/function_resolvers.ts | 111 +-- packages/api/src/schema.ts | 8 - packages/api/src/services/archive_link.ts | 19 - .../src/services/create_page_save_request.ts | 93 +- packages/api/src/services/create_user.ts | 4 +- packages/api/src/services/labels.ts | 83 +- packages/api/src/services/library_item.ts | 183 ++-- packages/api/src/services/save_file.ts | 10 +- packages/api/src/services/save_page.ts | 4 +- packages/api/src/services/upload_file.ts | 30 + packages/api/src/utils/auth.ts | 54 +- packages/api/src/utils/createTask.ts | 2 +- packages/api/src/utils/helpers.ts | 88 +- packages/api/src/utils/parser.ts | 9 +- packages/api/src/utils/search.ts | 38 +- 24 files changed, 772 insertions(+), 1026 deletions(-) create mode 100644 packages/api/src/repository/profile.ts delete mode 100644 packages/api/src/services/archive_link.ts create mode 100644 packages/api/src/services/upload_file.ts diff --git a/packages/api/src/entity/library_item.ts b/packages/api/src/entity/library_item.ts index 28333c597..4399100e5 100644 --- a/packages/api/src/entity/library_item.ts +++ b/packages/api/src/entity/library_item.ts @@ -178,7 +178,8 @@ export class LibraryItem { @OneToMany( () => Recommendation, - (recommendation) => recommendation.libraryItem + (recommendation) => recommendation.libraryItem, + { cascade: true } ) @JoinTable({ name: 'recommendation', @@ -190,7 +191,9 @@ export class LibraryItem { @Column('enum', { enum: DirectionalityType, default: DirectionalityType.LTR }) directionality!: DirectionalityType - @OneToMany(() => Highlight, (highlight) => highlight.libraryItem) + @OneToMany(() => Highlight, (highlight) => highlight.libraryItem, { + cascade: true, + }) @JoinTable({ name: 'highlight', joinColumn: { name: 'library_item_id' }, diff --git a/packages/api/src/repository/index.ts b/packages/api/src/repository/index.ts index ec1c44104..831a50c25 100644 --- a/packages/api/src/repository/index.ts +++ b/packages/api/src/repository/index.ts @@ -1,23 +1,5 @@ -import { EntityManager, EntityTarget, Repository } from 'typeorm' +import { EntityManager } from 'typeorm' import { appDataSource } from '../data_source' -import { Feature } from '../entity/feature' -import { Filter } from '../entity/filter' -import { Follower } from '../entity/follower' -import { Group } from '../entity/groups/group' -import { GroupMembership } from '../entity/groups/group_membership' -import { Invite } from '../entity/groups/invite' -import { Integration } from '../entity/integration' -import { NewsletterEmail } from '../entity/newsletter_email' -import { Profile } from '../entity/profile' -import { ReceivedEmail } from '../entity/received_email' -import { Recommendation } from '../entity/recommendation' -import { AbuseReport } from '../entity/reports/abuse_report' -import { ContentDisplayReport } from '../entity/reports/content_display_report' -import { Rule } from '../entity/rule' -import { Subscription } from '../entity/subscription' -import { UserDeviceToken } from '../entity/user_device_tokens' -import { UserPersonalization } from '../entity/user_personalization' -import { Webhook } from '../entity/webhook' export const setClaims = async ( manager: EntityManager, @@ -30,10 +12,6 @@ export const setClaims = async ( ]) } -export const getRepository = (entity: EntityTarget): Repository => { - return entityManager.getRepository(entity) -} - export const authTrx = async ( fn: (manager: EntityManager) => Promise, uid = '00000000-0000-0000-0000-000000000000', @@ -46,23 +24,3 @@ export const authTrx = async ( } export const entityManager = appDataSource.manager - -export const groupMembershipRepository = getRepository(GroupMembership) -export const groupRepository = getRepository(Group) -export const inviteRepository = getRepository(Invite) -export const abuseReportRepository = getRepository(AbuseReport) -export const contentDisplayReportRepository = - getRepository(ContentDisplayReport) -export const featureRepository = getRepository(Feature) -export const filterRepository = getRepository(Filter) -export const followerRepository = getRepository(Follower) -export const integrationRepository = getRepository(Integration) -export const newsletterEmailRepository = getRepository(NewsletterEmail) -export const profileRepository = getRepository(Profile) -export const receivedEmailRepository = getRepository(ReceivedEmail) -export const recommendationRepository = getRepository(Recommendation) -export const ruleRepository = getRepository(Rule) -export const subscriptionRepository = getRepository(Subscription) -export const userDeviceTokenRepository = getRepository(UserDeviceToken) -export const userPersonalizationRepository = getRepository(UserPersonalization) -export const webhookRepository = getRepository(Webhook) diff --git a/packages/api/src/repository/library_item.ts b/packages/api/src/repository/library_item.ts index 029dbbbe6..b6a4ca4cf 100644 --- a/packages/api/src/repository/library_item.ts +++ b/packages/api/src/repository/library_item.ts @@ -1,16 +1,6 @@ import { entityManager } from '.' import { LibraryItem } from '../entity/library_item' -export const getLibraryItemById = async (id: string) => { - return libraryItemRepository.findOneBy({ id }) -} - -export const getLibraryItemByUrl = async (url: string) => { - return libraryItemRepository.findOneBy({ - originalUrl: url, - }) -} - export const libraryItemRepository = entityManager .getRepository(LibraryItem) .extend({ diff --git a/packages/api/src/repository/profile.ts b/packages/api/src/repository/profile.ts new file mode 100644 index 000000000..cac9aaeca --- /dev/null +++ b/packages/api/src/repository/profile.ts @@ -0,0 +1,4 @@ +import { Profile } from '../entity/profile' +import { entityManager } from '.' + +export const profileRepository = entityManager.getRepository(Profile) diff --git a/packages/api/src/repository/upload_file.ts b/packages/api/src/repository/upload_file.ts index 38e3bd967..77cc839d2 100644 --- a/packages/api/src/repository/upload_file.ts +++ b/packages/api/src/repository/upload_file.ts @@ -1,4 +1,10 @@ import { entityManager } from '.' import { UploadFile } from '../entity/upload_file' -export const uploadFileRepository = entityManager.getRepository(UploadFile) +export const uploadFileRepository = entityManager + .getRepository(UploadFile) + .extend({ + findById(id: string) { + return this.findOneBy({ id }) + }, + }) diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index 4aaf986b4..ce8de19c9 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -1,4 +1,3 @@ -import { ApiKey } from '../../entity/api_key' import { env } from '../../env' import { ApiKeysError, @@ -13,6 +12,7 @@ import { RevokeApiKeyErrorCode, RevokeApiKeySuccess, } from '../../generated/graphql' +import { apiKeyRepository } from '../../repository/api_key' import { analytics } from '../../utils/analytics' import { generateApiKey, hashApiKey } from '../../utils/auth' import { authorized } from '../../utils/helpers' @@ -20,8 +20,8 @@ import { authorized } from '../../utils/helpers' export const apiKeysResolver = authorized( async (_, __, { log, authTrx }) => { try { - const apiKeys = await authTrx>(async (tx) => { - return tx.find(ApiKey, { + const apiKeys = await authTrx(async (tx) => { + return tx.withRepository(apiKeyRepository).find({ select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'], order: { usedAt: { direction: 'DESC', nulls: 'last' }, @@ -51,8 +51,8 @@ export const generateApiKeyResolver = authorized< try { const exp = new Date(expiresAt) const originalKey = generateApiKey() - const apiKeyCreated = await authTrx>(async (tx) => { - return tx.save(ApiKey, { + const apiKeyCreated = await authTrx(async (tx) => { + return tx.withRepository(apiKeyRepository).save({ user: { id: uid }, name, key: hashApiKey(originalKey), @@ -89,13 +89,14 @@ export const revokeApiKeyResolver = authorized< MutationRevokeApiKeyArgs >(async (_, { id }, { claims: { uid }, log, authTrx }) => { try { - const deletedApiKey = await authTrx>(async (tx) => { - const apiKey = await tx.findOneBy(ApiKey, { id }) + const deletedApiKey = await authTrx(async (tx) => { + const apiRepo = tx.withRepository(apiKeyRepository) + const apiKey = await apiRepo.findOneBy({ id }) if (!apiKey) { return null } - return tx.remove(ApiKey, apiKey) + return apiRepo.remove(apiKey) }) if (!deletedApiKey) { diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 5a49bff5b..57e8cd39d 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -5,16 +5,17 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { Readability } from '@omnivore/readability' import graphqlFields from 'graphql-fields' -import { Label } from '../../entity/label' -import { LibraryItemType } from '../../entity/library_item' -import { UploadFile } from '../../entity/upload_file' +import { + LibraryItem, + LibraryItemState, + LibraryItemType, +} from '../../entity/library_item' import { env } from '../../env' import { Article, ArticleError, ArticleErrorCode, ArticleSavingRequestStatus, - ArticlesError, ArticleSuccess, BulkActionError, BulkActionErrorCode, @@ -25,19 +26,17 @@ import { CreateArticleErrorCode, CreateArticleSuccess, FeedArticle, - InputMaybe, MutationBulkActionArgs, MutationCreateArticleArgs, MutationSaveArticleReadingProgressArgs, MutationSetBookmarkArticleArgs, MutationSetFavoriteArticleArgs, PageInfo, + PageType, QueryArticleArgs, - QueryArticlesArgs, QuerySearchArgs, QueryTypeaheadSearchArgs, QueryUpdatesSinceArgs, - ResolverFn, SaveArticleReadingProgressError, SaveArticleReadingProgressErrorCode, SaveArticleReadingProgressSuccess, @@ -45,33 +44,39 @@ import { SearchErrorCode, SearchSuccess, SetBookmarkArticleError, - SetBookmarkArticleErrorCode, SetBookmarkArticleSuccess, SetFavoriteArticleError, SetFavoriteArticleErrorCode, SetFavoriteArticleSuccess, SetShareArticleSuccess, - SortParams, TypeaheadSearchError, TypeaheadSearchErrorCode, - TypeaheadSearchItem, TypeaheadSearchSuccess, UpdateReason, UpdatesSinceError, - UpdatesSinceErrorCode, - UpdatesSinceSuccess + UpdatesSinceSuccess, } from '../../generated/graphql' -import { getLibraryItemByUrl } from '../../repository/library_item' +import { libraryItemRepository } from '../../repository/library_item' import { userRepository } from '../../repository/user' import { createPageSaveRequest } from '../../services/create_page_save_request' import { - addLabelToPage, + addLabelsToLibraryItem, + findLabelsByIds, getLabelsAndCreateIfNotExist, - getLabelsByIds } from '../../services/labels' -import { searchLibraryItems } from '../../services/library_item' -import { setFileUploadComplete } from '../../services/save_file' +import { + createLibraryItem, + findLibraryItemById, + findLibraryItemByUrl, + findLibraryItemsByPrefix, + searchLibraryItems, + updateLibraryItem, +} from '../../services/library_item' import { parsedContentToLibraryItem } from '../../services/save_page' +import { + findUploadFileById, + setFileUploadComplete, +} from '../../services/upload_file' import { traceAs } from '../../tracing' import { Merge } from '../../util' import { analytics } from '../../utils/analytics' @@ -82,10 +87,11 @@ import { generateSlug, isBase64Image, isParsingTimeout, + libraryItemToPartialArticle, + libraryItemToSearchItem, pageError, titleForFilePath, userDataToUser, - validatedDate } from '../../utils/helpers' import { createImageProxyUrl } from '../../utils/imageproxy' import { @@ -93,15 +99,13 @@ import { getDistillerResult, htmlToMarkdown, ParsedContentPuppeteer, - parsePreparedContent + parsePreparedContent, } from '../../utils/parser' -import { parseSearchQuery, SortBy, SortOrder } from '../../utils/search' +import { parseSearchQuery, sortParamsToSort } from '../../utils/search' import { - contentReaderForPage, getStorageFileDetails, - makeStorageFilePublic + makeStorageFilePublic, } from '../../utils/uploads' -import { WithDataSourcesContext } from '../types' import { itemTypeForContentType } from '../upload_files' export enum ArticleFormat { @@ -139,7 +143,7 @@ export const createArticleResolver = authorized< CreateArticleError, MutationCreateArticleArgs >( - async ( + ;async ( _, { input: { @@ -153,10 +157,8 @@ export const createArticleResolver = authorized< labels: inputLabels, }, }, - ctx + { log, uid, pubsub } ) => { - const { authTrx, log, uid } = ctx - analytics.track({ userId: uid, event: 'link_saved', @@ -173,8 +175,9 @@ export const createArticleResolver = authorized< { errorCodes: [CreateArticleErrorCode.Unauthorized], }, - ctx, - pageId + uid, + pageId, + pubsub ) } const user = userDataToUser(userData) @@ -185,8 +188,9 @@ export const createArticleResolver = authorized< { errorCodes: [CreateArticleErrorCode.NotAllowedToParse], }, - ctx, - pageId + uid, + pageId, + pubsub ) } @@ -220,7 +224,7 @@ export const createArticleResolver = authorized< content: '', description: '', title: '', - pageType: LibraryItemType.Unknown, + pageType: itemType as unknown as PageType, contentReader: ContentReader.Web, author: '', url, @@ -228,29 +232,18 @@ export const createArticleResolver = authorized< isArchived: false, }, } - // save state - const archivedAt = - state === ArticleSavingRequestStatus.Archived ? new Date() : null - // save labels - let labels: Label[] | undefined = undefined - if (inputLabels) { - labels = await getLabelsAndCreateIfNotExist(inputLabels, uid) - } if (uploadFileId) { /* We do not trust the values from client, lookup upload file by querying * with filtering on user ID and URL to verify client's uploadFileId is valid. */ - const uploadFile = await authTrx((tx) => - tx.findOneBy(UploadFile, { - id: uploadFileId, - }) - ) + const uploadFile = await findUploadFileById(uploadFileId, uid) if (!uploadFile) { return pageError( { errorCodes: [CreateArticleErrorCode.UploadFileMissing] }, - ctx, - pageId + uid, + pageId, + pubsub ) } const uploadFileDetails = await getStorageFileDetails( @@ -266,7 +259,12 @@ export const createArticleResolver = authorized< source !== 'puppeteer-parse' && FORCE_PUPPETEER_URLS.some((regex) => regex.test(url)) ) { - await createPageSaveRequest({ userId: uid, url, archivedAt, labels }) + await createPageSaveRequest({ + userId: uid, + url, + state: state || undefined, + labels: inputLabels || undefined, + }) return DUMMY_RESPONSE } else if (!skipParsing && preparedDocument?.document) { const parseResults = await traceAs>( @@ -282,7 +280,12 @@ export const createArticleResolver = authorized< } else if (!preparedDocument?.document) { // We have a URL but no document, so we try to send this to puppeteer // and return a dummy response. - await createPageSaveRequest({ userId: uid, url, archivedAt, labels }) + await createPageSaveRequest({ + userId: uid, + url, + state: state || undefined, + labels: inputLabels || undefined, + }) return DUMMY_RESPONSE } @@ -317,212 +320,144 @@ export const createArticleResolver = authorized< }) if (uploadFileId) { - const uploadFileData = await authTrx(async (tx) => { - return setFileUploadComplete(uploadFileId, tx) - }) + const uploadFileData = await setFileUploadComplete(uploadFileId, uid) if (!uploadFileData || !uploadFileData.id || !uploadFileData.fileName) { return pageError( { errorCodes: [CreateArticleErrorCode.UploadFileMissing], }, - ctx, - pageId + uid, + pageId, + pubsub ) } await makeStorageFilePublic(uploadFileData.id, uploadFileData.fileName) } - // save page's state and labels - libraryItemToSave.archivedAt = archivedAt - libraryItemToSave.labels = labels - const existingLibraryItem = await getLibraryItemByUrl( - libraryItemToSave.originalUrl!, + // save page's state and labels + libraryItemToSave.archivedAt = + state === ArticleSavingRequestStatus.Archived ? new Date() : null + if (inputLabels) { + libraryItemToSave.labels = await getLabelsAndCreateIfNotExist( + inputLabels, + uid + ) + } + + let libraryItemToReturn: LibraryItem + + const existingLibraryItem = await findLibraryItemByUrl( + libraryItemToSave.originalUrl, uid ) pageId = existingLibraryItem?.id || pageId - if (pageId || existingLibraryItem) { + if (pageId) { // update existing page's state from processing to succeeded - const updated = await updatePage(pageId, libraryItemToSave, { - ...ctx, + libraryItemToReturn = await updateLibraryItem( + pageId, + libraryItemToSave, uid, - }) - - if (!updated) { - return pageError( - { - errorCodes: [CreateArticleErrorCode.ElasticError], - }, - ctx, - pageId - ) - } + pubsub + ) } else { // create new page in elastic - const newPageId = await createPage(libraryItemToSave, { ...ctx, uid }) - if (!newPageId) { - return pageError( - { - errorCodes: [CreateArticleErrorCode.ElasticError], - }, - ctx, - pageId - ) - } - libraryItemToSave.id = newPageId + libraryItemToReturn = await createLibraryItem( + libraryItemToSave, + uid, + pubsub + ) } + log.info( 'page created in elastic', - libraryItemToSave.id, - libraryItemToSave.url, - libraryItemToSave.slug, - libraryItemToSave.title + libraryItemToReturn.id, + libraryItemToReturn.originalUrl, + libraryItemToReturn.slug, + libraryItemToReturn.title ) - const createdArticle: PartialArticle = { - ...libraryItemToSave, - isArchived: !!libraryItemToSave.archivedAt, - } return { user, - created: false, - createdArticle: createdArticle, + created: true, + createdArticle: libraryItemToPartialArticle(libraryItemToReturn), } - } } + } catch (error) { + log.error('Error creating article', error) + return pageError( + { + errorCodes: [CreateArticleErrorCode.ElasticError], + }, + uid, + pageId, + pubsub + ) + } + } ) export type ArticleSuccessPartial = Merge< ArticleSuccess, { article: PartialArticle } > -export const getArticleResolver: ResolverFn< - ArticleSuccessPartial | ArticleError, - Record, - WithDataSourcesContext, +export const getArticleResolver = authorized< + ArticleSuccessPartial, + ArticleError, QueryArticleArgs -> = async (_obj, { slug, format }, { claims, log }, info) => { +>(async (_obj, { slug, format }, { authTrx, uid, log }, info) => { try { - if (!claims?.uid) { - return { errorCodes: [ArticleErrorCode.Unauthorized] } - } - const includeOriginalHtml = format === ArticleFormat.Distiller || !!graphqlFields(info).article.originalHtml // We allow the backend to use the ID instead of a slug to fetch the article - const page = - (await getPageByParam( - { - userId: claims.uid, - slug, - }, - includeOriginalHtml - )) || - (await getPageByParam( - { - userId: claims.uid, - _id: slug, - }, - includeOriginalHtml - )) + const libraryItem = await authTrx((tx) => + tx + .withRepository(libraryItemRepository) + .createQueryBuilder('library_item') + .leftJoinAndSelect('library_item.labels', 'labels') + .leftJoinAndSelect('library_item.highlights', 'highlights') + .where('library_item.id = :id', { id: slug }) + .getOne() + ) - if (!page || page.state === ArticleSavingRequestStatus.Deleted) { + if (!libraryItem || libraryItem.state === LibraryItemState.Deleted) { return { errorCodes: [ArticleErrorCode.NotFound] } } - if (isParsingTimeout(page)) { - page.content = UNPARSEABLE_CONTENT + if (isParsingTimeout(libraryItem)) { + libraryItem.readableContent = UNPARSEABLE_CONTENT } if (format === ArticleFormat.Markdown) { - page.content = htmlToMarkdown(page.content) + libraryItem.readableContent = htmlToMarkdown(libraryItem.readableContent) } else if (format === ArticleFormat.Distiller) { - if (!page.originalHtml) { + if (!libraryItem.originalContent) { return { errorCodes: [ArticleErrorCode.BadData] } } const distillerResult = await getDistillerResult( - claims.uid, - page.originalHtml + uid, + libraryItem.originalContent ) if (!distillerResult) { return { errorCodes: [ArticleErrorCode.BadData] } } - page.content = distillerResult + libraryItem.readableContent = distillerResult } return { - article: { ...page, isArchived: !!page.archivedAt, linkId: page.id }, + article: libraryItemToPartialArticle(libraryItem), } } catch (error) { log.error(error) return { errorCodes: [ArticleErrorCode.BadData] } } -} +}) type PaginatedPartialArticles = { edges: { cursor: string; node: PartialArticle }[] pageInfo: PageInfo } -export const getArticlesResolver = authorized< - PaginatedPartialArticles, - ArticlesError, - QueryArticlesArgs ->(async (_obj, params, { claims, log }) => { - const startCursor = params.after || '' - const first = params.first || 10 - - const searchQuery = parseSearchQuery(params.query || undefined) - - const [pages, totalCount] = (await searchLibraryItems( - { - from: Number(startCursor), - size: first + 1, // fetch one more item to get next cursor - sort: searchQuery.sortParams, - includePending: params.includePending, - ...searchQuery, - }, - claims.uid - )) || [[], 0] - - const start = - startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 - const hasNextPage = pages.length > first - const endCursor = String(start + pages.length - (hasNextPage ? 1 : 0)) - - log.info( - 'start', - start, - 'returning end cursor', - endCursor, - 'length', - pages.length - 1 - ) - - //TODO: refactor so that the lastCursor included - if (hasNextPage) { - // remove an extra if exists - pages.pop() - } - - const edges = pages.map((node) => { - return { - node, - cursor: endCursor, - } - }) - return { - edges, - pageInfo: { - hasPreviousPage: false, - startCursor, - hasNextPage: hasNextPage, - endCursor, - totalCount, - }, - } -}) - export type SetShareArticleSuccessPartial = Merge< SetShareArticleSuccess, { @@ -606,108 +541,43 @@ export const setBookmarkArticleResolver = authorized< SetBookmarkArticleSuccessPartial, SetBookmarkArticleError, MutationSetBookmarkArticleArgs ->( - async ( - _, - { input: { articleID, bookmark } }, - { claims: { uid }, log, pubsub } - ) => { - const page = await getPageByParam({ +>(async (_, { input: { articleID } }, { uid, log, pubsub }) => { + // delete the page and its metadata + const deletedLibraryItem = await updateLibraryItem( + articleID, + { + state: LibraryItemState.Deleted, + }, + uid, + pubsub + ) + + analytics.track({ + userId: uid, + event: 'link_removed', + properties: { + id: articleID, + env: env.server.apiEnv, + }, + }) + + log.info('Article unbookmarked', { + page: Object.assign({}, deletedLibraryItem, { + readableContent: undefined, + originalContent: undefined, + }), + labels: { + source: 'resolver', + resolver: 'setBookmarkArticleResolver', userId: uid, - _id: articleID, - }) - if (!page) { - return { errorCodes: [SetBookmarkArticleErrorCode.NotFound] } - } - - const pageContext = { - pubsub, - uid, - refresh: true, // refresh to make sure the page is deleted - } - - if (!bookmark) { - // delete the page and its metadata - const deleted = await updatePage( - page.id, - { - state: ArticleSavingRequestStatus.Deleted, - }, - pageContext - ) - if (!deleted) { - return { errorCodes: [SetBookmarkArticleErrorCode.NotFound] } - } - - analytics.track({ - userId: uid, - event: 'link_removed', - properties: { - url: page.url, - env: env.server.apiEnv, - }, - }) - - log.info('Article unbookmarked', { - page: Object.assign({}, page, { - content: undefined, - originalHtml: undefined, - }), - labels: { - source: 'resolver', - resolver: 'setBookmarkArticleResolver', - userId: uid, - articleID, - }, - }) - // Make sure article.id instead of userArticle.id has passed. We use it for cache updates - return { - bookmarkedArticle: { - ...page, - isArchived: false, - savedByViewer: false, - postedByViewer: false, - }, - } - } else { - try { - const pageUpdated: Partial = { - userId: uid, - slug: generateSlug(page.title), - } - const updated = await updatePage(articleID, pageUpdated, pageContext) - if (!updated) { - return { errorCodes: [SetBookmarkArticleErrorCode.NotFound] } - } - - log.info('Article bookmarked', { - page: Object.assign({}, page, { - content: undefined, - originalHtml: undefined, - }), - labels: { - source: 'resolver', - resolver: 'setBookmarkArticleResolver', - userId: uid, - }, - }) - - // Make sure article.id instead of userArticle.id has passed. We use it for cache updates - return { - bookmarkedArticle: { - ...pageUpdated, - ...page, - isArchived: false, - savedByViewer: true, - postedByViewer: false, - }, - } - } catch (error) { - return { errorCodes: [SetBookmarkArticleErrorCode.BookmarkExists] } - } - } + articleID, + }, + }) + // Make sure article.id instead of userArticle.id has passed. We use it for cache updates + return { + bookmarkedArticle: libraryItemToPartialArticle(deletedLibraryItem), } -) +}) export type SaveArticleReadingProgressSuccessPartial = Merge< SaveArticleReadingProgressSuccess, @@ -728,11 +598,11 @@ export const saveArticleReadingProgressResolver = authorized< readingProgressTopPercent, }, }, - { claims: { uid }, pubsub } + { uid, pubsub } ) => { - const page = await getPageByParam({ userId: uid, _id: id }) + const libraryItem = await findLibraryItemById(id, uid) - if (!page) { + if (!libraryItem) { return { errorCodes: [SaveArticleReadingProgressErrorCode.NotFound] } } @@ -749,260 +619,104 @@ export const saveArticleReadingProgressResolver = authorized< // If we have a top percent, we only save it if it's greater than the current top percent // or set to zero if the top percent is zero. const readingProgressTopPercentToSave = readingProgressTopPercent - ? Math.max(readingProgressTopPercent, page.readingProgressTopPercent || 0) + ? Math.max( + readingProgressTopPercent, + libraryItem.readingProgressTopPercent || 0 + ) : readingProgressTopPercent === 0 ? 0 : undefined // If setting to zero we accept the update, otherwise we require it // be greater than the current reading progress. const updatedPart = { - readingProgressPercent: + readingProgressBottomPercent: readingProgressPercent === 0 ? 0 - : Math.max(readingProgressPercent, page.readingProgressPercent), - readingProgressAnchorIndex: + : Math.max( + readingProgressPercent, + libraryItem.readingProgressTopPercent + ), + readingProgressHighestReadAnchor: readingProgressAnchorIndex === 0 ? 0 : Math.max( readingProgressAnchorIndex, - page.readingProgressAnchorIndex + libraryItem.readingProgressHighestReadAnchor ), readingProgressTopPercent: readingProgressTopPercentToSave, readAt: new Date(), } - const updated = await updatePage(id, updatedPart, { pubsub, uid }) - if (!updated) { - return { errorCodes: [SaveArticleReadingProgressErrorCode.NotFound] } - } + const updatedItem = await updateLibraryItem(id, updatedPart, uid, pubsub) return { - updatedArticle: { - ...page, - ...updatedPart, - isArchived: !!page.archivedAt, - }, + updatedArticle: libraryItemToPartialArticle(updatedItem), } } ) -export const getReadingProgressForArticleResolver: ResolverFn< - number | { errorCodes: string[] }, - Article, - WithDataSourcesContext, - Record -> = async (article, _params, { claims }) => { - if (!claims?.uid) { - return 0 - } +export const searchResolver = authorized( + async (_obj, params, { uid, log }) => { + const startCursor = params.after || '' + const first = params.first || 10 - if ( - article.readingProgressPercent !== undefined && - article.readingProgressPercent !== null - ) { - return article.readingProgressPercent - } + // the query size is limited to 255 characters + if (params.query && params.query.length > 255) { + return { errorCodes: [SearchErrorCode.QueryTooLong] } + } - const articleReadingProgress = ( - await getPageByParam({ userId: claims.uid, _id: article.id }) - )?.readingProgressPercent + const searchQuery = parseSearchQuery(params.query || undefined) - return articleReadingProgress || 0 -} - -export const getReadingProgressAnchorIndexForArticleResolver: ResolverFn< - number | { errorCodes: string[] }, - Article, - WithDataSourcesContext, - Record -> = async (article, _params, { claims }) => { - if (!claims?.uid) { - return 0 - } - - if ( - article.readingProgressAnchorIndex !== undefined && - article.readingProgressAnchorIndex !== null - ) { - return article.readingProgressAnchorIndex - } - - const articleReadingProgressAnchorIndex = ( - await getPageByParam({ userId: claims.uid, _id: article.id }) - )?.readingProgressAnchorIndex - - return articleReadingProgressAnchorIndex || 0 -} - -export const searchResolver = authorized< - SearchSuccess, - SearchError, - QuerySearchArgs ->(async (_obj, params, { claims, log }) => { - const startCursor = params.after || '' - const first = params.first || 10 - - // the query size is limited to 255 characters - if (params.query && params.query.length > 255) { - return { errorCodes: [SearchErrorCode.QueryTooLong] } - } - - const searchQuery = parseSearchQuery(params.query || undefined) - - let results: SearchItemData[] - let totalCount: number - - const searchType = searchQuery.typeFilter - // search highlights if type:highlights - if (searchType === PageType.Highlights) { - ;[results, totalCount] = (await searchHighlights( + const { libraryItems, count } = await searchLibraryItems( { from: Number(startCursor), size: first + 1, // fetch one more item to get next cursor - sort: searchQuery.sortParams, - query: searchQuery.query, - }, - claims.uid - )) || [[], 0] - } else { - // otherwise, search pages - ;[results, totalCount] = (await searchLibraryItems( - { - from: Number(startCursor), - size: first + 1, // fetch one more item to get next cursor - sort: searchQuery.sortParams, + sort: searchQuery.sort, includePending: true, includeContent: params.includeContent ?? false, ...searchQuery, }, - claims.uid - )) || [[], 0] - } - - const start = - startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 - const hasNextPage = results.length > first - const endCursor = String(start + results.length - (hasNextPage ? 1 : 0)) - - if (hasNextPage) { - // remove an extra if exists - results.pop() - } - - const edges = results.map((r) => { - let siteIcon = r.siteIcon - if (siteIcon && !isBase64Image(siteIcon)) { - siteIcon = createImageProxyUrl(siteIcon, 128, 128) - } - if (params.includeContent && r.content) { - // convert html to the requested format - const format = params.format || ArticleFormat.Html - try { - const converter = contentConverter(format) - if (converter) { - r.content = converter(r.content, r.highlights) - } - } catch (error) { - log.error('Error converting content', error) - } - } - - return { - node: { - ...r, - image: r.image && createImageProxyUrl(r.image, 320, 320), - isArchived: !!r.archivedAt, - contentReader: contentReaderForPage(r.pageType, r.uploadFileId), - originalArticleUrl: r.url, - publishedAt: validatedDate(r.publishedAt), - ownedByViewer: r.userId === claims.uid, - siteIcon, - } as SearchItem, - cursor: endCursor, - } - }) - - return { - edges, - pageInfo: { - hasPreviousPage: false, - startCursor, - hasNextPage: hasNextPage, - endCursor, - totalCount, - }, - } -}) - -export const typeaheadSearchResolver = authorized< - TypeaheadSearchSuccess, - TypeaheadSearchError, - QueryTypeaheadSearchArgs ->(async (_obj, { query, first }, { claims }) => { - if (!claims?.uid) { - return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] } - } - const results = await searchAsYouType(claims.uid, query, first || undefined) - const items: TypeaheadSearchItem[] = results.map((r) => ({ - ...r, - contentReader: contentReaderForPage(r.pageType, r.uploadFileId), - })) - - return { items } -}) - -export const updatesSinceResolver = authorized< - UpdatesSinceSuccess, - UpdatesSinceError, - QueryUpdatesSinceArgs ->( - async ( - _obj, - { since, first, after, sort: sortParams }, - { claims: { uid } } - ) => { - if (!uid) { - return { errorCodes: [UpdatesSinceErrorCode.Unauthorized] } - } - - const sort = sortParamsToElasticSort(sortParams) - - const startCursor = after || '' - const size = first || 10 - const startDate = new Date(since) - const [pages, totalCount] = (await searchLibraryItems( - { - from: Number(startCursor), - size: size + 1, // fetch one more item to get next cursor - includeDeleted: true, - dateFilters: [{ field: 'updatedAt', startDate }], - sort, - }, uid - )) || [[], 0] + ) const start = startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 - const hasNextPage = pages.length > size - const endCursor = String(start + pages.length - (hasNextPage ? 1 : 0)) + const hasNextPage = libraryItems.length > first + const endCursor = String( + start + libraryItems.length - (hasNextPage ? 1 : 0) + ) - //TODO: refactor so that the lastCursor included if (hasNextPage) { // remove an extra if exists - pages.pop() + libraryItems.pop() } - const edges = pages.map((p) => { - const updateReason = getUpdateReason(p, startDate) + const edges = libraryItems.map((libraryItem) => { + if (libraryItem.siteIcon && !isBase64Image(libraryItem.siteIcon)) { + libraryItem.siteIcon = createImageProxyUrl( + libraryItem.siteIcon, + 128, + 128 + ) + } + if (params.includeContent && libraryItem.readableContent) { + // convert html to the requested format + const format = params.format || ArticleFormat.Html + try { + const converter = contentConverter(format) + if (converter) { + libraryItem.readableContent = converter( + libraryItem.readableContent, + libraryItem.highlights + ) + } + } catch (error) { + log.error('Error converting content', error) + } + } + return { - node: { - ...p, - image: p.image && createImageProxyUrl(p.image, 320, 320), - isArchived: !!p.archivedAt, - contentReader: contentReaderForPage(p.pageType, p.uploadFileId), - } as SearchItem, + node: libraryItemToSearchItem(libraryItem), cursor: endCursor, - itemID: p.id, - updateReason, } }) @@ -1011,26 +725,94 @@ export const updatesSinceResolver = authorized< pageInfo: { hasPreviousPage: false, startCursor, - hasNextPage, + hasNextPage: hasNextPage, endCursor, - totalCount, + totalCount: count, }, } } ) +export const typeaheadSearchResolver = authorized< + TypeaheadSearchSuccess, + TypeaheadSearchError, + QueryTypeaheadSearchArgs +>(async (_obj, { query, first }, { uid, log }) => { + try { + const items = await findLibraryItemsByPrefix(query, uid, first || undefined) + + return { + items: items.map((item) => ({ + ...item, + contentReader: item.contentReader as unknown as ContentReader, + })), + } + } catch (error) { + log.error('typeaheadSearchResolver error', error) + return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] } + } +}) + +export const updatesSinceResolver = authorized< + UpdatesSinceSuccess, + UpdatesSinceError, + QueryUpdatesSinceArgs +>(async (_obj, { since, first, after, sort: sortParams }, { uid }) => { + const sort = sortParamsToSort(sortParams) + + const startCursor = after || '' + const size = first || 10 + const startDate = new Date(since) + const { libraryItems, count } = await searchLibraryItems( + { + from: Number(startCursor), + size: size + 1, // fetch one more item to get next cursor + includeDeleted: true, + dateFilters: [{ field: 'updatedAt', startDate }], + sort, + }, + uid + ) + + const start = + startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 + const hasNextPage = libraryItems.length > size + const endCursor = String(start + libraryItems.length - (hasNextPage ? 1 : 0)) + + //TODO: refactor so that the lastCursor included + if (hasNextPage) { + // remove an extra if exists + libraryItems.pop() + } + + const edges = libraryItems.map((item) => { + const updateReason = getUpdateReason(item, startDate) + return { + node: libraryItemToSearchItem(item), + cursor: endCursor, + itemID: item.id, + updateReason, + } + }) + + return { + edges, + pageInfo: { + hasPreviousPage: false, + startCursor, + hasNextPage, + endCursor, + totalCount: count, + }, + } +}) + export const bulkActionResolver = authorized< BulkActionSuccess, BulkActionError, MutationBulkActionArgs ->( - async ( - _parent, - { query, action, labelIds, expectedCount, async }, - { claims: { uid }, log, pubsub } - ) => { - log.info('bulkActionResolver') - +>(async (_parent, { query, action, labelIds }, { uid, log }) => { + try { analytics.track({ userId: uid, event: 'BulkAction', @@ -1040,16 +822,6 @@ export const bulkActionResolver = authorized< }, }) - if (!uid) { - log.log('bulkActionResolver', { error: 'Unauthorized' }) - return { errorCodes: [BulkActionErrorCode.Unauthorized] } - } - - if (!query) { - log.log('bulkActionResolver', { error: 'no query' }) - return { errorCodes: [BulkActionErrorCode.BadRequest] } - } - // get labels if needed let labels = undefined if (action === BulkActionType.AddLabels) { @@ -1057,28 +829,20 @@ export const bulkActionResolver = authorized< return { errorCodes: [BulkActionErrorCode.BadRequest] } } - labels = await getLabelsByIds(uid, labelIds) + labels = await findLabelsByIds(labelIds, uid) } // parse query const searchQuery = parseSearchQuery(query) - // refresh index if not async - const ctx = { uid, pubsub, refresh: !async } + const updated = await updateLibraryItem(action, searchQuery, labels) - // start a task to update pages - const taskId = await updatePages( - ctx, - action, - searchQuery, - Math.min(expectedCount ?? 500, 500), // default and max to 500 - !!async, // default to false - labels - ) - - return { success: !!taskId } + return { success: updated } + } catch (error) { + log.error('bulkActionResolver error', error) + return { errorCodes: [BulkActionErrorCode.BadRequest] } } -) +}) export type SetFavoriteArticleSuccessPartial = Merge< SetFavoriteArticleSuccess, @@ -1088,13 +852,7 @@ export const setFavoriteArticleResolver = authorized< SetFavoriteArticleSuccessPartial, SetFavoriteArticleError, MutationSetFavoriteArticleArgs ->(async (_, { id }, { claims: { uid }, log, pubsub }) => { - log.info('setFavoriteArticleResolver', { id }) - - if (!uid) { - return { errorCodes: [SetFavoriteArticleErrorCode.Unauthorized] } - } - +>(async (_, { id }, { uid, log, pubsub }) => { try { analytics.track({ userId: uid, @@ -1105,11 +863,6 @@ export const setFavoriteArticleResolver = authorized< }, }) - const page = await getPageByParam({ userId: uid, _id: id }) - if (!page) { - return { errorCodes: [SetFavoriteArticleErrorCode.NotFound] } - } - const label = { id: '', name: 'Favorites', @@ -1117,20 +870,7 @@ export const setFavoriteArticleResolver = authorized< } // adds Favorites label to page - const result = await addLabelToPage( - { - uid, - pubsub, - refresh: true, - }, - page.id, - label - ) - if (!result) { - return { errorCodes: [SetFavoriteArticleErrorCode.AlreadyExists] } - } - - log.debug('Favorites label added:', result) + const updatedLibraryItem = await addLabelsToLibraryItem([label], id, uid) return { favoriteArticle: { @@ -1145,38 +885,12 @@ export const setFavoriteArticleResolver = authorized< } }) -const getUpdateReason = (page: Page, since: Date) => { - if (page.state === ArticleSavingRequestStatus.Deleted) { +const getUpdateReason = (libraryItem: LibraryItem, since: Date) => { + if (libraryItem.state === LibraryItemState.Deleted) { return UpdateReason.Deleted } - if (page.createdAt >= since) { + if (libraryItem.createdAt >= since) { return UpdateReason.Created } return UpdateReason.Updated } - -const sortParamsToElasticSort = ( - sortParams: InputMaybe | undefined -) => { - const sort = { by: SortBy.UPDATED, order: SortOrder.DESCENDING } - - if (sortParams) { - sortParams.order === 'ASCENDING' && (sort.order = SortOrder.ASCENDING) - switch (sortParams.by) { - case 'UPDATED_TIME': - sort.by = SortBy.UPDATED - break - case 'SCORE': - sort.by = SortBy.SCORE - break - case 'PUBLISHED_AT': - sort.by = SortBy.PUBLISHED - break - case 'SAVED_AT': - sort.by = SortBy.SAVED - break - } - } - - return sort -} diff --git a/packages/api/src/resolvers/article_saving_request/index.ts b/packages/api/src/resolvers/article_saving_request/index.ts index 5d54cf44a..8d85d9a07 100644 --- a/packages/api/src/resolvers/article_saving_request/index.ts +++ b/packages/api/src/resolvers/article_saving_request/index.ts @@ -1,11 +1,9 @@ /* eslint-disable prefer-const */ -import { getPageByParam } from '../../elastic/pages' -import { User } from '../../entity/user' +import { LibraryItem, LibraryItemState } from '../../entity/library_item' import { env } from '../../env' import { ArticleSavingRequestError, ArticleSavingRequestErrorCode, - ArticleSavingRequestStatus, ArticleSavingRequestSuccess, CreateArticleSavingRequestError, CreateArticleSavingRequestErrorCode, @@ -13,14 +11,18 @@ import { MutationCreateArticleSavingRequestArgs, QueryArticleSavingRequestArgs, } from '../../generated/graphql' -import { getRepository } from '../../repository' +import { userRepository } from '../../repository/user' import { createPageSaveRequest } from '../../services/create_page_save_request' +import { + findLibraryItemById, + findLibraryItemByUrl, +} from '../../services/library_item' import { analytics } from '../../utils/analytics' import { authorized, cleanUrl, isParsingTimeout, - pageToArticleSavingRequest, + libraryItemToArticleSavingRequest, } from '../../utils/helpers' import { isErrorWithCode } from '../user' @@ -63,35 +65,37 @@ export const articleSavingRequestResolver = authorized< ArticleSavingRequestSuccess, ArticleSavingRequestError, QueryArticleSavingRequestArgs ->(async (_, { id, url }, { claims }) => { - if (!id && !url) { - return { errorCodes: [ArticleSavingRequestErrorCode.BadData] } - } - const user = await getRepository(User).findOne({ - where: { id: claims.uid }, - relations: ['profile'], - }) - if (!user) { +>(async (_, { id, url }, { uid, log }) => { + try { + if (!id && !url) { + return { errorCodes: [ArticleSavingRequestErrorCode.BadData] } + } + const user = await userRepository.findById(uid) + if (!user) { + return { errorCodes: [ArticleSavingRequestErrorCode.Unauthorized] } + } + + let libraryItem: LibraryItem | null = null + if (id) { + libraryItem = await findLibraryItemById(id, uid) + } else if (url) { + libraryItem = await findLibraryItemByUrl(cleanUrl(url), uid) + } + + if (!libraryItem) { + return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] } + } + if (isParsingTimeout(libraryItem)) { + libraryItem.state = LibraryItemState.Succeeded + } + return { + articleSavingRequest: libraryItemToArticleSavingRequest( + user, + libraryItem + ), + } + } catch (error) { + log.error('articleSavingRequestResolver error', error) return { errorCodes: [ArticleSavingRequestErrorCode.Unauthorized] } } - - const normalizedUrl = url ? cleanUrl(url) : undefined - - const params = { - _id: id || undefined, - url: normalizedUrl, - userId: claims.uid, - state: [ - ArticleSavingRequestStatus.Succeeded, - ArticleSavingRequestStatus.Processing, - ], - } - const page = await getPageByParam(params) - if (!page) { - return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] } - } - if (isParsingTimeout(page)) { - page.state = ArticleSavingRequestStatus.Succeeded - } - return { articleSavingRequest: pageToArticleSavingRequest(user, page) } }) diff --git a/packages/api/src/resolvers/filters/index.ts b/packages/api/src/resolvers/filters/index.ts index 996d3d022..66889d4ea 100644 --- a/packages/api/src/resolvers/filters/index.ts +++ b/packages/api/src/resolvers/filters/index.ts @@ -33,7 +33,7 @@ export const saveFilterResolver = authorized< SaveFilterSuccess, SaveFilterError, MutationSaveFilterArgs ->(async (_, { input }, { claims: { uid }, log }) => { +>(async (_, { input }, { authTrx, uid, log }) => { log.info('Saving filters', { input, labels: { diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 5b2730766..45e7647ed 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -4,22 +4,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { getPageByParam } from '../elastic/pages' -import { getRepository } from '../repository' import { Subscription } from '../entity/subscription' -import { UploadFile } from '../entity/upload_file' -import { User } from '../entity/user' -import { - Article, - ArticleHighlightsInput, - Highlight, - HighlightType, - PageType, - SearchItem, -} from '../generated/graphql' -import { userDataToUser, validatedDate, wordsCount } from '../utils/helpers' +import { Article, PageType, SearchItem } from '../generated/graphql' +import { findUploadFileById } from '../services/upload_file' +import { validatedDate, wordsCount } from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' import { - contentReaderForPage, generateDownloadSignedUrl, generateUploadFilePathName, } from '../utils/uploads' @@ -51,7 +41,6 @@ import { generateApiKeyResolver, getAllUsersResolver, getArticleResolver, - getArticlesResolver, // getFollowersResolver, // getFollowingResolver, getMeUserResolver, @@ -219,7 +208,6 @@ export const functionResolvers = { validateUsername: validateUsernameResolver, article: getArticleResolver, // sharedArticle: getSharedArticleResolver, - articles: getArticlesResolver, // feedArticles: getUserFeedArticlesResolver, // getFollowers: getFollowersResolver, // getFollowing: getFollowingResolver, @@ -387,9 +375,10 @@ export const functionResolvers = { ctx.claims && article.uploadFileId ) { - const upload = await getRepository(UploadFile).findOneBy({ - id: article.uploadFileId, - }) + const upload = await findUploadFileById( + article.uploadFileId, + ctx.claims.uid + ) if (!upload || !upload.fileName) { return undefined } @@ -431,24 +420,6 @@ export const functionResolvers = { }) return !!page?.sharedAt }, - async savedAt( - article: { id: string; savedAt?: Date; createdAt?: Date }, - __: unknown, - ctx: WithDataSourcesContext & { claims: Claims } - ) { - if (!ctx.claims?.uid) return new Date() - if (article.savedAt) return article.savedAt - return ( - ( - await getPageByParam({ - userId: ctx.claims.uid, - _id: article.id, - }) - )?.savedAt || - article.createdAt || - new Date() - ) - }, hasContent(article: { content: string | null originalHtml: string | null @@ -458,37 +429,6 @@ export const functionResolvers = { publishedAt(article: { publishedAt: Date }) { return validatedDate(article.publishedAt) }, - async isArchived( - article: { - id: string - isArchived?: boolean | null - archivedAt?: Date | undefined - }, - __: unknown, - ctx: WithDataSourcesContext & { claims: Claims } - ) { - if ('isArchived' in article) return article.isArchived - if ('archivedAt' in article) return !!article.archivedAt - if (!ctx.claims?.uid) return false - const page = await getPageByParam({ - userId: ctx.claims.uid, - _id: article.id, - }) - return !!page?.archivedAt || false - }, - contentReader(article: { - pageType: PageType - uploadFileId: string | undefined - }) { - return contentReaderForPage(article.pageType, article.uploadFileId) - }, - highlights( - article: { id: string; userId?: string; highlights?: Highlight[] }, - _: { input: ArticleHighlightsInput }, - ctx: WithDataSourcesContext - ) { - return article.highlights || [] - }, // async shareInfo( // article: { id: string; sharedBy?: User; shareInfo?: LinkShareInfo }, // __: unknown, @@ -511,29 +451,7 @@ export const functionResolvers = { return article.content ? wordsCount(article.content) : undefined }, }, - ArticleSavingRequest: { - async article(request: { userId: string; articleId: string }, __: unknown) { - if (!request.userId || !request.articleId) return undefined - - return getPageByParam({ - userId: request.userId, - _id: request.articleId, - }) - }, - }, Highlight: { - async user( - highlight: { userId: string }, - __: unknown, - ctx: WithDataSourcesContext - ) { - const userData = await getRepository(User).findOneBy({ - id: highlight.userId, - }) - if (!userData) return null - - return userDataToUser(userData) - }, // async reactions( // highlight: { id: string; reactions?: Reaction[] }, // _: unknown, @@ -549,10 +467,7 @@ export const functionResolvers = { __: unknown, ctx: WithDataSourcesContext ) { - return highlight.createdByMe ?? highlight.userId === ctx.claims?.uid - }, - type(highlight: { type: HighlightType }) { - return highlight.type || HighlightType.Highlight + return highlight.createdByMe ?? highlight.userId === ctx.uid }, }, // Reaction: { @@ -568,12 +483,10 @@ export const functionResolvers = { async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) { if ( (item.pageType == PageType.File || item.pageType == PageType.Book) && - ctx.claims && + ctx.uid && item.uploadFileId ) { - const upload = await getRepository(UploadFile).findOneBy({ - id: item.uploadFileId, - }) + const upload = await findUploadFileById(item.uploadFileId, ctx.uid) if (!upload || !upload.fileName) { return undefined } @@ -582,8 +495,8 @@ export const functionResolvers = { } return item.url }, - pageType(item: SearchItem) { - return item.pageType || PageType.Unknown + image(item: SearchItem) { + return item.image && createImageProxyUrl(item.image, 320, 320) }, }, Subscription: { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index bb873bde2..e25df9fec 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2701,14 +2701,6 @@ const schema = gql` hello: String me: User user(userId: ID, username: String): UserResult! - articles( - sharedOnly: Boolean - sort: SortParams - after: String - first: Int - query: String - includePending: Boolean - ): ArticlesResult! article(username: String!, slug: String!, format: String): ArticleResult! # sharedArticle( # username: String! diff --git a/packages/api/src/services/archive_link.ts b/packages/api/src/services/archive_link.ts deleted file mode 100644 index c249dcc87..000000000 --- a/packages/api/src/services/archive_link.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { appDataSource } from '../data_source' -import { Link } from '../entity/link' -import { setClaims } from '../repository' - -export const setLinkArchived = async ( - userId: string, - linkId: string, - archived: boolean -): Promise => { - await appDataSource.transaction(async (t) => { - await setClaims(t, userId) - await t.getRepository(Link).update( - { - id: linkId, - }, - { archivedAt: archived ? new Date() : null } - ) - }) -} diff --git a/packages/api/src/services/create_page_save_request.ts b/packages/api/src/services/create_page_save_request.ts index 4ac8337cf..6dbdf1417 100644 --- a/packages/api/src/services/create_page_save_request.ts +++ b/packages/api/src/services/create_page_save_request.ts @@ -1,34 +1,36 @@ import * as privateIpLib from 'private-ip' import { v4 as uuidv4 } from 'uuid' -import { countByCreatedAt, createPage, updatePage } from '../elastic/pages' +import { countByCreatedAt } from '../elastic/pages' import { ArticleSavingRequestStatus, PageType } from '../elastic/types' -import { LibraryItemState } from '../entity/library_item' -import { User } from '../entity/user' +import { LibraryItemState, LibraryItemType } from '../entity/library_item' import { ArticleSavingRequest, CreateArticleSavingRequestErrorCode, - CreateLabelInput + CreateLabelInput, } from '../generated/graphql' import { createPubSubClient, PubsubClient } from '../pubsub' -import { libraryItemRepository } from '../repository/library_item' import { userRepository } from '../repository/user' import { enqueueParseRequest } from '../utils/createTask' import { cleanUrl, generateSlug, - pageToArticleSavingRequest + libraryItemToArticleSavingRequest, } from '../utils/helpers' import { logger } from '../utils/logger' +import { + createLibraryItem, + findLibraryItemByUrl, + updateLibraryItem, +} from './library_item' interface PageSaveRequest { userId: string url: string pubsub?: PubsubClient articleSavingRequestId?: string - archivedAt?: Date | null + state?: ArticleSavingRequestStatus labels?: CreateLabelInput[] priority?: 'low' | 'high' - user?: User | null locale?: string timezone?: string savedAt?: Date @@ -80,10 +82,9 @@ export const createPageSaveRequest = async ({ url, pubsub = createPubSubClient(), articleSavingRequestId = uuidv4(), - archivedAt, + state, priority, labels, - user, locale, timezone, savedAt, @@ -92,62 +93,52 @@ export const createPageSaveRequest = async ({ try { validateUrl(url) } catch (error) { - logger.error('invalid url', { url, error }) + logger.info('invalid url', { url, error }) return Promise.reject({ errorCode: CreateArticleSavingRequestErrorCode.BadData, }) } // if user is not specified, get it from the database + const user = await userRepository.findById(userId) if (!user) { - user = await userRepository.findById(userId) - if (!user) { - logger.info('User not found', userId) - return Promise.reject({ - errorCode: CreateArticleSavingRequestErrorCode.BadData, - }) - } + logger.info('User not found', userId) + return Promise.reject({ + errorCode: CreateArticleSavingRequestErrorCode.BadData, + }) } url = cleanUrl(url) // look for existing library item - const existingLibraryItem = await libraryItemRepository.findByUrl(url) - if (!existingLibraryItem) { + let libraryItem = await findLibraryItemByUrl(url, userId) + if (!libraryItem) { logger.info('libraryItem does not exist', { url }) - libraryItem = { - id: articleSavingRequestId, - user: { id: userId }, - content: SAVING_CONTENT, - hash: '', - pageType: PageType.Unknown, - readingProgressAnchorIndex: 0, - readingProgressPercent: 0, - slug: generateSlug(url), - title: url, - url, - state: LibraryItemState.Processing, - createdAt: new Date(), - savedAt: savedAt || new Date(), - publishedAt, - archivedAt, - } // create processing page - const pageId = await createPage(page, ctx) - if (!pageId) { - logger.info('Failed to create page', url) - return Promise.reject({ - errorCode: CreateArticleSavingRequestErrorCode.BadData, - }) - } + libraryItem = await createLibraryItem( + { + id: articleSavingRequestId, + user: { id: userId }, + readableContent: SAVING_CONTENT, + itemType: LibraryItemType.Unknown, + slug: generateSlug(url), + title: url, + originalUrl: url, + state: LibraryItemState.Processing, + publishedAt, + }, + userId, + pubsub + ) } // reset state to processing - if (existingLibraryItem.state !== ArticleSavingRequestStatus.Processing) { - await updatePage( - page.id, + if (libraryItem.state !== LibraryItemState.Processing) { + libraryItem = await updateLibraryItem( + libraryItem.id, { - state: ArticleSavingRequestStatus.Processing, + state: LibraryItemState.Processing, }, - ctx + userId, + pubsub ) } @@ -160,7 +151,7 @@ export const createPageSaveRequest = async ({ userId, saveRequestId: articleSavingRequestId, priority, - state: archivedAt ? ArticleSavingRequestStatus.Archived : undefined, + state, labels, locale, timezone, @@ -168,5 +159,5 @@ export const createPageSaveRequest = async ({ publishedAt, }) - return pageToArticleSavingRequest(user, page) + return libraryItemToArticleSavingRequest(user, libraryItem) } diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 7c2427d5b..159757326 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -6,7 +6,7 @@ import { Profile } from '../entity/profile' import { StatusType, User } from '../entity/user' import { SignupErrorCode } from '../generated/graphql' import { getRepository } from '../repository' -import { getUserByEmail } from '../repository/user' +import { userRepository } from '../repository/user' import { AuthProvider } from '../routers/auth/auth_types' import { logger } from '../utils/logger' import { validateUsername } from '../utils/usernamePolicy' @@ -41,7 +41,7 @@ export const createUser = async (input: { pendingConfirmation?: boolean }): Promise<[User, Profile]> => { const trimmedEmail = input.email.trim() - const existingUser = await getUserByEmail(trimmedEmail) + const existingUser = await userRepository.findByEmail(trimmedEmail) if (existingUser) { if (existingUser.profile) { return Promise.reject({ errorCode: SignupErrorCode.UserExists }) diff --git a/packages/api/src/services/labels.ts b/packages/api/src/services/labels.ts index 2b0487fc3..449cc2aa5 100644 --- a/packages/api/src/services/labels.ts +++ b/packages/api/src/services/labels.ts @@ -1,35 +1,35 @@ -import DataLoader from 'dataloader' import { In } from 'typeorm' -import { Highlight } from '../entity/highlight' import { Label } from '../entity/label' import { LibraryItem } from '../entity/library_item' -import { Link } from '../entity/link' -import { EntityType, PubsubClient } from '../pubsub' -import { authTrx, getRepository } from '../repository' +import { createPubSubClient, EntityType } from '../pubsub' +import { entityManager, setClaims } from '../repository' import { highlightRepository } from '../repository/highlight' import { CreateLabelInput, labelRepository } from '../repository/label' import { libraryItemRepository } from '../repository/library_item' -const batchGetLabelsFromLinkIds = async ( - linkIds: readonly string[] -): Promise => { - const links = await getRepository(Link).find({ - where: { id: In(linkIds as string[]) }, - relations: ['labels'], - }) +// const batchGetLabelsFromLinkIds = async ( +// linkIds: readonly string[] +// ): Promise => { +// const links = await getRepository(Link).find({ +// where: { id: In(linkIds as string[]) }, +// relations: ['labels'], +// }) - return linkIds.map( - (linkId) => links.find((link) => link.id === linkId)?.labels || [] - ) -} +// return linkIds.map( +// (linkId) => links.find((link) => link.id === linkId)?.labels || [] +// ) +// } -export const labelsLoader = new DataLoader(batchGetLabelsFromLinkIds) +// export const labelsLoader = new DataLoader(batchGetLabelsFromLinkIds) export const getLabelsAndCreateIfNotExist = async ( labels: CreateLabelInput[], - userId: string + userId: string, + em = entityManager ): Promise => { - return authTrx(async (tx) => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + const labelRepo = tx.withRepository(labelRepository) // find existing labels const labelEntities = await labelRepo.findByNames(labels.map((l) => l.name)) @@ -55,15 +55,14 @@ export const saveLabelsInLibraryItem = async ( labels: Label[], libraryItemId: string, userId: string, - pubsub: PubsubClient + pubsub = createPubSubClient(), + em = entityManager ) => { - await authTrx(async (tx) => { + await em.transaction(async (tx) => { + await setClaims(tx, userId) await tx .withRepository(libraryItemRepository) - .createQueryBuilder() - .relation(LibraryItem, 'labels') - .of(libraryItemId) - .set(labels) + .update(libraryItemId, { labels }) }) // create pubsub event @@ -78,9 +77,11 @@ export const addLabelsToLibraryItem = async ( labels: Label[], libraryItemId: string, userId: string, - pubsub: PubsubClient + pubsub = createPubSubClient(), + em = entityManager ) => { - await authTrx(async (tx) => { + await em.transaction(async (tx) => { + await setClaims(tx, userId) await tx .withRepository(libraryItemRepository) .createQueryBuilder() @@ -101,15 +102,13 @@ export const saveLabelsInHighlight = async ( labels: Label[], highlightId: string, userId: string, - pubsub: PubsubClient + pubsub = createPubSubClient(), + em = entityManager ) => { - await authTrx(async (tx) => { - await tx - .withRepository(highlightRepository) - .createQueryBuilder() - .relation(Highlight, 'labels') - .of(highlightId) - .set(labels) + await em.transaction(async (tx) => { + await setClaims(tx, userId) + + await tx.withRepository(highlightRepository).update(highlightId, { labels }) }) // create pubsub event @@ -119,3 +118,17 @@ export const saveLabelsInHighlight = async ( userId ) } + +export const findLabelsByIds = async ( + ids: string[], + userId: string, + em = entityManager +): Promise => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx.withRepository(labelRepository).findBy({ + id: In(ids), + }) + }) +} diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 051f5a338..385093a11 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -7,9 +7,8 @@ import { LibraryItemType, } from '../entity/library_item' import { createPubSubClient, EntityType } from '../pubsub' -import { entityManager } from '../repository' +import { entityManager, setClaims } from '../repository' import { libraryItemRepository } from '../repository/library_item' -import { logger } from '../utils/logger' import { DateFilter, FieldFilter, @@ -21,16 +20,13 @@ import { ReadFilter, SortBy, SortOrder, - SortParams, + Sort, } from '../utils/search' -const MAX_CONTENT_LENGTH = 10 * 1024 * 1024 // 10MB for readable content -const CONTENT_LENGTH_ERROR = 'Your page content is too large to be saved.' - -export interface PageSearchArgs { +export interface SearchArgs { from?: number size?: number - sort?: SortParams + sort?: Sort query?: string inFilter?: InFilter readFilter?: ReadFilter @@ -49,7 +45,7 @@ export interface PageSearchArgs { siteName?: string } -export interface SearchItem { +export interface SearchResultItem { annotation?: string | null author?: string | null createdAt: Date @@ -83,36 +79,9 @@ export interface SearchItem { content?: string } -export const createLibraryItem = async ( - libraryItem: DeepPartial, - pubsub = createPubSubClient() -): Promise => { - if ( - libraryItem.readableContent && - libraryItem.readableContent.length > MAX_CONTENT_LENGTH - ) { - logger.warn('page content is too large', { - url: libraryItem.originalUrl, - contentLength: libraryItem.readableContent.length, - }) - - libraryItem.readableContent = CONTENT_LENGTH_ERROR - } - - const newItem = await libraryItemRepository.save(libraryItem) - - await pubsub.entityCreated( - EntityType.PAGE, - newItem, - newItem.user.id - ) - - return newItem -} - const buildWhereClause = ( queryBuilder: SelectQueryBuilder, - args: PageSearchArgs + args: SearchArgs ) => { if (args.query) { queryBuilder @@ -255,9 +224,10 @@ const buildWhereClause = ( } export const searchLibraryItems = async ( - args: PageSearchArgs, - userId: string -): Promise<[LibraryItem[], number] | null> => { + args: SearchArgs, + userId: string, + em = entityManager +): Promise<{ libraryItems: LibraryItem[]; count: number }> => { const { from = 0, size = 10, sort } = args // default order is descending @@ -265,23 +235,124 @@ export const searchLibraryItems = async ( // default sort by saved_at const sortField = sort?.by || SortBy.SAVED - const queryBuilder = entityManager - .createQueryBuilder(LibraryItem, 'library_item') - .leftJoinAndSelect('library_item.labels', 'labels') - .leftJoinAndSelect('library_item.highlights', 'highlights') - .where('library_item.user_id = :userId', { userId }) - - // build the where clause - buildWhereClause(queryBuilder, args) - // add pagination and sorting - const libraryItems = await queryBuilder - .orderBy(`library_item.${sortField}`, sortOrder) - .offset(from) - .limit(size) - .getMany() + return em.transaction(async (tx) => { + await setClaims(tx, userId) - const count = await queryBuilder.getCount() + const queryBuilder = tx + .createQueryBuilder(LibraryItem, 'library_item') + .leftJoinAndSelect('library_item.labels', 'labels') + .leftJoinAndSelect('library_item.highlights', 'highlights') + .where('library_item.user_id = :userId', { userId }) - return [libraryItems, count] + // build the where clause + buildWhereClause(queryBuilder, args) + + const libraryItems = await queryBuilder + .orderBy(`library_item.${sortField}`, sortOrder) + .offset(from) + .limit(size) + .getMany() + + const count = await queryBuilder.getCount() + + return { libraryItems, count } + }) +} + +export const findLibraryItemById = async ( + id: string, + userId: string, + em = entityManager +): Promise => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx + .createQueryBuilder(LibraryItem, 'library_item') + .leftJoinAndSelect('library_item.labels', 'labels') + .leftJoinAndSelect('library_item.highlights', 'highlights') + .where('library_item.user_id = :userId', { userId }) + .andWhere('library_item.id = :id', { id }) + .getOne() + }) +} + +export const findLibraryItemByUrl = async ( + url: string, + userId: string, + em = entityManager +): Promise => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx + .createQueryBuilder(LibraryItem, 'library_item') + .leftJoinAndSelect('library_item.labels', 'labels') + .leftJoinAndSelect('library_item.highlights', 'highlights') + .where('library_item.user_id = :userId', { userId }) + .andWhere('library_item.url = :url', { url }) + .getOne() + }) +} + +export const updateLibraryItem = async ( + id: string, + libraryItem: DeepPartial, + userId: string, + pubsub = createPubSubClient(), + em = entityManager +): Promise => { + const updatedLibraryItem = await em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx.withRepository(libraryItemRepository).save({ id, ...libraryItem }) + }) + + await pubsub.entityUpdated>( + EntityType.PAGE, + libraryItem, + userId + ) + + return updatedLibraryItem +} + +export const createLibraryItem = async ( + libraryItem: DeepPartial, + userId: string, + pubsub = createPubSubClient(), + em = entityManager +): Promise => { + const newLibraryItem = await em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx.withRepository(libraryItemRepository).save(libraryItem) + }) + + await pubsub.entityCreated( + EntityType.PAGE, + newLibraryItem, + userId + ) + + return newLibraryItem +} + +export const findLibraryItemsByPrefix = async ( + prefix: string, + userId: string, + limit = 5, + em = entityManager +): Promise => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + + return tx + .createQueryBuilder(LibraryItem, 'library_item') + .where('library_item.title ILIKE :prefix', { prefix: `${prefix}%` }) + .orWhere('library_item.site_name ILIKE :prefix', { prefix: `${prefix}%` }) + .limit(limit) + .getMany() + }) } diff --git a/packages/api/src/services/save_file.ts b/packages/api/src/services/save_file.ts index 8ebb9ce42..ab93b3ff8 100644 --- a/packages/api/src/services/save_file.ts +++ b/packages/api/src/services/save_file.ts @@ -1,4 +1,3 @@ -import { updatePage } from '../elastic/pages' import { UploadFile } from '../entity/upload_file' import { User } from '../entity/user' import { homePageURL } from '../env' @@ -14,13 +13,6 @@ import { logger } from '../utils/logger' import { getStorageFileDetails } from '../utils/uploads' import { getLabelsAndCreateIfNotExist } from './labels' -export const setFileUploadComplete = async ( - id: string, - em = entityManager -): Promise => { - return em.getRepository(UploadFile).save({ id, status: 'COMPLETED' }) -} - export const saveFile = async ( ctx: WithDataSourcesContext, user: User, @@ -55,7 +47,7 @@ export const saveFile = async ( input.state === ArticleSavingRequestStatus.Archived ? new Date() : null // add labels to page const labels = input.labels - ? await getLabelsAndCreateIfNotExist(ctx, input.labels) + ? await getLabelsAndCreateIfNotExist(input.labels, user.id) : undefined if (input.state || input.labels) { const updated = await updatePage( diff --git a/packages/api/src/services/save_page.ts b/packages/api/src/services/save_page.ts index 2e9ca5fdb..16918855a 100644 --- a/packages/api/src/services/save_page.ts +++ b/packages/api/src/services/save_page.ts @@ -109,7 +109,7 @@ export const savePage = async ( try { await createPageSaveRequest({ userId: user.id, - url: itemToSave.originalUrl!, + url: itemToSave.originalUrl, pubsub: ctx.pubsub, articleSavingRequestId: input.clientRequestId, archivedAt, @@ -230,7 +230,7 @@ export const parsedContentToLibraryItem = ({ saveTime?: Date rssFeedUrl?: string | null publishedAt?: Date | null -}): DeepPartial => { +}): DeepPartial & { originalUrl: string } => { return { id: pageId ?? undefined, slug, diff --git a/packages/api/src/services/upload_file.ts b/packages/api/src/services/upload_file.ts new file mode 100644 index 000000000..3d6d72f26 --- /dev/null +++ b/packages/api/src/services/upload_file.ts @@ -0,0 +1,30 @@ +import { entityManager, setClaims } from '../repository' +import { uploadFileRepository } from '../repository/upload_file' + +export const findUploadFileById = async ( + id: string, + userId: string, + em = entityManager +) => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + const uploadFile = await tx + .withRepository(uploadFileRepository) + .findById(id) + + return uploadFile + }) +} + +export const setFileUploadComplete = async ( + id: string, + userId: string, + em = entityManager +) => { + return em.transaction(async (tx) => { + await setClaims(tx, userId) + return tx + .withRepository(uploadFileRepository) + .save({ id, status: 'COMPLETED' }) + }) +} diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index d4ef7aee6..ef44a6863 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -4,9 +4,9 @@ import express from 'express' import * as jwt from 'jsonwebtoken' import { promisify } from 'util' import { v4 as uuidv4 } from 'uuid' -import { getRepository } from '../repository' -import { ApiKey } from '../entity/api_key' import { env } from '../env' +import { authTrx } from '../repository' +import { apiKeyRepository } from '../repository/api_key' import { Claims, ClaimsToSet } from '../resolvers/types' import { logger } from './logger' @@ -33,30 +33,34 @@ export const hashApiKey = (apiKey: string) => { export const claimsFromApiKey = async (key: string): Promise => { const hashedKey = hashApiKey(key) - const apiKey = await getRepository(ApiKey).findOne({ - where: { - key: hashedKey, - }, - relations: ['user'], + return authTrx(async (tx) => { + const apiKeyRepo = tx.withRepository(apiKeyRepository) + + const apiKey = await apiKeyRepo.findOne({ + where: { + key: hashedKey, + }, + relations: ['user'], + }) + if (!apiKey) { + throw new Error('api key not found') + } + + const iat = Math.floor(Date.now() / 1000) + const exp = Math.floor(new Date(apiKey.expiresAt).getTime() / 1000) + if (exp < iat) { + throw new Error('api key expired') + } + + // update last used + await apiKeyRepo.update(apiKey.id, { usedAt: new Date() }) + + return { + uid: apiKey.user.id, + iat, + exp, + } }) - if (!apiKey) { - throw new Error('api key not found') - } - - const iat = Math.floor(Date.now() / 1000) - const exp = Math.floor(new Date(apiKey.expiresAt).getTime() / 1000) - if (exp < iat) { - throw new Error('api key expired') - } - - // update last used - await getRepository(ApiKey).update(apiKey.id, { usedAt: new Date() }) - - return { - uid: apiKey.user.id, - iat, - exp, - } } // verify jwt token first diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 2304b6506..4b90d217e 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -5,7 +5,7 @@ import { CloudTasksClient, protos } from '@google-cloud/tasks' import { google } from '@google-cloud/tasks/build/protos/protos' import axios from 'axios' import { nanoid } from 'nanoid' -import { Recommendation } from '../elastic/types' +import { Recommendation } from '../entity/recommendation' import { Subscription } from '../entity/subscription' import { env } from '../env' import { diff --git a/packages/api/src/utils/helpers.ts b/packages/api/src/utils/helpers.ts index 46174a888..78215e8aa 100644 --- a/packages/api/src/utils/helpers.ts +++ b/packages/api/src/utils/helpers.ts @@ -5,20 +5,29 @@ import path from 'path' import _ from 'underscore' import slugify from 'voca/slugify' import wordsCounter from 'word-counting' -import { updatePage } from '../elastic/pages' -import { ArticleSavingRequestStatus, Page } from '../elastic/types' -import { LibraryItem } from '../entity/library_item' +import { Highlight as HighlightData } from '../entity/highlight' +import { LibraryItem, LibraryItemState } from '../entity/library_item' +import { Recommendation as RecommendationData } from '../entity/recommendation' import { RegistrationType, User } from '../entity/user' import { ArticleSavingRequest, + ArticleSavingRequestStatus, + ContentReader, CreateArticleError, FeedArticle, + Highlight, + HighlightType, + PageType, Profile, + Recommendation, ResolverFn, + SearchItem, } from '../generated/graphql' -import { CreateArticlesSuccessPartial } from '../resolvers' +import { createPubSubClient } from '../pubsub' +import { CreateArticlesSuccessPartial, PartialArticle } from '../resolvers' import { Claims, WithDataSourcesContext } from '../resolvers/types' import { validateUrl } from '../services/create_page_save_request' +import { updateLibraryItem } from '../services/library_item' import { Merge } from '../util' import { logger } from './logger' interface InputObject { @@ -170,23 +179,42 @@ export const MAX_CONTENT_LENGTH = 5e7 //50MB export const pageError = async ( result: CreateArticleError, - ctx: WithDataSourcesContext, - pageId?: string | null + userId: string, + pageId?: string | null, + pubsub = createPubSubClient() ): Promise => { if (!pageId) return result - await updatePage( + await updateLibraryItem( pageId, { - state: ArticleSavingRequestStatus.Failed, + state: LibraryItemState.Failed, }, - ctx + userId, + pubsub ) return result } -export const pageToArticleSavingRequest = ( +const highlightDataToHighlight = (highlight: HighlightData): Highlight => ({ + ...highlight, + createdByMe: true, + reactions: [], + replies: [], + type: highlight.highlightType as unknown as HighlightType, + user: userDataToUser(highlight.user), +}) + +const recommandationDataToRecommendation = ( + recommendation: RecommendationData +): Recommendation => ({ + ...recommendation, + name: recommendation.recommender.name, + recommendedAt: recommendation.createdAt, +}) + +export const libraryItemToArticleSavingRequest = ( user: User, item: LibraryItem ): ArticleSavingRequest => ({ @@ -197,11 +225,45 @@ export const pageToArticleSavingRequest = ( userId: user.id, }) -export const isParsingTimeout = (page: Page): boolean => { +export const libraryItemToPartialArticle = ( + item: LibraryItem +): PartialArticle => ({ + ...item, + url: item.originalUrl, + state: item.state as unknown as ArticleSavingRequestStatus, + content: item.readableContent, + hash: item.textContentHash || '', + isArchived: item.state === LibraryItemState.Archived, + recommendations: item.recommendations?.map( + recommandationDataToRecommendation + ), + subscription: item.subscription?.name, + image: item.thumbnail, +}) + +export const libraryItemToSearchItem = (item: LibraryItem): SearchItem => ({ + ...item, + url: item.originalUrl, + state: item.state as unknown as ArticleSavingRequestStatus, + content: item.readableContent, + isArchived: item.state === LibraryItemState.Archived, + pageType: item.itemType as unknown as PageType, + readingProgressPercent: item.readingProgressTopPercent, + contentReader: item.contentReader as unknown as ContentReader, + readingProgressAnchorIndex: item.readingProgressHighestReadAnchor, + subscription: item.subscription?.name, + recommendations: item.recommendations?.map( + recommandationDataToRecommendation + ), + image: item.thumbnail, + highlights: item.highlights?.map(highlightDataToHighlight), +}) + +export const isParsingTimeout = (libraryItem: LibraryItem): boolean => { return ( // page processed more than 30 seconds ago - page.state === ArticleSavingRequestStatus.Processing && - new Date(page.savedAt).getTime() < new Date().getTime() - 1000 * 30 + libraryItem.state === LibraryItemState.Processing && + libraryItem.savedAt.getTime() < new Date().getTime() - 1000 * 30 ) } diff --git a/packages/api/src/utils/parser.ts b/packages/api/src/utils/parser.ts index 0dad18031..51d9e5f3e 100644 --- a/packages/api/src/utils/parser.ts +++ b/packages/api/src/utils/parser.ts @@ -15,11 +15,10 @@ import { ElementNode } from 'node-html-markdown/dist/nodes' import { ILike } from 'typeorm' import { promisify } from 'util' import { v4 as uuid } from 'uuid' -import { Highlight } from '../elastic/types' -import { getRepository } from '../repository' -import { User } from '../entity/user' +import { Highlight } from '../entity/highlight' import { env } from '../env' import { PageType, PreparedDocumentInput } from '../generated/graphql' +import { userRepository } from '../repository/user' import { ArticleFormat } from '../resolvers/article' import { EmbeddedHighlightData, @@ -469,7 +468,7 @@ export const isProbablyArticle = async ( email: string, subject: string ): Promise => { - const user = await getRepository(User).findOneBy({ + const user = await userRepository.findOneBy({ email: ILike(email), }) return !!user || subject.includes(ARTICLE_PREFIX) @@ -655,7 +654,7 @@ export const htmlToHighlightedMarkdown = ( // wrap highlights in special tags highlights - .filter((h) => h.type == 'HIGHLIGHT' && h.patch) + .filter((h) => h.highlightType == 'HIGHLIGHT' && h.patch) .forEach((highlight) => { try { makeHighlightNodeAttributes( diff --git a/packages/api/src/utils/search.ts b/packages/api/src/utils/search.ts index 741ecf4bb..68a109b67 100644 --- a/packages/api/src/utils/search.ts +++ b/packages/api/src/utils/search.ts @@ -10,6 +10,7 @@ import { SearchParserTextOffset, } from 'search-query-parser' import { LibraryItemType } from '../entity/library_item' +import { InputMaybe, SortParams } from '../generated/graphql' export enum ReadFilter { ALL, @@ -32,7 +33,7 @@ export interface SearchFilter { readFilter: ReadFilter typeFilter?: LibraryItemType labelFilters: LabelFilter[] - sortParams?: SortParams + sort?: Sort hasFilters: HasFilter[] dateFilters: DateFilter[] termFilters: FieldFilter[] @@ -67,7 +68,6 @@ export interface DateFilter { export enum SortBy { SAVED = 'saved_at', UPDATED = 'updated_at', - SCORE = '_score', PUBLISHED = 'published_at', READ = 'read_at', LISTENED = 'listened_at', @@ -79,7 +79,7 @@ export enum SortOrder { DESCENDING = 'DESC', } -export interface SortParams { +export interface Sort { by: SortBy order?: SortOrder } @@ -179,7 +179,7 @@ const parseLabelFilter = ( } } -const parseSortParams = (str?: string): SortParams | undefined => { +const parseSort = (str?: string): Sort | undefined => { if (str === undefined) { return undefined } @@ -199,11 +199,6 @@ const parseSortParams = (str?: string): SortParams | undefined => { by: SortBy.SAVED, order: sortOrder, } - case 'SCORE': - // sort by score does not need an order - return { - by: SortBy.SCORE, - } case 'PUBLISHED': return { by: SortBy.PUBLISHED, @@ -417,7 +412,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { break } case 'sort': - result.sortParams = parseSortParams(keyword.value) + result.sort = parseSort(keyword.value) break case 'has': { const hasFilter = parseHasFilter(keyword.value) @@ -476,3 +471,26 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { return result } + +export const sortParamsToSort = ( + sortParams: InputMaybe | undefined +) => { + const sort = { by: SortBy.UPDATED, order: SortOrder.DESCENDING } + + if (sortParams) { + sortParams.order === 'ASCENDING' && (sort.order = SortOrder.ASCENDING) + switch (sortParams.by) { + case 'UPDATED_TIME': + sort.by = SortBy.UPDATED + break + case 'PUBLISHED_AT': + sort.by = SortBy.PUBLISHED + break + case 'SAVED_AT': + sort.by = SortBy.SAVED + break + } + } + + return sort +}