diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 9c0ce1271..b06bcd304 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -42,6 +42,7 @@ import { } from './services/service_usage' import { batchGetSubscriptionsByNames } from './services/subscriptions' import { batchGetUploadFilesByIds } from './services/upload_file' +import { findUsersByIds } from './services/user' import { tracer } from './tracing' import { getClaimsByToken, setAuthInCookie } from './utils/auth' import { SetClaimsRole } from './utils/dictionary' @@ -124,6 +125,9 @@ const contextFunc: ContextFunction = async ({ return batchGetSubscriptionsByNames(claims.uid, names as string[]) }), + users: new DataLoader(async (ids: readonly string[]) => + findUsersByIds(ids as string[]) + ), }, } diff --git a/packages/api/src/entity/highlight.ts b/packages/api/src/entity/highlight.ts index 60b8eed3a..cdbe26b65 100644 --- a/packages/api/src/entity/highlight.ts +++ b/packages/api/src/entity/highlight.ts @@ -36,6 +36,9 @@ export class Highlight { @JoinColumn({ name: 'user_id' }) user!: User + @Column('uuid') + userId!: string + @ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'library_item_id' }) libraryItem!: LibraryItem diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 3ed794529..27cdcf62b 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1275,6 +1275,12 @@ export type Highlight = { user: User; }; +export type HighlightEdge = { + __typename?: 'HighlightEdge'; + cursor: Scalars['String']; + node: Highlight; +}; + export type HighlightReply = { __typename?: 'HighlightReply'; createdAt: Scalars['Date']; @@ -1296,6 +1302,23 @@ export enum HighlightType { Redaction = 'REDACTION' } +export type HighlightsError = { + __typename?: 'HighlightsError'; + errorCodes: Array; +}; + +export enum HighlightsErrorCode { + BadRequest = 'BAD_REQUEST' +} + +export type HighlightsResult = HighlightsError | HighlightsSuccess; + +export type HighlightsSuccess = { + __typename?: 'HighlightsSuccess'; + edges: Array; + pageInfo: PageInfo; +}; + export type HomeEdge = { __typename?: 'HomeEdge'; cursor: Scalars['String']; @@ -2261,6 +2284,7 @@ export type Query = { groups: GroupsResult; hello?: Maybe; hiddenHomeSection: HiddenHomeSectionResult; + highlights: HighlightsResult; home: HomeResult; integration: IntegrationResult; integrations: IntegrationsResult; @@ -2311,6 +2335,13 @@ export type QueryGetDiscoverFeedArticlesArgs = { }; +export type QueryHighlightsArgs = { + after?: InputMaybe; + first?: InputMaybe; + query?: InputMaybe; +}; + + export type QueryHomeArgs = { after?: InputMaybe; first?: InputMaybe; @@ -4331,9 +4362,14 @@ export type ResolversTypes = { HiddenHomeSectionResult: ResolversTypes['HiddenHomeSectionError'] | ResolversTypes['HiddenHomeSectionSuccess']; HiddenHomeSectionSuccess: ResolverTypeWrapper; Highlight: ResolverTypeWrapper; + HighlightEdge: ResolverTypeWrapper; HighlightReply: ResolverTypeWrapper; HighlightStats: ResolverTypeWrapper; HighlightType: HighlightType; + HighlightsError: ResolverTypeWrapper; + HighlightsErrorCode: HighlightsErrorCode; + HighlightsResult: ResolversTypes['HighlightsError'] | ResolversTypes['HighlightsSuccess']; + HighlightsSuccess: ResolverTypeWrapper; HomeEdge: ResolverTypeWrapper; HomeError: ResolverTypeWrapper; HomeErrorCode: HomeErrorCode; @@ -4900,8 +4936,12 @@ export type ResolversParentTypes = { HiddenHomeSectionResult: ResolversParentTypes['HiddenHomeSectionError'] | ResolversParentTypes['HiddenHomeSectionSuccess']; HiddenHomeSectionSuccess: HiddenHomeSectionSuccess; Highlight: Highlight; + HighlightEdge: HighlightEdge; HighlightReply: HighlightReply; HighlightStats: HighlightStats; + HighlightsError: HighlightsError; + HighlightsResult: ResolversParentTypes['HighlightsError'] | ResolversParentTypes['HighlightsSuccess']; + HighlightsSuccess: HighlightsSuccess; HomeEdge: HomeEdge; HomeError: HomeError; HomeItem: HomeItem; @@ -6103,6 +6143,12 @@ export type HighlightResolvers; }; +export type HighlightEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type HighlightReplyResolvers = { createdAt?: Resolver; highlight?: Resolver; @@ -6118,6 +6164,21 @@ export type HighlightStatsResolvers; }; +export type HighlightsErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type HighlightsResultResolvers = { + __resolveType: TypeResolveFn<'HighlightsError' | 'HighlightsSuccess', ParentType, ContextType>; +}; + +export type HighlightsSuccessResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type HomeEdgeResolvers = { cursor?: Resolver; node?: Resolver; @@ -6581,6 +6642,7 @@ export type QueryResolvers; hello?: Resolver, ParentType, ContextType>; hiddenHomeSection?: Resolver; + highlights?: Resolver>; home?: Resolver>; integration?: Resolver>; integrations?: Resolver; @@ -7797,8 +7859,12 @@ export type Resolvers = { HiddenHomeSectionResult?: HiddenHomeSectionResultResolvers; HiddenHomeSectionSuccess?: HiddenHomeSectionSuccessResolvers; Highlight?: HighlightResolvers; + HighlightEdge?: HighlightEdgeResolvers; HighlightReply?: HighlightReplyResolvers; HighlightStats?: HighlightStatsResolvers; + HighlightsError?: HighlightsErrorResolvers; + HighlightsResult?: HighlightsResultResolvers; + HighlightsSuccess?: HighlightsSuccessResolvers; HomeEdge?: HomeEdgeResolvers; HomeError?: HomeErrorResolvers; HomeItem?: HomeItemResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index bb2185cbf..d0e46a4b3 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1147,6 +1147,11 @@ type Highlight { user: User! } +type HighlightEdge { + cursor: String! + node: Highlight! +} + type HighlightReply { createdAt: Date! highlight: Highlight! @@ -1166,6 +1171,21 @@ enum HighlightType { REDACTION } +type HighlightsError { + errorCodes: [HighlightsErrorCode!]! +} + +enum HighlightsErrorCode { + BAD_REQUEST +} + +union HighlightsResult = HighlightsError | HighlightsSuccess + +type HighlightsSuccess { + edges: [HighlightEdge!]! + pageInfo: PageInfo! +} + type HomeEdge { cursor: String! node: HomeSection! @@ -1738,6 +1758,7 @@ type Query { groups: GroupsResult! hello: String hiddenHomeSection: HiddenHomeSectionResult! + highlights(after: String, first: Int, query: String): HighlightsResult! home(after: String, first: Int): HomeResult! integration(name: String!): IntegrationResult! integrations: IntegrationsResult! diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index ba5079831..1a7d0ba40 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { createHmac } from 'crypto' import { isError } from 'lodash' -import { Highlight as HighlightEntity } from '../entity/highlight' +import { Highlight } from '../entity/highlight' import { LibraryItem } from '../entity/library_item' import { EXISTING_NEWSLETTER_FOLDER, @@ -16,10 +16,10 @@ import { DEFAULT_SUBSCRIPTION_FOLDER, Subscription, } from '../entity/subscription' +import { User as UserEntity } from '../entity/user' import { env } from '../env' import { Article, - Highlight, HomeItem, HomeItemSource, HomeItemSourceType, @@ -33,7 +33,6 @@ import { getAISummary } from '../services/ai-summaries' import { findUserFeatures } from '../services/features' import { Merge } from '../util' import { - highlightDataToHighlight, isBase64Image, recommandationDataToRecommendation, validatedDate, @@ -60,6 +59,7 @@ import { saveDiscoverArticleResolver, } from './discover_feeds' import { optInFeatureResolver } from './features' +import { highlightsResolver } from './highlight' import { hiddenHomeSectionResolver, homeResolver, @@ -376,6 +376,7 @@ export const functionResolvers = { home: homeResolver, subscription: subscriptionResolver, hiddenHomeSection: hiddenHomeSectionResolver, + highlights: highlightsResolver, }, User: { async intercomHash( @@ -414,6 +415,16 @@ export const functionResolvers = { return findUserFeatures(ctx.claims.uid) }, + picture: (user: UserEntity) => user.profile.pictureUrl, + // not implemented yet + friendsCount: () => 0, + followersCount: () => 0, + isFullUser: () => true, + viewerIsFollowing: () => false, + sharedArticles: () => [], + sharedArticlesCount: () => 0, + sharedHighlightsCount: () => 0, + sharedNotesCount: () => 0, }, Article: { async url(article: Article, _: unknown, ctx: WithDataSourcesContext) { @@ -465,16 +476,12 @@ export const functionResolvers = { ...readingProgressHandlers, }, Highlight: { - // async reactions( - // highlight: { id: string; reactions?: Reaction[] }, - // _: unknown, - // ctx: WithDataSourcesContext - // ) { - // const { reactions, id } = highlight - // if (reactions) return reactions - - // return await ctx.models.reaction.batchGetFromHighlight(id) - // }, + reactions: () => [], + replies: () => [], + type: (highlight: Highlight) => highlight.highlightType, + async user(highlight: Highlight, __: unknown, ctx: WithDataSourcesContext) { + return ctx.dataLoaders.users.load(highlight.userId) + }, createdByMe( highlight: { user: { id: string } }, __: unknown, @@ -483,15 +490,6 @@ export const functionResolvers = { return highlight.user.id === ctx.uid }, }, - // Reaction: { - // async user( - // reaction: { userId: string }, - // __: unknown, - // ctx: WithDataSourcesContext - // ) { - // return userDataToUser(await ctx.models.user.get(reaction.userId)) - // }, - // }, SearchItem: { async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) { if ( @@ -570,7 +568,7 @@ export const functionResolvers = { if (item.highlights) return item.highlights const highlights = await ctx.dataLoaders.highlights.load(item.id) - return highlights.map(highlightDataToHighlight) + return highlights }, ...readingProgressHandlers, async content( @@ -585,7 +583,7 @@ export const functionResolvers = { ) { // convert html to the requested format if requested if (item.format && item.format !== ArticleFormat.Html && item.content) { - let highlights: HighlightEntity[] = [] + let highlights: Highlight[] = [] // load highlights if needed if ( item.format === ArticleFormat.HighlightedMarkdown && @@ -886,4 +884,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Subscription'), ...resultResolveTypeResolver('RefreshHome'), ...resultResolveTypeResolver('HiddenHomeSection'), + ...resultResolveTypeResolver('Highlights'), } diff --git a/packages/api/src/resolvers/highlight/index.ts b/packages/api/src/resolvers/highlight/index.ts index a142b3299..9d6d34359 100644 --- a/packages/api/src/resolvers/highlight/index.ts +++ b/packages/api/src/resolvers/highlight/index.ts @@ -3,7 +3,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { DeepPartial } from 'typeorm' import { - Highlight as HighlightData, + Highlight as HighlightEntity, HighlightType, RepresentationType, } from '../../entity/highlight' @@ -16,6 +16,10 @@ import { DeleteHighlightError, DeleteHighlightErrorCode, DeleteHighlightSuccess, + HighlightEdge, + HighlightsError, + HighlightsErrorCode, + HighlightsSuccess, MergeHighlightError, MergeHighlightErrorCode, MergeHighlightSuccess, @@ -23,6 +27,7 @@ import { MutationDeleteHighlightArgs, MutationMergeHighlightArgs, MutationUpdateHighlightArgs, + QueryHighlightsArgs, UpdateHighlightError, UpdateHighlightErrorCode, UpdateHighlightSuccess, @@ -32,14 +37,15 @@ import { createHighlight, deleteHighlightById, mergeHighlights, + searchHighlights, updateHighlight, } from '../../services/highlights' +import { Merge } from '../../util' import { analytics } from '../../utils/analytics' import { authorized } from '../../utils/gql-utils' -import { highlightDataToHighlight } from '../../utils/helpers' export const createHighlightResolver = authorized< - CreateHighlightSuccess, + Merge, CreateHighlightError, MutationCreateHighlightArgs >(async (_, { input }, { log, pubsub, uid }) => { @@ -68,7 +74,7 @@ export const createHighlightResolver = authorized< }, }) - return { highlight: highlightDataToHighlight(newHighlight) } + return { highlight: newHighlight } } catch (err) { log.error('Error creating highlight', err) return { @@ -78,7 +84,7 @@ export const createHighlightResolver = authorized< }) export const mergeHighlightResolver = authorized< - MergeHighlightSuccess, + Merge, MergeHighlightError, MutationMergeHighlightArgs >(async (_, { input }, { authTrx, log, pubsub, uid }) => { @@ -123,7 +129,7 @@ export const mergeHighlightResolver = authorized< const color = newHighlightInput.color || mergedColors[mergedColors.length - 1] - const highlight: DeepPartial = { + const highlight: DeepPartial = { ...newHighlightInput, annotation: mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null, @@ -154,7 +160,7 @@ export const mergeHighlightResolver = authorized< }) return { - highlight: highlightDataToHighlight(newHighlight), + highlight: newHighlight, overlapHighlightIdList: input.overlapHighlightIdList, } } catch (e) { @@ -166,7 +172,7 @@ export const mergeHighlightResolver = authorized< }) export const updateHighlightResolver = authorized< - UpdateHighlightSuccess, + Merge, UpdateHighlightError, MutationUpdateHighlightArgs >(async (_, { input }, { pubsub, uid, log }) => { @@ -183,7 +189,7 @@ export const updateHighlightResolver = authorized< pubsub ) - return { highlight: highlightDataToHighlight(updatedHighlight) } + return { highlight: updatedHighlight } } catch (error) { log.error('updateHighlightResolver error', error) return { @@ -193,7 +199,7 @@ export const updateHighlightResolver = authorized< }) export const deleteHighlightResolver = authorized< - DeleteHighlightSuccess, + Merge, DeleteHighlightError, MutationDeleteHighlightArgs >(async (_, { highlightId }, { log }) => { @@ -206,7 +212,7 @@ export const deleteHighlightResolver = authorized< } } - return { highlight: highlightDataToHighlight(deletedHighlight) } + return { highlight: deletedHighlight } } catch (error) { log.error('deleteHighlightResolver error', error) return { @@ -215,53 +221,54 @@ export const deleteHighlightResolver = authorized< } }) -// export const setShareHighlightResolver = authorized< -// SetShareHighlightSuccess, -// SetShareHighlightError, -// MutationSetShareHighlightArgs -// >(async (_, { input: { id, share } }, { pubsub, claims, log }) => { -// const highlight = await getHighlightById(id) +type PartialHighlightEdge = Merge< + HighlightEdge, + { + node: HighlightEntity + } +> +type PartialHighlightsSuccess = Merge< + HighlightsSuccess, + { + edges: PartialHighlightEdge[] + } +> +export const highlightsResolver = authorized< + PartialHighlightsSuccess, + HighlightsError, + QueryHighlightsArgs +>(async (_, { after, first }, { uid, log }) => { + const limit = first || 10 + const offset = parseInt(after || '0') + if (isNaN(offset) || offset < 0) { + log.error('Invalid after', { after }) -// if (!highlight?.id) { -// return { -// errorCodes: [SetShareHighlightErrorCode.NotFound], -// } -// } + return { + errorCodes: [HighlightsErrorCode.BadRequest], + } + } -// if (highlight.userId !== claims.uid) { -// return { -// errorCodes: [SetShareHighlightErrorCode.Forbidden], -// } -// } + const highlights = await searchHighlights(uid, limit + 1, offset) -// const sharedAt = share ? new Date() : null + const start = offset + const hasNextPage = highlights.length > limit + if (hasNextPage) { + highlights.pop() + } + const endCursor = String(start + highlights.length) -// log.info(`${share ? 'S' : 'Uns'}haring a highlight`, { -// highlight, -// labels: { -// source: 'resolver', -// resolver: 'setShareHighlightResolver', -// userId: highlight.userId, -// }, -// }) + const edges = highlights.map((highlight) => ({ + cursor: endCursor, + node: highlight, + })) -// const updatedHighlight: HighlightData = { -// ...highlight, -// sharedAt, -// updatedAt: new Date(), -// } - -// const updated = await updateHighlight(updatedHighlight, { -// pubsub, -// uid: claims.uid, -// refresh: true, -// }) - -// if (!updated) { -// return { -// errorCodes: [SetShareHighlightErrorCode.NotFound], -// } -// } - -// return { highlight: highlightDataToHighlight(updatedHighlight) } -// }) + return { + edges, + pageInfo: { + startCursor: String(start), + endCursor, + hasPreviousPage: start > 0, + hasNextPage, + }, + } +}) diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index 7aaf46322..cc833e556 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -13,6 +13,7 @@ import { PublicItem } from '../entity/public_item' import { Recommendation } from '../entity/recommendation' import { Subscription } from '../entity/subscription' import { UploadFile } from '../entity/upload_file' +import { User } from '../entity/user' import { HomeItem } from '../generated/graphql' import { PubsubClient } from '../pubsub' @@ -58,6 +59,7 @@ export interface RequestContext { libraryItems: DataLoader publicItems: DataLoader subscriptions: DataLoader + users: DataLoader } } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 14cdeddcd..e7d48d5a4 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3226,6 +3226,26 @@ const schema = gql` PENDING } + union HighlightsResult = HighlightsSuccess | HighlightsError + + type HighlightsSuccess { + edges: [HighlightEdge!]! + pageInfo: PageInfo! + } + + type HighlightEdge { + cursor: String! + node: Highlight! + } + + type HighlightsError { + errorCodes: [HighlightsErrorCode!]! + } + + enum HighlightsErrorCode { + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -3425,6 +3445,7 @@ const schema = gql` home(first: Int, after: String): HomeResult! subscription(id: ID!): SubscriptionResult! hiddenHomeSection: HiddenHomeSectionResult! + highlights(after: String, first: Int, query: String): HighlightsResult! } schema { diff --git a/packages/api/src/services/highlights.ts b/packages/api/src/services/highlights.ts index dd6685362..2b2c89b71 100644 --- a/packages/api/src/services/highlights.ts +++ b/packages/api/src/services/highlights.ts @@ -1,5 +1,5 @@ import { diff_match_patch } from 'diff-match-patch' -import { DeepPartial, In } from 'typeorm' +import { DeepPartial, In, LessThan } from 'typeorm' import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity' import { EntityLabel } from '../entity/entity_label' import { Highlight } from '../entity/highlight' @@ -260,3 +260,20 @@ export const findHighlightsByLibraryItemId = async ( userId ) } + +export const searchHighlights = async ( + userId: string, + limit: number, + offset?: number +) => { + return authTrx( + async (tx) => + tx.withRepository(highlightRepository).find({ + where: { user: { id: userId } }, + take: limit, + skip: offset, + }), + undefined, + userId + ) +} diff --git a/packages/api/src/services/user.ts b/packages/api/src/services/user.ts index 3eabe002a..171b92f1d 100644 --- a/packages/api/src/services/user.ts +++ b/packages/api/src/services/user.ts @@ -60,7 +60,7 @@ export const findActiveUser = async (id: string): Promise => { return userRepository.findOneBy({ id, status: StatusType.Active }) } -export const findUsersById = async (ids: string[]): Promise => { +export const findUsersByIds = async (ids: string[]): Promise => { return userRepository.findBy({ id: In(ids) }) } diff --git a/packages/api/test/routers/user.test.ts b/packages/api/test/routers/user.test.ts index d6ca4a003..33b874efe 100644 --- a/packages/api/test/routers/user.test.ts +++ b/packages/api/test/routers/user.test.ts @@ -5,7 +5,7 @@ import { StatusType } from '../../src/entity/user' import { createUsers, deleteUsers, - findUsersById, + findUsersByIds, } from '../../src/services/user' import { request } from '../util' @@ -58,7 +58,7 @@ describe('User Service Router', () => { .send(data) .expect(200) - const deletedUsers = await findUsersById(toDeleteUserIds) + const deletedUsers = await findUsersByIds(toDeleteUserIds) expect(deletedUsers.length).to.equal(0) }) })