LoginSignup
1
1

オリジナルWebアプリ「CurioNest」の開発記録【Docker + NestJS + Prisma + MySQL + NextJS】

Last updated at Posted at 2023-07-11

オリジナルのWebアプリ 「CurioNest」 を開発する手順を記録していこうと思います。

使用技術

tech.drawio.png

開発環境構築

  • Docker / Docker Compose

バックエンド

  • NestJS
  • TypeScript
  • Prisma

データベース

  • PostgreSQL
  • pgadmin

フロントエンド

  • NextJS
  • TypeScript
  • Tailwind CSS
  • React-Query
  • Zustand

CI / CD

  • Google Cloud Build / Cloud Run (バックエンド)
  • Google Cloud SQL (データベース)
  • Render(バックエンド、データベース)
  • Vercel (フロントエンド)

開発環境構築

プロジェクトセットアップ

$ mkdir curio-nest
$ cd curio-nest

githubレポジトリを作成して連携しておく。

バックエンド

プロジェクト新規作成

$ npm i -g @nestjs/cli
$ npm i -g yarn

$ nest new server

パッケージマネージャはyarnを選択する

自動的にgitファイルが作られているので削除する。

$ rm -rf .git

tsconfig.json

tsconfig.json
"compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": true, //false → trueに変更
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false,
    "strict" : true //追加
  }

main.ts

フロントエンドで3000番を使うため、ポート番号を3000から5000に変更

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT || 5000);
}
bootstrap();

Dockerfile.local

Dockerfile.local
FROM node:18.16.0-alpine3.17 AS builder

WORKDIR /app

COPY package.json ./
COPY yarn.lock ./

RUN yarn install --frozen-lockfile

COPY . .

RUN yarn build

##########################################################

FROM node:18.16.0-alpine3.17 AS runner

USER node

WORKDIR /app

EXPOSE 5000

COPY --from=builder --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/yarn.lock ./

COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules

CMD ["yarn", "run", "start:dev"]

.dockerignore

.dockerignore
.git
node_modules/
dist/
.gitignore
Dockerfile.local
README.md

フロントエンド

プロジェクト新規作成

以下のコマンドでプロジェクトを作成する。

$ yarn create next-app

ESLintやTailwind CSSなどを使うかどうか聞かれるので選択する。
nextjs-setup.png

linter / formatter

$ yarn add --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ yarn add --dev prettier eslint-config-prettier

ESLint と Prettier を連携させる(競合するルールをオフにする)

.eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "project": "./tsconfig.json",
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ]
}
.prettierrc
{
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "printWidth": 60
}

Dockerfile.local

Dockerfile.local
FROM node:18.16.0-alpine3.17 AS builder
WORKDIR /app

COPY package.json ./
COPY yarn.lock ./

RUN yarn install

COPY . .
RUN yarn build

##########################################################

FROM node:18.16.0-alpine3.17 AS runner

USER node

WORKDIR /app

EXPOSE 3000

COPY --from=builder --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/yarn.lock ./

COPY --from=builder --chown=node:node /app/node_modules ./node_modules

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next

CMD ["yarn", "run", "dev"]
.dockerignore
.git
node_modules/
dist/

開発コンテナを起動

docker-compose.ymlファイルにコンテナの構成を記述する。

/docker-compose.yml
services:
  client:
    build:
      context: ./client
      dockerfile: Dockerfile.local
    working_dir: /app
    ports:
      - 3000:3000
    restart: always
    tty: true
    volumes:
      - ./client:/app

  server:
    build:
      context: ./server
      dockerfile: Dockerfile.local
    working_dir: /app
    ports:
      - 5000:5000
      - 5555:5555
    restart: always
    tty: true
    volumes:
      - ./server:/app
    environment:
      POSTGRES_HOST: db
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: curio_nest
  
  db:
    image: postgres:14.8-alpine
    ports:
      - 5434:5432
    volumes:
      - curio-nest-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: curio_nest
    restart: always

volumes:
  curio-nest-data:

以下のコマンドでコンテナを構築して起動する.

$ docker compose up -d

バックエンド開発

データベース操作

Prismaを使うための設定

インストール

$ yarn add -D prisma
$ yarn add @prisma/client

セットアップ

以下のコマンドでPrismaをセットアップする。

$ npx prisma init

データソースを設定

PostgreSQLを使うので、provider を"postgresql"に設定

/server/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

docker-compose.yml ファイルで設定した環境変数を基に、 .env ファイルのDATABASE_URLを変更する。

docker-compose.ymlで設定した環境変数
environment:
    MYSQL_HOST: db
    MYSQL_USER: root
    MYSQL_PASSWORD: secret
    MYSQL_DB: curio_nest
/server/.env
- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+ DATABASE_URL="postgres://root:secret@localhost:5434/curio_nest?schema=public"

.gitignore ファイルに .envを追加する。

/server/.gitignore
# compiled output
/dist
/node_modules

# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# OS
.DS_Store

# Tests
/coverage
/.nyc_output

# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace

# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

# env
+ .env

.envファイルはgithubに公開していないので、コードをプルするたびに .envファイルを書き直す必要が出てしまった。
そのため、 docker-compose.ymlで、先ほどのDATABASE_URLを環境変数として記述する方法を取る。

docker-compose.ymlにも、先ほどのDATABASE_URLを環境変数として記述し、
serverコンテナからdbコンテナにprismaを使って接続できるようにする。

/docker-compose.yml
services:
  client:
    build:
      context: ./client
      dockerfile: Dockerfile.local
    working_dir: /app
    ports:
      - 3000:3000
    restart: always
    tty: true
    volumes:
      - ./client:/app

  server:
    build:
      context: ./server
      dockerfile: Dockerfile.local
    working_dir: /app
    ports:
      - 5000:5000
      - 5555:5555
    restart: always
    tty: true
    volumes:
      - ./server:/app
    environment:
      POSTGRES_HOST: db
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: curio_nest
+     DATABASE_URL: postgresql://root:secret@db:5432/curio_nest?schema=public
  
  db:
    image: postgres:14.8-alpine
    ports:
      - 5434:5432
    volumes:
      - curio-nest-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: curio_nest
    restart: always

volumes:
  curio-nest-data:

以下のコマンドでコンテナを再構築する。

$ docker compose up -d

prismaディレクトリ作成

NestJSのCLIを使ってmoduleとserviceを作成する。

$ nest g module prisma
$ nest g service prisma --no-spec

prisma.module.tsのexportsにPrismaServiceを追加する。
これにより、他のモジュールにPrismaModuleをインポートすると、そのモジュールでPrismaServiceを使えるようになる。

/server/src/prisma/prisma.module.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
+ exports: [PrismaService],
})
export class PrismaModule {}

NestJSでPrismaを使うために、PrismaServiceを以下のように記述する。

/server/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

データを定義する

スキーマを作成する

/server/prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
  binaryTargets = ["native", "debian-openssl-1.1.x","linux-musl-openssl-3.0.x"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  //id >> autoincrementで自動採番
  id Int @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  //email >> @uniqueで重複を防ぐ
  email String @unique
  //ユーザ名
  userName String
  //ハッシュ化したパスワード
  hashedPassword String
  //プロフィール画像
  profilePicture String?
  //カバー画像
  coverPicture String?
  //Questionの配列
  questions Question[]
  //いいねしたQuestionの配列
  likeQuestions Like[]
  //Bookの配列
  books Book[]
  //フォロー
  followedBy Follow[] @relation("followed")
  following  Follow[] @relation("following")
}
model Follow {
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  following    User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
  followingId  Int
  followed   User @relation("followed", fields: [followedId], references: [id], onDelete: Cascade)
  followedId Int

  @@id([followingId, followedId])
}

