diff --git a/packages/create-bison-app/cypress/integration/createBisonAppWithDefaults.test.js b/packages/create-bison-app/cypress/integration/createBisonAppWithDefaults.test.js index ae12c9f7..3d2ec527 100644 --- a/packages/create-bison-app/cypress/integration/createBisonAppWithDefaults.test.js +++ b/packages/create-bison-app/cypress/integration/createBisonAppWithDefaults.test.js @@ -14,7 +14,7 @@ describe("Creating a new app", () => { describe("prisma .env", () => { it("contains the proper database URL", () => { cy.task("getAppName").then((appName) => { - cy.task("readProjectFile", "prisma/.env") + cy.task("readProjectFile", ".env") .should("contain", `postgresql://postgres@localhost:5432`) .should("contain", `${appName}_dev`); }); diff --git a/packages/create-bison-app/tasks/copyFiles.js b/packages/create-bison-app/tasks/copyFiles.js index 3181b9a3..61211ac3 100644 --- a/packages/create-bison-app/tasks/copyFiles.js +++ b/packages/create-bison-app/tasks/copyFiles.js @@ -38,6 +38,12 @@ async function copyFiles({ variables, targetFolder }) { copyWithTemplate(fromPath("README.md.ejs"), toPath("README.md"), variables), copyWithTemplate(fromPath("_.gitignore"), toPath(".gitignore"), variables), + copyWithTemplate( + fromPath("_.env.ejs"), + toPath(".env"), + variables + ), + copyWithTemplate( fromPath("_.env.local.ejs"), toPath(".env.local"), @@ -80,7 +86,6 @@ async function copyFiles({ variables, targetFolder }) { "layouts", "lib", "prisma", - "!prisma/_.env*", "public", "scripts", "services", diff --git a/packages/create-bison-app/template/prisma/_.env.ejs b/packages/create-bison-app/template/_.env.ejs similarity index 100% rename from packages/create-bison-app/template/prisma/_.env.ejs rename to packages/create-bison-app/template/_.env.ejs diff --git a/packages/create-bison-app/template/_.gitignore b/packages/create-bison-app/template/_.gitignore index 60ade8cd..eef14bbd 100644 --- a/packages/create-bison-app/template/_.gitignore +++ b/packages/create-bison-app/template/_.gitignore @@ -26,12 +26,13 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.env + # local env files .env.local .env.development.local .env.test.local .env.production.local -prisma/.env .vercel diff --git a/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs b/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs index 5bb213e6..b606263c 100644 --- a/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs +++ b/packages/create-bison-app/template/_templates/graphql/new/graphql.ejs @@ -3,7 +3,8 @@ to: graphql/modules/<%= name %>.ts --- <% camelized = h.inflection.camelize(name) -%> <% plural = h.inflection.pluralize(camelized) -%> -import { objectType, extendType } from 'nexus'; +import { objectType, extendType, inputObjectType, stringArg, arg, nonNull, enumType } from 'nexus'; +import { Role } from '@prisma/client'; import { UserInputError, /*ForbiddenError*/ } from 'apollo-server-micro'; // import { isAdmin } from '../services/permissions'; @@ -13,47 +14,34 @@ export const <%= camelized %> = objectType({ name: '<%= camelized %>', description: 'A <%= camelized %>', definition(t) { - t.model.id(); - t.model.createdAt(); - t.model.updatedAt(); + t.nonNull.id('id'); + t.nonNull.date('createdAt'); + t.nonNull.date('updatedAt'); }, }); -/* -// Enums -export const CallPreference = enumType({ - name: 'CallPreference', - members: ['WEEKDAY', 'WEEKEND', 'WEEKNIGHT'], -}); - // Queries export const <%= camelized %>Queries = extendType({ type: 'Query', definition: (t) => { - // List <%= plural %> Query (admin only) - t.crud.<%= plural.toLowerCase() %>({ - filtering: true, - ordering: true, - // use resolve for permission checks or to remove fields - resolve: async (root, args, ctx, info, originalResolve) => { - if (!isAdmin(ctx.user)) throw new ForbiddenError('Unauthorized'); + // List <%= plural %> Query + t.list.field('<%= plural.toLowerCase() %>', { + type: '<%= camelized %>', + authorize: (_root, _args, ctx) => !!ctx.user, + args: nonNull(arg({ type: 'SomethingQueryInput' })), + description: 'Returns available <%= plural.toLowerCase() %>', + }) - return await originalResolve(root, args, ctx, info); + // single query + t.field('something', { + type: '<%= camelized %>', + description: 'Returns a specific <%= camelized %>', + authorize: (_root, _args, ctx) => !!ctx.user, + args: nonNull(arg({ type: 'SomethingQueryInput' })), + resolve: (_root, args, ctx) => { + // TODO }, }); - - // Custom Query - t.field('me', { - type: 'User', - description: 'Returns the currently logged in user', - nullable: true, - resolve: (_root, _args, ctx) => ctx.user, - }); - - t.list.field('availabilityForUser', { - type: 'Event', - description: 'Returns available time slots to schedule calls with an expert', - }) }, }); @@ -61,6 +49,60 @@ export const <%= camelized %>Queries = extendType({ export const <%= camelized %>Mutations = extendType({ type: 'Mutation', definition: (t) => { + t.field('somethingMutation', { + type: 'String', + description: 'Does something', + args: { + data: nonNull(arg({ type: 'SomethingMutationInput' })), + }, + authorize: (_root, _args, ctx) => !!ctx.user, + resolve: async (_root, args, ctx) => { + console.log(args.data.hello) + return args.data.hello + } + }); }, }); -*/ \ No newline at end of file + +// Inputs +export const SomethingMutationInput = inputObjectType({ + name: 'SomethingMutationInput', + description: 'Input used to do something', + definition: (t) => { + t.nonNull.string('hello'); + }, +}) + +export const SomethingQueryInput = inputObjectType({ + name: 'SomethingQueryInput', + description: 'Input used to do something', + definition: (t) => { + t.nonNull.string('hello'); + }, +}); + +export const <%= camelized %>OrderByInput = inputObjectType({ + name: '<%= camelized %>OrderByInput', + description: 'Order <%= camelized.toLowerCase() %> by a specific field', + definition(t) { + t.field('hello', { type: 'SortOrder' }); + }, +}); + +export const UserWhereUniqueInput = inputObjectType({ + name: 'UserWhereUniqueInput', + description: 'Input to find users based on unique fields', + definition(t) { + t.id('id'); + t.email('email'); + }, +}); + +export const UserWhereInput = inputObjectType({ + name: 'UserWhereInput', + description: 'Input to find users based other fields', + definition(t) { + t.int('id'); + t.field('email', { type: 'StringFilter' }); + }, +}); \ No newline at end of file diff --git a/packages/create-bison-app/template/graphql/modules/index.ts b/packages/create-bison-app/template/graphql/modules/index.ts index 1d151a80..899cb199 100644 --- a/packages/create-bison-app/template/graphql/modules/index.ts +++ b/packages/create-bison-app/template/graphql/modules/index.ts @@ -1,3 +1,4 @@ export * from './scalars'; export * from './user'; export * from './profile'; +export * from './shared' diff --git a/packages/create-bison-app/template/graphql/modules/profile.ts b/packages/create-bison-app/template/graphql/modules/profile.ts index 0ef5a5b8..16bcc581 100644 --- a/packages/create-bison-app/template/graphql/modules/profile.ts +++ b/packages/create-bison-app/template/graphql/modules/profile.ts @@ -5,15 +5,21 @@ export const Profile = objectType({ name: 'Profile', description: 'A User Profile', definition(t) { - t.model.id(); - t.model.firstName(); - t.model.lastName(); - t.model.createdAt(); - t.model.updatedAt(); - t.model.user(); + t.nonNull.id('id'); + t.nonNull.date('createdAt'); + t.nonNull.date('updatedAt'); + t.nonNull.string('firstName'); + t.nonNull.string('lastName'); + t.nonNull.field('user', { + type: 'User', + resolve: (parent, _, context) => { + return context.prisma.profile.findUnique({ + where: { id: parent.id } + }).user() + } + }) t.string('fullName', { - nullable: true, description: 'The first and last name of a user', resolve({ firstName, lastName }) { return [firstName, lastName].filter((n) => Boolean(n)).join(' '); diff --git a/packages/create-bison-app/template/graphql/modules/shared.ts b/packages/create-bison-app/template/graphql/modules/shared.ts new file mode 100644 index 00000000..0e274cb4 --- /dev/null +++ b/packages/create-bison-app/template/graphql/modules/shared.ts @@ -0,0 +1,25 @@ +import { enumType, inputObjectType } from 'nexus'; + +// the following items are migrated from the prisma plugin +export const SortOrder = enumType({ + name: 'SortOrder', + description: 'Sort direction for filtering queries (ascending or descending)', + members: ['asc', 'desc'], +}); + +export const StringFilter = inputObjectType({ + name: 'StringFilter', + description: 'A way to filter string fields. Meant to pass to prisma where clause', + definition(t) { + t.string('contains'); + t.string('endsWith'); + t.string('equals'); + t.string('gt'); + t.string('gte'); + t.list.nonNull.string('in'); + t.string('lt'); + t.string('lte'); + t.list.nonNull.string('notIn'); + t.string('startsWith'); + }, +}); diff --git a/packages/create-bison-app/template/graphql/modules/user.ts.ejs b/packages/create-bison-app/template/graphql/modules/user.ts.ejs index 2178cfec..77891f42 100644 --- a/packages/create-bison-app/template/graphql/modules/user.ts.ejs +++ b/packages/create-bison-app/template/graphql/modules/user.ts.ejs @@ -1,11 +1,6 @@ -import { objectType, extendType, inputObjectType, stringArg, arg } from 'nexus'; +import { objectType, extendType, inputObjectType, stringArg, arg, nonNull, enumType } from 'nexus'; import { Role } from '@prisma/client'; -<% if (host.name === 'vercel') { -%> import { UserInputError, ForbiddenError } from 'apollo-server-micro'; -<% } -%> -<% if (host.name === 'heroku') { -%> -import { UserInputError, ForbiddenError } from 'apollo-server-express'; -<% } -%> import { hashPassword, appJwtForUser, comparePasswords } from '../../services/auth'; import { isAdmin, canAccess } from '../../services/permissions'; @@ -15,18 +10,24 @@ export const User = objectType({ name: 'User', description: 'A User', definition(t) { - t.model.id(); - t.model.email(); - t.model.roles(); - t.model.createdAt(); - t.model.updatedAt(); - t.model.profile(); + t.nonNull.id('id'); + t.nonNull.date('createdAt'); + t.nonNull.date('updatedAt'); + t.nonNull.list.nonNull.field('roles', { type: 'Role' }); // Show email as null for unauthorized users t.string('email', { - nullable: true, resolve: (profile, _args, ctx) => (canAccess(profile, ctx) ? profile.email : null), }); + + t.field('profile', { + type: 'Profile', + resolve: (parent, _, context) => { + return context.prisma.user.findUnique({ + where: { id: parent.id } + }).profile() + } + }) }, }); @@ -50,23 +51,8 @@ export const UserQueries = extendType({ t.field('me', { type: 'User', description: 'Returns the currently logged in user', - nullable: true, resolve: (_root, _args, ctx) => ctx.user, }); - - // List Users Query (admin only) - t.crud.users({ - filtering: true, - ordering: true, - resolve: async (root, args, ctx, info, originalResolve) => { - if (!isAdmin(ctx.user)) throw new ForbiddenError('Unauthorized'); - - return await originalResolve(root, args, ctx, info); - }, - }); - - // User Query - t.crud.user(); }, }); @@ -79,8 +65,8 @@ export const Mutations = extendType({ type: 'AuthPayload', description: 'Login to an existing account', args: { - email: stringArg({ required: true }), - password: stringArg({ required: true }), + email: nonNull(stringArg()), + password: nonNull(stringArg()), }, resolve: async (_root, args, ctx) => { const { email, password } = args; @@ -109,42 +95,15 @@ export const Mutations = extendType({ }, }); - // User Create (admin only) - t.crud.createOneUser({ - alias: 'createUser', - resolve: async (root, args, ctx, info, originalResolve) => { - if (!isAdmin(ctx.user)) { - throw new ForbiddenError('Not authorized'); - } - - const user = await ctx.db.user.findUnique({ where: { email: args.data.email } }); - - if (user) { - throw new UserInputError('Email already exists.', { - invalidArgs: { email: 'already exists' }, - }); - } - - // force role to user and hash the password - const updatedArgs = { - data: { - ...args.data, - password: hashPassword(args.data.password), - }, - }; - - return originalResolve(root, updatedArgs, ctx, info); - }, - }); - t.field('signup', { type: 'AuthPayload', description: 'Signup for an account', args: { - data: arg({ type: 'SignupInput', required: true }), + data: nonNull(arg({ type: 'SignupInput' })), }, resolve: async (_root, args, ctx) => { - const existingUser = await ctx.db.user.findUnique({ where: { email: args.data.email } }); + const { data } = args; + const existingUser = await ctx.db.user.findUnique({ where: { email: data.email } }); if (existingUser) { throw new UserInputError('Email already exists.', { @@ -155,9 +114,9 @@ export const Mutations = extendType({ // force role to user and hash the password const updatedArgs = { data: { - ...args.data, + ...data, roles: { set: [Role.USER] }, - password: hashPassword(args.data.password), + password: hashPassword(data.password), }, }; @@ -173,11 +132,46 @@ export const Mutations = extendType({ }, }); +// Inputs export const SignupInput = inputObjectType({ name: 'SignupInput', + description: 'Input required for a user to signup', definition: (t) => { - t.string('email', { required: true }); - t.string('password', { required: true }); - t.field('profile', { type: 'ProfileCreateNestedOneWithoutUserInput' }); + t.nonNull.string('email'); + t.nonNull.string('password'); + }, +}); + +// Manually added types that were previously in the prisma plugin +export const UserRole = enumType({ + name: 'Role', + members: Object.values(Role), +}); + +export const UserOrderByInput = inputObjectType({ + name: 'UserOrderByInput', + description: 'Order users by a specific field', + definition(t) { + t.field('email', { type: 'SortOrder' }); + t.field('createdAt', { type: 'SortOrder' }); + t.field('updatedAt', { type: 'SortOrder' }); }, }); + +export const UserWhereUniqueInput = inputObjectType({ + name: 'UserWhereUniqueInput', + description: 'Input to find users based on unique fields', + definition(t) { + t.id('id'); + t.string('email'); + }, +}); + +export const UserWhereInput = inputObjectType({ + name: 'UserWhereInput', + description: 'Input to find users based other fields', + definition(t) { + t.int('id'); + t.field('email', { type: 'StringFilter' }); + }, +}); \ No newline at end of file diff --git a/packages/create-bison-app/template/graphql/schema.ts b/packages/create-bison-app/template/graphql/schema.ts index 90e1532a..022b36c1 100644 --- a/packages/create-bison-app/template/graphql/schema.ts +++ b/packages/create-bison-app/template/graphql/schema.ts @@ -1,7 +1,6 @@ import path from 'path'; import { declarativeWrappingPlugin, fieldAuthorizePlugin, makeSchema } from 'nexus'; -import { nexusPrisma } from 'nexus-plugin-prisma'; import prettierConfig from '../prettier.config'; @@ -11,32 +10,20 @@ const currentDirectory = process.cwd(); export const schema = makeSchema({ types, - plugins: [ - fieldAuthorizePlugin(), - declarativeWrappingPlugin(), - nexusPrisma({ - experimentalCRUD: true, - outputs: { - typegen: path.join( - currentDirectory, - 'node_modules/@types/typegen-nexus-plugin-prisma/index.d.ts' - ), - }, - }), - ], + plugins: [fieldAuthorizePlugin(), declarativeWrappingPlugin()], outputs: { schema: path.join(currentDirectory, 'api.graphql'), - typegen: path.join(currentDirectory, 'node_modules/@types/nexus-typegen/index.d.ts'), + typegen: path.join(currentDirectory, 'types', 'nexus.d.ts'), + }, + contextType: { + module: path.join(currentDirectory, 'graphql', 'context.ts'), + export: 'Context', }, sourceTypes: { modules: [ { - module: path.join(currentDirectory, 'node_modules/.prisma/client/index.d.ts'), - alias: 'db', - }, - { - module: path.join(currentDirectory, 'graphql', 'context.ts'), - alias: 'ContextModule', + module: '.prisma/client', + alias: 'PrismaClient', }, ], }, diff --git a/packages/create-bison-app/template/package.json.ejs b/packages/create-bison-app/template/package.json.ejs index e65d7dd4..2ad82194 100644 --- a/packages/create-bison-app/template/package.json.ejs +++ b/packages/create-bison-app/template/package.json.ejs @@ -12,11 +12,12 @@ "build:nexus": "NODE_ENV=development ts-node -P tsconfig.cjs.json --transpile-only graphql/schema.ts", "cypress:open": "cypress open", "cypress:run": "cypress run", - "db:migrate": "yarn -s prisma migrate dev && yarn build:prisma", - "db:deploy": "yarn -s prisma migrate deploy && yarn build:prisma", - "db:reset": "yarn prisma migrate reset && yarn build:prisma", - "db:setup": "yarn db:migrate && yarn prisma generate", - "db:drop": "DOTENV_CONFIG_PATH=./prisma/.env ts-node -r dotenv/config ./scripts/dropDatabase", + "db:migrate": "prisma migrate dev", + "db:migrate:prod": "prisma migrate deploy", + "db:deploy": "prisma deploy", + "db:reset": "prisma migrate reset", + "db:seed": "prisma db seed --preview-feature", + "db:setup": "yarn db:reset", "dev": "concurrently -n \"WATCHERS,NEXT\" -c \"black.bgYellow.dim,black.bgCyan.dim\" \"yarn watch:all\" \"next dev\"", "dev:typecheck": "tsc --noEmit", "g:cell": "hygen cell new --name", @@ -45,7 +46,7 @@ "@apollo/client": "^3.0.2", "@chakra-ui/react": "1.3.4", "@chakra-ui/theme": "1.7.1", - "@prisma/client": "^2.19.0", + "@prisma/client": "^2.22.1", "apollo-server-micro": "^2.18.1", "bcryptjs": "^2.4.3", "cross-fetch": "3.0.5", @@ -58,7 +59,6 @@ "jsonwebtoken": "^8.5.1", "next": "9.5.3", "nexus": "^1.0.0", - "nexus-plugin-prisma": "^0.33.0", "react": "16.13.1", "react-dom": "16.13.1", "react-hook-form": "^6.1.0", @@ -99,7 +99,7 @@ "nanoid": "^3.1.10", "pg": "^8.3.0", "prettier": "^2.0.5", - "prisma": "^2.19.0", + "prisma": "^2.22.1", "start-server-and-test": "^1.11.2", "supertest": "^4.0.2", "ts-jest": "^26.1.3", diff --git a/packages/create-bison-app/template/prisma/seed.ts b/packages/create-bison-app/template/prisma/seed.ts new file mode 100644 index 00000000..7e3c3c35 --- /dev/null +++ b/packages/create-bison-app/template/prisma/seed.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +async function main() { + // TODO: seeds + console.log('no seeds yet!'); +} + +main() + .catch((e) => console.error(e)) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/create-bison-app/template/prisma/seeds.js b/packages/create-bison-app/template/prisma/seeds.js deleted file mode 100644 index d35e2fca..00000000 --- a/packages/create-bison-app/template/prisma/seeds.js +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable no-console */ -const { PrismaClient } = require('@prisma/client'); -const dotenv = require('dotenv'); - -dotenv.config(); -const db = new PrismaClient(); - -async function main() { - // Seed data is database data that needs to exist for your app to run. - // Ideally this file should be idempotent: running it multiple times - // will result in the same database state (usually by checking for the - // existence of a record before trying to create it). For example: - // - // const existing = await db.user.findMany({ where: { email: 'admin@email.com' }}) - // if (!existing.length) { - // await db.user.create({ data: { name: 'Admin', email: 'admin@email.com' }}) - // } - - console.info('No data to seed. See prisma/seeds.js for info.'); -} - -main() - .catch((e) => console.error(e)) - .finally(async () => { - await db.$disconnect(); - }); diff --git a/packages/create-bison-app/template/scripts/buildProd.ts b/packages/create-bison-app/template/scripts/buildProd.ts index cb0d9c42..077410da 100644 --- a/packages/create-bison-app/template/scripts/buildProd.ts +++ b/packages/create-bison-app/template/scripts/buildProd.ts @@ -1,7 +1,6 @@ export {}; const spawn = require('child_process').spawn; -const bisonConfig = require('../package.json').bison; const DEFAULT_BUILD_COMMAND = `yarn build:nexus && yarn build:prisma && yarn build:next`; /** @@ -9,22 +8,11 @@ const DEFAULT_BUILD_COMMAND = `yarn build:nexus && yarn build:prisma && yarn bui * if the current branch should be migrated, it runs migrations first. */ function buildProd() { - const { staging, production } = bisonConfig.branches; - const branchesToMigrate = new RegExp(`${staging}|${production}`); - const currentBranch = process.env.BRANCH || process.env.VERCEL_GITHUB_COMMIT_REF; - const shouldMigrate = branchesToMigrate.test(currentBranch); - - console.log('--------------------------------------------------------------'); - console.log('Determining if we should migrate the database...'); - console.log('branches to migrate:', branchesToMigrate); - console.log('current branch name:', currentBranch || '(unknown)'); - console.log(shouldMigrate ? `${currentBranch} detected. Migrating.` : `Not running migrations.`); - console.log('--------------------------------------------------------------'); - let buildCommand = DEFAULT_BUILD_COMMAND; + const shouldMigrate = process.env.NODE_ENV === 'production'; if (shouldMigrate) { - buildCommand = `yarn db:migrate && ${buildCommand}`; + buildCommand = `yarn db:deploy && ${buildCommand}`; } const child = spawn(buildCommand, { diff --git a/packages/create-bison-app/test/tasks/copyFiles.test.js b/packages/create-bison-app/test/tasks/copyFiles.test.js index fe8fb72d..e4c7122c 100644 --- a/packages/create-bison-app/test/tasks/copyFiles.test.js +++ b/packages/create-bison-app/test/tasks/copyFiles.test.js @@ -132,7 +132,7 @@ describe("copyFiles", () => { }); it("copies the prisma folder", async () => { - const files = ["migrations", "schema.prisma", "seeds.js"]; + const files = ["migrations", "schema.prisma", "seed.ts"]; files.forEach((file) => { const filePath = path.join(targetFolder, "prisma", file); @@ -141,8 +141,8 @@ describe("copyFiles", () => { }); }); - it("copies prisma/env with the correct contents", async () => { - const target = path.join(targetFolder, "prisma", ".env"); + it("copies env with the correct contents", async () => { + const target = path.join(targetFolder, ".env"); const file = await fs.promises.readFile(target); const fileString = file.toString(); const { user, password, host, port, name } = variables.db.dev;