diff --git a/packages/api/package.json b/packages/api/package.json index c465b0fbe..d5fc669a8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@bmatei/apollo-prometheus-exporter": "^3.0.0", + "@cospired/i18n-iso-languages": "^4.2.0", "@google-cloud/logging-winston": "^6.0.0", "@google-cloud/monitoring": "^4.0.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0", diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 11008c18b..67eb0b65a 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -32,12 +32,15 @@ import { ClaimsToSet, RequestContext, ResolverContext } from './resolvers/types' import ScalarResolvers from './scalars' import typeDefs from './schema' import { batchGetHighlightsFromLibraryItemIds } from './services/highlights' +import { batchGetPublicItems } from './services/home' import { batchGetLabelsFromLibraryItemIds } from './services/labels' +import { batchGetLibraryItems } from './services/library_item' import { batchGetRecommendationsFromLibraryItemIds } from './services/recommendation' import { countDailyServiceUsage, createServiceUsage, } from './services/service_usage' +import { findSubscriptionsByNames } from './services/subscriptions' import { batchGetUploadFilesByIds } from './services/upload_file' import { tracer } from './tracing' import { getClaimsByToken, setAuthInCookie } from './utils/auth' @@ -112,6 +115,15 @@ const contextFunc: ContextFunction = async ({ batchGetRecommendationsFromLibraryItemIds ), uploadFiles: new DataLoader(batchGetUploadFilesByIds), + libraryItems: new DataLoader(batchGetLibraryItems), + publicItems: new DataLoader(batchGetPublicItems), + subscriptions: new DataLoader(async (names: readonly string[]) => { + if (!claims?.uid) { + throw new Error('No user id found in claims') + } + + return findSubscriptionsByNames(claims?.uid || '', names as string[]) + }), }, } diff --git a/packages/api/src/entity/library_item.ts b/packages/api/src/entity/library_item.ts index fb92fa618..8fb0edc94 100644 --- a/packages/api/src/entity/library_item.ts +++ b/packages/api/src/entity/library_item.ts @@ -204,4 +204,16 @@ export class LibraryItem { @Column('text') highlightAnnotations?: string[] + + @Column('timestamptz') + seenAt?: Date + + @Column('ltree') + topic?: string + + @Column('timestamptz') + digestedAt?: Date + + @Column('float') + score?: number } diff --git a/packages/api/src/entity/public_item.ts b/packages/api/src/entity/public_item.ts new file mode 100644 index 000000000..78a174e31 --- /dev/null +++ b/packages/api/src/entity/public_item.ts @@ -0,0 +1,73 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { PublicItemSource } from './public_item_source' +import { PublicItemStats } from './public_item_stats' + +@Entity({ name: 'public_item' }) +export class PublicItem { + @PrimaryGeneratedColumn('uuid') + id!: string + + @OneToOne(() => PublicItemStats) + stats!: PublicItemStats + + @ManyToOne(() => PublicItemSource) + @JoinColumn({ name: 'source_id' }) + source!: PublicItemSource + + @Column('text') + siteIcon?: string + + @Column('text') + type!: string + + @Column('text') + title!: string + + @Column('text') + url!: string + + @Column('boolean') + approved!: boolean + + @Column('text') + thumbnail?: string + + @Column('text') + previewContent?: string + + @Column('text') + languageCode?: string + + @Column('text') + author?: string + + @Column('text') + dir?: string + + @Column('timestamptz') + publishedAt?: Date + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @Column('text') + topic?: string + + @Column('integer') + wordCount?: number + + @Column('text') + siteName?: string +} diff --git a/packages/api/src/entity/public_item_interaction.ts b/packages/api/src/entity/public_item_interaction.ts new file mode 100644 index 000000000..dc192e72a --- /dev/null +++ b/packages/api/src/entity/public_item_interaction.ts @@ -0,0 +1,47 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { PublicItem } from './public_item' +import { User } from './user' + +@Entity({ name: 'public_item_interactions' }) +export class PublicItemInteraction { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => PublicItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'public_item_id' }) + publicItem!: PublicItem + + @Column('uuid') + publicItemId!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('timestamptz') + seenAt!: Date + + @Column('timestamptz') + savedAt?: Date + + @Column('timestamptz') + likedAt?: Date + + @Column('timestamptz') + broadcastedAt?: Date + + @Column('timestamptz') + createdAt!: Date + + @Column('timestamptz') + updated!: Date + + @Column('timestamptz') + digested?: Date +} diff --git a/packages/api/src/entity/public_item_source.ts b/packages/api/src/entity/public_item_source.ts new file mode 100644 index 000000000..a79a6ec19 --- /dev/null +++ b/packages/api/src/entity/public_item_source.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' + +@Entity({ name: 'public_item_source' }) +export class PublicItemSource { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('text') + type!: string + + @Column('text') + name!: string + + @Column('text') + url?: string + + @Column('boolean') + approved!: boolean + + @Column('text') + icon?: string + + @Column('text') + topics?: string[] + + @Column('text') + languageCodes?: string[] + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/packages/api/src/entity/public_item_stats.ts b/packages/api/src/entity/public_item_stats.ts new file mode 100644 index 000000000..ade4a5c25 --- /dev/null +++ b/packages/api/src/entity/public_item_stats.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' + +@Entity({ name: 'public_item_stats' }) +export class PublicItemStats { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('uuid') + publicItemId!: string + + @Column('integer') + saveCount!: number + + @Column('integer') + likeCount!: number + + @Column('integer') + broadcastCount!: number + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index aa96fbe3b..ee60e84b9 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1270,6 +1270,78 @@ export enum HighlightType { Redaction = 'REDACTION' } +export type HomeEdge = { + __typename?: 'HomeEdge'; + cursor: Scalars['String']; + node: HomeSection; +}; + +export type HomeError = { + __typename?: 'HomeError'; + errorCodes: Array; +}; + +export enum HomeErrorCode { + BadRequest = 'BAD_REQUEST', + Pending = 'PENDING', + Unauthorized = 'UNAUTHORIZED' +} + +export type HomeItem = { + __typename?: 'HomeItem'; + author?: Maybe; + broadcastCount?: Maybe; + canArchive?: Maybe; + canComment?: Maybe; + canDelete?: Maybe; + canSave?: Maybe; + canShare?: Maybe; + date: Scalars['Date']; + dir?: Maybe; + id: Scalars['ID']; + likeCount?: Maybe; + previewContent?: Maybe; + saveCount?: Maybe; + seen_at?: Maybe; + source?: Maybe; + thumbnail?: Maybe; + title: Scalars['String']; + url: Scalars['String']; + wordCount?: Maybe; +}; + +export type HomeItemSource = { + __typename?: 'HomeItemSource'; + icon?: Maybe; + id?: Maybe; + name: Scalars['String']; + type: HomeItemSourceType; + url?: Maybe; +}; + +export enum HomeItemSourceType { + Library = 'LIBRARY', + Newsletter = 'NEWSLETTER', + Recommendation = 'RECOMMENDATION', + Rss = 'RSS' +} + +export type HomeResult = HomeError | HomeSuccess; + +export type HomeSection = { + __typename?: 'HomeSection'; + items: Array; + layout?: Maybe; + thumbnail?: Maybe; + title?: Maybe; +}; + +export type HomeSuccess = { + __typename?: 'HomeSuccess'; + edges: Array; + pageInfo: PageInfo; +}; + export type ImportFromIntegrationError = { __typename?: 'ImportFromIntegrationError'; errorCodes: Array; @@ -2159,6 +2231,7 @@ export type Query = { getUserPersonalization: GetUserPersonalizationResult; groups: GroupsResult; hello?: Maybe; + home: HomeResult; integration: IntegrationResult; integrations: IntegrationsResult; labels: LabelsResult; @@ -2207,6 +2280,12 @@ export type QueryGetDiscoverFeedArticlesArgs = { }; +export type QueryHomeArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + + export type QueryIntegrationArgs = { name: Scalars['String']; }; @@ -3220,6 +3299,11 @@ export type Subscription = { url?: Maybe; }; +export type SubscriptionRootType = { + __typename?: 'SubscriptionRootType'; + hello?: Maybe; +}; + export enum SubscriptionStatus { Active = 'ACTIVE', Deleted = 'DELETED', @@ -4179,6 +4263,15 @@ export type ResolversTypes = { HighlightReply: ResolverTypeWrapper; HighlightStats: ResolverTypeWrapper; HighlightType: HighlightType; + HomeEdge: ResolverTypeWrapper; + HomeError: ResolverTypeWrapper; + HomeErrorCode: HomeErrorCode; + HomeItem: ResolverTypeWrapper; + HomeItemSource: ResolverTypeWrapper; + HomeItemSourceType: HomeItemSourceType; + HomeResult: ResolversTypes['HomeError'] | ResolversTypes['HomeSuccess']; + HomeSection: ResolverTypeWrapper; + HomeSuccess: ResolverTypeWrapper; ID: ResolverTypeWrapper; ImportFromIntegrationError: ResolverTypeWrapper; ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode; @@ -4421,7 +4514,8 @@ export type ResolversTypes = { SubscribeInput: SubscribeInput; SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess']; SubscribeSuccess: ResolverTypeWrapper; - Subscription: ResolverTypeWrapper<{}>; + Subscription: ResolverTypeWrapper; + SubscriptionRootType: ResolverTypeWrapper<{}>; SubscriptionStatus: SubscriptionStatus; SubscriptionType: SubscriptionType; SubscriptionsError: ResolverTypeWrapper; @@ -4727,6 +4821,13 @@ export type ResolversParentTypes = { Highlight: Highlight; HighlightReply: HighlightReply; HighlightStats: HighlightStats; + HomeEdge: HomeEdge; + HomeError: HomeError; + HomeItem: HomeItem; + HomeItemSource: HomeItemSource; + HomeResult: ResolversParentTypes['HomeError'] | ResolversParentTypes['HomeSuccess']; + HomeSection: HomeSection; + HomeSuccess: HomeSuccess; ID: Scalars['ID']; ImportFromIntegrationError: ImportFromIntegrationError; ImportFromIntegrationResult: ResolversParentTypes['ImportFromIntegrationError'] | ResolversParentTypes['ImportFromIntegrationSuccess']; @@ -4915,7 +5016,8 @@ export type ResolversParentTypes = { SubscribeInput: SubscribeInput; SubscribeResult: ResolversParentTypes['SubscribeError'] | ResolversParentTypes['SubscribeSuccess']; SubscribeSuccess: SubscribeSuccess; - Subscription: {}; + Subscription: Subscription; + SubscriptionRootType: {}; SubscriptionsError: SubscriptionsError; SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess']; SubscriptionsSuccess: SubscriptionsSuccess; @@ -5915,6 +6017,67 @@ export type HighlightStatsResolvers; }; +export type HomeEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HomeErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HomeItemResolvers = { + author?: Resolver, ParentType, ContextType>; + broadcastCount?: Resolver, ParentType, ContextType>; + canArchive?: Resolver, ParentType, ContextType>; + canComment?: Resolver, ParentType, ContextType>; + canDelete?: Resolver, ParentType, ContextType>; + canSave?: Resolver, ParentType, ContextType>; + canShare?: Resolver, ParentType, ContextType>; + date?: Resolver; + dir?: Resolver, ParentType, ContextType>; + id?: Resolver; + likeCount?: Resolver, ParentType, ContextType>; + previewContent?: Resolver, ParentType, ContextType>; + saveCount?: Resolver, ParentType, ContextType>; + seen_at?: Resolver, ParentType, ContextType>; + source?: Resolver, ParentType, ContextType>; + thumbnail?: Resolver, ParentType, ContextType>; + title?: Resolver; + url?: Resolver; + wordCount?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HomeItemSourceResolvers = { + icon?: Resolver, ParentType, ContextType>; + id?: Resolver, ParentType, ContextType>; + name?: Resolver; + type?: Resolver; + url?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HomeResultResolvers = { + __resolveType: TypeResolveFn<'HomeError' | 'HomeSuccess', ParentType, ContextType>; +}; + +export type HomeSectionResolvers = { + items?: Resolver, ParentType, ContextType>; + layout?: Resolver, ParentType, ContextType>; + thumbnail?: Resolver, ParentType, ContextType>; + title?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HomeSuccessResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ImportFromIntegrationErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6313,6 +6476,7 @@ export type QueryResolvers; groups?: Resolver; hello?: Resolver, ParentType, ContextType>; + home?: Resolver>; integration?: Resolver>; integrations?: Resolver; labels?: Resolver; @@ -6898,28 +7062,33 @@ export type SubscribeSuccessResolvers = { - autoAddToLibrary?: SubscriptionResolver, "autoAddToLibrary", ParentType, ContextType>; - count?: SubscriptionResolver; - createdAt?: SubscriptionResolver; - description?: SubscriptionResolver, "description", ParentType, ContextType>; - failedAt?: SubscriptionResolver, "failedAt", ParentType, ContextType>; - fetchContent?: SubscriptionResolver; - fetchContentType?: SubscriptionResolver; - folder?: SubscriptionResolver; - icon?: SubscriptionResolver, "icon", ParentType, ContextType>; - id?: SubscriptionResolver; - isPrivate?: SubscriptionResolver, "isPrivate", ParentType, ContextType>; - lastFetchedAt?: SubscriptionResolver, "lastFetchedAt", ParentType, ContextType>; - mostRecentItemDate?: SubscriptionResolver, "mostRecentItemDate", ParentType, ContextType>; - name?: SubscriptionResolver; - newsletterEmail?: SubscriptionResolver, "newsletterEmail", ParentType, ContextType>; - refreshedAt?: SubscriptionResolver, "refreshedAt", ParentType, ContextType>; - status?: SubscriptionResolver; - type?: SubscriptionResolver; - unsubscribeHttpUrl?: SubscriptionResolver, "unsubscribeHttpUrl", ParentType, ContextType>; - unsubscribeMailTo?: SubscriptionResolver, "unsubscribeMailTo", ParentType, ContextType>; - updatedAt?: SubscriptionResolver, "updatedAt", ParentType, ContextType>; - url?: SubscriptionResolver, "url", ParentType, ContextType>; + autoAddToLibrary?: Resolver, ParentType, ContextType>; + count?: Resolver; + createdAt?: Resolver; + description?: Resolver, ParentType, ContextType>; + failedAt?: Resolver, ParentType, ContextType>; + fetchContent?: Resolver; + fetchContentType?: Resolver; + folder?: Resolver; + icon?: Resolver, ParentType, ContextType>; + id?: Resolver; + isPrivate?: Resolver, ParentType, ContextType>; + lastFetchedAt?: Resolver, ParentType, ContextType>; + mostRecentItemDate?: Resolver, ParentType, ContextType>; + name?: Resolver; + newsletterEmail?: Resolver, ParentType, ContextType>; + refreshedAt?: Resolver, ParentType, ContextType>; + status?: Resolver; + type?: Resolver; + unsubscribeHttpUrl?: Resolver, ParentType, ContextType>; + unsubscribeMailTo?: Resolver, ParentType, ContextType>; + updatedAt?: Resolver, ParentType, ContextType>; + url?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SubscriptionRootTypeResolvers = { + hello?: SubscriptionResolver, "hello", ParentType, ContextType>; }; export type SubscriptionsErrorResolvers = { @@ -7491,6 +7660,13 @@ export type Resolvers = { Highlight?: HighlightResolvers; HighlightReply?: HighlightReplyResolvers; HighlightStats?: HighlightStatsResolvers; + HomeEdge?: HomeEdgeResolvers; + HomeError?: HomeErrorResolvers; + HomeItem?: HomeItemResolvers; + HomeItemSource?: HomeItemSourceResolvers; + HomeResult?: HomeResultResolvers; + HomeSection?: HomeSectionResolvers; + HomeSuccess?: HomeSuccessResolvers; ImportFromIntegrationError?: ImportFromIntegrationErrorResolvers; ImportFromIntegrationResult?: ImportFromIntegrationResultResolvers; ImportFromIntegrationSuccess?: ImportFromIntegrationSuccessResolvers; @@ -7646,6 +7822,7 @@ export type Resolvers = { SubscribeResult?: SubscribeResultResolvers; SubscribeSuccess?: SubscribeSuccessResolvers; Subscription?: SubscriptionResolvers; + SubscriptionRootType?: SubscriptionRootTypeResolvers; SubscriptionsError?: SubscriptionsErrorResolvers; SubscriptionsResult?: SubscriptionsResultResolvers; SubscriptionsSuccess?: SubscriptionsSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 0b08658b0..220808a8a 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1,3 +1,9 @@ +schema { + query: Query + mutation: Mutation + subscription: SubscriptionRootType +} + directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION type AddDiscoverFeedError { @@ -1136,6 +1142,72 @@ enum HighlightType { REDACTION } +type HomeEdge { + cursor: String! + node: HomeSection! +} + +type HomeError { + errorCodes: [HomeErrorCode!]! +} + +enum HomeErrorCode { + BAD_REQUEST + PENDING + UNAUTHORIZED +} + +type HomeItem { + author: String + broadcastCount: Int + canArchive: Boolean + canComment: Boolean + canDelete: Boolean + canSave: Boolean + canShare: Boolean + date: Date! + dir: String + id: ID! + likeCount: Int + previewContent: String + saveCount: Int + seen_at: Date + source: HomeItemSource + thumbnail: String + title: String! + url: String! + wordCount: Int +} + +type HomeItemSource { + icon: String + id: ID + name: String! + type: HomeItemSourceType! + url: String +} + +enum HomeItemSourceType { + LIBRARY + NEWSLETTER + RECOMMENDATION + RSS +} + +union HomeResult = HomeError | HomeSuccess + +type HomeSection { + items: [HomeItem!]! + layout: String + thumbnail: String + title: String +} + +type HomeSuccess { + edges: [HomeEdge!]! + pageInfo: PageInfo! +} + type ImportFromIntegrationError { errorCodes: [ImportFromIntegrationErrorCode!]! } @@ -1638,6 +1710,7 @@ type Query { getUserPersonalization: GetUserPersonalizationResult! groups: GroupsResult! hello: String + home(after: String, first: Int): HomeResult! integration(name: String!): IntegrationResult! integrations: IntegrationsResult! labels: LabelsResult! @@ -2541,6 +2614,10 @@ type Subscription { url: String } +type SubscriptionRootType { + hello: String +} + enum SubscriptionStatus { ACTIVE DELETED diff --git a/packages/api/src/jobs/score_library_item.ts b/packages/api/src/jobs/score_library_item.ts new file mode 100644 index 000000000..47279ce50 --- /dev/null +++ b/packages/api/src/jobs/score_library_item.ts @@ -0,0 +1,84 @@ +import { + findLibraryItemById, + updateLibraryItem, +} from '../services/library_item' +import { Feature, getScores } from '../services/score' +import { lanaugeToCode } from '../utils/helpers' +import { logger } from '../utils/logger' + +export const SCORE_LIBRARY_ITEM_JOB = 'SCORE_LIBRARY_ITEM_JOB' + +export interface ScoreLibraryItemJobData { + userId: string + libraryItemId: string +} + +export const scoreLibraryItem = async ( + data: ScoreLibraryItemJobData +): Promise => { + logger.info('Scoring library item', data) + + const { userId, libraryItemId } = data + + const libraryItem = await findLibraryItemById(libraryItemId, userId, { + select: [ + 'id', + 'title', + 'thumbnail', + 'siteIcon', + 'savedAt', + 'siteName', + 'directionality', + 'folder', + 'author', + 'itemLanguage', + 'wordCount', + ], + }) + if (!libraryItem) { + logger.error('Library item not found', data) + return + } + + const itemFeatures = { + [libraryItem.id]: { + library_item_id: libraryItem.id, + title: libraryItem.title, + has_thumbnail: !!libraryItem.thumbnail, + has_site_icon: !!libraryItem.siteIcon, + saved_at: libraryItem.savedAt, + site: libraryItem.siteName, + directionality: libraryItem.directionality, + folder: libraryItem.folder, + subscription_type: 'library', + author: libraryItem.author, + language: lanaugeToCode(libraryItem.itemLanguage || 'English'), + word_count: libraryItem.wordCount, + published_at: libraryItem.publishedAt, + subscription: libraryItem.subscription, + } as Feature, + } + + const scores = await getScores({ + user_id: userId, + items: itemFeatures, + }) + + logger.info('Scores', scores) + const score = scores[libraryItem.id] + if (!score) { + logger.error('Failed to score library item', data) + throw new Error('Failed to score library item') + } + + await updateLibraryItem( + libraryItem.id, + { + score, + }, + userId, + undefined, + true + ) + logger.info('Library item scored', data) +} diff --git a/packages/api/src/jobs/update_home.ts b/packages/api/src/jobs/update_home.ts new file mode 100644 index 000000000..dd09639dc --- /dev/null +++ b/packages/api/src/jobs/update_home.ts @@ -0,0 +1,408 @@ +import { LibraryItem } from '../entity/library_item' +import { PublicItem } from '../entity/public_item' +import { Subscription } from '../entity/subscription' +import { User } from '../entity/user' +import { redisDataSource } from '../redis_data_source' +import { findUnseenPublicItems } from '../services/home' +import { searchLibraryItems } from '../services/library_item' +import { Feature, getScores, ScoreApiResponse } from '../services/score' +import { findSubscriptionsByNames } from '../services/subscriptions' +import { findActiveUser } from '../services/user' +import { lanaugeToCode } from '../utils/helpers' +import { logger } from '../utils/logger' + +export const UPDATE_HOME_JOB = 'UPDATE_HOME_JOB' + +export interface UpdateHomeJobData { + userId: string + cursor?: number +} + +interface Candidate { + id: string + title: string + url: string + type: string + thumbnail?: string + previewContent?: string + languageCode: string + author?: string + dir: string + date: Date + topic?: string + wordCount: number + siteIcon?: string + siteName?: string + folder?: string + score?: number + publishedAt?: Date + subscription?: { + name: string + type: string + } +} + +interface Item { + id: string + type: string +} + +interface Section { + items: Array + layout: string +} + +const libraryItemToCandidate = ( + item: LibraryItem, + subscriptions: Array +): Candidate => ({ + id: item.id, + title: item.title, + url: item.originalUrl, + type: 'library_item', + thumbnail: item.thumbnail || undefined, + previewContent: item.description || undefined, + languageCode: lanaugeToCode(item.itemLanguage || 'English'), + author: item.author || undefined, + dir: item.directionality || 'ltr', + date: item.createdAt, + topic: item.topic, + wordCount: item.wordCount || 0, + siteName: item.siteName || undefined, + siteIcon: item.siteIcon || undefined, + folder: item.folder, + score: item.score, + publishedAt: item.publishedAt || undefined, + subscription: subscriptions.find( + (subscription) => + subscription.name === item.subscription || + subscription.url === item.subscription + ), +}) + +const publicItemToCandidate = (item: PublicItem): Candidate => ({ + id: item.id, + title: item.title, + url: item.url, + type: 'public_item', + thumbnail: item.thumbnail, + previewContent: item.previewContent, + languageCode: item.languageCode || 'en', + author: item.author, + dir: item.dir || 'ltr', + date: item.createdAt, + topic: item.topic, + wordCount: item.wordCount || 0, + siteIcon: item.siteIcon, + siteName: item.siteName, + publishedAt: item.publishedAt, + subscription: { + name: item.source.name, + type: item.source.type, + }, +}) + +const selectCandidates = async (user: User): Promise> => { + const userId = user.id + // get last 100 library items saved and not seen by user + const libraryItems = await searchLibraryItems( + { + size: 100, + includeContent: false, + query: `-is:seen wordsCount:>0`, + }, + userId + ) + + logger.info(`Found ${libraryItems.length} library items`) + + // get subscriptions for the library items + const subscriptionNames = libraryItems + .filter((item) => !!item.subscription) + .map((item) => item.subscription as string) + + const subscriptions = await findSubscriptionsByNames( + userId, + subscriptionNames + ) + + // map library items to candidates and limit to 70 + const privateCandidates: Array = libraryItems + .map((item) => libraryItemToCandidate(item, subscriptions)) + .slice(0, 70) + const privateCandidatesSize = privateCandidates.length + + logger.info(`Found ${privateCandidatesSize} private candidates`) + + // get 100 items not seen by the user from public inventory + const publicItems = await findUnseenPublicItems(userId, { + limit: 100, + }) + + logger.info(`Found ${publicItems.length} public items`) + + // map public items to candidates and limit to the remaining vacancies + const publicCandidates: Array = publicItems + .map(publicItemToCandidate) + .slice(0, 100 - privateCandidatesSize) + + const publicCandidatesSize = publicCandidates.length + logger.info(`Found ${publicCandidatesSize} public candidates`) + + // returns 100 candidates which are a mix of private and public candidates + return [...privateCandidates, ...publicCandidates] +} + +const rankCandidates = async ( + userId: string, + candidates: Array +): Promise> => { + if (candidates.length <= 10) { + // no need to rank if there are less than 10 candidates + return candidates + } + + const unscoredCandidates = candidates.filter( + (item) => item.score === undefined + ) + + const data = { + user_id: userId, + items: unscoredCandidates.reduce((acc, item) => { + acc[item.id] = { + library_item_id: item.id, + title: item.title, + has_thumbnail: !!item.thumbnail, + has_site_icon: !!item.siteIcon, + saved_at: item.date, + site: item.siteName, + language: item.languageCode, + directionality: item.dir, + folder: item.folder, + subscription_type: item.subscription?.type, + author: item.author, + word_count: item.wordCount, + published_at: item.publishedAt, + subscription: item.subscription?.name, + } as Feature + return acc + }, {} as Record), + } + + const newScores = await getScores(data) + const preCalculatedScores = candidates + .filter((item) => item.score !== undefined) + .reduce((acc, item) => { + acc[item.id] = item.score as number + return acc + }, {} as ScoreApiResponse) + const scores = { ...preCalculatedScores, ...newScores } + + // rank candidates by score in ascending order + candidates.sort((a, b) => { + const scoreA = scores[a.id] || 0 + const scoreB = scores[b.id] || 0 + + return scoreA - scoreB + }) + + return candidates +} + +const redisKey = (userId: string) => `just-read-feed:${userId}` +const MAX_FEED_ITEMS = 500 + +export const getHomeSections = async ( + userId: string, + limit: number, + maxScore?: number +): Promise> => { + const redisClient = redisDataSource.redisClient + if (!redisClient) { + throw new Error('Redis client not available') + } + + const key = redisKey(userId) + + // get feed items from redis sorted set in descending order + // with score smalled than maxScore + // limit to the first `limit` items + // response is an array of [member1, score1, member2, score2, ...] + const results = await redisClient.zrevrangebyscore( + key, + maxScore ? maxScore - 1 : '+inf', + '-inf', + 'WITHSCORES', + 'LIMIT', + 0, + limit + ) + + const sections = [] + for (let i = 0; i < results.length; i += 2) { + const member = JSON.parse(results[i]) as Section + const score = Number(results[i + 1]) + sections.push({ member, score }) + } + + return sections +} + +const appendSectionsToHome = async ( + userId: string, + sections: Array
, + cursor = Date.now() +) => { + const redisClient = redisDataSource.redisClient + if (!redisClient) { + throw new Error('Redis client not available') + } + + const key = redisKey(userId) + + // store candidates in redis sorted set + const pipeline = redisClient.pipeline() + + const offset = sections.length + 86_400_000 + cursor = cursor - offset + + const scoreMembers = sections.flatMap((section, index) => [ + cursor + index + 86_400_000, // sections expire in 24 hours + JSON.stringify(section), + ]) + + // add section to the sorted set + pipeline.zadd(key, ...scoreMembers) + + // remove expired sections and keep only the top 500 + pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1)) + pipeline.zremrangebyscore(key, '-inf', Date.now()) + + logger.info('Adding home sections to redis') + await pipeline.exec() +} + +const mixHomeItems = (rankedHomeItems: Array): Array
=> { + // find the median word count + const wordCounts = rankedHomeItems.map((item) => item.wordCount) + wordCounts.sort((a, b) => a - b) + const medianWordCount = wordCounts[Math.floor(wordCounts.length / 2)] + // separate items into two groups based on word count + const shortItems: Array = [] + const longItems: Array = [] + for (const item of rankedHomeItems) { + if (item.wordCount < medianWordCount) { + shortItems.push(item) + } else { + longItems.push(item) + } + } + // initialize empty batches + const batches: Array> = Array.from( + { length: Math.floor(rankedHomeItems.length / 10) }, + () => [] + ) + + const checkConstraints = (batch: Array, item: Candidate) => { + const titleCount = batch.filter((i) => i.title === item.title).length + const authorCount = batch.filter((i) => i.author === item.author).length + const siteCount = batch.filter((i) => i.siteName === item.siteName).length + const subscriptionCount = batch.filter( + (i) => i.subscription?.name === item.subscription?.name + ).length + + return ( + titleCount < 1 && + authorCount < 2 && + siteCount < 2 && + subscriptionCount < 2 + ) + } + + const distributeItems = ( + items: Array, + batches: Array> + ) => { + for (const item of items) { + let added = false + for (const batch of batches) { + if (batch.length < 5 && checkConstraints(batch, item)) { + batch.push(item) + added = true + break + } + } + + if (!added) { + for (const batch of batches) { + if (batch.length < 10) { + batch.push(item) + break + } + } + } + } + } + + // distribute quick link items first + distributeItems(shortItems, batches) + distributeItems(longItems, batches) + + // convert batches to sections + const sections = [] + for (const batch of batches) { + // create a section for all quick links + sections.push({ + items: batch.slice(0, 5).map((item) => ({ + id: item.id, + type: item.type, + })), + layout: 'quick links', + }) + + // create a section for each long item + sections.push( + ...batch.slice(5).map((item) => ({ + items: [{ id: item.id, type: item.type }], + layout: 'long', + })) + ) + } + + return sections +} + +export const updateHome = async (data: UpdateHomeJobData) => { + const { userId, cursor } = data + logger.info('Updating home for user', data) + + const user = await findActiveUser(userId) + if (!user) { + logger.error(`User ${userId} not found`) + return + } + + logger.info(`Updating home for user ${userId}`) + + const candidates = await selectCandidates(user) + logger.info(`Found ${candidates.length} candidates`) + + // TODO: integrity check on candidates + + logger.info('Ranking candidates') + const rankedCandidates = await rankCandidates(userId, candidates) + if (rankedCandidates.length === 0) { + logger.info('No candidates found') + return + } + + // TODO: filter candidates + + logger.info('Mix home items to create sections') + const rankedSections = mixHomeItems(rankedCandidates) + logger.info(`Created ${rankedSections.length} sections`) + + logger.info('Appending sections to home') + await appendSectionsToHome(userId, rankedSections, cursor) + logger.info('Home updated for user', { userId }) +} diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index fea9139b7..3e6fea7fd 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -5,6 +5,7 @@ import { env } from './env' import { ReportType } from './generated/graphql' import { enqueueProcessYouTubeVideo, + enqueueScoreJob, enqueueTriggerRuleJob, } from './utils/createTask' import { logger } from './utils/logger' @@ -74,6 +75,11 @@ export const createPubSubClient = (): PubsubClient => { libraryItemId: data.id, }) } + + await enqueueScoreJob({ + userId, + libraryItemId: data.id, + }) } }, entityUpdated: async ( diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 359bdfcf1..188c37efb 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -48,6 +48,10 @@ import { import { refreshAllFeeds } from './jobs/rss/refreshAllFeeds' import { refreshFeed } from './jobs/rss/refreshFeed' import { savePageJob } from './jobs/save_page' +import { + scoreLibraryItem, + SCORE_LIBRARY_ITEM_JOB, +} from './jobs/score_library_item' import { syncReadPositionsJob, SYNC_READ_POSITIONS_JOB_NAME, @@ -59,6 +63,7 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from './jobs/update_db' +import { updateHome, UPDATE_HOME_JOB } from './jobs/update_home' import { updatePDFContentJob } from './jobs/update_pdf_content' import { uploadContentJob, UPLOAD_CONTENT_JOB } from './jobs/upload_content' import { redisDataSource } from './redis_data_source' @@ -185,6 +190,10 @@ export const createWorker = (connection: ConnectionOptions) => return createDigest(job.data) case UPLOAD_CONTENT_JOB: return uploadContentJob(job.data) + case UPDATE_HOME_JOB: + return updateHome(job.data) + case SCORE_LIBRARY_ITEM_JOB: + return scoreLibraryItem(job.data) default: logger.warning(`[queue-processor] unhandled job: ${job.name}`) } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index aa41b724d..6f4e84ee9 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -4,11 +4,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { createHmac } from 'crypto' +import { isError } from 'lodash' import { Highlight as HighlightEntity } from '../entity/highlight' +import { LibraryItem } from '../entity/library_item' import { EXISTING_NEWSLETTER_FOLDER, NewsletterEmail, } from '../entity/newsletter_email' +import { PublicItem } from '../entity/public_item' import { DEFAULT_SUBSCRIPTION_FOLDER, Subscription, @@ -17,6 +20,9 @@ import { env } from '../env' import { Article, Highlight, + HomeItem, + HomeItemSource, + HomeItemSourceType, Label, PageType, Recommendation, @@ -25,6 +31,7 @@ import { } from '../generated/graphql' import { getAISummary } from '../services/ai-summaries' import { findUserFeatures } from '../services/features' +import { Merge } from '../util' import { highlightDataToHighlight, isBase64Image, @@ -33,7 +40,6 @@ import { wordsCount, } from '../utils/helpers' import { createImageProxyUrl } from '../utils/imageproxy' -import { logger } from '../utils/logger' import { contentConverter } from '../utils/parser' import { generateDownloadSignedUrl, @@ -54,6 +60,7 @@ import { saveDiscoverArticleResolver, } from './discover_feeds' import { optInFeatureResolver } from './features' +import { homeResolver } from './home' import { uploadImportFileResolver } from './importers/uploadImportFileResolver' import { addPopularReadResolver, @@ -360,6 +367,7 @@ export const functionResolvers = { feeds: feedsResolver, scanFeeds: scanFeedsResolver, integration: integrationResolver, + home: homeResolver, }, User: { async intercomHash( @@ -623,6 +631,108 @@ export const functionResolvers = { return newsletterEmail.folder || EXISTING_NEWSLETTER_FOLDER }, }, + HomeSection: { + async items( + section: { + items: Array<{ id: string; type: 'library_item' | 'public_item' }> + }, + _: unknown, + ctx: WithDataSourcesContext + ) { + const libraryItemIds = section.items + .filter((item) => item.type === 'library_item') + .map((item) => item.id) + const libraryItems = ( + await ctx.dataLoaders.libraryItems.loadMany(libraryItemIds) + ).filter((libraryItem) => !isError(libraryItem)) as Array + + const publicItemIds = section.items + .filter((item) => item.type === 'public_item') + .map((item) => item.id) + const publicItems = ( + await ctx.dataLoaders.publicItems.loadMany(publicItemIds) + ).filter((publicItem) => !isError(publicItem)) as Array + + return libraryItems + .map( + (libraryItem) => + ({ + id: libraryItem.id, + title: libraryItem.title, + author: libraryItem.author, + thumbnail: libraryItem.thumbnail, + wordCount: libraryItem.wordCount, + date: libraryItem.savedAt, + url: libraryItem.originalUrl, + canArchive: !libraryItem.archivedAt, + canDelete: !libraryItem.deletedAt, + canSave: false, + dir: libraryItem.directionality, + previewContent: libraryItem.description, + subscription: libraryItem.subscription, + siteName: libraryItem.siteName, + siteIcon: libraryItem.siteIcon, + } as HomeItem) + ) + .concat( + publicItems.map( + (publicItem) => + ({ + id: publicItem.id, + title: publicItem.title, + author: publicItem.author, + dir: publicItem.dir, + previewContent: publicItem.previewContent, + thumbnail: publicItem.thumbnail, + wordCount: publicItem.wordCount, + date: publicItem.createdAt, + url: publicItem.url, + canArchive: false, + canDelete: false, + canSave: true, + broadcastCount: publicItem.stats.broadcastCount, + likeCount: publicItem.stats.likeCount, + saveCount: publicItem.stats.saveCount, + source: publicItem.source, + } as HomeItem) + ) + ) + }, + }, + HomeItem: { + async source( + item: Merge< + HomeItem, + { subscription?: string; siteName: string; siteIcon?: string } + >, + _: unknown, + ctx: WithDataSourcesContext + ): Promise { + if (item.source) { + return item.source + } + + if (!item.subscription) { + return { + name: item.siteName, + icon: item.siteIcon, + type: HomeItemSourceType.Library, + } + } + + const subscription = await ctx.dataLoaders.subscriptions.load( + item.subscription + ) + + return { + id: subscription.id, + url: subscription.url, + name: subscription.name, + icon: subscription.icon, + type: subscription.type as unknown as HomeItemSourceType, + } + }, + }, ...resultResolveTypeResolver('Login'), ...resultResolveTypeResolver('LogOut'), ...resultResolveTypeResolver('GoogleSignup'), @@ -722,4 +832,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Integration'), ...resultResolveTypeResolver('ExportToIntegration'), ...resultResolveTypeResolver('ReplyToEmail'), + ...resultResolveTypeResolver('Home'), } diff --git a/packages/api/src/resolvers/home/index.ts b/packages/api/src/resolvers/home/index.ts new file mode 100644 index 000000000..64a14503d --- /dev/null +++ b/packages/api/src/resolvers/home/index.ts @@ -0,0 +1,71 @@ +import { + HomeError, + HomeErrorCode, + HomeItem, + HomeSection, + HomeSuccess, + QueryHomeArgs, +} from '../../generated/graphql' +import { getHomeSections } from '../../jobs/update_home' +import { getJob } from '../../queue-processor' +import { Merge } from '../../util' +import { enqueueUpdateHomeJob, updateHomeJobId } from '../../utils/createTask' +import { authorized } from '../../utils/gql-utils' + +type PartialHomeItem = Merge, { type: string }> +type PartialHomeSection = Merge }> +type PartialHomeSuccess = Merge< + HomeSuccess, + { + edges: Array<{ cursor: string; node: PartialHomeSection }> + } +> +// This resolver is used to fetch the just read feed for the user. +// when the feed is empty, it enqueues a job to update the feed. +// when client tries to fetch more then the feed has, it enqueues a job to update the feed. +export const homeResolver = authorized< + PartialHomeSuccess, + HomeError, + QueryHomeArgs +>(async (_, { first, after }, { uid, log }) => { + const limit = first || 6 + const cursor = after ? parseInt(after) : undefined + + const sections = await getHomeSections(uid, limit, cursor) + log.info('Just read feed sections fetched') + + if (sections.length === 0) { + const existingJob = await getJob(updateHomeJobId(uid)) + if (existingJob) { + log.info('Just read feed update job already enqueued') + + return { + errorCodes: [HomeErrorCode.Pending], + } + } + + await enqueueUpdateHomeJob({ + userId: uid, + cursor, + }) + + log.info('Just read feed update enqueued') + + return { + errorCodes: [HomeErrorCode.Pending], + } + } + + const edges = sections.map((section) => ({ + cursor: section.score.toString(), + node: section.member, + })) + + return { + edges, + pageInfo: { + hasPreviousPage: true, // there is always a previous page for new items + hasNextPage: true, // there is always a next page for old items + }, + } +}) diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index f880a0145..21b049bae 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -8,8 +8,12 @@ import winston from 'winston' import { ReadingProgressDataSource } from '../datasources/reading_progress_data_source' import { Highlight } from '../entity/highlight' import { Label } from '../entity/label' +import { LibraryItem } from '../entity/library_item' +import { PublicItem } from '../entity/public_item' import { Recommendation } from '../entity/recommendation' +import { Subscription } from '../entity/subscription' import { UploadFile } from '../entity/upload_file' +import { HomeItem } from '../generated/graphql' import { PubsubClient } from '../pubsub' export interface Claims { @@ -51,6 +55,9 @@ export interface RequestContext { highlights: DataLoader recommendations: DataLoader uploadFiles: DataLoader + libraryItems: DataLoader + publicItems: DataLoader + subscriptions: DataLoader } } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index a8002e17e..963adf81f 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3101,6 +3101,76 @@ const schema = gql` SUBSCRIBE } + enum HomeItemSourceType { + RSS + NEWSLETTER + RECOMMENDATION + LIBRARY + } + + type HomeItemSource { + id: ID + name: String! + url: String + icon: String + type: HomeItemSourceType! + } + + type HomeItem { + id: ID! + title: String! + url: String! + thumbnail: String + previewContent: String + saveCount: Int + likeCount: Int + broadcastCount: Int + date: Date! + author: String + dir: String + seen_at: Date + wordCount: Int + source: HomeItemSource + canSave: Boolean + canComment: Boolean + canShare: Boolean + canArchive: Boolean + canDelete: Boolean + } + + type HomeSection { + title: String + layout: String + items: [HomeItem!]! + thumbnail: String + } + + type HomeEdge { + cursor: String! + node: HomeSection! + } + + type HomeSuccess { + edges: [HomeEdge!]! + pageInfo: PageInfo! + } + + enum HomeErrorCode { + UNAUTHORIZED + BAD_REQUEST + PENDING + } + + type HomeError { + errorCodes: [HomeErrorCode!]! + } + + union HomeResult = HomeSuccess | HomeError + + type SubscriptionRootType { + hello: String # for testing only + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -3296,6 +3366,13 @@ const schema = gql` feeds(input: FeedsInput!): FeedsResult! discoverFeeds: DiscoverFeedResult! scanFeeds(input: ScanFeedsInput!): ScanFeedsResult! + home(first: Int, after: String): HomeResult! + } + + schema { + query: Query + mutation: Mutation + subscription: SubscriptionRootType } ` diff --git a/packages/api/src/services/home.ts b/packages/api/src/services/home.ts new file mode 100644 index 000000000..c70a98003 --- /dev/null +++ b/packages/api/src/services/home.ts @@ -0,0 +1,113 @@ +import { PublicItem } from '../entity/public_item' +import { HomeItem } from '../generated/graphql' +import { authTrx } from '../repository' +import { findLibraryItemsByIds } from './library_item' + +export const batchGetPublicItems = async ( + ids: readonly string[] +): Promise> => { + return authTrx(async (tx) => + tx + .getRepository(PublicItem) + .createQueryBuilder('public_item') + .where('public_item.id IN (:...ids)', { ids }) + .getMany() + ) +} + +export const batchGetHomeItems = async ( + ids: readonly string[] +): Promise> => { + const libraryItems = await findLibraryItemsByIds(ids as string[]) + + const publicItems = await authTrx(async (tx) => + tx + .getRepository(PublicItem) + .createQueryBuilder('public_item') + .innerJoin( + 'public_item_stats', + 'stats', + 'stats.public_item_id = public_item.id' + ) + .innerJoin( + 'public_item_source', + 'source', + 'source.id = public_item.source_id' + ) + .where('public_item.id IN (:...ids)', { ids }) + .getMany() + ) + + return ids + .map((id) => { + const libraryItem = libraryItems.find((li) => li.id === id) + if (libraryItem) { + return { + ...libraryItem, + date: libraryItem.savedAt, + url: libraryItem.originalUrl, + canArchive: !libraryItem.archivedAt, + canDelete: !libraryItem.deletedAt, + canSave: false, + dir: libraryItem.directionality, + subscription: null, + previewContent: libraryItem.description, + } as HomeItem + } else { + const publicItem = publicItems.find((pi) => pi.id === id) + return publicItem + ? ({ + ...publicItem, + date: publicItem.createdAt, + url: publicItem.url, + canArchive: false, + canDelete: false, + canSave: true, + broadcastCount: publicItem.stats.broadcastCount, + likeCount: publicItem.stats.likeCount, + saveCount: publicItem.stats.saveCount, + subscription: publicItem.source, + } as HomeItem) + : undefined + } + }) + .filter((item) => item !== undefined) as Array +} + +export const findUnseenPublicItems = async ( + userId: string, + options: { + limit?: number + offset?: number + } +): Promise> => { + return authTrx( + async (tx) => + tx + .getRepository(PublicItem) + .createQueryBuilder('public_item') + .leftJoin( + 'public_item_interactions', + 'interaction', + 'interaction.public_item_id = public_item.id' + ) + .innerJoin( + 'public_item_stats', + 'stats', + 'stats.public_item_id = public_item.id' + ) + .innerJoin( + 'public_item_source', + 'source', + 'source.id = public_item.source_id' + ) + .where('interaction.user_id = :userId', { userId }) + .andWhere('interaction.seen_at IS NULL') + .orderBy('public_item.createdAt', 'DESC') + .take(options.limit) + .skip(options.offset) + .getMany(), + undefined, + userId + ) +} diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index b9e3aeb3e..b90aa0e80 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -60,6 +60,7 @@ enum ReadFilter { READ = 'read', READING = 'reading', UNREAD = 'unread', + SEEN = 'seen', } enum InFilter { @@ -139,6 +140,10 @@ interface Select { const readingProgressDataSource = new ReadingProgressDataSource() +export const batchGetLibraryItems = async (ids: readonly string[]) => { + return findLibraryItemsByIds(ids as string[]) +} + export const getItemUrl = (id: string) => `${env.client.url}/me/${id}` const markItemAsRead = async (libraryItemId: string, userId: string) => { @@ -332,6 +337,8 @@ export const buildQueryString = ( return 'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98' case ReadFilter.UNREAD: return 'library_item.reading_progress_bottom_percent < 2' + case ReadFilter.SEEN: + return 'library_item.seen_at IS NOT NULL' default: throw new Error(`Unexpected keyword: ${value}`) } @@ -772,7 +779,7 @@ export const findRecentLibraryItems = async ( export const findLibraryItemsByIds = async ( ids: string[], - userId: string, + userId?: string, options?: { select?: (keyof LibraryItem)[] } diff --git a/packages/api/src/services/score.ts b/packages/api/src/services/score.ts new file mode 100644 index 000000000..2aaf1cf9b --- /dev/null +++ b/packages/api/src/services/score.ts @@ -0,0 +1,50 @@ +export interface Feature { + library_item_id?: string + title: string + has_thumbnail: boolean + has_site_icon: boolean + saved_at: Date + site?: string + language?: string + author?: string + directionality: string + word_count?: number + subscription_type?: string + folder?: string + published_at?: Date + subscription?: string +} + +export interface ScoreApiRequestBody { + user_id: string + items: Record // item_id -> feature +} + +export type ScoreApiResponse = Record // item_id -> score + +export const getScores = async ( + data: ScoreApiRequestBody +): Promise => { + const API_URL = 'http://digest-score/batch' + // const token = process.env.SCORE_API_TOKEN + + // if (!token) { + // throw new Error('No score API token found') + // } + + const response = await fetch(API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + throw new Error(`Failed to score candidates: ${response.statusText}`) + } + + const scores = (await response.json()) as ScoreApiResponse + return scores +} diff --git a/packages/api/src/services/subscriptions.ts b/packages/api/src/services/subscriptions.ts index 22be8c2f4..72b0fc9d2 100644 --- a/packages/api/src/services/subscriptions.ts +++ b/packages/api/src/services/subscriptions.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { DeepPartial, DeleteResult } from 'typeorm' +import { DeepPartial, DeleteResult, In } from 'typeorm' import { appDataSource } from '../data_source' import { NewsletterEmail } from '../entity/newsletter_email' import { Subscription } from '../entity/subscription' @@ -214,3 +214,13 @@ export const createRssSubscriptions = async ( ) => { return getRepository(Subscription).save(subscriptions) } + +export const findSubscriptionsByNames = async ( + userId: string, + names: string[] +): Promise => { + return getRepository(Subscription).findBy([ + { user: { id: userId }, name: In(names) }, + { user: { id: userId }, url: In(names) }, + ]) +} diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index e06c1bb99..26ef6362b 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -45,6 +45,10 @@ import { REFRESH_ALL_FEEDS_JOB_NAME, REFRESH_FEED_JOB_NAME, } from '../jobs/rss/refreshAllFeeds' +import { + ScoreLibraryItemJobData, + SCORE_LIBRARY_ITEM_JOB, +} from '../jobs/score_library_item' import { SYNC_READ_POSITIONS_JOB_NAME } from '../jobs/sync_read_positions' import { TriggerRuleJobData, TRIGGER_RULE_JOB_NAME } from '../jobs/trigger_rule' import { @@ -53,6 +57,7 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from '../jobs/update_db' +import { UpdateHomeJobData, UPDATE_HOME_JOB } from '../jobs/update_home' import { UploadContentJobData, UPLOAD_CONTENT_JOB, @@ -85,6 +90,7 @@ export const getJobPriority = (jobName: string): number => { case UPDATE_HIGHLIGHT_JOB: case SYNC_READ_POSITIONS_JOB_NAME: case SEND_EMAIL_JOB: + case UPDATE_HOME_JOB: return 1 case TRIGGER_RULE_JOB_NAME: case CALL_WEBHOOK_JOB_NAME: @@ -95,6 +101,7 @@ export const getJobPriority = (jobName: string): number => { case `${REFRESH_FEED_JOB_NAME}_high`: case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: case UPLOAD_CONTENT_JOB: + case SCORE_LIBRARY_ITEM_JOB: return 10 case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: @@ -981,4 +988,40 @@ export const enqueueBulkUploadContentJob = async ( return queue.addBulk(jobs) } +export const updateHomeJobId = (userId: string) => + `${UPDATE_HOME_JOB}_${userId}_${JOB_VERSION}` + +export const enqueueUpdateHomeJob = async (data: UpdateHomeJobData) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(UPDATE_HOME_JOB, data, { + jobId: updateHomeJobId(data.userId), + removeOnComplete: true, + removeOnFail: true, + priority: getJobPriority(UPDATE_HOME_JOB), + attempts: 3, + }) +} + +export const updateScoreJobId = (userId: string) => + `${SCORE_LIBRARY_ITEM_JOB}_${userId}_${JOB_VERSION}` + +export const enqueueScoreJob = async (data: ScoreLibraryItemJobData) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(SCORE_LIBRARY_ITEM_JOB, data, { + jobId: updateScoreJobId(data.userId), + removeOnComplete: true, + removeOnFail: true, + priority: getJobPriority(SCORE_LIBRARY_ITEM_JOB), + attempts: 3, + }) +} + export default createHttpTaskWithToken diff --git a/packages/api/src/utils/helpers.ts b/packages/api/src/utils/helpers.ts index 01aaf9e94..30a821a5e 100644 --- a/packages/api/src/utils/helpers.ts +++ b/packages/api/src/utils/helpers.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import languages from '@cospired/i18n-iso-languages' import crypto from 'crypto' import Redis from 'ioredis' import normalizeUrl from 'normalize-url' @@ -31,6 +32,7 @@ import { validateUrl } from '../services/create_page_save_request' import { updateLibraryItem } from '../services/library_item' import { Merge } from '../util' import { logger } from './logger' + interface InputObject { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any @@ -423,3 +425,6 @@ export const getClientFromUserAgent = (userAgent: string): string => { return 'other' } + +export const lanaugeToCode = (language: string): string => + languages.getAlpha2Code(language, 'en') || 'en' diff --git a/packages/db/migrations/0177.do.public_item.sql b/packages/db/migrations/0177.do.public_item.sql new file mode 100755 index 000000000..ded66eefe --- /dev/null +++ b/packages/db/migrations/0177.do.public_item.sql @@ -0,0 +1,92 @@ +-- Type: DO +-- Name: public_item +-- Description: Create a table for public items + +BEGIN; + +CREATE TABLE omnivore.public_item_source ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + name TEXT NOT NULL, + type TEXT NOT NULL, -- public feeds, newsletters, or user recommended + topics TEXT[], + icon TEXT, + url TEXT, + language_codes TEXT[], + approved BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER update_public_item_source_modtime BEFORE UPDATE ON omnivore.public_item_source FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); +GRANT SELECT ON omnivore.public_item_source TO omnivore_user; + + +CREATE TABLE omnivore.public_item ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + source_id uuid NOT NULL REFERENCES omnivore.public_item_source(id) ON DELETE CASCADE, + site_icon TEXT, + type TEXT NOT NULL, -- public feeds, newsletters, or user recommended + title TEXT NOT NULL, + url TEXT NOT NULL, + topic TEXT, + approved BOOLEAN NOT NULL DEFAULT FALSE, + thumbnail TEXT, + preview_content TEXT, + language_code TEXT, + author TEXT, + dir TEXT, + published_at timestamptz, + word_count INT, + site_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER update_public_item_modtime BEFORE UPDATE ON omnivore.public_item FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); +GRANT SELECT ON omnivore.public_item TO omnivore_user; + + +CREATE TABLE omnivore.public_item_stats ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE, + save_count INT NOT NULL DEFAULT 0, + like_count INT NOT NULL DEFAULT 0, + broadcast_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX public_item_stats_public_item_id_idx ON omnivore.public_item_stats(public_item_id); +CREATE TRIGGER update_public_item_stats_modtime BEFORE UPDATE ON omnivore.public_item_stats FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); +GRANT SELECT ON omnivore.public_item_stats TO omnivore_user; + + +CREATE TABLE omnivore.public_item_interactions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE, + public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE, + saved_at TIMESTAMPTZ, + liked_at TIMESTAMPTZ, + broadcasted_at TIMESTAMPTZ, + seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + digested_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX public_item_interaction_user_id_idx ON omnivore.public_item_interactions(user_id); +CREATE INDEX public_item_interaction_public_item_id_idx ON omnivore.public_item_interactions(public_item_id); +CREATE TRIGGER update_public_item_interactions_modtime BEFORE UPDATE ON omnivore.public_item_interactions FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); +GRANT SELECT, INSERT, UPDATE ON omnivore.public_item_interactions TO omnivore_user; + +CREATE EXTENSION LTREE; + +ALTER TABLE omnivore.library_item + ADD COLUMN seen_at TIMESTAMPTZ, + ADD COLUMN digested_at TIMESTAMPTZ, + ADD COLUMN topic LTREE, + ADD COLUMN score FLOAT; + +CREATE INDEX library_item_topic_idx ON omnivore.library_item USING GIST (topic); + +COMMIT; diff --git a/packages/db/migrations/0177.undo.public_item.sql b/packages/db/migrations/0177.undo.public_item.sql new file mode 100755 index 000000000..cb068a36c --- /dev/null +++ b/packages/db/migrations/0177.undo.public_item.sql @@ -0,0 +1,22 @@ +-- Type: UNDO +-- Name: public_item +-- Description: Create a table for public items + +BEGIN; + +DROP TABLE omnivore.public_item_interactions; +DROP TABLE omnivore.public_item_stats; +DROP TABLE omnivore.public_item; +DROP TABLE omnivore.public_item_source; + +DROP INDEX omnivore.library_item_topic_idx; + +ALTER TABLE omnivore.library_item + DROP COLUMN seen_at, + DROP COLUMN digested_at, + DROP COLUMN topic, + DROP COLUMN score; + +DROP EXTENSION LTREE; + +COMMIT; diff --git a/yarn.lock b/yarn.lock index 688e36992..d1bde267b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2442,6 +2442,11 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== +"@cospired/i18n-iso-languages@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.2.0.tgz#094418a72f250fd612b3fc856b12f674a10864eb" + integrity sha512-vy8cq1176MTxVwB1X9niQjcIYOH29F8Huxtx8hLmT5Uz3l1ztGDGri8KN/4zE7LV2mCT7JrcAoNV/I9yb+lNUw== + "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -12870,11 +12875,6 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" - integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== - cookie@0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" @@ -13175,15 +13175,6 @@ crypto@^1.0.1: resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== -csrf@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/csrf/-/csrf-3.1.0.tgz#ec75e9656d004d674b8ef5ba47b41fbfd6cb9c30" - integrity sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w== - dependencies: - rndm "1.2.0" - tsscmp "1.0.6" - uid-safe "2.1.5" - css-loader@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" @@ -13316,16 +13307,6 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== -csurf@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/csurf/-/csurf-1.11.0.tgz#ab0c3c6634634192bd3d6f4b861be20800eeb61a" - integrity sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ== - dependencies: - cookie "0.4.0" - cookie-signature "1.0.6" - csrf "3.1.0" - http-errors "~1.7.3" - csv-file-validator@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/csv-file-validator/-/csv-file-validator-2.1.0.tgz#fc83e1e05835d7f03d03f8cce6235938e4cef32e" @@ -17951,17 +17932,6 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-errors@~1.7.3: - version "1.7.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" - integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.1.1" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" - http-parser-js@>=0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.5.tgz#d7c30d5d3c90d865b4a2e870181f9d6f22ac7ac5" @@ -26218,11 +26188,6 @@ randexp@0.4.6: discontinuous-range "1.0.0" ret "~0.1.10" -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== - randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -27837,11 +27802,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rndm@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c" - integrity sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw== - rollup@2.78.0: version "2.78.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e" @@ -28341,11 +28301,6 @@ setprototypeof@1.1.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== -setprototypeof@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" - integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== - setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -28997,7 +28952,7 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2": +"statuses@>= 1.4.0 < 2": version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= @@ -30075,11 +30030,6 @@ toggle-selection@^1.0.6: resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= -toidentifier@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" - integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== - toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" @@ -30371,11 +30321,6 @@ tslib@~2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== -tsscmp@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" - integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -30640,13 +30585,6 @@ uhyphen@^0.2.0: resolved "https://registry.yarnpkg.com/uhyphen/-/uhyphen-0.2.0.tgz#8fdf0623314486e020a3c00ee5cc7a12fe722b81" integrity sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA== -uid-safe@2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== - dependencies: - random-bytes "~1.0.0" - unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"