第3章:バックエンド実装

はじめに

ft_transcendenceのバックエンドは、REST API、WebSocket、データベースアクセスを提供します。本章では、NestJSを使用した実装詳細を学びます。

---

1. NestJS基礎

1.1 プロジェクト構造

backend/
├── src/
│   ├── main.ts                 # エントリーポイント
│   ├── app.module.ts           # ルートモジュール
│   ├── auth/                   # 認証モジュール
│   │   ├── auth.module.ts
│   │   ├── auth.controller.ts
│   │   ├── auth.service.ts
│   │   ├── strategies/
│   │   │   ├── jwt.strategy.ts
│   │   │   └── 42.strategy.ts
│   │   ├── guards/
│   │   │   └── jwt-auth.guard.ts
│   │   └── dto/
│   ├── users/                  # ユーザーモジュール
│   │   ├── users.module.ts
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── entities/
│   │   │   └── user.entity.ts
│   │   └── dto/
│   ├── chat/                   # チャットモジュール
│   │   ├── chat.module.ts
│   │   ├── chat.controller.ts
│   │   ├── chat.service.ts
│   │   ├── chat.gateway.ts
│   │   └── entities/
│   ├── game/                   # ゲームモジュール
│   │   ├── game.module.ts
│   │   ├── game.controller.ts
│   │   ├── game.service.ts
│   │   ├── game.gateway.ts
│   │   └── entities/
│   └── common/                 # 共通モジュール
│       ├── filters/
│       ├── interceptors/
│       └── decorators/
├── test/
├── package.json
└── tsconfig.json

1.2 メインファイル

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';

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

    // CORS設定
    app.enableCors({
        origin: process.env.FRONTEND_URL,
        credentials: true,
    });

    // Cookieパーサー
    app.use(cookieParser());

    // バリデーションパイプ
    app.useGlobalPipes(new ValidationPipe({
        whitelist: true,           // DTOにないプロパティを削除
        forbidNonWhitelisted: true, // 余分なプロパティでエラー
        transform: true,           // 型変換を有効化
    }));

    // グローバルプレフィックス
    app.setGlobalPrefix('api');

    await app.listen(3000);
    console.log('Backend listening on port 3000');
}
bootstrap();

1.3 ルートモジュール

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ChatModule } from './chat/chat.module';
import { GameModule } from './game/game.module';

@Module({
    imports: [
        // 環境変数
        ConfigModule.forRoot({
            isGlobal: true,
        }),

        // データベース
        TypeOrmModule.forRoot({
            type: 'postgres',
            url: process.env.DATABASE_URL,
            entities: [__dirname + '/**/*.entity{.ts,.js}'],
            synchronize: process.env.NODE_ENV !== 'production',
        }),

        // 機能モジュール
        AuthModule,
        UsersModule,
        ChatModule,
        GameModule,
    ],
})
export class AppModule {}

---

2. 認証実装

2.1 42 OAuth Strategy

// src/auth/strategies/42.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-42';
import { AuthService } from '../auth.service';

@Injectable()
export class FortyTwoStrategy extends PassportStrategy(Strategy, '42') {
    constructor(private authService: AuthService) {
        super({
            clientID: process.env.INTRA_CLIENT_ID,
            clientSecret: process.env.INTRA_CLIENT_SECRET,
            callbackURL: process.env.INTRA_CALLBACK_URL,
            scope: ['public'],
        });
    }

    async validate(
        accessToken: string,
        refreshToken: string,
        profile: any,
        done: VerifyCallback,
    ) {
        const user = await this.authService.validateOAuthUser({
            intraId: profile.id,
            username: profile.username,
            email: profile.emails[0].value,
            avatarUrl: profile._json.image.link,
        });

        done(null, user);
    }
}

2.2 認証コントローラー

