diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b366c5d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +## License +(The MIT License) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docker-compose.yml b/docker-compose.yml index 94570fc..f148898 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: - "27017:27017" volumes: - "./data/mongo:/data" + redis: + image: redis + ports: + - "6379:6379" # api: # build: . # volumes: diff --git a/package.json b/package.json index d30b3cd..9a2eb6f 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "version": "0.3.0", "description": "", "author": "", - "private": true, - "license": "UNLICENSED", + "license": "MIT", "scripts": { "prebuild": "rimraf dist", "build": "nest build", @@ -40,9 +39,12 @@ "cors": "^2.8.5", "cross-env": "^7.0.2", "graphql": "15.0.0", + "graphql-redis-subscriptions": "^2.2.1", + "graphql-subscriptions": "^1.1.0", "graphql-tools": "^5.0.0", "handlebars": "^4.7.6", "inquirer": "^7.1.0", + "ioredis": "^4.17.1", "migrate-mongoose": "^4.0.0", "mongoose": "^5.9.11", "nestjs-console": "^3.0.2", diff --git a/src/app.imports.ts b/src/app.imports.ts index 31a3c81..6167c9b 100644 --- a/src/app.imports.ts +++ b/src/app.imports.ts @@ -1,15 +1,16 @@ import { MailerModule } from '@nestjs-modules/mailer'; import { HttpModule, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { GraphQLFederationModule } from '@nestjs/graphql'; +import { GraphQLModule } from '@nestjs/graphql'; import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModuleOptions } from '@nestjs/mongoose/dist/interfaces/mongoose-options.interface'; import crypto from 'crypto'; +import { Request } from 'express-serve-static-core'; +import { IncomingHttpHeaders } from 'http'; import { ConsoleModule } from 'nestjs-console'; import { LoggerModule, Params as LoggerModuleParams } from 'nestjs-pino/dist'; import { join } from 'path'; -import { ContextCache } from './resolver/context.cache'; import { schema } from './schema'; export const LoggerConfig: LoggerModuleParams = { @@ -60,14 +61,14 @@ export const imports = [ }) }), LoggerModule.forRoot(LoggerConfig), - GraphQLFederationModule.forRoot({ + GraphQLModule.forRoot({ debug: process.env.NODE_ENV !== 'production', definitions: { outputAs: 'class', }, introspection: true, playground: true, - // installSubscriptionHandlers: true, + installSubscriptionHandlers: true, autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // to allow guards on resolver props https://github.com/nestjs/graphql/issues/295 fieldResolverEnhancers: [ @@ -77,11 +78,22 @@ export const imports = [ resolverValidationOptions: { }, - context: ({ req }) => { - return { - cache: new ContextCache(), - req, + context: ({ req, connection }) => { + if (!req && connection) { + const headers: IncomingHttpHeaders = {} + + Object.keys(connection.context).forEach(key => { + headers[key.toLowerCase()] = connection.context[key] + }) + + return { + req: { + headers + } as Request + } } + + return { req } }, }), MongooseModule.forRootAsync({ diff --git a/src/config/fields.ts b/src/config/fields.ts index 4e4ec21..767516e 100644 --- a/src/config/fields.ts +++ b/src/config/fields.ts @@ -3,10 +3,10 @@ export const fieldTypes = [ 'textfield', 'date', 'email', - 'legal', + // 'legal', 'textarea', 'link', - 'statement', + // 'statement', 'dropdown', 'rating', 'radio', diff --git a/src/dto/form/abstract.notification.input.ts b/src/dto/form/abstract.notification.input.ts new file mode 100644 index 0000000..90a3d90 --- /dev/null +++ b/src/dto/form/abstract.notification.input.ts @@ -0,0 +1,13 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType('NotificationInput', { isAbstract: true }) +export class AbstractNotificationInput { + @Field({ nullable: true }) + readonly subject?: string + + @Field({ nullable: true }) + readonly htmlTemplate?: string + + @Field() + readonly enabled: boolean +} diff --git a/src/dto/form/button.input.ts b/src/dto/form/button.input.ts new file mode 100644 index 0000000..b3ad8eb --- /dev/null +++ b/src/dto/form/button.input.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType('ButtonInput') +export class ButtonInput { + @Field({ nullable: true }) + readonly url?: string + + @Field({ nullable: true }) + readonly action?: string + + @Field({ nullable: true }) + readonly text?: string + + @Field({ nullable: true }) + readonly bgColor?: string + + @Field({ nullable: true }) + readonly color?: string +} diff --git a/src/dto/form/button.model.ts b/src/dto/form/button.model.ts index 8caae9a..80fdc0f 100644 --- a/src/dto/form/button.model.ts +++ b/src/dto/form/button.model.ts @@ -9,7 +9,7 @@ export class ButtonModel { readonly action?: string @Field({ nullable: true }) - readonly text: string + readonly text?: string @Field({ nullable: true }) readonly bgColor?: string diff --git a/src/dto/form/colors.input.ts b/src/dto/form/colors.input.ts new file mode 100644 index 0000000..cd91e24 --- /dev/null +++ b/src/dto/form/colors.input.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType('ColorsInput') +export class ColorsInput { + @Field() + readonly backgroundColor: string + + @Field() + readonly questionColor: string + + @Field() + readonly answerColor: string + + @Field() + readonly buttonColor: string + + @Field() + readonly buttonTextColor: string +} diff --git a/src/dto/form/design.input.ts b/src/dto/form/design.input.ts new file mode 100644 index 0000000..d7c84bb --- /dev/null +++ b/src/dto/form/design.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { ColorsInput } from './colors.input'; + +@InputType('DesignInput') +export class DesignInput { + @Field() + readonly colors: ColorsInput + + @Field({ nullable: true }) + readonly font?: string +} diff --git a/src/dto/form/form.create.input.ts b/src/dto/form/form.create.input.ts new file mode 100644 index 0000000..3bdeddf --- /dev/null +++ b/src/dto/form/form.create.input.ts @@ -0,0 +1,23 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; +import { FormFieldInput } from './form.field.input'; + +@InputType('FormCreateInput') +export class FormCreateInput { + @Field(() => ID, { nullable: true }) + readonly id: string + + @Field() + readonly title: string + + @Field() + readonly language: string + + @Field() + readonly showFooter: boolean + + @Field() + readonly isLive: boolean + + @Field(() => [FormFieldInput], { nullable: true }) + readonly fields: FormFieldInput[] +} diff --git a/src/dto/form/form.field.input.ts b/src/dto/form/form.field.input.ts new file mode 100644 index 0000000..f0ab7ce --- /dev/null +++ b/src/dto/form/form.field.input.ts @@ -0,0 +1,22 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; + +@InputType('FormFieldInput') +export class FormFieldInput { + @Field(() => ID) + readonly id: string + + @Field() + readonly title: string + + @Field() + readonly type: string + + @Field() + readonly description: string + + @Field() + readonly required: boolean + + @Field() + readonly value: string +} diff --git a/src/dto/form/form.field.model.ts b/src/dto/form/form.field.model.ts new file mode 100644 index 0000000..26e9d0c --- /dev/null +++ b/src/dto/form/form.field.model.ts @@ -0,0 +1,32 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { FormFieldDocument } from '../../schema/form.field.schema'; + +@ObjectType('FormField') +export class FormFieldModel { + @Field(() => ID) + readonly id: string + + @Field() + readonly title: string + + @Field() + readonly type: string + + @Field() + readonly description: string + + @Field() + readonly required: boolean + + @Field() + readonly value: string + + constructor(document: FormFieldDocument) { + this.id = document.id + this.title = document.title + this.type = document.type + this.description = document.description + this.required = document.required + this.value = document.value + } +} diff --git a/src/dto/form/form.model.ts b/src/dto/form/form.model.ts index 08a4d4c..ee8acfa 100644 --- a/src/dto/form/form.model.ts +++ b/src/dto/form/form.model.ts @@ -21,12 +21,12 @@ export class FormModel { @Field() readonly showFooter: boolean - constructor(partial: Partial) { - this.id = partial.id - this.title = partial.title - this.created = partial.created - this.lastModified = partial.lastModified - this.language = partial.language - this.showFooter = partial.showFooter + constructor(form: FormDocument) { + this.id = form.id + this.title = form.title + this.created = form.created + this.lastModified = form.lastModified + this.language = form.language + this.showFooter = form.showFooter } } diff --git a/src/dto/form/form.update.input.ts b/src/dto/form/form.update.input.ts new file mode 100644 index 0000000..9ef46ea --- /dev/null +++ b/src/dto/form/form.update.input.ts @@ -0,0 +1,42 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; +import { DesignInput } from './design.input'; +import { FormFieldInput } from './form.field.input'; +import { PageInput } from './page.input'; +import { RespondentNotificationsInput } from './respondent.notifications.input'; +import { SelfNotificationsInput } from './self.notifications.input'; + +@InputType('FormUpdateInput') +export class FormUpdateInput { + @Field(() => ID) + readonly id: string + + @Field({ nullable: true }) + readonly title: string + + @Field({ nullable: true }) + readonly language: string + + @Field({ nullable: true }) + readonly showFooter: boolean + + @Field({ nullable: true }) + readonly isLive: boolean + + @Field(() => [FormFieldInput], { nullable: true }) + readonly fields: FormFieldInput[] + + @Field({ nullable: true }) + readonly design: DesignInput + + @Field({ nullable: true }) + readonly startPage: PageInput + + @Field({ nullable: true }) + readonly endPage: PageInput + + @Field({ nullable: true }) + readonly selfNotifications: SelfNotificationsInput + + @Field({ nullable: true }) + readonly respondentNotifications: RespondentNotificationsInput +} diff --git a/src/dto/form/page.input.ts b/src/dto/form/page.input.ts new file mode 100644 index 0000000..34993ea --- /dev/null +++ b/src/dto/form/page.input.ts @@ -0,0 +1,20 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { ButtonInput } from './button.input'; + +@InputType('PageInput') +export class PageInput { + @Field() + readonly show: boolean + + @Field({ nullable: true }) + readonly title?: string + + @Field({ nullable: true }) + readonly paragraph?: string + + @Field({ nullable: true }) + readonly buttonText?: string + + @Field(() => [ButtonInput]) + readonly buttons: ButtonInput[] +} diff --git a/src/dto/form/form.page.model.ts b/src/dto/form/page.model.ts similarity index 92% rename from src/dto/form/form.page.model.ts rename to src/dto/form/page.model.ts index 56b0a48..3e18a43 100644 --- a/src/dto/form/form.page.model.ts +++ b/src/dto/form/page.model.ts @@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { FormPage } from '../../schema/form.schema'; import { ButtonModel } from './button.model'; -@ObjectType('FormPage') -export class FormPageModel { +@ObjectType('Page') +export class PageModel { @Field() readonly show: boolean diff --git a/src/dto/form/pager.form.model.ts b/src/dto/form/pager.form.model.ts new file mode 100644 index 0000000..7df5818 --- /dev/null +++ b/src/dto/form/pager.form.model.ts @@ -0,0 +1,25 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { GraphQLInt } from 'graphql'; +import { FormModel } from './form.model'; + +@ObjectType('PagerForm') +export class PagerFormModel { + @Field(() => [FormModel]) + entries: FormModel[] + + @Field(() => GraphQLInt) + total: number + + @Field(() => GraphQLInt) + limit: number + + @Field(() => GraphQLInt) + start: number + + constructor(entries: FormModel[], total: number, limit: number, start: number) { + this.entries = entries + this.total = total + this.limit = limit + this.start = start + } +} diff --git a/src/dto/form/respondent.notifications.input.ts b/src/dto/form/respondent.notifications.input.ts new file mode 100644 index 0000000..eefe1a9 --- /dev/null +++ b/src/dto/form/respondent.notifications.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AbstractNotificationInput } from './abstract.notification.input'; + +@InputType() +export class RespondentNotificationsInput extends AbstractNotificationInput { + @Field({ nullable: true }) + readonly toField?: string + + @Field({ nullable: true }) + readonly fromEmail?: string +} diff --git a/src/dto/form/self.notifications.input.ts b/src/dto/form/self.notifications.input.ts new file mode 100644 index 0000000..0075c0c --- /dev/null +++ b/src/dto/form/self.notifications.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AbstractNotificationInput } from './abstract.notification.input'; + +@InputType() +export class SelfNotificationsInput extends AbstractNotificationInput { + @Field({ nullable: true }) + readonly fromField?: string + + @Field({ nullable: true }) + readonly toEmail?: string +} diff --git a/src/dto/user/own.user.model.ts b/src/dto/user/own.user.model.ts index 00cc79a..74c459d 100644 --- a/src/dto/user/own.user.model.ts +++ b/src/dto/user/own.user.model.ts @@ -1,8 +1,15 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { UserDocument } from '../../schema/user.schema'; import { UserModel } from './user.model'; @ObjectType('OwnUser') export class OwnUserModel extends UserModel { @Field(() => [String]) readonly roles: string[] + + constructor(user: UserDocument) { + super(user) + + this.roles = user.roles + } } diff --git a/src/dto/user/user.model.ts b/src/dto/user/user.model.ts index 1d55697..7e16fb8 100644 --- a/src/dto/user/user.model.ts +++ b/src/dto/user/user.model.ts @@ -21,7 +21,7 @@ export class UserModel { @Field() readonly lastName?: string - constructor(user: Partial) { + constructor(user: UserDocument) { this.id = user.id this.username = user.username this.email = user.email diff --git a/src/guard/gql.auth.guard.ts b/src/guard/gql.auth.guard.ts index 0219419..83f4722 100644 --- a/src/guard/gql.auth.guard.ts +++ b/src/guard/gql.auth.guard.ts @@ -1,15 +1,14 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { AuthGuard } from '@nestjs/passport'; +import { ContextCache } from '../resolver/context.cache'; @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); if (!ctx.getContext().cache) { - ctx.getContext().cache = { - // add(type, id, object) => - } + ctx.getContext().cache = new ContextCache() } return ctx.getContext().req; } diff --git a/src/main.ts b/src/main.ts index e4c2d5a..d131a8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,5 +19,5 @@ import { AppModule } from './app.module'; app.enableCors({origin: '*'}) app.getHttpAdapter().options('*', cors()) - await app.listen(process.env.PORT || 3000); + await app.listen(process.env.PORT || 4100); })() diff --git a/src/resolver/auth/auth.register.resolver.ts b/src/resolver/auth/auth.register.resolver.ts index ed848fa..2a86082 100644 --- a/src/resolver/auth/auth.register.resolver.ts +++ b/src/resolver/auth/auth.register.resolver.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Args, Mutation } from '@nestjs/graphql'; +import { PinoLogger } from 'nestjs-pino/dist'; import { AuthJwtModel } from '../../dto/auth/auth.jwt.model'; import { UserCreateInput } from '../../dto/user/user.create.input'; import { AuthService } from '../../service/auth/auth.service'; @@ -10,6 +11,7 @@ export class AuthRegisterResolver { constructor( private readonly createUser: UserCreateService, private readonly auth: AuthService, + private readonly logger: PinoLogger, ) { } @@ -17,6 +19,10 @@ export class AuthRegisterResolver { async authRegister( @Args({ name: 'user' }) data: UserCreateInput, ): Promise { + this.logger.info({ + email: data.email, + username: data.username, + }, 'try to register new user') const user = await this.createUser.create(data) return this.auth.login(user) diff --git a/src/resolver/form/form.create.mutation.ts b/src/resolver/form/form.create.mutation.ts new file mode 100644 index 0000000..a60fe69 --- /dev/null +++ b/src/resolver/form/form.create.mutation.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Args, Context, Mutation } from '@nestjs/graphql'; +import { Roles } from '../../decorator/roles.decorator'; +import { User } from '../../decorator/user.decorator'; +import { FormCreateInput } from '../../dto/form/form.create.input'; +import { FormModel } from '../../dto/form/form.model'; +import { UserDocument } from '../../schema/user.schema'; +import { FormCreateService } from '../../service/form/form.create.service'; +import { ContextCache } from '../context.cache'; + +@Injectable() +export class FormCreateMutation { + constructor( + private readonly createService: FormCreateService + ) { + } + + @Mutation(() => FormModel) + @Roles('admin') + async createForm( + @User() user: UserDocument, + @Args({ name: 'form', type: () => FormCreateInput }) input: FormCreateInput, + @Context('cache') cache: ContextCache, + ): Promise { + const form = await this.createService.create(user, input) + + cache.addForm(form) + + return new FormModel(form) + } +} diff --git a/src/resolver/form/form.delete.mutation.ts b/src/resolver/form/form.delete.mutation.ts new file mode 100644 index 0000000..49f6610 --- /dev/null +++ b/src/resolver/form/form.delete.mutation.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { Args, ID, Mutation } from '@nestjs/graphql'; +import { Roles } from '../../decorator/roles.decorator'; +import { User } from '../../decorator/user.decorator'; +import { FormModel } from '../../dto/form/form.model'; +import { UserDocument } from '../../schema/user.schema'; +import { FormDeleteService } from '../../service/form/form.delete.service'; +import { FormService } from '../../service/form/form.service'; + +@Injectable() +export class FormDeleteMutation { + constructor( + private readonly deleteService: FormDeleteService, + private readonly formService: FormService, + ) { + } + + @Mutation(() => FormModel) + @Roles('admin') + async deleteForm( + @User() user: UserDocument, + @Args({ name: 'id', type: () => ID}) id: string, + ) { + const form = await this.formService.findById(id) + + if (!form.isLive && !await this.formService.isAdmin(form, user)) { + throw new Error('invalid form') + } + + await this.deleteService.delete(id) + + return new FormModel(form) + } +} diff --git a/src/resolver/form/form.resolver.ts b/src/resolver/form/form.resolver.ts index 8b8e0b8..b50c2eb 100644 --- a/src/resolver/form/form.resolver.ts +++ b/src/resolver/form/form.resolver.ts @@ -2,8 +2,9 @@ import { Args, Context, ID, Parent, Query, ResolveField, Resolver } from '@nestj 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 { FormModel } from '../../dto/form/form.model'; -import { FormPageModel } from '../../dto/form/form.page.model'; +import { PageModel } from '../../dto/form/page.model'; import { RespondentNotificationsModel } from '../../dto/form/respondent.notifications.model'; import { SelfNotificationsModel } from '../../dto/form/self.notifications.model'; import { UserModel } from '../../dto/user/user.model'; @@ -35,6 +36,17 @@ export class FormResolver { return new FormModel(form) } + @ResolveField('fields', () => [FormFieldModel]) + async getFields( + @User() user: UserDocument, + @Parent() parent: FormModel, + @Context('cache') cache: ContextCache, + ): Promise { + const form = await cache.getForm(parent.id) + + return form.fields.map(field => new FormFieldModel(field)) + } + @ResolveField('isLive', () => Boolean) @Roles('admin') async getRoles( @@ -67,13 +79,13 @@ export class FormResolver { return new SelfNotificationsModel(form.selfNotifications) } - @ResolveField('respondentNotifications', () => SelfNotificationsModel) + @ResolveField('respondentNotifications', () => RespondentNotificationsModel) @Roles('admin') async getRespondentNotifications( @User() user: UserDocument, @Parent() parent: FormModel, @Context('cache') cache: ContextCache, - ): Promise { + ): Promise { const form = await cache.getForm(parent.id) if (!await this.formService.isAdmin(form, user)) { @@ -94,24 +106,24 @@ export class FormResolver { return new DesignModel(form.design) } - @ResolveField('startPage', () => FormPageModel) + @ResolveField('startPage', () => PageModel) async getStartPage( @Parent() parent: FormModel, @Context('cache') cache: ContextCache, - ): Promise { + ): Promise { const form = await cache.getForm(parent.id) - return new FormPageModel(form.startPage) + return new PageModel(form.startPage) } - @ResolveField('endPage', () => FormPageModel) + @ResolveField('endPage', () => PageModel) async getEndPage( @Parent() parent: FormModel, @Context('cache') cache: ContextCache, - ): Promise { + ): Promise { const form = await cache.getForm(parent.id) - return new FormPageModel(form.endPage) + return new PageModel(form.endPage) } @ResolveField('admin', () => UserModel) diff --git a/src/resolver/form/form.search.resolver.ts b/src/resolver/form/form.search.resolver.ts new file mode 100644 index 0000000..e35b6c5 --- /dev/null +++ b/src/resolver/form/form.search.resolver.ts @@ -0,0 +1,35 @@ +import { Args, Context, Query, Resolver } from '@nestjs/graphql'; +import { GraphQLInt } from 'graphql'; +import { User } from '../../decorator/user.decorator'; +import { FormModel } from '../../dto/form/form.model'; +import { PagerFormModel } from '../../dto/form/pager.form.model'; +import { UserDocument } from '../../schema/user.schema'; +import { FormService } from '../../service/form/form.service'; +import { ContextCache } from '../context.cache'; + +@Resolver(() => PagerFormModel) +export class FormSearchResolver { + constructor( + private readonly formService: FormService, + ) { + } + + @Query(() => PagerFormModel) + async listForms( + @User() user: UserDocument, + @Args('start', {type: () => GraphQLInt, defaultValue: 0, nullable: true}) start, + @Args('limit', {type: () => GraphQLInt, defaultValue: 50, nullable: true}) limit, + @Context('cache') cache: ContextCache, + ) { + const [forms, total] = await this.formService.find(user, start, limit) + + forms.forEach(form => cache.addForm(form)) + + return new PagerFormModel( + forms.map(form => new FormModel(form)), + total, + limit, + start, + ) + } +} diff --git a/src/resolver/form/form.update.mutation.ts b/src/resolver/form/form.update.mutation.ts new file mode 100644 index 0000000..cd18798 --- /dev/null +++ b/src/resolver/form/form.update.mutation.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { Args, Context, Mutation } from '@nestjs/graphql'; +import { Roles } from '../../decorator/roles.decorator'; +import { User } from '../../decorator/user.decorator'; +import { FormModel } from '../../dto/form/form.model'; +import { FormUpdateInput } from '../../dto/form/form.update.input'; +import { UserDocument } from '../../schema/user.schema'; +import { FormService } from '../../service/form/form.service'; +import { FormUpdateService } from '../../service/form/form.update.service'; +import { ContextCache } from '../context.cache'; + +@Injectable() +export class FormUpdateMutation { + constructor( + private readonly updateService: FormUpdateService, + private readonly formService: FormService, + ) { + } + + @Mutation(() => FormModel) + @Roles('admin') + async updateForm( + @User() user: UserDocument, + @Args({ name: 'form', type: () => FormUpdateInput }) input: FormUpdateInput, + @Context('cache') cache: ContextCache, + ): Promise { + let form = await this.formService.findById(input.id) + + if (!form.isLive && !await this.formService.isAdmin(form, user)) { + throw new Error('invalid form') + } + + form = await this.updateService.update(form, input) + + cache.addForm(form) + + return new FormModel(form) + } +} diff --git a/src/resolver/form/index.ts b/src/resolver/form/index.ts index 1ad0464..4942134 100644 --- a/src/resolver/form/index.ts +++ b/src/resolver/form/index.ts @@ -1,9 +1,13 @@ -import { FieldResolver } from './field.resolver'; -import { FormCreateResolver } from './form.create.resolver'; +import { FormCreateMutation } from './form.create.mutation'; +import { FormDeleteMutation } from './form.delete.mutation'; import { FormResolver } from './form.resolver'; +import { FormSearchResolver } from './form.search.resolver'; +import { FormUpdateMutation } from './form.update.mutation'; export const formResolvers = [ FormResolver, - FormCreateResolver, - FieldResolver, + FormSearchResolver, + FormCreateMutation, + FormDeleteMutation, + FormUpdateMutation, ] diff --git a/src/schema/embedded/logic.jump.ts b/src/schema/embedded/logic.jump.ts index 5f6a063..43be689 100644 --- a/src/schema/embedded/logic.jump.ts +++ b/src/schema/embedded/logic.jump.ts @@ -1,5 +1,5 @@ import { Schema, SchemaDefinition } from 'mongoose'; -import { FieldSchemaName } from '../field.schema'; +import { FormFieldSchemaName } from '../form.field.schema'; export const LogicJump: SchemaDefinition = { expressionString: { @@ -21,14 +21,14 @@ export const LogicJump: SchemaDefinition = { }, fieldA: { type: Schema.Types.ObjectId, - ref: FieldSchemaName + ref: FormFieldSchemaName }, valueB: { type: String, }, jumpTo: { type: Schema.Types.ObjectId, - ref: FieldSchemaName + ref: FormFieldSchemaName }, enabled: { type: Boolean, diff --git a/src/schema/embedded/rating.field.ts b/src/schema/embedded/rating.field.ts index 13c7aba..0a16c5c 100644 --- a/src/schema/embedded/rating.field.ts +++ b/src/schema/embedded/rating.field.ts @@ -24,9 +24,4 @@ export const RatingField: SchemaDefinition = { 'Trash', ], }, - validShapes: { - type: [{ - type: String, - }] - } } diff --git a/src/schema/field.schema.ts b/src/schema/form.field.schema.ts similarity index 61% rename from src/schema/field.schema.ts rename to src/schema/form.field.schema.ts index bdef424..e443e33 100644 --- a/src/schema/field.schema.ts +++ b/src/schema/form.field.schema.ts @@ -4,17 +4,21 @@ import { FieldOption } from './embedded/field.option'; import { LogicJump } from './embedded/logic.jump'; import { RatingField } from './embedded/rating.field'; -export const FieldSchemaName = 'FormField' +export const FormFieldSchemaName = 'FormField' -export interface FieldDocument extends Document { - isSubmission: boolean +export interface FormFieldDocument extends Document { + title: string + description: string + logicJump: any + rating: any + options: any + required: boolean + disabled: boolean + type: string + value: any } -export const FieldSchema = new Schema({ - isSubmission: { - type: Boolean, - default: false, - }, +export const FormFieldSchema = new Schema({ title: { type: String, trim: true, @@ -27,9 +31,11 @@ export const FieldSchema = new Schema({ type: LogicJump, }, ratingOptions: { + alias: 'rating', type: RatingField, }, fieldOptions: { + alias: 'options', type: [FieldOption], }, required: { @@ -40,19 +46,20 @@ export const FieldSchema = new Schema({ type: Boolean, default: false, }, - deletePreserved: { // TODO remove - type: Boolean, - default: false, - }, - validFieldTypes: { // TODO remove - type: [String], - }, fieldType: { + alias: 'type', type: String, enum: fieldTypes, }, fieldValue: { + alias: 'value', type: Schema.Types.Mixed, default: '', }, }) + +export const FormFieldDefinition = { + name: FormFieldSchemaName, + schema: FormFieldSchema, +} + diff --git a/src/schema/form.schema.ts b/src/schema/form.schema.ts index a85e161..588f0bf 100644 --- a/src/schema/form.schema.ts +++ b/src/schema/form.schema.ts @@ -1,12 +1,10 @@ -import exp from 'constants'; import { Document, Schema } from 'mongoose'; import { matchType } from '../config/fields'; import { defaultLanguage, languages } from '../config/languages'; -import { rolesType } from '../config/roles'; import { ButtonDocument, ButtonSchema } from './button.schema'; -import { FieldDocument, FieldSchema } from './field.schema'; -import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema'; +import { FormFieldDocument, FormFieldSchema } from './form.field.schema'; import { UserDocument, UserSchemaName } from './user.schema'; +import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema'; export const FormSchemaName = 'Form' @@ -58,7 +56,7 @@ export interface FormDocument extends Document { readonly visitors: [VisitorDataDocument] } - readonly fields: [FieldDocument] + readonly fields: [FormFieldDocument] readonly admin: UserDocument @@ -103,9 +101,10 @@ export const FormSchema = new Schema({ type: [VisitorDataSchema], }, }, - fields: { - alias: 'form_fields', - type: [FieldSchema], + // eslint-disable-next-line @typescript-eslint/camelcase + form_fields: { + alias: 'fields', + type: [FormFieldSchema], default: [], }, admin: { @@ -113,18 +112,18 @@ export const FormSchema = new Schema({ ref: UserSchemaName, }, startPage: { - show: { - alias: 'showStart', + showStart: { + alias: 'startPage.show', type: Boolean, default: false }, - title: { - alias: 'introTitle', + introTitle: { + alias: 'startPage.title', type: String, default: 'Welcome to Form', }, - paragraph: { - alias: 'introParagraph', + introParagraph: { + alias: 'startPage.paragraph', type: String, default: 'Start', }, @@ -133,8 +132,8 @@ export const FormSchema = new Schema({ }, }, endPage: { - show: { - alias: 'showEnd', + showEnd: { + alias: 'endPage.show', type: Boolean, default: false, }, @@ -157,8 +156,8 @@ export const FormSchema = new Schema({ fromField: { type: String, }, - toEmail: { - alias: 'toEmails', + toEmails: { + alias: 'selfNotifications.toEmail', type: String, }, subject: { @@ -176,8 +175,8 @@ export const FormSchema = new Schema({ toField: { type: String, }, - fromEmail: { - alias: 'fromEmails', + fromEmails: { + alias: 'respondentNotifications.fromEmail', type: String, match: matchType.email, }, diff --git a/src/schema/index.ts b/src/schema/index.ts index a442e06..a935818 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,9 +1,11 @@ +import { FormFieldDefinition } from './form.field.schema'; import { FormDefinition } from './form.schema'; -import { FormSubmissionDefinition, FormSubmissionSchema } from './form.submission.schema'; +import { SubmissionDefinition } from './submission.schema'; import { UserDefinition } from './user.schema'; export const schema = [ FormDefinition, - FormSubmissionDefinition, + FormFieldDefinition, + SubmissionDefinition, UserDefinition, ] diff --git a/src/schema/submission.field.schema.ts b/src/schema/submission.field.schema.ts new file mode 100644 index 0000000..c385a9f --- /dev/null +++ b/src/schema/submission.field.schema.ts @@ -0,0 +1,26 @@ +import { Document, Schema } from 'mongoose'; +import { fieldTypes } from '../config/fields'; +import { FormFieldDocument, FormFieldSchemaName } from './form.field.schema'; + +export const SubmissionFieldSchemaName = 'SubmissionField' + +export interface SubmissionFieldDocument extends Document { + field: FormFieldDocument + fieldType: string + fieldValue: any +} + +export const SubmissionFormFieldSchema = new Schema({ + field: { + type: Schema.Types.ObjectId, + ref: FormFieldSchemaName + }, + fieldType: { + type: String, + enum: fieldTypes, + }, + fieldValue: { + type: Schema.Types.Mixed, + default: '', + }, +}) diff --git a/src/schema/form.submission.schema.ts b/src/schema/submission.schema.ts similarity index 60% rename from src/schema/form.submission.schema.ts rename to src/schema/submission.schema.ts index c5cba5d..f34fbf5 100644 --- a/src/schema/form.submission.schema.ts +++ b/src/schema/submission.schema.ts @@ -1,16 +1,17 @@ import { Document, Schema } from 'mongoose'; -import { FieldSchema } from './field.schema'; import { FormSchemaName } from './form.schema'; +import { SubmissionFieldDocument, SubmissionFieldSchemaName } from './submission.field.schema'; -export const FormSubmissionSchemaName = 'FormSubmission' +export const SubmissionSchemaName = 'FormSubmission' -export interface FormSubmissionDocument extends Document { +export interface SubmissionDocument extends Document { + fields: SubmissionFieldDocument[] } -export const FormSubmissionSchema = new Schema({ +export const SubmissionSchema = new Schema({ fields: { alias: 'form_fields', - type: [FieldSchema], + type: [SubmissionFieldSchemaName], default: [], }, form: { @@ -50,8 +51,8 @@ export const FormSubmissionSchema = new Schema({ } }) -export const FormSubmissionDefinition = { - name: FormSubmissionSchemaName, - schema: FormSubmissionSchema, +export const SubmissionDefinition = { + name: SubmissionSchemaName, + schema: SubmissionSchema, } diff --git a/src/schema/visitor.data.schema.ts b/src/schema/visitor.data.schema.ts index 2e8e03c..cf654eb 100644 --- a/src/schema/visitor.data.schema.ts +++ b/src/schema/visitor.data.schema.ts @@ -1,11 +1,11 @@ import { Document, Schema } from 'mongoose'; import { defaultLanguage, languages } from '../config/languages'; -import { FieldDocument, FieldSchemaName } from './field.schema'; +import { FormFieldDocument, FormFieldSchemaName } from './form.field.schema'; export interface VisitorDataDocument extends Document { readonly introParagraph?: string readonly referrer?: string - readonly filledOutFields: [FieldDocument] + readonly filledOutFields: [FormFieldDocument] readonly timeElapsed: number readonly isSubmitted: boolean readonly language: string @@ -24,7 +24,7 @@ export const VisitorDataSchema = new Schema({ filledOutFields: { type: [{ type: Schema.Types.ObjectId, - ref: FieldSchemaName, + ref: FormFieldSchemaName, }], }, timeElapsed: { diff --git a/src/service/auth/auth.service.ts b/src/service/auth/auth.service.ts index 2cfffa7..baf7d0b 100644 --- a/src/service/auth/auth.service.ts +++ b/src/service/auth/auth.service.ts @@ -16,6 +16,8 @@ export class AuthService { async validateUser(username: string, password: string): Promise { console.log('check user??', username) + // TODO only allow login for verified users! + const user = await this.userService.findByUsername(username); if (user && await this.passwordService.verify(password, user.passwordHash, user.salt)) { return user; diff --git a/src/service/form/form.create.service.ts b/src/service/form/form.create.service.ts index 02a1b71..d0b9a25 100644 --- a/src/service/form/form.create.service.ts +++ b/src/service/form/form.create.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from "mongoose"; +import { Model } from 'mongoose'; +import { FormCreateInput } from '../../dto/form/form.create.input'; import { FormDocument, FormSchemaName } from '../../schema/form.schema'; +import { UserDocument } from '../../schema/user.schema'; @Injectable() export class FormCreateService { @@ -10,4 +12,7 @@ export class FormCreateService { ) { } + async create(admin: UserDocument, input: FormCreateInput): Promise { + return null + } } diff --git a/src/service/form/form.delete.service.ts b/src/service/form/form.delete.service.ts new file mode 100644 index 0000000..6d8a84b --- /dev/null +++ b/src/service/form/form.delete.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { FormDocument, FormSchemaName } from '../../schema/form.schema'; + +@Injectable() +export class FormDeleteService { + constructor( + @InjectModel(FormSchemaName) private formModel: Model, + ) { + } + + async delete(id: string): Promise { + // TODO + throw new Error('form.delete not yet implemented') + } +} diff --git a/src/service/form/form.service.ts b/src/service/form/form.service.ts index 24c93f6..c5abb5e 100644 --- a/src/service/form/form.service.ts +++ b/src/service/form/form.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { GraphQLError } from 'graphql'; import { Model, Types } from 'mongoose'; import { FormDocument, FormSchemaName } from '../../schema/form.schema'; import { UserDocument } from '../../schema/user.schema'; @@ -20,6 +19,19 @@ export class FormService { return Types.ObjectId(form.admin.id).equals(Types.ObjectId(user.id)) } + async find(user: UserDocument, start: number, limit: number, sort: any = {}): Promise<[FormDocument[], number]> { + const qb = this.formModel.find() + + // TODO apply restrictions based on user! + + return [ + await qb.sort(sort) + .skip(start) + .limit(limit), + await qb.count() + ] + } + async findById(id: string): Promise { const form = await this.formModel.findById(id); diff --git a/src/service/form/form.update.service.ts b/src/service/form/form.update.service.ts new file mode 100644 index 0000000..88ff4f6 --- /dev/null +++ b/src/service/form/form.update.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { FormUpdateInput } from '../../dto/form/form.update.input'; +import { FormFieldDocument, FormFieldSchemaName } from '../../schema/form.field.schema'; +import { FormDocument, FormSchemaName } from '../../schema/form.schema'; + +@Injectable() +export class FormUpdateService { + constructor( + @InjectModel(FormSchemaName) private formModel: Model, + @InjectModel(FormFieldSchemaName) private formFieldModel: Model, + ) { + } + + async update(form: FormDocument, input: FormUpdateInput): Promise { + if (input.language !== undefined) { + form.set('language', input.language) + } + + if (input.title !== undefined) { + form.set('title', input.title) + } + + if (input.showFooter !== undefined) { + form.set('showFooter', input.showFooter) + } + + const fieldMapping = {} + + if (input.fields !== undefined) { + const nextFields = await Promise.all(input.fields.map(async (nextField) => { + let field = form.fields.find(field => field.id.toString() === nextField.id) + + if (!field) { + field = await this.formFieldModel.create({ + type: nextField.type, + }) + } + + // ability for other fields to apply mapping + fieldMapping[nextField.id] = field.id.toString() + field.set('title', nextField.title) + field.set('description', nextField.description) + field.set('required', nextField.required) + field.set('value', nextField.value) + + return field + })) + + console.log('field mapping', fieldMapping) + + form.set('fields', nextFields) + } + + const extractField = (id) => { + if (id && fieldMapping[id]) { + return fieldMapping[id] + } + + return null + } + + if (input.design !== undefined) { + form.set('design', input.design) + } + + if (input.selfNotifications !== undefined) { + form.set('selfNotifications', { + ...input.selfNotifications, + fromField: extractField(input.selfNotifications.fromField) + }) + } + + if (input.respondentNotifications !== undefined) { + form.set('respondentNotifications', { + ...input.respondentNotifications, + toField: extractField(input.respondentNotifications.toField) + }) + } + + if (input.startPage !== undefined) { + form.set('startPage', input.startPage) + } + + if (input.endPage !== undefined) { + form.set('endPage', input.endPage) + } + + await form.save() + + return form + } +} diff --git a/src/service/form/index.ts b/src/service/form/index.ts index 05a01ff..265120d 100644 --- a/src/service/form/index.ts +++ b/src/service/form/index.ts @@ -1,7 +1,11 @@ import { FormCreateService } from './form.create.service'; +import { FormDeleteService } from './form.delete.service'; import { FormService } from './form.service'; +import { FormUpdateService } from './form.update.service'; export const formServices = [ FormService, FormCreateService, + FormUpdateService, + FormDeleteService, ] diff --git a/src/service/index.ts b/src/service/index.ts index 6440bfa..a7225ae 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,3 +1,8 @@ +import { ConfigService } from '@nestjs/config'; +import { RedisPubSub } from 'graphql-redis-subscriptions'; +import { PubSub, PubSubEngine } from 'graphql-subscriptions'; +import Redis from 'ioredis'; +import { PinoLogger } from 'nestjs-pino/dist'; import { authServices } from './auth'; import { formServices } from './form'; import { MailService } from './mail.service'; @@ -8,4 +13,27 @@ export const services = [ ...formServices, ...authServices, MailService, + { + provide: 'PUB_SUB', + inject: [ConfigService, PinoLogger], + useFactory: (configService: ConfigService, logger: PinoLogger): PubSubEngine => { + const host = configService.get('REDIS_HOST', null) + const port = configService.get('REDIS_PORT', 6379) + + if (host === null) { + logger.warn('without redis graphql subscriptions will be unreliable in load balanced environments') + return new PubSub() + } + + const options = { + host, + port, + } + + return new RedisPubSub({ + publisher: new Redis(options), + subscriber: new Redis(options), + }) + } + }, ] diff --git a/yarn.lock b/yarn.lock index f0da522..f365e03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2979,6 +2979,11 @@ cls-hooked@^4.2.2: emitter-listener "^1.0.1" semver "^5.4.1" +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -3458,7 +3463,7 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -denque@^1.4.1: +denque@^1.1.0, denque@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf" integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ== @@ -4763,7 +4768,16 @@ graphql-extensions@^0.12.0: apollo-server-env "^2.4.3" apollo-server-types "^0.4.0" -graphql-subscriptions@^1.0.0: +graphql-redis-subscriptions@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/graphql-redis-subscriptions/-/graphql-redis-subscriptions-2.2.1.tgz#377be5670ff344aa78cf147a9852e686a65e4b21" + integrity sha512-Rk0hapKUZuZpJIv3rG5rmd1SX3f+9k1k5AXoh8bxbM3Vkdzh28WM7kvJOqq1pJuO3gQ4OAoqzciNT0MMHRylXQ== + dependencies: + iterall "^1.3.0" + optionalDependencies: + ioredis "^4.6.3" + +graphql-subscriptions@^1.0.0, graphql-subscriptions@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz#5f2fa4233eda44cf7570526adfcf3c16937aef11" integrity sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA== @@ -5240,6 +5254,21 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= +ioredis@^4.17.1, ioredis@^4.6.3: + version "4.17.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.17.1.tgz#06ef3d3b2cb96b7e6bc90a7b8839a33e743843ad" + integrity sha512-kfxkN/YO1dnyaoAGyNdH3my4A1eoGDy4QOfqn6o86fo4dTboxyxYVW0S0v/d3MkwCWlvSWhlwq6IJMY9BlWs6w== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.1.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + redis-commands "1.5.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.0.1" + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -6339,7 +6368,7 @@ lodash.bind@^4.1.4: resolved "https://registry.yarnpkg.com/lodash.bind/-/lodash.bind-4.2.1.tgz#7ae3017e939622ac31b7d7d7dcb1b34db1690d35" integrity sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU= -lodash.defaults@^4.0.1: +lodash.defaults@^4.0.1, lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= @@ -6349,7 +6378,7 @@ lodash.filter@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" integrity sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4= -lodash.flatten@^4.2.0: +lodash.flatten@^4.2.0, lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= @@ -8410,6 +8439,23 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redis-commands@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785" + integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + reflect-metadata@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -9132,6 +9178,11 @@ stack-utils@^1.0.1: resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== +standard-as-callback@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.0.1.tgz#ed8bb25648e15831759b6023bdb87e6b60b38126" + integrity sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"