diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cb74a..1da491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - default index.html for api without bundled ui - 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 diff --git a/src/dto/form/form.hook.input.ts b/src/dto/form/form.hook.input.ts new file mode 100644 index 0000000..67efe4d --- /dev/null +++ b/src/dto/form/form.hook.input.ts @@ -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 +} diff --git a/src/dto/form/form.hook.model.ts b/src/dto/form/form.hook.model.ts new file mode 100644 index 0000000..8cc007e --- /dev/null +++ b/src/dto/form/form.hook.model.ts @@ -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 + } +} diff --git a/src/resolver/form/form.resolver.ts b/src/resolver/form/form.resolver.ts index 67c3131..f7690d6 100644 --- a/src/resolver/form/form.resolver.ts +++ b/src/resolver/form/form.resolver.ts @@ -3,6 +3,7 @@ import { Roles } from '../../decorator/roles.decorator'; import { User } from '../../decorator/user.decorator'; import { DesignModel } from '../../dto/form/design.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 { PageModel } from '../../dto/form/page.model'; import { RespondentNotificationsModel } from '../../dto/form/respondent.notifications.model'; @@ -47,6 +48,17 @@ export class FormResolver { return form.fields.map(field => new FormFieldModel(field)) } + @ResolveField('hooks', () => [FormHookModel]) + async getHooks( + @User() user: UserDocument, + @Parent() parent: FormModel, + @Context('cache') cache: ContextCache, + ): Promise { + const form = await cache.getForm(parent.id) + + return form.hooks.map(hook => new FormHookModel(hook)) + } + @ResolveField('isLive', () => Boolean) @Roles('admin') async getRoles( diff --git a/src/schema/form.hook.schema.ts b/src/schema/form.hook.schema.ts new file mode 100644 index 0000000..c436e71 --- /dev/null +++ b/src/schema/form.hook.schema.ts @@ -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, +} + diff --git a/src/schema/form.schema.ts b/src/schema/form.schema.ts index eb7c17a..db3964e 100644 --- a/src/schema/form.schema.ts +++ b/src/schema/form.schema.ts @@ -3,6 +3,7 @@ import { matchType } from '../config/fields'; import { defaultLanguage, languages } from '../config/languages'; import { ButtonDocument, ButtonSchema } from './button.schema'; import { FormFieldDocument, FormFieldSchema } from './form.field.schema'; +import { FormHookDocument } from './form.hook.schema' import { UserDocument, UserSchemaName } from './user.schema'; import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema'; @@ -58,6 +59,7 @@ export interface FormDocument extends Document { } readonly fields: [FormFieldDocument] + readonly hooks: [FormHookDocument] readonly admin: UserDocument diff --git a/src/schema/index.ts b/src/schema/index.ts index a195ff5..faedf8e 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,4 +1,5 @@ import { FormFieldDefinition } from './form.field.schema'; +import { FormHookDefinition } from './form.hook.schema' import { FormDefinition } from './form.schema'; import { SubmissionFieldDefinition } from './submission.field.schema'; import { SubmissionDefinition } from './submission.schema'; @@ -7,6 +8,7 @@ import { UserDefinition } from './user.schema'; export const schema = [ FormDefinition, FormFieldDefinition, + FormHookDefinition, SubmissionDefinition, SubmissionFieldDefinition, UserDefinition, diff --git a/src/service/submission/index.ts b/src/service/submission/index.ts index 54e57eb..845cf61 100644 --- a/src/service/submission/index.ts +++ b/src/service/submission/index.ts @@ -3,6 +3,7 @@ import { SubmissionSetFieldService } from './submission.set.field.service'; import { SubmissionStartService } from './submission.start.service'; import { SubmissionStatisticService } from './submission.statistic.service'; import { SubmissionTokenService } from './submission.token.service'; +import { SubmissionHookService } from './submission.webhook.service' export const submissionServices = [ SubmissionService, @@ -10,4 +11,5 @@ export const submissionServices = [ SubmissionStartService, SubmissionStatisticService, SubmissionTokenService, + SubmissionHookService, ] diff --git a/src/service/submission/submission.hook.service.ts b/src/service/submission/submission.hook.service.ts new file mode 100644 index 0000000..c84e7ff --- /dev/null +++ b/src/service/submission/submission.hook.service.ts @@ -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 { + 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 { + 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)) + } +} diff --git a/src/service/submission/submission.set.field.service.ts b/src/service/submission/submission.set.field.service.ts index 49cb28b..443353b 100644 --- a/src/service/submission/submission.set.field.service.ts +++ b/src/service/submission/submission.set.field.service.ts @@ -2,15 +2,19 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import dayjs from 'dayjs'; import { Model } from 'mongoose'; +import { PinoLogger } from 'nestjs-pino/dist' import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input'; import { SubmissionFieldDocument, SubmissionFieldSchemaName } from '../../schema/submission.field.schema'; import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema'; +import { SubmissionHookService } from './submission.hook.service' @Injectable() export class SubmissionSetFieldService { constructor( @InjectModel(SubmissionSchemaName) private readonly submissionModel: Model, @InjectModel(SubmissionFieldSchemaName) private readonly submissionFieldModel: Model, + private readonly webHook: SubmissionHookService, + private readonly logger: PinoLogger, ) { } @@ -45,5 +49,11 @@ export class SubmissionSetFieldService { } await submission.save() + + if (submission.percentageComplete === 1) { + this.webHook.process(submission).catch(e => { + this.logger.error(`failed to send webhooks: ${e.message}`) + }) + } } }