// src/auth/auth.controller.ts
import {
    Controller,
    Get,
    Post,
    Body,
    UseGuards,
    Req,
    Res,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';

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

    // 42 OAuth開始
    @Get('login/42')
    @UseGuards(AuthGuard('42'))
    login42() {
        // PassportがリダイレクトするのでこのメソッドBodyは実行されない
    }

    // OAuthコールバック
    @Get('callback')
    @UseGuards(AuthGuard('42'))
    async callback(@Req() req, @Res() res: Response) {
        const { token, user } = await this.authService.login(req.user);

        // JWT を HTTP-only cookie に設定
        res.cookie('jwt', token, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'lax',
            maxAge: 24 * 60 * 60 * 1000, // 1日
        });

        // 2FAが有効な場合
        if (user.twoFactorEnabled) {
            return res.redirect(`${process.env.FRONTEND_URL}/2fa`);
        }

        res.redirect(`${process.env.FRONTEND_URL}/`);
    }

    // 現在のユーザー情報
    @Get('me')
    @UseGuards(JwtAuthGuard)
    getMe(@CurrentUser() user) {
        return user;
    }

    // ログアウト
    @Post('logout')
    @UseGuards(JwtAuthGuard)
    logout(@Res() res: Response) {
        res.clearCookie('jwt');
        return res.json({ message: 'Logged out' });
    }

    // 2FA有効化
    @Post('2fa/enable')
    @UseGuards(JwtAuthGuard)
    async enable2FA(@CurrentUser() user) {
        return this.authService.generate2FASecret(user);
    }

    // 2FA検証
    @Post('2fa/verify')
    @UseGuards(JwtAuthGuard)
    async verify2FA(
        @CurrentUser() user,
        @Body('code') code: string,
    ) {
        return this.authService.verify2FA(user, code);
    }
}

2.3 認証サービス

// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';

@Injectable()
export class AuthService {
    constructor(
        private usersService: UsersService,
        private jwtService: JwtService,
    ) {}

    async validateOAuthUser(profile: any) {
        let user = await this.usersService.findByIntraId(profile.intraId);

        if (!user) {
            user = await this.usersService.create({
                intraId: profile.intraId,
                username: profile.username,
                email: profile.email,
                avatarUrl: profile.avatarUrl,
            });
        }

        return user;
    }

    async login(user: any) {
        const payload = {
            sub: user.id,
            username: user.username,
            twoFactorAuthenticated: !user.twoFactorEnabled,
        };

        return {
            token: this.jwtService.sign(payload),
            user,
        };
    }

    async generate2FASecret(user: any) {
        const secret = authenticator.generateSecret();
        const otpAuthUrl = authenticator.keyuri(
            user.email,
            'ft_transcendence',
            secret,
        );

        // 一時的に秘密を保存
        await this.usersService.update(user.id, {
            twoFactorSecret: secret,
        });

        const qrCode = await QRCode.toDataURL(otpAuthUrl);

        return { qrCode };
    }

    async verify2FA(user: any, code: string) {
        const isValid = authenticator.verify({
            token: code,
            secret: user.twoFactorSecret,
        });

        if (!isValid) {
            throw new UnauthorizedException('Invalid 2FA code');
        }

        await this.usersService.update(user.id, {
            twoFactorEnabled: true,
        });

        // 完全認証済みトークンを発行
        const payload = {
            sub: user.id,
            username: user.username,
            twoFactorAuthenticated: true,
        };

        return {
            token: this.jwtService.sign(payload),
        };
    }
}

2.4 JWT Guard

// src/auth/guards/jwt-auth.guard.ts
import {
    Injectable,
    ExecutionContext,
    UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    canActivate(context: ExecutionContext) {
        return super.canActivate(context);
    }

    handleRequest(err: any, user: any, info: any) {
        if (err || !user) {
            throw err || new UnauthorizedException('Authentication required');
        }

        // 2FAが有効だが未認証の場合
        if (user.twoFactorEnabled && !user.twoFactorAuthenticated) {
            throw new UnauthorizedException('2FA verification required');
        }

        return user;
    }
}

---

3. ユーザー管理

3.1 ユーザーエンティティ

