Merge pull request #709 from omnivore-app/webhook

Webhook
This commit is contained in:
Hongbo Wu 2022-05-27 13:02:17 +08:00 committed by GitHub
commit 5bb720c54d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1154 additions and 1 deletions

View file

@ -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
}

View file

@ -502,6 +502,24 @@ export type DeleteReminderSuccess = {
reminder: Reminder;
};
export type DeleteWebhookError = {
__typename?: 'DeleteWebhookError';
errorCodes: Array<DeleteWebhookErrorCode>;
};
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<SetWebhookErrorCode>;
};
export enum SetWebhookErrorCode {
AlreadyExists = 'ALREADY_EXISTS',
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SetWebhookInput = {
contentType?: InputMaybe<Scalars['String']>;
enabled?: InputMaybe<Scalars['Boolean']>;
eventTypes: Array<WebhookEvent>;
id?: InputMaybe<Scalars['ID']>;
method?: InputMaybe<Scalars['String']>;
url: Scalars['String'];
};
export type SetWebhookResult = SetWebhookError | SetWebhookSuccess;
export type SetWebhookSuccess = {
__typename?: 'SetWebhookSuccess';
webhook: Webhook;
};
export type SharedArticleError = {
__typename?: 'SharedArticleError';
errorCodes: Array<SharedArticleErrorCode>;
@ -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<WebhookEvent>;
id: Scalars['ID'];
method: Scalars['String'];
updatedAt: Scalars['Date'];
url: Scalars['String'];
};
export type WebhookError = {
__typename?: 'WebhookError';
errorCodes: Array<WebhookErrorCode>;
};
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<WebhooksErrorCode>;
};
export enum WebhooksErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type WebhooksResult = WebhooksError | WebhooksSuccess;
export type WebhooksSuccess = {
__typename?: 'WebhooksSuccess';
webhooks: Array<Webhook>;
};
export type WebhookSuccess = {
__typename?: 'WebhookSuccess';
webhook: Webhook;
};
export type ResolverTypeWrapper<T> = Promise<T> | T;
@ -2234,6 +2358,10 @@ export type ResolversTypes = {
DeleteReminderErrorCode: DeleteReminderErrorCode;
DeleteReminderResult: ResolversTypes['DeleteReminderError'] | ResolversTypes['DeleteReminderSuccess'];
DeleteReminderSuccess: ResolverTypeWrapper<DeleteReminderSuccess>;
DeleteWebhookError: ResolverTypeWrapper<DeleteWebhookError>;
DeleteWebhookErrorCode: DeleteWebhookErrorCode;
DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess'];
DeleteWebhookSuccess: ResolverTypeWrapper<DeleteWebhookSuccess>;
DeviceToken: ResolverTypeWrapper<DeviceToken>;
FeedArticle: ResolverTypeWrapper<FeedArticle>;
FeedArticleEdge: ResolverTypeWrapper<FeedArticleEdge>;
@ -2367,6 +2495,11 @@ export type ResolversTypes = {
SetUserPersonalizationInput: SetUserPersonalizationInput;
SetUserPersonalizationResult: ResolversTypes['SetUserPersonalizationError'] | ResolversTypes['SetUserPersonalizationSuccess'];
SetUserPersonalizationSuccess: ResolverTypeWrapper<SetUserPersonalizationSuccess>;
SetWebhookError: ResolverTypeWrapper<SetWebhookError>;
SetWebhookErrorCode: SetWebhookErrorCode;
SetWebhookInput: SetWebhookInput;
SetWebhookResult: ResolversTypes['SetWebhookError'] | ResolversTypes['SetWebhookSuccess'];
SetWebhookSuccess: ResolverTypeWrapper<SetWebhookSuccess>;
SharedArticleError: ResolverTypeWrapper<SharedArticleError>;
SharedArticleErrorCode: SharedArticleErrorCode;
SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess'];
@ -2456,6 +2589,16 @@ export type ResolversTypes = {
UsersResult: ResolversTypes['UsersError'] | ResolversTypes['UsersSuccess'];
UsersSuccess: ResolverTypeWrapper<UsersSuccess>;
UserSuccess: ResolverTypeWrapper<UserSuccess>;
Webhook: ResolverTypeWrapper<Webhook>;
WebhookError: ResolverTypeWrapper<WebhookError>;
WebhookErrorCode: WebhookErrorCode;
WebhookEvent: WebhookEvent;
WebhookResult: ResolversTypes['WebhookError'] | ResolversTypes['WebhookSuccess'];
WebhooksError: ResolverTypeWrapper<WebhooksError>;
WebhooksErrorCode: WebhooksErrorCode;
WebhooksResult: ResolversTypes['WebhooksError'] | ResolversTypes['WebhooksSuccess'];
WebhooksSuccess: ResolverTypeWrapper<WebhooksSuccess>;
WebhookSuccess: ResolverTypeWrapper<WebhookSuccess>;
};
/** 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<ContextType = ResolverContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteWebhookErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteWebhookError'] = ResolversParentTypes['DeleteWebhookError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteWebhookErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteWebhookResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteWebhookResult'] = ResolversParentTypes['DeleteWebhookResult']> = {
__resolveType: TypeResolveFn<'DeleteWebhookError' | 'DeleteWebhookSuccess', ParentType, ContextType>;
};
export type DeleteWebhookSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteWebhookSuccess'] = ResolversParentTypes['DeleteWebhookSuccess']> = {
webhook?: Resolver<ResolversTypes['Webhook'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeviceTokenResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeviceToken'] = ResolversParentTypes['DeviceToken']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@ -3301,6 +3472,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
deleteReminder?: Resolver<ResolversTypes['DeleteReminderResult'], ParentType, ContextType, RequireFields<MutationDeleteReminderArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
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'>>;
@ -3320,6 +3492,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
signup?: Resolver<ResolversTypes['SignupResult'], ParentType, ContextType, RequireFields<MutationSignupArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'name'>>;
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
@ -3410,6 +3583,8 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
users?: Resolver<ResolversTypes['UsersResult'], ParentType, ContextType>;
validateUsername?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryValidateUsernameArgs, 'username'>>;
webhook?: Resolver<ResolversTypes['WebhookResult'], ParentType, ContextType, RequireFields<QueryWebhookArgs, 'id'>>;
webhooks?: Resolver<ResolversTypes['WebhooksResult'], ParentType, ContextType>;
};
export type ReactionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Reaction'] = ResolversParentTypes['Reaction']> = {
@ -3639,6 +3814,20 @@ export type SetUserPersonalizationSuccessResolvers<ContextType = ResolverContext
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetWebhookErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetWebhookError'] = ResolversParentTypes['SetWebhookError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetWebhookErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetWebhookResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetWebhookResult'] = ResolversParentTypes['SetWebhookResult']> = {
__resolveType: TypeResolveFn<'SetWebhookError' | 'SetWebhookSuccess', ParentType, ContextType>;
};
export type SetWebhookSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetWebhookSuccess'] = ResolversParentTypes['SetWebhookSuccess']> = {
webhook?: Resolver<ResolversTypes['Webhook'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SharedArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SharedArticleError'] = ResolversParentTypes['SharedArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SharedArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3928,6 +4117,46 @@ export type UserSuccessResolvers<ContextType = ResolverContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Webhook'] = ResolversParentTypes['Webhook']> = {
contentType?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
enabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
eventTypes?: Resolver<Array<ResolversTypes['WebhookEvent']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
method?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhookError'] = ResolversParentTypes['WebhookError']> = {
errorCodes?: Resolver<Array<ResolversTypes['WebhookErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhookResult'] = ResolversParentTypes['WebhookResult']> = {
__resolveType: TypeResolveFn<'WebhookError' | 'WebhookSuccess', ParentType, ContextType>;
};
export type WebhooksErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhooksError'] = ResolversParentTypes['WebhooksError']> = {
errorCodes?: Resolver<Array<ResolversTypes['WebhooksErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhooksResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhooksResult'] = ResolversParentTypes['WebhooksResult']> = {
__resolveType: TypeResolveFn<'WebhooksError' | 'WebhooksSuccess', ParentType, ContextType>;
};
export type WebhooksSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhooksSuccess'] = ResolversParentTypes['WebhooksSuccess']> = {
webhooks?: Resolver<Array<ResolversTypes['Webhook']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WebhookSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['WebhookSuccess'] = ResolversParentTypes['WebhookSuccess']> = {
webhook?: Resolver<ResolversTypes['Webhook'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type Resolvers<ContextType = ResolverContext> = {
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
@ -3990,6 +4219,9 @@ export type Resolvers<ContextType = ResolverContext> = {
DeleteReminderError?: DeleteReminderErrorResolvers<ContextType>;
DeleteReminderResult?: DeleteReminderResultResolvers<ContextType>;
DeleteReminderSuccess?: DeleteReminderSuccessResolvers<ContextType>;
DeleteWebhookError?: DeleteWebhookErrorResolvers<ContextType>;
DeleteWebhookResult?: DeleteWebhookResultResolvers<ContextType>;
DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers<ContextType>;
DeviceToken?: DeviceTokenResolvers<ContextType>;
FeedArticle?: FeedArticleResolvers<ContextType>;
FeedArticleEdge?: FeedArticleEdgeResolvers<ContextType>;
@ -4077,6 +4309,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SetUserPersonalizationError?: SetUserPersonalizationErrorResolvers<ContextType>;
SetUserPersonalizationResult?: SetUserPersonalizationResultResolvers<ContextType>;
SetUserPersonalizationSuccess?: SetUserPersonalizationSuccessResolvers<ContextType>;
SetWebhookError?: SetWebhookErrorResolvers<ContextType>;
SetWebhookResult?: SetWebhookResultResolvers<ContextType>;
SetWebhookSuccess?: SetWebhookSuccessResolvers<ContextType>;
SharedArticleError?: SharedArticleErrorResolvers<ContextType>;
SharedArticleResult?: SharedArticleResultResolvers<ContextType>;
SharedArticleSuccess?: SharedArticleSuccessResolvers<ContextType>;
@ -4132,6 +4367,13 @@ export type Resolvers<ContextType = ResolverContext> = {
UsersResult?: UsersResultResolvers<ContextType>;
UsersSuccess?: UsersSuccessResolvers<ContextType>;
UserSuccess?: UserSuccessResolvers<ContextType>;
Webhook?: WebhookResolvers<ContextType>;
WebhookError?: WebhookErrorResolvers<ContextType>;
WebhookResult?: WebhookResultResolvers<ContextType>;
WebhooksError?: WebhooksErrorResolvers<ContextType>;
WebhooksResult?: WebhooksResultResolvers<ContextType>;
WebhooksSuccess?: WebhooksSuccessResolvers<ContextType>;
WebhookSuccess?: WebhookSuccessResolvers<ContextType>;
};
export type DirectiveResolvers<ContextType = ResolverContext> = {

View file

@ -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!
}

View file

@ -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'),
}

View file

@ -17,3 +17,4 @@ export * from './labels'
export * from './subscriptions'
export * from './update'
export * from './popular_reads'
export * from './webhooks'

View file

@ -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<WebhooksSuccess, WebhooksError>(
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<Webhook> = {
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[],
})

View file

@ -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
}

View file

@ -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!
}
`

View file

@ -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())

View file

@ -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
})
})
})
})

View file

@ -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')
})
})
})

View file

@ -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;

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: webhooks
-- Description: webhooks model
BEGIN;
DROP TABLE IF EXISTS omnivore.webhooks;
COMMIT;