base system for graphql
This commit is contained in:
commit
faa4aa48eb
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
/dist
|
||||
/node_modules
|
||||
/data
|
||||
/.git
|
||||
24
.eslintrc.js
Normal file
24
.eslintrc.js
Normal file
@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'prettier/@typescript-eslint',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# local data
|
||||
/data
|
||||
/.env
|
||||
/src/schema.gql
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
FROM node:12 AS builder
|
||||
MAINTAINER OhMyForm <admin@ohmyform.com>
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# just copy everhing
|
||||
COPY . .
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN yarn build
|
||||
|
||||
FROM node:12-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --from=builder /usr/src/app /usr/src/app
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "yarn", "start:prod" ]
|
||||
52
README.md
Normal file
52
README.md
Normal file
@ -0,0 +1,52 @@
|
||||
# OhMyForm API
|
||||
|
||||
## Description
|
||||
|
||||
[OhMyForm](https://github.com/ohmyforn) api backend
|
||||
|
||||
All calls to the api are through GraphQL, with the endpoint
|
||||
providing an introspectable schema at `GET /graphql`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
TODO
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Website - [https://ohmyform.com](https://ohmyform.com/)
|
||||
|
||||
## License
|
||||
|
||||
TODO
|
||||
24
doc/roles.md
Normal file
24
doc/roles.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Roles
|
||||
|
||||
## unauthenticated
|
||||
|
||||
every request is unauthenticated unless an `Authorization`
|
||||
header with a valid `JWT Bearer` token is provided.
|
||||
|
||||
## user
|
||||
|
||||
any new registration is a user per default, they can only see their
|
||||
responses and do not have access to forms.
|
||||
|
||||
## admin
|
||||
|
||||
an admin can create forms and edit their own forms. They do not
|
||||
have access to forms from other users.
|
||||
|
||||
## superuser
|
||||
|
||||
a superuser can create and edit any form on the platform as well as
|
||||
modify any user
|
||||
|
||||
they can also grant a user admin or superuser access, they cannot revoke
|
||||
their own superuser role
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
||||
version: "3"
|
||||
services:
|
||||
mongo:
|
||||
image: mongo
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- "./data/mongo:/data"
|
||||
# api:
|
||||
# build: .
|
||||
# volumes:
|
||||
# - ".:/usr/src/app"
|
||||
# environment:
|
||||
# MONGODB_URI: mongodb://mongo/ohmyform
|
||||
# MAILER_URI: smtp://mail:1025
|
||||
# command: yarn start:dev
|
||||
# links:
|
||||
# - mongo
|
||||
# - mail
|
||||
# ports:
|
||||
# - "6000:3000"
|
||||
# depends_on:
|
||||
# - mongo
|
||||
mail:
|
||||
image: mailhog/mailhog
|
||||
ports:
|
||||
- "6001:8025"
|
||||
mongoexpress:
|
||||
image: mongo-express
|
||||
environment:
|
||||
ME_CONFIG_MONGODB_SERVER: mongo
|
||||
ports:
|
||||
- "6002:8081"
|
||||
links:
|
||||
- mongo
|
||||
depends_on:
|
||||
- mongo
|
||||
5
locales/en/mail/user/created/body.html
Normal file
5
locales/en/mail/user/created/body.html
Normal file
@ -0,0 +1,5 @@
|
||||
Welcome to OhMyForm
|
||||
|
||||
Your Username is {{username}}
|
||||
|
||||
cheers
|
||||
5
locales/en/mail/user/created/body.txt
Normal file
5
locales/en/mail/user/created/body.txt
Normal file
@ -0,0 +1,5 @@
|
||||
Welcome to OhMyForm
|
||||
|
||||
Your Username is {{username}}
|
||||
|
||||
cheers
|
||||
1
locales/en/mail/user/created/subject.txt
Normal file
1
locales/en/mail/user/created/subject.txt
Normal file
@ -0,0 +1 @@
|
||||
Welcome to OhMyForm
|
||||
7
nest-cli.json
Normal file
7
nest-cli.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/graphql/plugin"]
|
||||
}
|
||||
}
|
||||
101
package.json
Normal file
101
package.json
Normal file
@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "ohmyform-api",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"cli:dev": "cross-env CLI=true TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register src/cli.ts",
|
||||
"cli": "cross-env CLI=true node dist/console.js",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^1.4.2",
|
||||
"@nestjs/common": "^7.0.9",
|
||||
"@nestjs/config": "^0.4.0",
|
||||
"@nestjs/core": "^7.0.9",
|
||||
"@nestjs/graphql": "^7.3.7",
|
||||
"@nestjs/jwt": "^7.0.0",
|
||||
"@nestjs/mongoose": "^6.4.0",
|
||||
"@nestjs/passport": "^7.0.0",
|
||||
"@nestjs/platform-express": "^7.0.0",
|
||||
"apollo-server-express": "^2.13.0",
|
||||
"bcrypt": "^4.0.1",
|
||||
"class-transformer": "^0.2.3",
|
||||
"class-validator": "^0.12.2",
|
||||
"commander": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.2",
|
||||
"graphql": "15.0.0",
|
||||
"graphql-tools": "^5.0.0",
|
||||
"handlebars": "^4.7.6",
|
||||
"inquirer": "^7.1.0",
|
||||
"migrate-mongoose": "^4.0.0",
|
||||
"mongoose": "^5.9.11",
|
||||
"nestjs-console": "^3.0.2",
|
||||
"nestjs-pino": "^1.2.0",
|
||||
"nodemailer": "^6.4.6",
|
||||
"passport": "^0.4.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pino-pretty": "^4.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^6.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^7.0.0",
|
||||
"@nestjs/schematics": "^7.0.0",
|
||||
"@nestjs/testing": "^7.0.0",
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
"@types/express": "^4.17.3",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/inquirer": "^6.5.0",
|
||||
"@types/jest": "25.1.4",
|
||||
"@types/mongoose": "^5.7.14",
|
||||
"@types/node": "^13.9.1",
|
||||
"@types/passport-jwt": "^3.0.3",
|
||||
"@types/passport-local": "^1.0.33",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^2.23.0",
|
||||
"@typescript-eslint/parser": "^2.23.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"jest": "^25.1.0",
|
||||
"prettier": "^1.19.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "25.2.1",
|
||||
"ts-loader": "^6.2.1",
|
||||
"ts-node": "^8.6.2",
|
||||
"tsconfig-paths": "^3.9.0",
|
||||
"typescript": "^3.7.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
110
src/app.imports.ts
Normal file
110
src/app.imports.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { HttpModule, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { GraphQLFederationModule } 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 { 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 = {
|
||||
pinoHttp: {
|
||||
level: process.env.CLI ? 'warn' : process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
|
||||
prettyPrint: process.env.NODE_ENV !== 'production' || process.env.CLI ? {
|
||||
translateTime: true,
|
||||
colorize: true,
|
||||
ignore: 'pid,hostname,req,res',
|
||||
} : false,
|
||||
},
|
||||
exclude: [
|
||||
{
|
||||
method: RequestMethod.ALL,
|
||||
path: '_health',
|
||||
},
|
||||
{
|
||||
method: RequestMethod.ALL,
|
||||
path: 'favicon.ico',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
export const imports = [
|
||||
ConsoleModule,
|
||||
HttpModule.register({
|
||||
timeout: 5000,
|
||||
maxRedirects: 10,
|
||||
}),
|
||||
ConfigModule.forRoot({
|
||||
load: [
|
||||
() => {
|
||||
return {
|
||||
LOCALES_PATH: join(process.cwd(), 'locales'),
|
||||
AUTH_SECRET: process.env.AUTH_SECRET || crypto.randomBytes(20).toString('hex'),
|
||||
}
|
||||
}
|
||||
],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService): Promise<JwtModuleOptions> => ({
|
||||
secret: configService.get<string>('AUTH_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '4h',
|
||||
},
|
||||
})
|
||||
}),
|
||||
LoggerModule.forRoot(LoggerConfig),
|
||||
GraphQLFederationModule.forRoot({
|
||||
debug: process.env.NODE_ENV !== 'production',
|
||||
definitions: {
|
||||
outputAs: 'class',
|
||||
},
|
||||
introspection: true,
|
||||
playground: true,
|
||||
// installSubscriptionHandlers: true,
|
||||
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
||||
// to allow guards on resolver props https://github.com/nestjs/graphql/issues/295
|
||||
fieldResolverEnhancers: [
|
||||
'guards',
|
||||
'interceptors',
|
||||
],
|
||||
resolverValidationOptions: {
|
||||
|
||||
},
|
||||
context: ({ req }) => {
|
||||
return {
|
||||
cache: new ContextCache(),
|
||||
req,
|
||||
}
|
||||
},
|
||||
}),
|
||||
MongooseModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService): Promise<MongooseModuleOptions> => ({
|
||||
uri: configService.get<string>('MONGODB_URI', 'mongodb://localhost/ohmyform'),
|
||||
// takes care of deprecations from https://mongoosejs.com/docs/deprecations.html
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
useCreateIndex: true,
|
||||
useFindAndModify: false,
|
||||
})
|
||||
}),
|
||||
MongooseModule.forFeature(schema),
|
||||
MailerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
transport: configService.get<string>('MAILER_URI', 'smtp://localhost:1025'),
|
||||
defaults: {
|
||||
from: configService.get<string>('MAILER_FROM', 'OhMyForm <no-reply@localhost>'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]
|
||||
11
src/app.module.ts
Normal file
11
src/app.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { imports } from './app.imports';
|
||||
import { providers } from './app.providers';
|
||||
import { controllers } from './controller';
|
||||
|
||||
@Module({
|
||||
imports,
|
||||
controllers,
|
||||
providers,
|
||||
})
|
||||
export class AppModule {}
|
||||
11
src/app.providers.ts
Normal file
11
src/app.providers.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { commands } from './command';
|
||||
import { guards } from './guard';
|
||||
import { resolvers } from './resolver';
|
||||
import { services } from './service';
|
||||
|
||||
export const providers = [
|
||||
...resolvers,
|
||||
...commands,
|
||||
...services,
|
||||
...guards,
|
||||
]
|
||||
16
src/cli.ts
Normal file
16
src/cli.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { BootstrapConsole } from 'nestjs-console';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const bootstrap = new BootstrapConsole({
|
||||
module: AppModule,
|
||||
useDecorators: true,
|
||||
});
|
||||
bootstrap.init().then(async (app) => {
|
||||
try {
|
||||
await app.init();
|
||||
await bootstrap.boot();
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
5
src/command/index.ts
Normal file
5
src/command/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserCommand } from './user.command';
|
||||
|
||||
export const commands = [
|
||||
UserCommand,
|
||||
]
|
||||
64
src/command/user.command.ts
Normal file
64
src/command/user.command.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import inquirer from 'inquirer';
|
||||
import { Command, Console } from 'nestjs-console';
|
||||
import { matchType, validatePassword } from '../config/fields';
|
||||
import { UserCreateService } from '../service/user/user.create.service';
|
||||
|
||||
@Console({
|
||||
name: 'user',
|
||||
description: 'handle instance users'
|
||||
})
|
||||
export class UserCommand {
|
||||
constructor(
|
||||
private readonly createUser: UserCreateService
|
||||
) {
|
||||
}
|
||||
|
||||
@Command({
|
||||
command: 'create',
|
||||
})
|
||||
async create(): Promise<void> {
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'username for login',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'email',
|
||||
message: 'email to send notifications to',
|
||||
validate(input: string): boolean | string {
|
||||
if (!matchType.email.test(input)) {
|
||||
return 'invalid email'
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
validate: validatePassword,
|
||||
message: 'password to login',
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'create',
|
||||
message: current => {
|
||||
return `create user ${current.username} with email ${current.email}`
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
await this.createUser.create(answers)
|
||||
|
||||
console.info(`user ${answers.username} has been created`)
|
||||
}
|
||||
|
||||
@Command({
|
||||
command: 'activate <username>',
|
||||
})
|
||||
async activate(username: string): Promise<void> {
|
||||
console.log(`activate user ${username}`)
|
||||
}
|
||||
}
|
||||
30
src/config/fields.ts
Normal file
30
src/config/fields.ts
Normal file
@ -0,0 +1,30 @@
|
||||
|
||||
export const fieldTypes = [
|
||||
'textfield',
|
||||
'date',
|
||||
'email',
|
||||
'legal',
|
||||
'textarea',
|
||||
'link',
|
||||
'statement',
|
||||
'dropdown',
|
||||
'rating',
|
||||
'radio',
|
||||
'hidden',
|
||||
'yes_no',
|
||||
'number',
|
||||
]
|
||||
|
||||
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]*))?)/,
|
||||
email: /.+@.+\..+/,
|
||||
}
|
||||
|
||||
export const validatePassword = (password: string): true | string => {
|
||||
if (password.length < 4) {
|
||||
return 'password is too short'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
4
src/config/languages.ts
Normal file
4
src/config/languages.ts
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
export const languages = ['en', 'fr', 'es', 'it', 'de']
|
||||
|
||||
export const defaultLanguage = 'en'
|
||||
5
src/config/roles.ts
Normal file
5
src/config/roles.ts
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
export type roleType = 'user' | 'admin' | 'superuser'
|
||||
export type rolesType = roleType[]
|
||||
|
||||
export const roles: rolesType = ['user', 'admin', 'superuser']
|
||||
9
src/controller/health.controller.ts
Normal file
9
src/controller/health.controller.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get('/_health')
|
||||
getHello(): string {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
5
src/controller/index.ts
Normal file
5
src/controller/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
export const controllers = [
|
||||
HealthController,
|
||||
]
|
||||
4
src/decorator/roles.decorator.ts
Normal file
4
src/decorator/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { rolesType } from '../config/roles';
|
||||
|
||||
export const Roles = (...roles: rolesType) => SetMetadata('roles', roles);
|
||||
7
src/decorator/user.decorator.ts
Normal file
7
src/decorator/user.decorator.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const User = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) =>
|
||||
GqlExecutionContext.create(ctx).getContext().req.user,
|
||||
);
|
||||
15
src/dto/auth/auth.jwt.model.ts
Normal file
15
src/dto/auth/auth.jwt.model.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('AuthToken')
|
||||
export class AuthJwtModel {
|
||||
@Field()
|
||||
readonly accessToken: string
|
||||
|
||||
@Field()
|
||||
readonly refreshToken: string
|
||||
|
||||
constructor(partial: Partial<AuthJwtModel>) {
|
||||
this.accessToken = partial.accessToken
|
||||
this.refreshToken = partial.refreshToken
|
||||
}
|
||||
}
|
||||
20
src/dto/form/abstract.notification.model.ts
Normal file
20
src/dto/form/abstract.notification.model.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Field, InterfaceType } from '@nestjs/graphql';
|
||||
import { Notifications } from '../../schema/form.schema';
|
||||
|
||||
@InterfaceType('Notification')
|
||||
export class AbstractNotificationModel {
|
||||
@Field({ nullable: true })
|
||||
readonly subject?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly htmlTemplate?: string
|
||||
|
||||
@Field()
|
||||
readonly enabled: boolean
|
||||
|
||||
constructor(partial: Partial<Notifications>) {
|
||||
this.subject = partial.subject
|
||||
this.htmlTemplate = partial.htmlTemplate
|
||||
this.enabled = partial.enabled
|
||||
}
|
||||
}
|
||||
27
src/dto/form/button.model.ts
Normal file
27
src/dto/form/button.model.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('Button')
|
||||
export class ButtonModel {
|
||||
@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
|
||||
|
||||
constructor(button: Partial<ButtonModel>) {
|
||||
this.url = button.url
|
||||
this.action = button.action
|
||||
this.text = button.text
|
||||
this.bgColor = button.bgColor
|
||||
this.color = button.color
|
||||
}
|
||||
}
|
||||
28
src/dto/form/colors.model.ts
Normal file
28
src/dto/form/colors.model.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Colors } from '../../schema/form.schema';
|
||||
|
||||
@ObjectType('Colors')
|
||||
export class ColorsModel {
|
||||
@Field()
|
||||
readonly backgroundColor: string
|
||||
|
||||
@Field()
|
||||
readonly questionColor: string
|
||||
|
||||
@Field()
|
||||
readonly answerColor: string
|
||||
|
||||
@Field()
|
||||
readonly buttonColor: string
|
||||
|
||||
@Field()
|
||||
readonly buttonTextColor: string
|
||||
|
||||
constructor(partial: Partial<Colors>) {
|
||||
this.backgroundColor = partial.backgroundColor
|
||||
this.questionColor = partial.questionColor
|
||||
this.answerColor = partial.answerColor
|
||||
this.buttonColor = partial.buttonColor
|
||||
this.buttonTextColor = partial.buttonTextColor
|
||||
}
|
||||
}
|
||||
17
src/dto/form/design.model.ts
Normal file
17
src/dto/form/design.model.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Design } from '../../schema/form.schema';
|
||||
import { ColorsModel } from './colors.model';
|
||||
|
||||
@ObjectType('Design')
|
||||
export class DesignModel {
|
||||
@Field()
|
||||
readonly colors: ColorsModel
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly font?: string
|
||||
|
||||
constructor(partial: Partial<Design>) {
|
||||
this.colors = new ColorsModel(partial.colors)
|
||||
this.font = partial.font
|
||||
}
|
||||
}
|
||||
32
src/dto/form/form.model.ts
Normal file
32
src/dto/form/form.model.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { FormDocument } from '../../schema/form.schema';
|
||||
|
||||
@ObjectType('Form')
|
||||
export class FormModel {
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@Field()
|
||||
readonly title: string
|
||||
|
||||
@Field()
|
||||
readonly created: Date
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly lastModified?: Date
|
||||
|
||||
@Field()
|
||||
readonly language: string
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
29
src/dto/form/form.page.model.ts
Normal file
29
src/dto/form/form.page.model.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { FormPage } from '../../schema/form.schema';
|
||||
import { ButtonModel } from './button.model';
|
||||
|
||||
@ObjectType('FormPage')
|
||||
export class FormPageModel {
|
||||
@Field()
|
||||
readonly show: boolean
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly title?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly paragraph?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly buttonText?: string
|
||||
|
||||
@Field(() => [ButtonModel])
|
||||
readonly buttons: ButtonModel[]
|
||||
|
||||
constructor(page: Partial<FormPage>) {
|
||||
this.show = page.show
|
||||
this.title = page.title
|
||||
this.paragraph = page.paragraph
|
||||
this.buttonText = page.buttonText
|
||||
this.buttons = page.buttons.map(button => new ButtonModel(button))
|
||||
}
|
||||
}
|
||||
21
src/dto/form/respondent.notifications.model.ts
Normal file
21
src/dto/form/respondent.notifications.model.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { RespondentNotifications } from '../../schema/form.schema';
|
||||
import { AbstractNotificationModel } from './abstract.notification.model';
|
||||
|
||||
@ObjectType({
|
||||
implements: [AbstractNotificationModel],
|
||||
})
|
||||
export class RespondentNotificationsModel extends AbstractNotificationModel {
|
||||
@Field({ nullable: true })
|
||||
readonly toField?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly fromEmail?: string
|
||||
|
||||
constructor(partial: Partial<RespondentNotifications>) {
|
||||
super(partial);
|
||||
|
||||
this.toField = partial.toField
|
||||
this.fromEmail = partial.fromEmail
|
||||
}
|
||||
}
|
||||
21
src/dto/form/self.notifications.model.ts
Normal file
21
src/dto/form/self.notifications.model.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { SelfNotifications } from '../../schema/form.schema';
|
||||
import { AbstractNotificationModel } from './abstract.notification.model';
|
||||
|
||||
@ObjectType({
|
||||
implements: [AbstractNotificationModel],
|
||||
})
|
||||
export class SelfNotificationsModel extends AbstractNotificationModel {
|
||||
@Field({ nullable: true })
|
||||
readonly fromField?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
readonly toEmail?: string
|
||||
|
||||
constructor(partial: Partial<SelfNotifications>) {
|
||||
super(partial);
|
||||
|
||||
this.fromField = partial.fromField
|
||||
this.toEmail = partial.toEmail
|
||||
}
|
||||
}
|
||||
11
src/dto/status.model.ts
Normal file
11
src/dto/status.model.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('Version')
|
||||
export class StatusModel {
|
||||
@Field()
|
||||
readonly version: string
|
||||
|
||||
constructor(partial: Partial<StatusModel>) {
|
||||
this.version = partial.version
|
||||
}
|
||||
}
|
||||
8
src/dto/user/own.user.model.ts
Normal file
8
src/dto/user/own.user.model.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { UserModel } from './user.model';
|
||||
|
||||
@ObjectType('OwnUser')
|
||||
export class OwnUserModel extends UserModel {
|
||||
@Field(() => [String])
|
||||
readonly roles: string[]
|
||||
}
|
||||
28
src/dto/user/user.create.input.ts
Normal file
28
src/dto/user/user.create.input.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsEmail, IsNotEmpty, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UserCreateInput {
|
||||
@Field()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
username: string
|
||||
|
||||
@Field()
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string
|
||||
|
||||
@Field()
|
||||
@MinLength(5)
|
||||
password: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
firstName?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
lastName?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
language?: string
|
||||
}
|
||||
33
src/dto/user/user.model.ts
Normal file
33
src/dto/user/user.model.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { UserDocument } from '../../schema/user.schema';
|
||||
|
||||
@ObjectType('User')
|
||||
export class UserModel {
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@Field()
|
||||
readonly username: string
|
||||
|
||||
@Field()
|
||||
readonly email: string
|
||||
|
||||
@Field()
|
||||
readonly language: string
|
||||
|
||||
@Field()
|
||||
readonly firstName?: string
|
||||
|
||||
@Field()
|
||||
readonly lastName?: string
|
||||
|
||||
constructor(user: Partial<UserDocument>) {
|
||||
this.id = user.id
|
||||
this.username = user.username
|
||||
this.email = user.email
|
||||
|
||||
this.language = user.language
|
||||
this.firstName = user.firstName
|
||||
this.lastName = user.lastName
|
||||
}
|
||||
}
|
||||
23
src/guard/gql.auth.guard.ts
Normal file
23
src/guard/gql.auth.guard.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@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) =>
|
||||
}
|
||||
}
|
||||
return ctx.getContext().req;
|
||||
}
|
||||
|
||||
handleRequest(err, user) {
|
||||
if (err) {
|
||||
throw new Error('invalid token')
|
||||
}
|
||||
return user
|
||||
}
|
||||
}
|
||||
16
src/guard/index.ts
Normal file
16
src/guard/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { GqlAuthGuard } from './gql.auth.guard';
|
||||
import { LocalAuthGuard } from './local.auth.guard';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
export const guards = [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: GqlAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
LocalAuthGuard,
|
||||
]
|
||||
10
src/guard/local.auth.guard.ts
Normal file
10
src/guard/local.auth.guard.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {
|
||||
getRequest(context: ExecutionContext) {
|
||||
return GqlExecutionContext.create(context).getContext().req
|
||||
}
|
||||
}
|
||||
27
src/guard/roles.guard.ts
Normal file
27
src/guard/roles.guard.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
|
||||
const roles = this.reflector.get<string[]>('roles', ctx.getHandler());
|
||||
if (!roles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userRoles = ctx.getContext().req.user ? ctx.getContext().req.user.roles : []
|
||||
|
||||
for (const role of roles) {
|
||||
if (!userRoles.includes(role)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
src/main.ts
Normal file
23
src/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NestApplicationOptions, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cors from 'cors';
|
||||
import { Logger, PinoLogger } from 'nestjs-pino/dist';
|
||||
import { LoggerConfig } from './app.imports';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
(async () => {
|
||||
const options: NestApplicationOptions = {
|
||||
logger: new Logger(new PinoLogger(LoggerConfig), {}),
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(AppModule, options)
|
||||
app.useLogger(app.get(Logger))
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
disableErrorMessages: false,
|
||||
transform: true,
|
||||
}))
|
||||
app.enableCors({origin: '*'})
|
||||
app.getHttpAdapter().options('*', cors())
|
||||
|
||||
await app.listen(process.env.PORT || 3000);
|
||||
})()
|
||||
26
src/resolver/auth/auth.login.resolver.ts
Normal file
26
src/resolver/auth/auth.login.resolver.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
|
||||
import { AuthService } from '../../service/auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthLoginResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService
|
||||
) {
|
||||
}
|
||||
|
||||
@Mutation(() => AuthJwtModel)
|
||||
async authLogin(
|
||||
@Args({ name: 'username', type: () => String }) username,
|
||||
@Args({ name: 'password', type: () => String }) password,
|
||||
): Promise<AuthJwtModel> {
|
||||
const user = await this.auth.validateUser(username, password)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('invalid user / password')
|
||||
}
|
||||
|
||||
return this.auth.login(user)
|
||||
}
|
||||
}
|
||||
24
src/resolver/auth/auth.register.resolver.ts
Normal file
24
src/resolver/auth/auth.register.resolver.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
|
||||
import { UserCreateInput } from '../../dto/user/user.create.input';
|
||||
import { AuthService } from '../../service/auth/auth.service';
|
||||
import { UserCreateService } from '../../service/user/user.create.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthRegisterResolver {
|
||||
constructor(
|
||||
private readonly createUser: UserCreateService,
|
||||
private readonly auth: AuthService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Mutation(() => AuthJwtModel)
|
||||
async authRegister(
|
||||
@Args({ name: 'user' }) data: UserCreateInput,
|
||||
): Promise<AuthJwtModel> {
|
||||
const user = await this.createUser.create(data)
|
||||
|
||||
return this.auth.login(user)
|
||||
}
|
||||
}
|
||||
7
src/resolver/auth/index.ts
Normal file
7
src/resolver/auth/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { AuthLoginResolver } from './auth.login.resolver';
|
||||
import { AuthRegisterResolver } from './auth.register.resolver';
|
||||
|
||||
export const authServices = [
|
||||
AuthRegisterResolver,
|
||||
AuthLoginResolver,
|
||||
]
|
||||
28
src/resolver/context.cache.ts
Normal file
28
src/resolver/context.cache.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { FormDocument } from '../schema/form.schema';
|
||||
import { UserDocument } from '../schema/user.schema';
|
||||
|
||||
export class ContextCache {
|
||||
private users: {
|
||||
[id: string]: UserDocument,
|
||||
} = {}
|
||||
|
||||
private forms: {
|
||||
[id: string]: FormDocument,
|
||||
} = {}
|
||||
|
||||
public addUser(user: UserDocument) {
|
||||
this.users[user.id] = user;
|
||||
}
|
||||
|
||||
public async getUser(id: any): Promise<UserDocument> {
|
||||
return this.users[id]
|
||||
}
|
||||
|
||||
public addForm(form: FormDocument) {
|
||||
this.forms[form.id] = form
|
||||
}
|
||||
|
||||
public async getForm(id: any): Promise<FormDocument> {
|
||||
return this.forms[id]
|
||||
}
|
||||
}
|
||||
132
src/resolver/form/form.resolver.ts
Normal file
132
src/resolver/form/form.resolver.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { Args, Context, ID, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Roles } from '../../decorator/roles.decorator';
|
||||
import { User } from '../../decorator/user.decorator';
|
||||
import { DesignModel } from '../../dto/form/design.model';
|
||||
import { FormModel } from '../../dto/form/form.model';
|
||||
import { FormPageModel } from '../../dto/form/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';
|
||||
import { UserDocument } from '../../schema/user.schema';
|
||||
import { FormService } from '../../service/form/form.service';
|
||||
import { ContextCache } from '../context.cache';
|
||||
|
||||
@Resolver(() => FormModel)
|
||||
export class FormResolver {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => FormModel)
|
||||
async getFormById(
|
||||
@User() user: UserDocument,
|
||||
@Args('id', {type: () => ID}) id,
|
||||
@Context('cache') cache: ContextCache,
|
||||
) {
|
||||
const form = await this.formService.findById(id)
|
||||
|
||||
if (!form.isLive && !await this.formService.isAdmin(form, user)) {
|
||||
throw new Error('invalid form')
|
||||
}
|
||||
|
||||
cache.addForm(form)
|
||||
|
||||
return new FormModel(form)
|
||||
}
|
||||
|
||||
@ResolveField('isLive', () => Boolean)
|
||||
@Roles('admin')
|
||||
async getRoles(
|
||||
@User() user: UserDocument,
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<boolean> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
if (!await this.formService.isAdmin(form, user)) {
|
||||
throw new Error('no access to field')
|
||||
}
|
||||
|
||||
return form.isLive
|
||||
}
|
||||
|
||||
@ResolveField('selfNotifications', () => SelfNotificationsModel)
|
||||
@Roles('admin')
|
||||
async getSelfNotifications(
|
||||
@User() user: UserDocument,
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SelfNotificationsModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
if (!await this.formService.isAdmin(form, user)) {
|
||||
throw new Error('no access to field')
|
||||
}
|
||||
|
||||
return new SelfNotificationsModel(form.selfNotifications)
|
||||
}
|
||||
|
||||
@ResolveField('respondentNotifications', () => SelfNotificationsModel)
|
||||
@Roles('admin')
|
||||
async getRespondentNotifications(
|
||||
@User() user: UserDocument,
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SelfNotificationsModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
if (!await this.formService.isAdmin(form, user)) {
|
||||
throw new Error('no access to field')
|
||||
}
|
||||
|
||||
return new RespondentNotificationsModel(form.respondentNotifications)
|
||||
}
|
||||
|
||||
@ResolveField('design', () => DesignModel)
|
||||
async getDesign(
|
||||
@User() user: UserDocument,
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<DesignModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
return new DesignModel(form.design)
|
||||
}
|
||||
|
||||
@ResolveField('startPage', () => FormPageModel)
|
||||
async getStartPage(
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormPageModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
return new FormPageModel(form.startPage)
|
||||
}
|
||||
|
||||
@ResolveField('endPage', () => FormPageModel)
|
||||
async getEndPage(
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormPageModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
return new FormPageModel(form.endPage)
|
||||
}
|
||||
|
||||
@ResolveField('admin', () => UserModel)
|
||||
@Roles('superuser')
|
||||
async getAdmin(
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<UserModel> {
|
||||
const form = await cache.getForm(parent.id)
|
||||
|
||||
if (!form.populated('admin')) {
|
||||
form.populate('admin')
|
||||
await form.execPopulate()
|
||||
}
|
||||
|
||||
return new UserModel(form.admin)
|
||||
}
|
||||
}
|
||||
9
src/resolver/form/index.ts
Normal file
9
src/resolver/form/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { FieldResolver } from './field.resolver';
|
||||
import { FormCreateResolver } from './form.create.resolver';
|
||||
import { FormResolver } from './form.resolver';
|
||||
|
||||
export const formResolvers = [
|
||||
FormResolver,
|
||||
FormCreateResolver,
|
||||
FieldResolver,
|
||||
]
|
||||
13
src/resolver/index.ts
Normal file
13
src/resolver/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { authServices } from './auth';
|
||||
import { formResolvers } from './form';
|
||||
import { myResolvers } from './me';
|
||||
import { StatusResolver } from './status.resolver';
|
||||
import { userResolvers } from './user';
|
||||
|
||||
export const resolvers = [
|
||||
StatusResolver,
|
||||
...userResolvers,
|
||||
...authServices,
|
||||
...myResolvers,
|
||||
...formResolvers,
|
||||
]
|
||||
5
src/resolver/me/index.ts
Normal file
5
src/resolver/me/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ProfileResolver } from './profile.resolver';
|
||||
|
||||
export const myResolvers = [
|
||||
ProfileResolver,
|
||||
]
|
||||
19
src/resolver/me/profile.resolver.ts
Normal file
19
src/resolver/me/profile.resolver.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Context, Query } from '@nestjs/graphql';
|
||||
import { Roles } from '../../decorator/roles.decorator';
|
||||
import { User } from '../../decorator/user.decorator';
|
||||
import { OwnUserModel } from '../../dto/user/own.user.model';
|
||||
import { UserDocument } from '../../schema/user.schema';
|
||||
import { ContextCache } from '../context.cache';
|
||||
|
||||
export class ProfileResolver {
|
||||
@Query(() => OwnUserModel)
|
||||
@Roles('user')
|
||||
async me(
|
||||
@User() user: UserDocument,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<OwnUserModel> {
|
||||
cache.addUser(user)
|
||||
|
||||
return new OwnUserModel(user)
|
||||
}
|
||||
}
|
||||
12
src/resolver/status.resolver.ts
Normal file
12
src/resolver/status.resolver.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { StatusModel } from '../dto/status.model';
|
||||
|
||||
@Resolver(() => StatusModel)
|
||||
export class StatusResolver {
|
||||
@Query(() => StatusModel)
|
||||
async status(): Promise<StatusModel> {
|
||||
return new StatusModel({
|
||||
version: process.env.npm_package_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
5
src/resolver/user/index.ts
Normal file
5
src/resolver/user/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserResolver } from './user.resolver';
|
||||
|
||||
export const userResolvers = [
|
||||
UserResolver,
|
||||
]
|
||||
36
src/resolver/user/user.resolver.ts
Normal file
36
src/resolver/user/user.resolver.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Args, Context, GraphQLExecutionContext, ID, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { rolesType } from '../../config/roles';
|
||||
import { Roles } from '../../decorator/roles.decorator';
|
||||
import { UserModel } from '../../dto/user/user.model';
|
||||
import { UserService } from '../../service/user/user.service';
|
||||
import { ContextCache } from '../context.cache';
|
||||
|
||||
@Resolver(() => UserModel)
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => UserModel)
|
||||
@Roles('admin')
|
||||
async getUserById(
|
||||
@Args('id', {type: () => ID}) id,
|
||||
@Context('cache') cache: ContextCache,
|
||||
) {
|
||||
const user = await this.userService.findById(id)
|
||||
|
||||
cache.addUser(user)
|
||||
|
||||
return new UserModel(user)
|
||||
}
|
||||
|
||||
@ResolveField('roles', () => [String])
|
||||
@Roles('superuser')
|
||||
async getRoles(
|
||||
@Parent() user: UserModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<string[]> {
|
||||
return (await cache.getUser(user.id)).roles
|
||||
}
|
||||
}
|
||||
33
src/schema/button.schema.ts
Normal file
33
src/schema/button.schema.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Schema, Document } from 'mongoose';
|
||||
import { matchType } from '../config/fields';
|
||||
|
||||
export interface ButtonDocument extends Document{
|
||||
readonly url?: string
|
||||
readonly action?: string
|
||||
readonly text?: string
|
||||
readonly bgColor?: string
|
||||
readonly color?: string
|
||||
}
|
||||
|
||||
export const ButtonSchema = new Schema({
|
||||
url: {
|
||||
type: String,
|
||||
match: matchType.url,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
},
|
||||
bgColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#5bc0de',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#ffffff'
|
||||
},
|
||||
})
|
||||
17
src/schema/embedded/field.option.ts
Normal file
17
src/schema/embedded/field.option.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { SchemaDefinition } from 'mongoose';
|
||||
|
||||
export const FieldOption: SchemaDefinition = {
|
||||
id: {
|
||||
alias: 'option_id',
|
||||
type: Number,
|
||||
},
|
||||
title: {
|
||||
alias: 'option_title',
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
alias: 'option_value',
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
}
|
||||
37
src/schema/embedded/logic.jump.ts
Normal file
37
src/schema/embedded/logic.jump.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Schema, SchemaDefinition } from 'mongoose';
|
||||
import { FieldSchemaName } from '../field.schema';
|
||||
|
||||
export const LogicJump: SchemaDefinition = {
|
||||
expressionString: {
|
||||
type: String,
|
||||
enum: [
|
||||
'field == static',
|
||||
'field != static',
|
||||
'field > static',
|
||||
'field >= static',
|
||||
'field <= static',
|
||||
'field < static',
|
||||
'field contains static',
|
||||
'field !contains static',
|
||||
'field begins static',
|
||||
'field !begins static',
|
||||
'field ends static',
|
||||
'field !ends static',
|
||||
],
|
||||
},
|
||||
fieldA: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: FieldSchemaName
|
||||
},
|
||||
valueB: {
|
||||
type: String,
|
||||
},
|
||||
jumpTo: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: FieldSchemaName
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}
|
||||
32
src/schema/embedded/rating.field.ts
Normal file
32
src/schema/embedded/rating.field.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { SchemaDefinition } from 'mongoose';
|
||||
|
||||
export const RatingField: SchemaDefinition = {
|
||||
steps: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
shape: {
|
||||
type: String,
|
||||
enum: [
|
||||
'Heart',
|
||||
'Star',
|
||||
'thumbs-up',
|
||||
'thumbs-down',
|
||||
'Circle',
|
||||
'Square',
|
||||
'Check Circle',
|
||||
'Smile Outlined',
|
||||
'Hourglass',
|
||||
'bell',
|
||||
'Paper Plane',
|
||||
'Comment',
|
||||
'Trash',
|
||||
],
|
||||
},
|
||||
validShapes: {
|
||||
type: [{
|
||||
type: String,
|
||||
}]
|
||||
}
|
||||
}
|
||||
58
src/schema/field.schema.ts
Normal file
58
src/schema/field.schema.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Document, Schema } from 'mongoose';
|
||||
import { fieldTypes } from '../config/fields';
|
||||
import { FieldOption } from './embedded/field.option';
|
||||
import { LogicJump } from './embedded/logic.jump';
|
||||
import { RatingField } from './embedded/rating.field';
|
||||
|
||||
export const FieldSchemaName = 'FormField'
|
||||
|
||||
export interface FieldDocument extends Document {
|
||||
isSubmission: boolean
|
||||
}
|
||||
|
||||
export const FieldSchema = new Schema({
|
||||
isSubmission: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
logicJump: {
|
||||
type: LogicJump,
|
||||
},
|
||||
ratingOptions: {
|
||||
type: RatingField,
|
||||
},
|
||||
fieldOptions: {
|
||||
type: [FieldOption],
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deletePreserved: { // TODO remove
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
validFieldTypes: { // TODO remove
|
||||
type: [String],
|
||||
},
|
||||
fieldType: {
|
||||
type: String,
|
||||
enum: fieldTypes,
|
||||
},
|
||||
fieldValue: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
249
src/schema/form.schema.ts
Normal file
249
src/schema/form.schema.ts
Normal file
@ -0,0 +1,249 @@
|
||||
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 { UserDocument, UserSchemaName } from './user.schema';
|
||||
|
||||
export const FormSchemaName = 'Form'
|
||||
|
||||
export interface FormPage {
|
||||
readonly show: boolean
|
||||
readonly title?: string
|
||||
readonly paragraph?: string
|
||||
readonly buttonText?: string
|
||||
readonly buttons: [ButtonDocument]
|
||||
}
|
||||
|
||||
export interface Notifications {
|
||||
readonly subject?: string
|
||||
readonly htmlTemplate?: string
|
||||
readonly enabled: boolean
|
||||
}
|
||||
|
||||
export interface SelfNotifications extends Notifications{
|
||||
readonly fromField?: string
|
||||
readonly toEmail?: string
|
||||
}
|
||||
|
||||
export interface RespondentNotifications extends Notifications{
|
||||
readonly toField?: string
|
||||
readonly fromEmail?: string
|
||||
}
|
||||
|
||||
export interface Colors {
|
||||
readonly backgroundColor: string
|
||||
readonly questionColor: string
|
||||
readonly answerColor: string
|
||||
readonly buttonColor: string
|
||||
readonly buttonTextColor: string
|
||||
}
|
||||
|
||||
export interface Design {
|
||||
readonly colors: Colors
|
||||
|
||||
readonly font?: string
|
||||
}
|
||||
|
||||
export interface FormDocument extends Document {
|
||||
readonly title: string
|
||||
readonly language: string
|
||||
|
||||
readonly analytics: {
|
||||
readonly gaCode?: string
|
||||
// TODO extract to separate documents!
|
||||
readonly visitors: [VisitorDataDocument]
|
||||
}
|
||||
|
||||
readonly fields: [FieldDocument]
|
||||
|
||||
readonly admin: UserDocument
|
||||
|
||||
readonly startPage: FormPage;
|
||||
readonly endPage: FormPage;
|
||||
|
||||
readonly selfNotifications: SelfNotifications;
|
||||
readonly respondentNotifications: RespondentNotifications;
|
||||
|
||||
readonly showFooter: boolean;
|
||||
readonly isLive: boolean;
|
||||
readonly design: Design;
|
||||
|
||||
|
||||
readonly created: Date
|
||||
readonly lastModified: Date
|
||||
}
|
||||
|
||||
export const FormSchema = new Schema({
|
||||
title: {
|
||||
trim: true,
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
created: {
|
||||
type: Date,
|
||||
},
|
||||
lastModified: {
|
||||
type: Date,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
enum: languages,
|
||||
default: defaultLanguage,
|
||||
required: true,
|
||||
},
|
||||
analytics: {
|
||||
gaCode: {
|
||||
type: String,
|
||||
},
|
||||
visitors: {
|
||||
type: [VisitorDataSchema],
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
alias: 'form_fields',
|
||||
type: [FieldSchema],
|
||||
default: [],
|
||||
},
|
||||
admin: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: UserSchemaName,
|
||||
},
|
||||
startPage: {
|
||||
show: {
|
||||
alias: 'showStart',
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
alias: 'introTitle',
|
||||
type: String,
|
||||
default: 'Welcome to Form',
|
||||
},
|
||||
paragraph: {
|
||||
alias: 'introParagraph',
|
||||
type: String,
|
||||
default: 'Start',
|
||||
},
|
||||
buttons: {
|
||||
type: [ButtonSchema],
|
||||
},
|
||||
},
|
||||
endPage: {
|
||||
show: {
|
||||
alias: 'showEnd',
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Thank you for filling out the form',
|
||||
},
|
||||
paragraph: {
|
||||
type: String,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: 'Go back to Form'
|
||||
},
|
||||
buttons: {
|
||||
type: [ButtonSchema],
|
||||
},
|
||||
},
|
||||
selfNotifications: {
|
||||
fromField: {
|
||||
type: String,
|
||||
},
|
||||
toEmail: {
|
||||
alias: 'toEmails',
|
||||
type: String,
|
||||
},
|
||||
subject: {
|
||||
type: String,
|
||||
},
|
||||
htmlTemplate: {
|
||||
type: String,
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
respondentNotifications: {
|
||||
toField: {
|
||||
type: String,
|
||||
},
|
||||
fromEmail: {
|
||||
alias: 'fromEmails',
|
||||
type: String,
|
||||
match: matchType.email,
|
||||
},
|
||||
subject: {
|
||||
type: String,
|
||||
default: 'OhMyForm: Thank you for filling out this OhMyForm',
|
||||
},
|
||||
htmlTemplate: {
|
||||
type: String,
|
||||
default: 'Hello, <br><br> We’ve received your submission. <br><br> Thank you & have a nice day!',
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isLive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
design: {
|
||||
colors: {
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#fff'
|
||||
},
|
||||
questionColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#333'
|
||||
},
|
||||
answerColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#333'
|
||||
},
|
||||
buttonColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#fff'
|
||||
},
|
||||
buttonTextColor: {
|
||||
type: String,
|
||||
match: matchType.color,
|
||||
default: '#333'
|
||||
},
|
||||
},
|
||||
|
||||
font: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
timestamps: {
|
||||
createdAt: 'created',
|
||||
updatedAt: 'lastModified',
|
||||
}
|
||||
})
|
||||
|
||||
export const FormDefinition = {
|
||||
name: FormSchemaName,
|
||||
schema: FormSchema,
|
||||
}
|
||||
|
||||
57
src/schema/form.submission.schema.ts
Normal file
57
src/schema/form.submission.schema.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Document, Schema } from 'mongoose';
|
||||
import { FieldSchema } from './field.schema';
|
||||
import { FormSchemaName } from './form.schema';
|
||||
|
||||
export const FormSubmissionSchemaName = 'FormSubmission'
|
||||
|
||||
export interface FormSubmissionDocument extends Document {
|
||||
}
|
||||
|
||||
export const FormSubmissionSchema = new Schema({
|
||||
fields: {
|
||||
alias: 'form_fields',
|
||||
type: [FieldSchema],
|
||||
default: [],
|
||||
},
|
||||
form: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: FormSchemaName,
|
||||
required: true
|
||||
},
|
||||
ipAddr: {
|
||||
type: String
|
||||
},
|
||||
geoLocation: {
|
||||
Country: {
|
||||
type: String
|
||||
},
|
||||
City: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
device: {
|
||||
type: {
|
||||
type: String
|
||||
},
|
||||
name: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
timeElapsed: {
|
||||
type: Number
|
||||
},
|
||||
percentageComplete: {
|
||||
type: Number
|
||||
},
|
||||
}, {
|
||||
timestamps: {
|
||||
createdAt: 'created',
|
||||
updatedAt: 'lastModified',
|
||||
}
|
||||
})
|
||||
|
||||
export const FormSubmissionDefinition = {
|
||||
name: FormSubmissionSchemaName,
|
||||
schema: FormSubmissionSchema,
|
||||
}
|
||||
|
||||
9
src/schema/index.ts
Normal file
9
src/schema/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { FormDefinition } from './form.schema';
|
||||
import { FormSubmissionDefinition, FormSubmissionSchema } from './form.submission.schema';
|
||||
import { UserDefinition } from './user.schema';
|
||||
|
||||
export const schema = [
|
||||
FormDefinition,
|
||||
FormSubmissionDefinition,
|
||||
UserDefinition,
|
||||
]
|
||||
99
src/schema/user.schema.ts
Normal file
99
src/schema/user.schema.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Document, Schema } from 'mongoose';
|
||||
import { defaultLanguage, languages } from '../config/languages';
|
||||
import { roles, rolesType } from '../config/roles';
|
||||
|
||||
export const UserSchemaName = 'User'
|
||||
|
||||
export interface UserDocument extends Document {
|
||||
readonly firstName?: string
|
||||
readonly lastName?: string
|
||||
readonly email: string
|
||||
readonly username: string
|
||||
readonly passwordHash: string
|
||||
readonly salt: string
|
||||
readonly provider: string
|
||||
readonly roles: rolesType
|
||||
readonly language: string
|
||||
readonly resetPasswordToken?: string
|
||||
readonly resetPasswordExpires?: Date
|
||||
readonly token?: string
|
||||
readonly apiKey?: string
|
||||
readonly created: Date
|
||||
readonly lastModified: Date
|
||||
}
|
||||
|
||||
export const UserSchema = new Schema({
|
||||
firstName: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: '',
|
||||
},
|
||||
lastName: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: '',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
unique: true,
|
||||
match: /^(([^<>()\[\]\\.,;:\s@']+(\.[^<>()\[\]\\.,;:\s@']+)*)|('.+'))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||
required: true,
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
match: /^[a-zA-Z0-9\-]+$/,
|
||||
required: true,
|
||||
},
|
||||
passwordHash: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
salt: {
|
||||
type: String,
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
default: 'local'
|
||||
},
|
||||
roles: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: roles,
|
||||
}],
|
||||
default: ['user'],
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
enum: languages,
|
||||
default: defaultLanguage,
|
||||
},
|
||||
resetPasswordToken: {
|
||||
type: String,
|
||||
},
|
||||
resetPasswordExpires: {
|
||||
type: Date,
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
},
|
||||
apiKey: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true,
|
||||
sparse: true
|
||||
},
|
||||
}, {
|
||||
timestamps: {
|
||||
createdAt: 'created',
|
||||
updatedAt: 'lastModified',
|
||||
}
|
||||
})
|
||||
|
||||
export const UserDefinition = {
|
||||
name: UserSchemaName,
|
||||
schema: UserSchema,
|
||||
}
|
||||
52
src/schema/visitor.data.schema.ts
Normal file
52
src/schema/visitor.data.schema.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Document, Schema } from 'mongoose';
|
||||
import { defaultLanguage, languages } from '../config/languages';
|
||||
import { FieldDocument, FieldSchemaName } from './field.schema';
|
||||
|
||||
export interface VisitorDataDocument extends Document {
|
||||
readonly introParagraph?: string
|
||||
readonly referrer?: string
|
||||
readonly filledOutFields: [FieldDocument]
|
||||
readonly timeElapsed: number
|
||||
readonly isSubmitted: boolean
|
||||
readonly language: string
|
||||
readonly ipAddr: string
|
||||
readonly deviceType: string
|
||||
readonly userAgent: string
|
||||
}
|
||||
|
||||
export const VisitorDataSchema = new Schema({
|
||||
introParagraph: {
|
||||
type: String,
|
||||
},
|
||||
referrer: {
|
||||
type: String,
|
||||
},
|
||||
filledOutFields: {
|
||||
type: [{
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: FieldSchemaName,
|
||||
}],
|
||||
},
|
||||
timeElapsed: {
|
||||
type: Number,
|
||||
},
|
||||
isSubmitted: {
|
||||
type: Boolean,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
enum: languages,
|
||||
default: defaultLanguage,
|
||||
},
|
||||
ipAddr: {
|
||||
type: String,
|
||||
},
|
||||
deviceType: {
|
||||
type: String,
|
||||
enum: ['desktop', 'phone', 'tablet', 'other'],
|
||||
default: 'other',
|
||||
},
|
||||
userAgent: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
40
src/service/auth/auth.service.ts
Normal file
40
src/service/auth/auth.service.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
|
||||
import { UserDocument } from '../../schema/user.schema';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private passwordService: PasswordService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, password: string): Promise<UserDocument> {
|
||||
console.log('check user??', username)
|
||||
|
||||
const user = await this.userService.findByUsername(username);
|
||||
if (user && await this.passwordService.verify(password, user.passwordHash, user.salt)) {
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: UserDocument): Promise<AuthJwtModel> {
|
||||
return new AuthJwtModel({
|
||||
accessToken: this.jwtService.sign({
|
||||
username: user.username,
|
||||
roles: user.roles,
|
||||
sub: user.id,
|
||||
}),
|
||||
refreshToken: this.jwtService.sign({
|
||||
sub: user.id,
|
||||
}, {
|
||||
expiresIn: '30d',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/service/auth/index.ts
Normal file
11
src/service/auth/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
export const authServices = [
|
||||
AuthService,
|
||||
LocalStrategy,
|
||||
PasswordService,
|
||||
JwtStrategy,
|
||||
]
|
||||
30
src/service/auth/jwt.strategy.ts
Normal file
30
src/service/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { UserDocument } from '../../schema/user.schema';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('AUTH_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any): Promise<UserDocument> {
|
||||
try {
|
||||
return await this.userService.findById(payload.sub)
|
||||
} catch (e) {
|
||||
// log error
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
19
src/service/auth/local.strategy.ts
Normal file
19
src/service/auth/local.strategy.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<any> {
|
||||
const user = await this.authService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
29
src/service/auth/password.service.ts
Normal file
29
src/service/auth/password.service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
async verify (password: string, hash: string, salt?: string): Promise<boolean> {
|
||||
if (hash[0] === '$') {
|
||||
return await bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
//Generate salt if it doesn't exist yet
|
||||
if(!salt){
|
||||
salt = crypto.randomBytes(64).toString('base64');
|
||||
}
|
||||
|
||||
return hash === crypto.pbkdf2Sync(
|
||||
password,
|
||||
new Buffer(salt, 'base64'),
|
||||
10000,
|
||||
128,
|
||||
'SHA1'
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
hash (password): Promise<string> {
|
||||
return bcrypt.hash(password, 4)
|
||||
}
|
||||
}
|
||||
13
src/service/form/form.create.service.ts
Normal file
13
src/service/form/form.create.service.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from "mongoose";
|
||||
import { FormDocument, FormSchemaName } from '../../schema/form.schema';
|
||||
|
||||
@Injectable()
|
||||
export class FormCreateService {
|
||||
constructor(
|
||||
@InjectModel(FormSchemaName) private formModel: Model<FormDocument>,
|
||||
) {
|
||||
}
|
||||
|
||||
}
|
||||
32
src/service/form/form.service.ts
Normal file
32
src/service/form/form.service.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class FormService {
|
||||
constructor(
|
||||
@InjectModel(FormSchemaName) private formModel: Model<FormDocument>,
|
||||
) {
|
||||
}
|
||||
|
||||
async isAdmin(form: FormDocument, user: UserDocument): Promise<boolean> {
|
||||
if (user.roles.includes('superuser')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Types.ObjectId(form.admin.id).equals(Types.ObjectId(user.id))
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<FormDocument> {
|
||||
const form = await this.formModel.findById(id);
|
||||
|
||||
if (!form) {
|
||||
throw new Error('no form found')
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
}
|
||||
7
src/service/form/index.ts
Normal file
7
src/service/form/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { FormCreateService } from './form.create.service';
|
||||
import { FormService } from './form.service';
|
||||
|
||||
export const formServices = [
|
||||
FormService,
|
||||
FormCreateService,
|
||||
]
|
||||
11
src/service/index.ts
Normal file
11
src/service/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { authServices } from './auth';
|
||||
import { formServices } from './form';
|
||||
import { MailService } from './mail.service';
|
||||
import { userServices } from './user';
|
||||
|
||||
export const services = [
|
||||
...userServices,
|
||||
...formServices,
|
||||
...authServices,
|
||||
MailService,
|
||||
]
|
||||
65
src/service/mail.service.ts
Normal file
65
src/service/mail.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import fs from 'fs';
|
||||
import handlebars from 'handlebars';
|
||||
import { PinoLogger } from 'nestjs-pino/dist';
|
||||
import { join } from 'path';
|
||||
import { defaultLanguage } from '../config/languages';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly nestMailer: MailerService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
logger.setContext(this.constructor.name)
|
||||
}
|
||||
|
||||
async send(to: string, template: string, context: { [key: string]: any }, language: string = defaultLanguage): Promise<boolean> {
|
||||
this.logger.info({
|
||||
email: to,
|
||||
}, `send email ${template}`)
|
||||
try {
|
||||
const path = this.getTemplatePath(template, language)
|
||||
|
||||
const process = (file: string) => {
|
||||
const content = fs.readFileSync(join(path, file))
|
||||
const template = handlebars.compile(content.toString('UTF-8'))
|
||||
|
||||
return template(context)
|
||||
}
|
||||
|
||||
await this.nestMailer.sendMail({
|
||||
to,
|
||||
subject: process('subject.txt'),
|
||||
html: process('body.html'),
|
||||
text: process('body.txt'),
|
||||
})
|
||||
this.logger.info('sent email')
|
||||
} catch (error) {
|
||||
this.logger.error({
|
||||
error: error.message,
|
||||
email: to,
|
||||
}, `failed to send email ${template}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private getTemplatePath(template: string, language: string): string {
|
||||
let templatePath = join(this.configService.get<string>('LOCALES_PATH'), language, 'mail', template)
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
templatePath = join(this.configService.get<string>('LOCALES_PATH'), 'en', 'mail', template)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
throw new Error('invalid template')
|
||||
}
|
||||
|
||||
return templatePath
|
||||
}
|
||||
}
|
||||
7
src/service/user/index.ts
Normal file
7
src/service/user/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { UserCreateService } from './user.create.service';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
export const userServices = [
|
||||
UserCreateService,
|
||||
UserService,
|
||||
]
|
||||
47
src/service/user/user.create.service.ts
Normal file
47
src/service/user/user.create.service.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { PinoLogger } from 'nestjs-pino/dist';
|
||||
import { UserCreateInput } from '../../dto/user/user.create.input';
|
||||
import { UserDocument, UserSchemaName } from '../../schema/user.schema';
|
||||
import { PasswordService } from '../auth/password.service';
|
||||
import { MailService } from '../mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserCreateService {
|
||||
constructor(
|
||||
@InjectModel(UserSchemaName) private userModel: Model<UserDocument>,
|
||||
private readonly mailerService: MailService,
|
||||
private readonly logger: PinoLogger,
|
||||
private readonly passwordService: PasswordService,
|
||||
) {}
|
||||
|
||||
async create(user: UserCreateInput): Promise<UserDocument> {
|
||||
// TODO check for uniqueness of email & username!
|
||||
|
||||
const entry = new this.userModel({
|
||||
...user,
|
||||
passwordHash: await this.passwordService.hash(user.password),
|
||||
})
|
||||
|
||||
await entry.save({
|
||||
validateBeforeSave: true,
|
||||
})
|
||||
|
||||
const sent = await this.mailerService.send(
|
||||
entry.email,
|
||||
'user/created',
|
||||
{
|
||||
username: entry.username,
|
||||
confirm: 'https://www.google.com', // TODO confirm url
|
||||
}
|
||||
)
|
||||
|
||||
// so send email
|
||||
if (!sent) {
|
||||
this.logger.warn('failed to send email for user creation')
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
}
|
||||
35
src/service/user/user.service.ts
Normal file
35
src/service/user/user.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { Model } from 'mongoose';
|
||||
import { UserDocument, UserSchemaName } from '../../schema/user.schema';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(UserSchemaName) private userModel: Model<UserDocument>,
|
||||
) {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserDocument> {
|
||||
const user = await this.userModel.findById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('no user found')
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserDocument> {
|
||||
const user = await this.userModel.findOne({
|
||||
username,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error('no user found')
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user