Added series subscriptions. Added series searching. Fixed database relations. Added logging for library controller.

This commit is contained in:
Tom
2025-03-07 16:06:08 +00:00
parent 4aafe86ef0
commit c7ece75e7a
17 changed files with 364 additions and 175 deletions

View File

@@ -54,7 +54,6 @@ CREATE TABLE
published_at timestamp default NULL, published_at timestamp default NULL,
added_at timestamp default NULL, added_at timestamp default NULL,
PRIMARY KEY (book_id), PRIMARY KEY (book_id),
-- FOREIGN KEY (series_id) REFERENCES series (series_id),
FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE, FOREIGN KEY (provider, provider_series_id) REFERENCES series (provider, provider_series_id) ON DELETE CASCADE,
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume) UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume)
); );
@@ -131,7 +130,7 @@ CREATE INDEX book_statuses_user_id_login_idx ON users (user_id);
CREATE TABLE CREATE TABLE
series_subscriptions ( series_subscriptions (
user_id uuid, user_id uuid,
provider text, provider varchar(12) NOT NULL,
provider_series_id text, provider_series_id text,
added_at timestamp default NULL, added_at timestamp default NULL,
PRIMARY KEY (user_id, provider, provider_series_id), PRIMARY KEY (user_id, provider, provider_series_id),

View File

@@ -1,18 +0,0 @@
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();
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { BookEntity } from './entities/book.entity'; import { BookEntity } from './entities/book.entity';
import { In, InsertResult, Repository } from 'typeorm'; import { DeleteResult, In, InsertResult, Repository } from 'typeorm';
import { BookOriginEntity } from './entities/book-origin.entity'; import { BookOriginEntity } from './entities/book-origin.entity';
import { BookStatusEntity } from './entities/book-status.entity'; import { BookStatusEntity } from './entities/book-status.entity';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
@@ -10,6 +10,8 @@ import { CreateBookOriginDto } from './dto/create-book-origin.dto';
import { CreateBookStatusDto } from './dto/create-book-status.dto'; import { CreateBookStatusDto } from './dto/create-book-status.dto';
import { DeleteBookStatusDto } from './dto/delete-book-status.dto'; import { DeleteBookStatusDto } from './dto/delete-book-status.dto';
import { SeriesDto } from 'src/series/dto/series.dto'; import { SeriesDto } from 'src/series/dto/series.dto';
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
import { BookOriginDto } from './dto/book-origin.dto';
@Injectable() @Injectable()
export class BooksService { export class BooksService {
@@ -37,16 +39,18 @@ export class BooksService {
return await this.bookOriginRepository.insert(origin); return await this.bookOriginRepository.insert(origin);
} }
async deleteBookOrigin(origin: CreateBookOriginDto) { async deleteBookOrigin(origin: BookOriginDto[]): Promise<DeleteResult> {
return await this.bookOriginRepository.createQueryBuilder() return await this.bookOriginRepository.createQueryBuilder()
.delete() .delete()
.where({ .where({
whereFactory: origin, whereFactory: {
bookOriginId: In(origin.map(o => o.bookOriginId)),
},
}) })
.execute(); .execute();
} }
async deleteBookStatus(status: DeleteBookStatusDto) { async deleteBookStatus(status: DeleteBookStatusDto): Promise<DeleteResult> {
return await this.bookStatusRepository.createQueryBuilder() return await this.bookStatusRepository.createQueryBuilder()
.delete() .delete()
.where({ .where({
@@ -55,7 +59,7 @@ export class BooksService {
.execute(); .execute();
} }
async findBooksByIds(bookIds: UUID[]) { async findBooksByIds(bookIds: UUID[]): Promise<BookEntity[]> {
return await this.bookRepository.find({ return await this.bookRepository.find({
where: { where: {
bookId: In(bookIds) bookId: In(bookIds)
@@ -63,7 +67,7 @@ export class BooksService {
}); });
} }
async findBooksFromSeries(series: SeriesDto) { async findBooksFromSeries(series: SeriesDto): Promise<BookEntity[]> {
return await this.bookRepository.find({ return await this.bookRepository.find({
where: { where: {
providerSeriesId: series.providerSeriesId, providerSeriesId: series.providerSeriesId,
@@ -72,25 +76,23 @@ export class BooksService {
}); });
} }
async findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> { async findActualBookStatusesTrackedBy(userId: UUID, series: SeriesDto): Promise<BookStatusEntity[]> {
return await this.bookStatusRepository.createQueryBuilder('s') return await this.bookStatusRepository.createQueryBuilder('s')
.select(['s.book_id', 's.user_id'])
.where('s.user_id = :id', { id: userId })
.innerJoin('s.book', 'b') .innerJoin('s.book', 'b')
.where('s.user_id = :id', { id: userId })
.andWhere('b.provider = :provider', { provider: series.provider })
.andWhere('b.providerSeriesId = :id', { id: series.providerSeriesId })
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId']) .addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId'])
.getMany(); .getMany();
} }
async findSeriesTrackedBy(userId: UUID) { async findBookStatusesTrackedBy(subscription: SeriesSubscriptionDto): Promise<any> {
return await this.bookStatusRepository.createQueryBuilder('s') return await this.bookRepository.createQueryBuilder('b')
.where({ .where('b.provider = :provider', { provider: subscription.provider })
whereFactory: { .andWhere(`b.provider_series_id = :id`, { id: subscription.providerSeriesId })
userId: userId .leftJoin('b.statuses', 's')
} .where(`s.user_id = :id`, { id: subscription.userId })
}) .addSelect(['s.state'])
.innerJoin('s.book', 'b')
.addSelect(['b.provider', 'b.providerSeriesId'])
.distinctOn(['b.provider', 'b.providerSeriesId'])
.getMany(); .getMany();
} }
@@ -111,7 +113,7 @@ export class BooksService {
await this.bookStatusRepository.createQueryBuilder() await this.bookStatusRepository.createQueryBuilder()
.insert() .insert()
.values(status) .values(status)
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'], { skipUpdateIfNoValuesChanged: true }) .orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
.execute(); .execute();
} }
} }

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";
export class BookOriginDto {
@IsString()
@IsNotEmpty()
bookOriginId: string;
}

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
export class CreateBookStatusDto { export class CreateBookStatusDto {
@@ -11,8 +11,10 @@ export class CreateBookStatusDto {
@IsOptional() @IsOptional()
readonly userId: UUID; readonly userId: UUID;
@IsString() @IsNumber()
@IsNotEmpty() @IsNotEmpty()
@Min(0)
@Max(6)
state: number; state: number;
modifiedAt: Date; modifiedAt: Date;

View File

@@ -1,6 +1,6 @@
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { BookOriginType } from 'src/shared/enums/book_origin_type'; import { BookOriginType } from 'src/shared/enums/book_origin_type';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookEntity } from './book.entity'; import { BookEntity } from './book.entity';
@Entity("book_origins") @Entity("book_origins")
@@ -19,6 +19,9 @@ export class BookOriginEntity {
value: string; value: string;
@OneToOne(type => BookEntity, book => book.metadata) @OneToOne(type => BookEntity, book => book.metadata)
@JoinColumn({ name: 'book_id' }) @JoinColumn({
name: 'book_id',
referencedColumnName: 'bookId',
})
book: BookEntity; book: BookEntity;
} }

View File

@@ -1,6 +1,6 @@
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { UserEntity } from 'src/users/entities/users.entity'; import { UserEntity } from 'src/users/entities/users.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { BookEntity } from './book.entity'; import { BookEntity } from './book.entity';
@Entity("book_statuses") @Entity("book_statuses")
@@ -21,10 +21,16 @@ export class BookStatusEntity {
modifiedAt: Date; modifiedAt: Date;
@OneToOne(type => BookEntity, book => book.statuses) @OneToOne(type => BookEntity, book => book.statuses)
@JoinColumn({ name: 'book_id' }) @JoinColumn({
name: 'book_id',
referencedColumnName: 'bookId',
})
book: BookEntity; book: BookEntity;
@OneToOne(type => UserEntity, user => user.bookStatuses) @OneToOne(type => UserEntity, user => user.bookStatuses)
@JoinColumn({ name: 'user_id' }) @JoinColumn({
name: 'user_id',
referencedColumnName: 'userId',
})
user: UserEntity; user: UserEntity;
} }

View File

@@ -1,5 +1,5 @@
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { BookOriginEntity } from './book-origin.entity'; import { BookOriginEntity } from './book-origin.entity';
import { BookStatusEntity } from './book-status.entity'; import { BookStatusEntity } from './book-status.entity';
import { SeriesEntity } from 'src/series/entities/series.entity'; import { SeriesEntity } from 'src/series/entities/series.entity';
@@ -34,14 +34,20 @@ export class BookEntity {
@Column({ name: 'added_at', type: 'timestamptz', nullable: false }) @Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date; addedAt: Date;
@OneToMany(type => BookOriginEntity, origin => origin.bookId) @OneToMany(type => BookOriginEntity, origin => origin.book)
metadata: BookOriginEntity[]; metadata: BookOriginEntity[];
@OneToMany(type => BookStatusEntity, status => status.bookId) @OneToMany(type => BookStatusEntity, status => status.book)
statuses: BookStatusEntity[]; statuses: BookStatusEntity[];
@OneToOne(type => SeriesEntity, series => series.volumes) @OneToOne(type => SeriesEntity, series => series.volumes)
@JoinColumn({ name: 'provider_series_id' }) @JoinColumn([{
@JoinColumn({ name: 'provider' }) name: 'provider_series_id',
referencedColumnName: 'providerSeriesId',
},
{
name: 'provider',
referencedColumnName: 'provider',
}])
series: SeriesEntity; series: SeriesEntity;
} }

View File

@@ -5,7 +5,7 @@ import { PinoLogger } from 'nestjs-pino';
import { GoogleSearchContext } from 'src/providers/contexts/google.search.context'; import { GoogleSearchContext } from 'src/providers/contexts/google.search.context';
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto'; import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
import { ProvidersService } from 'src/providers/providers.service'; import { ProvidersService } from 'src/providers/providers.service';
import { CreateSeriesSubscriptionJobDto } from 'src/series/dto/create-series-subscription-job.dto'; import { SeriesSubscriptionJobDto } from 'src/series/dto/series-subscription-job.dto';
import { LibraryService } from './library.service'; import { LibraryService } from './library.service';
@Processor('library') @Processor('library')
@@ -27,7 +27,7 @@ export class LibraryConsumer extends WorkerHost {
}); });
if (job.name == 'new_series') { if (job.name == 'new_series') {
const series: CreateSeriesSubscriptionJobDto = job.data; const series: SeriesSubscriptionJobDto = job.data;
const books = await this.search(job, series, null); const books = await this.search(job, series, null);
let counter = 0; let counter = 0;
@@ -51,8 +51,8 @@ export class LibraryConsumer extends WorkerHost {
} }
} }
} else if (job.name == 'update_series') { } else if (job.name == 'update_series') {
const series: CreateSeriesSubscriptionJobDto = job.data; const series: SeriesSubscriptionJobDto = job.data;
const existingBooks = await this.library.getBooksFromSeries(series); const existingBooks = await this.library.findBooksFromSeries(series);
const existingVolumes = existingBooks.map(b => b.volume); const existingVolumes = existingBooks.map(b => b.volume);
const lastPublishedBook = existingBooks.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime())[0]; const lastPublishedBook = existingBooks.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime())[0];
const books = await this.search(job, series, lastPublishedBook?.publishedAt); const books = await this.search(job, series, lastPublishedBook?.publishedAt);
@@ -100,7 +100,7 @@ export class LibraryConsumer extends WorkerHost {
return null; return null;
} }
private async search(job: Job, series: CreateSeriesSubscriptionJobDto, after: Date|null): Promise<{ result: BookSearchResultDto, score: number }[]> { private async search(job: Job, series: SeriesSubscriptionJobDto, after: Date | null): Promise<{ result: BookSearchResultDto, score: number }[]> {
let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext; let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext;
context.maxResults = '40'; context.maxResults = '40';
if (after) { if (after) {
@@ -185,7 +185,7 @@ export class LibraryConsumer extends WorkerHost {
}); });
} }
private toScore(book: BookSearchResultDto, series: CreateSeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) { private toScore(book: BookSearchResultDto, series: SeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
if (!book) { if (!book) {
return { return {
result: null, result: null,

View File

@@ -10,11 +10,13 @@ import { QueryFailedError } from 'typeorm';
import { UpdateBookDto } from 'src/books/dto/update-book.dto'; import { UpdateBookDto } from 'src/books/dto/update-book.dto';
import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto'; import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto';
import { LibraryService } from './library.service'; import { LibraryService } from './library.service';
import { CreateSeriesSubscriptionDto } from 'src/series/dto/create-series-subscription.dto';
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard'; import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
import { SeriesDto } from 'src/series/dto/series.dto'; import { SeriesDto } from 'src/series/dto/series.dto';
import { CreateBookOriginDto } from 'src/books/dto/create-book-origin.dto';
import { DeleteBookStatusDto } from 'src/books/dto/delete-book-status.dto'; import { DeleteBookStatusDto } from 'src/books/dto/delete-book-status.dto';
import { CreateBookStatusDto } from 'src/books/dto/create-book-status.dto';
import { JwtAccessAdminGuard } from 'src/auth/guards/jwt-access.admin.guard';
import { BookOriginDto } from 'src/books/dto/book-origin.dto';
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
@UseGuards(JwtAccessGuard) @UseGuards(JwtAccessGuard)
@Controller('library') @Controller('library')
@@ -30,7 +32,6 @@ export class LibraryController {
@Get('series') @Get('series')
async getSeries( async getSeries(
@Request() req, @Request() req,
@Res({ passthrough: true }) response: Response,
) { ) {
return { return {
success: true, success: true,
@@ -41,16 +42,11 @@ export class LibraryController {
@Post('series') @Post('series')
async createSeries( async createSeries(
@Request() req, @Request() req,
@Body() body: CreateSeriesSubscriptionDto, @Body() body: CreateSeriesDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { try {
await this.library.addSeries({ await this.library.addSeries(body);
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
mediaType: body.mediaType,
});
return { return {
success: true, success: true,
@@ -58,15 +54,32 @@ export class LibraryController {
} catch (err) { } catch (err) {
if (err instanceof QueryFailedError) { if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') { if (err.driverError.code == '23505') {
// Subscription already exist. this.logger.warn({
class: LibraryController.name,
method: this.createSeries.name,
user: req.user,
body: body,
msg: 'Failed to create a series.',
error: err,
});
response.statusCode = 409; response.statusCode = 409;
return { return {
success: false, success: false,
error_message: 'Series subscription already exists.', error_message: 'Series already exists.',
}; };
} }
} }
this.logger.error({
class: LibraryController.name,
method: this.createSeries.name,
user: req.user,
body: body,
msg: 'Failed to create a series.',
error: err,
});
response.statusCode = 500; response.statusCode = 500;
return { return {
success: false, success: false,
@@ -78,34 +91,93 @@ export class LibraryController {
@Patch('series') @Patch('series')
async updateSeries( async updateSeries(
@Request() req, @Request() req,
@Body() body: CreateSeriesSubscriptionDto, @Body() body: CreateSeriesDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { try {
const series = await this.series.getSeries({ await this.library.updateSeries(body);
provider: body.provider,
providerSeriesId: body.providerSeriesId,
});
if (!series) {
response.statusCode = 404;
return {
success: false,
error_message: 'Series has not been added.'
};
}
await this.library.updateSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
mediaType: body.mediaType,
});
return { return {
success: true, success: true,
}; };
} catch (err) { } catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.updateSeries.name,
user: req.user,
msg: 'Failed to update a series.',
error: err,
});
// Subscription already exist.
response.statusCode = 409;
return {
success: false,
error_message: 'Series subscription already exists.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.updateSeries.name,
user: req.user,
body: body,
msg: 'Failed to update a series.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@UseGuards(JwtAccessAdminGuard)
@Delete('series')
async deleteSeries(
@Request() req,
@Body() body: SeriesDto,
@Res({ passthrough: true }) response: Response,
) {
try {
const del = await this.series.deleteSeries(body);
return {
success: del && del.affected > 0,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.deleteSeries.name,
user: req.user,
body: body,
msg: 'Failed to delete a series.',
error: err,
});
response.statusCode = 404;
return {
success: false,
error_message: 'The series does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.deleteSeries.name,
user: req.user,
body: body,
msg: 'Failed to delete a series.',
error: err,
});
response.statusCode = 500; response.statusCode = 500;
return { return {
success: false, success: false,
@@ -117,7 +189,6 @@ export class LibraryController {
@Get('series/subscriptions') @Get('series/subscriptions')
async getSeriesSubscriptions( async getSeriesSubscriptions(
@Request() req, @Request() req,
@Res({ passthrough: true }) response: Response,
) { ) {
return { return {
success: true, success: true,
@@ -143,6 +214,15 @@ export class LibraryController {
} catch (err) { } catch (err) {
if (err instanceof QueryFailedError) { if (err instanceof QueryFailedError) {
if (err.driverError.code == '23505') { if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
// Subscription already exists. // Subscription already exists.
response.statusCode = 409; response.statusCode = 409;
return { return {
@@ -150,6 +230,15 @@ export class LibraryController {
error_message: 'Series subscription already exists.', error_message: 'Series subscription already exists.',
}; };
} else if (err.driverError.code == '23503') { } else if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
// Series does not exist. // Series does not exist.
response.statusCode = 404; response.statusCode = 404;
return { return {
@@ -159,6 +248,15 @@ export class LibraryController {
} }
} }
this.logger.error({
class: LibraryController.name,
method: this.subscribe.name,
user: req.user,
body: body,
msg: 'Failed to subscribe to a series.',
error: err,
});
response.statusCode = 500; response.statusCode = 500;
return { return {
success: false, success: false,
@@ -167,13 +265,31 @@ export class LibraryController {
} }
} }
@Delete('series/subscribe')
async deleteSeriesSubscription(
@Request() req,
@Body() body: SeriesDto,
) {
const del = await this.series.deleteSeriesSubscription({
...body,
userId: req.user.userId,
});
return {
success: del && del.affected > 0,
};
}
@Get('books') @Get('books')
async getBooksFromUser( async getBooksFromUser(
@Request() req, @Request() req,
@Body() body: SeriesDto,
) { ) {
return { return {
success: true, success: true,
data: await this.books.findBookStatusesTrackedBy(req.user.userId), data: await this.library.findBooksFromSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
}),
}; };
} }
@@ -184,32 +300,18 @@ export class LibraryController {
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
if (body.provider && body.providerSeriesId) { if (body.provider && body.providerSeriesId) {
try { this.logger.warn({
await this.series.updateSeries({
provider: body.provider,
providerSeriesId: body.providerSeriesId,
title: body.title,
mediaType: body.mediaType,
});
} catch (err) {
if (err instanceof QueryFailedError) {
this.logger.error({
class: LibraryController.name, class: LibraryController.name,
method: this.createBook.name, method: this.createBook.name,
user: req.user, user: req.user,
msg: 'Failed to create a series for a book.', body: body,
error: err, msg: 'Failed to create book due to book being part of a series.',
}); });
// Ignore if the series already exist. response.statusCode = 400;
if (err.driverError.code != '23505') {
response.statusCode = 500;
return { return {
success: false, success: false,
error_message: 'Something went wrong.', error_message: 'This book is part of a seris. Use the series route to create a series.',
};
}
}
} }
} }
@@ -220,15 +322,16 @@ export class LibraryController {
}; };
} catch (err) { } catch (err) {
if (err instanceof QueryFailedError) { if (err instanceof QueryFailedError) {
this.logger.error({ if (err.driverError.code == '23505') {
this.logger.warn({
class: LibraryController.name, class: LibraryController.name,
method: this.createBook.name, method: this.createBook.name,
user: req.user, user: req.user,
body: body,
msg: 'Failed to create book.', msg: 'Failed to create book.',
error: err, error: err,
}); });
if (err.driverError.code == '23505') {
// Book exists already. // Book exists already.
response.statusCode = 409; response.statusCode = 409;
return { return {
@@ -236,15 +339,33 @@ export class LibraryController {
error_message: 'The book has already been added previously.', error_message: 'The book has already been added previously.',
}; };
} else if (err.driverError.code == '23503') { } else if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create book.',
error: err,
});
// Series is missing. // Series is missing.
response.statusCode = 500; response.statusCode = 404;
return { return {
success: false, success: false,
error_message: 'Series has not been added.', error_message: 'Series does not exist.',
}; };
} }
} }
this.logger.error({
class: LibraryController.name,
method: this.createBook.name,
user: req.user,
body: body,
msg: 'Failed to create a book.',
error: err,
});
response.statusCode = 500; response.statusCode = 500;
return { return {
success: false, success: false,
@@ -262,33 +383,17 @@ export class LibraryController {
const result = await this.books.updateBook(body.bookId, data); const result = await this.books.updateBook(body.bookId, data);
return { return {
success: result?.affected == 1, success: result && result.affected > 0,
}; };
} }
@Delete('books/origins') @Delete('books/origins')
async deleteBookOrigin( async deleteBookOrigin(
@Body() body: CreateBookOriginDto, @Body() body: BookOriginDto[],
) { ) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookOrigin(body); const result = await this.books.deleteBookOrigin(body);
return { return {
success: result?.affected == 1, success: result && result.affected > 0,
};
}
@Delete('books/status')
async deleteBookStatus(
@Body() body: DeleteBookStatusDto,
) {
const data = { ...body };
delete data['bookOriginId'];
const result = await this.books.deleteBookStatus(body);
return {
success: result?.affected == 1,
}; };
} }
@@ -301,7 +406,76 @@ export class LibraryController {
const result = await this.books.updateBookOrigin(body.bookOriginId, data); const result = await this.books.updateBookOrigin(body.bookOriginId, data);
return { return {
success: result?.affected == 1, success: result && result.affected > 0,
};
}
@Get('books/status')
async getBookStatus(
@Request() req,
@Body() body: SeriesDto,
) {
return {
success: true,
data: await this.books.findActualBookStatusesTrackedBy(req.user.userId, body),
};
}
@Put('books/status')
async updateBookStatus(
@Request() req,
@Body() body: CreateBookStatusDto,
@Res({ passthrough: true }) response: Response,
) {
try {
await this.books.updateBookStatus(body);
return {
success: true,
};
} catch (err) {
if (err instanceof QueryFailedError) {
if (err.driverError.code == '23503') {
this.logger.warn({
class: LibraryController.name,
method: this.updateBookStatus.name,
user: req.user,
body: body,
msg: 'Failed to update the user\'s status of a book.',
error: err,
});
response.statusCode = 404;
return {
success: false,
error_message: 'The book does not exist.',
};
}
}
this.logger.error({
class: LibraryController.name,
method: this.updateBookStatus.name,
user: req.user,
body: body,
msg: 'Failed to update the user\'s status of a book.',
error: err,
});
response.statusCode = 500;
return {
success: false,
error_message: 'Something went wrong.',
};
}
}
@Delete('books/status')
async deleteBookStatus(
@Body() body: DeleteBookStatusDto,
) {
const result = await this.books.deleteBookStatus(body);
return {
success: result && result.affected > 0,
}; };
} }
} }

View File

@@ -136,11 +136,11 @@ export class LibraryService {
return bookId; return bookId;
} }
async findBooksFromSeries(series: SeriesDto) {
return await this.books.findBooksFromSeries(series);
}
async updateSeries(series: CreateSeriesDto) { async updateSeries(series: CreateSeriesDto) {
return await this.jobs.add('update_series', series); return await this.jobs.add('update_series', series);
} }
async getBooksFromSeries(series: SeriesDto) {
return await this.books.findBooksFromSeries(series);
}
} }

View File

@@ -1,12 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesDto } from './series.dto';
export class CreateSeriesSubscriptionDto extends SeriesDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
mediaType: string;
}

View File

@@ -1,7 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
import { SeriesSubscriptionDto } from './series-subscription.dto'; import { SeriesSubscriptionDto } from './series-subscription.dto';
export class CreateSeriesSubscriptionJobDto extends SeriesSubscriptionDto { export class SeriesSubscriptionJobDto extends SeriesSubscriptionDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title: string; title: string;

View File

@@ -1,5 +1,6 @@
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { Column, Entity, PrimaryColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
import { SeriesEntity } from './series.entity';
@Entity("series_subscriptions") @Entity("series_subscriptions")
export class SeriesSubscriptionEntity { export class SeriesSubscriptionEntity {
@@ -14,4 +15,15 @@ export class SeriesSubscriptionEntity {
@Column({ name: 'added_at', type: 'timestamptz', nullable: false }) @Column({ name: 'added_at', type: 'timestamptz', nullable: false })
addedAt: Date; addedAt: Date;
@OneToOne(type => SeriesEntity, series => series.subscriptions)
@JoinColumn([{
name: 'provider_series_id',
referencedColumnName: 'providerSeriesId',
},
{
name: 'provider',
referencedColumnName: 'provider',
}])
series: SeriesSubscriptionEntity[];
} }

View File

@@ -1,6 +1,7 @@
import { UUID } from 'crypto'; import { UUID } from 'crypto';
import { BookEntity } from 'src/books/entities/book.entity'; import { BookEntity } from 'src/books/entities/book.entity';
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm'; import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
import { SeriesSubscriptionEntity } from './series-subscription.entity';
@Entity("series") @Entity("series")
@Unique(['provider', 'providerSeriesId']) @Unique(['provider', 'providerSeriesId'])
@@ -25,4 +26,7 @@ export class SeriesEntity {
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId]) @OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
volumes: BookEntity[]; volumes: BookEntity[];
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
subscriptions: SeriesSubscriptionEntity[];
} }

View File

@@ -12,9 +12,9 @@ import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
export class SeriesService { export class SeriesService {
constructor( constructor(
@InjectRepository(SeriesEntity) @InjectRepository(SeriesEntity)
private seriesRepository: Repository<SeriesEntity>, private readonly seriesRepository: Repository<SeriesEntity>,
@InjectRepository(SeriesSubscriptionEntity) @InjectRepository(SeriesSubscriptionEntity)
private seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>, private readonly seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
) { } ) { }
@@ -37,7 +37,7 @@ export class SeriesService {
async getSeries(series: SeriesDto) { async getSeries(series: SeriesDto) {
return await this.seriesRepository.findOne({ return await this.seriesRepository.findOne({
where: series where: series
}) });
} }
async getAllSeries() { async getAllSeries() {
@@ -45,11 +45,15 @@ export class SeriesService {
} }
async getSeriesSubscribedBy(userId: UUID) { async getSeriesSubscribedBy(userId: UUID) {
return await this.seriesSubscriptionRepository.find({ return await this.seriesRepository.createQueryBuilder('s')
where: { .select(['s.seriesId', 's.providerSeriesId', 's.provider', 's.title', 's.mediaType', 's.addedAt'])
userId, .innerJoinAndMapOne('s.subscriptions',
} qb => qb
}); .select(['subscription.provider', 'subscription.provider_series_id', 'subscription.user_id'])
.from(SeriesSubscriptionEntity, 'subscription'),
'ss', `"ss"."subscription_provider" = "s"."provider" AND "ss"."provider_series_id" = "s"."provider_series_id"`)
.where(`ss.user_id = :id`, { id: userId })
.getMany();
} }
async updateSeries(series: CreateSeriesDto) { async updateSeries(series: CreateSeriesDto) {

View File

@@ -7,7 +7,7 @@ import { LoginUserDto } from './dto/login-user.dto';
import { UUID } from 'crypto'; import { UUID } from 'crypto';
class UserDto { class UserDto {
userId: string; userId: UUID;
userLogin: string; userLogin: string;
userName: string; userName: string;
isAdmin: boolean; isAdmin: boolean;