Added modules for books & series.
This commit is contained in:
@ -12,7 +12,9 @@ import { UserEntity } from './users/entities/users.entity';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers';
|
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 { ProvidersModule } from './providers/providers.module';
|
||||||
|
import { SeriesModule } from './series/series.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -54,7 +56,9 @@ import { ProvidersModule } from './providers/providers.module';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
ProvidersModule
|
BooksModule,
|
||||||
|
ProvidersModule,
|
||||||
|
SeriesModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, UsersService],
|
providers: [AppService, UsersService],
|
||||||
|
18
backend/nestjs-seshat-api/src/books/books.controller.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/books/books.controller.spec.ts
Normal file
@ -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>(BooksController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
94
backend/nestjs-seshat-api/src/books/books.controller.ts
Normal file
94
backend/nestjs-seshat-api/src/books/books.controller.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
29
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
29
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
@ -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 {}
|
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
@ -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>(BooksService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
169
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
169
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
@ -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<BookEntity>,
|
||||||
|
@InjectRepository(BookOriginEntity)
|
||||||
|
private bookOriginRepository: Repository<BookOriginEntity>,
|
||||||
|
@InjectRepository(BookStatusEntity)
|
||||||
|
private bookStatusRepository: Repository<BookStatusEntity>,
|
||||||
|
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<InsertResult> {
|
||||||
|
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<InsertResult> {
|
||||||
|
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<BookStatusEntity[]> {
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
19
backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts
Normal file
19
backend/nestjs-seshat-api/src/books/dto/book-status.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
@ -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
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
@ -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
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
47
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
47
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -17,7 +17,7 @@ export class GoogleService {
|
|||||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
||||||
.pipe(
|
.pipe(
|
||||||
timeout({ first: 5000 }),
|
timeout({ first: 5000 }),
|
||||||
map(this.transform),
|
map(value => this.transform(value)),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -40,8 +40,10 @@ export class GoogleService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: any[] = response.data.items;
|
return response.data.items.map(item => this.extract(item));
|
||||||
return items.map((item: any) => {
|
}
|
||||||
|
|
||||||
|
private extract(item: any): BookSearchResultDto {
|
||||||
const result: BookSearchResultDto = {
|
const result: BookSearchResultDto = {
|
||||||
providerBookId: item.id,
|
providerBookId: item.id,
|
||||||
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
|
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
|
||||||
@ -55,21 +57,13 @@ export class GoogleService {
|
|||||||
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
|
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
|
||||||
publishedAt: new Date(item.volumeInfo.publishedDate),
|
publishedAt: new Date(item.volumeInfo.publishedDate),
|
||||||
language: item.volumeInfo.language,
|
language: item.volumeInfo.language,
|
||||||
thumbnail: item.volumeInfo.imageLinks.thumbnail,
|
thumbnail: item.volumeInfo.imageLinks?.thumbnail,
|
||||||
url: item.volumeInfo.canonicalVolumeLink,
|
url: item.volumeInfo.canonicalVolumeLink,
|
||||||
provider: 'google'
|
provider: 'google'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.providerSeriesId) {
|
if (result.providerSeriesId) {
|
||||||
let regex = null;
|
let regex = this.getRegexByPublisher(result.publisher);
|
||||||
switch (result.publisher) {
|
|
||||||
case 'J-Novel Club':
|
|
||||||
regex = new RegExp(/(?<title>.+?):?\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+)/);
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = result.title.match(regex);
|
const match = result.title.match(regex);
|
||||||
if (match?.groups) {
|
if (match?.groups) {
|
||||||
@ -81,6 +75,18 @@ export class GoogleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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+)/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { ProvidersService } from './providers.service';
|
|||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { ProvidersController } from './providers.controller';
|
import { ProvidersController } from './providers.controller';
|
||||||
import { BooksService } from 'src/books/books/books.service';
|
import { BooksService } from 'src/books/books.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteSeriesDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provider: string;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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[];
|
||||||
|
}
|
22
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
22
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
@ -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 { }
|
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
25
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
25
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { UUID } 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 { BigIntTransformer } from 'src/shared/transformers/bigint';
|
||||||
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
|
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
|
||||||
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
Reference in New Issue
Block a user