add submission web hooks to backend
https://github.com/ohmyform/api/issues/2
This commit is contained in:
parent
7d14e393b4
commit
4b69665453
17
CHANGELOG.md
17
CHANGELOG.md
@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
- default index.html for api without bundled ui
|
- default index.html for api without bundled ui
|
||||||
- slug for form fields can now be saved
|
- slug for form fields can now be saved
|
||||||
|
- submission webhooks with ability to customize json payload
|
||||||
|
```
|
||||||
|
{
|
||||||
|
form: ID
|
||||||
|
submission: ID
|
||||||
|
created: DateTime
|
||||||
|
lastModified: DateTime
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
field: ID
|
||||||
|
slug: String
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
17
src/dto/form/form.hook.input.ts
Normal file
17
src/dto/form/form.hook.input.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Field, ID, InputType } from '@nestjs/graphql'
|
||||||
|
import { GraphQLInt } from 'graphql';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class FormHookInput {
|
||||||
|
@Field(() => ID)
|
||||||
|
readonly id: string
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
readonly enabled: boolean
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
readonly url?: string
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
readonly format?: string
|
||||||
|
}
|
||||||
24
src/dto/form/form.hook.model.ts
Normal file
24
src/dto/form/form.hook.model.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { FormHookDocument } from '../../schema/form.hook.schema'
|
||||||
|
|
||||||
|
@ObjectType('FormHook')
|
||||||
|
export class FormHookModel {
|
||||||
|
@Field(() => ID)
|
||||||
|
readonly id: string
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
readonly enabled: boolean
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
readonly url?: string
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
readonly format?: string
|
||||||
|
|
||||||
|
constructor(hook: FormHookDocument) {
|
||||||
|
this.id = hook.id
|
||||||
|
this.enabled = hook.enabled
|
||||||
|
this.url = hook.url
|
||||||
|
this.format = hook.format
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Roles } from '../../decorator/roles.decorator';
|
|||||||
import { User } from '../../decorator/user.decorator';
|
import { User } from '../../decorator/user.decorator';
|
||||||
import { DesignModel } from '../../dto/form/design.model';
|
import { DesignModel } from '../../dto/form/design.model';
|
||||||
import { FormFieldModel } from '../../dto/form/form.field.model';
|
import { FormFieldModel } from '../../dto/form/form.field.model';
|
||||||
|
import { FormHookModel } from '../../dto/form/form.hook.model'
|
||||||
import { FormModel } from '../../dto/form/form.model';
|
import { FormModel } from '../../dto/form/form.model';
|
||||||
import { PageModel } from '../../dto/form/page.model';
|
import { PageModel } from '../../dto/form/page.model';
|
||||||
import { RespondentNotificationsModel } from '../../dto/form/respondent.notifications.model';
|
import { RespondentNotificationsModel } from '../../dto/form/respondent.notifications.model';
|
||||||
@ -47,6 +48,17 @@ export class FormResolver {
|
|||||||
return form.fields.map(field => new FormFieldModel(field))
|
return form.fields.map(field => new FormFieldModel(field))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField('hooks', () => [FormHookModel])
|
||||||
|
async getHooks(
|
||||||
|
@User() user: UserDocument,
|
||||||
|
@Parent() parent: FormModel,
|
||||||
|
@Context('cache') cache: ContextCache,
|
||||||
|
): Promise<FormHookModel[]> {
|
||||||
|
const form = await cache.getForm(parent.id)
|
||||||
|
|
||||||
|
return form.hooks.map(hook => new FormHookModel(hook))
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField('isLive', () => Boolean)
|
@ResolveField('isLive', () => Boolean)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
async getRoles(
|
async getRoles(
|
||||||
|
|||||||
35
src/schema/form.hook.schema.ts
Normal file
35
src/schema/form.hook.schema.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Document, Schema } from 'mongoose';
|
||||||
|
import { fieldTypes, matchType } from '../config/fields'
|
||||||
|
import { FieldOption } from './embedded/field.option';
|
||||||
|
import { LogicJump } from './embedded/logic.jump';
|
||||||
|
import { RatingField } from './embedded/rating.field';
|
||||||
|
|
||||||
|
export const FormHookSchemaName = 'FormHook'
|
||||||
|
|
||||||
|
export interface FormHookDocument extends Document {
|
||||||
|
readonly enabled: boolean
|
||||||
|
readonly url?: string
|
||||||
|
readonly format?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormHookSchema = new Schema({
|
||||||
|
enabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
match: matchType.url,
|
||||||
|
trim: true,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FormHookDefinition = {
|
||||||
|
name: FormHookSchemaName,
|
||||||
|
schema: FormHookSchema,
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,6 +3,7 @@ import { matchType } from '../config/fields';
|
|||||||
import { defaultLanguage, languages } from '../config/languages';
|
import { defaultLanguage, languages } from '../config/languages';
|
||||||
import { ButtonDocument, ButtonSchema } from './button.schema';
|
import { ButtonDocument, ButtonSchema } from './button.schema';
|
||||||
import { FormFieldDocument, FormFieldSchema } from './form.field.schema';
|
import { FormFieldDocument, FormFieldSchema } from './form.field.schema';
|
||||||
|
import { FormHookDocument } from './form.hook.schema'
|
||||||
import { UserDocument, UserSchemaName } from './user.schema';
|
import { UserDocument, UserSchemaName } from './user.schema';
|
||||||
import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema';
|
import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema';
|
||||||
|
|
||||||
@ -58,6 +59,7 @@ export interface FormDocument extends Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly fields: [FormFieldDocument]
|
readonly fields: [FormFieldDocument]
|
||||||
|
readonly hooks: [FormHookDocument]
|
||||||
|
|
||||||
readonly admin: UserDocument
|
readonly admin: UserDocument
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { FormFieldDefinition } from './form.field.schema';
|
import { FormFieldDefinition } from './form.field.schema';
|
||||||
|
import { FormHookDefinition } from './form.hook.schema'
|
||||||
import { FormDefinition } from './form.schema';
|
import { FormDefinition } from './form.schema';
|
||||||
import { SubmissionFieldDefinition } from './submission.field.schema';
|
import { SubmissionFieldDefinition } from './submission.field.schema';
|
||||||
import { SubmissionDefinition } from './submission.schema';
|
import { SubmissionDefinition } from './submission.schema';
|
||||||
@ -7,6 +8,7 @@ import { UserDefinition } from './user.schema';
|
|||||||
export const schema = [
|
export const schema = [
|
||||||
FormDefinition,
|
FormDefinition,
|
||||||
FormFieldDefinition,
|
FormFieldDefinition,
|
||||||
|
FormHookDefinition,
|
||||||
SubmissionDefinition,
|
SubmissionDefinition,
|
||||||
SubmissionFieldDefinition,
|
SubmissionFieldDefinition,
|
||||||
UserDefinition,
|
UserDefinition,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { SubmissionSetFieldService } from './submission.set.field.service';
|
|||||||
import { SubmissionStartService } from './submission.start.service';
|
import { SubmissionStartService } from './submission.start.service';
|
||||||
import { SubmissionStatisticService } from './submission.statistic.service';
|
import { SubmissionStatisticService } from './submission.statistic.service';
|
||||||
import { SubmissionTokenService } from './submission.token.service';
|
import { SubmissionTokenService } from './submission.token.service';
|
||||||
|
import { SubmissionHookService } from './submission.webhook.service'
|
||||||
|
|
||||||
export const submissionServices = [
|
export const submissionServices = [
|
||||||
SubmissionService,
|
SubmissionService,
|
||||||
@ -10,4 +11,5 @@ export const submissionServices = [
|
|||||||
SubmissionStartService,
|
SubmissionStartService,
|
||||||
SubmissionStatisticService,
|
SubmissionStatisticService,
|
||||||
SubmissionTokenService,
|
SubmissionTokenService,
|
||||||
|
SubmissionHookService,
|
||||||
]
|
]
|
||||||
|
|||||||
67
src/service/submission/submission.hook.service.ts
Normal file
67
src/service/submission/submission.hook.service.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { HttpService, Injectable } from '@nestjs/common'
|
||||||
|
import fs from "fs"
|
||||||
|
import { PinoLogger } from 'nestjs-pino/dist'
|
||||||
|
import { FormDocument } from '../../schema/form.schema'
|
||||||
|
import handlebars from 'handlebars'
|
||||||
|
import { SubmissionDocument } from '../../schema/submission.schema'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubmissionHookService {
|
||||||
|
constructor(
|
||||||
|
private httpService: HttpService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public async process(submission: SubmissionDocument): Promise<void> {
|
||||||
|
await Promise.all(submission.form.hooks.map(async (hook) => {
|
||||||
|
if (!hook.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.httpService.post(
|
||||||
|
hook.url,
|
||||||
|
await this.format(submission, hook.format)
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`failed to post to "${hook.url}: ${e.message}`)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async format(submission: SubmissionDocument, format?: string): Promise<any> {
|
||||||
|
if (!submission.populated('form')) {
|
||||||
|
submission.populate('form')
|
||||||
|
await submission.execPopulate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = {}
|
||||||
|
submission.form.fields.forEach((field) => {
|
||||||
|
fields[field.id] = field
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
form: submission.form.id,
|
||||||
|
submission: submission.id,
|
||||||
|
created: submission.created,
|
||||||
|
lastModified: submission.lastModified,
|
||||||
|
fields: submission.fields.map((submissionField) => {
|
||||||
|
const formField = submission.form.fields.find(formField => formField.id.toString() === submissionField.field)
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: formField.id,
|
||||||
|
slug: formField.slug || null,
|
||||||
|
value: submissionField.fieldValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!format) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(handlebars.compile(format)(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,15 +2,19 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Model } from 'mongoose';
|
import { Model } from 'mongoose';
|
||||||
|
import { PinoLogger } from 'nestjs-pino/dist'
|
||||||
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input';
|
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input';
|
||||||
import { SubmissionFieldDocument, SubmissionFieldSchemaName } from '../../schema/submission.field.schema';
|
import { SubmissionFieldDocument, SubmissionFieldSchemaName } from '../../schema/submission.field.schema';
|
||||||
import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema';
|
import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema';
|
||||||
|
import { SubmissionHookService } from './submission.hook.service'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionSetFieldService {
|
export class SubmissionSetFieldService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectModel(SubmissionSchemaName) private readonly submissionModel: Model<SubmissionDocument>,
|
@InjectModel(SubmissionSchemaName) private readonly submissionModel: Model<SubmissionDocument>,
|
||||||
@InjectModel(SubmissionFieldSchemaName) private readonly submissionFieldModel: Model<SubmissionFieldDocument>,
|
@InjectModel(SubmissionFieldSchemaName) private readonly submissionFieldModel: Model<SubmissionFieldDocument>,
|
||||||
|
private readonly webHook: SubmissionHookService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,5 +49,11 @@ export class SubmissionSetFieldService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await submission.save()
|
await submission.save()
|
||||||
|
|
||||||
|
if (submission.percentageComplete === 1) {
|
||||||
|
this.webHook.process(submission).catch(e => {
|
||||||
|
this.logger.error(`failed to send webhooks: ${e.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user