// src/users/entities/user.entity.ts
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    CreateDateColumn,
    UpdateDateColumn,
    ManyToMany,
    JoinTable,
    OneToMany,
} from 'typeorm';
import { Exclude } from 'class-transformer';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true, nullable: true })
    intraId: number;

    @Column({ unique: true, length: 50 })
    username: string;

    @Column({ unique: true })
    email: string;

    @Column({ nullable: true })
    avatarUrl: string;

    @Column({ default: false })
    twoFactorEnabled: boolean;

    @Exclude()
    @Column({ nullable: true })
    twoFactorSecret: string;

    @Column({ default: 'offline' })
    status: 'online' | 'offline' | 'in_game';

    @Column({ default: 0 })
    wins: number;

    @Column({ default: 0 })
    losses: number;

    @Column({ default: 1 })
    ladderLevel: number;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    // フレンド関係(多対多)
    @ManyToMany(() => User, (user) => user.friendsOf)
    @JoinTable({
        name: 'friendships',
        joinColumn: { name: 'user_id', referencedColumnName: 'id' },
        inverseJoinColumn: { name: 'friend_id', referencedColumnName: 'id' },
    })
    friends: User[];

    @ManyToMany(() => User, (user) => user.friends)
    friendsOf: User[];

    // ブロックリスト
    @ManyToMany(() => User)
    @JoinTable({
        name: 'blocked_users',
        joinColumn: { name: 'blocker_id', referencedColumnName: 'id' },
        inverseJoinColumn: { name: 'blocked_id', referencedColumnName: 'id' },
    })
    blockedUsers: User[];
}

3.2 ユーザーサービス

// src/users/users.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private usersRepository: Repository<User>,
    ) {}

    async findAll(): Promise<User[]> {
        return this.usersRepository.find();
    }

    async findById(id: number): Promise<User> {
        const user = await this.usersRepository.findOne({ where: { id } });
        if (!user) {
            throw new NotFoundException(`User #${id} not found`);
        }
        return user;
    }

    async findByIntraId(intraId: number): Promise<User | null> {
        return this.usersRepository.findOne({ where: { intraId } });
    }

    async findByUsername(username: string): Promise<User | null> {
        return this.usersRepository.findOne({ where: { username } });
    }

    async create(createUserDto: CreateUserDto): Promise<User> {
        // ユーザー名重複チェック
        const existing = await this.findByUsername(createUserDto.username);
        if (existing) {
            throw new ConflictException('Username already taken');
        }

        const user = this.usersRepository.create(createUserDto);
        return this.usersRepository.save(user);
    }

    async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
        const user = await this.findById(id);

        // ユーザー名変更時の重複チェック
        if (updateUserDto.username && updateUserDto.username !== user.username) {
            const existing = await this.findByUsername(updateUserDto.username);
            if (existing) {
                throw new ConflictException('Username already taken');
            }
        }

        Object.assign(user, updateUserDto);
        return this.usersRepository.save(user);
    }

    async updateStatus(id: number, status: 'online' | 'offline' | 'in_game'): Promise<void> {
        await this.usersRepository.update(id, { status });
    }

    // フレンド関連
    async getFriends(userId: number): Promise<User[]> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['friends'],
        });
        return user?.friends || [];
    }

    async addFriend(userId: number, friendId: number): Promise<void> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['friends'],
        });
        const friend = await this.findById(friendId);

        if (!user.friends.some(f => f.id === friendId)) {
            user.friends.push(friend);
            await this.usersRepository.save(user);
        }
    }

    async removeFriend(userId: number, friendId: number): Promise<void> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['friends'],
        });

        user.friends = user.friends.filter(f => f.id !== friendId);
        await this.usersRepository.save(user);
    }

    // ブロック関連
    async getBlockedUsers(userId: number): Promise<User[]> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['blockedUsers'],
        });
        return user?.blockedUsers || [];
    }

    async blockUser(userId: number, blockedId: number): Promise<void> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['blockedUsers'],
        });
        const blocked = await this.findById(blockedId);

        if (!user.blockedUsers.some(b => b.id === blockedId)) {
            user.blockedUsers.push(blocked);
            await this.usersRepository.save(user);
        }
    }

    async isBlocked(userId: number, targetId: number): Promise<boolean> {
        const user = await this.usersRepository.findOne({
            where: { id: userId },
            relations: ['blockedUsers'],
        });
        return user?.blockedUsers.some(b => b.id === targetId) || false;
    }

    // 統計
    async getMatchHistory(userId: number) {
        return this.usersRepository.manager.query(`
            SELECT gh.*,
                   p1.username as player1_username,
                   p2.username as player2_username
            FROM game_history gh
            JOIN users p1 ON gh.player1_id = p1.id
            JOIN users p2 ON gh.player2_id = p2.id
            WHERE gh.player1_id = $1 OR gh.player2_id = $1
            ORDER BY gh.played_at DESC
            LIMIT 50
        `, [userId]);
    }

    async updateStats(userId: number, won: boolean): Promise<void> {
        if (won) {
            await this.usersRepository.increment({ id: userId }, 'wins', 1);
        } else {
            await this.usersRepository.increment({ id: userId }, 'losses', 1);
        }

        // ラダーレベル更新
        const user = await this.findById(userId);
        const newLevel = Math.floor((user.wins - user.losses) / 5) + 1;
        if (newLevel > 0 && newLevel !== user.ladderLevel) {
            await this.usersRepository.update(userId, { ladderLevel: newLevel });
        }
    }
}

