feat: highlights api

This commit is contained in:
Hongbo Wu 2024-06-05 20:44:18 +08:00
parent 05cfd4b272
commit 0013150c26
11 changed files with 224 additions and 84 deletions

View file

@ -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<ExpressContext, ResolverContext> = async ({
return batchGetSubscriptionsByNames(claims.uid, names as string[])
}),
users: new DataLoader(async (ids: readonly string[]) =>
findUsersByIds(ids as string[])
),
},
}

View file

@ -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

View file

@ -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<HighlightsErrorCode>;
};
export enum HighlightsErrorCode {
BadRequest = 'BAD_REQUEST'
}
export type HighlightsResult = HighlightsError | HighlightsSuccess;
export type HighlightsSuccess = {
__typename?: 'HighlightsSuccess';
edges: Array<HighlightEdge>;
pageInfo: PageInfo;
};
export type HomeEdge = {
__typename?: 'HomeEdge';
cursor: Scalars['String'];
@ -2261,6 +2284,7 @@ export type Query = {
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
hiddenHomeSection: HiddenHomeSectionResult;
highlights: HighlightsResult;
home: HomeResult;
integration: IntegrationResult;
integrations: IntegrationsResult;
@ -2311,6 +2335,13 @@ export type QueryGetDiscoverFeedArticlesArgs = {
};
export type QueryHighlightsArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
query?: InputMaybe<Scalars['String']>;
};
export type QueryHomeArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
@ -4331,9 +4362,14 @@ export type ResolversTypes = {
HiddenHomeSectionResult: ResolversTypes['HiddenHomeSectionError'] | ResolversTypes['HiddenHomeSectionSuccess'];
HiddenHomeSectionSuccess: ResolverTypeWrapper<HiddenHomeSectionSuccess>;
Highlight: ResolverTypeWrapper<Highlight>;
HighlightEdge: ResolverTypeWrapper<HighlightEdge>;
HighlightReply: ResolverTypeWrapper<HighlightReply>;
HighlightStats: ResolverTypeWrapper<HighlightStats>;
HighlightType: HighlightType;
HighlightsError: ResolverTypeWrapper<HighlightsError>;
HighlightsErrorCode: HighlightsErrorCode;
HighlightsResult: ResolversTypes['HighlightsError'] | ResolversTypes['HighlightsSuccess'];
HighlightsSuccess: ResolverTypeWrapper<HighlightsSuccess>;
HomeEdge: ResolverTypeWrapper<HomeEdge>;
HomeError: ResolverTypeWrapper<HomeError>;
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<ContextType = ResolverContext, ParentType extends
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightEdge'] = ResolversParentTypes['HighlightEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['Highlight'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightReplyResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightReply'] = ResolversParentTypes['HighlightReply']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
highlight?: Resolver<ResolversTypes['Highlight'], ParentType, ContextType>;
@ -6118,6 +6164,21 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsError'] = ResolversParentTypes['HighlightsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['HighlightsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsResult'] = ResolversParentTypes['HighlightsResult']> = {
__resolveType: TypeResolveFn<'HighlightsError' | 'HighlightsSuccess', ParentType, ContextType>;
};
export type HighlightsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsSuccess'] = ResolversParentTypes['HighlightsSuccess']> = {
edges?: Resolver<Array<ResolversTypes['HighlightEdge']>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HomeEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeEdge'] = ResolversParentTypes['HomeEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['HomeSection'], ParentType, ContextType>;
@ -6581,6 +6642,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
hiddenHomeSection?: Resolver<ResolversTypes['HiddenHomeSectionResult'], ParentType, ContextType>;
highlights?: Resolver<ResolversTypes['HighlightsResult'], ParentType, ContextType, Partial<QueryHighlightsArgs>>;
home?: Resolver<ResolversTypes['HomeResult'], ParentType, ContextType, Partial<QueryHomeArgs>>;
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'name'>>;
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
@ -7797,8 +7859,12 @@ export type Resolvers<ContextType = ResolverContext> = {
HiddenHomeSectionResult?: HiddenHomeSectionResultResolvers<ContextType>;
HiddenHomeSectionSuccess?: HiddenHomeSectionSuccessResolvers<ContextType>;
Highlight?: HighlightResolvers<ContextType>;
HighlightEdge?: HighlightEdgeResolvers<ContextType>;
HighlightReply?: HighlightReplyResolvers<ContextType>;
HighlightStats?: HighlightStatsResolvers<ContextType>;
HighlightsError?: HighlightsErrorResolvers<ContextType>;
HighlightsResult?: HighlightsResultResolvers<ContextType>;
HighlightsSuccess?: HighlightsSuccessResolvers<ContextType>;
HomeEdge?: HomeEdgeResolvers<ContextType>;
HomeError?: HomeErrorResolvers<ContextType>;
HomeItem?: HomeItemResolvers<ContextType>;

View file

@ -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!

View file

@ -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'),
}

View file

@ -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<CreateHighlightSuccess, { highlight: HighlightEntity }>,
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<MergeHighlightSuccess, { highlight: HighlightEntity }>,
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<HighlightData> = {
const highlight: DeepPartial<HighlightEntity> = {
...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<UpdateHighlightSuccess, { highlight: HighlightEntity }>,
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<DeleteHighlightSuccess, { highlight: HighlightEntity }>,
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,
},
}
})

View file

@ -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<string, LibraryItem | undefined>
publicItems: DataLoader<string, PublicItem | undefined>
subscriptions: DataLoader<string, Subscription | undefined>
users: DataLoader<string, User | undefined>
}
}

View file

@ -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 {

View file

@ -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
)
}

View file

@ -60,7 +60,7 @@ export const findActiveUser = async (id: string): Promise<User | null> => {
return userRepository.findOneBy({ id, status: StatusType.Active })
}
export const findUsersById = async (ids: string[]): Promise<User[]> => {
export const findUsersByIds = async (ids: string[]): Promise<User[]> => {
return userRepository.findBy({ id: In(ids) })
}

View file

@ -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)
})
})