Add a type for export or import to the integration table

This commit is contained in:
Hongbo Wu
2023-02-15 12:17:26 +08:00
parent 9056318667
commit 4ce4cd0a62
11 changed files with 174 additions and 81 deletions

View File

@ -9,6 +9,11 @@ import {
} from 'typeorm'
import { User } from './user'
export enum IntegrationType {
Export = 'EXPORT',
Import = 'IMPORT',
}
@Entity({ name: 'integrations' })
export class Integration {
@PrimaryGeneratedColumn('uuid')
@ -18,8 +23,14 @@ export class Integration {
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
type!: string
@Column('varchar', { length: 40 })
name!: string
@Column('enum', {
enum: IntegrationType,
default: IntegrationType.Export,
})
type!: IntegrationType
@Column('varchar', { length: 255 })
token!: string

View File

@ -940,18 +940,37 @@ export enum HighlightType {
Redaction = 'REDACTION'
}
export type ImportFromIntegrationError = {
__typename?: 'ImportFromIntegrationError';
errorCodes: Array<ImportFromIntegrationErrorCode>;
};
export enum ImportFromIntegrationErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type ImportFromIntegrationResult = ImportFromIntegrationError | ImportFromIntegrationSuccess;
export type ImportFromIntegrationSuccess = {
__typename?: 'ImportFromIntegrationSuccess';
success: Scalars['Boolean'];
};
export type Integration = {
__typename?: 'Integration';
createdAt: Scalars['Date'];
enabled: Scalars['Boolean'];
id: Scalars['ID'];
name: Scalars['String'];
token: Scalars['String'];
type: IntegrationType;
updatedAt: Scalars['Date'];
};
export enum IntegrationType {
Readwise = 'READWISE'
Export = 'EXPORT',
Import = 'IMPORT'
}
export type IntegrationsError = {
@ -1223,6 +1242,7 @@ export type Mutation = {
generateApiKey: GenerateApiKeyResult;
googleLogin: LoginResult;
googleSignup: GoogleSignupResult;
importFromIntegration: ImportFromIntegrationResult;
joinGroup: JoinGroupResult;
leaveGroup: LeaveGroupResult;
logOut: LogOutResult;
@ -1389,6 +1409,11 @@ export type MutationGoogleSignupArgs = {
};
export type MutationImportFromIntegrationArgs = {
integrationId: Scalars['ID'];
};
export type MutationJoinGroupArgs = {
inviteCode: Scalars['String'];
};
@ -2387,8 +2412,9 @@ export enum SetIntegrationErrorCode {
export type SetIntegrationInput = {
enabled: Scalars['Boolean'];
id?: InputMaybe<Scalars['ID']>;
name: Scalars['String'];
token: Scalars['String'];
type: IntegrationType;
type?: InputMaybe<IntegrationType>;
};
export type SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess;
@ -3398,6 +3424,10 @@ export type ResolversTypes = {
HighlightStats: ResolverTypeWrapper<HighlightStats>;
HighlightType: HighlightType;
ID: ResolverTypeWrapper<Scalars['ID']>;
ImportFromIntegrationError: ResolverTypeWrapper<ImportFromIntegrationError>;
ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode;
ImportFromIntegrationResult: ResolversTypes['ImportFromIntegrationError'] | ResolversTypes['ImportFromIntegrationSuccess'];
ImportFromIntegrationSuccess: ResolverTypeWrapper<ImportFromIntegrationSuccess>;
Int: ResolverTypeWrapper<Scalars['Int']>;
Integration: ResolverTypeWrapper<Integration>;
IntegrationType: IntegrationType;
@ -3842,6 +3872,9 @@ export type ResolversParentTypes = {
HighlightReply: HighlightReply;
HighlightStats: HighlightStats;
ID: Scalars['ID'];
ImportFromIntegrationError: ImportFromIntegrationError;
ImportFromIntegrationResult: ResolversParentTypes['ImportFromIntegrationError'] | ResolversParentTypes['ImportFromIntegrationSuccess'];
ImportFromIntegrationSuccess: ImportFromIntegrationSuccess;
Int: Scalars['Int'];
Integration: Integration;
IntegrationsError: IntegrationsError;
@ -4764,10 +4797,25 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ImportFromIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ImportFromIntegrationError'] = ResolversParentTypes['ImportFromIntegrationError']> = {
errorCodes?: Resolver<Array<ResolversTypes['ImportFromIntegrationErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ImportFromIntegrationResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ImportFromIntegrationResult'] = ResolversParentTypes['ImportFromIntegrationResult']> = {
__resolveType: TypeResolveFn<'ImportFromIntegrationError' | 'ImportFromIntegrationSuccess', ParentType, ContextType>;
};
export type ImportFromIntegrationSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ImportFromIntegrationSuccess'] = ResolversParentTypes['ImportFromIntegrationSuccess']> = {
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__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>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['IntegrationType'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
@ -4975,6 +5023,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
googleSignup?: Resolver<ResolversTypes['GoogleSignupResult'], ParentType, ContextType, RequireFields<MutationGoogleSignupArgs, 'input'>>;
importFromIntegration?: Resolver<ResolversTypes['ImportFromIntegrationResult'], ParentType, ContextType, RequireFields<MutationImportFromIntegrationArgs, 'integrationId'>>;
joinGroup?: Resolver<ResolversTypes['JoinGroupResult'], ParentType, ContextType, RequireFields<MutationJoinGroupArgs, 'inviteCode'>>;
leaveGroup?: Resolver<ResolversTypes['LeaveGroupResult'], ParentType, ContextType, RequireFields<MutationLeaveGroupArgs, 'groupId'>>;
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
@ -6086,6 +6135,9 @@ export type Resolvers<ContextType = ResolverContext> = {
Highlight?: HighlightResolvers<ContextType>;
HighlightReply?: HighlightReplyResolvers<ContextType>;
HighlightStats?: HighlightStatsResolvers<ContextType>;
ImportFromIntegrationError?: ImportFromIntegrationErrorResolvers<ContextType>;
ImportFromIntegrationResult?: ImportFromIntegrationResultResolvers<ContextType>;
ImportFromIntegrationSuccess?: ImportFromIntegrationSuccessResolvers<ContextType>;
Integration?: IntegrationResolvers<ContextType>;
IntegrationsError?: IntegrationsErrorResolvers<ContextType>;
IntegrationsResult?: IntegrationsResultResolvers<ContextType>;

View File

@ -835,17 +835,34 @@ enum HighlightType {
REDACTION
}
type ImportFromIntegrationError {
errorCodes: [ImportFromIntegrationErrorCode!]!
}
enum ImportFromIntegrationErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union ImportFromIntegrationResult = ImportFromIntegrationError | ImportFromIntegrationSuccess
type ImportFromIntegrationSuccess {
success: Boolean!
}
type Integration {
createdAt: Date!
enabled: Boolean!
id: ID!
name: String!
token: String!
type: IntegrationType!
updatedAt: Date!
}
enum IntegrationType {
READWISE
EXPORT
IMPORT
}
type IntegrationsError {
@ -1093,6 +1110,7 @@ type Mutation {
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
importFromIntegration(integrationId: ID!): ImportFromIntegrationResult!
joinGroup(inviteCode: String!): JoinGroupResult!
leaveGroup(groupId: ID!): LeaveGroupResult!
logOut: LogOutResult!
@ -1775,8 +1793,9 @@ enum SetIntegrationErrorCode {
input SetIntegrationInput {
enabled: Boolean!
id: ID
name: String!
token: String!
type: IntegrationType!
type: IntegrationType
}
union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess

View File

@ -6,7 +6,6 @@ import {
IntegrationsError,
IntegrationsErrorCode,
IntegrationsSuccess,
IntegrationType,
MutationDeleteIntegrationArgs,
MutationSetIntegrationArgs,
SetIntegrationError,
@ -65,27 +64,17 @@ export const setIntegrationResolver = authorized<
}
} 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))) {
if (!(await validateToken(input.token, input.name))) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
}
integrationToSave = {
...integrationToSave,
name: input.name,
type: input.type ?? undefined,
token: input.token,
type: input.type,
enabled: true,
}
}
@ -126,10 +115,7 @@ export const setIntegrationResolver = authorized<
})
return {
integration: {
...integration,
type: integration.type as IntegrationType,
},
integration,
}
} catch (error) {
log.error(error)
@ -158,10 +144,7 @@ export const integrationsResolver = authorized<
})
return {
integrations: integrations.map((integration) => ({
...integration,
type: integration.type as IntegrationType,
})),
integrations,
}
} catch (error) {
log.error(error)
@ -225,10 +208,7 @@ export const deleteIntegrationResolver = authorized<
})
return {
integration: {
...deletedIntegration,
type: deletedIntegration.type as IntegrationType,
},
integration,
}
} catch (error) {
log.error(error)

View File

@ -24,10 +24,10 @@ const logger = buildLogger('app.dispatch')
export function integrationsServiceRouter() {
const router = express.Router()
router.post('/:integrationType/:action', async (req, res) => {
router.post('/:integrationName/:action', async (req, res) => {
logger.info('start to sync with integration', {
action: req.params.action,
integrationType: req.params.integrationType,
integrationName: req.params.integrationName,
})
const { message: msgStr, expired } = readPushSubscription(req)
@ -54,7 +54,8 @@ export function integrationsServiceRouter() {
const integration = await getRepository(Integration).findOneBy({
user: { id: userId },
type: req.params.integrationType.toUpperCase(),
name: req.params.integrationName.toUpperCase(),
type: IntegrationType.Export,
enabled: true,
})
if (!integration) {

View File

@ -1903,6 +1903,7 @@ const schema = gql`
type Integration {
id: ID!
name: String!
type: IntegrationType!
token: String!
enabled: Boolean!
@ -1911,7 +1912,8 @@ const schema = gql`
}
enum IntegrationType {
READWISE
EXPORT
IMPORT
}
type SetIntegrationError {
@ -1928,7 +1930,8 @@ const schema = gql`
input SetIntegrationInput {
id: ID
type: IntegrationType!
name: String!
type: IntegrationType
token: String!
enabled: Boolean!
}
@ -2416,6 +2419,23 @@ const schema = gql`
UNAUTHORIZED
}
union ImportFromIntegrationResult =
ImportFromIntegrationSuccess
| ImportFromIntegrationError
type ImportFromIntegrationSuccess {
success: Boolean!
}
type ImportFromIntegrationError {
errorCodes: [ImportFromIntegrationErrorCode!]!
}
enum ImportFromIntegrationErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2506,6 +2526,7 @@ const schema = gql`
): UploadImportFileResult!
markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult!
bulkAction(query: String, action: BulkActionType!): BulkActionResult!
importFromIntegration(integrationId: ID!): ImportFromIntegrationResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed

View File

@ -1,4 +1,3 @@
import { IntegrationType } from '../generated/graphql'
import { env } from '../env'
import axios from 'axios'
import { wait } from '../utils/helpers'
@ -38,10 +37,10 @@ export const READWISE_API_URL = 'https://readwise.io/api/v2'
export const validateToken = async (
token: string,
type: IntegrationType
name: string
): Promise<boolean> => {
switch (type) {
case IntegrationType.Readwise:
switch (name) {
case 'READWISE':
return validateReadwiseToken(token)
default:
return false
@ -97,8 +96,8 @@ export const syncWithIntegration = async (
pages: Page[]
): Promise<boolean> => {
let result = true
switch (integration.type) {
case IntegrationType.Readwise: {
switch (integration.name) {
case 'READWISE': {
const highlights = pages.flatMap(pageToReadwiseHighlight)
// If there are no highlights, we will skip the sync
if (highlights.length > 0) {

View File

@ -2,10 +2,7 @@ import 'mocha'
import { User } from '../../src/entity/user'
import { createTestUser, deleteTestIntegrations, deleteTestUser } from '../db'
import { generateFakeUuid, graphqlRequest, request } from '../util'
import {
IntegrationType,
SetIntegrationErrorCode,
} from '../../src/generated/graphql'
import { SetIntegrationErrorCode } from '../../src/generated/graphql'
import { expect } from 'chai'
import { getRepository } from '../../src/entity/utils'
import { Integration } from '../../src/entity/integration'
@ -34,14 +31,14 @@ describe('Integrations resolvers', () => {
const validToken = 'valid-token'
const query = (
id = '',
type: IntegrationType = IntegrationType.Readwise,
name = 'READWISE',
token: string = 'test token',
enabled = true
) => `
mutation {
setIntegration(input: {
id: "${id}",
type: ${type},
name: ${name},
token: "${token}",
enabled: ${enabled},
}) {
@ -59,7 +56,7 @@ describe('Integrations resolvers', () => {
`
let integrationId: string
let token: string
let integrationType: IntegrationType
let integrationName: string
let enabled: boolean
let scope: nock.Scope
@ -88,10 +85,10 @@ describe('Integrations resolvers', () => {
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: { id: loginUser.id },
type: 'READWISE',
name: 'READWISE',
token: 'fakeToken',
})
integrationType = existingIntegration.type as IntegrationType
integrationName = existingIntegration.name
})
after(async () => {
@ -100,7 +97,7 @@ describe('Integrations resolvers', () => {
it('returns AlreadyExists error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
query(integrationId, integrationName),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
@ -117,7 +114,7 @@ describe('Integrations resolvers', () => {
it('returns InvalidToken error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
query(integrationId, integrationName, token),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
@ -134,13 +131,13 @@ describe('Integrations resolvers', () => {
afterEach(async () => {
await deleteTestIntegrations(loginUser.id, {
user: { id: loginUser.id },
type: integrationType,
name: integrationName,
})
})
it('creates new integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
query(integrationId, integrationName, token),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be.true
@ -148,7 +145,7 @@ describe('Integrations resolvers', () => {
it('creates new cloud task to sync all existing articles and highlights', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token),
query(integrationId, integrationName, token),
authToken
)
const integration = await getRepository(Integration).findOneBy({
@ -170,7 +167,7 @@ describe('Integrations resolvers', () => {
it('returns NotFound error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
query(integrationId, integrationName),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
@ -187,7 +184,7 @@ describe('Integrations resolvers', () => {
otherUser = await createTestUser('otherUser')
existingIntegration = await getRepository(Integration).save({
user: { id: otherUser.id },
type: 'READWISE',
name: 'READWISE',
token: 'fakeToken',
})
integrationId = existingIntegration.id
@ -200,7 +197,7 @@ describe('Integrations resolvers', () => {
it('returns Unauthorized error code', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType),
query(integrationId, integrationName),
authToken
)
expect(res.body.data.setIntegration.errorCodes).to.eql([
@ -213,7 +210,7 @@ describe('Integrations resolvers', () => {
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: { id: loginUser.id },
type: 'READWISE',
name: 'READWISE',
token: 'fakeToken',
})
integrationId = existingIntegration.id
@ -237,7 +234,7 @@ describe('Integrations resolvers', () => {
it('disables integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
query(integrationId, integrationName, token, enabled),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be
@ -246,7 +243,7 @@ describe('Integrations resolvers', () => {
it('deletes cloud task', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
query(integrationId, integrationName, token, enabled),
authToken
)
const integration = await getRepository(Integration).findOneBy({
@ -270,7 +267,7 @@ describe('Integrations resolvers', () => {
it('enables integration', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
query(integrationId, integrationName, token, enabled),
authToken
)
expect(res.body.data.setIntegration.integration.enabled).to.be
@ -279,7 +276,7 @@ describe('Integrations resolvers', () => {
it('creates new cloud task to sync all existing articles and highlights', async () => {
const res = await graphqlRequest(
query(integrationId, integrationType, token, enabled),
query(integrationId, integrationName, token, enabled),
authToken
)
const integration = await getRepository(Integration).findOneBy({
@ -313,7 +310,7 @@ describe('Integrations resolvers', () => {
before(async () => {
existingIntegration = await getRepository(Integration).save({
user: { id: loginUser.id },
type: 'READWISE',
name: 'READWISE',
token: 'fakeToken',
})
})
@ -359,7 +356,7 @@ describe('Integrations resolvers', () => {
beforeEach(async () => {
existingIntegration = await getRepository(Integration).save({
user: { id: loginUser.id },
type: 'READWISE',
name: 'READWISE',
token: 'fakeToken',
taskName: 'some task name',
})

View File

@ -26,11 +26,11 @@ 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}`
const endpoint = (token: string, name = 'name', action = 'action') =>
`/svc/pubsub/integrations/${name}/${action}?token=${token}`
let action: string
let data: PubSubRequestBody
let integrationType: string
let integrationName: string
context('when token is invalid', () => {
before(() => {
@ -95,7 +95,7 @@ describe('Integrations routers', () => {
context('when integration not found', () => {
before(() => {
integrationType = 'READWISE'
integrationName = 'READWISE'
data = {
message: {
data: Buffer.from(
@ -108,7 +108,7 @@ describe('Integrations routers', () => {
it('returns 200 with No integration found', async () => {
const res = await request
.post(endpoint(token, integrationType))
.post(endpoint(token, integrationName))
.send(data)
.expect(200)
expect(res.text).to.eql('No integration found')
@ -125,10 +125,10 @@ describe('Integrations routers', () => {
before(async () => {
integration = await getRepository(Integration).save({
user: { id: user.id },
type: 'READWISE',
name: 'READWISE',
token: 'token',
})
integrationType = integration.type
integrationName = integration.name
// create page
page = await createTestElasticPage(user.id)
ctx = {
@ -177,7 +177,7 @@ describe('Integrations routers', () => {
})
context('when action is sync_updated', () => {
before(async () => {
before(() => {
action = 'sync_updated'
})
@ -208,7 +208,7 @@ describe('Integrations routers', () => {
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
@ -240,7 +240,7 @@ describe('Integrations routers', () => {
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
@ -275,7 +275,7 @@ describe('Integrations routers', () => {
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
@ -313,7 +313,7 @@ describe('Integrations routers', () => {
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationType, action))
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')

View File

@ -4,6 +4,13 @@
BEGIN;
ALTER TABLE omnivore.integrations ALTER COLUMN "type" TYPE text;
ALTER TABLE omnivore.integrations RENAME COLUMN "type" TO "name";
ALTER TABLE omnivore.integrations
DROP CONSTRAINT integrations_user_id_type_key,
ALTER COLUMN "name" TYPE VARCHAR(40) USING "name"::VARCHAR(40);
DROP TYPE omnivore.integration_type;
CREATE TYPE omnivore.integration_type AS ENUM ('EXPORT', 'IMPORT');
ALTER TABLE omnivore.integrations
ADD COLUMN "type" omnivore.integration_type NOT NULL DEFAULT 'EXPORT';
COMMIT;

View File

@ -4,6 +4,12 @@
BEGIN;
ALTER TABLE omnivore.integrations ALTER COLUMN "type" TYPE omnivore.integration_type USING "type"::omnivore.integration_type;
ALTER TABLE omnivore.integrations DROP COLUMN "type";
DROP TYPE omnivore.integration_type;
CREATE TYPE omnivore.integration_type AS ENUM ('READWISE', 'POCKET');
ALTER TABLE omnivore.integrations
ALTER COLUMN "name" TYPE omnivore.integration_type USING "name"::omnivore.integration_type,
ADD CONSTRAINT integrations_user_id_type_key UNIQUE (user_id, "name");
ALTER TABLE omnivore.integrations RENAME COLUMN "name" TO "type";
COMMIT;