diff --git a/packages/api/src/entity/webhook.ts b/packages/api/src/entity/webhook.ts new file mode 100644 index 000000000..253fa1b5a --- /dev/null +++ b/packages/api/src/entity/webhook.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { User } from './user' + +@Entity({ name: 'webhooks' }) +export class Webhook { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('text') + url!: string + + @Column('text', { array: true }) + eventTypes!: string[] + + @Column('text', { default: 'POST' }) + method!: string + + @Column('text', { default: 'application/json' }) + contentType!: string + + @Column('boolean', { default: true }) + enabled!: boolean + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date + + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 201e76621..f6b93caa6 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -502,6 +502,24 @@ export type DeleteReminderSuccess = { reminder: Reminder; }; +export type DeleteWebhookError = { + __typename?: 'DeleteWebhookError'; + errorCodes: Array; +}; + +export enum DeleteWebhookErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type DeleteWebhookResult = DeleteWebhookError | DeleteWebhookSuccess; + +export type DeleteWebhookSuccess = { + __typename?: 'DeleteWebhookSuccess'; + webhook: Webhook; +}; + export type DeviceToken = { __typename?: 'DeviceToken'; createdAt: Scalars['Date']; @@ -817,6 +835,7 @@ export type Mutation = { deleteNewsletterEmail: DeleteNewsletterEmailResult; deleteReaction: DeleteReactionResult; deleteReminder: DeleteReminderResult; + deleteWebhook: DeleteWebhookResult; generateApiKey: GenerateApiKeyResult; googleLogin: LoginResult; googleSignup: GoogleSignupResult; @@ -836,6 +855,7 @@ export type Mutation = { setShareArticle: SetShareArticleResult; setShareHighlight: SetShareHighlightResult; setUserPersonalization: SetUserPersonalizationResult; + setWebhook: SetWebhookResult; signup: SignupResult; subscribe: SubscribeResult; unsubscribe: UnsubscribeResult; @@ -922,6 +942,11 @@ export type MutationDeleteReminderArgs = { }; +export type MutationDeleteWebhookArgs = { + id: Scalars['ID']; +}; + + export type MutationGenerateApiKeyArgs = { input: GenerateApiKeyInput; }; @@ -1012,6 +1037,11 @@ export type MutationSetUserPersonalizationArgs = { }; +export type MutationSetWebhookArgs = { + input: SetWebhookInput; +}; + + export type MutationSignupArgs = { input: SignupInput; }; @@ -1180,6 +1210,8 @@ export type Query = { user: UserResult; users: UsersResult; validateUsername: Scalars['Boolean']; + webhook: WebhookResult; + webhooks: WebhooksResult; }; @@ -1256,6 +1288,11 @@ export type QueryValidateUsernameArgs = { username: Scalars['String']; }; + +export type QueryWebhookArgs = { + id: Scalars['ID']; +}; + export type Reaction = { __typename?: 'Reaction'; code: ReactionType; @@ -1611,6 +1648,34 @@ export type SetUserPersonalizationSuccess = { updatedUserPersonalization: UserPersonalization; }; +export type SetWebhookError = { + __typename?: 'SetWebhookError'; + errorCodes: Array; +}; + +export enum SetWebhookErrorCode { + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type SetWebhookInput = { + contentType?: InputMaybe; + enabled?: InputMaybe; + eventTypes: Array; + id?: InputMaybe; + method?: InputMaybe; + url: Scalars['String']; +}; + +export type SetWebhookResult = SetWebhookError | SetWebhookSuccess; + +export type SetWebhookSuccess = { + __typename?: 'SetWebhookSuccess'; + webhook: Webhook; +}; + export type SharedArticleError = { __typename?: 'SharedArticleError'; errorCodes: Array; @@ -2073,6 +2138,65 @@ export type UserSuccess = { user: User; }; +export type Webhook = { + __typename?: 'Webhook'; + contentType: Scalars['String']; + createdAt: Scalars['Date']; + enabled: Scalars['Boolean']; + eventTypes: Array; + id: Scalars['ID']; + method: Scalars['String']; + updatedAt: Scalars['Date']; + url: Scalars['String']; +}; + +export type WebhookError = { + __typename?: 'WebhookError'; + errorCodes: Array; +}; + +export enum WebhookErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export enum WebhookEvent { + HighlightCreated = 'HIGHLIGHT_CREATED', + HighlightDeleted = 'HIGHLIGHT_DELETED', + HighlightUpdated = 'HIGHLIGHT_UPDATED', + LabelCreated = 'LABEL_CREATED', + LabelDeleted = 'LABEL_DELETED', + LabelUpdated = 'LABEL_UPDATED', + PageCreated = 'PAGE_CREATED', + PageDeleted = 'PAGE_DELETED', + PageUpdated = 'PAGE_UPDATED' +} + +export type WebhookResult = WebhookError | WebhookSuccess; + +export type WebhooksError = { + __typename?: 'WebhooksError'; + errorCodes: Array; +}; + +export enum WebhooksErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type WebhooksResult = WebhooksError | WebhooksSuccess; + +export type WebhooksSuccess = { + __typename?: 'WebhooksSuccess'; + webhooks: Array; +}; + +export type WebhookSuccess = { + __typename?: 'WebhookSuccess'; + webhook: Webhook; +}; + export type ResolverTypeWrapper = Promise | T; @@ -2234,6 +2358,10 @@ export type ResolversTypes = { DeleteReminderErrorCode: DeleteReminderErrorCode; DeleteReminderResult: ResolversTypes['DeleteReminderError'] | ResolversTypes['DeleteReminderSuccess']; DeleteReminderSuccess: ResolverTypeWrapper; + DeleteWebhookError: ResolverTypeWrapper; + DeleteWebhookErrorCode: DeleteWebhookErrorCode; + DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess']; + DeleteWebhookSuccess: ResolverTypeWrapper; DeviceToken: ResolverTypeWrapper; FeedArticle: ResolverTypeWrapper; FeedArticleEdge: ResolverTypeWrapper; @@ -2367,6 +2495,11 @@ export type ResolversTypes = { SetUserPersonalizationInput: SetUserPersonalizationInput; SetUserPersonalizationResult: ResolversTypes['SetUserPersonalizationError'] | ResolversTypes['SetUserPersonalizationSuccess']; SetUserPersonalizationSuccess: ResolverTypeWrapper; + SetWebhookError: ResolverTypeWrapper; + SetWebhookErrorCode: SetWebhookErrorCode; + SetWebhookInput: SetWebhookInput; + SetWebhookResult: ResolversTypes['SetWebhookError'] | ResolversTypes['SetWebhookSuccess']; + SetWebhookSuccess: ResolverTypeWrapper; SharedArticleError: ResolverTypeWrapper; SharedArticleErrorCode: SharedArticleErrorCode; SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess']; @@ -2456,6 +2589,16 @@ export type ResolversTypes = { UsersResult: ResolversTypes['UsersError'] | ResolversTypes['UsersSuccess']; UsersSuccess: ResolverTypeWrapper; UserSuccess: ResolverTypeWrapper; + Webhook: ResolverTypeWrapper; + WebhookError: ResolverTypeWrapper; + WebhookErrorCode: WebhookErrorCode; + WebhookEvent: WebhookEvent; + WebhookResult: ResolversTypes['WebhookError'] | ResolversTypes['WebhookSuccess']; + WebhooksError: ResolverTypeWrapper; + WebhooksErrorCode: WebhooksErrorCode; + WebhooksResult: ResolversTypes['WebhooksError'] | ResolversTypes['WebhooksSuccess']; + WebhooksSuccess: ResolverTypeWrapper; + WebhookSuccess: ResolverTypeWrapper; }; /** Mapping between all available schema types and the resolvers parents */ @@ -2531,6 +2674,9 @@ export type ResolversParentTypes = { DeleteReminderError: DeleteReminderError; DeleteReminderResult: ResolversParentTypes['DeleteReminderError'] | ResolversParentTypes['DeleteReminderSuccess']; DeleteReminderSuccess: DeleteReminderSuccess; + DeleteWebhookError: DeleteWebhookError; + DeleteWebhookResult: ResolversParentTypes['DeleteWebhookError'] | ResolversParentTypes['DeleteWebhookSuccess']; + DeleteWebhookSuccess: DeleteWebhookSuccess; DeviceToken: DeviceToken; FeedArticle: FeedArticle; FeedArticleEdge: FeedArticleEdge; @@ -2640,6 +2786,10 @@ export type ResolversParentTypes = { SetUserPersonalizationInput: SetUserPersonalizationInput; SetUserPersonalizationResult: ResolversParentTypes['SetUserPersonalizationError'] | ResolversParentTypes['SetUserPersonalizationSuccess']; SetUserPersonalizationSuccess: SetUserPersonalizationSuccess; + SetWebhookError: SetWebhookError; + SetWebhookInput: SetWebhookInput; + SetWebhookResult: ResolversParentTypes['SetWebhookError'] | ResolversParentTypes['SetWebhookSuccess']; + SetWebhookSuccess: SetWebhookSuccess; SharedArticleError: SharedArticleError; SharedArticleResult: ResolversParentTypes['SharedArticleError'] | ResolversParentTypes['SharedArticleSuccess']; SharedArticleSuccess: SharedArticleSuccess; @@ -2708,6 +2858,13 @@ export type ResolversParentTypes = { UsersResult: ResolversParentTypes['UsersError'] | ResolversParentTypes['UsersSuccess']; UsersSuccess: UsersSuccess; UserSuccess: UserSuccess; + Webhook: Webhook; + WebhookError: WebhookError; + WebhookResult: ResolversParentTypes['WebhookError'] | ResolversParentTypes['WebhookSuccess']; + WebhooksError: WebhooksError; + WebhooksResult: ResolversParentTypes['WebhooksError'] | ResolversParentTypes['WebhooksSuccess']; + WebhooksSuccess: WebhooksSuccess; + WebhookSuccess: WebhookSuccess; }; export type SanitizeDirectiveArgs = { @@ -3051,6 +3208,20 @@ export type DeleteReminderSuccessResolvers; }; +export type DeleteWebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type DeleteWebhookResultResolvers = { + __resolveType: TypeResolveFn<'DeleteWebhookError' | 'DeleteWebhookSuccess', ParentType, ContextType>; +}; + +export type DeleteWebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeviceTokenResolvers = { createdAt?: Resolver; id?: Resolver; @@ -3301,6 +3472,7 @@ export type MutationResolvers>; deleteReaction?: Resolver>; deleteReminder?: Resolver>; + deleteWebhook?: Resolver>; generateApiKey?: Resolver>; googleLogin?: Resolver>; googleSignup?: Resolver>; @@ -3320,6 +3492,7 @@ export type MutationResolvers>; setShareHighlight?: Resolver>; setUserPersonalization?: Resolver>; + setWebhook?: Resolver>; signup?: Resolver>; subscribe?: Resolver>; unsubscribe?: Resolver>; @@ -3410,6 +3583,8 @@ export type QueryResolvers>; users?: Resolver; validateUsername?: Resolver>; + webhook?: Resolver>; + webhooks?: Resolver; }; export type ReactionResolvers = { @@ -3639,6 +3814,20 @@ export type SetUserPersonalizationSuccessResolvers; }; +export type SetWebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SetWebhookResultResolvers = { + __resolveType: TypeResolveFn<'SetWebhookError' | 'SetWebhookSuccess', ParentType, ContextType>; +}; + +export type SetWebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SharedArticleErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3928,6 +4117,46 @@ export type UserSuccessResolvers; }; +export type WebhookResolvers = { + contentType?: Resolver; + createdAt?: Resolver; + enabled?: Resolver; + eventTypes?: Resolver, ParentType, ContextType>; + id?: Resolver; + method?: Resolver; + updatedAt?: Resolver; + url?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhookResultResolvers = { + __resolveType: TypeResolveFn<'WebhookError' | 'WebhookSuccess', ParentType, ContextType>; +}; + +export type WebhooksErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhooksResultResolvers = { + __resolveType: TypeResolveFn<'WebhooksError' | 'WebhooksSuccess', ParentType, ContextType>; +}; + +export type WebhooksSuccessResolvers = { + webhooks?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AddPopularReadError?: AddPopularReadErrorResolvers; AddPopularReadResult?: AddPopularReadResultResolvers; @@ -3990,6 +4219,9 @@ export type Resolvers = { DeleteReminderError?: DeleteReminderErrorResolvers; DeleteReminderResult?: DeleteReminderResultResolvers; DeleteReminderSuccess?: DeleteReminderSuccessResolvers; + DeleteWebhookError?: DeleteWebhookErrorResolvers; + DeleteWebhookResult?: DeleteWebhookResultResolvers; + DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers; DeviceToken?: DeviceTokenResolvers; FeedArticle?: FeedArticleResolvers; FeedArticleEdge?: FeedArticleEdgeResolvers; @@ -4077,6 +4309,9 @@ export type Resolvers = { SetUserPersonalizationError?: SetUserPersonalizationErrorResolvers; SetUserPersonalizationResult?: SetUserPersonalizationResultResolvers; SetUserPersonalizationSuccess?: SetUserPersonalizationSuccessResolvers; + SetWebhookError?: SetWebhookErrorResolvers; + SetWebhookResult?: SetWebhookResultResolvers; + SetWebhookSuccess?: SetWebhookSuccessResolvers; SharedArticleError?: SharedArticleErrorResolvers; SharedArticleResult?: SharedArticleResultResolvers; SharedArticleSuccess?: SharedArticleSuccessResolvers; @@ -4132,6 +4367,13 @@ export type Resolvers = { UsersResult?: UsersResultResolvers; UsersSuccess?: UsersSuccessResolvers; UserSuccess?: UserSuccessResolvers; + Webhook?: WebhookResolvers; + WebhookError?: WebhookErrorResolvers; + WebhookResult?: WebhookResultResolvers; + WebhooksError?: WebhooksErrorResolvers; + WebhooksResult?: WebhooksResultResolvers; + WebhooksSuccess?: WebhooksSuccessResolvers; + WebhookSuccess?: WebhookSuccessResolvers; }; export type DirectiveResolvers = { diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index d6c81bf3e..3e228c912 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -440,6 +440,22 @@ type DeleteReminderSuccess { reminder: Reminder! } +type DeleteWebhookError { + errorCodes: [DeleteWebhookErrorCode!]! +} + +enum DeleteWebhookErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union DeleteWebhookResult = DeleteWebhookError | DeleteWebhookSuccess + +type DeleteWebhookSuccess { + webhook: Webhook! +} + type DeviceToken { createdAt: Date! id: ID! @@ -725,6 +741,7 @@ type Mutation { deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! deleteReaction(id: ID!): DeleteReactionResult! deleteReminder(id: ID!): DeleteReminderResult! + deleteWebhook(id: ID!): DeleteWebhookResult! generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! googleLogin(input: GoogleLoginInput!): LoginResult! googleSignup(input: GoogleSignupInput!): GoogleSignupResult! @@ -744,6 +761,7 @@ type Mutation { setShareArticle(input: SetShareArticleInput!): SetShareArticleResult! setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! + setWebhook(input: SetWebhookInput!): SetWebhookResult! signup(input: SignupInput!): SignupResult! subscribe(name: String!): SubscribeResult! unsubscribe(name: String!): UnsubscribeResult! @@ -856,6 +874,8 @@ type Query { user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! + webhook(id: ID!): WebhookResult! + webhooks: WebhooksResult! } type Reaction { @@ -1185,6 +1205,32 @@ type SetUserPersonalizationSuccess { updatedUserPersonalization: UserPersonalization! } +type SetWebhookError { + errorCodes: [SetWebhookErrorCode!]! +} + +enum SetWebhookErrorCode { + ALREADY_EXISTS + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input SetWebhookInput { + contentType: String + enabled: Boolean + eventTypes: [WebhookEvent!]! + id: ID + method: String + url: String! +} + +union SetWebhookResult = SetWebhookError | SetWebhookSuccess + +type SetWebhookSuccess { + webhook: Webhook! +} + type SharedArticleError { errorCodes: [SharedArticleErrorCode!]! } @@ -1607,3 +1653,57 @@ type UsersSuccess { type UserSuccess { user: User! } + +type Webhook { + contentType: String! + createdAt: Date! + enabled: Boolean! + eventTypes: [WebhookEvent!]! + id: ID! + method: String! + updatedAt: Date! + url: String! +} + +type WebhookError { + errorCodes: [WebhookErrorCode!]! +} + +enum WebhookErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +enum WebhookEvent { + HIGHLIGHT_CREATED + HIGHLIGHT_DELETED + HIGHLIGHT_UPDATED + LABEL_CREATED + LABEL_DELETED + LABEL_UPDATED + PAGE_CREATED + PAGE_DELETED + PAGE_UPDATED +} + +union WebhookResult = WebhookError | WebhookSuccess + +type WebhooksError { + errorCodes: [WebhooksErrorCode!]! +} + +enum WebhooksErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union WebhooksResult = WebhooksError | WebhooksSuccess + +type WebhooksSuccess { + webhooks: [Webhook!]! +} + +type WebhookSuccess { + webhook: Webhook! +} diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 67baa03f9..de13e589d 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -32,6 +32,7 @@ import { deleteLabelResolver, deleteNewsletterEmailResolver, deleteReminderResolver, + deleteWebhookResolver, getAllUsersResolver, getArticleResolver, getArticlesResolver, @@ -64,6 +65,7 @@ import { setShareArticleResolver, setShareHighlightResolver, setUserPersonalizationResolver, + setWebhookResolver, signupResolver, subscribeResolver, subscriptionsResolver, @@ -71,14 +73,16 @@ import { updateHighlightResolver, updateLabelResolver, updateLinkShareInfoResolver, + updatePageResolver, updateReminderResolver, updateSharedCommentResolver, updateUserProfileResolver, updateUserResolver, uploadFileRequestResolver, validateUsernameResolver, - updatePageResolver, addPopularReadResolver, + webhookResolver, + webhooksResolver, } from './index' import { getShareInfoForArticle } from '../datalayer/links/share_info' import { @@ -151,6 +155,8 @@ export const functionResolvers = { updatePage: updatePageResolver, subscribe: subscribeResolver, addPopularRead: addPopularReadResolver, + setWebhook: setWebhookResolver, + deleteWebhook: deleteWebhookResolver, }, Query: { me: getMeUserResolver, @@ -170,6 +176,8 @@ export const functionResolvers = { labels: labelsResolver, search: searchResolver, subscriptions: subscriptionsResolver, + webhooks: webhooksResolver, + webhook: webhookResolver, }, User: { async sharedArticles( @@ -563,4 +571,8 @@ export const functionResolvers = { ...resultResolveTypeResolver('UpdatePage'), ...resultResolveTypeResolver('Subscribe'), ...resultResolveTypeResolver('AddPopularRead'), + ...resultResolveTypeResolver('SetWebhook'), + ...resultResolveTypeResolver('Webhooks'), + ...resultResolveTypeResolver('DeleteWebhook'), + ...resultResolveTypeResolver('Webhook'), } diff --git a/packages/api/src/resolvers/index.ts b/packages/api/src/resolvers/index.ts index fc0da5241..58434a7e4 100644 --- a/packages/api/src/resolvers/index.ts +++ b/packages/api/src/resolvers/index.ts @@ -17,3 +17,4 @@ export * from './labels' export * from './subscriptions' export * from './update' export * from './popular_reads' +export * from './webhooks' diff --git a/packages/api/src/resolvers/webhooks/index.ts b/packages/api/src/resolvers/webhooks/index.ts new file mode 100644 index 000000000..718c7f6fc --- /dev/null +++ b/packages/api/src/resolvers/webhooks/index.ts @@ -0,0 +1,240 @@ +import { authorized } from '../../utils/helpers' +import { + DeleteWebhookError, + DeleteWebhookErrorCode, + DeleteWebhookSuccess, + MutationDeleteWebhookArgs, + MutationSetWebhookArgs, + QueryWebhookArgs, + SetWebhookError, + SetWebhookErrorCode, + SetWebhookSuccess, + Webhook as WebhookResponse, + WebhookError, + WebhookErrorCode, + WebhookEvent, + WebhooksError, + WebhooksErrorCode, + WebhooksSuccess, + WebhookSuccess, +} from '../../generated/graphql' +import { getRepository } from '../../entity/utils' +import { User } from '../../entity/user' +import { Webhook } from '../../entity/webhook' +import { analytics } from '../../utils/analytics' +import { env } from '../../env' + +export const webhooksResolver = authorized( + async (_obj, _params, { claims: { uid }, log }) => { + log.info('webhooksResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [WebhooksErrorCode.Unauthorized], + } + } + + const webhooks = await getRepository(Webhook).findBy({ + user: { id: uid }, + }) + + return { + webhooks: webhooks.map((webhook) => webhookDataToResponse(webhook)), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [WebhooksErrorCode.BadRequest], + } + } + } +) + +export const webhookResolver = authorized< + WebhookSuccess, + WebhookError, + QueryWebhookArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('webhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [WebhookErrorCode.Unauthorized], + } + } + + const webhook = await getRepository(Webhook).findOne({ + where: { id }, + relations: ['user'], + }) + + if (!webhook) { + return { + errorCodes: [WebhookErrorCode.NotFound], + } + } + + if (webhook.user.id !== uid) { + return { + errorCodes: [WebhookErrorCode.Unauthorized], + } + } + + return { + webhook: webhookDataToResponse(webhook), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [WebhookErrorCode.BadRequest], + } + } +}) + +export const deleteWebhookResolver = authorized< + DeleteWebhookSuccess, + DeleteWebhookError, + MutationDeleteWebhookArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('deleteWebhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [DeleteWebhookErrorCode.Unauthorized], + } + } + + const webhook = await getRepository(Webhook).findOne({ + where: { id }, + relations: ['user'], + }) + + if (!webhook) { + return { + errorCodes: [DeleteWebhookErrorCode.NotFound], + } + } + + if (webhook.user.id !== uid) { + return { + errorCodes: [DeleteWebhookErrorCode.Unauthorized], + } + } + + const deletedWebhook = await getRepository(Webhook).remove(webhook) + deletedWebhook.id = id + + analytics.track({ + userId: uid, + event: 'webhook_delete', + properties: { + webhookId: webhook.id, + env: env.server.apiEnv, + }, + }) + + return { + webhook: webhookDataToResponse(deletedWebhook), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [DeleteWebhookErrorCode.BadRequest], + } + } +}) + +export const setWebhookResolver = authorized< + SetWebhookSuccess, + SetWebhookError, + MutationSetWebhookArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('setWebhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [SetWebhookErrorCode.Unauthorized], + } + } + + const webhookToSave: Partial = { + url: input.url, + eventTypes: input.eventTypes as string[], + method: input.method || 'POST', + contentType: input.contentType || 'application/json', + enabled: input.enabled === null ? true : input.enabled, + } + + if (input.id) { + // Update + const existingWebhook = await getRepository(Webhook).findOne({ + where: { id: input.id }, + relations: ['user'], + }) + if (!existingWebhook) { + return { + errorCodes: [SetWebhookErrorCode.NotFound], + } + } + if (existingWebhook.user.id !== uid) { + return { + errorCodes: [SetWebhookErrorCode.Unauthorized], + } + } + + webhookToSave.id = input.id + } else { + // Create + const existingWebhook = await getRepository(Webhook).findOneBy({ + user: { id: uid }, + eventTypes: `{${input.eventTypes.join(',')}}`, + }) + + if (existingWebhook) { + return { + errorCodes: [SetWebhookErrorCode.AlreadyExists], + } + } + } + + const webhook = await getRepository(Webhook).save({ + user, + ...webhookToSave, + }) + + analytics.track({ + userId: uid, + event: 'webhook_set', + properties: { + webhookId: webhook.id, + env: env.server.apiEnv, + }, + }) + + return { + webhook: webhookDataToResponse(webhook), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [SetWebhookErrorCode.BadRequest], + } + } +}) + +const webhookDataToResponse = (webhook: Webhook): WebhookResponse => ({ + ...webhook, + eventTypes: webhook.eventTypes as WebhookEvent[], +}) diff --git a/packages/api/src/routers/svc/webhooks.ts b/packages/api/src/routers/svc/webhooks.ts new file mode 100644 index 000000000..eaa351be9 --- /dev/null +++ b/packages/api/src/routers/svc/webhooks.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import express from 'express' +import { readPushSubscription } from '../../datalayer/pubsub' +import { getRepository } from '../../entity/utils' +import { Webhook } from '../../entity/webhook' +import axios, { Method } from 'axios' + +export function webhooksServiceRouter() { + const router = express.Router() + + router.post('/trigger/:action', async (req, res) => { + console.log('trigger webhook of action', req.params.action) + const { message: msgStr, expired } = readPushSubscription(req) + + if (!msgStr) { + res.status(400).send('Bad Request') + return + } + + if (expired) { + console.log('discarding expired message') + res.status(200).send('Expired') + return + } + + try { + const data = JSON.parse(msgStr) + const { userId, type } = data + if (!userId || !type) { + console.log('No userId or type found in message') + res.status(400).send('Bad Request') + return + } + + // example: PAGE_CREATED + const eventType = `${type as string}_${req.params.action}`.toUpperCase() + const webhooks = await getRepository(Webhook) + .createQueryBuilder() + .where('user_id = :userId', { userId }) + .andWhere(':eventType = ANY(event_types)', { eventType }) + .andWhere('enabled = true') + .getMany() + + if (webhooks.length <= 0) { + console.log( + 'No active webhook found for user', + userId, + 'and eventType', + eventType + ) + res.status(200).send('No webhook found') + return + } + + // trigger webhooks + for (const webhook of webhooks) { + const url = webhook.url + const method = webhook.method as Method + const body = JSON.stringify({ + action: req.params.action, + userId, + [type]: data, + }) + + console.log('triggering webhook', url, method, body) + await axios.request({ + url, + method, + headers: { + 'Content-Type': webhook.contentType, + }, + data: body, + }) + } + + res.status(200).send('OK') + } catch (err) { + console.log('trigger webhook failed', err) + res.status(500).send(err) + } + }) + + return router +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 2a0e8959a..5b634277e 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1571,6 +1571,102 @@ const schema = gql` NOT_FOUND } + input SetWebhookInput { + id: ID + url: String! + eventTypes: [WebhookEvent!]! + contentType: String + method: String + enabled: Boolean + } + + enum WebhookEvent { + PAGE_CREATED + PAGE_UPDATED + PAGE_DELETED + HIGHLIGHT_CREATED + HIGHLIGHT_UPDATED + HIGHLIGHT_DELETED + LABEL_CREATED + LABEL_UPDATED + LABEL_DELETED + } + + union SetWebhookResult = SetWebhookSuccess | SetWebhookError + + type SetWebhookSuccess { + webhook: Webhook! + } + + type Webhook { + id: ID! + url: String! + eventTypes: [WebhookEvent!]! + contentType: String! + method: String! + enabled: Boolean! + createdAt: Date! + updatedAt: Date! + } + + type SetWebhookError { + errorCodes: [SetWebhookErrorCode!]! + } + + enum SetWebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + ALREADY_EXISTS + NOT_FOUND + } + + union DeleteWebhookResult = DeleteWebhookSuccess | DeleteWebhookError + + type DeleteWebhookSuccess { + webhook: Webhook! + } + + type DeleteWebhookError { + errorCodes: [DeleteWebhookErrorCode!]! + } + + enum DeleteWebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + + union WebhookResult = WebhookSuccess | WebhookError + + type WebhookSuccess { + webhook: Webhook! + } + + type WebhookError { + errorCodes: [WebhookErrorCode!]! + } + + enum WebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + + union WebhooksResult = WebhooksSuccess | WebhooksError + + type WebhooksSuccess { + webhooks: [Webhook!]! + } + + type WebhooksError { + errorCodes: [WebhooksErrorCode!]! + } + + enum WebhooksErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1636,6 +1732,8 @@ const schema = gql` unsubscribe(name: String!): UnsubscribeResult! subscribe(name: String!): SubscribeResult! addPopularRead(name: String!): AddPopularReadResult! + setWebhook(input: SetWebhookInput!): SetWebhookResult! + deleteWebhook(id: ID!): DeleteWebhookResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed @@ -1675,6 +1773,8 @@ const schema = gql` labels: LabelsResult! search(after: String, first: Int, query: String): SearchResult! subscriptions(sort: SortParams): SubscriptionsResult! + webhooks: WebhooksResult! + webhook(id: ID!): WebhookResult! } ` diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 87e642d4e..d65bbcdd8 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -42,6 +42,7 @@ import { corsConfig } from './utils/corsConfig' import { initElasticsearch } from './elastic' import { uploadServiceRouter } from './routers/svc/upload' import rateLimit from 'express-rate-limit' +import { webhooksServiceRouter } from './routers/svc/webhooks' const PORT = process.env.PORT || 4000 @@ -111,6 +112,7 @@ export const createApp = (): { app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) app.use('/svc/pubsub/emails', emailsServiceRouter()) app.use('/svc/pubsub/upload', uploadServiceRouter()) + app.use('/svc/pubsub/webhooks', webhooksServiceRouter()) app.use('/svc/reminders', remindersServiceRouter()) app.use('/svc/pdf-attachments', pdfAttachmentsRouter()) diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts new file mode 100644 index 000000000..e0e91b027 --- /dev/null +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -0,0 +1,239 @@ +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { expect } from 'chai' +import 'mocha' +import { User } from '../../src/entity/user' +import { WebhookEvent } from '../../src/generated/graphql' +import { Webhook } from '../../src/entity/webhook' +import { getRepository } from '../../src/entity/utils' + +describe('Webhooks API', () => { + const username = 'fakeUser' + + let user: User + let authToken: string + + before(async () => { + // create test user and login + user = await createTestUser(username) + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + authToken = res.body.authToken + + // create test webhooks + await getRepository(Webhook).save([ + { + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageCreated], + }, + { + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageUpdated], + }, + ]) + }) + + after(async () => { + // clean up + await deleteTestUser(username) + }) + + describe('Get webhook', () => { + let webhook: Webhook + + before(async () => { + // create test webhooks + webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageDeleted], + }) + }) + + it('should return a webhook', async () => { + const query = ` + query { + webhook(id: "${webhook.id}") { + ... on WebhookSuccess { + webhook { + id + url + eventTypes + enabled + } + } + } + } + ` + + const res = await graphqlRequest(query, authToken) + + expect(res.body.data.webhook.webhook.id).to.eql(webhook.id) + expect(res.body.data.webhook.webhook.url).to.eql(webhook.url) + expect(res.body.data.webhook.webhook.eventTypes).to.eql( + webhook.eventTypes + ) + expect(res.body.data.webhook.webhook.enabled).to.eql(webhook.enabled) + }) + }) + + describe('List webhooks', () => { + it('should return a list of webhooks', async () => { + const query = ` + query { + webhooks { + ... on WebhooksSuccess { + webhooks { + id + url + eventTypes + enabled + } + } + } + } + ` + + const res = await graphqlRequest(query, authToken) + const webhooks = await getRepository(Webhook).findBy({ + user: { id: user.id }, + }) + + expect(res.body.data.webhooks.webhooks).to.eql( + webhooks.map((w) => ({ + id: w.id, + url: w.url, + eventTypes: w.eventTypes, + enabled: w.enabled, + })) + ) + }) + }) + + describe('Set webhook', () => { + let eventTypes: WebhookEvent[] + let query: string + let webhookUrl: string + let webhookId: string + let enabled: boolean + + beforeEach(async () => { + query = ` + mutation { + setWebhook( + input: { + id: "${webhookId}", + url: "${webhookUrl}", + eventTypes: [${eventTypes}], + enabled: ${enabled} + } + ) { + ... on SetWebhookSuccess { + webhook { + id + url + eventTypes + enabled + } + } + ... on SetWebhookError { + errorCodes + } + } + } + ` + }) + + context('when id is not set', () => { + before(() => { + webhookId = '' + webhookUrl = 'http://localhost:3000/webhooks/test' + eventTypes = [WebhookEvent.HighlightCreated] + enabled = true + }) + + it('should create a webhook', async () => { + const res = await graphqlRequest(query, authToken) + + expect(res.body.data.setWebhook.webhook).to.be.an('object') + expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl) + expect(res.body.data.setWebhook.webhook.eventTypes).to.eql(eventTypes) + expect(res.body.data.setWebhook.webhook.enabled).to.be.true + }) + }) + + context('when id is there', () => { + before(async () => { + const webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.HighlightUpdated], + }) + webhookId = webhook.id + webhookUrl = 'http://localhost:3000/webhooks/test_2' + eventTypes = [ + WebhookEvent.HighlightUpdated, + WebhookEvent.HighlightCreated, + ] + enabled = false + }) + + it('should update a webhook', async () => { + const res = await graphqlRequest(query, authToken) + + expect(res.body.data.setWebhook.webhook).to.be.an('object') + expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl) + expect(res.body.data.setWebhook.webhook.eventTypes).to.eql(eventTypes) + expect(res.body.data.setWebhook.webhook.enabled).to.be.false + }) + }) + }) + + describe('Delete webhook', () => { + let query: string + let webhookId: string + + beforeEach(async () => { + query = ` + mutation { + deleteWebhook(id: "${webhookId}") { + ... on DeleteWebhookSuccess { + webhook { + id + } + } + ... on DeleteWebhookError { + errorCodes + } + } + } + ` + }) + + context('when webhook exists', () => { + before(async () => { + const webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.LabelCreated], + }) + webhookId = webhook.id + }) + + it('should delete a webhook', async () => { + const res = await graphqlRequest(query, authToken) + const webhook = await getRepository(Webhook).findOneBy({ + id: webhookId, + }) + + expect(res.body.data.deleteWebhook.webhook).to.be.an('object') + expect(res.body.data.deleteWebhook.webhook.id).to.eql(webhookId) + expect(webhook).to.be.null + }) + }) + }) +}) diff --git a/packages/api/test/routers/webhooks.test.ts b/packages/api/test/routers/webhooks.test.ts new file mode 100644 index 000000000..60e0c2bd0 --- /dev/null +++ b/packages/api/test/routers/webhooks.test.ts @@ -0,0 +1,58 @@ +import { createTestUser, deleteTestUser } from '../db' +import { request } from '../util' +import { User } from '../../src/entity/user' +import 'mocha' +import { getRepository } from '../../src/entity/utils' +import { Webhook } from '../../src/entity/webhook' +import { expect } from 'chai' +import nock from 'nock' + +describe('Webhooks Router', () => { + const username = 'fakeUser' + const token = process.env.PUBSUB_VERIFICATION_TOKEN || '' + const webhookBaseUrl = 'https://localhost:3000' + const webhookPath = `/webhooks` + + let user: User + let webhook: Webhook + + before(async () => { + // create test user and login + user = await createTestUser(username) + await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + webhook = await getRepository(Webhook).save({ + url: webhookBaseUrl + webhookPath, + user: { id: user.id }, + eventTypes: ['PAGE_CREATED'], + }) + }) + + after(async () => { + // clean up + await deleteTestUser(username) + }) + + describe('trigger webhooks', () => { + it('should trigger webhooks', async () => { + const data = { + message: { + data: Buffer.from( + JSON.stringify({ userId: user.id, type: 'page' }) + ).toString('base64'), + publishTime: new Date().toISOString(), + }, + } + + nock(webhookBaseUrl).post(webhookPath).reply(200) + + const res = await request + .post('/svc/pubsub/webhooks/trigger/created?token=' + token) + .send(data) + .expect(200) + expect(res.text).to.eql('OK') + }) + }) +}) diff --git a/packages/db/migrations/0083.do.webhooks.sql b/packages/db/migrations/0083.do.webhooks.sql new file mode 100755 index 000000000..f03a548f7 --- /dev/null +++ b/packages/db/migrations/0083.do.webhooks.sql @@ -0,0 +1,23 @@ +-- Type: DO +-- Name: webhooks +-- Description: webhooks model + +BEGIN; + +CREATE TABLE omnivore.webhooks ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, + url text NOT NULL, + method text NOT NULL DEFAULT 'POST', + content_type text NOT NULL DEFAULT 'application/json', + enabled boolean NOT NULL DEFAULT true, + event_types text[] NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + UNIQUE (user_id, event_types) +); + +CREATE TRIGGER update_webhook_modtime BEFORE UPDATE ON omnivore.webhooks + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + +COMMIT; diff --git a/packages/db/migrations/0083.undo.webhooks.sql b/packages/db/migrations/0083.undo.webhooks.sql new file mode 100755 index 000000000..b700ba0ee --- /dev/null +++ b/packages/db/migrations/0083.undo.webhooks.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: webhooks +-- Description: webhooks model + +BEGIN; + +DROP TABLE IF EXISTS omnivore.webhooks; + +COMMIT;