diff --git a/package.json b/package.json index 9a2eb6f..237943a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "commander": "^5.1.0", "cors": "^2.8.5", "cross-env": "^7.0.2", + "dayjs": "^1.8.28", "graphql": "15.0.0", "graphql-redis-subscriptions": "^2.2.1", "graphql-subscriptions": "^1.1.0", diff --git a/src/config/fields.ts b/src/config/fields.ts index 767516e..de664b1 100644 --- a/src/config/fields.ts +++ b/src/config/fields.ts @@ -16,8 +16,8 @@ export const fieldTypes = [ ] export const matchType = { - color: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - url: /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/, + color: /^#([A-F0-9]{6}|[A-F0-9]{3})$/i, + url: /((([A-Z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/i, email: /.+@.+\..+/, } diff --git a/src/dto/form/button.input.ts b/src/dto/form/button.input.ts index b3ad8eb..eda2e36 100644 --- a/src/dto/form/button.input.ts +++ b/src/dto/form/button.input.ts @@ -1,6 +1,6 @@ import { Field, InputType } from '@nestjs/graphql'; -@InputType('ButtonInput') +@InputType() export class ButtonInput { @Field({ nullable: true }) readonly url?: string @@ -14,6 +14,9 @@ export class ButtonInput { @Field({ nullable: true }) readonly bgColor?: string + @Field({ nullable: true }) + readonly activeColor?: string + @Field({ nullable: true }) readonly color?: string } diff --git a/src/dto/form/button.model.ts b/src/dto/form/button.model.ts index 80fdc0f..a24fdee 100644 --- a/src/dto/form/button.model.ts +++ b/src/dto/form/button.model.ts @@ -14,6 +14,9 @@ export class ButtonModel { @Field({ nullable: true }) readonly bgColor?: string + @Field({ nullable: true }) + readonly activeColor?: string + @Field({ nullable: true }) readonly color?: string @@ -22,6 +25,7 @@ export class ButtonModel { this.action = button.action this.text = button.text this.bgColor = button.bgColor + this.activeColor = button.activeColor this.color = button.color } } diff --git a/src/dto/form/colors.input.ts b/src/dto/form/colors.input.ts index cd91e24..41f0478 100644 --- a/src/dto/form/colors.input.ts +++ b/src/dto/form/colors.input.ts @@ -1,6 +1,6 @@ import { Field, InputType } from '@nestjs/graphql'; -@InputType('ColorsInput') +@InputType() export class ColorsInput { @Field() readonly backgroundColor: string @@ -14,6 +14,9 @@ export class ColorsInput { @Field() readonly buttonColor: string + @Field() + readonly buttonActiveColor: string + @Field() readonly buttonTextColor: string } diff --git a/src/dto/form/colors.model.ts b/src/dto/form/colors.model.ts index 9792953..3da3f12 100644 --- a/src/dto/form/colors.model.ts +++ b/src/dto/form/colors.model.ts @@ -15,6 +15,9 @@ export class ColorsModel { @Field() readonly buttonColor: string + @Field() + readonly buttonActiveColor: string + @Field() readonly buttonTextColor: string @@ -23,6 +26,7 @@ export class ColorsModel { this.questionColor = partial.questionColor this.answerColor = partial.answerColor this.buttonColor = partial.buttonColor + this.buttonActiveColor = partial.buttonActiveColor this.buttonTextColor = partial.buttonTextColor } } diff --git a/src/dto/form/design.input.ts b/src/dto/form/design.input.ts index d7c84bb..db9e5fc 100644 --- a/src/dto/form/design.input.ts +++ b/src/dto/form/design.input.ts @@ -1,7 +1,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { ColorsInput } from './colors.input'; -@InputType('DesignInput') +@InputType() export class DesignInput { @Field() readonly colors: ColorsInput diff --git a/src/dto/form/form.create.input.ts b/src/dto/form/form.create.input.ts index 0bb361f..aa74591 100644 --- a/src/dto/form/form.create.input.ts +++ b/src/dto/form/form.create.input.ts @@ -1,8 +1,16 @@ -import { Field, ID, InputType } from '@nestjs/graphql'; -import { FormUpdateInput } from './form.update.input'; +import { Field, InputType } from '@nestjs/graphql'; @InputType('FormCreateInput') -export class FormCreateInput extends FormUpdateInput { - @Field(() => ID, { nullable: true }) - readonly id: string +export class FormCreateInput { + @Field() + readonly title: string + + @Field() + readonly language: string + + @Field({ nullable: true }) + readonly showFooter: boolean + + @Field({ nullable: true }) + readonly isLive: boolean } diff --git a/src/dto/form/form.field.input.ts b/src/dto/form/form.field.input.ts index f0ab7ce..f4151b6 100644 --- a/src/dto/form/form.field.input.ts +++ b/src/dto/form/form.field.input.ts @@ -1,6 +1,6 @@ import { Field, ID, InputType } from '@nestjs/graphql'; -@InputType('FormFieldInput') +@InputType() export class FormFieldInput { @Field(() => ID) readonly id: string diff --git a/src/dto/form/form.update.input.ts b/src/dto/form/form.update.input.ts index 9ef46ea..392d957 100644 --- a/src/dto/form/form.update.input.ts +++ b/src/dto/form/form.update.input.ts @@ -5,7 +5,7 @@ import { PageInput } from './page.input'; import { RespondentNotificationsInput } from './respondent.notifications.input'; import { SelfNotificationsInput } from './self.notifications.input'; -@InputType('FormUpdateInput') +@InputType() export class FormUpdateInput { @Field(() => ID) readonly id: string diff --git a/src/dto/form/page.input.ts b/src/dto/form/page.input.ts index 34993ea..7cb8c0d 100644 --- a/src/dto/form/page.input.ts +++ b/src/dto/form/page.input.ts @@ -1,7 +1,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { ButtonInput } from './button.input'; -@InputType('PageInput') +@InputType() export class PageInput { @Field() readonly show: boolean diff --git a/src/dto/submission/device.input.ts b/src/dto/submission/device.input.ts new file mode 100644 index 0000000..b39de58 --- /dev/null +++ b/src/dto/submission/device.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class DeviceInput { + @Field() + readonly type: string + + @Field() + readonly name: string +} diff --git a/src/dto/submission/device.model.ts b/src/dto/submission/device.model.ts new file mode 100644 index 0000000..c31cdf7 --- /dev/null +++ b/src/dto/submission/device.model.ts @@ -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 + } +} diff --git a/src/dto/submission/geo.location.model.ts b/src/dto/submission/geo.location.model.ts new file mode 100644 index 0000000..c45d156 --- /dev/null +++ b/src/dto/submission/geo.location.model.ts @@ -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 + } +} diff --git a/src/dto/submission/pager.submission.model.ts b/src/dto/submission/pager.submission.model.ts new file mode 100644 index 0000000..1bd964a --- /dev/null +++ b/src/dto/submission/pager.submission.model.ts @@ -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 + } +} diff --git a/src/dto/submission/submission.field.model.ts b/src/dto/submission/submission.field.model.ts new file mode 100644 index 0000000..9f934b7 --- /dev/null +++ b/src/dto/submission/submission.field.model.ts @@ -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 + } +} diff --git a/src/dto/submission/submission.model.ts b/src/dto/submission/submission.model.ts new file mode 100644 index 0000000..2aabb66 --- /dev/null +++ b/src/dto/submission/submission.model.ts @@ -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 + } +} diff --git a/src/dto/submission/submission.progress.model.ts b/src/dto/submission/submission.progress.model.ts new file mode 100644 index 0000000..2008a73 --- /dev/null +++ b/src/dto/submission/submission.progress.model.ts @@ -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) { + this.id = submission.id + + this.timeElapsed = submission.timeElapsed + this.percentageComplete = submission.percentageComplete + + this.created = submission.created + this.lastModified = submission.lastModified + } +} diff --git a/src/dto/submission/submission.set.field.input.ts b/src/dto/submission/submission.set.field.input.ts new file mode 100644 index 0000000..b14c071 --- /dev/null +++ b/src/dto/submission/submission.set.field.input.ts @@ -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 +} diff --git a/src/dto/submission/submission.start.input.ts b/src/dto/submission/submission.start.input.ts new file mode 100644 index 0000000..67a1621 --- /dev/null +++ b/src/dto/submission/submission.start.input.ts @@ -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 +} diff --git a/src/resolver/context.cache.ts b/src/resolver/context.cache.ts index 13f8b89..10257f5 100644 --- a/src/resolver/context.cache.ts +++ b/src/resolver/context.cache.ts @@ -1,4 +1,6 @@ +import { FormFieldDocument } from '../schema/form.field.schema'; import { FormDocument } from '../schema/form.schema'; +import { SubmissionDocument } from '../schema/submission.schema'; import { UserDocument } from '../schema/user.schema'; export class ContextCache { @@ -10,6 +12,14 @@ export class ContextCache { [id: string]: FormDocument, } = {} + private submissions: { + [id: string]: SubmissionDocument, + } = {} + + private formField: { + [id: string]: FormFieldDocument, + } = {} + public addUser(user: UserDocument) { this.users[user.id] = user; } @@ -25,4 +35,20 @@ export class ContextCache { public async getForm(id: any): Promise { return this.forms[id] } + + public addSubmission(submission: SubmissionDocument) { + this.submissions[submission.id] = submission + } + + public async getSubmission(id: any): Promise { + return this.submissions[id] + } + + public addFormField(formField: FormFieldDocument) { + this.formField[formField.id] = formField + } + + public async getFormField(id: any): Promise { + return this.formField[id] + } } diff --git a/src/resolver/form/form.search.resolver.ts b/src/resolver/form/form.search.resolver.ts index e35b6c5..120a78f 100644 --- a/src/resolver/form/form.search.resolver.ts +++ b/src/resolver/form/form.search.resolver.ts @@ -21,7 +21,12 @@ export class FormSearchResolver { @Args('limit', {type: () => GraphQLInt, defaultValue: 50, nullable: true}) limit, @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)) diff --git a/src/resolver/index.ts b/src/resolver/index.ts index cf721b5..8cbac75 100644 --- a/src/resolver/index.ts +++ b/src/resolver/index.ts @@ -2,6 +2,7 @@ import { authServices } from './auth'; import { formResolvers } from './form'; import { myResolvers } from './me'; import { StatusResolver } from './status.resolver'; +import { submissionResolvers } from './submission'; import { userResolvers } from './user'; export const resolvers = [ @@ -10,4 +11,5 @@ export const resolvers = [ ...authServices, ...myResolvers, ...formResolvers, + ...submissionResolvers, ] diff --git a/src/resolver/submission/index.ts b/src/resolver/submission/index.ts new file mode 100644 index 0000000..015b7f0 --- /dev/null +++ b/src/resolver/submission/index.ts @@ -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, +] diff --git a/src/resolver/submission/submission.progress.resolver.ts b/src/resolver/submission/submission.progress.resolver.ts new file mode 100644 index 0000000..3c94be1 --- /dev/null +++ b/src/resolver/submission/submission.progress.resolver.ts @@ -0,0 +1,7 @@ +import { Resolver } from '@nestjs/graphql'; +import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model'; + +@Resolver(() => SubmissionProgressModel) +export class SubmissionProgressResolver { + +} diff --git a/src/resolver/submission/submission.resolver.ts b/src/resolver/submission/submission.resolver.ts new file mode 100644 index 0000000..179082a --- /dev/null +++ b/src/resolver/submission/submission.resolver.ts @@ -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 { + 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)) + } +} diff --git a/src/resolver/submission/submission.search.resolver.ts b/src/resolver/submission/submission.search.resolver.ts new file mode 100644 index 0000000..5d7093c --- /dev/null +++ b/src/resolver/submission/submission.search.resolver.ts @@ -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 { + 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, + ) + } +} diff --git a/src/resolver/submission/submission.set.field.mutation.ts b/src/resolver/submission/submission.set.field.mutation.ts new file mode 100644 index 0000000..8f3f583 --- /dev/null +++ b/src/resolver/submission/submission.set.field.mutation.ts @@ -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 { + 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) + } +} diff --git a/src/resolver/submission/submission.start.mutation.ts b/src/resolver/submission/submission.start.mutation.ts new file mode 100644 index 0000000..fb499da --- /dev/null +++ b/src/resolver/submission/submission.start.mutation.ts @@ -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 { + const form = await this.formService.findById(id) + + const submission = await this.startService.start(form, input, user) + + cache.addSubmission(submission) + + return new SubmissionProgressModel(submission) + } +} diff --git a/src/schema/button.schema.ts b/src/schema/button.schema.ts index 771def2..b2f62f8 100644 --- a/src/schema/button.schema.ts +++ b/src/schema/button.schema.ts @@ -1,4 +1,4 @@ -import { Schema, Document } from 'mongoose'; +import { Document, Schema } from 'mongoose'; import { matchType } from '../config/fields'; export interface ButtonDocument extends Document{ @@ -6,6 +6,7 @@ export interface ButtonDocument extends Document{ readonly action?: string readonly text?: string readonly bgColor?: string + readonly activeColor?: string readonly color?: string } @@ -23,11 +24,16 @@ export const ButtonSchema = new Schema({ bgColor: { type: String, match: matchType.color, - default: '#5bc0de', + default: '#fff', + }, + activeColor: { + type: String, + match: matchType.color, + default: '#40a9ff', }, color: { type: String, match: matchType.color, - default: '#ffffff' + default: '#666' }, }) diff --git a/src/schema/form.schema.ts b/src/schema/form.schema.ts index 588f0bf..eb7c17a 100644 --- a/src/schema/form.schema.ts +++ b/src/schema/form.schema.ts @@ -37,6 +37,7 @@ export interface Colors { readonly questionColor: string readonly answerColor: string readonly buttonColor: string + readonly buttonActiveColor: string readonly buttonTextColor: string } @@ -93,6 +94,14 @@ export const FormSchema = new Schema({ default: defaultLanguage, required: true, }, + showFooter: { + type: Boolean, + default: true, + }, + isLive: { + type: Boolean, + default: true, + }, analytics: { gaCode: { type: String, @@ -193,14 +202,6 @@ export const FormSchema = new Schema({ default: false, }, }, - showFooter: { - type: Boolean, - default: true, - }, - isLive: { - type: Boolean, - default: true, - }, design: { colors: { backgroundColor: { @@ -223,10 +224,15 @@ export const FormSchema = new Schema({ match: matchType.color, default: '#fff' }, + buttonActiveColor: { + type: String, + match: matchType.color, + default: '#40a9ff' + }, buttonTextColor: { type: String, match: matchType.color, - default: '#333' + default: '#666' }, }, diff --git a/src/schema/index.ts b/src/schema/index.ts index a935818..a195ff5 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,5 +1,6 @@ import { FormFieldDefinition } from './form.field.schema'; import { FormDefinition } from './form.schema'; +import { SubmissionFieldDefinition } from './submission.field.schema'; import { SubmissionDefinition } from './submission.schema'; import { UserDefinition } from './user.schema'; @@ -7,5 +8,6 @@ export const schema = [ FormDefinition, FormFieldDefinition, SubmissionDefinition, + SubmissionFieldDefinition, UserDefinition, ] diff --git a/src/schema/submission.field.schema.ts b/src/schema/submission.field.schema.ts index c385a9f..990c81b 100644 --- a/src/schema/submission.field.schema.ts +++ b/src/schema/submission.field.schema.ts @@ -5,12 +5,12 @@ import { FormFieldDocument, FormFieldSchemaName } from './form.field.schema'; export const SubmissionFieldSchemaName = 'SubmissionField' export interface SubmissionFieldDocument extends Document { - field: FormFieldDocument - fieldType: string - fieldValue: any + readonly field: FormFieldDocument + readonly fieldType: string + readonly fieldValue: any } -export const SubmissionFormFieldSchema = new Schema({ +export const SubmissionFieldSchema = new Schema({ field: { type: Schema.Types.ObjectId, ref: FormFieldSchemaName @@ -24,3 +24,9 @@ export const SubmissionFormFieldSchema = new Schema({ default: '', }, }) + +export const SubmissionFieldDefinition = { + name: SubmissionFieldSchemaName, + schema: SubmissionFieldSchema, +} + diff --git a/src/schema/submission.schema.ts b/src/schema/submission.schema.ts index f34fbf5..89f3ce4 100644 --- a/src/schema/submission.schema.ts +++ b/src/schema/submission.schema.ts @@ -1,17 +1,38 @@ import { Document, Schema } from 'mongoose'; -import { FormSchemaName } from './form.schema'; -import { SubmissionFieldDocument, SubmissionFieldSchemaName } from './submission.field.schema'; +import { FormDocument, FormSchemaName } from './form.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 { - 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({ fields: { - alias: 'form_fields', - type: [SubmissionFieldSchemaName], + type: [SubmissionFieldSchema], default: [], }, form: { @@ -19,14 +40,21 @@ export const SubmissionSchema = new Schema({ ref: FormSchemaName, required: true }, + user: { + type: Schema.Types.ObjectId, + ref: UserSchemaName, + }, ipAddr: { type: String }, + tokenHash: { + type: String + }, geoLocation: { - Country: { + country: { type: String }, - City: { + city: { type: String } }, @@ -39,10 +67,12 @@ export const SubmissionSchema = new Schema({ } }, timeElapsed: { - type: Number + type: Number, + default: 0, }, percentageComplete: { - type: Number + type: Number, + default: 0, }, }, { timestamps: { diff --git a/src/service/form/form.create.service.ts b/src/service/form/form.create.service.ts index d75001b..9fb08c4 100644 --- a/src/service/form/form.create.service.ts +++ b/src/service/form/form.create.service.ts @@ -4,21 +4,19 @@ 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'; -import { FormUpdateService } from './form.update.service'; @Injectable() export class FormCreateService { constructor( @InjectModel(FormSchemaName) private readonly formModel: Model, - private readonly updateService: FormUpdateService, ) { } async create(admin: UserDocument, input: FormCreateInput): Promise { - const form = await this.formModel.create({ - admin + return await this.formModel.create({ + admin, + ...input, }) - - return await this.updateService.update(form, input) } } + diff --git a/src/service/form/form.service.ts b/src/service/form/form.service.ts index c5abb5e..dd27c6a 100644 --- a/src/service/form/form.service.ts +++ b/src/service/form/form.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model, Types } from 'mongoose'; +import { FilterQuery, Model, Types } from 'mongoose'; import { FormDocument, FormSchemaName } from '../../schema/form.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)) } - async find(user: UserDocument, start: number, limit: number, sort: any = {}): Promise<[FormDocument[], number]> { - const qb = this.formModel.find() + async find(start: number, limit: number, sort: any = {}, user?: UserDocument): Promise<[FormDocument[], number]> { + let conditions: FilterQuery + + if (user) { + conditions = { + admin: user + } + } + + const qb = this.formModel.find(conditions) // TODO apply restrictions based on user! diff --git a/src/service/form/form.update.service.ts b/src/service/form/form.update.service.ts index d6ffc9e..c5fd387 100644 --- a/src/service/form/form.update.service.ts +++ b/src/service/form/form.update.service.ts @@ -8,8 +8,8 @@ import { FormDocument, FormSchemaName } from '../../schema/form.schema'; @Injectable() export class FormUpdateService { constructor( - @InjectModel(FormSchemaName) private formModel: Model, - @InjectModel(FormFieldSchemaName) private formFieldModel: Model, + @InjectModel(FormSchemaName) private readonly formModel: Model, + @InjectModel(FormFieldSchemaName) private readonly formFieldModel: Model, ) { } @@ -37,7 +37,7 @@ export class FormUpdateService { let field = form.fields.find(field => field.id.toString() === nextField.id) if (!field) { - field = await this.formFieldModel.create({ + field = new this.formFieldModel({ type: nextField.type, }) } diff --git a/src/service/index.ts b/src/service/index.ts index a7225ae..8125edc 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -6,12 +6,14 @@ import { PinoLogger } from 'nestjs-pino/dist'; import { authServices } from './auth'; import { formServices } from './form'; import { MailService } from './mail.service'; +import { submissionServices } from './submission'; import { userServices } from './user'; export const services = [ ...userServices, ...formServices, ...authServices, + ...submissionServices, MailService, { provide: 'PUB_SUB', diff --git a/src/service/submission/index.ts b/src/service/submission/index.ts new file mode 100644 index 0000000..4266a5a --- /dev/null +++ b/src/service/submission/index.ts @@ -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, +] diff --git a/src/service/submission/submission.service.ts b/src/service/submission/submission.service.ts new file mode 100644 index 0000000..18e4c81 --- /dev/null +++ b/src/service/submission/submission.service.ts @@ -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, + private readonly tokenService: SubmissionTokenService + ) { + } + + async isOwner(submission: SubmissionDocument, token: string): Promise { + 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 { + const submission = await this.submissionModel.findById(id); + + if (!submission) { + throw new Error('no form found') + } + + return submission + } +} diff --git a/src/service/submission/submission.set.field.service.ts b/src/service/submission/submission.set.field.service.ts new file mode 100644 index 0000000..49cb28b --- /dev/null +++ b/src/service/submission/submission.set.field.service.ts @@ -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, + @InjectModel(SubmissionFieldSchemaName) private readonly submissionFieldModel: Model, + ) { + } + + 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() + } +} diff --git a/src/service/submission/submission.start.service.ts b/src/service/submission/submission.start.service.ts new file mode 100644 index 0000000..bb92810 --- /dev/null +++ b/src/service/submission/submission.start.service.ts @@ -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, + private readonly tokenService: SubmissionTokenService + ) { + } + + async start( + form: FormDocument, + input: SubmissionStartInput, + user?: UserDocument, + ): Promise { + return await this.submissionModel.create({ + form, + device: input.device, + user, + tokenHash: await this.tokenService.hash(input.token) + }) + } +} diff --git a/src/service/submission/submission.token.service.ts b/src/service/submission/submission.token.service.ts new file mode 100644 index 0000000..871b4ae --- /dev/null +++ b/src/service/submission/submission.token.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SubmissionTokenService { + async hash(token: string): Promise { + return token + } + + async verify(token: string, hash: string): Promise { + return token == hash + } +} diff --git a/yarn.lock b/yarn.lock index f365e03..0fbc85e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,6 +3355,11 @@ dayjs@^1.8.16: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.8.26.tgz#c6d62ccdf058ca72a8d14bb93a23501058db9f1e" 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: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"