Skip to content

Commit

Permalink
feat: GET /books/{id} (#74)
Browse files Browse the repository at this point in the history
* feat: dto

* refactor: {Book=>BookCopy, BookInfo=>Book}

더 직관적인 표현으로 변경

* refactor: intSchema 사용

* refactor: paginationOptionsSchema

* fix: 엔티티 타입

* fix: 스키마 타입 변경

* feat: books API

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
scarf005 and autofix-ci[bot] authored Dec 15, 2024
1 parent 12be539 commit 1666820
Show file tree
Hide file tree
Showing 30 changed files with 688 additions and 162 deletions.
5 changes: 3 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; // Add this line
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HistoriesModule } from './histories/histories.module';
import { BooksModule } from './books/books.module';
import { dbConfig } from './config';

@Module({
imports: [TypeOrmModule.forRoot(dbConfig), HistoriesModule],
imports: [TypeOrmModule.forRoot(dbConfig), HistoriesModule, BooksModule],
controllers: [AppController],
providers: [AppService],
})
Expand Down
144 changes: 144 additions & 0 deletions backend/src/books/books.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UsePipes,
NotFoundException,
} from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
ApiParam,
ApiBody,
} from '@nestjs/swagger';
import { ZodValidationPipe } from '@anatine/zod-nestjs';
import { BooksService } from './books.service';
import {
BookDto,
BookGetResponseDto,
BookDetailResponseDto,
CreateBookCopyRequestDto,
CreateBookCopyResponseDto,
BookCopySearchResponseDto,
UpdateBookRequestDto,
} from './dto/books.dto';
import { PaginationOptionsDto } from 'src/common/dtos/page-options.dto';

@ApiTags('books')
@Controller('books')
@UsePipes(ZodValidationPipe)
export class BooksController {
constructor(private readonly booksService: BooksService) {}

@Get()
@ApiOperation({ summary: '도서 목록 조회' })
@ApiResponse({
status: 200,
description: '도서 목록 조회 성공',
type: BookGetResponseDto,
})
async findAll(
@Query() paginationOption: PaginationOptionsDto,
): Promise<BookGetResponseDto> {
const [items, count] = await this.booksService.findAll(paginationOption);
const categories = Object.entries(
Object.groupBy(
items.map((item) => item.category),
(x) => x.name,
),
)
.map(([name, count]) => ({ name, count: count?.length ?? 0 }))
.filter((item) => item.count > 0);

return {
items,
categories,
meta: {
itemCount: items.length,
currentPage: paginationOption.page,
itemsPerPage: paginationOption.take,
totalItems: count,
totalPages: Math.ceil(count / paginationOption.take),
},
};
}

@Get(':id')
@ApiOperation({ summary: '도서 상세 정보 조회' })
@ApiParam({ name: 'id', description: '도서 ID' })
@ApiResponse({
status: 200,
description: '도서 상세 정보 조회 성공',
type: BookDetailResponseDto,
})
async findOne(@Param('id') id: number): Promise<BookDetailResponseDto> {
const book = await this.booksService.findOne(id);
if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}
return book;
}

@Post(':id/book-copies')
@ApiOperation({ summary: '도서 복본 생성' })
@ApiParam({ name: 'id', description: '도서 ID' })
@ApiBody({ type: CreateBookCopyRequestDto })
@ApiResponse({
status: 201,
description: '도서 복본 생성 성공',
type: CreateBookCopyResponseDto,
})
async createCopy(
@Param('id') id: number,
@Body() createBookCopyDto: CreateBookCopyRequestDto,
): Promise<CreateBookCopyResponseDto> {
return this.booksService.createCopy(id, createBookCopyDto);
}

@Get(':id/book-copies')
@ApiOperation({ summary: '도서 복본 목록 조회' })
@ApiParam({ name: 'id', description: '도서 ID' })
@ApiResponse({
status: 200,
description: '도서 복본 목록 조회 성공',
type: BookCopySearchResponseDto,
})
async findCopies(
@Param('id') id: number,
): Promise<BookCopySearchResponseDto> {
return this.booksService.findCopies(id);
}

@Put(':id')
@ApiOperation({ summary: '도서 정보 수정' })
@ApiParam({ name: 'id', description: '도서 ID' })
@ApiBody({ type: UpdateBookRequestDto })
@ApiResponse({
status: 200,
description: '도서 정보 수정 성공',
type: BookDto,
})
async update(
@Param('id') id: number,
@Body() updateBookDto: UpdateBookRequestDto,
): Promise<BookDto> {
return this.booksService.update(id, updateBookDto);
}

