From a44cd890723ab992f5efc8178ea3b98200b7237d Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 24 Feb 2025 20:54:58 +0000 Subject: [PATCH] Added modules for books & series. --- backend/nestjs-seshat-api/src/app.module.ts | 6 +- .../src/books/books.controller.spec.ts | 18 ++ .../src/books/books.controller.ts | 94 ++++++++++ .../src/books/books.module.ts | 29 +++ .../src/books/books.service.spec.ts | 18 ++ .../src/books/books.service.ts | 169 ++++++++++++++++++ .../src/books/dto/book-status.dto.ts | 19 ++ .../src/books/dto/create-book-origin.dto.ts | 19 ++ .../src/books/dto/create-book.dto.ts | 35 ++++ .../src/books/dto/update-book-origin.dto.ts | 23 +++ .../src/books/dto/update-book.dto.ts | 40 +++++ .../src/books/entities/book-origin.entity.ts | 24 +++ .../src/books/entities/book-status.entity.ts | 30 ++++ .../src/books/entities/book.entity.ts | 47 +++++ .../src/providers/google/google.service.ts | 82 +++++---- .../src/providers/providers.module.ts | 2 +- .../src/series/dto/create-series.dto.ts | 15 ++ .../src/series/dto/delete-series.dto.ts | 11 ++ .../src/series/dto/update-series.dto.ts | 20 +++ .../src/series/entities/series.entity.ts | 25 +++ .../src/series/series.module.ts | 22 +++ .../src/series/series.service.spec.ts | 18 ++ .../src/series/series.service.ts | 25 +++ .../src/shared/enums/book_origin_type.ts | 15 ++ .../src/users/entities/users.entity.ts | 2 +- 25 files changed, 767 insertions(+), 41 deletions(-) create mode 100644 backend/nestjs-seshat-api/src/books/books.controller.spec.ts create mode 100644 backend/nestjs-seshat-api/src/books/books.controller.ts create mode 100644 backend/nestjs-seshat-api/src/books/books.module.ts create mode 100644 backend/nestjs-seshat-api/src/books/books.service.spec.ts create mode 100644 backend/nestjs-seshat-api/src/books/books.service.ts create mode 100644 backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts create mode 100644 backend/nestjs-seshat-api/src/books/dto/create-book-origin.dto.ts create mode 100644 backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts create mode 100644 backend/nestjs-seshat-api/src/books/dto/update-book-origin.dto.ts create mode 100644 backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts create mode 100644 backend/nestjs-seshat-api/src/books/entities/book-origin.entity.ts create mode 100644 backend/nestjs-seshat-api/src/books/entities/book-status.entity.ts create mode 100644 backend/nestjs-seshat-api/src/books/entities/book.entity.ts create mode 100644 backend/nestjs-seshat-api/src/series/dto/create-series.dto.ts create mode 100644 backend/nestjs-seshat-api/src/series/dto/delete-series.dto.ts create mode 100644 backend/nestjs-seshat-api/src/series/dto/update-series.dto.ts create mode 100644 backend/nestjs-seshat-api/src/series/entities/series.entity.ts create mode 100644 backend/nestjs-seshat-api/src/series/series.module.ts create mode 100644 backend/nestjs-seshat-api/src/series/series.service.spec.ts create mode 100644 backend/nestjs-seshat-api/src/series/series.service.ts create mode 100644 backend/nestjs-seshat-api/src/shared/enums/book_origin_type.ts diff --git a/backend/nestjs-seshat-api/src/app.module.ts b/backend/nestjs-seshat-api/src/app.module.ts index 3840e57..086df15 100644 --- a/backend/nestjs-seshat-api/src/app.module.ts +++ b/backend/nestjs-seshat-api/src/app.module.ts @@ -12,7 +12,9 @@ import { UserEntity } from './users/entities/users.entity'; import { AuthModule } from './auth/auth.module'; import { LoggerModule } from 'nestjs-pino'; import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers'; +import { BooksModule } from './books/books.module'; import { ProvidersModule } from './providers/providers.module'; +import { SeriesModule } from './series/series.module'; @Module({ imports: [ @@ -54,7 +56,9 @@ import { ProvidersModule } from './providers/providers.module'; } } }), - ProvidersModule + BooksModule, + ProvidersModule, + SeriesModule ], controllers: [AppController], providers: [AppService, UsersService], diff --git a/backend/nestjs-seshat-api/src/books/books.controller.spec.ts b/backend/nestjs-seshat-api/src/books/books.controller.spec.ts new file mode 100644 index 0000000..e141d1f --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/books.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BooksController } from './books.controller'; + +describe('BooksController', () => { + let controller: BooksController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BooksController], + }).compile(); + + controller = module.get(BooksController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/nestjs-seshat-api/src/books/books.controller.ts b/backend/nestjs-seshat-api/src/books/books.controller.ts new file mode 100644 index 0000000..82daae3 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/books.controller.ts @@ -0,0 +1,94 @@ +import { Body, Controller, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard'; +import { BooksService } from './books.service'; +import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto'; +import { UpdateBookOriginDto } from './dto/update-book-origin.dto'; +import { UpdateBookDto } from './dto/update-book.dto'; +import { PinoLogger } from 'nestjs-pino'; +import { QueryFailedError } from 'typeorm'; + +@UseGuards(JwtAccessGuard) +@Controller('books') +export class BooksController { + constructor( + private books: BooksService, + private logger: PinoLogger, + ) { } + + + @Post('') + async CreateBook( + @Request() req, + @Body() body: BookSearchResultDto, + @Res({ passthrough: true }) response: Response, + ) { + try { + return { + success: true, + data: await this.books.createBook(body), + }; + } catch (err) { + if (err instanceof QueryFailedError) { + if (err.driverError.code == '23505') { + // Book exists already. + response.statusCode = 400; + return { + success: false, + error_message: 'The book has already been added previously.', + } + } + } + + this.logger.error({ + class: BooksController.name, + method: this.CreateBook.name, + user: req.user, + msg: 'Failed to create book.', + error: err, + }); + + response.statusCode = 500; + return { + success: false, + error_message: 'Something went wrong while adding the book.', + } + } + } + + @Get('') + async GetBooksFromUser( + @Request() req, + ) { + return { + success: true, + data: await this.books.findBookStatusesTrackedBy(req.user.userId), + }; + } + + @Put('') + async UpdateBook( + @Body() body: UpdateBookDto, + ) { + const data = { ...body }; + delete data['bookId']; + + const result = await this.books.updateBook(body.bookId, data); + return { + success: result?.affected == 1, + }; + } + + @Put('origins') + async UpdateBookOrigin( + @Body() body: UpdateBookOriginDto, + ) { + const data = { ...body }; + delete data['bookOriginId']; + + const result = await this.books.updateBookOrigin(body.bookOriginId, data); + return { + success: result?.affected == 1, + }; + } +} diff --git a/backend/nestjs-seshat-api/src/books/books.module.ts b/backend/nestjs-seshat-api/src/books/books.module.ts new file mode 100644 index 0000000..42584d8 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/books.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { BooksController } from './books.controller'; +import { BooksService } from './books.service'; +import { BookEntity } from './entities/book.entity'; +import { BookOriginEntity } from './entities/book-origin.entity'; +import { BookStatusEntity } from './entities/book-status.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { ProvidersModule } from 'src/providers/providers.module'; +import { SeriesModule } from 'src/series/series.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + BookEntity, + BookOriginEntity, + BookStatusEntity, + ]), + SeriesModule, + HttpModule, + ProvidersModule, + ], + controllers: [BooksController], + exports: [ + BooksService + ], + providers: [BooksService] +}) +export class BooksModule {} diff --git a/backend/nestjs-seshat-api/src/books/books.service.spec.ts b/backend/nestjs-seshat-api/src/books/books.service.spec.ts new file mode 100644 index 0000000..6343e6b --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/books.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BooksService } from './books.service'; + +describe('BooksService', () => { + let service: BooksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BooksService], + }).compile(); + + service = module.get(BooksService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/nestjs-seshat-api/src/books/books.service.ts b/backend/nestjs-seshat-api/src/books/books.service.ts new file mode 100644 index 0000000..319f00f --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/books.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BookEntity } from './entities/book.entity'; +import { In, InsertResult, Repository } from 'typeorm'; +import { BookOriginEntity } from './entities/book-origin.entity'; +import { BookStatusEntity } from './entities/book-status.entity'; +import { UUID } from 'crypto'; +import { PinoLogger } from 'nestjs-pino'; +import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto'; +import { BookOriginType } from 'src/shared/enums/book_origin_type'; +import { CreateBookDto } from './dto/create-book.dto'; +import { CreateBookOriginDto } from './dto/create-book-origin.dto'; +import { CreateBookStatusDto as BookStatusDto } from './dto/book-status.dto'; +import { SeriesService } from 'src/series/series.service'; + +@Injectable() +export class BooksService { + constructor( + @InjectRepository(BookEntity) + private bookRepository: Repository, + @InjectRepository(BookOriginEntity) + private bookOriginRepository: Repository, + @InjectRepository(BookStatusEntity) + private bookStatusRepository: Repository, + private series: SeriesService, + private logger: PinoLogger, + ) { } + + + async createBook(book: BookSearchResultDto) { + this.logger.debug({ + class: BooksService.name, + method: this.createBook.name, + book: book, + msg: 'Saving book to database...', + }); + + if (book.providerSeriesId) { + await this.series.updateSeries({ + providerSeriesId: book.providerSeriesId, + title: book.title, + provider: book.provider, + }); + + this.logger.debug({ + class: BooksService.name, + method: this.createBook.name, + series: book.providerSeriesId, + msg: 'Series saved to database.', + }); + } + + const createBook: CreateBookDto = { + title: book.title, + desc: book.desc, + providerSeriesId: book.providerSeriesId, + providerBookId: book.providerBookId, + volume: book.volume, + provider: book.provider, + publishedAt: book.publishedAt, + }; + const data = await this.createBookInternal(createBook); + const bookId = data.identifiers[0]['bookId']; + + const tasks = []; + + tasks.push(book.authors.map(author => this.addBookOrigin(bookId, BookOriginType.AUTHOR, author))); + tasks.push(book.categories.map(category => this.addBookOrigin(bookId, BookOriginType.CATEGORY, category))); + tasks.push(this.addBookOrigin(bookId, BookOriginType.LANGUAGE, book.language)); + if (book.maturityRating) { + tasks.push(this.addBookOrigin(bookId, BookOriginType.MATURITY_RATING, book.maturityRating)); + } + if (book.thumbnail) { + tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_THUMBNAIL, book.thumbnail)); + } + if (book.url) { + tasks.push(this.addBookOrigin(bookId, BookOriginType.PROVIDER_URL, book.url)); + } + + if ('ISBN_10' in book.industryIdentifiers) { + tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_10'])); + } + + if ('ISBN_13' in book.industryIdentifiers) { + tasks.push(this.addBookOrigin(bookId, BookOriginType.ISBN_10, book.industryIdentifiers['ISBN_13'])); + } + + await Promise.all(tasks); + + this.logger.info({ + class: BooksService.name, + method: this.createBook.name, + book: book, + msg: 'Book saved to database.', + }); + + return bookId; + } + + private async createBookInternal(book: CreateBookDto): Promise { + const entity = this.bookRepository.create(book); + return await this.bookRepository.createQueryBuilder() + .insert() + .into(BookEntity) + .values(entity) + .returning('book_id') + .execute(); + } + + async addBookOrigin(bookId: UUID, type: BookOriginType, value: string): Promise { + return await this.bookOriginRepository.insert({ + bookId, + type, + value, + }); + } + + async findBooksByIds(bookIds: UUID[]) { + return await this.bookRepository.find({ + where: { + bookId: In(bookIds) + } + }) + } + + async findBookStatusesTrackedBy(userId: UUID): Promise { + return await this.bookStatusRepository.createQueryBuilder('s') + .select(['s.book_id', 's.user_id']) + .where('s.user_id = :id', { id: userId }) + .innerJoin('s.book', 'b') + .addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider']) + .getMany(); + } + + async findSeriesTrackedBy(userId: UUID) { + return await this.bookStatusRepository.createQueryBuilder('s') + .where({ + whereFactory: { + userId: userId + } + }) + .innerJoin('s.book', 'b') + .addSelect(['b.provider', 'b.providerSeriesId']) + .distinctOn(['b.provider', 'b.providerSeriesId']) + .getMany(); + } + + async updateBook(bookId: UUID, update: CreateBookDto) { + return await this.bookRepository.update({ + bookId, + }, update); + } + + async updateBookOrigin(bookOriginId: UUID, update: CreateBookOriginDto) { + return await this.bookOriginRepository.update({ + bookOriginId + }, update); + } + + async updateBookStatus(status: BookStatusDto) { + status.modifiedAt = new Date(); + await this.bookStatusRepository.createQueryBuilder() + .insert() + .values(status) + .orUpdate(['user_id', 'book_id'], ['state', 'modified_at'], { skipUpdateIfNoValuesChanged: true }) + .execute(); + return await this.bookStatusRepository.upsert(status, ['book_id']); + } +} diff --git a/backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts b/backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts new file mode 100644 index 0000000..d5599ed --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { UUID } from 'crypto'; + +export class CreateBookStatusDto { + @IsUUID() + @IsNotEmpty() + readonly bookId: UUID; + + @IsUUID() + @IsNotEmpty() + @IsOptional() + readonly userId: UUID; + + @IsString() + @IsNotEmpty() + state: string; + + modifiedAt: Date; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/dto/create-book-origin.dto.ts b/backend/nestjs-seshat-api/src/books/dto/create-book-origin.dto.ts new file mode 100644 index 0000000..af4116e --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/dto/create-book-origin.dto.ts @@ -0,0 +1,19 @@ +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator'; +import { UUID } from 'crypto'; +import { BookOriginType } from 'src/shared/enums/book_origin_type'; + +export class CreateBookOriginDto { + @IsUUID() + @IsNotEmpty() + readonly bookId: UUID; + + @IsNumber() + @IsNotEmpty() + @Transform(({ value }) => value as BookOriginType) + type: BookOriginType; + + @IsString() + @IsNotEmpty() + value: string; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts b/backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts new file mode 100644 index 0000000..bbdc0f3 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts @@ -0,0 +1,35 @@ +import { Transform } from 'class-transformer'; +import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator'; + +export class CreateBookDto { + @IsString() + @IsOptional() + providerSeriesId: string; + + @IsString() + @IsNotEmpty() + providerBookId: string; + + @IsString() + @IsNotEmpty() + @MaxLength(128) + title: string; + + @IsString() + @MaxLength(512) + desc: string; + + @IsNumber() + @IsOptional() + @IsPositive() + volume: number | null; + + @IsString() + @IsNotEmpty() + provider: string; + + @IsDate() + @IsNotEmpty() + @Transform(({ value }) => new Date(value)) + publishedAt: Date +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/dto/update-book-origin.dto.ts b/backend/nestjs-seshat-api/src/books/dto/update-book-origin.dto.ts new file mode 100644 index 0000000..f465d85 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/dto/update-book-origin.dto.ts @@ -0,0 +1,23 @@ +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator'; +import { UUID } from 'crypto'; +import { BookOriginType } from 'src/shared/enums/book_origin_type'; + +export class UpdateBookOriginDto { + @IsUUID() + @IsNotEmpty() + readonly bookOriginId: UUID; + + @IsUUID() + @IsNotEmpty() + readonly bookId: UUID; + + @IsNumber() + @IsNotEmpty() + @Transform(({ value }) => value as BookOriginType) + type: BookOriginType; + + @IsString() + @IsNotEmpty() + value: string; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts b/backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts new file mode 100644 index 0000000..c371d57 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts @@ -0,0 +1,40 @@ +import { Transform } from 'class-transformer'; +import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, IsUUID, MaxLength } from 'class-validator'; +import { UUID } from 'crypto'; + +export class UpdateBookDto { + @IsUUID() + @IsNotEmpty() + readonly bookId: UUID; + + @IsString() + @IsOptional() + providerSeriesId: string; + + @IsString() + @IsNotEmpty() + providerBookId: string; + + @IsString() + @IsNotEmpty() + @MaxLength(128) + title: string; + + @IsString() + @MaxLength(512) + desc: string; + + @IsNumber() + @IsOptional() + @IsPositive() + volume: number | null; + + @IsString() + @IsNotEmpty() + provider: string; + + @IsDate() + @IsNotEmpty() + @Transform(({ value }) => new Date(value)) + publishedAt: Date +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/entities/book-origin.entity.ts b/backend/nestjs-seshat-api/src/books/entities/book-origin.entity.ts new file mode 100644 index 0000000..141acce --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/entities/book-origin.entity.ts @@ -0,0 +1,24 @@ +import { UUID } from 'crypto'; +import { BookOriginType } from 'src/shared/enums/book_origin_type'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn, Unique } from 'typeorm'; +import { BookEntity } from './book.entity'; + +@Entity("book_origins") +@Unique(['bookOriginId', 'type', 'value']) +export class BookOriginEntity { + @PrimaryColumn({ name: 'book_origin_id' }) + readonly bookOriginId: UUID; + + @Column({ name: 'book_id' }) + readonly bookId: UUID; + + @Column({ name: 'origin_type' }) + type: BookOriginType; + + @Column({ name: 'origin_value' }) + value: string; + + @OneToOne(type => BookEntity, book => book.metadata) + @JoinColumn({ name: 'book_id' }) + book: BookEntity; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/entities/book-status.entity.ts b/backend/nestjs-seshat-api/src/books/entities/book-status.entity.ts new file mode 100644 index 0000000..ec101b8 --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/entities/book-status.entity.ts @@ -0,0 +1,30 @@ +import { UUID } from 'crypto'; +import { UserEntity } from 'src/users/entities/users.entity'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn } from 'typeorm'; +import { BookEntity } from './book.entity'; + +@Entity("book_statuses") +export class BookStatusEntity { + @PrimaryColumn({ name: 'book_id', type: 'uuid' }) + readonly bookId: UUID; + + @PrimaryColumn({ name: 'user_id', type: 'uuid' }) + readonly userId: UUID; + + @Column({ name: 'state', type: 'varchar' }) + state: string; + + @Column({ name: 'added_at', type: 'timestamptz', nullable: false }) + addedAt: Date + + @Column({ name: 'modified_at', type: 'timestamptz', nullable: false }) + modifiedAt: Date; + + @OneToOne(type => BookEntity, book => book.statuses) + @JoinColumn({ name: 'book_id' }) + book: BookEntity; + + @OneToOne(type => UserEntity, user => user.bookStatuses) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/books/entities/book.entity.ts b/backend/nestjs-seshat-api/src/books/entities/book.entity.ts new file mode 100644 index 0000000..497b66c --- /dev/null +++ b/backend/nestjs-seshat-api/src/books/entities/book.entity.ts @@ -0,0 +1,47 @@ +import { UUID } from 'crypto'; +import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm'; +import { BookOriginEntity } from './book-origin.entity'; +import { BookStatusEntity } from './book-status.entity'; +import { SeriesEntity } from 'src/series/entities/series.entity'; + +@Entity("books") +@Unique(['providerSeriesId', 'providerBookId']) +export class BookEntity { + @PrimaryColumn({ name: 'book_id', type: 'uuid' }) + readonly bookId: UUID; + + @Column({ name: 'provider_series_id', type: 'text', nullable: true }) + providerSeriesId: string; + + @Column({ name: 'provider_book_id', type: 'text', nullable: false }) + providerBookId: string; + + @Column({ name: 'book_title', type: 'text', nullable: false }) + title: string; + + @Column({ name: 'book_desc', type: 'text', nullable: true }) + desc: string; + + @Column({ name: 'book_volume', type: 'integer', nullable: true }) + volume: number; + + @Column({ name: 'provider', type: 'varchar', nullable: false }) + provider: string; + + @Column({ name: 'published_at', type: 'timestamptz', nullable: false }) + publishedAt: Date + + @Column({ name: 'added_at', type: 'timestamptz', nullable: false }) + addedAt: Date; + + @OneToMany(type => BookOriginEntity, origin => origin.bookId) + metadata: BookOriginEntity[]; + + @OneToMany(type => BookStatusEntity, status => status.bookId) + statuses: BookStatusEntity[]; + + @OneToOne(type => SeriesEntity, series => series.volumes) + @JoinColumn({ name: 'provider_series_id' }) + @JoinColumn({ name: 'provider' }) + series: SeriesEntity; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/providers/google/google.service.ts b/backend/nestjs-seshat-api/src/providers/google/google.service.ts index 888064b..71b5471 100644 --- a/backend/nestjs-seshat-api/src/providers/google/google.service.ts +++ b/backend/nestjs-seshat-api/src/providers/google/google.service.ts @@ -17,7 +17,7 @@ export class GoogleService { this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery) .pipe( timeout({ first: 5000 }), - map(this.transform), + map(value => this.transform(value)), ) ); } @@ -40,47 +40,53 @@ export class GoogleService { return []; } - const items: any[] = response.data.items; - return items.map((item: any) => { - const result: BookSearchResultDto = { - providerBookId: item.id, - providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId, - title: item.volumeInfo.title, - desc: item.volumeInfo.description, - volume: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber), - publisher: item.volumeInfo.publisher, - authors: item.volumeInfo.authors, - categories: item.volumeInfo.categories, - maturityRating: item.volumeInfo.maturityRating, - industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))), - publishedAt: new Date(item.volumeInfo.publishedDate), - language: item.volumeInfo.language, - thumbnail: item.volumeInfo.imageLinks.thumbnail, - url: item.volumeInfo.canonicalVolumeLink, - provider: 'google' - } + return response.data.items.map(item => this.extract(item)); + } - if (result.providerSeriesId) { - let regex = null; - switch (result.publisher) { - case 'J-Novel Club': - regex = new RegExp(/(?.+?):?\sVolume\s(?<volume>\d+)/); - case 'Yen Press LLC': - regex = new RegExp(/(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>\w+)\)/); - default: - regex = new RegExp(/(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/); - } + private extract(item: any): BookSearchResultDto { + const result: BookSearchResultDto = { + providerBookId: item.id, + providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId, + title: item.volumeInfo.title, + desc: item.volumeInfo.description, + volume: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber), + publisher: item.volumeInfo.publisher, + authors: item.volumeInfo.authors, + categories: item.volumeInfo.categories, + maturityRating: item.volumeInfo.maturityRating, + industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))), + publishedAt: new Date(item.volumeInfo.publishedDate), + language: item.volumeInfo.language, + thumbnail: item.volumeInfo.imageLinks?.thumbnail, + url: item.volumeInfo.canonicalVolumeLink, + provider: 'google' + } - const match = result.title.match(regex); - if (match?.groups) { - result.title = match.groups['title'].trim(); - if (!result.volume) { - result.volume = parseInt(match.groups['volume']); - } + if (result.providerSeriesId) { + let regex = this.getRegexByPublisher(result.publisher); + + const match = result.title.match(regex); + if (match?.groups) { + result.title = match.groups['title'].trim(); + if (!result.volume) { + result.volume = parseInt(match.groups['volume']); } } + } - return result; - }); + return result; + } + + private getRegexByPublisher(publisher: string): RegExp { + switch (publisher) { + case 'J-Novel Club': + return /(?<title>.+?):?\sVolume\s(?<volume>\d+)/i; + case 'Yen On': + case 'Yen Press': + case 'Yen Press LLC': + return /(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>[\w\s]+)\)/; + default: + return /(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/; + } } } diff --git a/backend/nestjs-seshat-api/src/providers/providers.module.ts b/backend/nestjs-seshat-api/src/providers/providers.module.ts index 435bae1..501ccba 100644 --- a/backend/nestjs-seshat-api/src/providers/providers.module.ts +++ b/backend/nestjs-seshat-api/src/providers/providers.module.ts @@ -4,7 +4,7 @@ import { ProvidersService } from './providers.service'; import { HttpModule } from '@nestjs/axios'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ProvidersController } from './providers.controller'; -import { BooksService } from 'src/books/books/books.service'; +import { BooksService } from 'src/books/books.service'; @Module({ imports: [ diff --git a/backend/nestjs-seshat-api/src/series/dto/create-series.dto.ts b/backend/nestjs-seshat-api/src/series/dto/create-series.dto.ts new file mode 100644 index 0000000..e219367 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/dto/create-series.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateSeriesDto { + @IsString() + @IsNotEmpty() + providerSeriesId: string; + + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + @IsNotEmpty() + provider: string; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/series/dto/delete-series.dto.ts b/backend/nestjs-seshat-api/src/series/dto/delete-series.dto.ts new file mode 100644 index 0000000..b073b69 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/dto/delete-series.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteSeriesDto { + @IsString() + @IsNotEmpty() + providerSeriesId: string; + + @IsString() + @IsNotEmpty() + provider: string; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/series/dto/update-series.dto.ts b/backend/nestjs-seshat-api/src/series/dto/update-series.dto.ts new file mode 100644 index 0000000..befca63 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/dto/update-series.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { UUID } from 'crypto'; + +export class UpdateSeriesDto { + @IsUUID() + @IsNotEmpty() + seriesId: UUID; + + @IsString() + @IsNotEmpty() + providerSeriesId: string; + + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + @IsNotEmpty() + provider: string; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/series/entities/series.entity.ts b/backend/nestjs-seshat-api/src/series/entities/series.entity.ts new file mode 100644 index 0000000..75efea2 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/entities/series.entity.ts @@ -0,0 +1,25 @@ +import { UUID } from 'crypto'; +import { BookEntity } from 'src/books/entities/book.entity'; +import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm'; + +@Entity("series") +@Unique(['provider', 'providerSeriesId']) +export class SeriesEntity { + @PrimaryColumn({ name: 'series_id', type: 'uuid' }) + readonly seriesId: UUID; + + @Column({ name: 'provider_series_id', type: 'text', nullable: true }) + providerSeriesId: string; + + @Column({ name: 'series_title', type: 'text', nullable: false }) + title: string; + + @Column({ name: 'provider', type: 'text', nullable: false }) + provider: string; + + @Column({ name: 'added_at', type: 'timestamptz', nullable: false }) + addedAt: Date; + + @OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId]) + volumes: BookEntity[]; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/series/series.module.ts b/backend/nestjs-seshat-api/src/series/series.module.ts new file mode 100644 index 0000000..d07a90e --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/series.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { SeriesService } from './series.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SeriesEntity } from './entities/series.entity'; +import { HttpModule } from '@nestjs/axios'; +import { ProvidersModule } from 'src/providers/providers.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SeriesEntity, + ]), + HttpModule, + ProvidersModule, + ], + controllers: [], + exports: [ + SeriesService, + ], + providers: [SeriesService] +}) +export class SeriesModule { } diff --git a/backend/nestjs-seshat-api/src/series/series.service.spec.ts b/backend/nestjs-seshat-api/src/series/series.service.spec.ts new file mode 100644 index 0000000..3a35cb9 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/series.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SeriesService } from './series.service'; + +describe('SeriesService', () => { + let service: SeriesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SeriesService], + }).compile(); + + service = module.get<SeriesService>(SeriesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/nestjs-seshat-api/src/series/series.service.ts b/backend/nestjs-seshat-api/src/series/series.service.ts new file mode 100644 index 0000000..e32b481 --- /dev/null +++ b/backend/nestjs-seshat-api/src/series/series.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { SeriesEntity } from './entities/series.entity'; +import { Repository } from 'typeorm'; +import { PinoLogger } from 'nestjs-pino'; +import { CreateSeriesDto } from './dto/create-series.dto'; +import { DeleteSeriesDto } from './dto/delete-series.dto'; + +@Injectable() +export class SeriesService { + constructor( + @InjectRepository(SeriesEntity) + private seriesRepository: Repository<SeriesEntity>, + private logger: PinoLogger, + ) { } + + + async deleteSeries(series: DeleteSeriesDto) { + return await this.seriesRepository.delete(series); + } + + async updateSeries(series: CreateSeriesDto) { + return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']); + } +} diff --git a/backend/nestjs-seshat-api/src/shared/enums/book_origin_type.ts b/backend/nestjs-seshat-api/src/shared/enums/book_origin_type.ts new file mode 100644 index 0000000..6f2bf4e --- /dev/null +++ b/backend/nestjs-seshat-api/src/shared/enums/book_origin_type.ts @@ -0,0 +1,15 @@ +export enum BookOriginType { + AUTHOR = 1, + ILLUSTRATOR = 2, + CATEGORY = 10, + LANGUAGE = 11, + MEDIA_TYPE = 12, + MATURITY_RATING = 20, + PROVIDER_THUMBNAIL = 30, + PROVIDER_URL = 31, + // 4x - Ratings + ISBN_10 = 40, + ISBN_13 = 41, + // 1xx - User-defined + TAGS = 100, +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/users/entities/users.entity.ts b/backend/nestjs-seshat-api/src/users/entities/users.entity.ts index c871a17..67f0537 100644 --- a/backend/nestjs-seshat-api/src/users/entities/users.entity.ts +++ b/backend/nestjs-seshat-api/src/users/entities/users.entity.ts @@ -1,7 +1,7 @@ import * as argon2 from 'argon2'; import * as crypto from 'crypto'; import { UUID } from "crypto"; -import { BookStatusEntity } from 'src/books/books/entities/book-status.entity'; +import { BookStatusEntity } from 'src/books/entities/book-status.entity'; import { BigIntTransformer } from 'src/shared/transformers/bigint'; import { StringToLowerCaseTransformer } from 'src/shared/transformers/string'; import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";