model Like {
createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int

  question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
  questionId Int

  @@id([userId, questionId])
}

model Link {
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
  questionId Int

  book Book @relation(fields: [bookId], references: [id], onDelete: Cascade)
  bookId Int

  @@id([questionId, bookId])
}

model Question {
  id Int @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  title String
  description String?
  //非公開設定
  isPrivate Boolean
  //いいねしたユーザー
  likes Like[]
  //紐づけされた本
  books Link[]
  
  //userIdとUserのidを紐づけする
  //userにUserのモデル構造が入る >> user User
  //userIdとUserのidを紐づける >> fields: [userId], references: [id]
  //Userが削除された場合に自動削除する >> onDelete: Cascade
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  //作成したUserのid
  userId Int
}

model Book {
  id Int @id @default(autoincrement())

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  googleBooksId String
  isbn String?
  title String?
  authors String[]
  publisher String?
  publishedDate String?
  pageCount String?
  imgLink String?
  previewLink String?

  links Link[]
  
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int
}

er.drawio.png

マイグレーション

以下のコマンドで、起動しているNestJSサーバーの [container-id] を確認する。

$ docker ps

以下のコマンドでコンテナ内に入る。

$ docker exec -it [container-id] sh

コンテナ内に入ったら、以下のコマンドでマイグレーションを実行し、
スキーマファイルで定義した内容をデータベースに反映する。

# npx prisma migrate dev

Prisma Clientを生成

以下のコマンドを実行し、Prisma Clientを生成する。
これにより、先ほど作成されたデータベースのデータ型が生成される。
※ Prismaスキーマを変更しデータベースに反映する度に、Prisma Clientを手動で再生成する必要がある。

# npx prisma generate

Prisma Studioを起動

以下のコマンドでPrisma Studioを起動してデータベースを確認する。

# npx prisma studio

型を拡張

Prisma-clientから自動生成した型を拡張する。

server/src/types/prisma-extended/user-with-relation.type.ts
import { Prisma } from '@prisma/client';

const userWithRelation = Prisma.validator<Prisma.UserArgs>()({
  include: {
    questions: true,
    books: true,
    likeQuestions: true,
    followedBy: true,
    following: true,
  },
});
export type User_WithRelation = Prisma.UserGetPayload<typeof userWithRelation>;
server/src/types/prisma-extended/question-with-relation.type.ts
import { Prisma } from '@prisma/client';

const questionWithRelation = Prisma.validator<Prisma.QuestionArgs>()({
  include: { likes: true, books: true },
});
export type Question_WithRelation = Prisma.QuestionGetPayload<
  typeof questionWithRelation
>;
server/src/types/prisma-extended/book-with-relation.type.ts
import { Prisma } from '@prisma/client';

const bookWithRelation = Prisma.validator<Prisma.BookArgs>()({
  include: { links: true },
});
export type Book_WithRelation = Prisma.BookGetPayload<typeof bookWithRelation>;

CRUDを作成する

パッケージインストール

$ yarn add @nestjs/config @nestjs/jwt @nestjs/passport 
$ yarn add cookie-parser csurf passport passport-jwt bcrypt class-validator class-transformer
$ yarn add -D @types/express @types/cookie-parser @types/csurf @types/passport-jwt @types/bcrypt

環境変数

app.module.tsのimportsにConfigModuleを追加する。
forRoot({ isGlobal: true })でグローバルに設定すると、プロジェクト全体で使用できる。
(auth.module.tsなどでimportsにConfigModuleを記述しなくて良くなる。)
ConfigModuleを使うと環境変数を扱える。

server/src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [PrismaModule, 
+           ConfigModule.forRoot({ isGlobal: true })],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

パッケージを使うための設定

server/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

//ValidationPipe: DTOとクラスバリデーションを有効化する
+ import { INestApplication, ValidationPipe } from '@nestjs/common';

//Request: リクエストのデータ型
+ import { Request } from 'express';

//cookieParser: Jwtトークンのやり取りをcookieベースで行うので、クライアントのリクエストからcookieを取り出すのに必要
+ import * as cookieParser from 'cookie-parser';

//csurf: csrf対策でcsrfトークンを使えるようにする
+ import * as csurf from 'csurf';

async function bootstrap() {
  const app: INestApplication = await NestFactory.create(AppModule);

  //DTOとクラスバリデーションを使う
  //whitelist: true で、dtoに含まれないものが送られてきた際に省く
+   app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

  //Corsの設定
+   app.enableCors({
    //credentials: フロントエンドとバックエンドでJWTトークンをcookieベースで通信する。
+     credentials: true,
    //バックエンドのサービスへのアクセスを許可したい、フロントエンド(React側)のドメインを指定
+     origin: ['http://localhost:3000'],
+   });

  //グローバルのミドルウェアでcookieParserを実行し、
  //フロントエンドから受け取ったcookieを解析できるようにする。
+   app.use(cookieParser());

  //Csurfのプロテクション設定
  //authの@Get('/csrf')のエンドポイントでの処理の際に、csftTokenをcookieにセットするようにするための処理。
  //認証フローでCsrf Tokenを生成する時に使ったSecretをcookieに格納するが、
  //Csrf Token用のシークレットキーをjavascriptの方から読み取られたくないので、httpOnly: trueに設定する。
  //(cookieの_csrfにSecretキーが自動的に付与されるようになる。)
  //secureはpostmanで確認する際はfalseにしないと動作しないが、googleChromeなどのブラウザではtrueにしても
  //localhostの場合はsecure属性が無視されるためcookieの送受信が問題なく動作する。
  //valueについて、
  //クライアントのリクエストヘッダーにCsrf Tokenを付与してサーバーサイドに問い合わせをするため、
  //サーバーサイドはCsrf Tokenを読み込む必要がある。
  //その読み込む処理がreq.header('csrf-token')で、その値をvalueに設定している。
  //(ログイン時にヘッダーからCsrf Tokenを読み込んで認証するようになる)
+  app.use(
+    csurf({
+      cookie: {
+        httpOnly: true,
+        sameSite: 'none',
+        secure: true,
+      },
+      value: (req: Request): string => {
+        return req.header('csrf-token');
+      },
+    }),
+  );

  await app.listen(process.env.PORT || 5000);
}
bootstrap();

RestAPIの雛形を作成

以下のコマンドでAPIの雛形を作成する。

$ nest g resource

どのような雛形にするかを聞かれるので指定する。

? What name would you like to use for this resource (plural, e.g., "users")? auth
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes

今回作成するresourceの名称は以下の5つ

  • auth
  • user
  • question
  • book
  • upload

以下の構成で雛形が作成される。

auth/
├── auth.controller.spec.ts
├── auth.controller.ts
├── auth.module.ts
├── auth.service.spec.ts
├── auth.service.ts
├── dto
│   ├── create-auth.dto.ts
│   └── update-auth.dto.ts
└── entities
    └── auth.entity.ts

Auth

AuthModule

AuthService内でPrismaServiceとJwtModuleを使いたいので、AuthModuleのimportsに追加する。

/server/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';

