├── .env ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── docker-compose.yml ├── next-env.d.ts ├── package.json ├── prisma ├── .env ├── migrations │ ├── 20201110101443 │ │ ├── README.md │ │ ├── schema.prisma │ │ └── steps.json │ └── migrate.lock └── schema.prisma ├── public ├── favicon.ico └── vercel.svg ├── src ├── components │ ├── Navbar.tsx │ ├── forms │ │ ├── CreateUserForm.tsx │ │ └── LoginForm.tsx │ └── layouts │ │ └── AuthLayout.tsx ├── constants.ts ├── interfaces │ └── user.ts ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ └── me.ts │ │ ├── socketio.ts │ │ └── user │ │ │ ├── create.ts │ │ │ └── get.ts │ └── index.tsx ├── styles │ ├── Home.module.css │ └── globals.css ├── types │ ├── next.ts │ └── user.ts └── utils │ ├── cache.ts │ ├── cookie.ts │ ├── format.ts │ ├── hooks │ └── useQuerySocket.ts │ ├── http.ts │ ├── validation.ts │ └── validators │ └── user.ts ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # POSTGRES DB 2 | POSTGRES_USER="postgres" 3 | POSTGRES_PASSWORD="postgres" 4 | POSTGRES_DB="typegraphql-example" 5 | 6 | # PRISMA CONNECTION 7 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/typegraphql-example?schema=public" 8 | 9 | # JWT 10 | JWT_AUTH_SECRET = "oiNOI9bW7876tr5r5dfgjhbkhvcc5uio" 11 | JWT_REFRESH_SECRET = "8olkjfq44lwke4fj83lbkj5h3j5kjf65" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | #VS Code 37 | /.vscode -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query real-time refetch example
with Socket.io (v3) and Next.js API Routes (v10.0.x) 2 | 3 | ## NEW UPDATE 4 | 5 | This boilerplate now implements **Socket.io** in **Next.js API routes** to give RT functionalities to **React Query**. 6 | 7 | - A custom **useQuerySocket** hook takes care to subscribe to a socket.io event name with the same value of the **cache key**. 8 | - **On the server**, notifications are emitted by the REST method when a new user has been created. 9 | - **On the client**, the hook is listening for any message emitted by the server, performing a new **refetch** for all the queries that are using the same cache key. 10 | 11 | **TRY IT IN ACTION:** 12 | 13 | 1. Open two different browser windows and register a new user in one of them. 14 | 15 | 2. The second browser window will refresh user's list in **real-time** 16 | 17 | ## What is it? 18 | 19 | This is an experimental full-stack boilerplate which puts together **Next.js** v10.0.1, **API routes** and **Prisma v2** as backend + **Yup** schema for server-side validation and **Chakra UI** + **react-query** on the frontend. 20 | 21 | Prisma v2 connects to a **PostgreSQL** database provided by the docker-compose file 22 | 23 | ## Getting Started 24 | 25 | Clone this repo and take care to have **docker-compose** installed and **Docker** daemon up and running in your DEV machine. 26 | 27 | Then: 28 | 29 | ```bash 30 | # build and run Postgres db 31 | docker-compose up --build 32 | 33 | # install dependencies 34 | yarn install 35 | 36 | # migrate Prisma schema to db 37 | yarn prisma migrate up --experimental 38 | 39 | # generate Prisma client 40 | yarn prisma generate 41 | 42 | # run dev server 43 | yarn dev 44 | ``` 45 | 46 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | # next-graphql: 4 | # container_name: next_graphql 5 | # depends_on: 6 | # - postgres 7 | # restart: always 8 | # build: . 9 | # ports: 10 | # - "3000:3000" 11 | # environment: 12 | # - DB_URL=mongodb://mongo_db 13 | # - GRAPHQL_URI=http://localhost:3000/api/graphql-data 14 | # volumes: 15 | # - .:/usr/src/app 16 | # - /usr/src/app/.next 17 | # - /usr/src/app/node_modules 18 | # redis: 19 | # image: redis:alpine 20 | # restart: always 21 | # command: redis-server --requirepass redis_password 22 | # volumes: 23 | # - redis_data:/var/lib/redis/data 24 | # # - redis_data/redis.conf:/var/lib/redis/data/redis.conf 25 | # environment: 26 | # REDIS_REPLICATION_MODE: master 27 | # ports: 28 | # - 6379:6379 29 | postgres: 30 | container_name: postgres 31 | image: postgres:12.4-alpine 32 | restart: always 33 | env_file: 34 | - .env 35 | volumes: 36 | - db_data:/var/lib/postgresql/data 37 | ports: 38 | - 5432:5432 39 | # networks: 40 | # - backend 41 | volumes: 42 | db_data: 43 | # redis_data: 44 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next10-api-routes-prisma", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@chakra-ui/core": "^1.0.0-rc.8", 12 | "@hookform/resolvers": "^1.0.1", 13 | "@prisma/client": "^2.10.2", 14 | "bcryptjs": "^2.4.3", 15 | "cookie": "^0.4.1", 16 | "framer-motion": "^2.9.4", 17 | "jsonwebtoken": "^8.5.1", 18 | "lodash": "^4.17.20", 19 | "next": "10.0.1", 20 | "react": "17.0.1", 21 | "react-dom": "17.0.1", 22 | "react-hook-form": "^6.11.0", 23 | "react-query": "^2.26.2", 24 | "socket.io": "^3.0.1", 25 | "socket.io-client": "^3.0.1", 26 | "yup": "^0.29.3" 27 | }, 28 | "devDependencies": { 29 | "@prisma/cli": "^2.10.2", 30 | "@types/bcryptjs": "^2.4.2", 31 | "@types/cookie": "^0.4.0", 32 | "@types/jsonwebtoken": "^8.5.0", 33 | "@types/lodash": "^4.14.165", 34 | "@types/node": "^14.14.7", 35 | "@types/react": "^16.9.56", 36 | "@types/yup": "^0.29.9", 37 | "typescript": "^4.0.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /prisma/.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | # DATABASE_URL="postgresql://postgres:postgres@localhost:5432/typegraphql-example?schema=public" -------------------------------------------------------------------------------- /prisma/migrations/20201110101443/README.md: -------------------------------------------------------------------------------- 1 | # Migration `20201110101443` 2 | 3 | This migration has been generated by redbaron76 at 11/10/2020, 11:14:43 AM. 4 | You can check out the [state of the schema](./schema.prisma) after the migration. 5 | 6 | ## Database Steps 7 | 8 | ```sql 9 | CREATE TYPE "public"."Role" AS ENUM ('USER', 'PLAYER', 'SHOP', 'ADMIN') 10 | 11 | CREATE TABLE "public"."User" ( 12 | "id" SERIAL, 13 | "email" text NOT NULL , 14 | "firstName" text NOT NULL , 15 | "lastName" text NOT NULL , 16 | "password" text NOT NULL , 17 | "role" "Role" NOT NULL DEFAULT E'USER', 18 | "count" integer NOT NULL DEFAULT 0, 19 | PRIMARY KEY ("id") 20 | ) 21 | 22 | CREATE TABLE "public"."Profile" ( 23 | "id" SERIAL, 24 | "bio" text NOT NULL , 25 | "userId" integer NOT NULL , 26 | PRIMARY KEY ("id") 27 | ) 28 | 29 | CREATE TABLE "public"."Book" ( 30 | "id" SERIAL, 31 | "title" text NOT NULL , 32 | "userId" integer NOT NULL , 33 | PRIMARY KEY ("id") 34 | ) 35 | 36 | CREATE TABLE "public"."Category" ( 37 | "id" SERIAL, 38 | PRIMARY KEY ("id") 39 | ) 40 | 41 | CREATE TABLE "public"."_BookToCategory" ( 42 | "A" integer NOT NULL , 43 | "B" integer NOT NULL 44 | ) 45 | 46 | CREATE UNIQUE INDEX "User.email_unique" ON "public"."User"("email") 47 | 48 | CREATE UNIQUE INDEX "Profile_userId_unique" ON "public"."Profile"("userId") 49 | 50 | CREATE UNIQUE INDEX "_BookToCategory_AB_unique" ON "public"."_BookToCategory"("A", "B") 51 | 52 | CREATE INDEX "_BookToCategory_B_index" ON "public"."_BookToCategory"("B") 53 | 54 | ALTER TABLE "public"."Profile" ADD FOREIGN KEY("userId")REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE 55 | 56 | ALTER TABLE "public"."Book" ADD FOREIGN KEY("userId")REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE 57 | 58 | ALTER TABLE "public"."_BookToCategory" ADD FOREIGN KEY("A")REFERENCES "public"."Book"("id") ON DELETE CASCADE ON UPDATE CASCADE 59 | 60 | ALTER TABLE "public"."_BookToCategory" ADD FOREIGN KEY("B")REFERENCES "public"."Category"("id") ON DELETE CASCADE ON UPDATE CASCADE 61 | ``` 62 | 63 | ## Changes 64 | 65 | ```diff 66 | diff --git schema.prisma schema.prisma 67 | migration ..20201110101443 68 | --- datamodel.dml 69 | +++ datamodel.dml 70 | @@ -1,0 +1,50 @@ 71 | +// This is your Prisma schema file, 72 | +// learn more about it in the docs: https://pris.ly/d/prisma-schema 73 | + 74 | +datasource db { 75 | + provider = "postgresql" 76 | + url = "***" 77 | +} 78 | + 79 | +generator client { 80 | + provider = "prisma-client-js" 81 | +} 82 | + 83 | +enum Role { 84 | + USER 85 | + PLAYER 86 | + SHOP 87 | + ADMIN 88 | +} 89 | + 90 | +model User { 91 | + id Int @id @default(autoincrement()) 92 | + email String @unique 93 | + firstName String 94 | + lastName String 95 | + password String 96 | + role Role @default(USER) 97 | + count Int @default(0) 98 | + books Book[] 99 | + profile Profile 100 | +} 101 | + 102 | +model Profile { 103 | + id Int @id @default(autoincrement()) 104 | + bio String 105 | + user User @relation(fields: [userId], references: [id]) 106 | + userId Int 107 | +} 108 | + 109 | +model Book { 110 | + id Int @id @default(autoincrement()) 111 | + title String 112 | + author User @relation(fields: [userId], references: [id]) 113 | + userId Int 114 | + categories Category[] 115 | +} 116 | + 117 | +model Category { 118 | + id Int @id @default(autoincrement()) 119 | + books Book[] 120 | +} 121 | ``` 122 | 123 | 124 | -------------------------------------------------------------------------------- /prisma/migrations/20201110101443/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = "***" 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | enum Role { 14 | USER 15 | PLAYER 16 | SHOP 17 | ADMIN 18 | } 19 | 20 | model User { 21 | id Int @id @default(autoincrement()) 22 | email String @unique 23 | firstName String 24 | lastName String 25 | password String 26 | role Role @default(USER) 27 | count Int @default(0) 28 | books Book[] 29 | profile Profile 30 | } 31 | 32 | model Profile { 33 | id Int @id @default(autoincrement()) 34 | bio String 35 | user User @relation(fields: [userId], references: [id]) 36 | userId Int 37 | } 38 | 39 | model Book { 40 | id Int @id @default(autoincrement()) 41 | title String 42 | author User @relation(fields: [userId], references: [id]) 43 | userId Int 44 | categories Category[] 45 | } 46 | 47 | model Category { 48 | id Int @id @default(autoincrement()) 49 | books Book[] 50 | } 51 | -------------------------------------------------------------------------------- /prisma/migrations/20201110101443/steps.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.14-fixed", 3 | "steps": [ 4 | { 5 | "tag": "CreateEnum", 6 | "enum": "Role", 7 | "values": [ 8 | "USER", 9 | "PLAYER", 10 | "SHOP", 11 | "ADMIN" 12 | ] 13 | }, 14 | { 15 | "tag": "CreateSource", 16 | "source": "db" 17 | }, 18 | { 19 | "tag": "CreateArgument", 20 | "location": { 21 | "tag": "Source", 22 | "source": "db" 23 | }, 24 | "argument": "provider", 25 | "value": "\"postgresql\"" 26 | }, 27 | { 28 | "tag": "CreateArgument", 29 | "location": { 30 | "tag": "Source", 31 | "source": "db" 32 | }, 33 | "argument": "url", 34 | "value": "\"***\"" 35 | }, 36 | { 37 | "tag": "CreateModel", 38 | "model": "User" 39 | }, 40 | { 41 | "tag": "CreateField", 42 | "model": "User", 43 | "field": "id", 44 | "type": "Int", 45 | "arity": "Required" 46 | }, 47 | { 48 | "tag": "CreateDirective", 49 | "location": { 50 | "path": { 51 | "tag": "Field", 52 | "model": "User", 53 | "field": "id" 54 | }, 55 | "directive": "id" 56 | } 57 | }, 58 | { 59 | "tag": "CreateDirective", 60 | "location": { 61 | "path": { 62 | "tag": "Field", 63 | "model": "User", 64 | "field": "id" 65 | }, 66 | "directive": "default" 67 | } 68 | }, 69 | { 70 | "tag": "CreateArgument", 71 | "location": { 72 | "tag": "Directive", 73 | "path": { 74 | "tag": "Field", 75 | "model": "User", 76 | "field": "id" 77 | }, 78 | "directive": "default" 79 | }, 80 | "argument": "", 81 | "value": "autoincrement()" 82 | }, 83 | { 84 | "tag": "CreateField", 85 | "model": "User", 86 | "field": "email", 87 | "type": "String", 88 | "arity": "Required" 89 | }, 90 | { 91 | "tag": "CreateDirective", 92 | "location": { 93 | "path": { 94 | "tag": "Field", 95 | "model": "User", 96 | "field": "email" 97 | }, 98 | "directive": "unique" 99 | } 100 | }, 101 | { 102 | "tag": "CreateField", 103 | "model": "User", 104 | "field": "firstName", 105 | "type": "String", 106 | "arity": "Required" 107 | }, 108 | { 109 | "tag": "CreateField", 110 | "model": "User", 111 | "field": "lastName", 112 | "type": "String", 113 | "arity": "Required" 114 | }, 115 | { 116 | "tag": "CreateField", 117 | "model": "User", 118 | "field": "password", 119 | "type": "String", 120 | "arity": "Required" 121 | }, 122 | { 123 | "tag": "CreateField", 124 | "model": "User", 125 | "field": "role", 126 | "type": "Role", 127 | "arity": "Required" 128 | }, 129 | { 130 | "tag": "CreateDirective", 131 | "location": { 132 | "path": { 133 | "tag": "Field", 134 | "model": "User", 135 | "field": "role" 136 | }, 137 | "directive": "default" 138 | } 139 | }, 140 | { 141 | "tag": "CreateArgument", 142 | "location": { 143 | "tag": "Directive", 144 | "path": { 145 | "tag": "Field", 146 | "model": "User", 147 | "field": "role" 148 | }, 149 | "directive": "default" 150 | }, 151 | "argument": "", 152 | "value": "USER" 153 | }, 154 | { 155 | "tag": "CreateField", 156 | "model": "User", 157 | "field": "count", 158 | "type": "Int", 159 | "arity": "Required" 160 | }, 161 | { 162 | "tag": "CreateDirective", 163 | "location": { 164 | "path": { 165 | "tag": "Field", 166 | "model": "User", 167 | "field": "count" 168 | }, 169 | "directive": "default" 170 | } 171 | }, 172 | { 173 | "tag": "CreateArgument", 174 | "location": { 175 | "tag": "Directive", 176 | "path": { 177 | "tag": "Field", 178 | "model": "User", 179 | "field": "count" 180 | }, 181 | "directive": "default" 182 | }, 183 | "argument": "", 184 | "value": "0" 185 | }, 186 | { 187 | "tag": "CreateField", 188 | "model": "User", 189 | "field": "books", 190 | "type": "Book", 191 | "arity": "List" 192 | }, 193 | { 194 | "tag": "CreateField", 195 | "model": "User", 196 | "field": "profile", 197 | "type": "Profile", 198 | "arity": "Required" 199 | }, 200 | { 201 | "tag": "CreateModel", 202 | "model": "Profile" 203 | }, 204 | { 205 | "tag": "CreateField", 206 | "model": "Profile", 207 | "field": "id", 208 | "type": "Int", 209 | "arity": "Required" 210 | }, 211 | { 212 | "tag": "CreateDirective", 213 | "location": { 214 | "path": { 215 | "tag": "Field", 216 | "model": "Profile", 217 | "field": "id" 218 | }, 219 | "directive": "id" 220 | } 221 | }, 222 | { 223 | "tag": "CreateDirective", 224 | "location": { 225 | "path": { 226 | "tag": "Field", 227 | "model": "Profile", 228 | "field": "id" 229 | }, 230 | "directive": "default" 231 | } 232 | }, 233 | { 234 | "tag": "CreateArgument", 235 | "location": { 236 | "tag": "Directive", 237 | "path": { 238 | "tag": "Field", 239 | "model": "Profile", 240 | "field": "id" 241 | }, 242 | "directive": "default" 243 | }, 244 | "argument": "", 245 | "value": "autoincrement()" 246 | }, 247 | { 248 | "tag": "CreateField", 249 | "model": "Profile", 250 | "field": "bio", 251 | "type": "String", 252 | "arity": "Required" 253 | }, 254 | { 255 | "tag": "CreateField", 256 | "model": "Profile", 257 | "field": "user", 258 | "type": "User", 259 | "arity": "Required" 260 | }, 261 | { 262 | "tag": "CreateDirective", 263 | "location": { 264 | "path": { 265 | "tag": "Field", 266 | "model": "Profile", 267 | "field": "user" 268 | }, 269 | "directive": "relation" 270 | } 271 | }, 272 | { 273 | "tag": "CreateArgument", 274 | "location": { 275 | "tag": "Directive", 276 | "path": { 277 | "tag": "Field", 278 | "model": "Profile", 279 | "field": "user" 280 | }, 281 | "directive": "relation" 282 | }, 283 | "argument": "fields", 284 | "value": "[userId]" 285 | }, 286 | { 287 | "tag": "CreateArgument", 288 | "location": { 289 | "tag": "Directive", 290 | "path": { 291 | "tag": "Field", 292 | "model": "Profile", 293 | "field": "user" 294 | }, 295 | "directive": "relation" 296 | }, 297 | "argument": "references", 298 | "value": "[id]" 299 | }, 300 | { 301 | "tag": "CreateField", 302 | "model": "Profile", 303 | "field": "userId", 304 | "type": "Int", 305 | "arity": "Required" 306 | }, 307 | { 308 | "tag": "CreateModel", 309 | "model": "Book" 310 | }, 311 | { 312 | "tag": "CreateField", 313 | "model": "Book", 314 | "field": "id", 315 | "type": "Int", 316 | "arity": "Required" 317 | }, 318 | { 319 | "tag": "CreateDirective", 320 | "location": { 321 | "path": { 322 | "tag": "Field", 323 | "model": "Book", 324 | "field": "id" 325 | }, 326 | "directive": "id" 327 | } 328 | }, 329 | { 330 | "tag": "CreateDirective", 331 | "location": { 332 | "path": { 333 | "tag": "Field", 334 | "model": "Book", 335 | "field": "id" 336 | }, 337 | "directive": "default" 338 | } 339 | }, 340 | { 341 | "tag": "CreateArgument", 342 | "location": { 343 | "tag": "Directive", 344 | "path": { 345 | "tag": "Field", 346 | "model": "Book", 347 | "field": "id" 348 | }, 349 | "directive": "default" 350 | }, 351 | "argument": "", 352 | "value": "autoincrement()" 353 | }, 354 | { 355 | "tag": "CreateField", 356 | "model": "Book", 357 | "field": "title", 358 | "type": "String", 359 | "arity": "Required" 360 | }, 361 | { 362 | "tag": "CreateField", 363 | "model": "Book", 364 | "field": "author", 365 | "type": "User", 366 | "arity": "Required" 367 | }, 368 | { 369 | "tag": "CreateDirective", 370 | "location": { 371 | "path": { 372 | "tag": "Field", 373 | "model": "Book", 374 | "field": "author" 375 | }, 376 | "directive": "relation" 377 | } 378 | }, 379 | { 380 | "tag": "CreateArgument", 381 | "location": { 382 | "tag": "Directive", 383 | "path": { 384 | "tag": "Field", 385 | "model": "Book", 386 | "field": "author" 387 | }, 388 | "directive": "relation" 389 | }, 390 | "argument": "fields", 391 | "value": "[userId]" 392 | }, 393 | { 394 | "tag": "CreateArgument", 395 | "location": { 396 | "tag": "Directive", 397 | "path": { 398 | "tag": "Field", 399 | "model": "Book", 400 | "field": "author" 401 | }, 402 | "directive": "relation" 403 | }, 404 | "argument": "references", 405 | "value": "[id]" 406 | }, 407 | { 408 | "tag": "CreateField", 409 | "model": "Book", 410 | "field": "userId", 411 | "type": "Int", 412 | "arity": "Required" 413 | }, 414 | { 415 | "tag": "CreateField", 416 | "model": "Book", 417 | "field": "categories", 418 | "type": "Category", 419 | "arity": "List" 420 | }, 421 | { 422 | "tag": "CreateModel", 423 | "model": "Category" 424 | }, 425 | { 426 | "tag": "CreateField", 427 | "model": "Category", 428 | "field": "id", 429 | "type": "Int", 430 | "arity": "Required" 431 | }, 432 | { 433 | "tag": "CreateDirective", 434 | "location": { 435 | "path": { 436 | "tag": "Field", 437 | "model": "Category", 438 | "field": "id" 439 | }, 440 | "directive": "id" 441 | } 442 | }, 443 | { 444 | "tag": "CreateDirective", 445 | "location": { 446 | "path": { 447 | "tag": "Field", 448 | "model": "Category", 449 | "field": "id" 450 | }, 451 | "directive": "default" 452 | } 453 | }, 454 | { 455 | "tag": "CreateArgument", 456 | "location": { 457 | "tag": "Directive", 458 | "path": { 459 | "tag": "Field", 460 | "model": "Category", 461 | "field": "id" 462 | }, 463 | "directive": "default" 464 | }, 465 | "argument": "", 466 | "value": "autoincrement()" 467 | }, 468 | { 469 | "tag": "CreateField", 470 | "model": "Category", 471 | "field": "books", 472 | "type": "Book", 473 | "arity": "List" 474 | } 475 | ] 476 | } -------------------------------------------------------------------------------- /prisma/migrations/migrate.lock: -------------------------------------------------------------------------------- 1 | # Prisma Migrate lockfile v1 2 | 3 | 20201110101443 -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | enum Role { 14 | USER 15 | PLAYER 16 | SHOP 17 | ADMIN 18 | } 19 | 20 | model User { 21 | id Int @id @default(autoincrement()) 22 | email String @unique 23 | firstName String 24 | lastName String 25 | password String 26 | role Role @default(USER) 27 | count Int @default(0) 28 | books Book[] 29 | profile Profile 30 | } 31 | 32 | model Profile { 33 | id Int @id @default(autoincrement()) 34 | bio String 35 | user User @relation(fields: [userId], references: [id]) 36 | userId Int 37 | } 38 | 39 | model Book { 40 | id Int @id @default(autoincrement()) 41 | title String 42 | author User @relation(fields: [userId], references: [id]) 43 | userId Int 44 | categories Category[] 45 | } 46 | 47 | model Category { 48 | id Int @id @default(autoincrement()) 49 | books Book[] 50 | } 51 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redbaron76/nextjs-api-routes-prisma-yup-chakra-ui/ee558f34616cb964b8946962d820ed1561a3734e/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useMutation, queryCache } from "react-query"; 3 | import { Box, Flex, Heading, Text, Link } from "@chakra-ui/core"; 4 | import { AuthContext } from "src/components/layouts/AuthLayout"; 5 | import { IAuthContext, IMeUser } from "src/interfaces/user"; 6 | import { HTTP } from "src/utils/http"; 7 | 8 | interface LogoutResponse { 9 | success: boolean; 10 | } 11 | 12 | const Navbar = (props: any) => { 13 | const { userLogged, userData, userId } = useContext(AuthContext); 14 | 15 | const logoutUser = async () => { 16 | const response = await HTTP("/api/auth/logout", "POST", { 17 | userId, 18 | }); 19 | return await response.json(); 20 | }; 21 | 22 | const [logout] = useMutation(logoutUser, { 23 | onSuccess: ({ success }) => { 24 | // reset AuthContext on logout 25 | if (success) return queryCache.setQueryData("me", null); 26 | }, 27 | }); 28 | 29 | return ( 30 | 40 | 41 | 42 | RT Demo App 43 | 44 | 45 | 46 | {userData?.email || "Please, signup!"} 47 | {userLogged && ( 48 | 49 | (logout) 50 | 51 | )} 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default Navbar; 58 | -------------------------------------------------------------------------------- /src/components/forms/CreateUserForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mutateNewData } from "src/utils/cache"; 3 | import { useForm } from "react-hook-form"; 4 | import { HTTP } from "src/utils/http"; 5 | import { setFormErrors } from "src/utils/validation"; 6 | import { useMutation, queryCache } from "react-query"; 7 | import { 8 | IUserCreate, 9 | IUserCreateErrors, 10 | IUserCreateResponse, 11 | } from "src/interfaces/user"; 12 | import { 13 | Box, 14 | Button, 15 | FormControl, 16 | FormErrorMessage, 17 | FormLabel, 18 | Heading, 19 | Input, 20 | } from "@chakra-ui/core"; 21 | 22 | const CreateUserForm: React.FC = () => { 23 | const createUser = async (newUser: IUserCreate) => { 24 | const response = await HTTP("/api/user/create", "POST", { 25 | create: newUser, 26 | }); 27 | return await response.json(); 28 | }; 29 | 30 | const [mutate, { isLoading }] = useMutation< 31 | IUserCreateResponse, 32 | IUserCreateErrors, 33 | IUserCreate, 34 | () => void 35 | >(createUser, { 36 | onMutate: (newUser) => mutateNewData("users", newUser), 37 | onError: (_errors, _newData, rollback) => rollback(), 38 | onSettled: () => queryCache.refetchQueries("users"), 39 | }); 40 | 41 | const { handleSubmit, register, reset, errors, setError } = useForm< 42 | IUserCreate 43 | >(); 44 | 45 | const onSubmit = async (input: IUserCreate) => { 46 | const data = await mutate(input); 47 | const { user, errors }: IUserCreateResponse = data; 48 | 49 | if (errors) return setFormErrors(errors, setError); 50 | 51 | reset(); 52 | return user; 53 | }; 54 | 55 | return ( 56 | <> 57 | 58 | Register 59 | 60 | 61 |
62 | 63 | First name 64 | 69 | 70 | {errors.firstName && errors.firstName.message} 71 | 72 | 73 | 74 | Last name 75 | 80 | 81 | {errors.lastName && errors.lastName.message} 82 | 83 | 84 | 85 | Email 86 | 92 | 93 | {errors.email && errors.email.message} 94 | 95 | 96 | 97 | Password 98 | 104 | 105 | {errors.password && errors.password.message} 106 | 107 | 108 | 109 | Confirm password 110 | 116 | 117 | {errors.confirmPassword && errors.confirmPassword.message} 118 | 119 | 120 | 123 |
124 |
125 | 126 | ); 127 | }; 128 | 129 | export default CreateUserForm; 130 | -------------------------------------------------------------------------------- /src/components/forms/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { useMutation, queryCache } from "react-query"; 4 | import { HTTP } from "src/utils/http"; 5 | import { 6 | IUserLogin, 7 | IUserLoginError, 8 | IUserLoginResponse, 9 | } from "src/interfaces/user"; 10 | import { 11 | Box, 12 | Button, 13 | FormControl, 14 | FormErrorMessage, 15 | FormLabel, 16 | Heading, 17 | Input, 18 | } from "@chakra-ui/core"; 19 | 20 | const LoginForm: React.FC = () => { 21 | const loginUser = async (loginUser: IUserLogin) => { 22 | const response = await HTTP("/api/auth/login", "POST", { 23 | login: loginUser, 24 | }); 25 | return await response.json(); 26 | }; 27 | 28 | const [mutate, { isLoading }] = useMutation< 29 | IUserLoginResponse, 30 | IUserLoginError, 31 | IUserLogin 32 | >(loginUser, { 33 | onSuccess: ({ user, error }) => { 34 | // update cache for "me" query 35 | if (user) return queryCache.setQueryData("me", user); 36 | // trigger error if not good login 37 | if (error) alert(error.message); 38 | }, 39 | }); 40 | 41 | const { handleSubmit, register, reset, errors, setError } = useForm< 42 | IUserLogin 43 | >(); 44 | 45 | const onSubmit = async (login: IUserLogin) => { 46 | await mutate(login); 47 | }; 48 | 49 | return ( 50 | <> 51 | 52 | Login 53 | 54 | 55 |
56 | 57 | Email 58 | 64 | 65 | {errors.email && errors.email.message} 66 | 67 | 68 | 69 | Password 70 | 76 | 77 | {errors.password && errors.password.message} 78 | 79 | 80 | 83 |
84 |
85 | 86 | ); 87 | }; 88 | 89 | export default LoginForm; 90 | -------------------------------------------------------------------------------- /src/components/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery } from "react-query"; 3 | import { Center, ChakraProvider, Spinner } from "@chakra-ui/core"; 4 | import { IAuthContext, IMeUser } from "src/interfaces/user"; 5 | import Navbar from "../Navbar"; 6 | import { User } from "@prisma/client"; 7 | import { HTTP } from "src/utils/http"; 8 | 9 | const initialContext: IAuthContext = { 10 | userLogged: false, 11 | }; 12 | 13 | export const AuthContext: React.Context = React.createContext( 14 | initialContext 15 | ); 16 | 17 | async function getLoggedUser(): Promise { 18 | const response = await HTTP("api/auth/me", "POST"); 19 | const { user }: { user: User | null } = await response.json(); 20 | return user; 21 | } 22 | 23 | const AuthLayout: React.FC = ({ children }) => { 24 | const { data, isLoading } = useQuery("me", getLoggedUser); 25 | return ( 26 | 27 | {isLoading ? ( 28 |
29 | 30 |
31 | ) : ( 32 | 39 | 40 | {children} 41 | 42 | )} 43 |
44 | ); 45 | }; 46 | 47 | export default AuthLayout; 48 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_TOKEN_NAME = "authToken"; 2 | export const AUTH_TOKEN_EXPIRE = 60 * 15; // 15m 3 | 4 | export const REFRESH_TOKEN_NAME = "refreshToken"; 5 | export const REFRESH_TOKEN_EXPIRE = 60 * 60 * 24 * 7; // 7d 6 | 7 | export const SUBSCR_NEW_USER = "SUBSCR_NEW_USER"; 8 | -------------------------------------------------------------------------------- /src/interfaces/user.ts: -------------------------------------------------------------------------------- 1 | import { Profile, Role } from "@prisma/client"; 2 | 3 | // USER 4 | 5 | export interface IMeUser { 6 | id: number; 7 | firstName: string; 8 | lastName: string; 9 | email: string; 10 | role: Role; 11 | profile?: Profile; 12 | } 13 | 14 | export interface IAuthContext { 15 | userId?: number | null; 16 | userData?: IMeUser | null; 17 | userLogged: boolean; 18 | } 19 | 20 | // LOGIN 21 | 22 | export interface IUserLogin { 23 | email: string; 24 | password: string; 25 | } 26 | 27 | export interface IUserLoginError { 28 | message: string; 29 | } 30 | 31 | export interface IUserLoginResponse { 32 | user?: IMeUser; 33 | error?: IUserLoginError; 34 | } 35 | 36 | // CREATE 37 | 38 | export interface IUserCreate { 39 | firstName: string; 40 | lastName: string; 41 | email: string; 42 | password: string; 43 | confirmPassword: string; 44 | } 45 | 46 | export interface IUserCreateErrors { 47 | firstName?: string; 48 | lastName?: string; 49 | email?: string; 50 | password?: string; 51 | confirmPassword?: string; 52 | } 53 | 54 | export interface IUserCreateResponse { 55 | user?: IUserCreate; 56 | value?: IUserCreate; 57 | errors?: IUserCreateErrors; 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import { AppProps } from "next/dist/next-server/lib/router/router"; 3 | 4 | import AuthLayout from "src/components/layouts/AuthLayout"; 5 | 6 | const App: NextPage = ({ Component, pageProps }) => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/pages/api/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import { setCookies } from "src/utils/cookie"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { IUserLogin } from "src/interfaces/user"; 7 | import { 8 | AUTH_TOKEN_NAME, 9 | REFRESH_TOKEN_NAME, 10 | AUTH_TOKEN_EXPIRE, 11 | REFRESH_TOKEN_EXPIRE, 12 | } from "src/constants"; 13 | import { reduceMeUser } from "src/utils/format"; 14 | 15 | export default async (req: NextApiRequest, res: NextApiResponse) => { 16 | if (req.method === "POST") { 17 | const prisma = new PrismaClient({ log: ["query", "info"] }); 18 | try { 19 | const { login: data }: { login: IUserLogin } = req.body; 20 | const user = await prisma.user.findOne({ 21 | where: { 22 | email: data.email, 23 | }, 24 | include: { 25 | profile: true, 26 | }, 27 | }); 28 | 29 | if (!user) throw new Error("User not valid"); 30 | 31 | const validPassword = await bcrypt.compare(data.password, user.password); 32 | if (!validPassword) throw new Error("Password not valid"); 33 | 34 | await setCookies(res, [ 35 | { 36 | tokenName: AUTH_TOKEN_NAME, 37 | payload: { userId: user.id }, 38 | expire: AUTH_TOKEN_EXPIRE, 39 | secret: process.env.JWT_AUTH_SECRET, 40 | }, 41 | { 42 | tokenName: REFRESH_TOKEN_NAME, 43 | payload: { userId: user.id, count: user.count }, 44 | expire: REFRESH_TOKEN_EXPIRE, 45 | secret: process.env.JWT_REFRESH_SECRET, 46 | }, 47 | ]); 48 | 49 | // return logged-in user 50 | res.status(200).json({ user: reduceMeUser(user) }); 51 | } catch (error) { 52 | // return error message 53 | res.status(200).json({ error: { message: error.message } }); 54 | } finally { 55 | // disconnect at the end 56 | await prisma.$disconnect(); 57 | } 58 | } 59 | res.status(405).end(); 60 | }; 61 | -------------------------------------------------------------------------------- /src/pages/api/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import { setCookies } from "src/utils/cookie"; 4 | 5 | import { AUTH_TOKEN_NAME } from "src/constants"; 6 | 7 | export default async (req: NextApiRequest, res: NextApiResponse) => { 8 | if (req.method === "POST") { 9 | const prisma = new PrismaClient({ log: ["query", "info"] }); 10 | try { 11 | const { userId }: { userId: number } = req.body; 12 | const user = await prisma.user.update({ 13 | where: { id: userId }, 14 | data: { 15 | count: { 16 | increment: 1, 17 | }, 18 | }, 19 | }); 20 | 21 | if (!user) throw new Error("User not found"); 22 | 23 | await setCookies(res, [ 24 | { 25 | tokenName: AUTH_TOKEN_NAME, 26 | expire: -1, 27 | }, 28 | ]); 29 | 30 | // return success as true 31 | res.status(200).json({ success: true }); 32 | } catch (error) { 33 | // return error as false 34 | res.status(200).json({ success: false }); 35 | } finally { 36 | // disconnect at the end 37 | await prisma.$disconnect(); 38 | } 39 | } 40 | res.status(405).end(); 41 | }; 42 | -------------------------------------------------------------------------------- /src/pages/api/auth/me.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import { setCookies } from "src/utils/cookie"; 4 | import { verify } from "jsonwebtoken"; 5 | import { isNumber } from "lodash"; 6 | import cookie from "cookie"; 7 | 8 | import { 9 | REFRESH_TOKEN_EXPIRE, 10 | AUTH_TOKEN_EXPIRE, 11 | AUTH_TOKEN_NAME, 12 | REFRESH_TOKEN_NAME, 13 | } from "src/constants"; 14 | 15 | interface Token { 16 | userId?: number; 17 | } 18 | 19 | interface RefreshToken extends Token { 20 | count?: number; 21 | } 22 | 23 | const getUserId = async (req: NextApiRequest): Promise => { 24 | // get parsed cookies from headers 25 | const parsedCookies = req && cookie.parse(req.headers.cookie ?? ""); 26 | 27 | if (parsedCookies) { 28 | const authToken = parsedCookies[AUTH_TOKEN_NAME]; 29 | const refreshToken = parsedCookies[REFRESH_TOKEN_NAME]; 30 | 31 | // exit with undefined if no tokens 32 | if (!refreshToken && !authToken) 33 | return { 34 | userId: null, 35 | }; 36 | 37 | try { 38 | const verifiedToken = verify( 39 | authToken, 40 | process.env.JWT_AUTH_SECRET 41 | ) as Token; 42 | 43 | // GOOD authToken -> set userId in context 44 | return { 45 | userId: verifiedToken && verifiedToken.userId, 46 | }; 47 | } catch { 48 | // authToken expired -> check refreshToken 49 | } 50 | 51 | // BAD refreshToken and no authToken -> exit with undefined 52 | if (!refreshToken) return; 53 | 54 | let verifiedToken: RefreshToken; 55 | 56 | try { 57 | verifiedToken = verify( 58 | refreshToken, 59 | process.env.JWT_REFRESH_SECRET 60 | ) as RefreshToken; 61 | } catch { 62 | return { 63 | userId: null, 64 | }; 65 | } 66 | 67 | // get saved items from refreshToken 68 | return verifiedToken; 69 | } 70 | 71 | return { 72 | userId: null, 73 | }; 74 | }; 75 | 76 | export default async (req: NextApiRequest, res: NextApiResponse) => { 77 | if (req.method === "POST") { 78 | const prisma = new PrismaClient({ log: ["query", "info"] }); 79 | const { userId, count } = await getUserId(req); 80 | 81 | if (!userId) { 82 | res.status(200).json({ user: null }); 83 | return; 84 | } 85 | 86 | let user = await prisma.user.findOne({ 87 | where: { 88 | id: userId, 89 | }, 90 | }); 91 | 92 | if (!user || (isNumber(count) && user.count !== count)) { 93 | res.status(200).json({ user: null }); 94 | return; 95 | } 96 | 97 | await setCookies(res, [ 98 | { 99 | tokenName: AUTH_TOKEN_NAME, 100 | payload: { userId: user.id }, 101 | expire: AUTH_TOKEN_EXPIRE, 102 | secret: process.env.JWT_AUTH_SECRET, 103 | }, 104 | { 105 | tokenName: REFRESH_TOKEN_NAME, 106 | payload: { userId: user.id, count: user.count }, 107 | expire: REFRESH_TOKEN_EXPIRE, 108 | secret: process.env.JWT_REFRESH_SECRET, 109 | }, 110 | ]); 111 | 112 | res.status(200).json({ user }); 113 | } 114 | res.status(405).end(); 115 | }; 116 | -------------------------------------------------------------------------------- /src/pages/api/socketio.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from "next"; 2 | import { NextApiResponseServerIO } from "src/types/next"; 3 | import { Server as ServerIO } from "socket.io"; 4 | import { Server as NetServer } from "http"; 5 | 6 | export const config = { 7 | api: { 8 | bodyParser: false, 9 | }, 10 | }; 11 | 12 | export default async (req: NextApiRequest, res: NextApiResponseServerIO) => { 13 | if (!res.socket.server.io) { 14 | console.log("New Socket.io server..."); 15 | // adapt Next's net Server to http Server 16 | const httpServer: NetServer = res.socket.server as any; 17 | const io = new ServerIO(httpServer, { 18 | path: "/api/socketio", 19 | }); 20 | // append SocketIO server to Next.js socket server response 21 | res.socket.server.io = io; 22 | } 23 | res.end(); 24 | }; 25 | -------------------------------------------------------------------------------- /src/pages/api/user/create.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { NextApiResponseServerIO } from "src/types/next"; 3 | import { PrismaClient } from "@prisma/client"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { IUserCreate } from "src/interfaces/user"; 7 | import { validUserCreate } from "src/utils/validators/user"; 8 | import { generateErrors } from "src/utils/validation"; 9 | 10 | export default async (req: NextApiRequest, res: NextApiResponseServerIO) => { 11 | if (req.method === "POST") { 12 | const prisma = new PrismaClient({ log: ["query", "info"] }); 13 | const { create: data }: { create: IUserCreate } = req.body; 14 | await validUserCreate 15 | .validate(data, { 16 | abortEarly: false, 17 | }) 18 | .then(async (valid) => { 19 | if (valid) { 20 | delete data.confirmPassword; 21 | try { 22 | data.password = await bcrypt.hash(data.password, 12); 23 | const newUser = await prisma.user.create({ 24 | data: { 25 | ...data, 26 | profile: { 27 | create: { 28 | bio: "", 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | // Trigger event in useQuerySocket 35 | res.socket.server.io.emit("users", newUser); 36 | 37 | res.status(201).json({ data }); 38 | } catch (err) { 39 | console.log("ERR", err); 40 | res.status(400).end(); 41 | } 42 | } 43 | }) 44 | .catch(({ value, inner }) => { 45 | res.status(200).json({ value, errors: generateErrors(inner) }); 46 | }) 47 | .finally(async () => { 48 | await prisma.$disconnect(); 49 | }); 50 | } 51 | res.status(405).end(); 52 | }; 53 | -------------------------------------------------------------------------------- /src/pages/api/user/get.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | export default async (req: NextApiRequest, res: NextApiResponse) => { 5 | if (req.method === "GET") { 6 | const prisma = new PrismaClient({ log: ["query", "info"] }); 7 | const users = await prisma.user.findMany({ 8 | orderBy: { 9 | id: "asc", 10 | }, 11 | }); 12 | 13 | res.status(200).json({ users }); 14 | } 15 | res.status(405).end(); 16 | }; 17 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import Head from "next/head"; 3 | import { AuthContext } from "src/components/layouts/AuthLayout"; 4 | import { Box, Flex, UnorderedList, ListItem, Heading } from "@chakra-ui/core"; 5 | import CreateUserForm from "src/components/forms/CreateUserForm"; 6 | import useQuerySocket from "src/utils/hooks/useQuerySocket"; 7 | import LoginForm from "src/components/forms/LoginForm"; 8 | import { User } from "@prisma/client"; 9 | import { HTTP } from "src/utils/http"; 10 | 11 | async function fetchUsers(): Promise { 12 | const response = await HTTP("/api/user/get", "GET"); 13 | const { users }: { users: User[] | [] } = await response.json(); 14 | return users; 15 | } 16 | 17 | export default function Home() { 18 | const { userLogged } = useContext(AuthContext); 19 | 20 | // Fetch users and keep track of changes in "users" in real-time 21 | const { data: users, isLoading } = useQuerySocket( 22 | "users", 23 | fetchUsers 24 | ); 25 | 26 | if (!users || isLoading) return Loading...; 27 | 28 | return ( 29 | 30 | 31 | App Demo ChakraUI 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Users 42 | 43 | 44 | {users.map((user) => ( 45 | {`${user.firstName} ${user.lastName} (${user.email})`} 48 | ))} 49 | 50 | 51 | {!userLogged && ( 52 | 53 | 54 | 55 | )} 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .main { 11 | padding: 5rem 0; 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | .footer { 20 | width: 100%; 21 | height: 100px; 22 | border-top: 1px solid #eaeaea; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | 28 | .footer img { 29 | margin-left: 0.5rem; 30 | } 31 | 32 | .footer a { 33 | display: flex; 34 | justify-content: center; 35 | align-items: center; 36 | } 37 | 38 | .title a { 39 | color: #0070f3; 40 | text-decoration: none; 41 | } 42 | 43 | .title a:hover, 44 | .title a:focus, 45 | .title a:active { 46 | text-decoration: underline; 47 | } 48 | 49 | .title { 50 | margin: 0; 51 | line-height: 1.15; 52 | font-size: 4rem; 53 | } 54 | 55 | .title, 56 | .description { 57 | text-align: center; 58 | } 59 | 60 | .description { 61 | line-height: 1.5; 62 | font-size: 1.5rem; 63 | } 64 | 65 | .code { 66 | background: #fafafa; 67 | border-radius: 5px; 68 | padding: 0.75rem; 69 | font-size: 1.1rem; 70 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 71 | Bitstream Vera Sans Mono, Courier New, monospace; 72 | } 73 | 74 | .grid { 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | flex-wrap: wrap; 79 | max-width: 800px; 80 | margin-top: 3rem; 81 | } 82 | 83 | .card { 84 | margin: 1rem; 85 | flex-basis: 45%; 86 | padding: 1.5rem; 87 | text-align: left; 88 | color: inherit; 89 | text-decoration: none; 90 | border: 1px solid #eaeaea; 91 | border-radius: 10px; 92 | transition: color 0.15s ease, border-color 0.15s ease; 93 | } 94 | 95 | .card:hover, 96 | .card:focus, 97 | .card:active { 98 | color: #0070f3; 99 | border-color: #0070f3; 100 | } 101 | 102 | .card h3 { 103 | margin: 0 0 1rem 0; 104 | font-size: 1.5rem; 105 | } 106 | 107 | .card p { 108 | margin: 0; 109 | font-size: 1.25rem; 110 | line-height: 1.5; 111 | } 112 | 113 | .logo { 114 | height: 1em; 115 | } 116 | 117 | @media (max-width: 600px) { 118 | .grid { 119 | width: 100%; 120 | flex-direction: column; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/next.ts: -------------------------------------------------------------------------------- 1 | import { Server as NetServer, Socket } from "net"; 2 | import { NextApiResponse } from "next"; 3 | import { Server as SocketIOServer } from "socket.io"; 4 | 5 | export type NextApiResponseServerIO = NextApiResponse & { 6 | socket: Socket & { 7 | server: NetServer & { 8 | io: SocketIOServer; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Profile, User } from "@prisma/client"; 2 | 3 | export type UserWithProfile = User & { 4 | profile: Profile; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { queryCache } from "react-query"; 2 | 3 | /** 4 | * Perform onMutate callback on mutate data 5 | * @param cacheKey string or array 6 | * @param newData object 7 | */ 8 | export const mutateNewData = (cacheKey: any | [], newData: object) => { 9 | queryCache.cancelQueries(cacheKey); 10 | const prevCacheData = queryCache.getQueryData(cacheKey); 11 | queryCache.setQueryData(cacheKey, (prev: I[]) => [ 12 | ...prev, 13 | { ...newData, id: fakeId() }, 14 | ]); 15 | return () => queryCache.setQueryData(cacheKey, prevCacheData); 16 | }; 17 | 18 | export const fakeId = (): number => { 19 | return new Date().getTime(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import cookie from "cookie"; 2 | import { sign } from "jsonwebtoken"; 3 | 4 | import { NextApiResponse } from "next"; 5 | 6 | export async function setCookie( 7 | res: NextApiResponse, 8 | tokenName: string = "token", 9 | payload: object | string, 10 | expire: number = 60 * 60 * 6, // 6h 11 | secret: string = process.env.JWT_AUTH_SECRET 12 | ): Promise { 13 | res.setHeader( 14 | "Set-Cookie", 15 | cookie.serialize(tokenName, payload ? sign(payload, secret) : "", { 16 | httpOnly: true, 17 | maxAge: expire, 18 | path: "/", 19 | sameSite: "lax", 20 | secure: process.env.NODE_ENV === "production", 21 | }) 22 | ); 23 | } 24 | 25 | interface Cookie { 26 | tokenName: string; 27 | payload?: object; 28 | expire: number; 29 | secret?: string; 30 | } 31 | 32 | export async function setCookies( 33 | res: NextApiResponse, 34 | cookies: Cookie[] 35 | ): Promise { 36 | res.setHeader( 37 | "Set-Cookie", 38 | cookies.map((c) => { 39 | return cookie.serialize( 40 | c.tokenName, 41 | c.payload ? sign(c.payload, c.secret) : "", 42 | { 43 | httpOnly: true, 44 | maxAge: c.expire, 45 | path: "/", 46 | sameSite: "lax", 47 | secure: process.env.NODE_ENV === "production", 48 | } 49 | ); 50 | }) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { omit } from "lodash"; 2 | import { UserWithProfile } from "src/types/user"; 3 | import { IMeUser } from "src/interfaces/user"; 4 | 5 | export const reduceMeUser = (user: UserWithProfile): IMeUser => { 6 | // Remove password and count from logged in user return 7 | return omit(user, ["password", "count"]); 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/hooks/useQuerySocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useQuery } from "react-query"; 3 | import { io } from "socket.io-client"; 4 | 5 | export default function useQuerySocket( 6 | cacheKey: any, 7 | callback: () => Promise 8 | ) { 9 | const { data, isLoading, refetch } = useQuery(cacheKey, callback); 10 | 11 | useEffect(() => { 12 | const socket = io({ 13 | path: "/api/socketio", 14 | }); 15 | 16 | socket.on(cacheKey, (_emitParam: any) => { 17 | // refetch query on server emit 18 | refetch(); 19 | }); 20 | 21 | // socket disconnet onUnmount 22 | return () => socket.disconnect(); 23 | }, []); 24 | 25 | return { data, isLoading }; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/http.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from "lodash"; 2 | 3 | /** 4 | * fetch() wrapper 5 | * @param uri /api 6 | * @param method POST 7 | * @param body empty obj {} 8 | * @param contentType application/json 9 | */ 10 | export const HTTP = async ( 11 | uri: string = "/api", 12 | method: string = "POST", 13 | body: object = {}, 14 | contentType: string = "application/json" 15 | ): Promise => { 16 | return await fetch(uri, { 17 | method, 18 | headers: { 19 | "Content-Type": contentType, 20 | }, 21 | body: !isEmpty(body) ? JSON.stringify(body) : null, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "yup"; 2 | import { IUserCreateErrors } from "src/interfaces/user"; 3 | import { ErrorOption } from "react-hook-form"; 4 | 5 | export const generateErrors = (inner: ValidationError[]): IUserCreateErrors => { 6 | if (inner && inner.length) { 7 | return inner.reduce((obj, e) => { 8 | obj[e.path] = e.errors[0]; 9 | return obj; 10 | }, {}); 11 | } 12 | }; 13 | 14 | export const setFormErrors = ( 15 | errors: IUserCreateErrors, 16 | setError: (prop: keyof IUserCreateErrors, error: ErrorOption) => void 17 | ): void => { 18 | Object.keys(errors).forEach((prop: keyof IUserCreateErrors) => { 19 | setError(prop, { 20 | type: "required", 21 | message: errors[prop], 22 | }); 23 | }); 24 | return; 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/validators/user.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { IUserCreate } from "src/interfaces/user"; 3 | import * as yup from "yup"; 4 | 5 | export const validUserCreate = yup.object().shape({ 6 | firstName: yup.string().required().min(2), 7 | lastName: yup.string().required().min(2), 8 | password: yup.string().required().min(8), 9 | confirmPassword: yup.string().oneOf([yup.ref("password"), null]), 10 | email: yup 11 | .string() 12 | .required("This field is required") 13 | .email("This field must be an e-mail address") 14 | .test("unique-email", "This e-mail is already in use", async function ( 15 | email: string 16 | ): Promise { 17 | const prisma = new PrismaClient({ log: ["query", "info"] }); 18 | // const {options} = this; 19 | const user = await prisma.user.findOne({ 20 | where: { 21 | email, 22 | }, 23 | }); 24 | await prisma.$disconnect(); 25 | return !user; 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "rootDir": "src" 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------