From 4ce4cd0a62d98dac55e344eb89c86b67d7330d54 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 15 Feb 2023 12:17:26 +0800 Subject: [PATCH] Add a type for export or import to the integration table --- packages/api/src/entity/integration.ts | 15 ++++- packages/api/src/generated/graphql.ts | 56 ++++++++++++++++++- packages/api/src/generated/schema.graphql | 23 +++++++- .../api/src/resolvers/integrations/index.ts | 32 ++--------- packages/api/src/routers/svc/integrations.ts | 7 ++- packages/api/src/schema.ts | 25 ++++++++- packages/api/src/services/integrations.ts | 11 ++-- .../api/test/resolvers/integrations.test.ts | 45 +++++++-------- .../api/test/routers/integrations.test.ts | 24 ++++---- .../0111.do.change_type_in_integration.sql | 9 ++- .../0111.undo.change_type_in_integration.sql | 8 ++- 11 files changed, 174 insertions(+), 81 deletions(-) diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index 2aba8d7fb..a051ac0db 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -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 diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 22d51b8b4..656149b99 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -940,18 +940,37 @@ export enum HighlightType { Redaction = 'REDACTION' } +export type ImportFromIntegrationError = { + __typename?: 'ImportFromIntegrationError'; + errorCodes: Array; +}; + +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; + name: Scalars['String']; token: Scalars['String']; - type: IntegrationType; + type?: InputMaybe; }; export type SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess; @@ -3398,6 +3424,10 @@ export type ResolversTypes = { HighlightStats: ResolverTypeWrapper; HighlightType: HighlightType; ID: ResolverTypeWrapper; + ImportFromIntegrationError: ResolverTypeWrapper; + ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode; + ImportFromIntegrationResult: ResolversTypes['ImportFromIntegrationError'] | ResolversTypes['ImportFromIntegrationSuccess']; + ImportFromIntegrationSuccess: ResolverTypeWrapper; Int: ResolverTypeWrapper; Integration: ResolverTypeWrapper; 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; }; +export type ImportFromIntegrationErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type ImportFromIntegrationResultResolvers = { + __resolveType: TypeResolveFn<'ImportFromIntegrationError' | 'ImportFromIntegrationSuccess', ParentType, ContextType>; +}; + +export type ImportFromIntegrationSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type IntegrationResolvers = { createdAt?: Resolver; enabled?: Resolver; id?: Resolver; + name?: Resolver; token?: Resolver; type?: Resolver; updatedAt?: Resolver; @@ -4975,6 +5023,7 @@ export type MutationResolvers>; googleLogin?: Resolver>; googleSignup?: Resolver>; + importFromIntegration?: Resolver>; joinGroup?: Resolver>; leaveGroup?: Resolver>; logOut?: Resolver; @@ -6086,6 +6135,9 @@ export type Resolvers = { Highlight?: HighlightResolvers; HighlightReply?: HighlightReplyResolvers; HighlightStats?: HighlightStatsResolvers; + ImportFromIntegrationError?: ImportFromIntegrationErrorResolvers; + ImportFromIntegrationResult?: ImportFromIntegrationResultResolvers; + ImportFromIntegrationSuccess?: ImportFromIntegrationSuccessResolvers; Integration?: IntegrationResolvers; IntegrationsError?: IntegrationsErrorResolvers; IntegrationsResult?: IntegrationsResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 247ff2eda..651eafbff 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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 diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index e5a380c68..ab731b3f5 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -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) diff --git a/packages/api/src/routers/svc/integrations.ts b/packages/api/src/routers/svc/integrations.ts index bffa55c94..b9656ab9b 100644 --- a/packages/api/src/routers/svc/integrations.ts +++ b/packages/api/src/routers/svc/integrations.ts @@ -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) { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 3beeaff93..7751f0c33 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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 diff --git a/packages/api/src/services/integrations.ts b/packages/api/src/services/integrations.ts index 91d0df654..e6a5fa8d2 100644 --- a/packages/api/src/services/integrations.ts +++ b/packages/api/src/services/integrations.ts @@ -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 => { - 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 => { 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) { diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index 5e5b54f46..c3a8d4b44 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -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', }) diff --git a/packages/api/test/routers/integrations.test.ts b/packages/api/test/routers/integrations.test.ts index d5cf2507f..f6bb67d30 100644 --- a/packages/api/test/routers/integrations.test.ts +++ b/packages/api/test/routers/integrations.test.ts @@ -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') diff --git a/packages/db/migrations/0111.do.change_type_in_integration.sql b/packages/db/migrations/0111.do.change_type_in_integration.sql index ecdac43f6..ebaee00a7 100755 --- a/packages/db/migrations/0111.do.change_type_in_integration.sql +++ b/packages/db/migrations/0111.do.change_type_in_integration.sql @@ -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; diff --git a/packages/db/migrations/0111.undo.change_type_in_integration.sql b/packages/db/migrations/0111.undo.change_type_in_integration.sql index 0cd785f81..4b36d4b5b 100755 --- a/packages/db/migrations/0111.undo.change_type_in_integration.sql +++ b/packages/db/migrations/0111.undo.change_type_in_integration.sql @@ -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;