@Module({
+ imports: [PrismaModule, JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

.envファイルにJWTを生成するためのJWT_SECRET_KEY(好きな文字列)を追加する。

.env
+ JWT_SECRET_KEY="curio-nest-jwt-secretkey"

DATABASE_URL="postgres://root:secret@localhost:5434/curio_nest?schema=public"

AuthDto

server/src/auth/dto/auth.dto.ts
//DTO (Data Transfer Object) : クライアントからサーバーに送られてくるデータオブジェクト
//class-validatorを使って、クライアントから送られてくるデータのバリデーションを行う

+ import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

export class AuthDto {
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;

+ @IsString()
+ @IsNotEmpty()
+ @MinLength(8)
+ password: string;
}

interfaces

AuthServiceやAuthControllerで使用するデータ型を定義しておく

server/src/auth/interfaces/i-msg.interface.ts
//レスポンスのメッセージの型
export interface IMsg {
  message: string;
}
server/src/auth/interfaces/i-jwt.interface.ts
//JwtのaccessTokenの型
export interface IJwt {
  accessToken: string;
}
server/src/auth/interfaces/i-csrf.interface.ts
//csrfTokenの型
export interface ICsrf {
  csrfToken: string;
}

AuthService

server/auth/auth.service.ts
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthDto } from './dto/auth.dto';
import { IJwt } from './interfaces/i-jwt.interface';
import { IMsg } from './interfaces/i-msg.interface';
import * as bcrypt from 'bcrypt';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
import { User_WithRelation } from 'src/types/prisma-extended/user-with-relation';

@Injectable()
export class AuthService {
  //各ServiceをDI(Dependancy Injection)する
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
  ) {}

  //ユーザー新規作成
  //戻り値はinterfaceで定義したMsg
  async signUp(dto: AuthDto): Promise<IMsg> {
    //dtoで受け取ったパスワードをハッシュ化
    //第2引数: roundsを指定(2の12乗) >> ハッシュ計算に必要な回数
    const hashed: string = await bcrypt.hash(dto.password, 12);
    //PrismaServiceのcreateメソッドで、データベースにデータを作成する
    try {
      await this.prisma.user.create({
        data: {
          email: dto.email,
          userName: dto.email.substring(0, dto.email.indexOf('@')),
          hashedPassword: hashed,
        },
      });
      return {
        message: 'ok',
      };
    } catch (err) {
      if (err instanceof PrismaClientKnownRequestError) {
        //P2002 : ユニークキーのエラー(email String @unique に設定したため)
        if (err.code === 'P2002') {
          throw new ForbiddenException('This email is already taken');
        }
      }
      throw err;
      console.error(err);
    }
  }

  //ログイン
  async login(dto: AuthDto): Promise<IJwt> {
    //データベースからユーザーを探す
    const user: User_WithRelation = await this.prisma.user.findUnique({
      where: {
        email: dto.email,
      },
      include: {
        questions: true,
        likeQuestions: true,
        books: true,
        followedBy: true,
        following: true,
      },
    });
    //ユーザーが見つからない場合
    if (!user) throw new ForbiddenException('Email or Password incorrect');

    //dtoで渡されたパスワードと、データベースのハッシュ化されたパスワードを比較
    const isValid: boolean = await bcrypt.compare(
      dto.password,
      user.hashedPassword,
    );

    if (!isValid) throw new ForbiddenException('Email or Password incorrect');
    //emailとパスワードに問題がなければ、Jwtを生成する
    return this.generateJwt(user.id, user.email);
  }

  //Jwtを生成する
  async generateJwt(userId: number, email: string): Promise<IJwt> {
    //payloadを定義
    const payload: {
      sub: number;
      email: string;
    } = {
      sub: userId,
      email,
    };
    //.envファイルからシークレットキーを取得(ConfigServiceのgetメソッド)
    const secret: string = this.config.get<string>('JWT_SECRET_KEY');
    //payloadとsecretを使ってトークンを生成(JwtServiceのsignAsyncメソッド)
    const token: string = await this.jwt.signAsync(payload, {
      //トークンの有効期限
      expiresIn: '30m',
      //シークレットキー
      secret: secret,
    });
    return {
      accessToken: token,
    };
  }
}

AuthController

server/src/auth/auth.controller.ts
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Req,
  Res,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { AuthDto } from './dto/auth.dto';
import { ICsrf } from './interfaces/i-csrf.interface';
import { IMsg } from './interfaces/i-msg.interface';
import { IJwt } from './interfaces/i-jwt.interface';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Get('csrf')
  getCsrfToken(@Req() req: Request): ICsrf {
    //csrfTokenを生成するメソッドcsrfToken()が準備されている
    return { csrfToken: req.csrfToken() };
  }

  @Post('signup')
  signUp(@Body() dto: AuthDto): Promise<IMsg> {
    return this.authService.signUp(dto);
  }

  //ログイン
  //リクエスト成功時のステータス : 201 Created → 200 Ok に変更
  //@Res({ passthrough: true }) : Json形式への変換とcookieの設定を両方有効化
  @HttpCode(HttpStatus.OK)
  @Post('login')
  async login(
    @Body() dto: AuthDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<IMsg> {
    const jwt: IJwt = await this.authService.login(dto);

    //レスポンスに'access_token'という名前でcookieを設定
    //第2引数: cookieの値。jwtのaccessTokenの値を設定。

    //secure: false >> ローカル開発中はfalse(デプロイ時にtrueに変更)
    //trueにすると、httpsで暗号化された通信でのみcookieが使用可能になる。

    //sameSite: googleのCsrf対策でデフォルトで'lax'になっていて、Cookieがセットできないので、
    //Cookieの送受信が可能になるように'none'に設定。
    res.cookie('access_token', jwt.accessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'none',
      path: '/',
    });
    return {
      message: 'ok',
    };
  }

  //ログアウト
  @HttpCode(HttpStatus.OK)
  @Post('logout')
  logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): IMsg {
    //値を''にしてloginで設定したcookieをリセット
    res.cookie('access_token', '', {
      httpOnly: true,
      secure: true,
      sameSite: 'none',
      path: '/',
    });
    return {
      message: 'ok',
    };
  }
}

JwtStrategy

server/auth/strategy/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from 'src/prisma/prisma.service';
import { User_WithRelation } from 'src/types/prisma-extended/user-with-relation';

//AuthGuard('jwt')をJwtStrategyでカスタマイズする。(cookieを取り出す場所やシークレットキーの設定など)

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly prisma: PrismaService,
    private readonly config: ConfigService,
  ) {
    super({
      //リクエストのcookiesから'access_token'という名前のjwtを取り出す。
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req): string | null => {
          let jwt = null;
          if (req && req.cookies) {
            jwt = req.cookies['access_token'];
          }
          return jwt;
        },
      ]),
      //jwtの有効期限が切れていた場合に無効にする
      ignoreExpiration: false,
      //jwt生成に使ったキーを.envから取得(ConfigServiceのgetメソッド)
      secretOrKey: config.get('JWT_SECRET_KEY'),
    });
  }

  //PassportStrategyクラスのvalidateメソッド(抽象メソッド)を実装。
  //クライアントから送られてきたjwtに問題がなければ、jwtとシークレットキーを合わせてpayloadを復元しvalidateメソッドに渡す処理が行われる。
  //(auth.service.tsのgenerateJwtメソッド内で、payloadとシークレットキーを使ってjwtを生成した。)
  async validate(payload: { sub: number; email: string }) {
    const user: User_WithRelation = await this.prisma.user.findUnique({
      where: {
        id: payload.sub,
      },
      include: {
        questions: true,
        likeQuestions: true,
        books: true,
        followedBy: true,
        following: true,
      },
    });
    delete user.hashedPassword;
    //ログインしているユーザーのオブジェクトを返す。
    //nestJsでは、自動的にRequestの中にuserを含めてくれるので、controller内でRequestからユーザー情報にアクセスできる。
    return user;
  }
}

auth.module.tsのprovidersにJwtStrategyを追加

server/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategy/jwt.strategy';

