├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .graphqlrc.js ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── ROADMAP.md ├── client └── nuxt │ ├── generated │ └── graphql-operations.ts │ ├── graphql │ └── hello.query.gql │ ├── nuxt.config.js │ ├── package.json │ ├── pages │ └── index.vue │ ├── plugins │ ├── villus.ts │ └── vue-formulate.ts │ ├── static │ └── favicon.ico │ ├── tailwind.config.js │ ├── tsconfig.json │ └── types │ ├── vue-formulate.d.ts │ └── vue-shim.d.ts ├── docker-entrypoint.sh ├── docs └── schema-workflow.md ├── main.ts ├── nuxt.config.js ├── package.json ├── paths.ts ├── providers ├── config │ ├── index.ts │ └── package.json └── mailer │ ├── index.ts │ └── package.json ├── server ├── backend │ ├── context.ts │ ├── index.ts │ ├── package.json │ └── plugins │ │ ├── auth-jwt.ts │ │ ├── auth-local.ts │ │ ├── mercurius.ts │ │ └── nuxt.ts ├── prisma │ ├── index.ts │ ├── migrations │ │ ├── 20210124235947_initial_user │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── package.json │ ├── schema.prisma │ ├── seed.ts │ └── seeds │ │ ├── admin-user.ts │ │ └── index.ts └── schema │ ├── generated │ ├── nexus-prisma-types.ts │ ├── nexus-types.ts │ ├── schema.graphql │ └── types.ts │ ├── index.ts │ ├── package.json │ ├── rules.ts │ └── types │ ├── hello.ts │ ├── index.ts │ ├── upload.ts │ └── user.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | server/prisma/.env 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "vue-eslint-parser", 3 | parserOptions: { 4 | parser: "@typescript-eslint/parser", 5 | ecmaVersion: 2020, 6 | sourceType: "module", 7 | }, 8 | extends: [ 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier/@typescript-eslint", 11 | "plugin:prettier/recommended", 12 | "plugin:vue/recommended", 13 | "prettier/vue", 14 | ], 15 | rules: { 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/ban-types": "off", 18 | "@typescript-eslint/explicit-module-boundary-types": "off", 19 | "@typescript-eslint/no-empty-interface": "off", 20 | "@typescript-eslint/no-explicit-any": "off", 21 | "@typescript-eslint/no-unused-vars": "off", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .nuxt 3 | dist 4 | node_modules 5 | *.lock 6 | *.log 7 | -------------------------------------------------------------------------------- /.graphqlrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: ["./server/schema/generated/schema.graphql"], 3 | documents: "./client/nuxt/graphql/**/*.gql", 4 | extensions: { 5 | codegen: { 6 | generates: { 7 | "./server/schema/generated/types.ts": { 8 | plugins: ["typescript"], 9 | }, 10 | "./client/nuxt/generated/graphql-operations.ts": { 11 | plugins: ["typescript","typescript-operations","typed-document-node"], 12 | } 13 | }, 14 | hooks: { 15 | afterAllFileWrite: ["yarn lint"], 16 | }, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | tabWidth: 2, 5 | trailingComma: "all", 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.0", 3 | "configurations": [ 4 | { 5 | "type": "node-terminal", 6 | "name": "Development", 7 | "request": "launch", 8 | "command": "yarn dev", 9 | "cwd": "${workspaceFolder}" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | 3 | [0.2.0] 4 | - TypeScript configuration 5 | - ESLint / Prettier configuration 6 | - Monorepo paths resolution 7 | - Type-safe configuration with dotenv / env-var 8 | - Fastify backend 9 | - Winston logger 10 | - Vite / Vue 3 frontend 11 | - Serve frontend static files (production) 12 | - Backend proxy (development) 13 | - Mercurius GraphQL server 14 | - Nexus Schema with hello query 15 | - Vue-Router 16 | - Graceful "Not found" handler 17 | - Villus GraphQL client 18 | - Type-safe GraphQL context 19 | - Subscriptions client forwarder 20 | - Prisma data model 21 | - Nexus Prisma model and CRUD projections 22 | - GraphQL code generation 23 | - Set versions hook 24 | - User in GraphQL context from JWT in authorization header 25 | - Nexus Shield field-level authorization 26 | - TailwindCSS 27 | - Seed admin user 28 | - VSCode development debug launcher 29 | - Schema Workflow documentation 30 | - AuthPayload object type 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### BASE ### 2 | FROM node:14-buster-slim as base 3 | 4 | RUN apt-get update && apt-get install --no-install-recommends --yes openssl 5 | 6 | WORKDIR /app 7 | 8 | # Copy all package.json files 9 | COPY *.json nuxt.config.js yarn.lock ./ 10 | COPY client/nuxt/*.json ./client/nuxt/ 11 | COPY client/nuxt/nuxt.config.js ./client/nuxt/ 12 | COPY providers/mailer/*.json ./providers/mailer/ 13 | COPY server/backend/*.json ./server/backend/ 14 | COPY server/config/*.json ./server/config/ 15 | COPY server/prisma/*.json ./server/prisma/ 16 | COPY server/schema/*.json ./server/schema/ 17 | 18 | ### BUILDER ### 19 | FROM base AS builder 20 | 21 | # Install production dependencies 22 | RUN yarn install --production --pure-lockfile 23 | RUN cp -RL ./node_modules/ /tmp/node_modules/ 24 | 25 | # Install all dependencies 26 | RUN yarn install --pure-lockfile 27 | 28 | # Copy source files 29 | COPY .gitignore *.js *.ts ./ 30 | COPY client/ ./client/ 31 | COPY providers/ ./providers/ 32 | COPY server/ ./server/ 33 | 34 | # Build 35 | RUN yarn build 36 | 37 | ### RUNNER ### 38 | FROM base 39 | 40 | ENV NODE_ENV production 41 | 42 | # Copy runtime dependencies 43 | COPY --from=builder /tmp/node_modules/ ./node_modules/ 44 | COPY --from=builder /app/node_modules/.prisma/client/ ./node_modules/.prisma/client/ 45 | COPY --from=builder /app/server/prisma/ ./server/prisma/ 46 | COPY --from=builder /app/.nuxt/ ./.nuxt/ 47 | COPY --from=builder /app/dist/ ./dist/ 48 | COPY ./docker-entrypoint.sh / 49 | 50 | USER node 51 | 52 | EXPOSE 4000 53 | 54 | ENTRYPOINT ["/docker-entrypoint.sh"] 55 | CMD ["node", "./dist/main.js"] 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fullstack Boilerplate 2 | 3 | Fullstack application boilerplate (Fastify / Mercurius / Nexus / Prisma / Nuxt). 4 | 5 | # Getting started 6 | 7 | Configure the database URL in `packages/prisma/.env` 8 | 9 | ``` 10 | // Postgres 11 | DATABASE_URL=postgres://username:password@localhost:5432/database 12 | 13 | // MySQL 14 | DATABASE_URL=mysql://username:password@localhost:3306/database 15 | ``` 16 | 17 | Install dependencies, deploy database migration and seed initial data 18 | 19 | ``` 20 | yarn install 21 | yarn migrate deploy 22 | yarn seed 23 | ``` 24 | 25 | Prisma Studio can be used to browse the models and data 26 | 27 | ``` 28 | yarn studio 29 | ``` 30 | 31 | When starting in development mode, the Nuxt application is served via an HTTP proxy on `http://localhost:4000` 32 | 33 | ``` 34 | yarn dev 35 | ``` 36 | 37 | When starting in production, the Nuxt application is served by the backend on `http://localhost:4000` 38 | 39 | ``` 40 | yarn build 41 | yarn start 42 | ``` 43 | 44 | # Documentation 45 | 46 | - [Schema Workflow](./docs/schema-workflow.md) 47 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | These are features we would like to implement in no particular order: 4 | 5 | - User roles enum (admin / editor) 6 | - Role-based authorization (hasRole) 7 | - Login / Password authentication 8 | - Passwordless authentication 9 | - Redirect after authentication from query parameter 10 | - External OAuth2 authentication 11 | - Docker image / compose project 12 | - User email verification 13 | - Internationalization 14 | - Background tasks in a separate thread 15 | - SSL support 16 | -------------------------------------------------------------------------------- /client/nuxt/generated/graphql-operations.ts: -------------------------------------------------------------------------------- 1 | import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core"; 2 | export type Maybe = T | null; 3 | export type Exact = { [K in keyof T]: T[K] }; 4 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 5 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 6 | /** All built-in and custom scalars, mapped to their actual values */ 7 | export type Scalars = { 8 | ID: string; 9 | String: string; 10 | Boolean: boolean; 11 | Int: number; 12 | Float: number; 13 | /** The `Upload` scalar type represents a file upload. */ 14 | Upload: any; 15 | }; 16 | 17 | export type EnumUserRoleFieldUpdateOperationsInput = { 18 | set?: Maybe; 19 | }; 20 | 21 | export type Mutation = { 22 | __typename?: "Mutation"; 23 | createOneUser: User; 24 | deleteOneUser?: Maybe; 25 | ping?: Maybe; 26 | updateOneUser?: Maybe; 27 | }; 28 | 29 | export type MutationCreateOneUserArgs = { 30 | data: UserCreateInput; 31 | }; 32 | 33 | export type MutationDeleteOneUserArgs = { 34 | where: UserWhereUniqueInput; 35 | }; 36 | 37 | export type MutationPingArgs = { 38 | message?: Maybe; 39 | }; 40 | 41 | export type MutationUpdateOneUserArgs = { 42 | data: UserUpdateInput; 43 | where: UserWhereUniqueInput; 44 | }; 45 | 46 | export type Query = { 47 | __typename?: "Query"; 48 | hello: Scalars["String"]; 49 | user?: Maybe; 50 | users: Array; 51 | }; 52 | 53 | export type QueryUserArgs = { 54 | where: UserWhereUniqueInput; 55 | }; 56 | 57 | export type QueryUsersArgs = { 58 | after?: Maybe; 59 | before?: Maybe; 60 | first?: Maybe; 61 | last?: Maybe; 62 | }; 63 | 64 | export type StringFieldUpdateOperationsInput = { 65 | set?: Maybe; 66 | }; 67 | 68 | export type Subscription = { 69 | __typename?: "Subscription"; 70 | ping?: Maybe; 71 | }; 72 | 73 | export type User = { 74 | __typename?: "User"; 75 | email: Scalars["String"]; 76 | id: Scalars["Int"]; 77 | role: UserRole; 78 | }; 79 | 80 | export type UserCreateInput = { 81 | email: Scalars["String"]; 82 | password: Scalars["String"]; 83 | role?: Maybe; 84 | }; 85 | 86 | export enum UserRole { 87 | Admin = "ADMIN", 88 | Editor = "EDITOR", 89 | User = "USER", 90 | } 91 | 92 | export type UserUpdateInput = { 93 | email?: Maybe; 94 | password?: Maybe; 95 | role?: Maybe; 96 | }; 97 | 98 | export type UserWhereUniqueInput = { 99 | email?: Maybe; 100 | id?: Maybe; 101 | }; 102 | 103 | export type HelloQueryVariables = Exact<{ [key: string]: never }>; 104 | 105 | export type HelloQuery = { __typename?: "Query" } & Pick; 106 | 107 | export const HelloDocument: DocumentNode = { 108 | kind: "Document", 109 | definitions: [ 110 | { 111 | kind: "OperationDefinition", 112 | operation: "query", 113 | name: { kind: "Name", value: "Hello" }, 114 | selectionSet: { kind: "SelectionSet", selections: [{ kind: "Field", name: { kind: "Name", value: "hello" } }] }, 115 | }, 116 | ], 117 | }; 118 | -------------------------------------------------------------------------------- /client/nuxt/graphql/hello.query.gql: -------------------------------------------------------------------------------- 1 | query Hello { 2 | hello 3 | } -------------------------------------------------------------------------------- /client/nuxt/nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | buildDir: "../../.nuxt", 3 | buildModules: ["@nuxtjs/composition-api", "@nuxt/typescript-build", "@nuxtjs/tailwindcss"], 4 | css: ["@braid/vue-formulate/dist/snow.css"], 5 | modules: ["@nuxt/http"], 6 | plugins: ["~/plugins/vue-formulate.ts", "~/plugins/villus.ts"], 7 | }; 8 | -------------------------------------------------------------------------------- /client/nuxt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@client/nuxt", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "build": "nuxt build --quiet", 8 | "dev": "nuxt" 9 | }, 10 | "dependencies": { 11 | "@braid/vue-formulate": "^2.5.0", 12 | "@nuxt/http": "^0.6.2", 13 | "@nuxtjs/composition-api": "^0.19.1", 14 | "@villus/batch": "^1.0.0-rc.8", 15 | "@villus/multipart": "^1.0.0-rc.7", 16 | "cross-fetch": "^3.0.6", 17 | "graphql-subscriptions-client": "^0.16.0", 18 | "nuxt": "^2.14.12", 19 | "villus": "^1.0.0-rc.11" 20 | }, 21 | "devDependencies": { 22 | "@graphql-codegen/typed-document-node": "^1.18.2", 23 | "@graphql-typed-document-node/core": "^3.1.0", 24 | "@nuxt/types": "^2.14.12", 25 | "@nuxt/typescript-build": "^2.0.4", 26 | "@nuxtjs/tailwindcss": "^3.4.2", 27 | "autoprefixer": "^9", 28 | "postcss": "^7", 29 | "tailwindcss": "npm:@tailwindcss/postcss7-compat" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/nuxt/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /client/nuxt/plugins/villus.ts: -------------------------------------------------------------------------------- 1 | import { onGlobalSetup } from "@nuxtjs/composition-api"; 2 | import { useClient, cache, dedup, handleSubscriptions } from "villus"; 3 | import { batch } from "@villus/batch"; 4 | import { multipart } from "@villus/multipart"; 5 | import { SubscriptionClient } from "graphql-subscriptions-client"; 6 | import fetch from "cross-fetch"; 7 | 8 | export default () => { 9 | onGlobalSetup(() => { 10 | const plugins = []; 11 | if (process.client) { 12 | const subscriptionClient = new SubscriptionClient(`ws://localhost:4000/api/graphql`, { 13 | reconnect: false, 14 | }); 15 | // @ts-expect-error: Villus has more complex operation types than graphql-subscriptions-client 16 | plugins.push(handleSubscriptions((operation) => subscriptionClient.request(operation))); 17 | } 18 | 19 | plugins.push(...[multipart(), cache(), dedup(), batch({ fetch })]); 20 | useClient({ 21 | url: "/api/graphql", 22 | use: plugins, 23 | }); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /client/nuxt/plugins/vue-formulate.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueFormulate from "@braid/vue-formulate"; 3 | 4 | Vue.use(VueFormulate); 5 | -------------------------------------------------------------------------------- /client/nuxt/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chagadev/fullstack-boilerplate/1630d697a993395e947f8c3b275213dad9cb2ac0/client/nuxt/static/favicon.ico -------------------------------------------------------------------------------- /client/nuxt/tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | theme: {}, 3 | variants: {}, 4 | plugins: [], 5 | purge: { 6 | enabled: process.env.NODE_ENV === "production", 7 | content: ["components/**/*.vue", "layouts/**/*.vue", "pages/**/*.vue", "plugins/**/*.(js,ts)", "nuxt.config.js"], 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /client/nuxt/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ESNext", 8 | "ESNext.AsyncIterable", 9 | "DOM" 10 | ], 11 | "esModuleInterop": true, 12 | "allowJs": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": [ 19 | "./*" 20 | ], 21 | "@/*": [ 22 | "./*" 23 | ] 24 | }, 25 | "types": [ 26 | "@types/node", 27 | "@nuxt/types" 28 | ] 29 | }, 30 | "exclude": [ 31 | "node_modules" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client/nuxt/types/vue-formulate.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@braid/vue-formulate"; 2 | -------------------------------------------------------------------------------- /client/nuxt/types/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Deploy Prisma migration 5 | /app/server/prisma/node_modules/.bin/prisma migrate deploy --schema /app/server/prisma/schema.prisma --preview-feature 6 | 7 | # Seed database 8 | if [ "$SEED_DATABASE" == true ] ; then 9 | node /app/dist/server/prisma/seed.js 10 | fi 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /docs/schema-workflow.md: -------------------------------------------------------------------------------- 1 | # Schema Workflow 2 | 3 | The schema is defined at the database level (Prisma data model) and at the application level (GraphQL Nexus schema). 4 | This separation allows for better control over what is stored in the database vs what is exposed to the frontend via the GraphQL endpoint. 5 | 6 | ## Working at the database level 7 | 8 | The database tables and columns, which correspond to the [Prisma data model](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/data-model/), are defined inside `server/prisma/scheme.prisma` using the [Prisma Schema API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/prisma-schema-reference). 9 | 10 | A default data model is provided with a basic `User` model for authentication purposes. 11 | 12 | ## Working at the application level 13 | 14 | The GraphQL types, queries, mutations and subscriptions exposed to the frontend are defined inside `server/schema/types/` using [GraphQL Nexus](https://nexusjs.org/) in a declarative, code-first approach. 15 | 16 | Projecting types from the Prisma data model onto GraphQL is done using the [Nexus Prisma plugin](https://nexusjs.org/docs/pluginss/prisma/overview) along with [Nexus Shield](https://github.com/Sytten/nexus-shield) as an authorization layer. 17 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import "./paths"; 2 | import { app } from "@server/backend"; 3 | import { config } from "@providers/config"; 4 | 5 | async function listen() { 6 | await app.listen({ port: config.backend.port, host: config.backend.host }); 7 | console.log(`🚀 http://${config.backend.host}:${config.backend.port}`); 8 | } 9 | 10 | listen(); 11 | 12 | process.on("SIGTERM", () => process.exit()); 13 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import config from "./client/nuxt/nuxt.config"; 2 | 3 | delete config.buildDir; 4 | config.srcDir = "./client/nuxt"; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-boilerplate", 3 | "description": "Fullstack application boilerplate", 4 | "version": "0.4.1", 5 | "license": "MIT", 6 | "private": true, 7 | "workspaces": [ 8 | "client/*", 9 | "providers/*", 10 | "server/*" 11 | ], 12 | "scripts": { 13 | "build:prisma": "yarn workspace @server/prisma run generate", 14 | "build:schema": "ts-node -r ./paths.ts -T ./server/schema/index.ts --nexus-generate", 15 | "build:codegen": "graphql-codegen", 16 | "build:client": "yarn workspace @client/nuxt run build", 17 | "build:server": "tsc", 18 | "build": "run-s build:*", 19 | "clean:nuxt": "rm -rf ./.nuxt", 20 | "clean:dist": "rm -rf ./dist", 21 | "clean:npm": "rm -rf ./node_modules ./**/*/node_modules ./*.lock ./*.log", 22 | "clean:generated": "rm -rf ./**/generated", 23 | "clean": "run-s clean:*", 24 | "dev:prisma": "yarn workspace @server/prisma run dev", 25 | "dev:codegen": "graphql-codegen --watch", 26 | "dev:client": "yarn workspace @client/nuxt run dev", 27 | "dev:server": "ts-node-dev --transpile-only --no-notify ./main.ts", 28 | "dev": "run-p dev:*", 29 | "lint": "eslint --ext .js,.ts,.vue --fix --ignore-path .gitignore .", 30 | "migrate": "yarn workspace @server/prisma run migrate", 31 | "postversion": "set-versions -w", 32 | "seed": "ts-node -r ./paths.ts -T ./server/prisma/seed.ts", 33 | "start": "NODE_ENV=production node ./dist/main.js", 34 | "studio": "yarn workspace @server/prisma run studio" 35 | }, 36 | "dependencies": { 37 | "graphql": "^15.5.0", 38 | "module-alias": "^2.2.2" 39 | }, 40 | "devDependencies": { 41 | "@graphql-codegen/cli": "^1.20.1", 42 | "@graphql-codegen/typescript": "^1.20.2", 43 | "@graphql-codegen/typescript-operations": "^1.17.14", 44 | "@types/module-alias": "^2.0.0", 45 | "@types/node": "^14.14.25", 46 | "@typescript-eslint/eslint-plugin": "^4.15.0", 47 | "@typescript-eslint/parser": "^4.15.0", 48 | "eslint": "^7.19.0", 49 | "eslint-config-prettier": "^7.2.0", 50 | "eslint-plugin-prettier": "^3.3.1", 51 | "eslint-plugin-vue": "^7.5.0", 52 | "npm-run-all": "^4.1.5", 53 | "prettier": "^2.2.1", 54 | "set-versions": "^1.0.3", 55 | "ts-node-dev": "^1.1.1", 56 | "typescript": "^4.1.3" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /paths.ts: -------------------------------------------------------------------------------- 1 | import moduleAlias from "module-alias"; 2 | import { resolve } from "path"; 3 | moduleAlias.addAliases({ "@client": resolve(__dirname, "./client") }); 4 | moduleAlias.addAliases({ "@providers": resolve(__dirname, "./providers") }); 5 | moduleAlias.addAliases({ "@server": resolve(__dirname, "./server") }); 6 | -------------------------------------------------------------------------------- /providers/config/index.ts: -------------------------------------------------------------------------------- 1 | import env from "env-var"; 2 | import { config as dotenvConfig } from "dotenv"; 3 | import { resolve } from "path"; 4 | 5 | export interface Config { 6 | auth: { 7 | jwt: { 8 | cookiePrefix: string; 9 | secret: string; 10 | }; 11 | }; 12 | backend: { 13 | host: string; 14 | port: number; 15 | }; 16 | mode: "development" | "production"; 17 | paths: { 18 | root: string; 19 | }; 20 | providers: { 21 | mailer: { 22 | host: string; 23 | port: number; 24 | auth: { 25 | user: string; 26 | pass: string; 27 | }; 28 | }; 29 | }; 30 | seed: { 31 | adminUser: { 32 | email: string; 33 | password: string; 34 | }; 35 | }; 36 | web: { 37 | host: string; 38 | port: number; 39 | }; 40 | } 41 | 42 | const mode = process.env.NODE_ENV || "development"; 43 | const rootPath = resolve(__dirname, "../..").replace("/dist", ""); 44 | dotenvConfig({ path: `${rootPath}/.env` }); 45 | 46 | export const config = { 47 | auth: { 48 | jwt: { 49 | cookiePrefix: env.get("AUTH_JWT_COOKIE_PREFIX").default("fullstack-boilerplate").asString(), 50 | secret: env.get("AUTH_JWT_SECRET").default("supersecret").asString(), 51 | }, 52 | }, 53 | backend: { 54 | host: env.get("BACKEND_HOST").default("0.0.0.0").asString(), 55 | port: env.get("BACKEND_PORT").default("4000").asPortNumber(), 56 | }, 57 | mode, 58 | paths: { 59 | root: rootPath, 60 | }, 61 | providers: { 62 | mailer: { 63 | host: env.get("MAILER_SMTP_HOST").default("localhost").asString(), 64 | port: env.get("MAILER_SMTP_PORT").default("465").asPortNumber(), 65 | auth: { 66 | user: env.get("MAILER_SMTP_USER").asString(), 67 | pass: env.get("MAILER_SMTP_PASSWORD").asString(), 68 | }, 69 | }, 70 | }, 71 | seed: { 72 | adminUser: { 73 | email: env.get("SEED_ADMIN_USER_EMAIL").default("pascal@lewebsimple.ca").asString(), 74 | password: env.get("SEED_ADMIN_USER_PASSWORD").default("changeme").asString(), 75 | }, 76 | }, 77 | web: { 78 | host: env.get("WEB_HOST").default("localhost").asString(), 79 | port: env.get("WEB_PORT").default("3000").asPortNumber(), 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /providers/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@providers/config", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "dotenv": "^8.2.0", 8 | "env-var": "^7.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /providers/mailer/index.ts: -------------------------------------------------------------------------------- 1 | import { createTransport } from "nodemailer"; 2 | import { config } from "@providers/config"; 3 | 4 | export const transporter = createTransport( 5 | Object.assign(config.providers.mailer, { 6 | secure: config.providers.mailer.port === 465, 7 | }), 8 | ); 9 | -------------------------------------------------------------------------------- /providers/mailer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@providers/mailer", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "nodemailer": "^6.4.17" 8 | }, 9 | "devDependencies": { 10 | "@types/nodemailer": "^6.4.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/backend/context.ts: -------------------------------------------------------------------------------- 1 | import { MercuriusContext } from "mercurius"; 2 | import { FastifyRequest } from "fastify"; 3 | import { prisma, Prisma } from "@server/prisma"; 4 | 5 | export interface ExtraContext { 6 | request: FastifyRequest; 7 | prisma: Prisma; 8 | } 9 | 10 | export interface Context extends MercuriusContext, ExtraContext {} 11 | 12 | export const getContextFromRequest = (request: FastifyRequest): ExtraContext => { 13 | return { request, prisma }; 14 | }; 15 | -------------------------------------------------------------------------------- /server/backend/index.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import authJwtPlugin from "./plugins/auth-jwt"; 3 | import authLocalPlugin from "./plugins/auth-local"; 4 | import mercuriusPlugin from "./plugins/mercurius"; 5 | import nuxtPlugin from "./plugins/nuxt"; 6 | 7 | export const app = fastify({}); 8 | 9 | app.register(authJwtPlugin); 10 | app.register(authLocalPlugin); 11 | app.register(mercuriusPlugin); 12 | app.register(nuxtPlugin); 13 | -------------------------------------------------------------------------------- /server/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@server/backend", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "bcryptjs": "^2.4.3", 8 | "fastify": "^3.11.0", 9 | "fastify-cookie": "^5.1.0", 10 | "fastify-http-proxy": "^4.3.0", 11 | "fastify-jwt": "^2.3.0", 12 | "fastify-nuxtjs": "^1.0.1", 13 | "fastify-plugin": "^3.0.0", 14 | "mercurius": "^6.12.0", 15 | "mercurius-upload": "^1.1.1" 16 | }, 17 | "devDependencies": { 18 | "@types/bcryptjs": "^2.4.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/backend/plugins/auth-jwt.ts: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin"; 2 | import { FastifyPluginCallback } from "fastify"; 3 | import fastifyCookie from "fastify-cookie"; 4 | import fastifyJwt, { SignPayloadType } from "fastify-jwt"; 5 | import { config } from "@providers/config"; 6 | import { User } from "@server/schema/generated/types"; 7 | 8 | declare module "fastify-jwt" { 9 | interface FastifyJWT { 10 | payload: User; 11 | } 12 | } 13 | 14 | declare module "fastify" { 15 | interface FastifyReply { 16 | /** 17 | * Set authentication cookie in reply 18 | * 19 | * @param jwtPayload Authentication token payload 20 | */ 21 | setAuthCookie(jwtPayload: SignPayloadType): FastifyReply; 22 | } 23 | } 24 | 25 | const authJwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { 26 | // Register fastify-cookie 27 | fastify.register(fastifyCookie); 28 | 29 | // Register fastify-jwt 30 | fastify.register(fastifyJwt, { 31 | secret: config.auth.jwt.secret, 32 | verify: { 33 | extractToken: (request) => { 34 | if (request.headers && request.headers.authorization) { 35 | // Bearer token in authorization header 36 | const parts = request.headers.authorization.split("Bearer "); 37 | if (parts.length === 2) return parts[1]; 38 | } else if ( 39 | request.cookies[`${config.auth.jwt.cookiePrefix}-payload`] && 40 | request.cookies[`${config.auth.jwt.cookiePrefix}-signature`] 41 | ) { 42 | // JWT from payload / signature cookies 43 | return ( 44 | request.cookies[`${config.auth.jwt.cookiePrefix}-payload`] + 45 | "." + 46 | request.cookies[`${config.auth.jwt.cookiePrefix}-signature`] 47 | ); 48 | } 49 | return null; 50 | }, 51 | }, 52 | }); 53 | 54 | // Handle JWT authentication on every request 55 | fastify.addHook("onRequest", async (request) => { 56 | try { 57 | await request.jwtVerify(); 58 | } catch (error) { 59 | // Ignore missing token 60 | } 61 | }); 62 | 63 | // Provide reply decorator for setting authentication cookie from user 64 | fastify.decorateReply("setAuthCookie", function (jwtPayload: SignPayloadType | null) { 65 | // TODO: Detect SSL and set secure / sameSite accordingly 66 | const cookieOptions = { 67 | path: "/", 68 | sameSite: "Lax", 69 | }; 70 | if (jwtPayload) { 71 | delete (jwtPayload as any).password; // Make sure password is never stored in cookie 72 | const [header, payload, signature] = fastify.jwt.sign(jwtPayload).split("."); 73 | this.setCookie(`${config.auth.jwt.cookiePrefix}-payload`, `${header}.${payload}`, cookieOptions); 74 | this.setCookie( 75 | `${config.auth.jwt.cookiePrefix}-signature`, 76 | signature, 77 | Object.assign(cookieOptions, { httpOnly: true }), 78 | ); 79 | } else { 80 | this.clearCookie(`${config.auth.jwt.cookiePrefix}-payload`, cookieOptions); 81 | this.clearCookie(`${config.auth.jwt.cookiePrefix}-signature`, Object.assign(cookieOptions, { httpOnly: true })); 82 | } 83 | 84 | return this.send({ user: jwtPayload }); 85 | }); 86 | 87 | // Logout 88 | fastify.get("/api/auth/logout", {}, async (request, reply) => { 89 | return reply.setAuthCookie(null); 90 | }); 91 | 92 | next(); 93 | }; 94 | 95 | export default fp(authJwtPlugin, { name: "auth-jwt" }); 96 | -------------------------------------------------------------------------------- /server/backend/plugins/auth-local.ts: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin"; 2 | import { FastifyPluginCallback } from "fastify"; 3 | import { prisma } from "@server/prisma"; 4 | import { compareSync, hashSync } from "bcryptjs"; 5 | import { SignPayloadType } from "fastify-jwt"; 6 | 7 | export function verifyPassword(password: string, hash: string): boolean { 8 | return compareSync(password, hash); 9 | } 10 | 11 | export function encryptPassword(password: string): string { 12 | return hashSync(password); 13 | } 14 | 15 | const authLocalPlugin: FastifyPluginCallback = (fastify, _opts, next) => { 16 | // Local login 17 | fastify.post<{ 18 | Body: { 19 | email: string; 20 | password: string; 21 | }; 22 | }>("/api/auth/login", {}, async ({ body: { email, password } }, reply) => { 23 | try { 24 | const user = await prisma.user.findUnique({ where: { email } }); 25 | if (verifyPassword(password, user.password)) { 26 | delete user.password; 27 | return reply.setAuthCookie(user as SignPayloadType); 28 | } 29 | } catch (error) {} 30 | return reply.code(401).send({ user: null }); 31 | }); 32 | 33 | next(); 34 | }; 35 | 36 | export default fp(authLocalPlugin, { name: "auth-local", dependencies: ["auth-jwt"] }); 37 | -------------------------------------------------------------------------------- /server/backend/plugins/mercurius.ts: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin"; 2 | import { FastifyPluginCallback } from "fastify"; 3 | import mercurius from "mercurius"; 4 | import mercuriusUpload from "mercurius-upload"; 5 | import { config } from "@providers/config"; 6 | import { schema } from "@server/schema"; 7 | import { getContextFromRequest } from "../context"; 8 | 9 | const mercuriusPlugin: FastifyPluginCallback = (fastify, _opts, next) => { 10 | fastify.register(mercuriusUpload); 11 | fastify.register(mercurius, { 12 | context: (request) => getContextFromRequest(request), 13 | graphiql: config.mode === "development" && "playground", 14 | prefix: "api", 15 | schema, 16 | subscription: true, 17 | allowBatchedQueries: true, 18 | }); 19 | next(); 20 | }; 21 | 22 | export default fp(mercuriusPlugin, { name: "mercurius" }); 23 | -------------------------------------------------------------------------------- /server/backend/plugins/nuxt.ts: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin"; 2 | import { FastifyPluginCallback } from "fastify"; 3 | import fastifyHttpProxy from "fastify-http-proxy"; 4 | import fastifyNuxtJS from "fastify-nuxtjs"; 5 | import { config } from "@providers/config"; 6 | 7 | const nuxtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { 8 | if (config.mode === "production") { 9 | fastify.register(fastifyNuxtJS).after(() => { 10 | fastify.nuxt("*"); 11 | }); 12 | } else { 13 | fastify.register(fastifyHttpProxy, { 14 | upstream: `http://${config.web.host}:${config.web.port}`, 15 | }); 16 | } 17 | next(); 18 | }; 19 | 20 | export default fp(nuxtPlugin, { name: "nuxt" }); 21 | -------------------------------------------------------------------------------- /server/prisma/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | export * from "@prisma/client"; 3 | 4 | export class Prisma extends PrismaClient { 5 | private static instance: Prisma; 6 | 7 | private constructor() { 8 | super(); 9 | } 10 | 11 | static getInstance(): Prisma { 12 | if (!Prisma.instance) { 13 | Prisma.instance = new Prisma(); 14 | } 15 | 16 | return Prisma.instance; 17 | } 18 | } 19 | 20 | export const prisma = Prisma.getInstance(); 21 | -------------------------------------------------------------------------------- /server/prisma/migrations/20210124235947_initial_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `User` ( 3 | `id` INT NOT NULL AUTO_INCREMENT, 4 | `email` VARCHAR(191) NOT NULL, 5 | `password` VARCHAR(191) NOT NULL, 6 | `role` ENUM('USER', 'EDITOR', 'ADMIN') NOT NULL DEFAULT 'USER', 7 | UNIQUE INDEX `User.email_unique`(`email`), 8 | 9 | PRIMARY KEY (`id`) 10 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 11 | -------------------------------------------------------------------------------- /server/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | provider = "mysql" -------------------------------------------------------------------------------- /server/prisma/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@server/prisma", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "scripts": { 7 | "dev": "nodemon -q -w schema.prisma -x yarn migrate dev", 8 | "generate": "yarn prisma generate", 9 | "migrate": "yarn prisma migrate --preview-feature", 10 | "studio": "yarn prisma studio" 11 | }, 12 | "dependencies": { 13 | "@prisma/client": "^2.16.1", 14 | "prisma": "^2.16.1" 15 | }, 16 | "devDependencies": { 17 | "nodemon": "^2.0.7" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | url = env("DATABASE_URL") 3 | provider = "mysql" 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | enum UserRole { 11 | USER 12 | EDITOR 13 | ADMIN 14 | } 15 | 16 | model User { 17 | id Int @id @default(autoincrement()) 18 | email String @unique 19 | password String 20 | role UserRole @default(USER) 21 | } 22 | -------------------------------------------------------------------------------- /server/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import "../../paths"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import * as seeds from "./seeds"; 4 | 5 | export interface SeedResult { 6 | message: string; 7 | warnings?: string[]; 8 | } 9 | 10 | const prisma = new PrismaClient(); 11 | 12 | async function main() { 13 | for (const key of Object.keys(seeds)) { 14 | const { message, warnings } = await seeds[key](prisma); 15 | console.log(message); 16 | if (warnings.length) { 17 | console.log(warnings); 18 | } 19 | } 20 | } 21 | 22 | main() 23 | .catch((error) => { 24 | console.error(error.message); 25 | process.exit(1); 26 | }) 27 | .finally(async () => { 28 | await prisma.$disconnect(); 29 | }); 30 | -------------------------------------------------------------------------------- /server/prisma/seeds/admin-user.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, UserRole } from "@prisma/client"; 2 | import { SeedResult } from "@server/prisma/seed"; 3 | import { config } from "@providers/config"; 4 | import { encryptPassword } from "@server/backend/plugins/auth-local"; 5 | 6 | export async function seedAdminUser(prisma: PrismaClient): Promise { 7 | config.seed.adminUser.password = encryptPassword(config.seed.adminUser.password); 8 | const adminUser = await prisma.user.upsert({ 9 | where: { email: config.seed.adminUser.email }, 10 | create: { ...config.seed.adminUser, role: UserRole.ADMIN }, 11 | update: { ...config.seed.adminUser, role: UserRole.ADMIN }, 12 | }); 13 | return { message: `Upserted admin user (${config.seed.adminUser.email})` }; 14 | } 15 | -------------------------------------------------------------------------------- /server/prisma/seeds/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./admin-user"; 2 | -------------------------------------------------------------------------------- /server/schema/generated/nexus-prisma-types.ts: -------------------------------------------------------------------------------- 1 | import * as Typegen from "nexus-plugin-prisma/typegen"; 2 | import * as Prisma from "@prisma/client"; 3 | 4 | // Pagination type 5 | type Pagination = { 6 | first?: boolean; 7 | last?: boolean; 8 | before?: boolean; 9 | after?: boolean; 10 | }; 11 | 12 | // Prisma custom scalar names 13 | type CustomScalars = "No custom scalars are used in your Prisma Schema."; 14 | 15 | // Prisma model type definitions 16 | interface PrismaModels { 17 | User: Prisma.User; 18 | } 19 | 20 | // Prisma input types metadata 21 | interface NexusPrismaInputs { 22 | Query: { 23 | users: { 24 | filtering: "AND" | "OR" | "NOT" | "id" | "email" | "password" | "role"; 25 | ordering: "id" | "email" | "password" | "role"; 26 | }; 27 | }; 28 | User: {}; 29 | } 30 | 31 | // Prisma output types metadata 32 | interface NexusPrismaOutputs { 33 | Query: { 34 | user: "User"; 35 | users: "User"; 36 | }; 37 | Mutation: { 38 | createOneUser: "User"; 39 | updateOneUser: "User"; 40 | updateManyUser: "AffectedRowsOutput"; 41 | deleteOneUser: "User"; 42 | deleteManyUser: "AffectedRowsOutput"; 43 | upsertOneUser: "User"; 44 | }; 45 | User: { 46 | id: "Int"; 47 | email: "String"; 48 | password: "String"; 49 | role: "UserRole"; 50 | }; 51 | } 52 | 53 | // Helper to gather all methods relative to a model 54 | interface NexusPrismaMethods { 55 | User: Typegen.NexusPrismaFields<"User">; 56 | Query: Typegen.NexusPrismaFields<"Query">; 57 | Mutation: Typegen.NexusPrismaFields<"Mutation">; 58 | } 59 | 60 | interface NexusPrismaGenTypes { 61 | inputs: NexusPrismaInputs; 62 | outputs: NexusPrismaOutputs; 63 | methods: NexusPrismaMethods; 64 | models: PrismaModels; 65 | pagination: Pagination; 66 | scalars: CustomScalars; 67 | } 68 | 69 | declare global { 70 | interface NexusPrismaGen extends NexusPrismaGenTypes {} 71 | 72 | type NexusPrisma = Typegen.GetNexusPrisma< 73 | TypeName, 74 | ModelOrCrud 75 | >; 76 | } 77 | -------------------------------------------------------------------------------- /server/schema/generated/nexus-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by Nexus Schema 3 | * Do not make changes to this file directly 4 | */ 5 | 6 | import { Context } from "@server/backend/context"; 7 | import { FieldShieldResolver, ObjectTypeShieldResolver } from "nexus-shield"; 8 | import { core } from "nexus"; 9 | declare global { 10 | interface NexusGenCustomInputMethods { 11 | /** 12 | * The `Upload` scalar type represents a file upload. 13 | */ 14 | upload( 15 | fieldName: FieldName, 16 | opts?: core.CommonInputFieldConfig, 17 | ): void; // "Upload"; 18 | } 19 | } 20 | declare global { 21 | interface NexusGenCustomOutputMethods { 22 | /** 23 | * The `Upload` scalar type represents a file upload. 24 | */ 25 | upload(fieldName: FieldName, ...opts: core.ScalarOutSpread): void; // "Upload"; 26 | } 27 | } 28 | declare global { 29 | interface NexusGenCustomOutputProperties { 30 | crud: NexusPrisma; 31 | model: NexusPrisma; 32 | } 33 | } 34 | 35 | declare global { 36 | interface NexusGen extends NexusGenTypes {} 37 | } 38 | 39 | export interface NexusGenInputs { 40 | EnumUserRoleFieldUpdateOperationsInput: { 41 | // input type 42 | set?: NexusGenEnums["UserRole"] | null; // UserRole 43 | }; 44 | StringFieldUpdateOperationsInput: { 45 | // input type 46 | set?: string | null; // String 47 | }; 48 | UserCreateInput: { 49 | // input type 50 | email: string; // String! 51 | password: string; // String! 52 | role?: NexusGenEnums["UserRole"] | null; // UserRole 53 | }; 54 | UserUpdateInput: { 55 | // input type 56 | email?: NexusGenInputs["StringFieldUpdateOperationsInput"] | null; // StringFieldUpdateOperationsInput 57 | password?: NexusGenInputs["StringFieldUpdateOperationsInput"] | null; // StringFieldUpdateOperationsInput 58 | role?: NexusGenInputs["EnumUserRoleFieldUpdateOperationsInput"] | null; // EnumUserRoleFieldUpdateOperationsInput 59 | }; 60 | UserWhereUniqueInput: { 61 | // input type 62 | email?: string | null; // String 63 | id?: number | null; // Int 64 | }; 65 | } 66 | 67 | export interface NexusGenEnums { 68 | UserRole: "ADMIN" | "EDITOR" | "USER"; 69 | } 70 | 71 | export interface NexusGenScalars { 72 | String: string; 73 | Int: number; 74 | Float: number; 75 | Boolean: boolean; 76 | ID: string; 77 | Upload: any; 78 | } 79 | 80 | export interface NexusGenObjects { 81 | Mutation: {}; 82 | Query: {}; 83 | Subscription: {}; 84 | User: { 85 | // root type 86 | email: string; // String! 87 | id: number; // Int! 88 | role: NexusGenEnums["UserRole"]; // UserRole! 89 | }; 90 | } 91 | 92 | export interface NexusGenInterfaces {} 93 | 94 | export interface NexusGenUnions {} 95 | 96 | export type NexusGenRootTypes = NexusGenObjects; 97 | 98 | export type NexusGenAllTypes = NexusGenRootTypes & NexusGenScalars & NexusGenEnums; 99 | 100 | export interface NexusGenFieldTypes { 101 | Mutation: { 102 | // field return type 103 | createOneUser: NexusGenRootTypes["User"]; // User! 104 | deleteOneUser: NexusGenRootTypes["User"] | null; // User 105 | ping: string | null; // String 106 | updateOneUser: NexusGenRootTypes["User"] | null; // User 107 | }; 108 | Query: { 109 | // field return type 110 | hello: string; // String! 111 | user: NexusGenRootTypes["User"] | null; // User 112 | users: NexusGenRootTypes["User"][]; // [User!]! 113 | }; 114 | Subscription: { 115 | // field return type 116 | ping: string | null; // String 117 | }; 118 | User: { 119 | // field return type 120 | email: string; // String! 121 | id: number; // Int! 122 | role: NexusGenEnums["UserRole"]; // UserRole! 123 | }; 124 | } 125 | 126 | export interface NexusGenFieldTypeNames { 127 | Mutation: { 128 | // field return type name 129 | createOneUser: "User"; 130 | deleteOneUser: "User"; 131 | ping: "String"; 132 | updateOneUser: "User"; 133 | }; 134 | Query: { 135 | // field return type name 136 | hello: "String"; 137 | user: "User"; 138 | users: "User"; 139 | }; 140 | Subscription: { 141 | // field return type name 142 | ping: "String"; 143 | }; 144 | User: { 145 | // field return type name 146 | email: "String"; 147 | id: "Int"; 148 | role: "UserRole"; 149 | }; 150 | } 151 | 152 | export interface NexusGenArgTypes { 153 | Mutation: { 154 | createOneUser: { 155 | // args 156 | data: NexusGenInputs["UserCreateInput"]; // UserCreateInput! 157 | }; 158 | deleteOneUser: { 159 | // args 160 | where: NexusGenInputs["UserWhereUniqueInput"]; // UserWhereUniqueInput! 161 | }; 162 | ping: { 163 | // args 164 | message: string | null; // String 165 | }; 166 | updateOneUser: { 167 | // args 168 | data: NexusGenInputs["UserUpdateInput"]; // UserUpdateInput! 169 | where: NexusGenInputs["UserWhereUniqueInput"]; // UserWhereUniqueInput! 170 | }; 171 | }; 172 | Query: { 173 | user: { 174 | // args 175 | where: NexusGenInputs["UserWhereUniqueInput"]; // UserWhereUniqueInput! 176 | }; 177 | users: { 178 | // args 179 | after?: NexusGenInputs["UserWhereUniqueInput"] | null; // UserWhereUniqueInput 180 | before?: NexusGenInputs["UserWhereUniqueInput"] | null; // UserWhereUniqueInput 181 | first?: number | null; // Int 182 | last?: number | null; // Int 183 | }; 184 | }; 185 | } 186 | 187 | export interface NexusGenAbstractTypeMembers {} 188 | 189 | export interface NexusGenTypeInterfaces {} 190 | 191 | export type NexusGenObjectNames = keyof NexusGenObjects; 192 | 193 | export type NexusGenInputNames = keyof NexusGenInputs; 194 | 195 | export type NexusGenEnumNames = keyof NexusGenEnums; 196 | 197 | export type NexusGenInterfaceNames = never; 198 | 199 | export type NexusGenScalarNames = keyof NexusGenScalars; 200 | 201 | export type NexusGenUnionNames = never; 202 | 203 | export type NexusGenObjectsUsingAbstractStrategyIsTypeOf = never; 204 | 205 | export type NexusGenAbstractsUsingStrategyResolveType = never; 206 | 207 | export type NexusGenFeaturesConfig = { 208 | abstractTypeStrategies: { 209 | isTypeOf: false; 210 | resolveType: true; 211 | __typename: false; 212 | }; 213 | }; 214 | 215 | export interface NexusGenTypes { 216 | context: Context; 217 | inputTypes: NexusGenInputs; 218 | rootTypes: NexusGenRootTypes; 219 | inputTypeShapes: NexusGenInputs & NexusGenEnums & NexusGenScalars; 220 | argTypes: NexusGenArgTypes; 221 | fieldTypes: NexusGenFieldTypes; 222 | fieldTypeNames: NexusGenFieldTypeNames; 223 | allTypes: NexusGenAllTypes; 224 | typeInterfaces: NexusGenTypeInterfaces; 225 | objectNames: NexusGenObjectNames; 226 | inputNames: NexusGenInputNames; 227 | enumNames: NexusGenEnumNames; 228 | interfaceNames: NexusGenInterfaceNames; 229 | scalarNames: NexusGenScalarNames; 230 | unionNames: NexusGenUnionNames; 231 | allInputTypes: NexusGenTypes["inputNames"] | NexusGenTypes["enumNames"] | NexusGenTypes["scalarNames"]; 232 | allOutputTypes: 233 | | NexusGenTypes["objectNames"] 234 | | NexusGenTypes["enumNames"] 235 | | NexusGenTypes["unionNames"] 236 | | NexusGenTypes["interfaceNames"] 237 | | NexusGenTypes["scalarNames"]; 238 | allNamedTypes: NexusGenTypes["allInputTypes"] | NexusGenTypes["allOutputTypes"]; 239 | abstractTypes: NexusGenTypes["interfaceNames"] | NexusGenTypes["unionNames"]; 240 | abstractTypeMembers: NexusGenAbstractTypeMembers; 241 | objectsUsingAbstractStrategyIsTypeOf: NexusGenObjectsUsingAbstractStrategyIsTypeOf; 242 | abstractsUsingStrategyResolveType: NexusGenAbstractsUsingStrategyResolveType; 243 | features: NexusGenFeaturesConfig; 244 | } 245 | 246 | declare global { 247 | interface NexusGenPluginTypeConfig { 248 | /** 249 | * Default authorization rule to execute on all fields of this object 250 | */ 251 | shield?: ObjectTypeShieldResolver; 252 | } 253 | interface NexusGenPluginFieldConfig { 254 | /** 255 | * Authorization rule to execute for this field 256 | */ 257 | shield?: FieldShieldResolver; 258 | } 259 | interface NexusGenPluginInputFieldConfig {} 260 | interface NexusGenPluginSchemaConfig {} 261 | interface NexusGenPluginArgConfig {} 262 | } 263 | -------------------------------------------------------------------------------- /server/schema/generated/schema.graphql: -------------------------------------------------------------------------------- 1 | ### This file was generated by Nexus Schema 2 | ### Do not make changes to this file directly 3 | 4 | input EnumUserRoleFieldUpdateOperationsInput { 5 | set: UserRole 6 | } 7 | 8 | type Mutation { 9 | createOneUser(data: UserCreateInput!): User! 10 | deleteOneUser(where: UserWhereUniqueInput!): User 11 | ping(message: String = "Pong!"): String 12 | updateOneUser(data: UserUpdateInput!, where: UserWhereUniqueInput!): User 13 | } 14 | 15 | type Query { 16 | hello: String! 17 | user(where: UserWhereUniqueInput!): User 18 | users(after: UserWhereUniqueInput, before: UserWhereUniqueInput, first: Int, last: Int): [User!]! 19 | } 20 | 21 | input StringFieldUpdateOperationsInput { 22 | set: String 23 | } 24 | 25 | type Subscription { 26 | ping: String 27 | } 28 | 29 | """ 30 | The `Upload` scalar type represents a file upload. 31 | """ 32 | scalar Upload 33 | 34 | type User { 35 | email: String! 36 | id: Int! 37 | role: UserRole! 38 | } 39 | 40 | input UserCreateInput { 41 | email: String! 42 | password: String! 43 | role: UserRole 44 | } 45 | 46 | enum UserRole { 47 | ADMIN 48 | EDITOR 49 | USER 50 | } 51 | 52 | input UserUpdateInput { 53 | email: StringFieldUpdateOperationsInput 54 | password: StringFieldUpdateOperationsInput 55 | role: EnumUserRoleFieldUpdateOperationsInput 56 | } 57 | 58 | input UserWhereUniqueInput { 59 | email: String 60 | id: Int 61 | } 62 | -------------------------------------------------------------------------------- /server/schema/generated/types.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | export type Exact = { [K in keyof T]: T[K] }; 3 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 4 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 5 | /** All built-in and custom scalars, mapped to their actual values */ 6 | export type Scalars = { 7 | ID: string; 8 | String: string; 9 | Boolean: boolean; 10 | Int: number; 11 | Float: number; 12 | /** The `Upload` scalar type represents a file upload. */ 13 | Upload: any; 14 | }; 15 | 16 | export type EnumUserRoleFieldUpdateOperationsInput = { 17 | set?: Maybe; 18 | }; 19 | 20 | export type Mutation = { 21 | __typename?: "Mutation"; 22 | createOneUser: User; 23 | deleteOneUser?: Maybe; 24 | ping?: Maybe; 25 | updateOneUser?: Maybe; 26 | }; 27 | 28 | export type MutationCreateOneUserArgs = { 29 | data: UserCreateInput; 30 | }; 31 | 32 | export type MutationDeleteOneUserArgs = { 33 | where: UserWhereUniqueInput; 34 | }; 35 | 36 | export type MutationPingArgs = { 37 | message?: Maybe; 38 | }; 39 | 40 | export type MutationUpdateOneUserArgs = { 41 | data: UserUpdateInput; 42 | where: UserWhereUniqueInput; 43 | }; 44 | 45 | export type Query = { 46 | __typename?: "Query"; 47 | hello: Scalars["String"]; 48 | user?: Maybe; 49 | users: Array; 50 | }; 51 | 52 | export type QueryUserArgs = { 53 | where: UserWhereUniqueInput; 54 | }; 55 | 56 | export type QueryUsersArgs = { 57 | after?: Maybe; 58 | before?: Maybe; 59 | first?: Maybe; 60 | last?: Maybe; 61 | }; 62 | 63 | export type StringFieldUpdateOperationsInput = { 64 | set?: Maybe; 65 | }; 66 | 67 | export type Subscription = { 68 | __typename?: "Subscription"; 69 | ping?: Maybe; 70 | }; 71 | 72 | export type User = { 73 | __typename?: "User"; 74 | email: Scalars["String"]; 75 | id: Scalars["Int"]; 76 | role: UserRole; 77 | }; 78 | 79 | export type UserCreateInput = { 80 | email: Scalars["String"]; 81 | password: Scalars["String"]; 82 | role?: Maybe; 83 | }; 84 | 85 | export enum UserRole { 86 | Admin = "ADMIN", 87 | Editor = "EDITOR", 88 | User = "USER", 89 | } 90 | 91 | export type UserUpdateInput = { 92 | email?: Maybe; 93 | password?: Maybe; 94 | role?: Maybe; 95 | }; 96 | 97 | export type UserWhereUniqueInput = { 98 | email?: Maybe; 99 | id?: Maybe; 100 | }; 101 | -------------------------------------------------------------------------------- /server/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { makeSchema } from "nexus"; 3 | import { nexusPrisma } from "nexus-plugin-prisma"; 4 | import { nexusShield, allow } from "nexus-shield"; 5 | import { config } from "@providers/config"; 6 | import * as types from "./types"; 7 | 8 | const shouldGenerateArtifacts = process.argv.includes("--nexus-generate") || config.mode === "development"; 9 | 10 | export const schema = makeSchema({ 11 | contextType: { 12 | module: "@server/backend/context", 13 | export: "Context", 14 | }, 15 | plugins: [ 16 | nexusPrisma({ 17 | experimentalCRUD: true, 18 | outputs: { 19 | typegen: resolve(__dirname, "generated/nexus-prisma-types.ts"), 20 | }, 21 | shouldGenerateArtifacts, 22 | }), 23 | nexusShield({ 24 | defaultRule: allow, 25 | }), 26 | ], 27 | prettierConfig: resolve(__dirname, "../../.prettierrc.js"), 28 | outputs: { 29 | schema: resolve(__dirname, "generated/schema.graphql"), 30 | typegen: resolve(__dirname, "generated/nexus-types.ts"), 31 | }, 32 | shouldGenerateArtifacts, 33 | types, 34 | }); 35 | -------------------------------------------------------------------------------- /server/schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@server/schema", 3 | "version": "0.4.1", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "nexus": "^1.0.0", 8 | "nexus-plugin-prisma": "^0.30.0", 9 | "nexus-shield": "^2.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/schema/rules.ts: -------------------------------------------------------------------------------- 1 | import { generic, ruleType, ShieldCache } from "nexus-shield"; 2 | import { UserRole } from "@server/prisma"; 3 | 4 | export const isAuthenticated = generic( 5 | ruleType({ 6 | cache: ShieldCache.CONTEXTUAL, 7 | resolve: (_root, _args, { request }) => { 8 | return !!request.user; 9 | }, 10 | }), 11 | ); 12 | 13 | export const hasUserRole = (role: UserRole) => { 14 | return generic( 15 | ruleType({ 16 | cache: ShieldCache.CONTEXTUAL, 17 | resolve: (_root, _args, { request }) => { 18 | return [role, UserRole.ADMIN].includes(request.user?.role); 19 | }, 20 | }), 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /server/schema/types/hello.ts: -------------------------------------------------------------------------------- 1 | import { extendType, nonNull, stringArg, subscriptionField } from "nexus"; 2 | 3 | export const HelloQuery = extendType({ 4 | type: "Query", 5 | definition(t) { 6 | t.field("hello", { 7 | type: nonNull("String"), 8 | resolve: () => `Hello World`, 9 | }); 10 | }, 11 | }); 12 | 13 | export const PingMutation = extendType({ 14 | type: "Mutation", 15 | definition(t) { 16 | t.field("ping", { 17 | type: "String", 18 | args: { 19 | message: stringArg({ default: "Pong!" }), 20 | }, 21 | resolve: (_root, { message }, { pubsub }) => { 22 | pubsub.publish({ 23 | topic: "ping", 24 | payload: message, 25 | }); 26 | return message; 27 | }, 28 | }); 29 | }, 30 | }); 31 | 32 | export const PingSubscription = subscriptionField("ping", { 33 | type: "String", 34 | subscribe: async (_root, _args, { pubsub }) => await pubsub.subscribe("ping"), 35 | resolve: (payload) => payload as string, 36 | }); 37 | -------------------------------------------------------------------------------- /server/schema/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hello"; 2 | export * from "./upload"; 3 | export * from "./user"; 4 | -------------------------------------------------------------------------------- /server/schema/types/upload.ts: -------------------------------------------------------------------------------- 1 | import { asNexusMethod } from "nexus"; 2 | import { GraphQLUpload } from "graphql-upload"; 3 | 4 | export const Upload = asNexusMethod(GraphQLUpload, "upload"); 5 | -------------------------------------------------------------------------------- /server/schema/types/user.ts: -------------------------------------------------------------------------------- 1 | import { extendType, objectType } from "nexus"; 2 | import { hasUserRole } from "@server/schema/rules"; 3 | import { UserRole } from "@prisma/client"; 4 | 5 | export const UserObject = objectType({ 6 | name: "User", 7 | definition(t) { 8 | t.model.id(); 9 | t.model.email(); 10 | t.model.role(); 11 | }, 12 | }); 13 | 14 | export const UserQuery = extendType({ 15 | type: "Query", 16 | definition(t) { 17 | t.crud.user({ shield: hasUserRole(UserRole.EDITOR)() }); 18 | t.crud.users({ shield: hasUserRole(UserRole.EDITOR)() }); 19 | }, 20 | }); 21 | 22 | export const UserMutation = extendType({ 23 | type: "Mutation", 24 | definition(t) { 25 | t.crud.createOneUser({ shield: hasUserRole(UserRole.EDITOR)() }); 26 | t.crud.deleteOneUser({ shield: hasUserRole(UserRole.EDITOR)() }); 27 | t.crud.updateOneUser({ shield: hasUserRole(UserRole.EDITOR)() }); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "lib": [ 6 | "DOM", 7 | "ESNext" 8 | ], 9 | "esModuleInterop": true, 10 | "sourceMap": true, 11 | "declaration": true, 12 | "skipLibCheck": true, 13 | "outDir": "dist", 14 | "baseUrl": ".", 15 | "paths": { 16 | "@client/*": [ 17 | "client/*" 18 | ], 19 | "@providers/*": [ 20 | "providers/*" 21 | ], 22 | "@server/*": [ 23 | "server/*" 24 | ] 25 | }, 26 | "types": [ 27 | "@types/node" 28 | ] 29 | }, 30 | "exclude": [ 31 | "client", 32 | "dist", 33 | "node_modules" 34 | ] 35 | } 36 | --------------------------------------------------------------------------------