Merge pull request #1036 from omnivore-app/feature/readwise-integration-api

feature/readwise integration api
This commit is contained in:
Hongbo Wu 2022-08-09 10:26:35 +08:00 committed by GitHub
commit 7b3eb56d33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 2050 additions and 230 deletions

View file

@ -46,6 +46,7 @@
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dataloader": "^2.0.0",
"diff-match-patch": "^1.0.5",
"dompurify": "^2.0.17",
"dot-case": "^3.0.4",
"dotenv": "^8.2.0",
@ -96,6 +97,7 @@
"@types/chai-string": "^1.4.2",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/diff-match-patch": "^1.0.32",
"@types/dompurify": "^2.0.4",
"@types/express": "^4.17.7",
"@types/graphql-fields": "^1.3.4",

View file

@ -111,7 +111,7 @@ interface PubSubRequestMessage {
publishTime: string
}
interface PubSubRequestBody {
export interface PubSubRequestBody {
message: PubSubRequestMessage
}

View file

@ -274,7 +274,7 @@ export const deletePage = async (
}
export const getPageByParam = async <K extends keyof ParamSet>(
param: Record<K, Page[K]>,
param: Record<K, ParamSet[K]>,
includeOriginalHtml = false
): Promise<Page | undefined> => {
try {
@ -506,7 +506,7 @@ export const countByCreatedAt = async (
}
export const deletePagesByParam = async <K extends keyof ParamSet>(
param: Record<K, Page[K]>,
param: Record<K, ParamSet[K]>,
ctx: PageContext
): Promise<boolean> => {
try {

View file

@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
export enum IntegrationType {
Readwise = 'READWISE',
}
@Entity({ name: 'integrations' })
export class Integration {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('enum', { enum: IntegrationType })
type!: IntegrationType
@Column('varchar', { length: 255 })
token!: string
@Column('boolean', { default: true })
enabled!: boolean
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('timestamp', { nullable: true })
syncedAt?: Date | null
@Column('text', { nullable: true })
taskName?: string | null
}

View file

@ -479,6 +479,24 @@ export type DeleteHighlightSuccess = {
highlight: Highlight;
};
export type DeleteIntegrationError = {
__typename?: 'DeleteIntegrationError';
errorCodes: Array<DeleteIntegrationErrorCode>;
};
export enum DeleteIntegrationErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type DeleteIntegrationResult = DeleteIntegrationError | DeleteIntegrationSuccess;
export type DeleteIntegrationSuccess = {
__typename?: 'DeleteIntegrationSuccess';
integration: Integration;
};
export type DeleteLabelError = {
__typename?: 'DeleteLabelError';
errorCodes: Array<DeleteLabelErrorCode>;
@ -745,6 +763,37 @@ export type HighlightStats = {
highlightCount: Scalars['Int'];
};
export type Integration = {
__typename?: 'Integration';
createdAt: Scalars['Date'];
enabled: Scalars['Boolean'];
id: Scalars['ID'];
token: Scalars['String'];
type: IntegrationType;
updatedAt: Scalars['Date'];
};
export enum IntegrationType {
Readwise = 'READWISE'
}
export type IntegrationsError = {
__typename?: 'IntegrationsError';
errorCodes: Array<IntegrationsErrorCode>;
};
export enum IntegrationsErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type IntegrationsResult = IntegrationsError | IntegrationsSuccess;
export type IntegrationsSuccess = {
__typename?: 'IntegrationsSuccess';
integrations: Array<Integration>;
};
export type Label = {
__typename?: 'Label';
color: Scalars['String'];
@ -904,6 +953,7 @@ export type Mutation = {
deleteAccount: DeleteAccountResult;
deleteHighlight: DeleteHighlightResult;
deleteHighlightReply: DeleteHighlightReplyResult;
deleteIntegration: DeleteIntegrationResult;
deleteLabel: DeleteLabelResult;
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteReaction: DeleteReactionResult;
@ -924,6 +974,7 @@ export type Mutation = {
setBookmarkArticle: SetBookmarkArticleResult;
setDeviceToken: SetDeviceTokenResult;
setFollow: SetFollowResult;
setIntegration: SetIntegrationResult;
setLabels: SetLabelsResult;
setLabelsForHighlight: SetLabelsResult;
setLinkArchived: ArchiveLinkResult;
@ -1001,6 +1052,11 @@ export type MutationDeleteHighlightReplyArgs = {
};
export type MutationDeleteIntegrationArgs = {
id: Scalars['ID'];
};
export type MutationDeleteLabelArgs = {
id: Scalars['ID'];
};
@ -1096,6 +1152,11 @@ export type MutationSetFollowArgs = {
};
export type MutationSetIntegrationArgs = {
input: SetIntegrationInput;
};
export type MutationSetLabelsArgs = {
input: SetLabelsInput;
};
@ -1286,6 +1347,7 @@ export type Query = {
getFollowing: GetFollowingResult;
getUserPersonalization: GetUserPersonalizationResult;
hello?: Maybe<Scalars['String']>;
integrations: IntegrationsResult;
labels: LabelsResult;
me?: Maybe<User>;
newsletterEmails: NewsletterEmailsResult;
@ -1694,6 +1756,33 @@ export type SetFollowSuccess = {
updatedUser: User;
};
export type SetIntegrationError = {
__typename?: 'SetIntegrationError';
errorCodes: Array<SetIntegrationErrorCode>;
};
export enum SetIntegrationErrorCode {
AlreadyExists = 'ALREADY_EXISTS',
BadRequest = 'BAD_REQUEST',
InvalidToken = 'INVALID_TOKEN',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SetIntegrationInput = {
enabled: Scalars['Boolean'];
id?: InputMaybe<Scalars['ID']>;
token: Scalars['String'];
type: IntegrationType;
};
export type SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess;
export type SetIntegrationSuccess = {
__typename?: 'SetIntegrationSuccess';
integration: Integration;
};
export type SetLabelsError = {
__typename?: 'SetLabelsError';
errorCodes: Array<SetLabelsErrorCode>;
@ -2537,6 +2626,10 @@ export type ResolversTypes = {
DeleteHighlightReplySuccess: ResolverTypeWrapper<DeleteHighlightReplySuccess>;
DeleteHighlightResult: ResolversTypes['DeleteHighlightError'] | ResolversTypes['DeleteHighlightSuccess'];
DeleteHighlightSuccess: ResolverTypeWrapper<DeleteHighlightSuccess>;
DeleteIntegrationError: ResolverTypeWrapper<DeleteIntegrationError>;
DeleteIntegrationErrorCode: DeleteIntegrationErrorCode;
DeleteIntegrationResult: ResolversTypes['DeleteIntegrationError'] | ResolversTypes['DeleteIntegrationSuccess'];
DeleteIntegrationSuccess: ResolverTypeWrapper<DeleteIntegrationSuccess>;
DeleteLabelError: ResolverTypeWrapper<DeleteLabelError>;
DeleteLabelErrorCode: DeleteLabelErrorCode;
DeleteLabelResult: ResolversTypes['DeleteLabelError'] | ResolversTypes['DeleteLabelSuccess'];
@ -2592,6 +2685,12 @@ export type ResolversTypes = {
HighlightStats: ResolverTypeWrapper<HighlightStats>;
ID: ResolverTypeWrapper<Scalars['ID']>;
Int: ResolverTypeWrapper<Scalars['Int']>;
Integration: ResolverTypeWrapper<Integration>;
IntegrationType: IntegrationType;
IntegrationsError: ResolverTypeWrapper<IntegrationsError>;
IntegrationsErrorCode: IntegrationsErrorCode;
IntegrationsResult: ResolversTypes['IntegrationsError'] | ResolversTypes['IntegrationsSuccess'];
IntegrationsSuccess: ResolverTypeWrapper<IntegrationsSuccess>;
Label: ResolverTypeWrapper<Label>;
LabelsError: ResolverTypeWrapper<LabelsError>;
LabelsErrorCode: LabelsErrorCode;
@ -2682,6 +2781,11 @@ export type ResolversTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversTypes['SetFollowError'] | ResolversTypes['SetFollowSuccess'];
SetFollowSuccess: ResolverTypeWrapper<SetFollowSuccess>;
SetIntegrationError: ResolverTypeWrapper<SetIntegrationError>;
SetIntegrationErrorCode: SetIntegrationErrorCode;
SetIntegrationInput: SetIntegrationInput;
SetIntegrationResult: ResolversTypes['SetIntegrationError'] | ResolversTypes['SetIntegrationSuccess'];
SetIntegrationSuccess: ResolverTypeWrapper<SetIntegrationSuccess>;
SetLabelsError: ResolverTypeWrapper<SetLabelsError>;
SetLabelsErrorCode: SetLabelsErrorCode;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
@ -2884,6 +2988,9 @@ export type ResolversParentTypes = {
DeleteHighlightReplySuccess: DeleteHighlightReplySuccess;
DeleteHighlightResult: ResolversParentTypes['DeleteHighlightError'] | ResolversParentTypes['DeleteHighlightSuccess'];
DeleteHighlightSuccess: DeleteHighlightSuccess;
DeleteIntegrationError: DeleteIntegrationError;
DeleteIntegrationResult: ResolversParentTypes['DeleteIntegrationError'] | ResolversParentTypes['DeleteIntegrationSuccess'];
DeleteIntegrationSuccess: DeleteIntegrationSuccess;
DeleteLabelError: DeleteLabelError;
DeleteLabelResult: ResolversParentTypes['DeleteLabelError'] | ResolversParentTypes['DeleteLabelSuccess'];
DeleteLabelSuccess: DeleteLabelSuccess;
@ -2929,6 +3036,10 @@ export type ResolversParentTypes = {
HighlightStats: HighlightStats;
ID: Scalars['ID'];
Int: Scalars['Int'];
Integration: Integration;
IntegrationsError: IntegrationsError;
IntegrationsResult: ResolversParentTypes['IntegrationsError'] | ResolversParentTypes['IntegrationsSuccess'];
IntegrationsSuccess: IntegrationsSuccess;
Label: Label;
LabelsError: LabelsError;
LabelsResult: ResolversParentTypes['LabelsError'] | ResolversParentTypes['LabelsSuccess'];
@ -3001,6 +3112,10 @@ export type ResolversParentTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess'];
SetFollowSuccess: SetFollowSuccess;
SetIntegrationError: SetIntegrationError;
SetIntegrationInput: SetIntegrationInput;
SetIntegrationResult: ResolversParentTypes['SetIntegrationError'] | ResolversParentTypes['SetIntegrationSuccess'];
SetIntegrationSuccess: SetIntegrationSuccess;
SetLabelsError: SetLabelsError;
SetLabelsForHighlightInput: SetLabelsForHighlightInput;
SetLabelsInput: SetLabelsInput;
@ -3429,6 +3544,20 @@ export type DeleteHighlightSuccessResolvers<ContextType = ResolverContext, Paren
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteIntegrationError'] = ResolversParentTypes['DeleteIntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteIntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteIntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteIntegrationResult'] = ResolversParentTypes['DeleteIntegrationResult']> = {
__resolveType: TypeResolveFn<'DeleteIntegrationError' | 'DeleteIntegrationSuccess', ParentType, ContextType>;
};
export type DeleteIntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteIntegrationSuccess'] = ResolversParentTypes['DeleteIntegrationSuccess']> = {
integration?: Resolver<ResolversTypes['Integration'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteLabelErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteLabelError'] = ResolversParentTypes['DeleteLabelError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteLabelErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3644,6 +3773,30 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type IntegrationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Integration'] = ResolversParentTypes['Integration']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
enabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['IntegrationType'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type IntegrationsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationsError'] = ResolversParentTypes['IntegrationsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['IntegrationsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type IntegrationsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationsResult'] = ResolversParentTypes['IntegrationsResult']> = {
__resolveType: TypeResolveFn<'IntegrationsError' | 'IntegrationsSuccess', ParentType, ContextType>;
};
export type IntegrationsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationsSuccess'] = ResolversParentTypes['IntegrationsSuccess']> = {
integrations?: Resolver<Array<ResolversTypes['Integration']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type LabelResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Label'] = ResolversParentTypes['Label']> = {
color?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
@ -3762,6 +3915,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
deleteHighlightReply?: Resolver<ResolversTypes['DeleteHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightReplyArgs, 'highlightReplyId'>>;
deleteIntegration?: Resolver<ResolversTypes['DeleteIntegrationResult'], ParentType, ContextType, RequireFields<MutationDeleteIntegrationArgs, 'id'>>;
deleteLabel?: Resolver<ResolversTypes['DeleteLabelResult'], ParentType, ContextType, RequireFields<MutationDeleteLabelArgs, 'id'>>;
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
@ -3782,6 +3936,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setIntegration?: Resolver<ResolversTypes['SetIntegrationResult'], ParentType, ContextType, RequireFields<MutationSetIntegrationArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
setLabelsForHighlight?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsForHighlightArgs, 'input'>>;
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
@ -3870,6 +4025,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
getFollowing?: Resolver<ResolversTypes['GetFollowingResult'], ParentType, ContextType, Partial<QueryGetFollowingArgs>>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
@ -4088,6 +4244,20 @@ export type SetFollowSuccessResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationError'] = ResolversParentTypes['SetIntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetIntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetIntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationResult'] = ResolversParentTypes['SetIntegrationResult']> = {
__resolveType: TypeResolveFn<'SetIntegrationError' | 'SetIntegrationSuccess', ParentType, ContextType>;
};
export type SetIntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetIntegrationSuccess'] = ResolversParentTypes['SetIntegrationSuccess']> = {
integration?: Resolver<ResolversTypes['Integration'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetLabelsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsError'] = ResolversParentTypes['SetLabelsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetLabelsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4578,6 +4748,9 @@ export type Resolvers<ContextType = ResolverContext> = {
DeleteHighlightReplySuccess?: DeleteHighlightReplySuccessResolvers<ContextType>;
DeleteHighlightResult?: DeleteHighlightResultResolvers<ContextType>;
DeleteHighlightSuccess?: DeleteHighlightSuccessResolvers<ContextType>;
DeleteIntegrationError?: DeleteIntegrationErrorResolvers<ContextType>;
DeleteIntegrationResult?: DeleteIntegrationResultResolvers<ContextType>;
DeleteIntegrationSuccess?: DeleteIntegrationSuccessResolvers<ContextType>;
DeleteLabelError?: DeleteLabelErrorResolvers<ContextType>;
DeleteLabelResult?: DeleteLabelResultResolvers<ContextType>;
DeleteLabelSuccess?: DeleteLabelSuccessResolvers<ContextType>;
@ -4617,6 +4790,10 @@ export type Resolvers<ContextType = ResolverContext> = {
Highlight?: HighlightResolvers<ContextType>;
HighlightReply?: HighlightReplyResolvers<ContextType>;
HighlightStats?: HighlightStatsResolvers<ContextType>;
Integration?: IntegrationResolvers<ContextType>;
IntegrationsError?: IntegrationsErrorResolvers<ContextType>;
IntegrationsResult?: IntegrationsResultResolvers<ContextType>;
IntegrationsSuccess?: IntegrationsSuccessResolvers<ContextType>;
Label?: LabelResolvers<ContextType>;
LabelsError?: LabelsErrorResolvers<ContextType>;
LabelsResult?: LabelsResultResolvers<ContextType>;
@ -4677,6 +4854,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SetFollowError?: SetFollowErrorResolvers<ContextType>;
SetFollowResult?: SetFollowResultResolvers<ContextType>;
SetFollowSuccess?: SetFollowSuccessResolvers<ContextType>;
SetIntegrationError?: SetIntegrationErrorResolvers<ContextType>;
SetIntegrationResult?: SetIntegrationResultResolvers<ContextType>;
SetIntegrationSuccess?: SetIntegrationSuccessResolvers<ContextType>;
SetLabelsError?: SetLabelsErrorResolvers<ContextType>;
SetLabelsResult?: SetLabelsResultResolvers<ContextType>;
SetLabelsSuccess?: SetLabelsSuccessResolvers<ContextType>;

View file

@ -420,6 +420,22 @@ type DeleteHighlightSuccess {
highlight: Highlight!
}
type DeleteIntegrationError {
errorCodes: [DeleteIntegrationErrorCode!]!
}
enum DeleteIntegrationErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
union DeleteIntegrationResult = DeleteIntegrationError | DeleteIntegrationSuccess
type DeleteIntegrationSuccess {
integration: Integration!
}
type DeleteLabelError {
errorCodes: [DeleteLabelErrorCode!]!
}
@ -658,6 +674,34 @@ type HighlightStats {
highlightCount: Int!
}
type Integration {
createdAt: Date!
enabled: Boolean!
id: ID!
token: String!
type: IntegrationType!
updatedAt: Date!
}
enum IntegrationType {
READWISE
}
type IntegrationsError {
errorCodes: [IntegrationsErrorCode!]!
}
enum IntegrationsErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union IntegrationsResult = IntegrationsError | IntegrationsSuccess
type IntegrationsSuccess {
integrations: [Integration!]!
}
type Label {
color: String!
createdAt: Date
@ -803,6 +847,7 @@ type Mutation {
deleteAccount(userID: ID!): DeleteAccountResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult!
deleteIntegration(id: ID!): DeleteIntegrationResult!
deleteLabel(id: ID!): DeleteLabelResult!
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteReaction(id: ID!): DeleteReactionResult!
@ -823,6 +868,7 @@ type Mutation {
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
setFollow(input: SetFollowInput!): SetFollowResult!
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
@ -933,6 +979,7 @@ type Query {
getFollowing(userId: ID): GetFollowingResult!
getUserPersonalization: GetUserPersonalizationResult!
hello: String
integrations: IntegrationsResult!
labels: LabelsResult!
me: User
newsletterEmails: NewsletterEmailsResult!
@ -1225,6 +1272,31 @@ type SetFollowSuccess {
updatedUser: User!
}
type SetIntegrationError {
errorCodes: [SetIntegrationErrorCode!]!
}
enum SetIntegrationErrorCode {
ALREADY_EXISTS
BAD_REQUEST
INVALID_TOKEN
NOT_FOUND
UNAUTHORIZED
}
input SetIntegrationInput {
enabled: Boolean!
id: ID
token: String!
type: IntegrationType!
}
union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess
type SetIntegrationSuccess {
integration: Integration!
}
type SetLabelsError {
errorCodes: [SetLabelsErrorCode!]!
}

View file

@ -32,6 +32,7 @@ import {
createReminderResolver,
deleteAccountResolver,
deleteHighlightResolver,
deleteIntegrationResolver,
deleteLabelResolver,
deleteNewsletterEmailResolver,
deleteReminderResolver,
@ -49,6 +50,7 @@ import {
getUserResolver,
googleLoginResolver,
googleSignupResolver,
integrationsResolver,
labelsResolver,
logOutResolver,
mergeHighlightResolver,
@ -66,6 +68,7 @@ import {
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFollowResolver,
setIntegrationResolver,
setLabelsForHighlightResolver,
setLabelsResolver,
setLinkArchivedResolver,
@ -165,6 +168,8 @@ export const functionResolvers = {
revokeApiKey: revokeApiKeyResolver,
setLabelsForHighlight: setLabelsForHighlightResolver,
moveLabel: moveLabelResolver,
setIntegration: setIntegrationResolver,
deleteIntegration: deleteIntegrationResolver,
},
Query: {
me: getMeUserResolver,
@ -190,6 +195,7 @@ export const functionResolvers = {
apiKeys: apiKeysResolver,
typeaheadSearch: typeaheadSearchResolver,
updatesSince: updatesSinceResolver,
integrations: integrationsResolver,
},
User: {
async sharedArticles(
@ -592,4 +598,7 @@ export const functionResolvers = {
...resultResolveTypeResolver('TypeaheadSearch'),
...resultResolveTypeResolver('UpdatesSince'),
...resultResolveTypeResolver('MoveLabel'),
...resultResolveTypeResolver('SetIntegration'),
...resultResolveTypeResolver('Integrations'),
...resultResolveTypeResolver('DeleteIntegration'),
}

View file

@ -20,3 +20,4 @@ export * from './update'
export * from './popular_reads'
export * from './webhooks'
export * from './api_key'
export * from './integrations'

View file

@ -0,0 +1,221 @@
import { authorized } from '../../utils/helpers'
import {
DeleteIntegrationError,
DeleteIntegrationErrorCode,
DeleteIntegrationSuccess,
IntegrationsError,
IntegrationsErrorCode,
IntegrationsSuccess,
MutationDeleteIntegrationArgs,
MutationSetIntegrationArgs,
SetIntegrationError,
SetIntegrationErrorCode,
SetIntegrationSuccess,
} from '../../generated/graphql'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
import { Integration } from '../../entity/integration'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { validateToken } from '../../services/integrations'
import { deleteTask, enqueueSyncWithIntegration } from '../../utils/createTask'
export const setIntegrationResolver = authorized<
SetIntegrationSuccess,
SetIntegrationError,
MutationSetIntegrationArgs
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('setIntegrationResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SetIntegrationErrorCode.Unauthorized],
}
}
let integrationToSave: Partial<Integration> = {
user,
}
if (input.id) {
// Update
const existingIntegration = await getRepository(Integration).findOne({
where: { id: input.id },
relations: ['user'],
})
if (!existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.NotFound],
}
}
if (existingIntegration.user.id !== uid) {
return {
errorCodes: [SetIntegrationErrorCode.Unauthorized],
}
}
if (existingIntegration.enabled === input.enabled) {
return {
integration: existingIntegration,
}
}
integrationToSave = {
...integrationToSave,
id: existingIntegration.id,
taskName: existingIntegration.taskName,
enabled: input.enabled,
}
} else {
// Create
const existingIntegration = await getRepository(Integration).findOneBy({
user: { id: uid },
type: input.type,
})
if (existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.AlreadyExists],
}
}
// validate token
if (!(await validateToken(input.token, input.type))) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
}
integrationToSave = {
...integrationToSave,
token: input.token,
type: input.type,
enabled: true,
}
}
if (!integrationToSave.id || integrationToSave.enabled) {
// create a task to sync all the pages if new integration or enable integration
const taskName = await enqueueSyncWithIntegration(user.id, input.type)
log.info('enqueued task', taskName)
integrationToSave.taskName = taskName
} else if (integrationToSave.taskName) {
// delete the task if disable integration and task exists
await deleteTask(integrationToSave.taskName)
integrationToSave.taskName = null
log.info('task deleted', integrationToSave.taskName)
}
const integration = await getRepository(Integration).save(integrationToSave)
analytics.track({
userId: uid,
event: 'integration_set',
properties: {
id: integrationToSave.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
})
export const integrationsResolver = authorized<
IntegrationsSuccess,
IntegrationsError
>(async (_, __, { claims: { uid }, log }) => {
log.info('integrationsResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [IntegrationsErrorCode.Unauthorized],
}
}
const integrations = await getRepository(Integration).findBy({
user: { id: uid },
})
return {
integrations,
}
} catch (error) {
log.error(error)
return {
errorCodes: [IntegrationsErrorCode.BadRequest],
}
}
})
export const deleteIntegrationResolver = authorized<
DeleteIntegrationSuccess,
DeleteIntegrationError,
MutationDeleteIntegrationArgs
>(async (_, { id }, { claims: { uid }, log }) => {
log.info('deleteIntegrationResolver')
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [DeleteIntegrationErrorCode.Unauthorized],
}
}
const integration = await getRepository(Integration).findOne({
where: { id },
relations: ['user'],
})
if (!integration) {
return {
errorCodes: [DeleteIntegrationErrorCode.NotFound],
}
}
if (integration.user.id !== uid) {
return {
errorCodes: [DeleteIntegrationErrorCode.Unauthorized],
}
}
if (integration.taskName) {
// delete the task if task exists
await deleteTask(integration.taskName)
log.info('task deleted', integration.taskName)
}
const deletedIntegration = await getRepository(Integration).remove(
integration
)
deletedIntegration.id = id
analytics.track({
userId: uid,
event: 'integration_delete',
properties: {
integrationId: deletedIntegration.id,
env: env.server.apiEnv,
},
})
return {
integration: deletedIntegration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [DeleteIntegrationErrorCode.BadRequest],
}
}
})

View file

@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import express from 'express'
import { EntityType, readPushSubscription } from '../../datalayer/pubsub'
import { getRepository } from '../../entity/utils'
import { Integration, IntegrationType } from '../../entity/integration'
import { buildLogger } from '../../utils/logger'
import { syncWithIntegration } from '../../services/integrations'
import { getPageById, searchPages } from '../../elastic/pages'
import { Page } from '../../elastic/types'
import { DateFilter } from '../../utils/search'
export interface Message {
type?: EntityType
data?: any
userId: string
}
const logger = buildLogger('app.dispatch')
export function integrationsServiceRouter() {
const router = express.Router()
router.post('/:integrationType/:action', async (req, res) => {
logger.info('start to sync with integration', {
action: req.params.action,
integrationType: req.params.integrationType,
})
const { message: msgStr, expired } = readPushSubscription(req)
if (!msgStr) {
res.status(400).send('Bad Request')
return
}
if (expired) {
logger.info('discarding expired message')
res.status(200).send('Expired')
return
}
try {
const { userId, type, data }: Message = JSON.parse(msgStr)
if (!userId) {
logger.info('No userId found in message')
res.status(400).send('Bad Request')
return
}
const integration = await getRepository(Integration).findOneBy({
user: { id: userId },
type: req.params.integrationType.toUpperCase() as IntegrationType,
enabled: true,
})
if (!integration) {
logger.info('No active integration found for user', { userId })
res.status(200).send('No integration found')
return
}
const action = req.params.action.toUpperCase()
if (action === 'SYNC_UPDATED') {
// get updated page by id
let id = ''
switch (type) {
case EntityType.PAGE:
id = data.id
break
case EntityType.HIGHLIGHT:
id = data.articleId
break
case EntityType.LABEL:
id = data.pageId
break
}
const page = await getPageById(id)
if (!page) {
logger.info('No page found for id', { id })
res.status(200).send('No page found')
return
}
// sync updated page with integration
logger.info('syncing updated page with integration', {
integrationId: integration.id,
pageId: page.id,
})
const synced = await syncWithIntegration(integration, [page])
if (!synced) {
logger.info('failed to sync page', {
integrationId: integration.id,
pageId: page.id,
})
res.status(400).send('Failed to sync')
return
}
} else if (action === 'SYNC_ALL') {
// sync all pages of the user
const size = 50
for (
let hasNextPage = true, count = 0, after = 0, pages: Page[] = [];
hasNextPage;
after += size, hasNextPage = count > after
) {
const syncedAt = integration.syncedAt
// only sync pages that were updated after syncedAt
const dateFilters: DateFilter[] = []
syncedAt &&
dateFilters.push({ field: 'updatedAt', startDate: syncedAt })
;[pages, count] = (await searchPages(
{ from: after, size, dateFilters },
userId
))!
const pageIds = pages.map((p) => p.id)
logger.info('syncing pages', { pageIds })
const synced = await syncWithIntegration(integration, pages)
if (!synced) {
logger.info('failed to sync pages', {
pageIds,
integrationId: integration.id,
})
res.status(400).send('Failed to sync')
return
}
}
// delete task name if completed
await getRepository(Integration).update(integration.id, {
taskName: null,
})
} else {
logger.info('unknown action', { action })
res.status(200).send('Unknown action')
return
}
res.status(200).send('OK')
} catch (err) {
logger.error('sync with integrations failed', err)
res.status(500).send(err)
}
})
return router
}

View file

@ -1812,6 +1812,77 @@ const schema = gql`
NOT_FOUND
}
union SetIntegrationResult = SetIntegrationSuccess | SetIntegrationError
type SetIntegrationSuccess {
integration: Integration!
}
type Integration {
id: ID!
type: IntegrationType!
token: String!
enabled: Boolean!
createdAt: Date!
updatedAt: Date!
}
enum IntegrationType {
READWISE
}
type SetIntegrationError {
errorCodes: [SetIntegrationErrorCode!]!
}
enum SetIntegrationErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
INVALID_TOKEN
ALREADY_EXISTS
}
input SetIntegrationInput {
id: ID
type: IntegrationType!
token: String!
enabled: Boolean!
}
union IntegrationsResult = IntegrationsSuccess | IntegrationsError
type IntegrationsSuccess {
integrations: [Integration!]!
}
type IntegrationsError {
errorCodes: [IntegrationsErrorCode!]!
}
enum IntegrationsErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
union DeleteIntegrationResult =
DeleteIntegrationSuccess
| DeleteIntegrationError
type DeleteIntegrationSuccess {
integration: Integration!
}
type DeleteIntegrationError {
errorCodes: [DeleteIntegrationErrorCode!]!
}
enum DeleteIntegrationErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1881,6 +1952,8 @@ const schema = gql`
revokeApiKey(id: ID!): RevokeApiKeyResult!
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
moveLabel(input: MoveLabelInput!): MoveLabelResult!
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
deleteIntegration(id: ID!): DeleteIntegrationResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed
@ -1926,6 +1999,7 @@ const schema = gql`
apiKeys: ApiKeysResult!
typeaheadSearch(query: String!, first: Int): TypeaheadSearchResult!
updatesSince(after: String, first: Int, since: Date!): UpdatesSinceResult!
integrations: IntegrationsResult!
}
`

View file

@ -44,6 +44,7 @@ import { initElasticsearch } from './elastic'
import { uploadServiceRouter } from './routers/svc/upload'
import rateLimit from 'express-rate-limit'
import { webhooksServiceRouter } from './routers/svc/webhooks'
import { integrationsServiceRouter } from './routers/svc/integrations'
const PORT = process.env.PORT || 4000
@ -115,6 +116,7 @@ export const createApp = (): {
app.use('/svc/pubsub/emails', emailsServiceRouter())
app.use('/svc/pubsub/upload', uploadServiceRouter())
app.use('/svc/pubsub/webhooks', webhooksServiceRouter())
app.use('/svc/pubsub/integrations', integrationsServiceRouter())
app.use('/svc/reminders', remindersServiceRouter())
app.use('/svc/pdf-attachments', pdfAttachmentsRouter())

View file

@ -0,0 +1,11 @@
import { diff_match_patch } from 'diff-match-patch'
import { homePageURL } from '../env'
export const getHighlightLocation = (patch: string): number | undefined => {
const dmp = new diff_match_patch()
const patches = dmp.patch_fromText(patch)
return patches[0].start1 || undefined
}
export const getHighlightUrl = (slug: string, highlightId: string): string =>
`${homePageURL()}/me/${slug}#${highlightId}`

View file

@ -0,0 +1,148 @@
import { IntegrationType } from '../generated/graphql'
import { env } from '../env'
import axios from 'axios'
import { wait } from '../utils/helpers'
import { Page } from '../elastic/types'
import { getHighlightLocation, getHighlightUrl } from './highlights'
import { Integration } from '../entity/integration'
import { getRepository } from '../entity/utils'
interface ReadwiseHighlight {
// The highlight text, (technically the only field required in a highlight object)
text: string
// The title of the page the highlight is on
title?: string
// The author of the page the highlight is on
author?: string
// The URL of the page image
image_url?: string
// The URL of the page
source_url?: string
// A meaningful unique identifier for your app
source_type?: string
// One of: books, articles, tweets or podcasts
category?: string
// Annotation note attached to the specific highlight
note?: string
// Highlight's location in the source text. Used to order the highlights
location?: number
// One of: page, order or time_offset
location_type?: string
// A datetime representing when the highlight was taken in the ISO 8601 format
highlighted_at?: string
// Unique url of the specific highlight
highlight_url?: string
}
export const READWISE_API_URL = 'https://readwise.io/api/v2'
export const validateToken = async (
token: string,
type: IntegrationType
): Promise<boolean> => {
switch (type) {
case IntegrationType.Readwise:
return validateReadwiseToken(token)
default:
return false
}
}
const validateReadwiseToken = async (token: string): Promise<boolean> => {
const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth`
try {
const response = await axios.get(authUrl, {
headers: {
Authorization: `Token ${token}`,
},
})
return response.status === 204
} catch (error) {
console.log('error validating readwise token', error)
return false
}
}
const pageToReadwiseHighlight = (page: Page): ReadwiseHighlight[] => {
if (!page.highlights) return []
return page.highlights.map((highlight) => {
const location = getHighlightLocation(highlight.patch)
return {
text: highlight.quote,
title: page.title,
author: page.author,
highlight_url: getHighlightUrl(page.slug, highlight.id),
highlighted_at: new Date(highlight.createdAt).toISOString(),
category: 'articles',
image_url: page.image,
location,
location_type: location ? 'page' : 'order',
note: highlight.annotation || undefined,
source_type: 'omnivore',
source_url: page.url,
}
})
}
export const syncWithIntegration = async (
integration: Integration,
pages: Page[]
): Promise<boolean> => {
let result = false
switch (integration.type) {
case IntegrationType.Readwise:
result = await syncWithReadwise(
integration.token,
pages.flatMap(pageToReadwiseHighlight)
)
break
default:
return false
}
// update integration syncedAt if successful
if (result) {
console.log('updating integration syncedAt')
await getRepository(Integration).update(integration.id, {
syncedAt: new Date(),
})
}
return result
}
export const syncWithReadwise = async (
token: string,
highlights: ReadwiseHighlight[],
retryCount = 0
): Promise<boolean> => {
const url = `${env.readwise.apiUrl || READWISE_API_URL}/highlights`
try {
const response = await axios.post(
url,
{
highlights,
},
{
headers: {
Authorization: `Token ${token}`,
ContentType: 'application/json',
},
}
)
return response.status === 200
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.status === 429 &&
retryCount < 3
) {
console.log('Readwise API rate limit exceeded, retrying...')
// wait for Retry-After seconds in the header if rate limited
// max retry count is 3
const retryAfter = error.response?.headers['retry-after'] || '10' // default to 10 seconds
await wait(parseInt(retryAfter, 10) * 1000)
return syncWithReadwise(token, highlights, retryCount + 1)
}
console.log('Error creating highlights in Readwise', error)
return false
}
}

View file

@ -62,6 +62,7 @@ interface BackendEnv {
contentFetchUrl: string
contentFetchGCFUrl: string
reminderTaskHanderUrl: string
integrationTaskHandlerUrl: string
}
fileUpload: {
gcsUploadBucket: string
@ -84,6 +85,9 @@ interface BackendEnv {
resetPasswordTemplateId: string
installationTemplateId: string
}
readwise: {
apiUrl: string
}
}
/***
@ -132,6 +136,8 @@ const nullableEnvVars = [
'SENDGRID_REMINDER_TEMPLATE_ID',
'SENDGRID_RESET_PASSWORD_TEMPLATE_ID',
'SENDGRID_INSTALLATION_TEMPLATE_ID',
'READWISE_API_URL',
'INTEGRATION_TASK_HANDLER_URL',
] // Allow some vars to be null/empty
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
@ -214,6 +220,7 @@ export function getEnv(): BackendEnv {
contentFetchUrl: parse('CONTENT_FETCH_URL'),
contentFetchGCFUrl: parse('CONTENT_FETCH_GCF_URL'),
reminderTaskHanderUrl: parse('REMINDER_TASK_HANDLER_URL'),
integrationTaskHandlerUrl: parse('INTEGRATION_TASK_HANDLER_URL'),
}
const imageProxy = {
url: parse('IMAGE_PROXY_URL'),
@ -245,6 +252,10 @@ export function getEnv(): BackendEnv {
installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'),
}
const readwise = {
apiUrl: parse('READWISE_API_URL'),
}
return {
pg,
client,
@ -262,6 +273,7 @@ export function getEnv(): BackendEnv {
elastic,
sender,
sendgrid,
readwise,
}
}

View file

@ -8,6 +8,7 @@ import { CreateTaskError } from './errors'
import { buildLogger } from './logger'
import { nanoid } from 'nanoid'
import { google } from '@google-cloud/tasks/build/protos/protos'
import { IntegrationType } from '../entity/integration'
import View = google.cloud.tasks.v2.Task.View
const logger = buildLogger('app.dispatch')
@ -283,4 +284,45 @@ export const enqueueReminder = async (
return createdTasks[0].name
}
export const enqueueSyncWithIntegration = async (
userId: string,
integrationType: IntegrationType
): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT, PUBSUB_VERIFICATION_TOKEN } = process.env
// use pubsub data format to send the userId to the task handler
const payload = {
message: {
data: Buffer.from(
JSON.stringify({
userId,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
return nanoid()
}
const createdTasks = await createHttpTaskWithToken({
project: GOOGLE_CLOUD_PROJECT,
payload,
taskHandlerUrl: `${
env.queue.integrationTaskHandlerUrl
}/${integrationType.toLowerCase()}/sync_all?token=${PUBSUB_VERIFICATION_TOKEN}`,
priority: 'low',
})
if (!createdTasks || !createdTasks[0].name) {
logger.error(`Unable to get the name of the task`, {
payload,
createdTasks,
})
throw new CreateTaskError(`Unable to get the name of the task`)
}
return createdTasks[0].name
}
export default createHttpTaskWithToken

View file

@ -254,3 +254,9 @@ export const validateUuid = (str: string): boolean => {
export const isString = (check: any): check is string => {
return typeof check === 'string' || check instanceof String
}
export const wait = (ms: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

View file

@ -0,0 +1,57 @@
import 'mocha'
import { expect } from 'chai'
import { Highlight, Page, PageContext } from '../../src/elastic/types'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import { deletePage } from '../../src/elastic/pages'
import {
addHighlightToPage,
searchHighlights,
} from '../../src/elastic/highlights'
import { createTestElasticPage } from '../util'
describe('highlights in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
refresh: true,
uid: userId,
}
describe('searchHighlights', () => {
const highlightId = 'highlightId'
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
const highlightData: Highlight = {
patch: 'test patch',
quote: 'test content',
shortId: 'test shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
}
await addHighlightToPage(page.id, highlightData, ctx)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('searches highlights', async () => {
const [searchResults, count] = (await searchHighlights(
{
query: 'test',
},
page.userId
)) || [[], 0]
expect(count).to.eq(1)
expect(searchResults[0].id).to.eq(highlightId)
})
})
})

View file

@ -0,0 +1,141 @@
import 'mocha'
import { expect } from 'chai'
import {
ArticleSavingRequestStatus,
Highlight,
Label,
Page,
PageContext,
PageType,
} from '../../src/elastic/types'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import { createPage, deletePage, getPageById } from '../../src/elastic/pages'
import { addLabelInPage, setLabelsForHighlight } from '../../src/elastic/labels'
import { addHighlightToPage } from '../../src/elastic/highlights'
describe('labels in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
refresh: true,
uid: userId,
}
describe('addLabelInPage', () => {
let page: Page
let label: Label = {
id: 'Test label id',
name: 'test label',
color: '#07D2D1',
}
let newLabel: Label
before(async () => {
// create a testing page
page = {
id: '',
hash: 'test hash',
userId: userId,
pageType: PageType.Article,
title: 'test title',
content: '<p>test</p>',
slug: 'test slug',
createdAt: new Date(),
updatedAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/getting-started-with-omnivore',
archivedAt: new Date(),
labels: [label],
state: ArticleSavingRequestStatus.Succeeded,
}
page.id = (await createPage(page, ctx))!
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
context('when the label not exist in the page', () => {
before(() => {
newLabel = {
id: 'new label id',
name: 'new label',
color: '#07D2D1',
}
})
it('adds the label to the page', async () => {
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.true
const updatedPage = await getPageById(page.id)
expect(updatedPage?.labels).to.deep.include(label)
})
})
context('when the label exists in the page', () => {
before(() => {
newLabel = label
})
it('does not add the label to the page', async () => {
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.false
})
})
})
describe('setLabelsForHighlight', () => {
const page = {
id: 'testPageId',
hash: 'test set labels for highlight hash',
userId: userId,
pageType: PageType.Article,
title: 'test set labels for highlight title',
content: '<p>test</p>',
slug: 'test set labels for highlight slug',
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/setting-labels-for-highlight',
state: ArticleSavingRequestStatus.Succeeded,
}
const highlightId = 'highlightId'
const label: Label = {
id: 'test label id',
name: 'test label',
color: '#07D2D1',
}
before(async () => {
// create a testing page
await createPage(page, ctx)
const highlightData: Highlight = {
patch: 'test set labels patch',
quote: 'test set labels quote',
shortId: 'test set labels shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
}
await addHighlightToPage(page.id, highlightData, ctx)
})
after(async () => {
await deletePage(page.id, ctx)
})
it('sets labels for highlights', async () => {
const result = await setLabelsForHighlight(highlightId, [label], ctx)
expect(result).to.be.true
})
})
})

View file

@ -3,8 +3,6 @@ import { expect } from 'chai'
import { InFilter, ReadFilter } from '../../src/utils/search'
import {
ArticleSavingRequestStatus,
Highlight,
Label,
Page,
PageContext,
PageType,
@ -21,13 +19,9 @@ import {
searchPages,
updatePage,
} from '../../src/elastic/pages'
import { addLabelInPage, setLabelsForHighlight } from '../../src/elastic/labels'
import {
addHighlightToPage,
searchHighlights,
} from '../../src/elastic/highlights'
import { createTestElasticPage } from '../util'
describe('elastic api', () => {
describe('pages in elastic', () => {
const userId = 'userId'
const ctx: PageContext = {
pubsub: createPubSubClient(),
@ -35,49 +29,6 @@ describe('elastic api', () => {
uid: userId,
}
let page: Page
before(async () => {
// create a testing page
page = {
id: '',
hash: 'test hash',
userId: userId,
pageType: PageType.Article,
title: 'test title',
content: '<p>test</p>',
slug: 'test slug',
createdAt: new Date(),
updatedAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/getting-started-with-omnivore',
archivedAt: new Date(),
labels: [
{
id: 'Test label id',
name: 'test label',
color: '#ffffff',
createdAt: new Date(),
},
{
id: 'Test label id 2',
name: 'test label 2',
color: '#eeeeee',
createdAt: new Date(),
},
],
state: ArticleSavingRequestStatus.Succeeded,
}
page.id = (await createPage(page, ctx))!
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
describe('createPage', () => {
let newPageId: string
@ -108,6 +59,18 @@ describe('elastic api', () => {
})
describe('getPageByParam', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('gets a page by url', async () => {
const pageFound = await getPageByParam({
userId: page.userId,
@ -119,6 +82,18 @@ describe('elastic api', () => {
})
describe('getPageById', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('gets a page by id', async () => {
const pageFound = await getPageById(page.id)
expect(pageFound).not.undefined
@ -126,6 +101,18 @@ describe('elastic api', () => {
})
describe('updatePage', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('updates a page', async () => {
const newTitle = 'new title'
const updatedPageData: Partial<Page> = {
@ -140,6 +127,18 @@ describe('elastic api', () => {
})
describe('searchPages', () => {
let page: Page
before(async () => {
// create a testing page
page = await createTestElasticPage(userId)
})
after(async () => {
// delete the testing page
await deletePage(page.id, ctx)
})
it('searches pages', async () => {
const searchResults = await searchPages(
{
@ -148,7 +147,7 @@ describe('elastic api', () => {
inFilter: InFilter.ALL,
labelFilters: [],
readFilter: ReadFilter.ALL,
query: 'test',
query: page.content,
},
page.userId
)
@ -156,43 +155,12 @@ describe('elastic api', () => {
})
})
describe('addLabelInPage', () => {
context('when the label not exist in the page', () => {
it('adds the label to the page', async () => {
const newLabel = {
id: 'new label id',
name: 'new label',
color: '#07D2D1',
}
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.true
const updatedPage = await getPageById(page.id)
expect(updatedPage?.labels).to.deep.include(newLabel)
})
})
context('when the label exists in the page', () => {
it('does not add the label to the page', async () => {
const newLabel = {
id: 'Test label id',
name: 'test label',
color: '#07D2D1',
}
const result = await addLabelInPage(page.id, newLabel, ctx)
expect(result).to.be.false
})
})
})
describe('countByCreatedAt', () => {
const createdAt = Date.now() - 60 * 60 * 24 * 1000
let page: Page
before(async () => {
const newPageData: Page = {
page = {
id: '',
hash: 'hash',
userId: userId,
@ -208,93 +176,16 @@ describe('elastic api', () => {
state: ArticleSavingRequestStatus.Succeeded,
}
await createPage(newPageData, ctx)
})
it('counts pages by createdAt', async () => {
const count = await countByCreatedAt(userId, createdAt, createdAt)
expect(count).to.eq(1)
})
})
describe('searchHighlights', () => {
const highlightId = 'highlightId'
before(async () => {
const highlightData: Highlight = {
patch: 'test patch',
quote: 'test content',
shortId: 'test shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
}
await addHighlightToPage(page.id, highlightData, ctx)
})
it('searches highlights', async () => {
const [searchResults, count] = (await searchHighlights(
{
query: 'test',
},
page.userId
)) || [[], 0]
expect(count).to.eq(1)
expect(searchResults[0].id).to.eq(highlightId)
})
})
describe('setLabelsForHighlight', () => {
const page = {
id: 'testPageId',
hash: 'test set labels for highlight hash',
userId: userId,
pageType: PageType.Article,
title: 'test set labels for highlight title',
content: '<p>test</p>',
slug: 'test set labels for highlight slug',
createdAt: new Date(),
savedAt: new Date(),
readingProgressPercent: 100,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/p/setting-labels-for-highlight',
state: ArticleSavingRequestStatus.Succeeded,
}
const highlightId = 'highlightId'
const label: Label = {
id: 'test label id',
name: 'test label',
color: '#07D2D1',
}
before(async () => {
// create a testing page
await createPage(page, ctx)
const highlightData: Highlight = {
patch: 'test set labels patch',
quote: 'test set labels quote',
shortId: 'test set labels shortId',
id: highlightId,
userId: page.userId,
createdAt: new Date(),
updatedAt: new Date(),
}
await addHighlightToPage(page.id, highlightData, ctx)
page.id = (await createPage(page, ctx))!
})
after(async () => {
await deletePage(page.id, ctx)
})
it('sets labels for highlights', async () => {
const result = await setLabelsForHighlight(highlightId, [label], ctx)
expect(result).to.be.true
it('counts pages by createdAt', async () => {
const count = await countByCreatedAt(userId, createdAt, createdAt)
expect(count).to.eq(1)
})
})

View file

@ -694,7 +694,7 @@ describe('Article API', () => {
let pageId = ''
before(async () => {
pageId = (await createTestElasticPage(user)).id!
pageId = (await createTestElasticPage(user.id)).id!
})
after(async () => {

View file

@ -104,7 +104,7 @@ describe('Highlights API', () => {
.send({ fakeEmail: user.email })
authToken = res.body.authToken
pageId = (await createTestElasticPage(user)).id
pageId = (await createTestElasticPage(user.id)).id
ctx = { pubsub: createPubSubClient(), uid: user.id }
})

View file

@ -0,0 +1,394 @@
import 'mocha'
import { User } from '../../src/entity/user'
import { createTestUser, deleteTestUser } from '../db'
import { generateFakeUuid, graphqlRequest, request } from '../util'
import {
IntegrationType,
SetIntegrationErrorCode,
} from '../../src/generated/graphql'
import { expect } from 'chai'
import { getRepository } from '../../src/entity/utils'
import {
Integration,
IntegrationType as DataIntegrationType,
} from '../../src/entity/integration'
import nock from 'nock'
import { READWISE_API_URL } from '../../src/services/integrations'
describe('Integrations resolvers', () => {
let loginUser: User
let authToken: string
before(async () => {
// create test user and login
loginUser = await createTestUser('loginUser')
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: loginUser.email })
authToken = res.body.authToken
})
after(async () => {
await deleteTestUser(loginUser.name)
})
describe('setIntegration API', () => {
const validToken = 'valid-token'
const query = (
id = '',
type: IntegrationType = IntegrationType.Readwise,
token: string = 'test token',
enabled = true
) => `
mutation {
setIntegration(input: {
id: "${id}",
type: ${type},
token: "${token}",
enabled: ${enabled},
}) {
... on SetIntegrationSuccess {
integration {
id
enabled
}
}
... on SetIntegrationError {
errorCodes
}
}
}
`
let integrationId: string
let token: string
let integrationType: IntegrationType
let enabled: boolean
let scope: nock.Scope
// mock Readwise Auth API
before(() => {
scope = nock(READWISE_API_URL, {
reqheaders: { Authorization: `Token ${validToken}` },
})
.get('/auth')
.reply(204)
.persist()
})
after(() => {
scope.persist(false)
})
context('when id is not in the request', () => {
before(() => {
integrationId = ''
})
context('when integration exists', () => {
let existingIntegration: Integration
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: loginUser,
type: DataIntegrationType.Readwise,
token: 'fakeToken',
})
integrationType = existingIntegration.type
})
after(async () => {
await getRepository(Integration).delete({
id: existingIntegration.id,
})
})
it('returns AlreadyExists error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
SetIntegrationErrorCode.AlreadyExists,
])
})
})
context('when integration does not exist', () => {
context('when token is invalid', () => {
before(() => {
token = 'invalid token'
})
it('returns InvalidToken error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
SetIntegrationErrorCode.InvalidToken,
])
})
})
context('when token is valid', () => {
before(() => {
token = validToken
})
afterEach(async () => {
await getRepository(Integration).delete({
user: loginUser,
type: integrationType,
})
})
it('creates new integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be.true
})
it('creates new cloud task to sync all existing articles and highlights', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
authToken
)
const integration = await getRepository(Integration).findOneBy({
id: res.body.data.setIntegration.integration.id,
})
expect(integration?.taskName).not.to.be.null
})
})
})
})
context('when id is in the request', () => {
let existingIntegration: Integration
context('when integration does not exist', () => {
before(() => {
integrationId = generateFakeUuid()
})
it('returns NotFound error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
SetIntegrationErrorCode.NotFound,
])
})
})
context('when integration exists', () => {
context('when integration does not belong to the user', () => {
let otherUser: User
before(async () => {
otherUser = await createTestUser('otherUser')
existingIntegration = await getRepository(Integration).save({
user: otherUser,
type: DataIntegrationType.Readwise,
token: 'fakeToken',
})
integrationId = existingIntegration.id
})
after(async () => {
await deleteTestUser(otherUser.name)
await getRepository(Integration).delete({
id: existingIntegration.id,
})
})
it('returns Unauthorized error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
SetIntegrationErrorCode.Unauthorized,
])
})
})
context('when integration belongs to the user', () => {
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: loginUser,
type: DataIntegrationType.Readwise,
token: 'fakeToken',
})
integrationId = existingIntegration.id
})
after(async () => {
await getRepository(Integration).delete({
id: existingIntegration.id,
})
})
context('when enable is false', () => {
before(() => {
enabled = false
})
afterEach(async () => {
await getRepository(Integration).update(existingIntegration.id, {
taskName: 'some task name',
enabled: true,
})
})
it('disables integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be
.false
})
it('deletes cloud task', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
authToken
)
const integration = await getRepository(Integration).findOneBy({
id: res.body.data.setIntegration.integration.id,
})
expect(integration?.taskName).to.be.null
})
})
context('when enable is true', () => {
before(() => {
enabled = true
})
afterEach(async () => {
await getRepository(Integration).update(existingIntegration.id, {
taskName: null,
enabled: false,
})
})
it('enables integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be
.true
})
it('creates new cloud task to sync all existing articles and highlights', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
authToken
)
const integration = await getRepository(Integration).findOneBy({
id: res.body.data.setIntegration.integration.id,
})
expect(integration?.taskName).not.to.be.null
})
})
})
})
})
})
describe('integrations API', () => {
const query = `
query {
integrations {
... on IntegrationsSuccess {
integrations {
id
type
enabled
}
}
}
}
`
let existingIntegration: Integration
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: loginUser,
type: DataIntegrationType.Readwise,
token: 'fakeToken',
})
})
after(async () => {
await getRepository(Integration).delete(existingIntegration.id)
})
it('returns all integrations', async () => {
const res = await graphqlRequest(query, authToken)
expect(res.body.data.integrations.integrations).to.have.length(1)
expect(res.body.data.integrations.integrations[0].id).to.equal(
existingIntegration.id
)
expect(res.body.data.integrations.integrations[0].type).to.equal(
existingIntegration.type
)
expect(res.body.data.integrations.integrations[0].enabled).to.equal(
existingIntegration.enabled
)
})
})
describe('deleteIntegration API', () => {
const query = (id: string) => `
mutation {
deleteIntegration(id: "${id}") {
... on DeleteIntegrationSuccess {
integration {
id
}
}
... on DeleteIntegrationError {
errorCodes
}
}
}
`
context('when integration exists', () => {
let existingIntegration: Integration
beforeEach(async () => {
existingIntegration = await getRepository(Integration).save({
user: loginUser,
type: DataIntegrationType.Readwise,
token: 'fakeToken',
taskName: 'some task name',
})
})
it('deletes the integration and cloud task', async () => {
const res = await graphqlRequest(
query(existingIntegration.id),
authToken
)
const integration = await getRepository(Integration).findOneBy({
id: existingIntegration.id,
})
expect(res.body.data.deleteIntegration.integration).to.be.an('object')
expect(res.body.data.deleteIntegration.integration.id).to.eql(
existingIntegration.id
)
expect(integration).to.be.null
})
})
})
})

View file

@ -20,15 +20,13 @@ import {
import { refreshIndex } from '../../src/elastic'
describe('Labels API', () => {
const username = 'fakeUser'
let user: User
let authToken: string
let ctx: PageContext
before(async () => {
// create test user and login
user = await createTestUser(username)
user = await createTestUser('fakeUser')
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
@ -42,7 +40,7 @@ describe('Labels API', () => {
after(async () => {
// clean up
await deleteTestUser(username)
await deleteTestUser(user.name)
})
describe('GET labels', () => {
@ -58,9 +56,7 @@ describe('Labels API', () => {
after(async () => {
// clean up
for (const label of labels) {
await getRepository(Label).delete(label.id)
}
await getRepository(Label).delete(labels.map((l) => l.id))
})
beforeEach(() => {
@ -246,7 +242,7 @@ describe('Labels API', () => {
before(async () => {
toDeleteLabel = await createTestLabel(user, 'page label', '#ffffff')
labelId = toDeleteLabel.id
page = await createTestElasticPage(user, [toDeleteLabel])
page = await createTestElasticPage(user.id, [toDeleteLabel])
})
after(async () => {
@ -267,7 +263,7 @@ describe('Labels API', () => {
let page: Page
before(async () => {
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
toDeleteLabel = await createTestLabel(
user,
'highlight label',
@ -340,14 +336,12 @@ describe('Labels API', () => {
const label1 = await createTestLabel(user, 'label_1', '#ffffff')
const label2 = await createTestLabel(user, 'label_2', '#eeeeee')
labels = [label1, label2]
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
})
after(async () => {
// clean up
for (const label of labels) {
await getRepository(Label).delete(label.id)
}
await getRepository(Label).delete(labels.map((l) => l.id))
await deletePage(page.id, ctx)
})
@ -497,7 +491,7 @@ describe('Labels API', () => {
let page: Page
before(async () => {
page = await createTestElasticPage(user, [toUpdateLabel])
page = await createTestElasticPage(user.id, [toUpdateLabel])
})
after(async () => {
@ -542,14 +536,12 @@ describe('Labels API', () => {
const label1 = await createTestLabel(user, 'label_1', '#ffffff')
const label2 = await createTestLabel(user, 'label_2', '#eeeeee')
labels = [label1, label2]
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
})
after(async () => {
// clean up
for (const label of labels) {
await getRepository(Label).delete(label.id)
}
await getRepository(Label).delete(labels.map((l) => l.id))
await deletePage(page.id, ctx)
})
@ -581,7 +573,7 @@ describe('Labels API', () => {
context('when labels exists', () => {
before(async () => {
highlightId = 'highlight-id'
highlightId = generateFakeUuid()
const highlight: Highlight = {
createdAt: new Date(),
id: highlightId,
@ -605,7 +597,7 @@ describe('Labels API', () => {
context('when labels not exist', () => {
before(async () => {
highlightId = 'highlight-id-2'
highlightId = generateFakeUuid()
const highlight: Highlight = {
createdAt: new Date(),
id: highlightId,
@ -677,9 +669,7 @@ describe('Labels API', () => {
after(async () => {
// clean up
for (const label of labels) {
await getRepository(Label).delete(label.id)
}
await getRepository(Label).delete(labels.map((l) => l.id))
})
context('when label exists', () => {

View file

@ -38,7 +38,7 @@ describe('Reminders API', () => {
authToken = res.body.authToken
// create page, link and reminders test data
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
reminder = await createTestReminder(user, page.id)
})

View file

@ -24,7 +24,7 @@ describe('Report API', () => {
authToken = res.body.authToken
// create a page
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
})
after(async () => {

View file

@ -1,9 +1,5 @@
import { createTestUser, deleteTestUser } from '../db'
import {
createTestElasticPage,
graphqlRequest,
request,
} from '../util'
import { createTestElasticPage, graphqlRequest, request } from '../util'
import { expect } from 'chai'
import 'mocha'
import { User } from '../../src/entity/user'
@ -24,7 +20,7 @@ describe('Update API', () => {
.send({ fakeEmail: user.email })
authToken = res.body.authToken
page = await createTestElasticPage(user)
page = await createTestElasticPage(user.id)
})
after(async () => {
@ -34,8 +30,8 @@ describe('Update API', () => {
describe('update page', () => {
let query: string
let title = "New Title"
let description = "New Description"
let title = 'New Title'
let description = 'New Description'
beforeEach(() => {
query = `
@ -59,14 +55,14 @@ describe('Update API', () => {
}
}
`
})
it('should update page', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const updatedPage = res?.body.data.updatePage.updatedPage
expect(updatedPage?.title).to.eql(title)
expect(updatedPage?.description).to.eql(description)
})
})
it('should update page', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const updatedPage = res?.body.data.updatePage.updatedPage
expect(updatedPage?.title).to.eql(title)
expect(updatedPage?.description).to.eql(description)
})
})
})

View file

@ -0,0 +1,327 @@
import 'mocha'
import { createTestElasticPage, request } from '../util'
import { expect } from 'chai'
import { DateTime } from 'luxon'
import {
createPubSubClient,
PubSubRequestBody,
} from '../../src/datalayer/pubsub'
import { User } from '../../src/entity/user'
import { createTestUser, deleteTestUser } from '../db'
import { Integration, IntegrationType } from '../../src/entity/integration'
import { getRepository } from '../../src/entity/utils'
import { Highlight, Page, PageContext } from '../../src/elastic/types'
import nock from 'nock'
import { READWISE_API_URL } from '../../src/services/integrations'
import { addHighlightToPage } from '../../src/elastic/highlights'
import { getHighlightUrl } from '../../src/services/highlights'
import { deletePage } from '../../src/elastic/pages'
describe('Integrations routers', () => {
let token: string
describe('sync with integrations', () => {
const endpoint = (token: string, type = 'type', action = 'action') =>
`/svc/pubsub/integrations/${type}/${action}?token=${token}`
let action: string
let data: PubSubRequestBody
let integrationType: string
context('when token is invalid', () => {
before(() => {
token = 'invalid-token'
})
it('returns 400', async () => {
return request.post(endpoint(token)).send(data).expect(400)
})
})
context('when token is valid', () => {
before(() => {
token = process.env.PUBSUB_VERIFICATION_TOKEN!
})
context('when data is expired', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: 'userId', type: 'page' })
).toString('base64'),
publishTime: DateTime.now().minus({ hours: 12 }).toISO(),
},
}
})
it('returns 200 with Expired', async () => {
const res = await request.post(endpoint(token)).send(data).expect(200)
expect(res.text).to.eql('Expired')
})
})
context('when userId is empty', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: '', type: 'page' })
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
})
it('returns 400', async () => {
return request.post(endpoint(token)).send(data).expect(400)
})
})
context('when user exists', () => {
let user: User
before(async () => {
user = await createTestUser('fakeUser')
})
after(async () => {
await deleteTestUser(user.name)
})
context('when integration not found', () => {
before(() => {
integrationType = IntegrationType.Readwise
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: user.id, type: 'page' })
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
})
it('returns 200 with No integration found', async () => {
const res = await request
.post(endpoint(token, integrationType))
.send(data)
.expect(200)
expect(res.text).to.eql('No integration found')
})
})
context('when integration is readwise and enabled', () => {
let integration: Integration
let ctx: PageContext
let page: Page
let highlight: Highlight
let highlightsData: string
before(async () => {
integration = await getRepository(Integration).save({
user: { id: user.id },
type: IntegrationType.Readwise,
token: 'token',
})
integrationType = integration.type
// create page
page = await createTestElasticPage(user.id)
ctx = {
uid: user.id,
pubsub: createPubSubClient(),
refresh: true,
}
// create highlight
const location = 109
const patch = `@@ -${location + 1},16 +${location + 1},36 @@
. We're
+%3Comnivore_highlight%3E
humbled
@@ -254,16 +254,37 @@
h in the
+%3C/omnivore_highlight%3E
coming`
highlight = {
createdAt: new Date(),
id: 'test id',
patch,
quote: 'test quote',
shortId: 'test shortId',
updatedAt: new Date(),
userId: user.id,
}
await addHighlightToPage(page.id, highlight, ctx)
// create highlights data for integration request
highlightsData = JSON.stringify({
highlights: [
{
text: highlight.quote,
title: page.title,
author: page.author,
highlight_url: getHighlightUrl(page.slug, highlight.id),
highlighted_at: highlight.createdAt.toISOString(),
category: 'articles',
image_url: page.image,
location,
location_type: 'page',
note: highlight.annotation,
source_type: 'omnivore',
source_url: page.url,
},
],
})
})
after(async () => {
await getRepository(Integration).delete(integration.id)
await deletePage(page.id, ctx)
})
context('when action is sync_updated', () => {
before(async () => {
action = 'sync_updated'
})
context('when entity type is page', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
type: 'page',
data: { id: page.id },
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
ContentType: 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
context('when readwise highlight API reaches rate limits', () => {
before(() => {
// mock Readwise Highlight API with rate limits
// retry after 1 second
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
ContentType: 'application/json',
},
})
.post('/highlights')
.reply(429, 'Rate Limited', { 'Retry-After': '1' })
// mock Readwise Highlight API after 1 second
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
ContentType: 'application/json',
},
})
.post('/highlights')
.delay(1000)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
context('when entity type is highlight', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
type: 'highlight',
data: { articleId: page.id },
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
ContentType: 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
context('when action is sync_all', () => {
before(async () => {
action = 'sync_all'
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
ContentType: 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
await getRepository(Integration).update(integration.id, {
syncedAt: null,
taskName: 'some task name',
})
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
})
})
})
})

View file

@ -0,0 +1,25 @@
import 'mocha'
import { expect } from 'chai'
import { getHighlightLocation } from '../../src/services/highlights'
describe('getHighlightLocation', () => {
let patch: string
let location: number
before(async () => {
location = 109
patch = `@@ -${location + 1},16 +${location + 1},36 @@
. We're
+%3Comnivore_highlight%3E
humbled
@@ -254,16 +254,37 @@
h in the
+%3C/omnivore_highlight%3E
coming`
})
it('returns highlight location from patch', async () => {
const result = getHighlightLocation(patch)
expect(result).to.eql(location)
})
})

View file

@ -4,9 +4,8 @@ import { v4 } from 'uuid'
import { corsConfig } from '../src/utils/corsConfig'
import { ArticleSavingRequestStatus, Label, Page } from '../src/elastic/types'
import { PageType } from '../src/generated/graphql'
import { User } from '../src/entity/user'
import { createPubSubClient } from '../src/datalayer/pubsub'
import { createPage, getPageById } from '../src/elastic/pages'
import { createPage } from '../src/elastic/pages'
const { app, apollo } = createApp()
export const request = supertest(app)
@ -39,13 +38,13 @@ export const generateFakeUuid = () => {
}
export const createTestElasticPage = async (
user: User,
userId: string,
labels?: Label[]
): Promise<Page> => {
const page: Page = {
id: '',
hash: 'test hash',
userId: user.id,
userId,
pageType: PageType.Article,
title: 'test title',
content: '<p>test content</p>',
@ -59,19 +58,10 @@ export const createTestElasticPage = async (
state: ArticleSavingRequestStatus.Succeeded,
}
const pageId = await createPage(page, {
page.id = (await createPage(page, {
pubsub: createPubSubClient(),
refresh: true,
uid: user.id,
})
if (pageId) {
page.id = pageId
}
const res = await getPageById(page.id)
console.log('got page', res)
if (!res) {
throw new Error('Failed to create page')
}
return res
uid: userId,
}))!
return page
}

View file

@ -0,0 +1,25 @@
-- Type: DO
-- Name: integrations
-- Description: Create integrations table
BEGIN;
CREATE TYPE omnivore.integration_type AS ENUM ('READWISE');
CREATE TABLE omnivore.integrations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE,
"type" omnivore.integration_type NOT NULL,
token varchar(255) NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
synced_at timestamptz,
task_name text,
UNIQUE (user_id, "type")
);
CREATE TRIGGER update_integration_modtime BEFORE UPDATE ON omnivore.integrations
FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
COMMIT;

View file

@ -0,0 +1,11 @@
-- Type: UNDO
-- Name: integrations
-- Description: Create integrations table
BEGIN;
DROP TABLE IF EXISTS omnivore.integrations;
DROP TYPE IF EXISTS omnivore.integration_type CASCADE;
COMMIT;