@Module({
  imports: [PrismaModule, JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService, 
+   JwtStrategy
  ],
})
export class AuthModule {}

Request型を拡張

パッケージ(@nestjs/passportと@types/passport-jwt)によって、
ExpressのRequest型からUserにアクセスできるようにプロパティが追加されているみたい。
req.user: Express.Userのようにしてログインしているユーザーの情報にアクセスできる。
Express.Userの型をプロジェクトで使用しているユーザーの型で拡張しておく。

server/src/types/express.d.ts
import { User_WithRelation } from './prisma-extended/user-with-relation.type';
import { Express } from 'express';

declare global {
  namespace Express {
    interface User extends Omit<User_WithRelation, 'hashedPassword'> {}
  }
}

User

UserModule

server/src/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
+ import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
+ imports: [PrismaModule],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

UpdateUserDto

server/src/user/dto/update-user.dto.ts
import { IsOptional, IsString } from 'class-validator';

export class UpdateUserDto {
  @IsString()
  @IsOptional()
  userName?: string;

  @IsString()
  @IsOptional()
  profilePicture?: string;

  @IsString()
  @IsOptional()
  coverPicture?: string;
}

UserService

server/src/user/user.service.ts
import {
  ForbiddenException,
  Injectable,
} from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { User_WithRelation } from 'src/types/prisma-extended/user-with-relation.type';
import { IMsg } from 'src/auth/interfaces/i-msg.interface';
import { Follow, User } from '@prisma/client';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}

  //ユーザーをuserIdで取得
  async getUserById(
    userId: number,
  ): Promise<Omit<User_WithRelation, 'hashedPassword'>> {
    try {
      const user: User_WithRelation =
        await this.prisma.user.findUnique({
          where: {
            id: userId,
          },
          include: {
            questions: true,
            likeQuestions: true,
            books: true,
            followedBy: true,
            following: true,
          },
        });
      return user;
    } catch (err) {
      throw err;
    }
  }

  //ユーザ情報を更新
  async updateUser(
    userId: number,
    dto: UpdateUserDto,
  ): Promise<Omit<User_WithRelation, 'hashedPassword'>> {
    const user: User_WithRelation =
      await this.prisma.user.update({
        where: {
          id: userId,
        },
        include: {
          questions: true,
          likeQuestions: true,
          books: true,
          followedBy: true,
          following: true,
        },
        data: {
          ...dto,
        },
      });
    //updateメソッドは変更後のuserオブジェクトを返す。
    //ハッシュ化されたパスワードも返してしまうため、delete user.hashedPasswordで除いてから返す。
    delete user.hashedPassword;
    return user;
  }

  //ユーザー削除
  async deleteUser(
    userId: number,
    targetId: number,
  ): Promise<IMsg> {
    if (userId === targetId) {
      try {
        const user: User = await this.prisma.user.delete({
          where: {
            id: targetId,
          },
        });
        return {
          message: 'アカウントが削除されました',
        };
      } catch (err) {
        throw err;
      }
    } else {
      throw new ForbiddenException(
        'No permission to delete',
      );
    }
  }

  //ユーザーをフォロー
  async followUser(
    userId: number,
    targetUserId: number,
  ): Promise<IMsg> {
    //フォロー対象が自分自身でない場合、フォローできる
    if (userId !== targetUserId) {
      try {
        //フォロー対象のユーザー
        const targetUser: User & {
          followedBy: Follow[];
          following: Follow[];
        } = await this.prisma.user.findFirst({
          where: {
            id: targetUserId,
          },
          include: {
            followedBy: true,
            following: true,
          },
        });
        //既にフォローしているかどうか
        let isFollowed: boolean = false;
        for (
          let i = 0;
          i < targetUser.followedBy.length;
          i++
        ) {
          if (
            targetUser.followedBy[i].followingId === userId
          ) {
            isFollowed = true;
            break;
          }
        }
        //フォローしていなげればフォローできる
        if (!isFollowed) {
          //Follow(relation)を作成
          await this.prisma.follow.create({
            data: {
              followingId: userId,
              followedId: targetUserId,
            },
          });
          return {
            message: 'フォローに成功しました',
          };
        } else {
          return {
            message: '既にフォローしています',
          };
        }
      } catch (err) {
        throw err;
      }
    } else
      return {
        message: '自分自身をフォローできません',
      };
  }

  //フォロー解除
  async unfollowUser(
    userId: number,
    targetUserId: number,
  ): Promise<IMsg> {
    //フォローを外す対象が自分自身でない場合、フォローを外すことができる
    if (userId !== targetUserId) {
      try {
        //フォローを外す対象のユーザー
        const targetUser: User & {
          following: Follow[];
          followedBy: Follow[];
        } = await this.prisma.user.findFirst({
          where: {
            id: targetUserId,
          },
          include: {
            followedBy: true,
            following: true,
          },
        });
        //フォローをはずず対象のユーザーのfollowedByの中に自分がいた場合、フォローを外せる
        //既にフォローしているかどうか
        let isFollowed: boolean = false;
        for (
          let i = 0;
          i < targetUser.followedBy.length;
          i++
        ) {
          if (
            targetUser.followedBy[i].followingId === userId
          ) {
            isFollowed = true;
            break;
          }
        }
        //フォローしていれば外すことができる
        if (isFollowed) {
          //Follow(relation)を削除
          await this.prisma.follow.delete({
            where: {
              followingId_followedId: {
                followingId: userId,
                followedId: targetUserId,
              },
            },
          });
          return {
            message: 'フォロー解除に成功しました',
          };
        } else {
          return {
            message:
              'フォローしていないのでフォロー解除できません',
          };
        }
      } catch (err) {
        throw err;
      }
    } else
      return {
        message: '自分自身をフォロー解除できません',
      };
  }
}

UserController

server/src/user/user.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Req,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
import { User_WithRelation } from 'src/types/prisma-extended/user-with-relation';
import { IMsg } from 'src/auth/interfaces/i-msg.interface';

//jwtによるプロテクションを'user'の全てのエンドポイントに適用
@UseGuards(AuthGuard('jwt'))
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  //ログインしているユーザーの情報を取得
  @Get()
  getLoginUser(@Req() req: Request) {
    //nestJsではjwt.strategy.ts内のvalidateメソッドの処理の際に、自動的にRequestの中にuserを含めてくれるので、
    //controller内でRequestからユーザー情報にアクセスできる。
    return req.userInfo;
  }
  @Get(':id')
  getUserById(
    @Param('id', ParseIntPipe) userId: number,
  ): Promise<Omit<User_WithRelation, 'hashedPassword'>> {
    return this.userService.getUserById(userId);
  }

  //ユーザ情報を更新
  @Patch()
  updateUser(
    @Req() req: Request,
    @Body() dto: UpdateUserDto,
  ): Promise<Omit<User_WithRelation, 'hashedPassword'>> {
    return this.userService.updateUser(req.userInfo.id, dto);
  }

  //ユーザー削除
  @Delete(':id')
  deleteUser(
    @Req() req: Request,
    @Param('id', ParseIntPipe) userId: number,
  ): Promise<IMsg> {
    return this.userService.deleteUser(req.userInfo.id, userId);
  }

  //ユーザーをフォロー
  @Patch(':id/follow')
  followUser(
    @Req() req: Request,
    @Param('id', ParseIntPipe) userId: number,
  ): Promise<IMsg> {
    return this.userService.followUser(req.userInfo.id, userId);
  }

  //ユーザーのフォローを外す
  @Patch(':id/unfollow')
  unfollowUser(
    @Req() req: Request,
    @Param('id', ParseIntPipe) userId: number,
  ): Promise<IMsg> {
    return this.userService.unfollowUser(req.userInfo.id, userId);
  }
}