@Delete(':id')
@ApiOperation({ summary: '도서 삭제' })
@ApiParam({ name: 'id', description: '도서 ID' })
@ApiResponse({
status: 204,
description: '도서 삭제 성공',
})
async remove(@Param('id') id: number): Promise<void> {
await this.booksService.remove(id);
}
}
13 changes: 13 additions & 0 deletions backend/src/books/books.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BooksController } from './books.controller';
import { BooksService } from './books.service';
import { Book, BookCopy } from '../entities';

@Module({
imports: [TypeOrmModule.forFeature([Book, BookCopy])],
controllers: [BooksController],
providers: [BooksService],
exports: [BooksService],
})
export class BooksModule {}
142 changes: 142 additions & 0 deletions backend/src/books/books.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Book, BookCopy } from '../entities';
import { PaginationOptionsDto } from '../common/dtos/page-options.dto';
import {
CreateBookCopyRequestDto,
CreateBookCopyResponseDto,
BookCopySearchResponseDto,
UpdateBookRequestDto,
BookDetailResponseDto,
BookDto,
} from './dto/books.dto';
import { BOOK_STATUS, getStatusString } from './constants';

@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book)
private readonly bookRepository: Repository<Book>,
@InjectRepository(BookCopy)
private readonly bookCopyRepository: Repository<BookCopy>,
) {}

async findAll(options: PaginationOptionsDto): Promise<[Book[], number]> {
return this.bookRepository.findAndCount({
take: options.take,
skip: (options.page - 1) * options.take,
order: { createdAt: options.order },
relations: ['category'],
});
}

private mapBookCopyToDto(copy: BookCopy) {
if (!copy.id) {
throw new Error('Book copy ID is required');
}
return {
id: copy.id,
callSign: copy.callSign,
donator: copy.donator,
status: getStatusString(copy.status),
dueDate: copy.lendings?.[0]?.returnedAt || null,
isLendable: copy.status === BOOK_STATUS.OK,
isReserved: Boolean(copy.reservations?.length),
};
}

async findOne(id: number): Promise<BookDetailResponseDto> {
const book = await this.bookRepository.findOne({
where: { id },
relations: ['category', 'books', 'books.lendings', 'books.reservations'],
});

if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

const bookCopies = (book.books || []).map((x) => this.mapBookCopyToDto(x));

return {
book,
bookCopies,
};
}

async createCopy(
id: number,
createBookCopyDto: CreateBookCopyRequestDto,
): Promise<CreateBookCopyResponseDto> {
const book = await this.bookRepository.findOne({
where: { id },
relations: ['category'],
});

if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

const bookCopy = this.bookCopyRepository.create({
info: book,
callSign: createBookCopyDto.callSign,
donator: createBookCopyDto.donator || null,
status: BOOK_STATUS.OK,
});

const savedCopy = await this.bookCopyRepository.save(bookCopy);

return {
book,
bookCopy: this.mapBookCopyToDto(savedCopy),
};
}

async findCopies(id: number): Promise<BookCopySearchResponseDto> {
const book = await this.bookRepository.findOne({
where: { id },
relations: ['books', 'books.lendings', 'books.reservations'],
});

if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

const bookCopies = (book.books || []).map(this.mapBookCopyToDto.bind(this));

return {
items: bookCopies,
meta: {
itemCount: bookCopies.length,
currentPage: 1,
itemsPerPage: bookCopies.length,
totalItems: bookCopies.length,
totalPages: 1,
},
};
}

async update(
id: number,
updateBookDto: UpdateBookRequestDto,
): Promise<BookDto> {
const book = await this.bookRepository.findOne({
where: { id },
relations: ['category'],
});

if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}

Object.assign(book, updateBookDto);
return this.bookRepository.save(book);
}

async remove(id: number): Promise<void> {
const result = await this.bookRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Book with ID ${id} not found`);
}
}
}
18 changes: 18 additions & 0 deletions backend/src/books/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const BOOK_STATUS = {
OK: 0,
LOST: 1,
DAMAGED: 2,
ASSIGNED: 3,
} as const;

export type BookStatus = (typeof BOOK_STATUS)[keyof typeof BOOK_STATUS];

export function getStatusString(status: BookStatus): string {
const statusMap: Record<BookStatus, string> = {
[BOOK_STATUS.OK]: 'AVAILABLE',
[BOOK_STATUS.ASSIGNED]: 'ASSIGNED',
[BOOK_STATUS.LOST]: 'LOST',
[BOOK_STATUS.DAMAGED]: 'DAMAGED',
};
return statusMap[status] || 'UNKNOWN';
}
Loading

0 comments on commit 1666820

Please sign in to comment.