add logic to create forms, to add submissions, to read submissions, etc

This commit is contained in:
Michael Schramm 2020-05-30 19:10:06 +02:00
parent eb5bc26e5c
commit eda8a3920c
44 changed files with 666 additions and 52 deletions

View File

@ -38,6 +38,7 @@
"commander": "^5.1.0", "commander": "^5.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"dayjs": "^1.8.28",
"graphql": "15.0.0", "graphql": "15.0.0",
"graphql-redis-subscriptions": "^2.2.1", "graphql-redis-subscriptions": "^2.2.1",
"graphql-subscriptions": "^1.1.0", "graphql-subscriptions": "^1.1.0",

View File

@ -16,8 +16,8 @@ export const fieldTypes = [
] ]
export const matchType = { export const matchType = {
color: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, color: /^#([A-F0-9]{6}|[A-F0-9]{3})$/i,
url: /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/, url: /((([A-Z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/i,
email: /.+@.+\..+/, email: /.+@.+\..+/,
} }

View File

@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
@InputType('ButtonInput') @InputType()
export class ButtonInput { export class ButtonInput {
@Field({ nullable: true }) @Field({ nullable: true })
readonly url?: string readonly url?: string
@ -14,6 +14,9 @@ export class ButtonInput {
@Field({ nullable: true }) @Field({ nullable: true })
readonly bgColor?: string readonly bgColor?: string
@Field({ nullable: true })
readonly activeColor?: string
@Field({ nullable: true }) @Field({ nullable: true })
readonly color?: string readonly color?: string
} }

View File

@ -14,6 +14,9 @@ export class ButtonModel {
@Field({ nullable: true }) @Field({ nullable: true })
readonly bgColor?: string readonly bgColor?: string
@Field({ nullable: true })
readonly activeColor?: string
@Field({ nullable: true }) @Field({ nullable: true })
readonly color?: string readonly color?: string
@ -22,6 +25,7 @@ export class ButtonModel {
this.action = button.action this.action = button.action
this.text = button.text this.text = button.text
this.bgColor = button.bgColor this.bgColor = button.bgColor
this.activeColor = button.activeColor
this.color = button.color this.color = button.color
} }
} }

View File

@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
@InputType('ColorsInput') @InputType()
export class ColorsInput { export class ColorsInput {
@Field() @Field()
readonly backgroundColor: string readonly backgroundColor: string
@ -14,6 +14,9 @@ export class ColorsInput {
@Field() @Field()
readonly buttonColor: string readonly buttonColor: string
@Field()
readonly buttonActiveColor: string
@Field() @Field()
readonly buttonTextColor: string readonly buttonTextColor: string
} }

View File

@ -15,6 +15,9 @@ export class ColorsModel {
@Field() @Field()
readonly buttonColor: string readonly buttonColor: string
@Field()
readonly buttonActiveColor: string
@Field() @Field()
readonly buttonTextColor: string readonly buttonTextColor: string
@ -23,6 +26,7 @@ export class ColorsModel {
this.questionColor = partial.questionColor this.questionColor = partial.questionColor
this.answerColor = partial.answerColor this.answerColor = partial.answerColor
this.buttonColor = partial.buttonColor this.buttonColor = partial.buttonColor
this.buttonActiveColor = partial.buttonActiveColor
this.buttonTextColor = partial.buttonTextColor this.buttonTextColor = partial.buttonTextColor
} }
} }

View File

@ -1,7 +1,7 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { ColorsInput } from './colors.input'; import { ColorsInput } from './colors.input';
@InputType('DesignInput') @InputType()
export class DesignInput { export class DesignInput {
@Field() @Field()
readonly colors: ColorsInput readonly colors: ColorsInput

View File

@ -1,8 +1,16 @@
import { Field, ID, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { FormUpdateInput } from './form.update.input';
@InputType('FormCreateInput') @InputType('FormCreateInput')
export class FormCreateInput extends FormUpdateInput { export class FormCreateInput {
@Field(() => ID, { nullable: true }) @Field()
readonly id: string readonly title: string
@Field()
readonly language: string
@Field({ nullable: true })
readonly showFooter: boolean
@Field({ nullable: true })
readonly isLive: boolean
} }

View File

@ -1,6 +1,6 @@
import { Field, ID, InputType } from '@nestjs/graphql'; import { Field, ID, InputType } from '@nestjs/graphql';
@InputType('FormFieldInput') @InputType()
export class FormFieldInput { export class FormFieldInput {
@Field(() => ID) @Field(() => ID)
readonly id: string readonly id: string

View File

@ -5,7 +5,7 @@ import { PageInput } from './page.input';
import { RespondentNotificationsInput } from './respondent.notifications.input'; import { RespondentNotificationsInput } from './respondent.notifications.input';
import { SelfNotificationsInput } from './self.notifications.input'; import { SelfNotificationsInput } from './self.notifications.input';
@InputType('FormUpdateInput') @InputType()
export class FormUpdateInput { export class FormUpdateInput {
@Field(() => ID) @Field(() => ID)
readonly id: string readonly id: string

View File

@ -1,7 +1,7 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { ButtonInput } from './button.input'; import { ButtonInput } from './button.input';
@InputType('PageInput') @InputType()
export class PageInput { export class PageInput {
@Field() @Field()
readonly show: boolean readonly show: boolean

View File

@ -0,0 +1,10 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class DeviceInput {
@Field()
readonly type: string
@Field()
readonly name: string
}

View File

@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Device } from '../../schema/submission.schema';
@ObjectType('Device')
export class DeviceModel {
@Field()
readonly type: string
@Field()
readonly name: string
constructor(device: Device) {
this.type = device.type
this.name = device.name
}
}

View File

@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { GeoLocation } from '../../schema/submission.schema';
@ObjectType('GeoLocation')
export class GeoLocationModel {
@Field({ nullable: true })
country?: string
@Field({ nullable: true })
city?: string
constructor(geo: GeoLocation) {
this.country = geo.country
this.city = geo.city
}
}

View File

@ -0,0 +1,25 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { GraphQLInt } from 'graphql';
import { SubmissionModel } from './submission.model';
@ObjectType('PagerSubmission')
export class PagerSubmissionModel {
@Field(() => [SubmissionModel])
entries: SubmissionModel[]
@Field(() => GraphQLInt)
total: number
@Field(() => GraphQLInt)
limit: number
@Field(() => GraphQLInt)
start: number
constructor(entries: SubmissionModel[], total: number, limit: number, start: number) {
this.entries = entries
this.total = total
this.limit = limit
this.start = start
}
}

View File

@ -0,0 +1,20 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { SubmissionFieldDocument } from '../../schema/submission.field.schema';
@ObjectType('SubmissionField')
export class SubmissionFieldModel {
@Field(() => ID)
readonly id: string
@Field()
readonly value: string
@Field()
readonly type: string
constructor(field: SubmissionFieldDocument) {
this.id = field.id
this.value = JSON.stringify(field.fieldValue)
this.type = field.fieldType
}
}

View File

@ -0,0 +1,45 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { SubmissionDocument } from '../../schema/submission.schema';
import { DeviceModel } from './device.model';
import { GeoLocationModel } from './geo.location.model';
@ObjectType('Submission')
export class SubmissionModel {
@Field(() => ID)
readonly id: string
@Field()
readonly ipAddr: string
@Field(() => GeoLocationModel)
readonly geoLocation: GeoLocationModel
@Field(() => DeviceModel)
readonly device: DeviceModel
@Field()
readonly timeElapsed: number
@Field()
readonly percentageComplete: number
@Field()
readonly created: Date
@Field({ nullable: true })
readonly lastModified?: Date
constructor(submission: SubmissionDocument) {
this.id = submission.id
this.ipAddr = submission.ipAddr
this.geoLocation = new GeoLocationModel(submission.geoLocation)
this.device = new DeviceModel(submission.device)
this.timeElapsed = submission.timeElapsed
this.percentageComplete = submission.percentageComplete
this.created = submission.created
this.lastModified = submission.lastModified
}
}

View File

@ -0,0 +1,30 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { SubmissionDocument } from '../../schema/submission.schema';
@ObjectType('SubmissionProgress')
export class SubmissionProgressModel {
@Field(() => ID)
readonly id: string
@Field()
readonly timeElapsed: number
@Field()
readonly percentageComplete: number
@Field()
readonly created: Date
@Field({ nullable: true })
readonly lastModified?: Date
constructor(submission: Partial<SubmissionDocument>) {
this.id = submission.id
this.timeElapsed = submission.timeElapsed
this.percentageComplete = submission.percentageComplete
this.created = submission.created
this.lastModified = submission.lastModified
}
}

View File

@ -0,0 +1,13 @@
import { Field, ID, InputType } from '@nestjs/graphql';
@InputType()
export class SubmissionSetFieldInput {
@Field()
readonly token: string
@Field(() => ID)
readonly field: string
@Field()
readonly data: string
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { DeviceInput } from './device.input';
@InputType()
export class SubmissionStartInput {
@Field()
readonly token: string
@Field(() => DeviceInput)
readonly device: DeviceInput
}

View File

@ -1,4 +1,6 @@
import { FormFieldDocument } from '../schema/form.field.schema';
import { FormDocument } from '../schema/form.schema'; import { FormDocument } from '../schema/form.schema';
import { SubmissionDocument } from '../schema/submission.schema';
import { UserDocument } from '../schema/user.schema'; import { UserDocument } from '../schema/user.schema';
export class ContextCache { export class ContextCache {
@ -10,6 +12,14 @@ export class ContextCache {
[id: string]: FormDocument, [id: string]: FormDocument,
} = {} } = {}
private submissions: {
[id: string]: SubmissionDocument,
} = {}
private formField: {
[id: string]: FormFieldDocument,
} = {}
public addUser(user: UserDocument) { public addUser(user: UserDocument) {
this.users[user.id] = user; this.users[user.id] = user;
} }
@ -25,4 +35,20 @@ export class ContextCache {
public async getForm(id: any): Promise<FormDocument> { public async getForm(id: any): Promise<FormDocument> {
return this.forms[id] return this.forms[id]
} }
public addSubmission(submission: SubmissionDocument) {
this.submissions[submission.id] = submission
}
public async getSubmission(id: any): Promise<SubmissionDocument> {
return this.submissions[id]
}
public addFormField(formField: FormFieldDocument) {
this.formField[formField.id] = formField
}
public async getFormField(id: any): Promise<FormFieldDocument> {
return this.formField[id]
}
} }

View File

@ -21,7 +21,12 @@ export class FormSearchResolver {
@Args('limit', {type: () => GraphQLInt, defaultValue: 50, nullable: true}) limit, @Args('limit', {type: () => GraphQLInt, defaultValue: 50, nullable: true}) limit,
@Context('cache') cache: ContextCache, @Context('cache') cache: ContextCache,
) { ) {
const [forms, total] = await this.formService.find(user, start, limit) const [forms, total] = await this.formService.find(
start,
limit,
{},
user.roles.includes('superuser') ? null : user,
)
forms.forEach(form => cache.addForm(form)) forms.forEach(form => cache.addForm(form))

View File

@ -2,6 +2,7 @@ import { authServices } from './auth';
import { formResolvers } from './form'; import { formResolvers } from './form';
import { myResolvers } from './me'; import { myResolvers } from './me';
import { StatusResolver } from './status.resolver'; import { StatusResolver } from './status.resolver';
import { submissionResolvers } from './submission';
import { userResolvers } from './user'; import { userResolvers } from './user';
export const resolvers = [ export const resolvers = [
@ -10,4 +11,5 @@ export const resolvers = [
...authServices, ...authServices,
...myResolvers, ...myResolvers,
...formResolvers, ...formResolvers,
...submissionResolvers,
] ]

View File

@ -0,0 +1,13 @@
import { SubmissionProgressResolver } from './submission.progress.resolver';
import { SubmissionResolver } from './submission.resolver';
import { SubmissionSearchResolver } from './submission.search.resolver';
import { SubmissionSetFieldMutation } from './submission.set.field.mutation';
import { SubmissionStartMutation } from './submission.start.mutation';
export const submissionResolvers = [
SubmissionProgressResolver,
SubmissionResolver,
SubmissionSetFieldMutation,
SubmissionStartMutation,
SubmissionSearchResolver,
]

View File

@ -0,0 +1,7 @@
import { Resolver } from '@nestjs/graphql';
import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model';
@Resolver(() => SubmissionProgressModel)
export class SubmissionProgressResolver {
}

View File

@ -0,0 +1,30 @@
import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { User } from '../../decorator/user.decorator';
import { SubmissionFieldModel } from '../../dto/submission/submission.field.model';
import { SubmissionModel } from '../../dto/submission/submission.model';
import { UserDocument } from '../../schema/user.schema';
import { ContextCache } from '../context.cache';
@Resolver(() => SubmissionModel)
export class SubmissionResolver {
@ResolveField('fields', () => [SubmissionFieldModel])
async getFields(
@User() user: UserDocument,
@Parent() parent: SubmissionModel,
@Context('cache') cache: ContextCache,
): Promise<SubmissionFieldModel[]> {
const submission = await cache.getSubmission(parent.id)
if (!submission.populated('form')) {
submission.populate('form')
await submission.execPopulate()
}
cache.addForm(submission.form)
submission.form.fields.forEach(field => {
cache.addFormField(field)
})
return submission.fields.map(field => new SubmissionFieldModel(field))
}
}

View File

@ -0,0 +1,45 @@
import { Args, Context, ID, Query, Resolver } from '@nestjs/graphql';
import { GraphQLInt } from 'graphql';
import { User } from '../../decorator/user.decorator';
import { PagerSubmissionModel } from '../../dto/submission/pager.submission.model';
import { SubmissionModel } from '../../dto/submission/submission.model';
import { UserDocument } from '../../schema/user.schema';
import { FormService } from '../../service/form/form.service';
import { SubmissionService } from '../../service/submission/submission.service';
import { ContextCache } from '../context.cache';
@Resolver(() => PagerSubmissionModel)
export class SubmissionSearchResolver {
constructor(
private readonly formService: FormService,
private readonly submissionService: SubmissionService,
) {
}
@Query(() => PagerSubmissionModel)
async listSubmissions(
@User() user: UserDocument,
@Args('form', {type: () => ID}) id: string,
@Args('start', {type: () => GraphQLInt, defaultValue: 0, nullable: true}) start,
@Args('limit', {type: () => GraphQLInt, defaultValue: 50, nullable: true}) limit,
@Context('cache') cache: ContextCache,
): Promise<PagerSubmissionModel> {
const form = await this.formService.findById(id)
const [submissions, total] = await this.submissionService.find(
form,
start,
limit,
{},
)
submissions.forEach(submission => cache.addSubmission(submission))
return new PagerSubmissionModel(
submissions.map(submission => new SubmissionModel(submission)),
total,
limit,
start,
)
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Args, Context, ID, Mutation } from '@nestjs/graphql';
import { User } from '../../decorator/user.decorator';
import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model';
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input';
import { UserDocument } from '../../schema/user.schema';
import { SubmissionService } from '../../service/submission/submission.service';
import { SubmissionSetFieldService } from '../../service/submission/submission.set.field.service';
import { ContextCache } from '../context.cache';
@Injectable()
export class SubmissionSetFieldMutation {
constructor(
private readonly submissionService: SubmissionService,
private readonly setFieldService: SubmissionSetFieldService,
) {
}
@Mutation(() => SubmissionProgressModel)
async submissionSetField(
@User() user: UserDocument,
@Args({ name: 'submission', type: () => ID }) id: string,
@Args({ name: 'field', type: () => SubmissionSetFieldInput }) input: SubmissionSetFieldInput,
@Context('cache') cache: ContextCache,
): Promise<SubmissionProgressModel> {
const submission = await this.submissionService.findById(id)
if (!await this.submissionService.isOwner(submission, input.token)) {
throw new Error('no access to submission')
}
await this.setFieldService.saveField(submission, input)
cache.addSubmission(submission)
return new SubmissionProgressModel(submission)
}
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { Args, Context, ID, Mutation } from '@nestjs/graphql';
import { User } from '../../decorator/user.decorator';
import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model';
import { SubmissionStartInput } from '../../dto/submission/submission.start.input';
import { UserDocument } from '../../schema/user.schema';
import { FormService } from '../../service/form/form.service';
import { SubmissionStartService } from '../../service/submission/submission.start.service';
import { ContextCache } from '../context.cache';
@Injectable()
export class SubmissionStartMutation {
constructor(
private readonly startService: SubmissionStartService,
private readonly formService: FormService,
) {
}
@Mutation(() => SubmissionProgressModel)
async submissionStart(
@User() user: UserDocument,
@Args({ name: 'form', type: () => ID }) id: string,
@Args({ name: 'submission', type: () => SubmissionStartInput }) input: SubmissionStartInput,
@Context('cache') cache: ContextCache,
): Promise<SubmissionProgressModel> {
const form = await this.formService.findById(id)
const submission = await this.startService.start(form, input, user)
cache.addSubmission(submission)
return new SubmissionProgressModel(submission)
}
}

View File

@ -1,4 +1,4 @@
import { Schema, Document } from 'mongoose'; import { Document, Schema } from 'mongoose';
import { matchType } from '../config/fields'; import { matchType } from '../config/fields';
export interface ButtonDocument extends Document{ export interface ButtonDocument extends Document{
@ -6,6 +6,7 @@ export interface ButtonDocument extends Document{
readonly action?: string readonly action?: string
readonly text?: string readonly text?: string
readonly bgColor?: string readonly bgColor?: string
readonly activeColor?: string
readonly color?: string readonly color?: string
} }
@ -23,11 +24,16 @@ export const ButtonSchema = new Schema({
bgColor: { bgColor: {
type: String, type: String,
match: matchType.color, match: matchType.color,
default: '#5bc0de', default: '#fff',
},
activeColor: {
type: String,
match: matchType.color,
default: '#40a9ff',
}, },
color: { color: {
type: String, type: String,
match: matchType.color, match: matchType.color,
default: '#ffffff' default: '#666'
}, },
}) })

View File

@ -37,6 +37,7 @@ export interface Colors {
readonly questionColor: string readonly questionColor: string
readonly answerColor: string readonly answerColor: string
readonly buttonColor: string readonly buttonColor: string
readonly buttonActiveColor: string
readonly buttonTextColor: string readonly buttonTextColor: string
} }
@ -93,6 +94,14 @@ export const FormSchema = new Schema({
default: defaultLanguage, default: defaultLanguage,
required: true, required: true,
}, },
showFooter: {
type: Boolean,
default: true,
},
isLive: {
type: Boolean,
default: true,
},
analytics: { analytics: {
gaCode: { gaCode: {
type: String, type: String,
@ -193,14 +202,6 @@ export const FormSchema = new Schema({
default: false, default: false,
}, },
}, },
showFooter: {
type: Boolean,
default: true,
},
isLive: {
type: Boolean,
default: true,
},
design: { design: {
colors: { colors: {
backgroundColor: { backgroundColor: {
@ -223,10 +224,15 @@ export const FormSchema = new Schema({
match: matchType.color, match: matchType.color,
default: '#fff' default: '#fff'
}, },
buttonActiveColor: {
type: String,
match: matchType.color,
default: '#40a9ff'
},
buttonTextColor: { buttonTextColor: {
type: String, type: String,
match: matchType.color, match: matchType.color,
default: '#333' default: '#666'
}, },
}, },

View File

@ -1,5 +1,6 @@
import { FormFieldDefinition } from './form.field.schema'; import { FormFieldDefinition } from './form.field.schema';
import { FormDefinition } from './form.schema'; import { FormDefinition } from './form.schema';
import { SubmissionFieldDefinition } from './submission.field.schema';
import { SubmissionDefinition } from './submission.schema'; import { SubmissionDefinition } from './submission.schema';
import { UserDefinition } from './user.schema'; import { UserDefinition } from './user.schema';
@ -7,5 +8,6 @@ export const schema = [
FormDefinition, FormDefinition,
FormFieldDefinition, FormFieldDefinition,
SubmissionDefinition, SubmissionDefinition,
SubmissionFieldDefinition,
UserDefinition, UserDefinition,
] ]

View File

@ -5,12 +5,12 @@ import { FormFieldDocument, FormFieldSchemaName } from './form.field.schema';
export const SubmissionFieldSchemaName = 'SubmissionField' export const SubmissionFieldSchemaName = 'SubmissionField'
export interface SubmissionFieldDocument extends Document { export interface SubmissionFieldDocument extends Document {
field: FormFieldDocument readonly field: FormFieldDocument
fieldType: string readonly fieldType: string
fieldValue: any readonly fieldValue: any
} }
export const SubmissionFormFieldSchema = new Schema({ export const SubmissionFieldSchema = new Schema({
field: { field: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: FormFieldSchemaName ref: FormFieldSchemaName
@ -24,3 +24,9 @@ export const SubmissionFormFieldSchema = new Schema({
default: '', default: '',
}, },
}) })
export const SubmissionFieldDefinition = {
name: SubmissionFieldSchemaName,
schema: SubmissionFieldSchema,
}

View File

@ -1,17 +1,38 @@
import { Document, Schema } from 'mongoose'; import { Document, Schema } from 'mongoose';
import { FormSchemaName } from './form.schema'; import { FormDocument, FormSchemaName } from './form.schema';
import { SubmissionFieldDocument, SubmissionFieldSchemaName } from './submission.field.schema'; import { SubmissionFieldDocument, SubmissionFieldSchema } from './submission.field.schema';
import { UserDocument, UserSchemaName } from './user.schema';
export const SubmissionSchemaName = 'FormSubmission' export const SubmissionSchemaName = 'Submission'
export interface GeoLocation {
readonly country?: string
readonly city?: string
}
export interface Device {
readonly type?: string
readonly name?: string
}
export interface SubmissionDocument extends Document { export interface SubmissionDocument extends Document {
fields: SubmissionFieldDocument[] readonly fields: SubmissionFieldDocument[]
readonly form: FormDocument
readonly ipAddr: string
readonly tokenHash: string
readonly geoLocation: GeoLocation
readonly device: Device
readonly timeElapsed: number
readonly percentageComplete: number
readonly user?: UserDocument
readonly created: Date
readonly lastModified: Date
} }
export const SubmissionSchema = new Schema({ export const SubmissionSchema = new Schema({
fields: { fields: {
alias: 'form_fields', type: [SubmissionFieldSchema],
type: [SubmissionFieldSchemaName],
default: [], default: [],
}, },
form: { form: {
@ -19,14 +40,21 @@ export const SubmissionSchema = new Schema({
ref: FormSchemaName, ref: FormSchemaName,
required: true required: true
}, },
user: {
type: Schema.Types.ObjectId,
ref: UserSchemaName,
},
ipAddr: { ipAddr: {
type: String type: String
}, },
geoLocation: { tokenHash: {
Country: {
type: String type: String
}, },
City: { geoLocation: {
country: {
type: String
},
city: {
type: String type: String
} }
}, },
@ -39,10 +67,12 @@ export const SubmissionSchema = new Schema({
} }
}, },
timeElapsed: { timeElapsed: {
type: Number type: Number,
default: 0,
}, },
percentageComplete: { percentageComplete: {
type: Number type: Number,
default: 0,
}, },
}, { }, {
timestamps: { timestamps: {

View File

@ -4,21 +4,19 @@ import { Model } from 'mongoose';
import { FormCreateInput } from '../../dto/form/form.create.input'; import { FormCreateInput } from '../../dto/form/form.create.input';
import { FormDocument, FormSchemaName } from '../../schema/form.schema'; import { FormDocument, FormSchemaName } from '../../schema/form.schema';
import { UserDocument } from '../../schema/user.schema'; import { UserDocument } from '../../schema/user.schema';
import { FormUpdateService } from './form.update.service';
@Injectable() @Injectable()
export class FormCreateService { export class FormCreateService {
constructor( constructor(
@InjectModel(FormSchemaName) private readonly formModel: Model<FormDocument>, @InjectModel(FormSchemaName) private readonly formModel: Model<FormDocument>,
private readonly updateService: FormUpdateService,
) { ) {
} }
async create(admin: UserDocument, input: FormCreateInput): Promise<FormDocument> { async create(admin: UserDocument, input: FormCreateInput): Promise<FormDocument> {
const form = await this.formModel.create({ return await this.formModel.create({
admin admin,
...input,
}) })
return await this.updateService.update(form, input)
} }
} }

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose'; import { FilterQuery, Model, Types } from 'mongoose';
import { FormDocument, FormSchemaName } from '../../schema/form.schema'; import { FormDocument, FormSchemaName } from '../../schema/form.schema';
import { UserDocument } from '../../schema/user.schema'; import { UserDocument } from '../../schema/user.schema';
@ -19,8 +19,16 @@ export class FormService {
return Types.ObjectId(form.admin.id).equals(Types.ObjectId(user.id)) return Types.ObjectId(form.admin.id).equals(Types.ObjectId(user.id))
} }
async find(user: UserDocument, start: number, limit: number, sort: any = {}): Promise<[FormDocument[], number]> { async find(start: number, limit: number, sort: any = {}, user?: UserDocument): Promise<[FormDocument[], number]> {
const qb = this.formModel.find() let conditions: FilterQuery<FormDocument>
if (user) {
conditions = {
admin: user
}
}
const qb = this.formModel.find(conditions)
// TODO apply restrictions based on user! // TODO apply restrictions based on user!

View File

@ -8,8 +8,8 @@ import { FormDocument, FormSchemaName } from '../../schema/form.schema';
@Injectable() @Injectable()
export class FormUpdateService { export class FormUpdateService {
constructor( constructor(
@InjectModel(FormSchemaName) private formModel: Model<FormDocument>, @InjectModel(FormSchemaName) private readonly formModel: Model<FormDocument>,
@InjectModel(FormFieldSchemaName) private formFieldModel: Model<FormFieldDocument>, @InjectModel(FormFieldSchemaName) private readonly formFieldModel: Model<FormFieldDocument>,
) { ) {
} }
@ -37,7 +37,7 @@ export class FormUpdateService {
let field = form.fields.find(field => field.id.toString() === nextField.id) let field = form.fields.find(field => field.id.toString() === nextField.id)
if (!field) { if (!field) {
field = await this.formFieldModel.create({ field = new this.formFieldModel({
type: nextField.type, type: nextField.type,
}) })
} }

View File

@ -6,12 +6,14 @@ import { PinoLogger } from 'nestjs-pino/dist';
import { authServices } from './auth'; import { authServices } from './auth';
import { formServices } from './form'; import { formServices } from './form';
import { MailService } from './mail.service'; import { MailService } from './mail.service';
import { submissionServices } from './submission';
import { userServices } from './user'; import { userServices } from './user';
export const services = [ export const services = [
...userServices, ...userServices,
...formServices, ...formServices,
...authServices, ...authServices,
...submissionServices,
MailService, MailService,
{ {
provide: 'PUB_SUB', provide: 'PUB_SUB',

View File

@ -0,0 +1,11 @@
import { SubmissionService } from './submission.service';
import { SubmissionSetFieldService } from './submission.set.field.service';
import { SubmissionStartService } from './submission.start.service';
import { SubmissionTokenService } from './submission.token.service';
export const submissionServices = [
SubmissionSetFieldService,
SubmissionStartService,
SubmissionService,
SubmissionTokenService,
]

View File

@ -0,0 +1,40 @@
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { FormDocument } from '../../schema/form.schema';
import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema';
import { SubmissionTokenService } from './submission.token.service';
export class SubmissionService {
constructor(
@InjectModel(SubmissionSchemaName) private readonly submissionModel: Model<SubmissionDocument>,
private readonly tokenService: SubmissionTokenService
) {
}
async isOwner(submission: SubmissionDocument, token: string): Promise<boolean> {
return await this.tokenService.verify(token, submission.tokenHash)
}
async find(form: FormDocument, start: number, limit: number, sort: any = {}): Promise<[SubmissionDocument[], number]> {
const qb = this.submissionModel.find({
form
})
return [
await qb.sort(sort)
.skip(start)
.limit(limit),
await qb.count()
]
}
async findById(id: string): Promise<SubmissionDocument> {
const submission = await this.submissionModel.findById(id);
if (!submission) {
throw new Error('no form found')
}
return submission
}
}

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import dayjs from 'dayjs';
import { Model } from 'mongoose';
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input';
import { SubmissionFieldDocument, SubmissionFieldSchemaName } from '../../schema/submission.field.schema';
import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema';
@Injectable()
export class SubmissionSetFieldService {
constructor(
@InjectModel(SubmissionSchemaName) private readonly submissionModel: Model<SubmissionDocument>,
@InjectModel(SubmissionFieldSchemaName) private readonly submissionFieldModel: Model<SubmissionFieldDocument>,
) {
}
async saveField(submission: SubmissionDocument, input: SubmissionSetFieldInput) {
const existing = submission.fields.find(field => field.field.toString() === input.field)
const data = JSON.parse(input.data)
if (existing) {
existing.set('fieldValue', data)
} else {
if (!submission.populated('form')) {
submission.populate('form')
await submission.execPopulate()
}
const field = submission.form.fields.find(field => field.id.toString() === input.field)
const newField = new this.submissionFieldModel({
field,
fieldType: field.type,
fieldValue: data
})
submission.set('percentageComplete', (1 + submission.fields.length) / submission.form.fields.length)
submission.set('timeElapsed', dayjs().diff(dayjs(submission.created), 'second'))
submission.set('fields', [
...submission.fields,
newField,
])
}
await submission.save()
}
}

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { SubmissionStartInput } from '../../dto/submission/submission.start.input';
import { FormDocument } from '../../schema/form.schema';
import { SubmissionDocument, SubmissionSchemaName } from '../../schema/submission.schema';
import { UserDocument } from '../../schema/user.schema';
import { SubmissionTokenService } from './submission.token.service';
@Injectable()
export class SubmissionStartService {
constructor(
@InjectModel(SubmissionSchemaName) private submissionModel: Model<SubmissionDocument>,
private readonly tokenService: SubmissionTokenService
) {
}
async start(
form: FormDocument,
input: SubmissionStartInput,
user?: UserDocument,
): Promise<SubmissionDocument> {
return await this.submissionModel.create({
form,
device: input.device,
user,
tokenHash: await this.tokenService.hash(input.token)
})
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class SubmissionTokenService {
async hash(token: string): Promise<string> {
return token
}
async verify(token: string, hash: string): Promise<boolean> {
return token == hash
}
}

View File

@ -3355,6 +3355,11 @@ dayjs@^1.8.16:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.8.26.tgz#c6d62ccdf058ca72a8d14bb93a23501058db9f1e" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.8.26.tgz#c6d62ccdf058ca72a8d14bb93a23501058db9f1e"
integrity sha512-KqtAuIfdNfZR5sJY1Dixr2Is4ZvcCqhb0dZpCOt5dGEFiMzoIbjkTSzUb4QKTCsP+WNpGwUjAFIZrnZvUxxkhw== integrity sha512-KqtAuIfdNfZR5sJY1Dixr2Is4ZvcCqhb0dZpCOt5dGEFiMzoIbjkTSzUb4QKTCsP+WNpGwUjAFIZrnZvUxxkhw==
dayjs@^1.8.28:
version "1.8.28"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.8.28.tgz#37aa6201df483d089645cb6c8f6cef6f0c4dbc07"
integrity sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg==
debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"