Question

QuestionModule

server/arc/question/question.module.ts
import { Module } from '@nestjs/common';
import { QuestionService } from './question.service';
import { QuestionController } from './question.controller';
+ import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
+ imports: [PrismaModule],
  controllers: [QuestionController],
  providers: [QuestionService],
})
export class QuestionModule {}

CreateQuestionDto

server/src/question/dto/create-question.dto.ts
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';

export class CreateQuestionDto {
  @IsString()
  @IsNotEmpty()
  title: string;

  @IsBoolean()
  isPrivate: boolean;
}

UpdateQuestionDto

server/src/question/dto/update-question.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateQuestionDto } from './create-question.dto';
import { IsOptional, IsString } from 'class-validator';

export class UpdateQuestionDto extends PartialType(CreateQuestionDto) {
  //任意の値は@IsOptionalを付ける
  @IsString()
  @IsOptional()
  description?: string;
}

LinkQuestionToBookDto

server/src/question/dto/link-question-to-book.dto.ts
import { IsNumber } from 'class-validator';

export class LinkQuestionToBookDto {
  @IsNumber()
  bookId: number;
}

QuestionService

server/src/question/question.service.ts
import { ForbiddenException, Injectable } from '@nestjs/common';
import { IMsg } from 'src/auth/interfaces/i-msg.interface';
import { PrismaService } from 'src/prisma/prisma.service';
import { Question_WithRelation } from 'src/types/prisma-extended/question-with-relation.type';
import { CreateQuestionDto } from './dto/create-question.dto';
import { LinkQuestionToBookDto } from './dto/link-question-to-book.dto';
import { UpdateQuestionDto } from './dto/update-question.dto';

@Injectable()
export class QuestionService {
  constructor(private prisma: PrismaService) {}

  //ログインしているユーザーのクエスチョンを全て取得する
  async getQuestions(userId: number): Promise<Question_WithRelation[]> {
    return await this.prisma.question.findMany({
      where: {
        userId,
      },
      include: {
        likes: true,
        books: true,
      },
      //新しい順に並べて返す
      orderBy: {
        createdAt: 'desc',
      },
    });
  }
  //ログインしているユーザーのタイムラインに表示するクエスチョンを全て取得
  async getTimelineQuestions(userId: number): Promise<Question_WithRelation[]> {
    try {
      const user = await this.prisma.user.findUnique({
        where: {
          id: userId,
        },
        include: {
          questions: true,
          likeQuestions: true,
          books: true,
          followedBy: true,
          following: true,
        },
      });
      //userのQuestionを全て取得する
      const userQuestions = await this.prisma.question.findMany({
        where: {
          userId,
        },
        include: {
          likes: true,
          books: true,
        },
        //新しい順に並べて返す
        orderBy: {
          createdAt: 'desc',
        },
      });
      // 自分がフォローしている友達のQuestionを全て取得する
      // 非同期処理のuserを使っているため、Promise.allを用意して待っておく。
      const friendQuestions = await Promise.all(
        user.following.map((follow) => {
          return this.prisma.question.findMany({
            where: {
              userId: follow.followingId,
            },
            include: {
              likes: true,
              books: true,
            },
            orderBy: {
              createdAt: 'desc',
            },
          });
        }),
      );
      console.log('friendQuestions: ', friendQuestions);
      //friendQuestions(friendのQuestionオブジェクト{}の配列[{}, {}, {}]の配列)
      //[ [{}, {}, {}], [{}, {}, {}] ]
      //スプレッド構文で全ての要素を展開してconcatする
      return userQuestions.concat(...friendQuestions);
    } catch (err) {
      throw err;
    }
  }

  //全てのQuestionを配列で返す
  async getAllQuestions(): Promise<Question_WithRelation[]> {
    return await this.prisma.question.findMany({
      where: {
        isPrivate: false,
      },
      include: {
        likes: true,
        books: true,
      },
      //新しい順に並べて返す
      orderBy: {
        createdAt: 'desc',
      },
    });
  }

  //ログインしているユーザーの特定のクエスチョンを1つ返す
  getQuestionById(
    userId: number,
    questionId: number,
  ): Promise<Question_WithRelation> {
    return this.prisma.question.findFirst({
      where: {
        userId,
        id: questionId,
      },
      include: {
        likes: true,
        books: true,
      },
    });
  }

  //クエスチョンを新規作成
  async createQuestion(
    userId: number,
    dto: CreateQuestionDto,
  ): Promise<Question_WithRelation> {
    const question = await this.prisma.question.create({
      data: {
        userId,
        ...dto,
      },
      include: {
        likes: true,
        books: true,
      },
    });
    return question;
  }

  //クエスチョンを更新する
  async updateQuestionById(
    userId: number,
    questionId: number,
    dto: UpdateQuestionDto,
  ): Promise<Question_WithRelation> {
    const question = await this.prisma.question.findUnique({
      where: {
        id: questionId,
      },
    });
    if (!question || question.userId !== userId) {
      throw new ForbiddenException('No permission to update');
    }
    return this.prisma.question.update({
      where: {
        id: questionId,
      },
      include: {
        likes: true,
        books: true,
      },
      data: {
        ...dto,
      },
    });
  }

  //クエスチョンにいいねを押す
  async likeQuestionById(userId: number, questionId: number): Promise<IMsg> {
    try {
      const question = await this.prisma.question.findUnique({
        where: {
          id: questionId,
        },
        include: {
          likes: true,
          books: true,
        },
      });
      //既にいいねしているかどうかで、いいねを押すかいいねを外すかを判断する
      let isLiked = false;
      for (let i = 0; i < question.likes.length; i++) {
        if (question.likes[i].userId === userId) isLiked = true;
      }
      if (!isLiked) {
        //Like(relation)を作成
        await this.prisma.like.create({
          data: {
            userId: userId,
            questionId: questionId,
          },
        });
        return {
          message: 'いいねを押しました',
        };
      } else
        await this.prisma.like.delete({
          where: {
            userId_questionId: {
              userId: userId,
              questionId: questionId,
            },
          },
        });
      return {
        message: 'いいねを外しました',
      };
    } catch (err) {
      throw err;
    }
  }

  //特定のQuestionと本を紐づけする
  async linkBook_QuestionById(
    userId: number,
    questionId: number,
    dto: LinkQuestionToBookDto,
  ): Promise<IMsg> {
    try {
      const question = await this.prisma.question.findFirst({
        where: {
          userId,
          id: questionId,
        },
        include: {
          likes: true,
          books: true,
        },
      });
      const book = await this.prisma.book.findFirst({
        where: {
          userId,
          id: dto.bookId,
        },
      });
      if (question && book) {
        let isLinked = false;
        for (let i = 0; i < question.books.length; i++) {
          if (question.books[i].bookId === book.id) {
            isLinked = true;
            break;
          }
        }
        //紐づけされていなければLinkを作成する
        if (!isLinked) {
          await this.prisma.link.create({
            data: {
              questionId,
              bookId: book.id,
            },
          });
          return {
            message: '紐づけに成功しました',
          };
        } else {
          return {
            message: 'すでに紐づけされています',
          };
        }
      }

      return { message: 'QuesionまたはBookが見つかりません' };
    } catch (err) {
      throw err;
    }
  }
  // 特定のQuestionと本の紐づけを解除する
  async unLinkBook_QuestionById(
    userId: number,
    questionId: number,
    dto: LinkQuestionToBookDto,
  ): Promise<IMsg> {
    try {
      const question = await this.prisma.question.findFirst({
        where: {
          userId,
          id: questionId,
        },
        include: {
          likes: true,
          books: true,
        },
      });
      const book = await this.prisma.book.findFirst({
        where: {
          userId,
          id: dto.bookId,
        },
      });
      if (question && book) {
        console.log(book.id);
        let isLinked = false;
        for (let i = 0; i < question.books.length; i++) {
          if (question.books[i].bookId === book.id) {
            isLinked = true;
            break;
          }
        }
        //紐づけされていればLinkを削除する
        if (isLinked) {
          //Linkを削除する
          await this.prisma.link.delete({
            where: {
              questionId_bookId: {
                questionId,
                bookId: book.id,
              },
            },
          });
          return { message: '紐づけを解除しました' };
        } else {
          return {
            message: '紐づけされていないので解除できません',
          };
        }
      }
    } catch (err) {
      throw err;
    }
  }

