diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 0352f71f0..a4392d52e 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -279,6 +279,7 @@ export enum BulkActionType { Archive = 'ARCHIVE', Delete = 'DELETE', MarkAsRead = 'MARK_AS_READ', + MarkAsSeen = 'MARK_AS_SEEN', MoveToFolder = 'MOVE_TO_FOLDER' } @@ -1713,6 +1714,7 @@ export type Mutation = { optInFeature: OptInFeatureResult; recommend: RecommendResult; recommendHighlights: RecommendHighlightsResult; + refreshHome: RefreshHomeResult; replyToEmail: ReplyToEmailResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; @@ -2524,6 +2526,22 @@ export type RecommendingUser = { username: Scalars['String']; }; +export type RefreshHomeError = { + __typename?: 'RefreshHomeError'; + errorCodes: Array; +}; + +export enum RefreshHomeErrorCode { + Pending = 'PENDING' +} + +export type RefreshHomeResult = RefreshHomeError | RefreshHomeSuccess; + +export type RefreshHomeSuccess = { + __typename?: 'RefreshHomeSuccess'; + success: Scalars['Boolean']; +}; + export type Reminder = { __typename?: 'Reminder'; archiveUntil: Scalars['Boolean']; @@ -2874,6 +2892,7 @@ export type SearchItem = { recommendations?: Maybe>; savedAt: Scalars['Date']; score?: Maybe; + seenAt?: Maybe; shortId?: Maybe; siteIcon?: Maybe; siteName?: Maybe; @@ -4410,6 +4429,10 @@ export type ResolversTypes = { Recommendation: ResolverTypeWrapper; RecommendationGroup: ResolverTypeWrapper; RecommendingUser: ResolverTypeWrapper; + RefreshHomeError: ResolverTypeWrapper; + RefreshHomeErrorCode: RefreshHomeErrorCode; + RefreshHomeResult: ResolversTypes['RefreshHomeError'] | ResolversTypes['RefreshHomeSuccess']; + RefreshHomeSuccess: ResolverTypeWrapper; Reminder: ResolverTypeWrapper; ReminderError: ResolverTypeWrapper; ReminderErrorCode: ReminderErrorCode; @@ -4946,6 +4969,9 @@ export type ResolversParentTypes = { Recommendation: Recommendation; RecommendationGroup: RecommendationGroup; RecommendingUser: RecommendingUser; + RefreshHomeError: RefreshHomeError; + RefreshHomeResult: ResolversParentTypes['RefreshHomeError'] | ResolversParentTypes['RefreshHomeSuccess']; + RefreshHomeSuccess: RefreshHomeSuccess; Reminder: Reminder; ReminderError: ReminderError; ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess']; @@ -6390,6 +6416,7 @@ export type MutationResolvers>; recommend?: Resolver>; recommendHighlights?: Resolver>; + refreshHome?: Resolver; replyToEmail?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; @@ -6662,6 +6689,20 @@ export type RecommendingUserResolvers; }; +export type RefreshHomeErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type RefreshHomeResultResolvers = { + __resolveType: TypeResolveFn<'RefreshHomeError' | 'RefreshHomeSuccess', ParentType, ContextType>; +}; + +export type RefreshHomeSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ReminderResolvers = { archiveUntil?: Resolver; id?: Resolver; @@ -6863,6 +6904,7 @@ export type SearchItemResolvers>, ParentType, ContextType>; savedAt?: Resolver; score?: Resolver, ParentType, ContextType>; + seenAt?: Resolver, ParentType, ContextType>; shortId?: Resolver, ParentType, ContextType>; siteIcon?: Resolver, ParentType, ContextType>; siteName?: Resolver, ParentType, ContextType>; @@ -7795,6 +7837,9 @@ export type Resolvers = { Recommendation?: RecommendationResolvers; RecommendationGroup?: RecommendationGroupResolvers; RecommendingUser?: RecommendingUserResolvers; + RefreshHomeError?: RefreshHomeErrorResolvers; + RefreshHomeResult?: RefreshHomeResultResolvers; + RefreshHomeSuccess?: RefreshHomeSuccessResolvers; Reminder?: ReminderResolvers; ReminderError?: ReminderErrorResolvers; ReminderResult?: ReminderResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 29a98e88c..1642622d7 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -241,6 +241,7 @@ enum BulkActionType { ARCHIVE DELETE MARK_AS_READ + MARK_AS_SEEN MOVE_TO_FOLDER } @@ -1550,6 +1551,7 @@ type Mutation { optInFeature(input: OptInFeatureInput!): OptInFeatureResult! recommend(input: RecommendInput!): RecommendResult! recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult! + refreshHome: RefreshHomeResult! replyToEmail(recentEmailId: ID!, reply: AllowedReply!): ReplyToEmailResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! @@ -1890,6 +1892,20 @@ type RecommendingUser { username: String! } +type RefreshHomeError { + errorCodes: [RefreshHomeErrorCode!]! +} + +enum RefreshHomeErrorCode { + PENDING +} + +union RefreshHomeResult = RefreshHomeError | RefreshHomeSuccess + +type RefreshHomeSuccess { + success: Boolean! +} + type Reminder { archiveUntil: Boolean! id: ID! @@ -2216,6 +2232,7 @@ type SearchItem { recommendations: [Recommendation!] savedAt: Date! score: Float + seenAt: Date shortId: String siteIcon: String siteName: String diff --git a/packages/api/src/jobs/update_home.ts b/packages/api/src/jobs/update_home.ts index 56a6879fa..504b9aaa9 100644 --- a/packages/api/src/jobs/update_home.ts +++ b/packages/api/src/jobs/update_home.ts @@ -235,6 +235,17 @@ export const getHomeSections = async ( return sections } +export const deleteHome = async (userId: string) => { + const redisClient = redisDataSource.redisClient + if (!redisClient) { + throw new Error('Redis client not available') + } + + const key = redisKey(userId) + + await redisClient.del(key) +} + const appendSectionsToHome = async ( userId: string, sections: Array
, @@ -250,23 +261,25 @@ const appendSectionsToHome = async ( // store candidates in redis sorted set const pipeline = redisClient.pipeline() - // sections expire in 24 hours - const ttl = 86_400_000 + const now = Date.now() const batchSize = sections.length - const savedAt = cursor ? cursor - batchSize - ttl : Date.now() + const savedAt = cursor ? cursor - batchSize : now const scoreMembers = sections.flatMap((section, index) => [ - savedAt + index + ttl, // score for the section is the savedAt + index + ttl + savedAt + index, // score for the section is the savedAt + index otherwise it will be the same for all sections JSON.stringify(section), ]) // add section to the sorted set pipeline.zadd(key, ...scoreMembers) - // remove expired sections and keep only the top 500 + // remove expired sections and sections expire in 24 hours + const ttl = 86_400_000 + pipeline.zremrangebyscore(key, '-inf', Date.now() - ttl) + + // keep only the top MAX_FEED_ITEMS items pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1)) - pipeline.zremrangebyscore(key, '-inf', Date.now()) logger.info('Adding home sections to redis') await pipeline.exec() diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index dc1489c9e..026cb7988 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -670,7 +670,7 @@ export const searchResolver = authorized< SearchSuccess, SearchError, QuerySearchArgs ->(async (_obj, params, { uid, log }) => { +>(async (_obj, params, { uid }) => { const startCursor = params.after || '' const first = Math.min(params.first || 10, 100) // limit to 100 items diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 45bfc9dfd..4de6224fa 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -60,7 +60,7 @@ import { saveDiscoverArticleResolver, } from './discover_feeds' import { optInFeatureResolver } from './features' -import { homeResolver } from './home' +import { homeResolver, refreshHomeResolver } from './home' import { uploadImportFileResolver } from './importers/uploadImportFileResolver' import { addPopularReadResolver, @@ -332,6 +332,7 @@ export const functionResolvers = { fetchContent: fetchContentResolver, exportToIntegration: exportToIntegrationResolver, replyToEmail: replyToEmailResolver, + refreshHome: refreshHomeResolver, }, Query: { me: getMeUserResolver, @@ -867,4 +868,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('ReplyToEmail'), ...resultResolveTypeResolver('Home'), ...resultResolveTypeResolver('Subscription'), + ...resultResolveTypeResolver('RefreshHome'), } diff --git a/packages/api/src/resolvers/home/index.ts b/packages/api/src/resolvers/home/index.ts index acb7f89e5..1c1cb7249 100644 --- a/packages/api/src/resolvers/home/index.ts +++ b/packages/api/src/resolvers/home/index.ts @@ -5,8 +5,11 @@ import { HomeSection, HomeSuccess, QueryHomeArgs, + RefreshHomeError, + RefreshHomeErrorCode, + RefreshHomeSuccess, } from '../../generated/graphql' -import { getHomeSections } from '../../jobs/update_home' +import { deleteHome, getHomeSections } from '../../jobs/update_home' import { getJob } from '../../queue-processor' import { Merge } from '../../util' import { enqueueUpdateHomeJob, updateHomeJobId } from '../../utils/createTask' @@ -29,15 +32,17 @@ export const homeResolver = authorized< QueryHomeArgs >(async (_, { first, after }, { uid, log }) => { const limit = first || 6 - const cursor = after ? parseInt(after) : undefined + // cursor is the timestamp of the last item in the feed + // if cursor is not provided, it defaults to the current time + const cursor = after ? parseInt(after) : Date.now() const sections = await getHomeSections(uid, limit, cursor) - log.info('Just read feed sections fetched') + log.info('Home sections fetched') if (sections.length === 0) { const existingJob = await getJob(updateHomeJobId(uid)) if (existingJob) { - log.info('Just read feed update job already enqueued') + log.info('Update job job already enqueued') return { errorCodes: [HomeErrorCode.Pending], @@ -49,7 +54,7 @@ export const homeResolver = authorized< cursor, }) - log.info('Just read feed update enqueued') + log.info('Update home job enqueued') return { errorCodes: [HomeErrorCode.Pending], @@ -73,3 +78,30 @@ export const homeResolver = authorized< }, } }) + +export const refreshHomeResolver = authorized< + RefreshHomeSuccess, + RefreshHomeError +>(async (_, __, { uid, log }) => { + await deleteHome(uid) + log.info('Home cache deleted') + + const existingJob = await getJob(updateHomeJobId(uid)) + if (existingJob) { + log.info('Update home job already enqueued') + + return { + errorCodes: [RefreshHomeErrorCode.Pending], + } + } + + await enqueueUpdateHomeJob({ + userId: uid, + }) + + log.info('Update home job enqueued') + + return { + success: true, + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index c0986f4a3..2e51e3b00 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1677,6 +1677,7 @@ const schema = gql` directionality: DirectionalityType format: String score: Float + seenAt: Date } type SearchItemEdge { @@ -2595,6 +2596,7 @@ const schema = gql` MARK_AS_READ ADD_LABELS MOVE_TO_FOLDER + MARK_AS_SEEN } union BulkActionResult = BulkActionSuccess | BulkActionError @@ -3192,6 +3194,20 @@ const schema = gql` union SubscriptionResult = SubscriptionSuccess | SubscriptionError + type RefreshHomeSuccess { + success: Boolean! + } + + enum RefreshHomeErrorCode { + PENDING + } + + type RefreshHomeError { + errorCodes: [RefreshHomeErrorCode!]! + } + + union RefreshHomeResult = RefreshHomeSuccess | RefreshHomeError + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -3317,6 +3333,7 @@ const schema = gql` ): DeleteDiscoverFeedResult! editDiscoverFeed(input: EditDiscoverFeedInput!): EditDiscoverFeedResult! emptyTrash: EmptyTrashResult! + refreshHome: RefreshHomeResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 80848fc75..4390e6128 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -1303,6 +1303,11 @@ export const batchUpdateLibraryItems = async ( savedAt: now, } + break + case BulkActionType.MarkAsSeen: + values = { + seenAt: now, + } break default: throw new Error('Invalid bulk action') diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index c81677a08..5a64d06da 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -2322,6 +2322,51 @@ describe('Article API', () => { expect(response.body.data.search.pageInfo.totalCount).to.eql(0) }) }) + + context( + 'when action is MarkAsSeen and query contains a list of item id', + () => { + const items: LibraryItem[] = [] + + before(async () => { + // Create some test items + for (let i = 0; i < 5; i++) { + const item = await createOrUpdateLibraryItem( + { + user, + title: 'test item', + slug: '', + originalUrl: `https://blog.omnivore.app/p/bulk-action-${i}`, + }, + user.id + ) + + items.push(item) + } + }) + + after(async () => { + // Delete all items + await deleteLibraryItemsByUserId(user.id) + }) + + it('marks items as seen', async () => { + const query = `includes:${items.map((i) => i.id).join(',')}` + + const res = await graphqlRequest( + bulkActionQuery(BulkActionType.MarkAsSeen, query), + authToken + ).expect(200) + expect(res.body.data.bulkAction.success).to.be.true + + const response = await graphqlRequest( + searchQuery('is:seen'), + authToken + ).expect(200) + expect(response.body.data.search.pageInfo.totalCount).to.eql(5) + }) + } + ) }) describe('SetFavoriteArticle API', () => {