mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1036 from omnivore-app/feature/readwise-integration-api
feature/readwise integration api
This commit is contained in:
commit
7b3eb56d33
32 changed files with 2050 additions and 230 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ interface PubSubRequestMessage {
|
|||
publishTime: string
|
||||
}
|
||||
|
||||
interface PubSubRequestBody {
|
||||
export interface PubSubRequestBody {
|
||||
message: PubSubRequestMessage
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
45
packages/api/src/entity/integration.ts
Normal file
45
packages/api/src/entity/integration.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!]!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export * from './update'
|
|||
export * from './popular_reads'
|
||||
export * from './webhooks'
|
||||
export * from './api_key'
|
||||
export * from './integrations'
|
||||
|
|
|
|||
221
packages/api/src/resolvers/integrations/index.ts
Normal file
221
packages/api/src/resolvers/integrations/index.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
148
packages/api/src/routers/svc/integrations.ts
Normal file
148
packages/api/src/routers/svc/integrations.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
11
packages/api/src/services/highlights.ts
Normal file
11
packages/api/src/services/highlights.ts
Normal 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}`
|
||||
148
packages/api/src/services/integrations.ts
Normal file
148
packages/api/src/services/integrations.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
57
packages/api/test/elastic/highlights.test.ts
Normal file
57
packages/api/test/elastic/highlights.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
141
packages/api/test/elastic/labels.test.ts
Normal file
141
packages/api/test/elastic/labels.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -694,7 +694,7 @@ describe('Article API', () => {
|
|||
let pageId = ''
|
||||
|
||||
before(async () => {
|
||||
pageId = (await createTestElasticPage(user)).id!
|
||||
pageId = (await createTestElasticPage(user.id)).id!
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
||||
|
|
|
|||
394
packages/api/test/resolvers/integrations.test.ts
Normal file
394
packages/api/test/resolvers/integrations.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
327
packages/api/test/routers/integrations.test.ts
Normal file
327
packages/api/test/routers/integrations.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
25
packages/api/test/services/highlights.test.ts
Normal file
25
packages/api/test/services/highlights.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
25
packages/db/migrations/0091.do.integrations.sql
Executable file
25
packages/db/migrations/0091.do.integrations.sql
Executable 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;
|
||||
11
packages/db/migrations/0091.undo.integrations.sql
Executable file
11
packages/db/migrations/0091.undo.integrations.sql
Executable 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;
|
||||
Loading…
Reference in a new issue