  //クエスチョンを削除する
  async deleteQuestion(userId: number, questionId: number): Promise<void> {
    const question = await this.prisma.question.findUnique({
      where: {
        id: questionId,
      },
    });
    if (!question || question.userId !== userId) {
      throw new ForbiddenException('No permission to delete');
    }
    await this.prisma.question.delete({
      where: {
        id: questionId,
      },
    });
  }
}

QuestionController

server/src/question/question.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  ParseIntPipe,
  Patch,
  Post,
  Req,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { IMsg } from 'src/auth/interfaces/i-msg.interface';
import { Question_WithRelation } from 'src/types/prisma-extended/question-with-relation.type';
import { CreateQuestionDto } from './dto/create-question.dto';
import { LinkQuestionToBookDto } from './dto/link-question-to-book.dto';
import { UpdateQuestionDto } from './dto/update-question.dto';
import { QuestionService } from './question.service';

@UseGuards(AuthGuard('jwt'))
@Controller('question')
export class QuestionController {
  constructor(private readonly questionService: QuestionService) {}

  //ログインしているユーザーのクエスチョンを全て取得
  @Get('all/profile')
  getLoginQuestions(@Req() req: Request): Promise<Question_WithRelation[]> {
    return this.questionService.getQuestions(req.user.id);
  }

  //ユーザーのクエスチョンを全て取得
  @Get('all/profile/:id')
  getQuestionsByUserId(
    @Req() req: Request,
    @Param('id', ParseIntPipe) userId: number,
  ): Promise<Question_WithRelation[]> {
    return this.questionService.getQuestions(userId);
  }

  //ユーザーのタイムラインに表示するクエスチョンを全て取得
  @Get('all/timeline')
  getTimelineQuestions(@Req() req: Request): Promise<Question_WithRelation[]> {
    return this.questionService.getTimelineQuestions(req.user.id);
  }

  @Get('all/allusers')
  getAllQuestions(): Promise<Question_WithRelation[]> {
    return this.questionService.getAllQuestions();
  }

  //ログインしているユーザーの特定のクエスチョンを1つ取得
  // :idでパスを変数化して@Paramで読み取る
  // ParseIntPipeでInt型に変換してquestionIdに格納する
  @Get(':id')
  getQuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
  ): Promise<Question_WithRelation> {
    return this.questionService.getQuestionById(req.user.id, questionId);
  }
  //クエスチョンを新規作成
  @Post()
  createQuestion(
    @Req() req: Request,
    @Body() dto: CreateQuestionDto,
  ): Promise<Question_WithRelation> {
    return this.questionService.createQuestion(req.user.id, dto);
  }
  //クエスチョンを更新
  @Patch(':id')
  updateQuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
    @Body() dto: UpdateQuestionDto,
  ): Promise<Question_WithRelation> {
    return this.questionService.updateQuestionById(
      req.user.id,
      questionId,
      dto,
    );
  }
  // 特定のQuestionにいいねを押す
  @Patch(':id/like')
  likeQuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
  ): Promise<IMsg> {
    return this.questionService.likeQuestionById(req.user.id, questionId);
  }

  // 特定のQuestionに本を紐づけする
  @Patch(':id/link')
  linkBook_QuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
    @Body() dto: LinkQuestionToBookDto,
  ): Promise<IMsg> {
    console.log(typeof dto.bookId);
    return this.questionService.linkBook_QuestionById(
      req.user.id,
      questionId,
      dto,
    );
  }

  // 特定のQuestionと本の紐づけを解除する
  @Patch(':id/unlink')
  unLinkBook_QuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
    @Body() dto: LinkQuestionToBookDto,
  ): Promise<IMsg> {
    return this.questionService.unLinkBook_QuestionById(
      req.user.id,
      questionId,
      dto,
    );
  }

  //クエスチョンを削除する
  //削除に成功した場合のステータスをNO_CONTENTに設定
  @HttpCode(HttpStatus.NO_CONTENT)
  @Delete(':id')
  deleteQuestionById(
    @Req() req: Request,
    @Param('id', ParseIntPipe) questionId: number,
  ): Promise<void> {
    return this.questionService.deleteQuestion(req.user.id, questionId);
  }
}


Book

BookModule

server/src/book/book.module.ts
import { Module } from '@nestjs/common';
import { BookService } from './book.service';
import { BookController } from './book.controller';
+ import { PrismaModule } from 'src/prisma/prisma.module';
+ import { HttpModule } from '@nestjs/axios';

@Module({
+ imports: [PrismaModule, HttpModule],
  controllers: [BookController],
  providers: [BookService]
})
export class BookModule {}

CreateBookDto

server/src/book/dto/create-book.dto.ts
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class CreateBookDto {
  @IsString()
  @IsNotEmpty()
  title: string;

  @IsString()
  @IsNotEmpty()
  googleBooksId: string;

  @IsString()
  @IsOptional()
  isbn?: string;

  @IsArray()
  @IsOptional()
  authors?: string[];

  @IsString()
  @IsOptional()
  publisher?: string;

  @IsString()
  @IsOptional()
  publishedDate?: string;

  @IsString()
  @IsOptional()
  pageCount?: string;

  @IsString()
  @IsOptional()
  imgLink?: string;

  @IsString()
  @IsOptional()
  previewLink?: string;
}

IGoogleBooks

server/src/book/interfaces/i-google-books.interfaces/ts
export interface IGoogleBooks {
  kind: string;
  totalItems: number;
  items: [];
}

インポート

$ yarn add axios @nestjs/axios

BookService

server/src/book/book.service.ts
import { HttpService } from "@nestjs/axios";
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
import { catchError, firstValueFrom } from "rxjs";
import { PrismaService } from "src/prisma/prisma.service";
import { Book_WithRelation } from "src/types/prisma-extended/book-with-relation.type";
import { CreateBookDto } from "./dto/create-book.dto";
import { IGoogleBooks } from "./interfaces/i-google-books.interface";


@Injectable()
export class BookService {
  private readonly logger = new Logger(BookService.name);
  constructor(
    private prisma: PrismaService,
    private readonly httpService: HttpService,
  ) {}

  //ユーザーの本棚にある本を全て取得する
  getBooks(userId: number): Promise<Book_WithRelation[]> {
    return this.prisma.book.findMany({
      where: {
        userId,
      },
      include: {
        links: true,
      },
      //新しい順に並べて返す
      orderBy: {
        createdAt: 'desc',
      },
    });
  }

  //ログインしているユーザーの本棚から特定の本を1つ返す
  getBookById(userId: number, bookId: number): Promise<Book_WithRelation> {
    return this.prisma.book.findFirst({
      where: {
        userId,
        id: bookId,
      },
      include: {
        links: true,
      },
    });
  }
  //本をキーワード検索(google books api)
  async searchBooks(keyword: string): Promise<IGoogleBooks> {
    const { data } = await firstValueFrom(
      this.httpService
        .get<IGoogleBooks>(
          `https://www.googleapis.com/books/v1/volumes?q=${keyword}`,
        )
        .pipe(
          catchError((error) => {
            this.logger.error(error.response.data);
            throw 'An error happened!';
          }),
        ),
    );
    return data;
  }

