This commit is contained in:
Michael Schramm 2020-05-29 17:09:19 +02:00
parent faa4aa48eb
commit 414bc04782
47 changed files with 799 additions and 102 deletions

21
LICENSE.md Normal file
View File

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

View File

@ -6,6 +6,10 @@ services:
- "27017:27017"
volumes:
- "./data/mongo:/data"
redis:
image: redis
ports:
- "6379:6379"
# api:
# build: .
# volumes:

View File

@ -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",

View File

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

View File

@ -3,10 +3,10 @@ export const fieldTypes = [
'textfield',
'date',
'email',
'legal',
// 'legal',
'textarea',
'link',
'statement',
// 'statement',
'dropdown',
'rating',
'radio',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,12 +21,12 @@ export class FormModel {
@Field()
readonly showFooter: boolean
constructor(partial: Partial<FormDocument>) {
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export class UserModel {
@Field()
readonly lastName?: string
constructor(user: Partial<UserDocument>) {
constructor(user: UserDocument) {
this.id = user.id
this.username = user.username
this.email = user.email

View File

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

View File

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

View File

@ -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<AuthJwtModel> {
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)

View File

@ -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<FormModel> {
const form = await this.createService.create(user, input)
cache.addForm(form)
return new FormModel(form)
}
}

View File

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

View File

@ -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<FormFieldModel[]> {
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<SelfNotificationsModel> {
): Promise<RespondentNotificationsModel> {
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<FormPageModel> {
): Promise<PageModel> {
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<FormPageModel> {
): Promise<PageModel> {
const form = await cache.getForm(parent.id)
return new FormPageModel(form.endPage)
return new PageModel(form.endPage)
}
@ResolveField('admin', () => UserModel)

View File

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

View File

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

View File

@ -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,
]

View File

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

View File

@ -24,9 +24,4 @@ export const RatingField: SchemaDefinition = {
'Trash',
],
},
validShapes: {
type: [{
type: String,
}]
}
}

View File

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

View File

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

View File

@ -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,
]

View File

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

View File

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

View File

@ -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: {

View File

@ -16,6 +16,8 @@ export class AuthService {
async validateUser(username: string, password: string): Promise<UserDocument> {
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;

View File

@ -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<FormDocument> {
return null
}
}

View File

@ -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<FormDocument>,
) {
}
async delete(id: string): Promise<void> {
// TODO
throw new Error('form.delete not yet implemented')
}
}

View File

@ -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<FormDocument> {
const form = await this.formModel.findById(id);

View File

@ -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<FormDocument>,
@InjectModel(FormFieldSchemaName) private formFieldModel: Model<FormFieldDocument>,
) {
}
async update(form: FormDocument, input: FormUpdateInput): Promise<FormDocument> {
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
}
}

View File

@ -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,
]

View File

@ -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<string>('REDIS_HOST', null)
const port = configService.get<number>('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),
})
}
},
]

View File

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