3.3 ユーザーコントローラー

// src/users/users.controller.ts
import {
    Controller,
    Get,
    Post,
    Patch,
    Delete,
    Param,
    Body,
    UseGuards,
    UseInterceptors,
    UploadedFile,
    ParseIntPipe,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';

@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
    constructor(private usersService: UsersService) {}

    @Get()
    findAll() {
        return this.usersService.findAll();
    }

    @Get('me')
    getMe(@CurrentUser() user) {
        return user;
    }

    @Patch('me')
    updateMe(@CurrentUser() user, @Body() updateUserDto: UpdateUserDto) {
        return this.usersService.update(user.id, updateUserDto);
    }

    @Post('me/avatar')
    @UseInterceptors(FileInterceptor('avatar', {
        limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
        fileFilter: (req, file, cb) => {
            if (!file.mimetype.match(/^image\/(jpeg|png|gif)$/)) {
                return cb(new Error('Invalid file type'), false);
            }
            cb(null, true);
        },
    }))
    async uploadAvatar(
        @CurrentUser() user,
        @UploadedFile() file: Express.Multer.File,
    ) {
        // ファイル保存処理(実際はクラウドストレージを使用)
        const avatarUrl = `/uploads/${file.filename}`;
        return this.usersService.update(user.id, { avatarUrl });
    }

    @Get(':id')
    findOne(@Param('id', ParseIntPipe) id: number) {
        return this.usersService.findById(id);
    }

    @Get(':id/match-history')
    getMatchHistory(@Param('id', ParseIntPipe) id: number) {
        return this.usersService.getMatchHistory(id);
    }

    // フレンド
    @Get('friends')
    getFriends(@CurrentUser() user) {
        return this.usersService.getFriends(user.id);
    }

    @Post('friends/:id')
    addFriend(
        @CurrentUser() user,
        @Param('id', ParseIntPipe) friendId: number,
    ) {
        return this.usersService.addFriend(user.id, friendId);
    }

    @Delete('friends/:id')
    removeFriend(
        @CurrentUser() user,
        @Param('id', ParseIntPipe) friendId: number,
    ) {
        return this.usersService.removeFriend(user.id, friendId);
    }

    // ブロック
    @Get('blocks')
    getBlockedUsers(@CurrentUser() user) {
        return this.usersService.getBlockedUsers(user.id);
    }

    @Post('blocks/:id')
    blockUser(
        @CurrentUser() user,
        @Param('id', ParseIntPipe) blockedId: number,
    ) {
        return this.usersService.blockUser(user.id, blockedId);
    }
}

---

4. チャット実装

4.1 チャットエンティティ

// src/chat/entities/channel.entity.ts
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    CreateDateColumn,
    ManyToOne,
    OneToMany,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { ChannelMember } from './channel-member.entity';
import { Message } from './message.entity';

@Entity('channels')
export class Channel {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ length: 100 })
    name: string;

    @Column({ default: 'public' })
    type: 'public' | 'private' | 'protected' | 'direct';

    @Column({ nullable: true })
    password: string;

    @ManyToOne(() => User, { nullable: true })
    owner: User;

    @OneToMany(() => ChannelMember, (member) => member.channel)
    members: ChannelMember[];

    @OneToMany(() => Message, (message) => message.channel)
    messages: Message[];

    @CreateDateColumn()
    createdAt: Date;
}