  //本を本棚に新規追加
  async createBook(
    userId: number,
    dto: CreateBookDto,
  ): Promise<Book_WithRelation> {
    try {
      //googleBooksId, userIdでMy本棚から本を探し、本が存在する場合は新規追加をしない。
      const found = await this.findByGoogleBooksId(dto.googleBooksId, userId);
      if (found) {
        return null;
      }
      const book = await this.prisma.book.create({
        data: {
          userId,
          ...dto,
        },
        include: {
          links: true,
        },
      });
      return book;
    } catch (err) {
      throw err;
    }
  }
  //My本棚からgoogleBooksIdで本を探すメソッド
  async findByGoogleBooksId(
    googleBooksId: string,
    userId: number,
  ): Promise<Book_WithRelation> {
    return this.prisma.book.findFirst({
      where: {
        googleBooksId,
        userId,
      },
      include: {
        links: true,
      },
    });
  }

  //本を本棚から削除する
  async deleteBook(userId: number, bookId: number): Promise<void> {
    const book = await this.prisma.book.findUnique({
      where: {
        id: bookId,
      },
    });
    if (!book || book.userId !== userId) {
      throw new ForbiddenException('No permission to delete');
    }
    await this.prisma.book.delete({
      where: {
        id: bookId,
      },
    });
  }
}

BookController

server/src/book/book.controller.ts
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Request } from "express";
import { Book_WithRelation } from "src/types/prisma-extended/book-with-relation.type";
import { BookService } from "./book.service";
import { CreateBookDto } from "./dto/create-book.dto";

  @UseGuards(AuthGuard('jwt'))
  @Controller('book')
  export class BookController {
    constructor(private readonly bookService: BookService) {}
  
    //ログインしているユーザーの本棚にある本を全て取得する
    @Get('all/shelf')
    getLoginBooks(
      @Req() req: Request,
    ): Promise<Book_WithRelation[]> {
      return this.bookService.getBooks(req.user.id);
    }
  
    //ユーザーの本棚にある本を全て取得する
    @Get('all/shelf/:id')
    getBooksById(
      @Req() req: Request,
      @Param('id', ParseIntPipe) userId: number,
    ): Promise<Book_WithRelation[]> {
      return this.bookService.getBooks(userId);
    }
  
    //ログインしているユーザーの本棚から特定の本を1つ返す
    @Get(':id')
    getBookById(
      @Req() req: Request,
      @Param('id', ParseIntPipe) bookId: number,
    ): Promise<Book_WithRelation> {
      return this.bookService.getBookById(req.user.id, bookId);
    }
    //本をキーワード検索(google books api)
    @Get('search/:keyword')
    searchBooks(@Param('keyword') keyword: string) {
      return this.bookService.searchBooks(keyword);
    }
  
    //本を本棚に新規追加
    @Post()
    createBook(
      @Req() req: Request,
      @Body() dto: CreateBookDto,
    ): Promise<Book_WithRelation> {
      return this.bookService.createBook(req.user.id, dto);
    }
  
    //本を本棚から削除
    @HttpCode(HttpStatus.NO_CONTENT)
    @Delete(':id')
    deleteBookById(
      @Req() req: Request,
      @Param('id', ParseIntPipe) bookId: number,
    ): Promise<void> {
      return this.bookService.deleteBook(req.user.id, bookId);
    }
  }
  


Upload

インポート

$ yarn add @nestjs/serve-static
$ yarn add -D @types/multer

静的ファイルの読み込み設定

ServeStaticModuleを追加し、静的ファイルを配置するディレクトリのパスを指定する。

server/src/upload/upload.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import { UserModule } from './user/user.module';
import { QuestionModule } from './question/question.module';
import { BookModule } from './book/book.module';
import { UploadModule } from './upload/upload.module';
+ import { ServeStaticModule } from '@nestjs/serve-static';
+ import { join } from 'path';

@Module({
  imports: [
    PrismaModule,
    AuthModule,
    ConfigModule.forRoot({ isGlobal: true }),
    UserModule,
    QuestionModule,
    BookModule,
    UploadModule,
+   ServeStaticModule.forRoot({
+     rootPath: join(__dirname, '..', '../public'),
+     exclude: ['/api*'],
+   }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

CreateUploadDto

server/src/upload/dto/create-upload.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateUploadDto {
  @IsString()
  @IsNotEmpty()
  name: string;
}

CreateUploadUtil

ファイルアップロードで使用するメソッドをまとめたファイル

server/src/upload/utils/create-upload.util.ts
export class CreateUploadUtil {
  static imageFileFilter(
    req: any,
    file: {
      fieldname: string;
      originalname: string;
      encoding: string;
      mimetype: string;
      size: number;
      destination: string;
      filename: string;
      path: string;
      buffer: Buffer;
    },
    callback: (error: Error, acceptFile: boolean) => void,
  ): void {
    if (!file.originalname.match(/\.(jpg|jpeg|png)$/)) {
      return callback(
        new Error('Only image files are allowed!'),
        false,
      );
    }
    callback(null, true);
  }

  static editFileName(
    req: any,
    file: Express.Multer.File,
    callback: (error: Error, filename: string) => void,
  ): void {
    const uniqueSuffix: string =
      Date.now() + '-' + Math.round(Math.random() * 1e9);
    callback(null, uniqueSuffix + '-' + file.originalname);
  }
}

UploadController

server/src/upload/upload.controller.ts
import {
  Controller,
  ParseFilePipeBuilder,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CreateUploadUtil } from './utils/create-upload.util';

@Controller('upload')
export class UploadController {
  @Post()
  @UseInterceptors(
    FileInterceptor('file', {
      fileFilter: CreateUploadUtil.imageFileFilter,
    }),
  )
  uploadFile(
    @UploadedFile(
      new ParseFilePipeBuilder()
        .addFileTypeValidator({
          fileType: /(jpg|jpeg|png)$/,
        })
        .addMaxSizeValidator({ maxSize: 50000000 })
        .build(),
    )
    file: Express.Multer.File,
  ) {
    console.log('file: ', file);
    return {
      fileName: file.filename,
      file: file.buffer,
    };
  }
}

UploadModule

server/src/upload/upload.module.ts
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { CreateUploadUtil } from './utils/create-upload.util';
import { diskStorage } from 'multer';

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        destination: 'public/images',
        filename: CreateUploadUtil.editFileName,
      }),
      limits: {
        fileSize: 50000000,
      },
    }),
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}

フロントエンド

データ型を生成

Prismaインストール

$ yarn add -D prisma
$ npx prisma init
$ yarn add @prisma/client

データベースからtypescriptの型を自動生成する

バックエンドで作成、使用した型をフロントエンドでも使いたいので、データベースからtypescriptの型を自動生成していく。

①バックエンド(NestJs)の.envファイルのDATABASE_URLを、フロントエンド(NextJs)の.envファイルにコピー&ペーストする。

②↓のコマンドで、docker内のpostgreSQLのデータ構造を解釈してフロントエンドのデータスキーマに復元する。

$ npx prisma db pull

プロパティを修正

