オリジナルのWebアプリ 「CurioNest」 を開発する手順を記録していこうと思います。
使用技術
開発環境構築
- 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
"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に変更
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
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
.git
node_modules/
dist/
.gitignore
Dockerfile.local
README.md
フロントエンド
プロジェクト新規作成
以下のコマンドでプロジェクトを作成する。
$ yarn create next-app
ESLintやTailwind CSSなどを使うかどうか聞かれるので選択する。
linter / formatter
$ yarn add --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ yarn add --dev prettier eslint-config-prettier
ESLint と Prettier を連携させる(競合するルールをオフにする)
{
"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"
]
}
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 60
}
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"]
.git
node_modules/
dist/
開発コンテナを起動
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"に設定
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
docker-compose.yml ファイルで設定した環境変数を基に、 .env ファイルのDATABASE_URLを変更する。
environment:
MYSQL_HOST: db
MYSQL_USER: root
MYSQL_PASSWORD: secret
MYSQL_DB: curio_nest
- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+ DATABASE_URL="postgres://root:secret@localhost:5434/curio_nest?schema=public"
.gitignore ファイルに .envを追加する。
# 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を使って接続できるようにする。
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を使えるようになる。
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
+ exports: [PrismaService],
})
export class PrismaModule {}
NestJSでPrismaを使うために、PrismaServiceを以下のように記述する。
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();
}
}
データを定義する
スキーマを作成する
// 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
}
マイグレーション
以下のコマンドで、起動している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から自動生成した型を拡張する。
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>;
import { Prisma } from '@prisma/client';
const questionWithRelation = Prisma.validator<Prisma.QuestionArgs>()({
include: { likes: true, books: true },
});
export type Question_WithRelation = Prisma.QuestionGetPayload<
typeof questionWithRelation
>;
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を使うと環境変数を扱える。
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 {}
パッケージを使うための設定
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に追加する。
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(好きな文字列)を追加する。
+ JWT_SECRET_KEY="curio-nest-jwt-secretkey"
DATABASE_URL="postgres://root:secret@localhost:5434/curio_nest?schema=public"
AuthDto
//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で使用するデータ型を定義しておく
//レスポンスのメッセージの型
export interface IMsg {
message: string;
}
//JwtのaccessTokenの型
export interface IJwt {
accessToken: string;
}
//csrfTokenの型
export interface ICsrf {
csrfToken: string;
}
AuthService
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
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
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を追加
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の型をプロジェクトで使用しているユーザーの型で拡張しておく。
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
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
import { IsOptional, IsString } from 'class-validator';
export class UpdateUserDto {
@IsString()
@IsOptional()
userName?: string;
@IsString()
@IsOptional()
profilePicture?: string;
@IsString()
@IsOptional()
coverPicture?: string;
}
UserService
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
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
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
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
export class CreateQuestionDto {
@IsString()
@IsNotEmpty()
title: string;
@IsBoolean()
isPrivate: boolean;
}
UpdateQuestionDto
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
import { IsNumber } from 'class-validator';
export class LinkQuestionToBookDto {
@IsNumber()
bookId: number;
}
QuestionService
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
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
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
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
export interface IGoogleBooks {
kind: string;
totalItems: number;
items: [];
}
インポート
$ yarn add axios @nestjs/axios
BookService
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
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を追加し、静的ファイルを配置するディレクトリのパスを指定する。
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
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateUploadDto {
@IsString()
@IsNotEmpty()
name: string;
}
CreateUploadUtil
ファイルアップロードで使用するメソッドをまとめたファイル
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
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
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
プロパティを修正
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
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
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
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の設定をする。
/** @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を以下の内容に変更する。
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を以下の内容に変更する。
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を設定
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
//新規登録orログインの入力フォームに適用するデータ型
export type AuthForm = {
email: string;
password: string;
};
SearchBookData
//本の検索でapiから取得するデータ型
export type SearchedBookData = {
kind: string;
totalItems: number;
items: Array<any>;
};
CreatingQuestion
//現在作成中のQuestionを管理するためのデータ型
export type CreatingQuestion = {
title: string;
isPrivate: boolean;
};
EditingQuestion
import { Like, Link } from "@prisma/client";
//現在編集中のQuestionを管理するためのデータ型
export type EditingQuestion = {
id: number;
title: string;
description?: string | null;
isPrivate: boolean;
books: Link[];
likes: Like[];
};