add integration api

This commit is contained in:
Hongbo Wu
2024-03-20 09:45:55 +08:00
parent 3d44e8ed1f
commit bf59fbf26e
6 changed files with 259 additions and 137 deletions

View File

@ -1274,6 +1274,22 @@ export type Integration = {
updatedAt?: Maybe<Scalars['Date']>;
};
export type IntegrationError = {
__typename?: 'IntegrationError';
errorCodes: Array<IntegrationErrorCode>;
};
export enum IntegrationErrorCode {
NotFound = 'NOT_FOUND'
}
export type IntegrationResult = IntegrationError | IntegrationSuccess;
export type IntegrationSuccess = {
__typename?: 'IntegrationSuccess';
integration: Integration;
};
export enum IntegrationType {
Export = 'EXPORT',
Import = 'IMPORT'
@ -2096,6 +2112,7 @@ export type Query = {
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
integration: IntegrationResult;
integrations: IntegrationsResult;
labels: LabelsResult;
me?: Maybe<User>;
@ -2143,6 +2160,11 @@ export type QueryGetDiscoverFeedArticlesArgs = {
};
export type QueryIntegrationArgs = {
id: Scalars['ID'];
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -4063,6 +4085,10 @@ export type ResolversTypes = {
ImportItemState: ImportItemState;
Int: ResolverTypeWrapper<Scalars['Int']>;
Integration: ResolverTypeWrapper<Integration>;
IntegrationError: ResolverTypeWrapper<IntegrationError>;
IntegrationErrorCode: IntegrationErrorCode;
IntegrationResult: ResolversTypes['IntegrationError'] | ResolversTypes['IntegrationSuccess'];
IntegrationSuccess: ResolverTypeWrapper<IntegrationSuccess>;
IntegrationType: IntegrationType;
IntegrationsError: ResolverTypeWrapper<IntegrationsError>;
IntegrationsErrorCode: IntegrationsErrorCode;
@ -4594,6 +4620,9 @@ export type ResolversParentTypes = {
ImportFromIntegrationSuccess: ImportFromIntegrationSuccess;
Int: Scalars['Int'];
Integration: Integration;
IntegrationError: IntegrationError;
IntegrationResult: ResolversParentTypes['IntegrationError'] | ResolversParentTypes['IntegrationSuccess'];
IntegrationSuccess: IntegrationSuccess;
IntegrationsError: IntegrationsError;
IntegrationsResult: ResolversParentTypes['IntegrationsError'] | ResolversParentTypes['IntegrationsSuccess'];
IntegrationsSuccess: IntegrationsSuccess;
@ -5777,6 +5806,20 @@ export type IntegrationResolvers<ContextType = ResolverContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type IntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationError'] = ResolversParentTypes['IntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['IntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type IntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationResult'] = ResolversParentTypes['IntegrationResult']> = {
__resolveType: TypeResolveFn<'IntegrationError' | 'IntegrationSuccess', ParentType, ContextType>;
};
export type IntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['IntegrationSuccess'] = ResolversParentTypes['IntegrationSuccess']> = {
integration?: Resolver<ResolversTypes['Integration'], 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>;
@ -6132,6 +6175,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'id'>>;
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
@ -7277,6 +7321,9 @@ export type Resolvers<ContextType = ResolverContext> = {
ImportFromIntegrationResult?: ImportFromIntegrationResultResolvers<ContextType>;
ImportFromIntegrationSuccess?: ImportFromIntegrationSuccessResolvers<ContextType>;
Integration?: IntegrationResolvers<ContextType>;
IntegrationError?: IntegrationErrorResolvers<ContextType>;
IntegrationResult?: IntegrationResultResolvers<ContextType>;
IntegrationSuccess?: IntegrationSuccessResolvers<ContextType>;
IntegrationsError?: IntegrationsErrorResolvers<ContextType>;
IntegrationsResult?: IntegrationsResultResolvers<ContextType>;
IntegrationsSuccess?: IntegrationsSuccessResolvers<ContextType>;

View File

@ -1140,6 +1140,20 @@ type Integration {
updatedAt: Date
}
type IntegrationError {
errorCodes: [IntegrationErrorCode!]!
}
enum IntegrationErrorCode {
NOT_FOUND
}
union IntegrationResult = IntegrationError | IntegrationSuccess
type IntegrationSuccess {
integration: Integration!
}
enum IntegrationType {
EXPORT
IMPORT
@ -1591,6 +1605,7 @@ type Query {
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
hello: String
integration(id: ID!): IntegrationResult!
integrations: IntegrationsResult!
labels: LabelsResult!
me: User

View File

@ -22,6 +22,8 @@ import {
SearchItem,
User,
} from '../generated/graphql'
import { getAISummary } from '../services/ai-summaries'
import { findUserFeatures } from '../services/features'
import { findHighlightsByLibraryItemId } from '../services/highlights'
import { findLabelsByLibraryItemId } from '../services/labels'
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
@ -79,6 +81,7 @@ import {
googleSignupResolver,
groupsResolver,
importFromIntegrationResolver,
integrationResolver,
integrationsResolver,
joinGroupResolver,
labelsResolver,
@ -150,8 +153,6 @@ import {
deleteDiscoverFeedsResolver,
editDiscoverFeedsResolver,
} from './discover_feeds'
import { getAISummary } from '../services/ai-summaries'
import { findUserFeatures, getFeatureName } from '../services/features'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -348,6 +349,7 @@ export const functionResolvers = {
recentEmails: recentEmailsResolver,
feeds: feedsResolver,
scanFeeds: scanFeedsResolver,
integration: integrationResolver,
},
User: {
async intercomHash(
@ -662,4 +664,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('UpdateNewsletterEmail'),
...resultResolveTypeResolver('EmptyTrash'),
...resultResolveTypeResolver('FetchContent'),
...resultResolveTypeResolver('Integration'),
}

View File

@ -12,12 +12,15 @@ import {
ImportFromIntegrationError,
ImportFromIntegrationErrorCode,
ImportFromIntegrationSuccess,
IntegrationError,
IntegrationErrorCode,
IntegrationsError,
IntegrationsErrorCode,
IntegrationsSuccess,
IntegrationSuccess,
MutationDeleteIntegrationArgs,
MutationImportFromIntegrationArgs,
MutationSetIntegrationArgs,
QueryIntegrationArgs,
SetIntegrationError,
SetIntegrationErrorCode,
SetIntegrationSuccess,
@ -42,85 +45,89 @@ export const setIntegrationResolver = authorized<
SetIntegrationSuccess,
SetIntegrationError,
MutationSetIntegrationArgs
>(async (_, { input }, { uid, log }) => {
try {
const integrationToSave: DeepPartial<Integration> = {
...input,
user: { id: uid },
id: input.id || undefined,
type: input.type || IntegrationType.Export,
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
importItemState:
input.type === IntegrationType.Import
? input.importItemState || ImportItemState.Unarchived // default to unarchived
: undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
settings: input.settings,
}
if (input.id) {
// Update
const existingIntegration = await findIntegration({ id: input.id }, uid)
if (!existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.NotFound],
}
>(async (_, { input }, { uid }) => {
const integrationToSave: DeepPartial<Integration> = {
...input,
user: { id: uid },
id: input.id || undefined,
type: input.type || IntegrationType.Export,
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
importItemState:
input.type === IntegrationType.Import
? input.importItemState || ImportItemState.Unarchived // default to unarchived
: undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
settings: input.settings,
}
if (input.id) {
// Update
const existingIntegration = await findIntegration({ id: input.id }, uid)
if (!existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.NotFound],
}
}
integrationToSave.id = existingIntegration.id
integrationToSave.taskName = existingIntegration.taskName
} else {
// Create
const integrationService = getIntegrationClient(input.name, input.token)
// authorize and get access token
const token = await integrationService.accessToken()
if (!token) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
integrationToSave.id = existingIntegration.id
integrationToSave.taskName = existingIntegration.taskName
} else {
// Create
const integrationService = getIntegrationClient(input.name, input.token)
// authorize and get access token
const token = await integrationService.accessToken()
if (!token) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
integrationToSave.token = token
}
integrationToSave.token = token
}
// save integration
const integration = await saveIntegration(integrationToSave, uid)
// save integration
const integration = await saveIntegration(integrationToSave, uid)
analytics.capture({
distinctId: uid,
event: 'integration_set',
properties: {
id: integrationToSave.id,
env: env.server.apiEnv,
},
})
analytics.capture({
distinctId: uid,
event: 'integration_set',
properties: {
id: integrationToSave.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
return {
integration,
}
})
export const integrationsResolver = authorized<
IntegrationsSuccess,
IntegrationsError
>(async (_, __, { uid, log }) => {
try {
const integrations = await findIntegrations(uid)
>(async (_, __, { uid }) => {
const integrations = await findIntegrations(uid)
return {
integrations,
}
})
export const integrationResolver = authorized<
IntegrationSuccess,
IntegrationError,
QueryIntegrationArgs
>(async (_, { id }, { uid, log }) => {
const integration = await findIntegration({ id }, uid)
if (!integration) {
log.error('integration not found', id)
return {
integrations,
errorCodes: [IntegrationErrorCode.NotFound],
}
} catch (error) {
log.error(error)
}
return {
errorCodes: [IntegrationsErrorCode.BadRequest],
}
return {
integration,
}
})
@ -131,42 +138,34 @@ export const deleteIntegrationResolver = authorized<
>(async (_, { id }, { claims: { uid }, log }) => {
log.info('deleteIntegrationResolver')
try {
const integration = await findIntegration({ id }, uid)
if (!integration) {
return {
errorCodes: [DeleteIntegrationErrorCode.NotFound],
}
}
if (integration.taskName) {
// delete the task if task exists
await deleteTask(integration.taskName)
log.info('task deleted', integration.taskName)
}
const deletedIntegration = await removeIntegration(integration, uid)
deletedIntegration.id = id
analytics.capture({
distinctId: uid,
event: 'integration_delete',
properties: {
integrationId: deletedIntegration.id,
env: env.server.apiEnv,
},
})
const integration = await findIntegration({ id }, uid)
if (!integration) {
return {
integration,
errorCodes: [DeleteIntegrationErrorCode.NotFound],
}
} catch (error) {
log.error(error)
}
return {
errorCodes: [DeleteIntegrationErrorCode.BadRequest],
}
if (integration.taskName) {
// delete the task if task exists
await deleteTask(integration.taskName)
log.info('task deleted', integration.taskName)
}
const deletedIntegration = await removeIntegration(integration, uid)
deletedIntegration.id = id
analytics.capture({
distinctId: uid,
event: 'integration_delete',
properties: {
integrationId: deletedIntegration.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
})
@ -175,52 +174,44 @@ export const importFromIntegrationResolver = authorized<
ImportFromIntegrationError,
MutationImportFromIntegrationArgs
>(async (_, { integrationId }, { claims: { uid }, log }) => {
try {
const integration = await findIntegration({ id: integrationId }, uid)
if (!integration) {
return {
errorCodes: [ImportFromIntegrationErrorCode.Unauthorized],
}
}
const authToken = await createIntegrationToken({
uid: integration.user.id,
token: integration.token,
})
if (!authToken) {
return {
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
}
}
// create a task to import all the pages
const taskName = await enqueueImportFromIntegration(
integration.id,
integration.name,
integration.syncedAt?.getTime() || 0,
authToken,
integration.importItemState || ImportItemState.Unarchived
)
// update task name in integration
await updateIntegration(integration.id, { taskName }, uid)
analytics.capture({
distinctId: uid,
event: 'integration_import',
properties: {
integrationId,
},
})
const integration = await findIntegration({ id: integrationId }, uid)
if (!integration) {
return {
success: true,
errorCodes: [ImportFromIntegrationErrorCode.Unauthorized],
}
} catch (error) {
log.error(error)
}
const authToken = await createIntegrationToken({
uid: integration.user.id,
token: integration.token,
})
if (!authToken) {
return {
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
}
}
// create a task to import all the pages
const taskName = await enqueueImportFromIntegration(
integration.id,
integration.name,
integration.syncedAt?.getTime() || 0,
authToken,
integration.importItemState || ImportItemState.Unarchived
)
// update task name in integration
await updateIntegration(integration.id, { taskName }, uid)
analytics.capture({
distinctId: uid,
event: 'integration_import',
properties: {
integrationId,
},
})
return {
success: true,
}
})

View File

@ -3009,6 +3009,20 @@ const schema = gql`
name: String!
}
union IntegrationResult = IntegrationSuccess | IntegrationError
type IntegrationSuccess {
integration: Integration!
}
type IntegrationError {
errorCodes: [IntegrationErrorCode!]!
}
enum IntegrationErrorCode {
NOT_FOUND
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -3191,6 +3205,7 @@ const schema = gql`
sort: SortParams
folder: String
): UpdatesSinceResult!
integration(id: ID!): IntegrationResult!
integrations: IntegrationsResult!
recentSearches: RecentSearchesResult!
rules(enabled: Boolean): RulesResult!

View File

@ -403,4 +403,55 @@ describe('Integrations resolvers', () => {
})
})
})
describe('integration API', () => {
const query = `
query Integration ($id: ID!) {
integration(id: $id) {
... on IntegrationSuccess {
integration {
id
type
enabled
}
}
... on IntegrationError {
errorCodes
}
}
}
`
let existingIntegration: Integration
before(async () => {
existingIntegration = await saveIntegration(
{
user: { id: loginUser.id },
name: 'READWISE',
token: 'fakeToken',
},
loginUser.id
)
})
after(async () => {
await deleteIntegrations(loginUser.id, [existingIntegration.id])
})
it('returns the integration', async () => {
const res = await graphqlRequest(query, authToken, {
id: existingIntegration.id,
})
expect(res.body.data.integration.integration.id).to.equal(
existingIntegration.id
)
expect(res.body.data.integration.integration.type).to.equal(
existingIntegration.type
)
expect(res.body.data.integration.integration.enabled).to.equal(
existingIntegration.enabled
)
})
})
})