client/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Book {
  id            Int      @id @default(autoincrement())
  createdAt     DateTime @default(now())
  updatedAt     DateTime
  googleBooksId String
  isbn          String?
  title         String?
  publisher     String?
  publishedDate String?
  pageCount     String?
  imgLink       String?
  previewLink   String?
  userId        Int
  authors       String[]
- User          User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user          User     @relation(fields: [userId], references: [id], onDelete: Cascade)
- Link          Link[]
+ links          Link[]
}

model Follow {
  createdAt                     DateTime @default(now())
  updatedAt                     DateTime
  followedId                    Int
  followingId                   Int
- User_Follow_followedIdToUser  User     @relation("Follow_followedIdToUser", fields: [followedId], references: [id], onDelete: Cascade)
+ followed  User     @relation("followed", fields: [followedId], references: [id], onDelete: Cascade)
- User_Follow_followingIdToUser User     @relation("Follow_followingIdToUser", fields: [followingId], references: [id], onDelete: Cascade)
+ following User     @relation("following", fields: [followingId], references: [id], onDelete: Cascade)

  @@id([followingId, followedId])
}

model Like {
  createdAt  DateTime @default(now())
  updatedAt  DateTime
  userId     Int
  questionId Int
- Question   Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
+ question   Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
- User       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)


  @@id([userId, questionId])
}

model Link {
  createdAt  DateTime @default(now())
  updatedAt  DateTime
  questionId Int
  bookId     Int
- Book       Book     @relation(fields: [bookId], references: [id], onDelete: Cascade)
+ book       Book     @relation(fields: [bookId], references: [id], onDelete: Cascade)
- Question   Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
+ question   Question @relation(fields: [questionId], references: [id], onDelete: Cascade)

  @@id([questionId, bookId])
}

model Question {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  updatedAt   DateTime
  title       String
  description String?
  isPrivate   Boolean
  userId      Int
- Like        Like[]
+ likes        Like[]
- Link        Link[]
+ books        Link[]
- User        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id                              Int        @id @default(autoincrement())
  createdAt                       DateTime   @default(now())
  updatedAt                       DateTime
  email                           String     @unique
  userName                        String
  hashedPassword                  String
  profilePicture                  String?
  coverPicture                    String?
- Book                            Book[]
+ books                            Book[]
- Follow_Follow_followedIdToUser  Follow[]   @relation("Follow_followedIdToUser")
+ followedBy  Follow[]   @relation("followed")
- Follow_Follow_followingIdToUser Follow[]   @relation("Follow_followingIdToUser")
+ following Follow[]   @relation("following")
- Like                            Like[]
+ likeQuestions                   Like[]
- Question                        Question[]
+ questions                       Question[]
}

③↓のコマンドで、復元したスキーマをフロントエンドのtypescriptで使えるように型生成する。

$ npx prisma generate

型を拡張

UserWithRelation

client/types/prisma-extended/user-with-relation.type.ts
import { Prisma } from "@prisma/client";

//Prisma-clientから自動生成されたUser型を拡張
const userWithRelation =
  Prisma.validator<Prisma.UserArgs>()({
    include: {
      questions: true,
      books: true,
      likeQuestions: true,
      followedBy: true,
      following: true,
    },
  });
export type User_WithRelation = Prisma.UserGetPayload<
  typeof userWithRelation
>;

QuestionWithRelation

client/types/prisma-extended/question-with-relation.type.ts
import { Prisma } from "@prisma/client";

//Prisma-clientから自動生成されたQuestion型を拡張
const questionWithRelation =
  Prisma.validator<Prisma.QuestionArgs>()({
    include: { likes: true, books: true },
  });
export type Question_WithRelation =
  Prisma.QuestionGetPayload<typeof questionWithRelation>;

BookWithRelation

client/types/prisma-extended/book-with-relation.type.ts
import { Prisma } from "@prisma/client";

//Prisma-clientから自動生成されたBook型を拡張
const bookWithRelation =
  Prisma.validator<Prisma.BookArgs>()({
    include: { links: true },
  });
export type Book_WithRelation = Prisma.BookGetPayload<
  typeof bookWithRelation
>;

CSS関連の設定

TailwindCSS

以下のコマンドでパッケージをインストールする。
※NextJSのプロジェクト作成時に、「TaliwindCSSを使用しますか? / Yes」のように選択していれば、
自動的にインストールされているので省略する。

$ yarn add -D tailwindcss postcss autoprefixer

以下のコマンドで、tailwind.config.js と postcss.config.jsを作成する。
※NextJSのプロジェクト作成時に、「TaliwindCSSを使用しますか? / Yes」のように選択していれば、
自動的にインストールされているので省略する。

$ npx tailwindcss init -p

以下のコマンドでprettier関連のパッケージをインストール

$ yarn add -D prettier prettier-plugin-tailwindcss

Mantine UIとTailwind CSSの互換性の問題の解消のため、
必要な場合はpreflightの設定をする。

client/tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      backgroundImage: {
        'gradient-radial':
          'radial-gradient(var(--tw-gradient-stops))',
        'gradient-conic':
          'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
      },
    },
  },
  plugins: [],
  //Mantine UIとTailwind CSSの互換性の問題の解消のため、preflightの設定をする
+ corePlugins: {
+   preflight: false,
+ },
};

Maitine

インストール

$ yarn add @mantine/core @mantine/hooks @mantine/form @mantine/next @emotion/server @emotion/react

設定

_document.tsxを以下の内容に変更する。

client/src/pages/_document.tsx
import { createGetInitialProps } from '@mantine/next';
import Document, { Head, Html, Main, NextScript } from 'next/document';

const getInitialProps = createGetInitialProps();

export default class _Document extends Document {
  static getInitialProps = getInitialProps;

  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

_app.tsxを以下の内容に変更する。

client/pages/_app.tsx
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { MantineProvider } from '@mantine/core';

export default function App({
  Component,
  pageProps,
}: AppProps) {
  return (
    <>
      <Head>
        <title>Page title</title>
        <meta
          name='viewport'
          content='minimum-scale=1, initial-scale=1, width=device-width'
        />
      </Head>

      <MantineProvider
        withGlobalStyles
        withNormalizeCSS
        theme={{
          /** Put your mantine theme override here */
          //プロジェクト全体のカラーテーマとフォントを設定
          colorScheme: 'light',
          fontFamily: 'Verdana, sans-serif',
        }}
      >
        <Component {...pageProps} />
      </MantineProvider>
    </>
  );
}

バックエンドに接続する

.env.localファイルで、RestAPIのurlを設定

client/.env.local
NEXT_PUBLIC_API_URL=http://localhost:5000

パッケージインストール

必要なパッケージをインストールしておく。

$ yarn add @tanstack/react-query @tanstack/react-query-devtools
$ yarn add axios@0.27.2 yup zustand
$ yarn add @heroicons/react@1.0.6 @tabler/icons@1.78.1

プロジェクトで使用する型を定義

AuthForm

client/types/auth/auth-form.type.ts
//新規登録orログインの入力フォームに適用するデータ型
export type AuthForm = {
  email: string;
  password: string;
};

SearchBookData

client/types/book/searched-book-data.type.ts
//本の検索でapiから取得するデータ型
export type SearchedBookData = {
  kind: string;
  totalItems: number;
  items: Array<any>;
};

CreatingQuestion

client/types/question/creating-question.type.ts
//現在作成中のQuestionを管理するためのデータ型
export type CreatingQuestion = {
  title: string;
  isPrivate: boolean;
};

EditingQuestion

client/types/question/editing-question.type.ts
import { Like, Link } from "@prisma/client";

//現在編集中のQuestionを管理するためのデータ型
export type EditingQuestion = {
  id: number;
  title: string;
  description?: string | null;
  isPrivate: boolean;

  books: Link[];
  likes: Like[];
};
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1