// src/chat/entities/channel-member.entity.ts
@Entity('channel_members')
export class ChannelMember {
    @PrimaryGeneratedColumn()
    id: number;

    @ManyToOne(() => Channel, (channel) => channel.members)
    channel: Channel;

    @ManyToOne(() => User)
    user: User;

    @Column({ default: 'member' })
    role: 'owner' | 'admin' | 'member';

    @Column({ nullable: true })
    mutedUntil: Date;

    @Column({ default: false })
    banned: boolean;

    @CreateDateColumn()
    joinedAt: Date;
}

// src/chat/entities/message.entity.ts
@Entity('messages')
export class Message {
    @PrimaryGeneratedColumn()
    id: number;

    @ManyToOne(() => Channel, (channel) => channel.messages)
    channel: Channel;

    @ManyToOne(() => User)
    sender: User;

    @Column('text')
    content: string;

    @CreateDateColumn()
    createdAt: Date;
}

4.2 チャットサービス

// src/chat/chat.service.ts
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Channel } from './entities/channel.entity';
import { ChannelMember } from './entities/channel-member.entity';
import { Message } from './entities/message.entity';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class ChatService {
    constructor(
        @InjectRepository(Channel)
        private channelRepository: Repository<Channel>,
        @InjectRepository(ChannelMember)
        private memberRepository: Repository<ChannelMember>,
        @InjectRepository(Message)
        private messageRepository: Repository<Message>,
        private usersService: UsersService,
    ) {}

    // チャンネル作成
    async createChannel(
        ownerId: number,
        name: string,
        type: string,
        password?: string,
    ): Promise<Channel> {
        const owner = await this.usersService.findById(ownerId);

        const channel = this.channelRepository.create({
            name,
            type: type as any,
            password: password ? await bcrypt.hash(password, 10) : null,
            owner,
        });

        const savedChannel = await this.channelRepository.save(channel);

        // オーナーをメンバーに追加
        await this.memberRepository.save({
            channel: savedChannel,
            user: owner,
            role: 'owner',
        });

        return savedChannel;
    }

    // チャンネル参加
    async joinChannel(
        userId: number,
        channelId: number,
        password?: string,
    ): Promise<void> {
        const channel = await this.channelRepository.findOne({
            where: { id: channelId },
        });

        if (!channel) {
            throw new NotFoundException('Channel not found');
        }

        // パスワードチェック
        if (channel.type === 'protected') {
            if (!password || !await bcrypt.compare(password, channel.password)) {
                throw new ForbiddenException('Invalid password');
            }
        }

        // 既にメンバーかチェック
        const existingMember = await this.memberRepository.findOne({
            where: {
                channel: { id: channelId },
                user: { id: userId },
            },
        });

        if (existingMember) {
            if (existingMember.banned) {
                throw new ForbiddenException('You are banned from this channel');
            }
            return; // 既にメンバー
        }

        const user = await this.usersService.findById(userId);
        await this.memberRepository.save({
            channel,
            user,
            role: 'member',
        });
    }

    // メッセージ送信
    async sendMessage(
        userId: number,
        channelId: number,
        content: string,
    ): Promise<Message> {
        // メンバーシップとミュート確認
        const member = await this.getMember(channelId, userId);

        if (!member) {
            throw new ForbiddenException('Not a member of this channel');
        }

        if (member.mutedUntil && member.mutedUntil > new Date()) {
            throw new ForbiddenException('You are muted');
        }

        const channel = await this.channelRepository.findOne({
            where: { id: channelId },
        });
        const sender = await this.usersService.findById(userId);

        const message = this.messageRepository.create({
            channel,
            sender,
            content,
        });

        return this.messageRepository.save(message);
    }

    // メッセージ取得
    async getMessages(
        channelId: number,
        limit = 50,
        before?: number,
    ): Promise<Message[]> {
        const query = this.messageRepository
            .createQueryBuilder('message')
            .leftJoinAndSelect('message.sender', 'sender')
            .where('message.channelId = :channelId', { channelId })
            .orderBy('message.createdAt', 'DESC')
            .take(limit);

        if (before) {
            query.andWhere('message.id < :before', { before });
        }

        return query.getMany();
    }

    // 管理者機能
    async kickUser(
        adminId: number,
        channelId: number,
        targetId: number,
    ): Promise<void> {
        await this.checkAdminPermission(adminId, channelId);

        const targetMember = await this.getMember(channelId, targetId);
        if (targetMember?.role === 'owner') {
            throw new ForbiddenException('Cannot kick the owner');
        }

        await this.memberRepository.delete({
            channel: { id: channelId },
            user: { id: targetId },
        });
    }

    async muteUser(
        adminId: number,
        channelId: number,
        targetId: number,
        duration: number, // 秒
    ): Promise<void> {
        await this.checkAdminPermission(adminId, channelId);

        const mutedUntil = new Date(Date.now() + duration * 1000);
        await this.memberRepository.update(
            { channel: { id: channelId }, user: { id: targetId } },
            { mutedUntil },
        );
    }

    async banUser(
        adminId: number,
        channelId: number,
        targetId: number,
    ): Promise<void> {
        await this.checkAdminPermission(adminId, channelId);

        const targetMember = await this.getMember(channelId, targetId);
        if (targetMember?.role === 'owner') {
            throw new ForbiddenException('Cannot ban the owner');
        }

        await this.memberRepository.update(
            { channel: { id: channelId }, user: { id: targetId } },
            { banned: true },
        );
    }

    // ヘルパー
    private async getMember(
        channelId: number,
        userId: number,
    ): Promise<ChannelMember | null> {
        return this.memberRepository.findOne({
            where: {
                channel: { id: channelId },
                user: { id: userId },
            },
        });
    }

    private async checkAdminPermission(
        userId: number,
        channelId: number,
    ): Promise<void> {
        const member = await this.getMember(channelId, userId);
        if (!member || (member.role !== 'owner' && member.role !== 'admin')) {
            throw new ForbiddenException('Admin permission required');
        }
    }
}

