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


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


# 使用技術
![tech.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2245453/93c535ee-cbcd-89de-f470-fa7df68fa95e.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
```typescript: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に変更
```typescript: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: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

```docker:.dockerignore
.git
node_modules/
dist/
.gitignore
Dockerfile.local
README.md
```

### フロントエンド
#### プロジェクト新規作成
以下のコマンドでプロジェクトを作成する。
```
$ yarn create next-app
```
ESLintやTailwind CSSなどを使うかどうか聞かれるので選択する。
![nextjs-setup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2245453/b14c6bc0-2eb5-656b-49af-b84671347e33.png)

#### linter / formatter
```
$ yarn add --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ yarn add --dev prettier eslint-config-prettier
```

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

```typescript:.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"
  ]
}
```

```typescript:.prettierrc
{
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "printWidth": 60
}
```

#### Dockerfile.local
```dockerfile: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"]
```

```docker:.dockerignore
.git
node_modules/
dist/
```

### 開発コンテナを起動
docker-compose.ymlファイルにコンテナの構成を記述する。
```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"に設定

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

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

*docker-compose.yml* ファイルで設定した環境変数を基に、  *.env* ファイルの**DATABASE_URL**を変更する。
```yml:docker-compose.ymlで設定した環境変数
environment:
    MYSQL_HOST: db
    MYSQL_USER: root
    MYSQL_PASSWORD: secret
    MYSQL_DB: curio_nest
```
```diff_typescript:/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**を追加する。

```diff_typescript:/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を使って接続できるようにする。
```diff_yaml:/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を使えるようになる。

```diff_typescript:/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を以下のように記述する。
```typescript:/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();
  }
}
```
### データを定義する
#### スキーマを作成する
```diff_typescript:/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
}

```

https://www.prisma.io/docs/concepts/components/prisma-schema/relations

![er.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2245453/fa79f430-6ef6-e51b-e687-7a80e6908806.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から自動生成した型を拡張する。
```typescript: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>;
```

```typescript: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
>;
```

```typescript: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を使うと環境変数を扱える。

```diff_typescript: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 {}
```

### パッケージを使うための設定
```diff_typescript: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の名称は以下の５つ
- 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に追加する。
```diff_typescript:/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(好きな文字列)を追加する。
```diff:.env
+ JWT_SECRET_KEY="curio-nest-jwt-secretkey"

DATABASE_URL="postgres://root:secret@localhost:5434/curio_nest?schema=public"
```
#### AuthDto
```diff_typescript: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で使用するデータ型を定義しておく
```typescript:server/src/auth/interfaces/i-msg.interface.ts
//レスポンスのメッセージの型
export interface IMsg {
  message: string;
}
```
```typescript:server/src/auth/interfaces/i-jwt.interface.ts
//JwtのaccessTokenの型
export interface IJwt {
  accessToken: string;
}
```
```typescript:server/src/auth/interfaces/i-csrf.interface.ts
//csrfTokenの型
export interface ICsrf {
  csrfToken: string;
}
```

#### AuthService
```typescript: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
```typescript: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を設定
    //第２引数: 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
```typescript: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を追加
```diff_typescript: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の型をプロジェクトで使用しているユーザーの型で拡張しておく。

```typescript: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
```diff_typescript: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
```typescript: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
```typescript: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
```typescript: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
```diff_typescript: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
```typescript: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
```typescript: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
```typescript:server/src/question/dto/link-question-to-book.dto.ts
import { IsNumber } from 'class-validator';

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

#### QuestionService
```typescript: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',
      },
    });
  }

  //ログインしているユーザーの特定のクエスチョンを１つ返す
  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
```typescript: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();
  }

  //ログインしているユーザーの特定のクエスチョンを１つ取得
  // :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
```diff_typescript: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
```typescript: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
```typescript:server/src/book/interfaces/i-google-books.interfaces/ts
export interface IGoogleBooks {
  kind: string;
  totalItems: number;
  items: [];
}
```

#### インポート
```
$ yarn add axios @nestjs/axios
```
#### BookService
```typescript: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',
      },
    });
  }

  //ログインしているユーザーの本棚から特定の本を１つ返す
  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);
    }
  
    //ログインしているユーザーの本棚から特定の本を１つ返す
    @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**を追加し、静的ファイルを配置するディレクトリのパスを指定する。
```diff_typescript: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
```typescript:server/src/upload/dto/create-upload.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';

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

#### CreateUploadUtil
ファイルアップロードで使用するメソッドをまとめたファイル
```typescript: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
```typescript: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
```typescript: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
```

### プロパティを修正
```diff_typescript: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
```typescript: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
```typescript: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
```typescript: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の設定をする。

https://tailwindcss.com/docs/preflight

```diff_typescript: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,
+ },
};
```

https://tailwindcss.com/docs/guides/nextjs
### Maitine
#### インストール
https://mantine.dev/guides/next/

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


#### 設定
_document.tsxを以下の内容に変更する。
```typescript: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を以下の内容に変更する。
```typescript: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[];
};
```