4.3 チャットゲートウェイ

// src/chat/chat.gateway.ts
import {
    WebSocketGateway,
    WebSocketServer,
    SubscribeMessage,
    ConnectedSocket,
    MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { UseGuards } from '@nestjs/common';
import { WsJwtGuard } from '../auth/guards/ws-jwt.guard';
import { ChatService } from './chat.service';
import { UsersService } from '../users/users.service';

@WebSocketGateway({
    namespace: 'chat',
    cors: {
        origin: process.env.FRONTEND_URL,
        credentials: true,
    },
})
export class ChatGateway {
    @WebSocketServer()
    server: Server;

    constructor(
        private chatService: ChatService,
        private usersService: UsersService,
    ) {}

    @UseGuards(WsJwtGuard)
    @SubscribeMessage('chat:join')
    async handleJoin(
        @ConnectedSocket() client: Socket,
        @MessageBody() data: { channelId: number; password?: string },
    ) {
        try {
            const userId = client.data.user.id;

            await this.chatService.joinChannel(
                userId,
                data.channelId,
                data.password,
            );

            client.join(`channel:${data.channelId}`);

            // 参加通知
            this.server.to(`channel:${data.channelId}`).emit('chat:user:joined', {
                channelId: data.channelId,
                user: client.data.user,
            });

            return { success: true };
        } catch (error) {
            return { success: false, error: error.message };
        }
    }

    @UseGuards(WsJwtGuard)
    @SubscribeMessage('chat:leave')
    async handleLeave(
        @ConnectedSocket() client: Socket,
        @MessageBody() data: { channelId: number },
    ) {
        client.leave(`channel:${data.channelId}`);

        this.server.to(`channel:${data.channelId}`).emit('chat:user:left', {
            channelId: data.channelId,
            userId: client.data.user.id,
        });

        return { success: true };
    }

    @UseGuards(WsJwtGuard)
    @SubscribeMessage('chat:message')
    async handleMessage(
        @ConnectedSocket() client: Socket,
        @MessageBody() data: { channelId: number; content: string },
    ) {
        try {
            const userId = client.data.user.id;

            // ブロックチェックを含むメッセージ送信
            const message = await this.chatService.sendMessage(
                userId,
                data.channelId,
                data.content,
            );

            // チャンネル全体に配信(ブロックしているユーザーはフロントエンドでフィルタ)
            this.server.to(`channel:${data.channelId}`).emit('chat:message:new', {
                id: message.id,
                channelId: data.channelId,
                sender: client.data.user,
                content: message.content,
                createdAt: message.createdAt,
            });

            return { success: true };
        } catch (error) {
            return { success: false, error: error.message };
        }
    }

    @UseGuards(WsJwtGuard)
    @SubscribeMessage('chat:typing')
    handleTyping(
        @ConnectedSocket() client: Socket,
        @MessageBody() data: { channelId: number },
    ) {
        client.to(`channel:${data.channelId}`).emit('chat:typing', {
            channelId: data.channelId,
            user: client.data.user,
        });
    }

    // 管理者コマンド
    @UseGuards(WsJwtGuard)
    @SubscribeMessage('chat:kick')
    async handleKick(
        @ConnectedSocket() client: Socket,
        @MessageBody() data: { channelId: number; targetId: number },
    ) {
        try {
            await this.chatService.kickUser(
                client.data.user.id,
                data.channelId,
                data.targetId,
            );

            this.server.to(`channel:${data.channelId}`).emit('chat:user:kicked', {
                channelId: data.channelId,
                targetId: data.targetId,
            });

            return { success: true };
        } catch (error) {
            return { success: false, error: error.message };
        }
    }
}

---

5. ゲームマッチング

5.1 マッチメイキングサービス

// src/game/matchmaking.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

interface QueuedPlayer {
    id: number;
    username: string;
    rating: number;
    joinedAt: Date;
    socketId: string;
}

@Injectable()
export class MatchmakingService {
    private queue: QueuedPlayer[] = [];
    private readonly MATCH_RANGE = 200; // レーティング差
    private readonly EXPAND_INTERVAL = 10000; // 10秒ごとに範囲拡大

    constructor(private eventEmitter: EventEmitter2) {}

    addToQueue(player: QueuedPlayer): void {
        // 既にキューにいる場合は追加しない
        if (this.queue.some(p => p.id === player.id)) {
            return;
        }

        this.queue.push(player);
        this.tryMatch();
    }

    removeFromQueue(playerId: number): void {
        this.queue = this.queue.filter(p => p.id !== playerId);
    }

    private tryMatch(): void {
        if (this.queue.length < 2) return;

        // レーティングでソート
        this.queue.sort((a, b) => a.rating - b.rating);

        for (let i = 0; i < this.queue.length - 1; i++) {
            const player1 = this.queue[i];
            const player2 = this.queue[i + 1];

            // 待機時間に応じて許容範囲を拡大
            const waitTime1 = Date.now() - player1.joinedAt.getTime();
            const waitTime2 = Date.now() - player2.joinedAt.getTime();
            const expandFactor = Math.min(waitTime1, waitTime2) / this.EXPAND_INTERVAL;
            const allowedDiff = this.MATCH_RANGE * (1 + expandFactor);

            const ratingDiff = Math.abs(player1.rating - player2.rating);

            if (ratingDiff <= allowedDiff) {
                // マッチ成立
                this.queue = this.queue.filter(
                    p => p.id !== player1.id && p.id !== player2.id
                );

                this.eventEmitter.emit('match.created', {
                    player1,
                    player2,
                });

                break;
            }
        }
    }

    getQueueStatus(playerId: number): { position: number; estimatedWait: number } | null {
        const index = this.queue.findIndex(p => p.id === playerId);
        if (index === -1) return null;

        return {
            position: index + 1,
            estimatedWait: Math.max(0, (index * 30) -
                (Date.now() - this.queue[index].joinedAt.getTime()) / 1000),
        };
    }
}

5.2 ゲーム状態管理

// src/game/game.state.ts

export interface Ball {
    x: number;
    y: number;
    vx: number;
    vy: number;
    radius: number;
}

export interface Paddle {
    y: number;
    width: number;
    height: number;
}

export interface GameState {
    id: string;
    player1: {
        id: number;
        username: string;
        paddle: Paddle;
        score: number;
        ready: boolean;
    };
    player2: {
        id: number;
        username: string;
        paddle: Paddle;
        score: number;
        ready: boolean;
    };
    ball: Ball;
    status: 'waiting' | 'countdown' | 'playing' | 'paused' | 'finished';
    winner: number | null;
    createdAt: Date;
    startedAt: Date | null;
}

export class GameEngine {
    private readonly WIDTH = 800;
    private readonly HEIGHT = 600;
    private readonly PADDLE_SPEED = 10;
    private readonly BALL_SPEED = 7;
    private readonly WIN_SCORE = 11;

    constructor(private state: GameState) {}

    update(): void {
        if (this.state.status !== 'playing') return;

        this.moveBall();
        this.checkCollisions();
        this.checkScore();
    }

    private moveBall(): void {
        this.state.ball.x += this.state.ball.vx;
        this.state.ball.y += this.state.ball.vy;
    }

    private checkCollisions(): void {
        const ball = this.state.ball;
        const paddle1 = this.state.player1.paddle;
        const paddle2 = this.state.player2.paddle;

        // 上下の壁
        if (ball.y - ball.radius <= 0 || ball.y + ball.radius >= this.HEIGHT) {
            ball.vy *= -1;
        }

        // Player1のパドル(左側)
        if (ball.x - ball.radius <= paddle1.width) {
            if (ball.y >= paddle1.y && ball.y <= paddle1.y + paddle1.height) {
                ball.vx = Math.abs(ball.vx); // 右に跳ね返す

                // パドルの当たった位置で角度を変える
                const hitPos = (ball.y - paddle1.y) / paddle1.height;
                ball.vy = (hitPos - 0.5) * this.BALL_SPEED * 2;
            }
        }

        // Player2のパドル(右側)
        if (ball.x + ball.radius >= this.WIDTH - paddle2.width) {
            if (ball.y >= paddle2.y && ball.y <= paddle2.y + paddle2.height) {
                ball.vx = -Math.abs(ball.vx); // 左に跳ね返す

                const hitPos = (ball.y - paddle2.y) / paddle2.height;
                ball.vy = (hitPos - 0.5) * this.BALL_SPEED * 2;
            }
        }
    }

    private checkScore(): void {
        const ball = this.state.ball;

        // Player2得点(ボールが左端を越えた)
        if (ball.x < 0) {
            this.state.player2.score++;
            this.resetBall(-1);
        }

        // Player1得点(ボールが右端を越えた)
        if (ball.x > this.WIDTH) {
            this.state.player1.score++;
            this.resetBall(1);
        }

        // 勝敗判定
        if (this.state.player1.score >= this.WIN_SCORE) {
            this.state.status = 'finished';
            this.state.winner = this.state.player1.id;
        } else if (this.state.player2.score >= this.WIN_SCORE) {
            this.state.status = 'finished';
            this.state.winner = this.state.player2.id;
        }
    }

    private resetBall(direction: number): void {
        this.state.ball = {
            x: this.WIDTH / 2,
            y: this.HEIGHT / 2,
            vx: this.BALL_SPEED * direction,
            vy: (Math.random() - 0.5) * this.BALL_SPEED,
            radius: 10,
        };
    }

    movePaddle(playerId: number, direction: 'up' | 'down'): void {
        const paddle = playerId === this.state.player1.id
            ? this.state.player1.paddle
            : this.state.player2.paddle;

        const delta = direction === 'up' ? -this.PADDLE_SPEED : this.PADDLE_SPEED;
        const newY = paddle.y + delta;

        // 画面内に収める
        paddle.y = Math.max(0, Math.min(this.HEIGHT - paddle.height, newY));
    }

    getState(): GameState {
        return this.state;
    }
}

---

まとめ

本章で学んだこと:

  • NestJS基礎: モジュール、コントローラー、サービス
  • 認証実装: OAuth 2.0、JWT、2FA
  • ユーザー管理: CRUD、フレンド、ブロック
  • チャット実装: チャンネル、メッセージ、WebSocket
  • ゲームマッチング: キュー、レーティング
  • ゲーム状態: 物理演算、衝突判定

次章では、フロントエンドの実装を学びます。