Compare commits
26 Commits
6b5bfa963e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef7e372e2 | |||
| 71e232380b | |||
| c2d06446eb | |||
| 1de822da14 | |||
| f735d1631f | |||
| 89b29c58dc | |||
| 60e179cd13 | |||
| 7875c5407c | |||
| 3326b7c589 | |||
| e20231639c | |||
| a0e8506027 | |||
| 26abb6163f | |||
| e7fc6e0802 | |||
| 8ac848e8f1 | |||
| 0bfdded52f | |||
| 03286c2013 | |||
| cc337d22f2 | |||
| bde574ccad | |||
| 6ac9a2f1ec | |||
| 6b010f66ba | |||
| c7ece75e7a | |||
| 4aafe86ef0 | |||
| 4b7417c39b | |||
| d02da321a1 | |||
| d0c074135e | |||
| 7e828b1662 |
@@ -25,6 +25,7 @@ CREATE TABLE
|
|||||||
-- 3rd party id for this series.
|
-- 3rd party id for this series.
|
||||||
provider_series_id text,
|
provider_series_id text,
|
||||||
series_title text NOT NULL,
|
series_title text NOT NULL,
|
||||||
|
media_type text,
|
||||||
-- 3rd party used to fetch the data for this series.
|
-- 3rd party used to fetch the data for this series.
|
||||||
provider varchar(12) NOT NULL,
|
provider varchar(12) NOT NULL,
|
||||||
added_at timestamp default NULL,
|
added_at timestamp default NULL,
|
||||||
@@ -53,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)
|
||||||
);
|
);
|
||||||
@@ -113,7 +113,7 @@ CREATE TABLE
|
|||||||
book_statuses (
|
book_statuses (
|
||||||
user_id uuid,
|
user_id uuid,
|
||||||
book_id uuid,
|
book_id uuid,
|
||||||
state varchar(12),
|
state smallint,
|
||||||
added_at timestamp default NULL,
|
added_at timestamp default NULL,
|
||||||
modified_at timestamp default NULL,
|
modified_at timestamp default NULL,
|
||||||
PRIMARY KEY (user_id, book_id),
|
PRIMARY KEY (user_id, book_id),
|
||||||
@@ -130,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),
|
||||||
|
|||||||
21
backend/nestjs-seshat-api/assets/config/config.json
Normal file
21
backend/nestjs-seshat-api/assets/config/config.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"registration": false
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"default": "google",
|
||||||
|
"google": {
|
||||||
|
"name": "Google",
|
||||||
|
"filters": {},
|
||||||
|
"languages": {
|
||||||
|
"zh": "Chinese",
|
||||||
|
"nl": "Dutch",
|
||||||
|
"en": "English",
|
||||||
|
"fr": "Francais",
|
||||||
|
"ko": "Korean",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"es": "Spanish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { ProvidersModule } from './providers/providers.module';
|
|||||||
import { SeriesModule } from './series/series.module';
|
import { SeriesModule } from './series/series.module';
|
||||||
import { LibraryModule } from './library/library.module';
|
import { LibraryModule } from './library/library.module';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { AssetModule } from './asset/asset.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -74,7 +75,9 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
BooksModule,
|
BooksModule,
|
||||||
ProvidersModule,
|
ProvidersModule,
|
||||||
SeriesModule,
|
SeriesModule,
|
||||||
LibraryModule
|
LibraryModule,
|
||||||
|
ConfigModule,
|
||||||
|
AssetModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, UsersService],
|
providers: [AppService, UsersService],
|
||||||
|
|||||||
7
backend/nestjs-seshat-api/src/asset/asset.module.ts
Normal file
7
backend/nestjs-seshat-api/src/asset/asset.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigController } from './config/config.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ConfigController]
|
||||||
|
})
|
||||||
|
export class AssetModule {}
|
||||||
7
backend/nestjs-seshat-api/src/asset/config/app-config.ts
Normal file
7
backend/nestjs-seshat-api/src/asset/config/app-config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const file_path = path.join(process.cwd(), './assets/config/config.json');
|
||||||
|
const file_content = fs.readFileSync(file_path).toString();
|
||||||
|
|
||||||
|
export const AppConfig = JSON.parse(file_content);
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { BooksController } from './books.controller';
|
import { ConfigController } from './config.controller';
|
||||||
|
|
||||||
describe('BooksController', () => {
|
describe('ConfigController', () => {
|
||||||
let controller: BooksController;
|
let controller: ConfigController;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [BooksController],
|
controllers: [ConfigController],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<BooksController>(BooksController);
|
controller = module.get<ConfigController>(ConfigController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Request, Get } from '@nestjs/common';
|
||||||
|
import { AppConfig } from './app-config';
|
||||||
|
|
||||||
|
@Controller('asset')
|
||||||
|
export class ConfigController {
|
||||||
|
@Get('config')
|
||||||
|
async config(
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
config: AppConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { UserEntity } from 'src/users/entities/users.entity';
|
import { UserEntity } from 'src/users/entities/users.entity';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { AccessTokenDto } from './dto/access-token.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthAccessService {
|
export class AuthAccessService {
|
||||||
@@ -13,7 +14,7 @@ export class AuthAccessService {
|
|||||||
private logger: PinoLogger,
|
private logger: PinoLogger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async generate(user: UserEntity) {
|
async generate(user: UserEntity): Promise<AccessTokenDto> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
|
const limit = parseInt(this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_EXPIRATION_MS'));
|
||||||
const expiration = moment(now).add(limit, 'ms').toDate();
|
const expiration = moment(now).add(limit, 'ms').toDate();
|
||||||
@@ -45,4 +46,12 @@ export class AuthAccessService {
|
|||||||
exp: expiration.getTime(),
|
exp: expiration.getTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async verify(token: string) {
|
||||||
|
return await this.jwts.verifyAsync(token,
|
||||||
|
{
|
||||||
|
secret: this.config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch } from '@nestjs/common';
|
import { Controller, Request, Post, UseGuards, Body, Res, Delete, Patch, UnauthorizedException } from '@nestjs/common';
|
||||||
import { LoginAuthGuard } from './guards/login-auth.guard';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UsersService } from 'src/users/users.service';
|
import { UsersService } from 'src/users/users.service';
|
||||||
import { RegisterUserDto } from './dto/register-user.dto';
|
import { RegisterUserDto } from './dto/register-user.dto';
|
||||||
@@ -9,7 +8,10 @@ import { OfflineGuard } from './guards/offline.guard';
|
|||||||
import { UserEntity } from 'src/users/entities/users.entity';
|
import { UserEntity } from 'src/users/entities/users.entity';
|
||||||
import { QueryFailedError } from 'typeorm';
|
import { QueryFailedError } from 'typeorm';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { JwtAccessGuard } from './guards/jwt-access.guard';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { AuthenticationDto } from './dto/authentication.dto';
|
||||||
|
import { AppConfig } from 'src/asset/config/app-config';
|
||||||
|
import { JwtMixedGuard } from './guards/jwt-mixed.guard';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -19,16 +21,17 @@ export class AuthController {
|
|||||||
private logger: PinoLogger,
|
private logger: PinoLogger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@UseGuards(LoginAuthGuard)
|
@UseGuards(OfflineGuard)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(
|
async login(
|
||||||
@Request() req,
|
@Request() req,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
@Body() body: LoginDto,
|
||||||
) {
|
) {
|
||||||
let data: AuthenticationDto | null;
|
let data: AuthenticationDto | null;
|
||||||
try {
|
try {
|
||||||
data = await this.auth.login(req.user);
|
data = await this.auth.login(body);
|
||||||
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
|
if (!data.access_token || body.remember_me && (!data.refresh_token || !data.refresh_exp)) {
|
||||||
response.statusCode = 500;
|
response.statusCode = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -44,6 +47,14 @@ export class AuthController {
|
|||||||
error: err,
|
error: err,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (err instanceof UnauthorizedException) {
|
||||||
|
response.statusCode = 401;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Invalid credentials.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
response.statusCode = 500;
|
response.statusCode = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -58,12 +69,14 @@ export class AuthController {
|
|||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (body.remember_me) {
|
||||||
response.cookie('Refresh', data.refresh_token, {
|
response.cookie('Refresh', data.refresh_token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: true,
|
||||||
expires: new Date(data.refresh_exp),
|
expires: new Date(data.refresh_exp),
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
class: AuthController.name,
|
class: AuthController.name,
|
||||||
@@ -71,6 +84,7 @@ export class AuthController {
|
|||||||
user: req.user,
|
user: req.user,
|
||||||
access_token: data.access_token,
|
access_token: data.access_token,
|
||||||
refresh_token: data.refresh_token,
|
refresh_token: data.refresh_token,
|
||||||
|
remember_me: body.remember_me,
|
||||||
msg: 'User logged in.',
|
msg: 'User logged in.',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +93,7 @@ export class AuthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAccessGuard)
|
@UseGuards(JwtMixedGuard)
|
||||||
@Delete('login')
|
@Delete('login')
|
||||||
async logout(
|
async logout(
|
||||||
@Request() req,
|
@Request() req,
|
||||||
@@ -91,19 +105,19 @@ export class AuthController {
|
|||||||
response.clearCookie('Authentication');
|
response.clearCookie('Authentication');
|
||||||
response.clearCookie('Refresh');
|
response.clearCookie('Refresh');
|
||||||
|
|
||||||
if (!refreshToken || !await this.auth.revoke(req.user.userId, refreshToken)) {
|
if (!accessToken && !refreshToken && !await this.auth.revoke(req.user.userId, refreshToken)) {
|
||||||
// User has already logged off.
|
// User has already logged off.
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
class: AuthController.name,
|
class: AuthController.name,
|
||||||
method: this.login.name,
|
method: this.logout.name,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
msg: 'User has already logged off via ' + (!refreshToken ? 'cookies' : 'database'),
|
msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'),
|
||||||
});
|
});
|
||||||
|
|
||||||
response.statusCode = 400;
|
response.statusCode = 400;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error_message: 'User has already logged off.'
|
error_message: 'User has already logged off.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +141,31 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
class: AuthController.name,
|
class: AuthController.name,
|
||||||
method: this.login.name,
|
method: this.refresh.name,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
refresh_token: req.cookies.Refresh,
|
refresh_token: req.cookies.Refresh,
|
||||||
msg: 'User logged in.',
|
msg: 'Attempting to renew access token.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = req.cookies.Refresh;
|
const results = await this.auth.verify(req.cookies.Authentication, req.cookies.Refresh);
|
||||||
const data = await this.auth.renew(req.user, refreshToken);
|
|
||||||
|
if (results.validation === false) {
|
||||||
|
this.logger.info({
|
||||||
|
class: AuthController.name,
|
||||||
|
method: this.refresh.name,
|
||||||
|
user: req.user,
|
||||||
|
refresh_token: req.cookies.Refresh,
|
||||||
|
msg: 'Refresh token is invalid. Access token is not refreshing.',
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Refresh token is invalid.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.auth.renew(req.user);
|
||||||
|
|
||||||
response.cookie('Authentication', data.access_token, {
|
response.cookie('Authentication', data.access_token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -150,22 +181,6 @@ export class AuthController {
|
|||||||
msg: 'Updated Authentication cookie for access token.',
|
msg: 'Updated Authentication cookie for access token.',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.refresh_token != refreshToken) {
|
|
||||||
response.cookie('Refresh', data.refresh_token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
expires: new Date(data.refresh_exp),
|
|
||||||
sameSite: 'strict',
|
|
||||||
});
|
|
||||||
this.logger.debug({
|
|
||||||
class: AuthController.name,
|
|
||||||
method: this.refresh.name,
|
|
||||||
user: req.user,
|
|
||||||
refresh_token: data.refresh_token,
|
|
||||||
msg: 'Updated Refresh cookie for refresh token.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +191,14 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() body: RegisterUserDto,
|
@Body() body: RegisterUserDto,
|
||||||
) {
|
) {
|
||||||
|
if (!AppConfig.features.registration) {
|
||||||
|
response.statusCode = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Registration disabled.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let user: UserEntity | null;
|
let user: UserEntity | null;
|
||||||
let data: AuthenticationDto | null;
|
let data: AuthenticationDto | null;
|
||||||
try {
|
try {
|
||||||
@@ -220,8 +243,12 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await this.auth.login(user);
|
data = await this.auth.login({
|
||||||
if (!data.access_token || !data.refresh_token || !data.refresh_exp) {
|
user_login: body.user_login,
|
||||||
|
password: body.password,
|
||||||
|
remember_me: false,
|
||||||
|
});
|
||||||
|
if (!data.access_token) {
|
||||||
this.logger.error({
|
this.logger.error({
|
||||||
class: AuthController.name,
|
class: AuthController.name,
|
||||||
method: this.register.name,
|
method: this.register.name,
|
||||||
@@ -246,6 +273,15 @@ export class AuthController {
|
|||||||
error: err,
|
error: err,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This should never happen...
|
||||||
|
if (err instanceof UnauthorizedException) {
|
||||||
|
response.statusCode = 401;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Invalid credentials.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
response.statusCode = 500;
|
response.statusCode = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -260,15 +296,31 @@ export class AuthController {
|
|||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
});
|
});
|
||||||
|
|
||||||
response.cookie('Refresh', data.refresh_token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
expires: new Date(data.refresh_exp),
|
|
||||||
sameSite: 'strict',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('validate')
|
||||||
|
async validate(
|
||||||
|
@Request() req,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const accessToken = req.cookies['Authentication'];
|
||||||
|
const refreshToken = req.cookies['Refresh'];
|
||||||
|
const verification = await this.auth.verify(accessToken, refreshToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...verification,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
response.statusCode = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: err,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UsersModule } from 'src/users/users.module';
|
import { UsersModule } from 'src/users/users.module';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { LoginStrategy } from './strategies/login.strategy';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { JwtOptions } from './jwt.options';
|
import { JwtOptions } from './jwt.options';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
|
||||||
import { AuthRefreshService } from './auth.refresh.service';
|
import { AuthRefreshService } from './auth.refresh.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
|
import { AuthRefreshTokenEntity } from './entities/auth.refresh-token.entity';
|
||||||
@@ -35,9 +34,8 @@ import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
|||||||
AuthAccessService,
|
AuthAccessService,
|
||||||
AuthRefreshService,
|
AuthRefreshService,
|
||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtAccessStrategy,
|
||||||
JwtRefreshStrategy,
|
JwtRefreshStrategy,
|
||||||
LoginStrategy,
|
|
||||||
],
|
],
|
||||||
controllers: [AuthController]
|
controllers: [AuthController]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -20,59 +20,11 @@ export class AuthRefreshService {
|
|||||||
private logger: PinoLogger,
|
private logger: PinoLogger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async generate(user: UserEntity, refreshToken?: string) {
|
async generate(user: UserEntity) {
|
||||||
let expiration: Date | null = null;
|
|
||||||
if (refreshToken) {
|
|
||||||
const token = await this.get(refreshToken, user.userId);
|
|
||||||
if (!token) {
|
|
||||||
this.logger.warn({
|
|
||||||
class: AuthRefreshService.name,
|
|
||||||
method: this.generate.name,
|
|
||||||
user,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
msg: 'Refresh token given is invalid.',
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Invalid refresh token.');
|
|
||||||
}
|
|
||||||
if (token.exp.getTime() < new Date().getTime()) {
|
|
||||||
this.logger.warn({
|
|
||||||
class: AuthRefreshService.name,
|
|
||||||
method: this.generate.name,
|
|
||||||
user,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
exp: expiration,
|
|
||||||
msg: 'Refresh token given has expired.',
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Invalid refresh token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
expiration = token.exp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new refresh token if either:
|
|
||||||
// - no previous token exists;
|
|
||||||
// - token has reached expiration threshold;
|
|
||||||
// - token has expired.
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
|
const expirationTime = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS'));
|
||||||
const threshhold = parseInt(this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS'));
|
const expiration = moment(now).add(expirationTime, 'ms').toDate();
|
||||||
if (!refreshToken || expirationTime - (expiration.getTime() - now.getTime()) > threshhold) {
|
const refreshToken = await this.jwts.signAsync(
|
||||||
let deletionTask = null;
|
|
||||||
if (refreshToken) {
|
|
||||||
deletionTask = this.revoke(user.userId, refreshToken);
|
|
||||||
|
|
||||||
this.logger.debug({
|
|
||||||
class: AuthRefreshService.name,
|
|
||||||
method: this.generate.name,
|
|
||||||
user,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
exp: expiration,
|
|
||||||
msg: 'Deleted previous refresh token.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
expiration = moment(now).add(expirationTime, 'ms').toDate();
|
|
||||||
refreshToken = await this.jwts.signAsync(
|
|
||||||
{
|
{
|
||||||
username: user.userLogin,
|
username: user.userLogin,
|
||||||
sub: user.userId,
|
sub: user.userId,
|
||||||
@@ -109,9 +61,6 @@ export class AuthRefreshService {
|
|||||||
msg: 'Inserted the new refresh token into the database.',
|
msg: 'Inserted the new refresh token into the database.',
|
||||||
});
|
});
|
||||||
|
|
||||||
await deletionTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
exp: expiration.getTime(),
|
exp: expiration.getTime(),
|
||||||
@@ -155,4 +104,14 @@ export class AuthRefreshService {
|
|||||||
const refresh = await this.get(refreshToken, userId);
|
const refresh = await this.get(refreshToken, userId);
|
||||||
return refresh && refresh.exp.getTime() > new Date().getTime();
|
return refresh && refresh.exp.getTime() > new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async verify(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<any> {
|
||||||
|
return await this.jwts.verifyAsync(refreshToken,
|
||||||
|
{
|
||||||
|
secret: this.config.getOrThrow<string>('AUTH_JWT_REFRESH_TOKEN_SECRET'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { UserEntity } from 'src/users/entities/users.entity';
|
import { UserEntity } from 'src/users/entities/users.entity';
|
||||||
import { UsersService } from 'src/users/users.service';
|
import { UsersService } from 'src/users/users.service';
|
||||||
import { AuthRefreshService } from './auth.refresh.service';
|
import { AuthRefreshService } from './auth.refresh.service';
|
||||||
import { AuthAccessService } from './auth.access.service';
|
import { AuthAccessService } from './auth.access.service';
|
||||||
import { UUID } from 'crypto';
|
import { UUID } from 'crypto';
|
||||||
|
import { AuthenticationDto } from './dto/authentication.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { AccessTokenDto } from './dto/access-token.dto';
|
||||||
|
import { TokenExpiredError } from '@nestjs/jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -14,33 +18,127 @@ export class AuthService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
async login(user: UserEntity): Promise<AuthenticationDto> {
|
async login(
|
||||||
return this.renew(user, null);
|
loginDetails: LoginDto
|
||||||
|
): Promise<AuthenticationDto> {
|
||||||
|
const user = await this.users.findOne(loginDetails);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const access_token = await this.accessTokens.generate(user);
|
||||||
|
|
||||||
|
if (!loginDetails.remember_me) {
|
||||||
|
return {
|
||||||
|
...access_token,
|
||||||
|
refresh_token: null,
|
||||||
|
refresh_exp: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh_token = await this.refreshTokens.generate(user);
|
||||||
|
return {
|
||||||
|
...access_token,
|
||||||
|
refresh_token: refresh_token.refresh_token,
|
||||||
|
refresh_exp: refresh_token.exp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renew(
|
||||||
|
user: UserEntity,
|
||||||
|
): Promise<AccessTokenDto> {
|
||||||
|
return await this.accessTokens.generate(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(
|
async validate(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<UserEntity | null> {
|
): Promise<UserEntity | null> {
|
||||||
return await this.users.findOne({ username, password });
|
return await this.users.findOne({ user_login: username, password, remember_me: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(
|
async verify(
|
||||||
user: UserEntity,
|
accessToken: string,
|
||||||
refresh_token: string | null
|
refreshToken: string
|
||||||
): Promise<AuthenticationDto | null> {
|
): Promise<{ validation: boolean, userId: UUID | null, username: string | null }> {
|
||||||
const new_refresh_data = await this.refreshTokens.generate(user, refresh_token);
|
let access: any = null;
|
||||||
const access_token = await this.accessTokens.generate(user);
|
let refresh: any = null;
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
try {
|
||||||
|
access = await this.accessTokens.verify(accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof TokenExpiredError)) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (access && (!access.username || !access.sub)) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
refresh = await this.refreshTokens.verify(refreshToken);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refresh.username || !refresh.sub) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!access && !refresh) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
} else if (!access && refresh) {
|
||||||
|
return {
|
||||||
|
validation: null,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
} else if (access && refresh) {
|
||||||
|
if (access.username != refresh.username || access.sub != refresh.sub) {
|
||||||
|
return {
|
||||||
|
validation: false,
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...access_token,
|
validation: true,
|
||||||
refresh_token: new_refresh_data.refresh_token,
|
userId: (access ?? refresh).sub,
|
||||||
refresh_exp: new_refresh_data.exp,
|
username: (access ?? refresh).username,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async revoke(userId: UUID, refreshToken: string): Promise<boolean> {
|
async revoke(
|
||||||
|
userId: UUID,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
const res = await this.refreshTokens.revoke(userId, refreshToken);
|
const res = await this.refreshTokens.revoke(userId, refreshToken);
|
||||||
return res?.affected === 1
|
return res?.affected === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export class AccessTokenDto {
|
||||||
|
access_token: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class AuthenticationDto {
|
export class AuthenticationDto {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
exp: number;
|
exp: number;
|
||||||
refresh_token: string | null;
|
refresh_token: string | null;
|
||||||
|
|||||||
19
backend/nestjs-seshat-api/src/auth/dto/login.dto.ts
Normal file
19
backend/nestjs-seshat-api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(24)
|
||||||
|
readonly user_login: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(128)
|
||||||
|
readonly password: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
readonly remember_me: boolean;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAccessGuard extends AuthGuard('jwt-access') {
|
export class JwtAccessGuard extends AuthGuard('jwt-access') {
|
||||||
handleRequest(err, user, info) {
|
handleRequest(err, user, info) {
|
||||||
if (err || !user || !user.isAdmin) {
|
if (err || !user) {
|
||||||
throw err || new UnauthorizedException();
|
throw err || new UnauthorizedException();
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
12
backend/nestjs-seshat-api/src/auth/guards/jwt-mixed.guard.ts
Normal file
12
backend/nestjs-seshat-api/src/auth/guards/jwt-mixed.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtMixedGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
|
||||||
|
handleRequest(err, user, info) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new ForbiddenException();
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LoginAuthGuard extends AuthGuard('login') { }
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
|
|
||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OfflineGuard implements CanActivate {
|
export class OfflineGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {
|
||||||
canActivate(
|
handleRequest(err, user, info) {
|
||||||
context: ExecutionContext,
|
if (err || user) {
|
||||||
): boolean | Promise<boolean> | Observable<boolean> {
|
throw err || new ForbiddenException();
|
||||||
const request = context.switchToHttp().getRequest();
|
}
|
||||||
return !request.user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { UsersService } from 'src/users/users.service';
|
import { UsersService } from 'src/users/users.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-access') {
|
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
|
||||||
constructor(private users: UsersService, private config: ConfigService) {
|
constructor(private users: UsersService, private config: ConfigService) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
//ExtractJwt.fromAuthHeaderAsBearerToken(),
|
//ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
JwtStrategy.extract,
|
JwtAccessStrategy.extract,
|
||||||
]),
|
]),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
secretOrKey: config.getOrThrow<string>('AUTH_JWT_ACCESS_TOKEN_SECRET'),
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
|
|
||||||
import { Strategy } from 'passport-local';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { AuthService } from '../auth.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LoginStrategy extends PassportStrategy(Strategy, 'login') {
|
|
||||||
constructor(private authService: AuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(username: string, password: string): Promise<any> {
|
|
||||||
const user = await this.authService.validate(username, password);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,7 @@ import { BookOriginEntity } from './entities/book-origin.entity';
|
|||||||
import { BookStatusEntity } from './entities/book-status.entity';
|
import { BookStatusEntity } from './entities/book-status.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { ProvidersModule } from 'src/providers/providers.module';
|
|
||||||
import { SeriesModule } from 'src/series/series.module';
|
import { SeriesModule } from 'src/series/series.module';
|
||||||
import { LibraryService } from 'src/library/library.service';
|
|
||||||
import { LibraryModule } from 'src/library/library.module';
|
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,16 +16,11 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
]),
|
]),
|
||||||
SeriesModule,
|
SeriesModule,
|
||||||
HttpModule,
|
HttpModule,
|
||||||
ProvidersModule,
|
|
||||||
LibraryModule,
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: 'library',
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
exports: [
|
exports: [
|
||||||
BooksService
|
BooksService
|
||||||
],
|
],
|
||||||
providers: [BooksService, LibraryService]
|
providers: [BooksService]
|
||||||
})
|
})
|
||||||
export class BooksModule { }
|
export class BooksModule { }
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -9,6 +9,9 @@ import { CreateBookDto } from './dto/create-book.dto';
|
|||||||
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
|
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 { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
|
||||||
|
import { BookOriginDto } from './dto/book-origin.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BooksService {
|
export class BooksService {
|
||||||
@@ -36,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({
|
||||||
@@ -54,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)
|
||||||
@@ -62,25 +67,36 @@ export class BooksService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBookStatusesTrackedBy(userId: UUID): Promise<BookStatusEntity[]> {
|
async findBooksFromSeries(series: SeriesDto): Promise<BookEntity[]> {
|
||||||
|
return await this.bookRepository.find({
|
||||||
|
where: {
|
||||||
|
providerSeriesId: series.providerSeriesId,
|
||||||
|
provider: series.provider,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBooks(): Promise<BookEntity[]> {
|
||||||
|
return await this.bookRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider'])
|
.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'])
|
||||||
.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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +117,7 @@ export class BooksService {
|
|||||||
await this.bookStatusRepository.createQueryBuilder()
|
await this.bookStatusRepository.createQueryBuilder()
|
||||||
.insert()
|
.insert()
|
||||||
.values(status)
|
.values(status)
|
||||||
.orUpdate(['user_id', 'book_id'], ['state', 'modified_at'], { skipUpdateIfNoValuesChanged: true })
|
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class BookOriginDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
bookOriginId: string;
|
||||||
|
}
|
||||||
@@ -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,9 +11,11 @@ export class CreateBookStatusDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
readonly userId: UUID;
|
readonly userId: UUID;
|
||||||
|
|
||||||
@IsString()
|
@IsNumber()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
state: string;
|
@Min(0)
|
||||||
|
@Max(6)
|
||||||
|
state: number;
|
||||||
|
|
||||||
modifiedAt: Date;
|
modifiedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
@@ -11,8 +11,8 @@ export class BookStatusEntity {
|
|||||||
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
||||||
readonly userId: UUID;
|
readonly userId: UUID;
|
||||||
|
|
||||||
@Column({ name: 'state', type: 'varchar' })
|
@Column({ name: 'state', type: 'smallint' })
|
||||||
state: string;
|
state: number;
|
||||||
|
|
||||||
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||||
addedAt: Date
|
addedAt: Date
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { OnQueueEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
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')
|
||||||
@@ -26,49 +26,9 @@ export class LibraryConsumer extends WorkerHost {
|
|||||||
msg: 'Started task on queue.',
|
msg: 'Started task on queue.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const series: CreateSeriesSubscriptionJobDto = job.data;
|
if (job.name == 'new_series') {
|
||||||
|
const series: SeriesSubscriptionJobDto = job.data;
|
||||||
let context = this.provider.generateSearchContext(series.provider, series.title) as GoogleSearchContext;
|
const books = await this.search(job, series, null);
|
||||||
//context.intitle = series.title;
|
|
||||||
context.maxResults = '40';
|
|
||||||
context.subject = 'Fiction';
|
|
||||||
|
|
||||||
// Search for the book(s) via the provider.
|
|
||||||
// Up until end of results or after 3 unhelpful pages of results.
|
|
||||||
let results = [];
|
|
||||||
let related = [];
|
|
||||||
let pageSearchedCount = 0;
|
|
||||||
let unhelpfulResultsCount = 0;
|
|
||||||
do {
|
|
||||||
pageSearchedCount += 1;
|
|
||||||
results = await this.provider.search(context);
|
|
||||||
const potential = results.filter(r => r.providerSeriesId == series.providerSeriesId || r.title == series.title);
|
|
||||||
if (potential.length > 0) {
|
|
||||||
related.push.apply(related, potential);
|
|
||||||
} else {
|
|
||||||
unhelpfulResultsCount += 1;
|
|
||||||
}
|
|
||||||
context = context.next();
|
|
||||||
job.updateProgress(pageSearchedCount * 5);
|
|
||||||
} while (results.length >= 40 && unhelpfulResultsCount < 3);
|
|
||||||
|
|
||||||
// Sort & de-duplicate the entries received.
|
|
||||||
const books = related.map(book => this.toScore(book, series))
|
|
||||||
.sort((a, b) => a.result.volume - b.result.volume || b.score - a.score)
|
|
||||||
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
|
|
||||||
job.updateProgress(25);
|
|
||||||
|
|
||||||
this.logger.debug({
|
|
||||||
class: LibraryConsumer.name,
|
|
||||||
method: this.process.name,
|
|
||||||
job: job,
|
|
||||||
msg: 'Finished searching for book entries.',
|
|
||||||
results: {
|
|
||||||
pages: pageSearchedCount,
|
|
||||||
related_entries: related.length,
|
|
||||||
volumes: books.length,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
for (let book of books) {
|
for (let book of books) {
|
||||||
@@ -82,7 +42,7 @@ export class LibraryConsumer extends WorkerHost {
|
|||||||
method: this.process.name,
|
method: this.process.name,
|
||||||
book: book.result,
|
book: book.result,
|
||||||
score: book.score,
|
score: book.score,
|
||||||
msg: 'Failed to add book in background.',
|
msg: 'Failed to add book in background during adding series.',
|
||||||
error: err,
|
error: err,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,6 +50,45 @@ export class LibraryConsumer extends WorkerHost {
|
|||||||
job.updateProgress(25 + 75 * counter / books.length);
|
job.updateProgress(25 + 75 * counter / books.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (job.name == 'update_series') {
|
||||||
|
const series: SeriesSubscriptionJobDto = job.data;
|
||||||
|
const existingBooks = await this.library.findBooksFromSeries(series);
|
||||||
|
const existingVolumes = existingBooks.map(b => b.volume);
|
||||||
|
const lastPublishedBook = existingBooks.reduce((a, b) => a.publishedAt.getTime() > b.publishedAt.getTime() ? a : b);
|
||||||
|
const books = await this.search(job, series, lastPublishedBook?.publishedAt);
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
for (let book of books) {
|
||||||
|
if (existingVolumes.includes(book.result.volume)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force the provider's series id to be set, so that we know which series this belongs.
|
||||||
|
book.result.providerSeriesId = series.providerSeriesId;
|
||||||
|
await this.library.addBook(book.result);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.process.name,
|
||||||
|
book: book.result,
|
||||||
|
score: book.score,
|
||||||
|
msg: 'Failed to add book in background during series update.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
counter++;
|
||||||
|
job.updateProgress(25 + 75 * counter / books.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.process.name,
|
||||||
|
job: job,
|
||||||
|
msg: 'Unknown job name found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
class: LibraryConsumer.name,
|
class: LibraryConsumer.name,
|
||||||
@@ -101,7 +100,92 @@ export class LibraryConsumer extends WorkerHost {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toScore(book: BookSearchResultDto, series: CreateSeriesSubscriptionJobDto): ({ 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;
|
||||||
|
context.maxResults = '40';
|
||||||
|
if (after) {
|
||||||
|
context.orderBy = 'newest';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the book(s) via the provider.
|
||||||
|
// Up until end of results or after 3 unhelpful pages of results.
|
||||||
|
let results = [];
|
||||||
|
let related = [];
|
||||||
|
let pageSearchedCount = 0;
|
||||||
|
let unhelpfulResultsCount = 0;
|
||||||
|
do {
|
||||||
|
pageSearchedCount += 1;
|
||||||
|
results = await this.provider.search(context);
|
||||||
|
const potential = results.filter((r: BookSearchResultDto) => r.providerSeriesId == series.providerSeriesId || r.title == series.title && r.mediaType == series.mediaType);
|
||||||
|
if (potential.length > 0) {
|
||||||
|
related.push.apply(related, potential);
|
||||||
|
} else {
|
||||||
|
unhelpfulResultsCount += 1;
|
||||||
|
}
|
||||||
|
context = context.next();
|
||||||
|
job.updateProgress(pageSearchedCount * 5);
|
||||||
|
} while (results.length >= context.maxResults && (!after || after < results[results.length - 1].publishedAt));
|
||||||
|
|
||||||
|
// Sort & de-duplicate the entries received.
|
||||||
|
const books = related.map(book => this.toScore(book, series))
|
||||||
|
.sort((a, b) => a.result.volume - b.result.volume || b.score - a.score)
|
||||||
|
.filter((_, index, arr) => index == 0 || arr[index - 1].result.volume != arr[index].result.volume);
|
||||||
|
job.updateProgress(25);
|
||||||
|
|
||||||
|
this.logger.debug({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.search.name,
|
||||||
|
job: job,
|
||||||
|
msg: 'Finished searching for book entries.',
|
||||||
|
results: {
|
||||||
|
pages: pageSearchedCount,
|
||||||
|
related_entries: related.length,
|
||||||
|
volumes: books.length,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueEvent('failed')
|
||||||
|
onFailed(job: Job, err: Error) {
|
||||||
|
this.logger.error({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.onFailed.name,
|
||||||
|
job: job,
|
||||||
|
msg: 'A library job failed.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueEvent('paused')
|
||||||
|
onPaused() {
|
||||||
|
this.logger.info({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.onPaused.name,
|
||||||
|
msg: 'Library jobs have been paused.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueEvent('resumed')
|
||||||
|
onResume(job: Job) {
|
||||||
|
this.logger.info({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.onResume.name,
|
||||||
|
msg: 'Library jobs have resumed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueEvent('waiting')
|
||||||
|
onWaiting(jobId: number | string) {
|
||||||
|
this.logger.info({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.onWaiting.name,
|
||||||
|
msg: 'A library job is waiting...',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toScore(book: BookSearchResultDto, series: SeriesSubscriptionJobDto): ({ result: BookSearchResultDto, score: number }) {
|
||||||
if (!book) {
|
if (!book) {
|
||||||
return {
|
return {
|
||||||
result: null,
|
result: null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Body, Controller, Delete, Get, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Patch, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
@@ -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,15 +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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -57,6 +54,63 @@ 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.createSeries.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create a series.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Something went wrong.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('series')
|
||||||
|
async updateSeries(
|
||||||
|
@Request() req,
|
||||||
|
@Body() body: CreateSeriesDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.library.updateSeries(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} 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.
|
// Subscription already exist.
|
||||||
response.statusCode = 409;
|
response.statusCode = 409;
|
||||||
return {
|
return {
|
||||||
@@ -66,6 +120,64 @@ export class LibraryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -77,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,
|
||||||
@@ -103,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 {
|
||||||
@@ -110,8 +230,17 @@ 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 = 400;
|
response.statusCode = 404;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error_message: 'Series does not exist.',
|
error_message: 'Series does not exist.',
|
||||||
@@ -119,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,
|
||||||
@@ -127,13 +265,27 @@ 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,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await this.books.findBookStatusesTrackedBy(req.user.userId),
|
data: await this.library.findBooks(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,23 +296,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({
|
class: LibraryController.name,
|
||||||
provider: body.provider,
|
method: this.createBook.name,
|
||||||
providerSeriesId: body.providerSeriesId,
|
user: req.user,
|
||||||
title: body.title,
|
body: body,
|
||||||
|
msg: 'Failed to create book due to book being part of a series.',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof QueryFailedError) {
|
response.statusCode = 400;
|
||||||
// Ignore if the series already exist.
|
|
||||||
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.',
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +319,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.createBook.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create book.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
// Book exists already.
|
// Book exists already.
|
||||||
response.statusCode = 409;
|
response.statusCode = 409;
|
||||||
return {
|
return {
|
||||||
@@ -179,11 +335,20 @@ 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') {
|
||||||
// Data dependency is missing.
|
this.logger.warn({
|
||||||
response.statusCode = 500;
|
class: LibraryController.name,
|
||||||
|
method: this.createBook.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create book.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series is missing.
|
||||||
|
response.statusCode = 404;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error_message: 'Series has not been added.',
|
error_message: 'Series does not exist.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +357,8 @@ export class LibraryController {
|
|||||||
class: LibraryController.name,
|
class: LibraryController.name,
|
||||||
method: this.createBook.name,
|
method: this.createBook.name,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
msg: 'Failed to create book.',
|
body: body,
|
||||||
|
msg: 'Failed to create a book.',
|
||||||
error: err,
|
error: err,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,33 +379,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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +402,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { LibraryController } from './library.controller';
|
|||||||
HttpModule,
|
HttpModule,
|
||||||
ProvidersModule,
|
ProvidersModule,
|
||||||
],
|
],
|
||||||
|
exports: [LibraryService],
|
||||||
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
|
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
|
||||||
controllers: [LibraryController]
|
controllers: [LibraryController]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import { BooksService } from 'src/books/books.service';
|
import { BooksService } from 'src/books/books.service';
|
||||||
import { CreateBookDto } from 'src/books/dto/create-book.dto';
|
|
||||||
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||||
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
|
import { CreateSeriesDto } from 'src/series/dto/create-series.dto';
|
||||||
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
|
import { SeriesSubscriptionDto } from 'src/series/dto/series-subscription.dto';
|
||||||
|
import { SeriesDto } from 'src/series/dto/series.dto';
|
||||||
import { SeriesService } from 'src/series/series.service';
|
import { SeriesService } from 'src/series/series.service';
|
||||||
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export class LibraryService {
|
|||||||
this.logger.debug({
|
this.logger.debug({
|
||||||
class: LibraryService.name,
|
class: LibraryService.name,
|
||||||
method: this.addSubscription.name,
|
method: this.addSubscription.name,
|
||||||
series: series.providerSeriesId,
|
series: series,
|
||||||
msg: 'Series saved to database.',
|
msg: 'Series saved to database.',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,4 +135,16 @@ export class LibraryService {
|
|||||||
|
|
||||||
return bookId;
|
return bookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findBooks() {
|
||||||
|
return await this.books.findBooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBooksFromSeries(series: SeriesDto) {
|
||||||
|
return await this.books.findBooksFromSeries(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSeries(series: CreateSeriesDto) {
|
||||||
|
return await this.jobs.add('update_series', series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function serialize_user_long(value: UserEntity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serialize_token(value: string) {
|
export function serialize_token(value: string) {
|
||||||
|
if (!value) return null;
|
||||||
return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0);
|
return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ async function bootstrap() {
|
|||||||
}));
|
}));
|
||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||||
await app.listen(process.env.PORT ?? 3001);
|
await app.listen(process.env.WEB_API_PORT ?? 3001);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export class GoogleSearchContext extends SearchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
generateQueryParams() {
|
generateQueryParams(): string {
|
||||||
const filterParams = ['maxResults', 'startIndex'];
|
const filterParams = ['maxResults', 'startIndex', 'orderBy', 'langRestrict'];
|
||||||
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
|
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
|
||||||
|
|
||||||
const queryParams = filterParams
|
const queryParams = filterParams
|
||||||
@@ -21,7 +21,19 @@ export class GoogleSearchContext extends SearchContext {
|
|||||||
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
|
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
|
||||||
].filter(p => p.length > 0).join('');
|
].filter(p => p.length > 0).join('');
|
||||||
|
|
||||||
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 0).join('&');
|
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 2).join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderBy(): 'newest' | 'relevant' {
|
||||||
|
return this.params['orderBy'] as 'newest' | 'relevant' ?? 'relevant';
|
||||||
|
}
|
||||||
|
|
||||||
|
set orderBy(value: 'newest' | 'relevant' | null) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['orderBy'];
|
||||||
|
} else {
|
||||||
|
this.params['orderBy'] = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxResults(): number {
|
get maxResults(): number {
|
||||||
@@ -108,12 +120,24 @@ export class GoogleSearchContext extends SearchContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
previous(pageCount: number = 1): GoogleSearchContext {
|
||||||
|
if (pageCount > 0)
|
||||||
|
return this.update(-pageCount);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(pageCount: number = 1): GoogleSearchContext {
|
||||||
|
if (pageCount > 0)
|
||||||
|
return this.update(pageCount);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(pageChange: number): GoogleSearchContext {
|
||||||
const resultsPerPage = this.params['maxResults'] ? parseInt(this.params['maxResults']) : 10;
|
const resultsPerPage = this.params['maxResults'] ? parseInt(this.params['maxResults']) : 10;
|
||||||
const index = this.params['startIndex'] ? parseInt(this.params['startIndex']) : 0;
|
const index = this.params['startIndex'] ? parseInt(this.params['startIndex']) : 0;
|
||||||
|
|
||||||
const data = { ...this.params };
|
const data = { ...this.params };
|
||||||
data['startIndex'] = (index + resultsPerPage).toString();
|
data['startIndex'] = Math.max(0, index + resultsPerPage * pageChange).toString();
|
||||||
|
|
||||||
return new GoogleSearchContext(this.search, data);
|
return new GoogleSearchContext(this.search, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export abstract class SearchContext {
|
|||||||
this.params = params;
|
this.params = params;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract generateQueryParams();
|
abstract generateQueryParams(): string;
|
||||||
abstract next();
|
abstract previous(pageCount: number): SearchContext;
|
||||||
|
abstract next(pageCount: number): SearchContext;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { GoogleSearchContext } from "./google.search.context";
|
||||||
|
import { SearchContext } from "./search.context";
|
||||||
|
|
||||||
|
export class SimplifiedSearchContext {
|
||||||
|
values: { [key: string]: string };
|
||||||
|
|
||||||
|
constructor(values: { [key: string]: string }) {
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
toSearchContext(): SearchContext | null {
|
||||||
|
const provider = this.values['provider']?.toString().toLowerCase();
|
||||||
|
const search = this.values['search']?.toString();
|
||||||
|
const valuesCopy = { ...this.values };
|
||||||
|
delete valuesCopy['provider'];
|
||||||
|
delete valuesCopy['search'];
|
||||||
|
|
||||||
|
if (provider == 'google') {
|
||||||
|
return new GoogleSearchContext(search, valuesCopy)
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ export class BookSearchResultDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
language: string;
|
language: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
mediaType: string | null;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
|
||||||
import { BookSearchResultDto } from '../dto/book-search-result.dto';
|
import { BookSearchResultDto } from '../dto/book-search-result.dto';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { firstValueFrom, map, timeout } from 'rxjs';
|
import { catchError, EMPTY, firstValueFrom, map, timeout } from 'rxjs';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosError, AxiosResponse } from 'axios';
|
||||||
import { GoogleSearchContext } from '../contexts/google.search.context';
|
import { GoogleSearchContext } from '../contexts/google.search.context';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleService {
|
export class GoogleService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
|
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
|
||||||
const queryParams = 'langRestrict=en&printType=books&maxResults=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
|
const queryParams = 'langRestrict=en&printType=books&maxResults=20&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
|
||||||
|
|
||||||
return await firstValueFrom(
|
return await firstValueFrom(
|
||||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
||||||
@@ -28,7 +30,7 @@ export class GoogleService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultQueryParams = 'langRestrict=en&printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
|
const defaultQueryParams = 'printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
|
||||||
const customQueryParams = context.generateQueryParams();
|
const customQueryParams = context.generateQueryParams();
|
||||||
|
|
||||||
return await firstValueFrom(
|
return await firstValueFrom(
|
||||||
@@ -36,6 +38,23 @@ export class GoogleService {
|
|||||||
.pipe(
|
.pipe(
|
||||||
timeout({ first: 5000 }),
|
timeout({ first: 5000 }),
|
||||||
map(value => this.transform(value)),
|
map(value => this.transform(value)),
|
||||||
|
catchError((err: any) => {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
if (err.status == 400) {
|
||||||
|
throw new BadRequestException(err.response.data);
|
||||||
|
} else if (err.status == 429) {
|
||||||
|
throw new HttpException(err.response?.data, 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error({
|
||||||
|
class: GoogleService.name,
|
||||||
|
method: this.search.name,
|
||||||
|
msg: 'Unknown Google search error.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,11 +65,11 @@ export class GoogleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.data.items
|
return response.data.items
|
||||||
//.filter(item => item.volumeInfo?.canonicalVolumeLink?.startsWith('https://play.google.com/store/books/details'))
|
|
||||||
.map(item => this.extract(item));
|
.map(item => this.extract(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
private extract(item: any): BookSearchResultDto {
|
private extract(item: any): BookSearchResultDto {
|
||||||
|
const secure = process.env.WEB_SECURE?.toLowerCase() == 'true';
|
||||||
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,
|
||||||
@@ -59,18 +78,18 @@ export class GoogleService {
|
|||||||
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
|
volume: item.volumeInfo.seriesInfo?.bookDisplayNumber ? parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber, 10) : undefined,
|
||||||
publisher: item.volumeInfo.publisher,
|
publisher: item.volumeInfo.publisher,
|
||||||
authors: item.volumeInfo.authors,
|
authors: item.volumeInfo.authors,
|
||||||
categories: item.volumeInfo.categories,
|
categories: item.volumeInfo.categories ?? [],
|
||||||
|
mediaType: null,
|
||||||
maturityRating: item.volumeInfo.maturityRating,
|
maturityRating: item.volumeInfo.maturityRating,
|
||||||
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))) : [],
|
industryIdentifiers: item.volumeInfo.industryIdentifiers ? Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => i.type == 'OTHER' ? { [i.identifier.split(':')[0]]: i.identifier.split(':')[1] } : { [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: secure && item.volumeInfo.imageLinks?.thumbnail ? item.volumeInfo.imageLinks.thumbnail.replaceAll('http://', 'https://') : item.volumeInfo.imageLinks?.thumbnail,
|
||||||
url: item.volumeInfo.canonicalVolumeLink,
|
url: item.volumeInfo.canonicalVolumeLink,
|
||||||
provider: 'google'
|
provider: 'google'
|
||||||
}
|
};
|
||||||
|
|
||||||
let regex = this.getRegexByPublisher(result.publisher);
|
|
||||||
|
|
||||||
|
const regex = this.getRegexByPublisher(result.publisher);
|
||||||
const match = result.title.match(regex);
|
const match = result.title.match(regex);
|
||||||
if (match?.groups) {
|
if (match?.groups) {
|
||||||
result.title = match.groups['title'].trim();
|
result.title = match.groups['title'].trim();
|
||||||
@@ -79,19 +98,41 @@ export class GoogleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (match?.groups && 'media_type' in match.groups) {
|
||||||
|
result.mediaType = match.groups['media_type'];
|
||||||
|
} else if (result.categories.includes('Comics & Graphic Novels')) {
|
||||||
|
result.mediaType = 'Comics & Graphic Novels';
|
||||||
|
} else if (result.categories.includes('Fiction') || result.categories.includes('Young Adult Fiction')) {
|
||||||
|
result.mediaType = 'Novel';
|
||||||
|
} else {
|
||||||
|
result.mediaType = 'Book';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mediaType) {
|
||||||
|
if (result.mediaType.toLowerCase() == "light novel") {
|
||||||
|
result.mediaType = 'Light Novel';
|
||||||
|
} else if (result.mediaType.toLowerCase() == 'manga') {
|
||||||
|
result.mediaType = 'Manga';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRegexByPublisher(publisher: string): RegExp {
|
private getRegexByPublisher(publisher: string): RegExp {
|
||||||
switch (publisher) {
|
switch (publisher) {
|
||||||
case 'J-Novel Club':
|
case 'J-Novel Club':
|
||||||
return /(?<title>.+?):?\sVolume\s(?<volume>\d+)/i;
|
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
|
||||||
case 'Yen On':
|
case 'Yen On':
|
||||||
case 'Yen Press':
|
case 'Yen Press':
|
||||||
case 'Yen Press LLC':
|
case 'Yen Press LLC':
|
||||||
return /(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>[\w\s]+)\)/;
|
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/i;
|
||||||
|
case 'Hanashi Media':
|
||||||
|
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\),?\sVol\.\s(?<volume>\d+)$/i
|
||||||
|
case 'Regin\'s Chronicles':
|
||||||
|
return /^(?<title>.+?)\s\((?<media_type>[\w\s]+)\)(?<subtitle>\:\s.+?)?$/i
|
||||||
default:
|
default:
|
||||||
return /(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/;
|
return /^(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)$/i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ProvidersService } from './providers.service';
|
import { ProvidersService } from './providers.service';
|
||||||
import { BookSearchInputDto } from './dto/book-search-input.dto';
|
|
||||||
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
||||||
|
import { SimplifiedSearchContext } from './contexts/simplified-search-context';
|
||||||
|
|
||||||
@Controller('providers')
|
@Controller('providers')
|
||||||
export class ProvidersController {
|
export class ProvidersController {
|
||||||
@@ -12,8 +12,10 @@ export class ProvidersController {
|
|||||||
@UseGuards(JwtAccessGuard)
|
@UseGuards(JwtAccessGuard)
|
||||||
@Get('search')
|
@Get('search')
|
||||||
async Search(
|
async Search(
|
||||||
@Body() body: BookSearchInputDto,
|
@Query() context,
|
||||||
) {
|
) {
|
||||||
return await this.providers.searchRaw(body.provider, body.query);
|
const simplified = new SimplifiedSearchContext(context);
|
||||||
|
const searchContext = simplified.toSearchContext();
|
||||||
|
return await this.providers.search(searchContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
import { SeriesDto } from './series.dto';
|
|
||||||
|
|
||||||
export class CreateSeriesSubscriptionDto extends SeriesDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { SeriesDto } from './series.dto';
|
import { SeriesDto } from './series.dto';
|
||||||
|
|
||||||
export class CreateSeriesDto extends SeriesDto {
|
export class CreateSeriesDto extends SeriesDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsOptional()
|
||||||
|
mediaType: string;
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
mediaType: string;
|
||||||
}
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
@@ -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'])
|
||||||
@@ -14,6 +15,9 @@ export class SeriesEntity {
|
|||||||
@Column({ name: 'series_title', type: 'text', nullable: false })
|
@Column({ name: 'series_title', type: 'text', nullable: false })
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@Column({ name: 'media_type', type: 'text', nullable: true })
|
||||||
|
mediaType: string;
|
||||||
|
|
||||||
@Column({ name: 'provider', type: 'text', nullable: false })
|
@Column({ name: 'provider', type: 'text', nullable: false })
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
||||||
@@ -22,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[];
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginUserDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
readonly username: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
readonly password: string;
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { UserEntity } from './entities/users.entity';
|
import { UserEntity } from './entities/users.entity';
|
||||||
import { LoginUserDto } from './dto/login-user.dto';
|
|
||||||
import { UUID } from 'crypto';
|
import { UUID } from 'crypto';
|
||||||
|
import { LoginDto } from 'src/auth/dto/login.dto';
|
||||||
|
|
||||||
class UserDto {
|
class UserDto {
|
||||||
userId: string;
|
userId: UUID;
|
||||||
userLogin: string;
|
userLogin: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -32,15 +32,15 @@ export class UsersService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne({ username, password }: LoginUserDto): Promise<UserEntity> {
|
async findOne(loginDetails: LoginDto): Promise<UserEntity> {
|
||||||
const user = await this.userRepository.findOneBy({ userLogin: username });
|
const user = await this.userRepository.findOneBy({ userLogin: loginDetails.user_login });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// TODO: force an argon2.verify() to occur here.
|
// TODO: force an argon2.verify() to occur here.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.concat([
|
const buffer = Buffer.concat([
|
||||||
Buffer.from(password, 'utf8'),
|
Buffer.from(loginDetails.password, 'utf8'),
|
||||||
Buffer.from(user.salt.toString(16), 'hex'),
|
Buffer.from(user.salt.toString(16), 'hex'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
16
frontend/angular-seshat/.editorconfig
Normal file
16
frontend/angular-seshat/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
frontend/angular-seshat/.gitignore
vendored
Normal file
42
frontend/angular-seshat/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
frontend/angular-seshat/.vscode/extensions.json
vendored
Normal file
4
frontend/angular-seshat/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
frontend/angular-seshat/.vscode/launch.json
vendored
Normal file
20
frontend/angular-seshat/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
frontend/angular-seshat/.vscode/tasks.json
vendored
Normal file
42
frontend/angular-seshat/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
frontend/angular-seshat/README.md
Normal file
27
frontend/angular-seshat/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# AngularSeshat
|
||||||
|
|
||||||
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.5.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||||
|
|
||||||
|
## Further help
|
||||||
|
|
||||||
|
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
136
frontend/angular-seshat/angular.json
Normal file
136
frontend/angular-seshat/angular.json
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"angular-seshat": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/angular-seshat",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/custom-theme.scss",
|
||||||
|
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"server": "src/main.server.ts",
|
||||||
|
"prerender": true,
|
||||||
|
"ssr": {
|
||||||
|
"entry": "server.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kB",
|
||||||
|
"maximumError": "4kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "angular-seshat:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "angular-seshat:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"projectType": "library",
|
||||||
|
"root": "projects/library",
|
||||||
|
"sourceRoot": "projects/library/src",
|
||||||
|
"prefix": "lib",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||||
|
"options": {
|
||||||
|
"project": "projects/library/ng-package.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"tsConfig": "projects/library/tsconfig.lib.prod.json"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"tsConfig": "projects/library/tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "projects/library/tsconfig.spec.json",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14418
frontend/angular-seshat/package-lock.json
generated
Normal file
14418
frontend/angular-seshat/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/angular-seshat/package.json
Normal file
48
frontend/angular-seshat/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "angular-seshat",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"serve:ssr:angular-seshat": "node dist/angular-seshat/server/server.mjs"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^18.0.0",
|
||||||
|
"@angular/cdk": "^18.2.14",
|
||||||
|
"@angular/common": "^18.0.0",
|
||||||
|
"@angular/compiler": "^18.0.0",
|
||||||
|
"@angular/core": "^18.0.0",
|
||||||
|
"@angular/forms": "^18.0.0",
|
||||||
|
"@angular/material": "^18.2.14",
|
||||||
|
"@angular/platform-browser": "^18.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^18.0.0",
|
||||||
|
"@angular/platform-server": "^18.0.0",
|
||||||
|
"@angular/router": "^18.0.0",
|
||||||
|
"@angular/ssr": "^18.0.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ngx-cookie-service": "^18.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^18.0.5",
|
||||||
|
"@angular/cli": "^18.0.5",
|
||||||
|
"@angular/compiler-cli": "^18.0.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/node": "^18.18.0",
|
||||||
|
"jasmine-core": "~5.1.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"ng-packagr": "^18.2.0",
|
||||||
|
"typescript": "~5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/angular-seshat/public/favicon.ico
Normal file
BIN
frontend/angular-seshat/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
1
frontend/angular-seshat/public/icons/close_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/close_icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 222 B |
1
frontend/angular-seshat/public/icons/error_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/error_icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 537 B |
1
frontend/angular-seshat/public/icons/search_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/search_icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 376 B |
1
frontend/angular-seshat/public/icons/warning_icon.svg
Normal file
1
frontend/angular-seshat/public/icons/warning_icon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 314 B |
57
frontend/angular-seshat/server.ts
Normal file
57
frontend/angular-seshat/server.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
|
import { CommonEngine } from '@angular/ssr';
|
||||||
|
import express from 'express';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import bootstrap from './src/main.server';
|
||||||
|
|
||||||
|
// The Express app is exported so that it can be used by serverless Functions.
|
||||||
|
export function app(): express.Express {
|
||||||
|
const server = express();
|
||||||
|
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||||
|
const indexHtml = join(serverDistFolder, 'index.server.html');
|
||||||
|
|
||||||
|
const commonEngine = new CommonEngine();
|
||||||
|
|
||||||
|
server.set('view engine', 'html');
|
||||||
|
server.set('views', browserDistFolder);
|
||||||
|
|
||||||
|
// Example Express Rest API endpoints
|
||||||
|
// server.get('/api/**', (req, res) => { });
|
||||||
|
// Serve static files from /browser
|
||||||
|
server.get('**', express.static(browserDistFolder, {
|
||||||
|
maxAge: '1y',
|
||||||
|
index: 'index.html',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// All regular routes use the Angular engine
|
||||||
|
server.get('**', (req, res, next) => {
|
||||||
|
const { protocol, originalUrl, baseUrl, headers } = req;
|
||||||
|
|
||||||
|
commonEngine
|
||||||
|
.render({
|
||||||
|
bootstrap,
|
||||||
|
documentFilePath: indexHtml,
|
||||||
|
url: `${protocol}://${headers.host}${originalUrl}`,
|
||||||
|
publicPath: browserDistFolder,
|
||||||
|
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
||||||
|
})
|
||||||
|
.then((html) => res.send(html))
|
||||||
|
.catch((err) => next(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(): void {
|
||||||
|
const port = process.env['PORT'] || 4000;
|
||||||
|
|
||||||
|
// Start up the Node server
|
||||||
|
const server = app();
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
10
frontend/angular-seshat/src/app/app.component.css
Normal file
10
frontend/angular-seshat/src/app/app.component.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
6
frontend/angular-seshat/src/app/app.component.html
Normal file
6
frontend/angular-seshat/src/app/app.component.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@if (loading) {
|
||||||
|
<div class="loading-container flex-content-center">
|
||||||
|
<div>hello, loading world.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<router-outlet />
|
||||||
29
frontend/angular-seshat/src/app/app.component.spec.ts
Normal file
29
frontend/angular-seshat/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should have the 'angular-seshat' title`, () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app.title).toEqual('angular-seshat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, angular-seshat');
|
||||||
|
});
|
||||||
|
});
|
||||||
53
frontend/angular-seshat/src/app/app.component.ts
Normal file
53
frontend/angular-seshat/src/app/app.component.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { AuthService } from './services/auth/auth.service';
|
||||||
|
import { ConfigService } from './services/config.service';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { LoadingService } from './services/loading.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { RedirectionService } from './services/redirection.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.css'
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly _auth = inject(AuthService);
|
||||||
|
private readonly _config = inject(ConfigService);
|
||||||
|
private readonly _loading = inject(LoadingService);
|
||||||
|
private readonly _platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly _redirect = inject(RedirectionService);
|
||||||
|
private readonly _subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
loading: boolean = false;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (!isPlatformBrowser(this._platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listenToLoading();
|
||||||
|
|
||||||
|
this._config.fetch();
|
||||||
|
this._auth.update();
|
||||||
|
|
||||||
|
this._loading.listenUntilReady()
|
||||||
|
.subscribe(async () => {
|
||||||
|
this._redirect.redirect(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this._subscriptions.forEach(s => s.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
listenToLoading(): void {
|
||||||
|
this._subscriptions.push(
|
||||||
|
this._loading.listen()
|
||||||
|
.subscribe((loading) => this.loading = loading)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/angular-seshat/src/app/app.config.server.ts
Normal file
11
frontend/angular-seshat/src/app/app.config.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||||
|
import { provideServerRendering } from '@angular/platform-server';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
|
||||||
|
const serverConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideServerRendering()
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
23
frontend/angular-seshat/src/app/app.config.ts
Normal file
23
frontend/angular-seshat/src/app/app.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { provideClientHydration } from '@angular/platform-browser';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi, withFetch, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { LoadingInterceptor } from './shared/interceptors/loading.interceptor';
|
||||||
|
import { TokenValidationInterceptor } from './shared/interceptors/token-validation.interceptor';
|
||||||
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideClientHydration(),
|
||||||
|
provideHttpClient(
|
||||||
|
withInterceptorsFromDi(),
|
||||||
|
withFetch()
|
||||||
|
),
|
||||||
|
LoadingInterceptor,
|
||||||
|
TokenValidationInterceptor,
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
]
|
||||||
|
};
|
||||||
22
frontend/angular-seshat/src/app/app.routes.ts
Normal file
22
frontend/angular-seshat/src/app/app.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { LoginFormComponent } from './login/login-form/login-form.component';
|
||||||
|
import { RegisterFormComponent } from './register/register-form/register-form.component';
|
||||||
|
import { AddNewPageComponent } from './library/add-new-page/add-new-page.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
component: LoginFormComponent,
|
||||||
|
canActivate: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
component: RegisterFormComponent,
|
||||||
|
canActivate: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'add/new',
|
||||||
|
component: AddNewPageComponent,
|
||||||
|
canActivate: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.search-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-box {
|
||||||
|
background: #5757576c;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-error {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: red;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-end {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ff8c00;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-error img,
|
||||||
|
.results-end img {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-error {
|
||||||
|
filter: brightness(0) saturate(100%) invert(25%) sepia(67%) saturate(2736%) hue-rotate(340deg) brightness(110%) contrast(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-warning {
|
||||||
|
filter: brightness(0) saturate(100%) invert(52%) sepia(95%) saturate(2039%) hue-rotate(3deg) brightness(106%) contrast(102%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 25px;
|
||||||
|
align-self: center;
|
||||||
|
padding: 5px;
|
||||||
|
margin: 20px;
|
||||||
|
background: radial-gradient(circle closest-side, #000 94%, #0000) right/calc(200% - 1em) 100%;
|
||||||
|
animation: l24 1s infinite alternate linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::before {
|
||||||
|
content: "Loading...";
|
||||||
|
line-height: 1em;
|
||||||
|
color: #0000;
|
||||||
|
background: inherit;
|
||||||
|
background-image: radial-gradient(circle closest-side, #fff 94%, #000);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes l24 {
|
||||||
|
100% {
|
||||||
|
background-position: left
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div #scrollbar
|
||||||
|
class="search-content">
|
||||||
|
<search-box (searchOutput)="search.next($event)"
|
||||||
|
(filtersOutput)="filters.next($event)" />
|
||||||
|
<div class="results-box"
|
||||||
|
(scroll)="onResultsScroll($event)">
|
||||||
|
@for (result of results; track $index) {
|
||||||
|
<media-search-item class="result-item"
|
||||||
|
[media]="result" />
|
||||||
|
}
|
||||||
|
@if (busy()) {
|
||||||
|
<div class="loading"></div>
|
||||||
|
}
|
||||||
|
@if (searchError() != null) {
|
||||||
|
<p class="results-error">
|
||||||
|
<img src="/icons/error_icon.svg"
|
||||||
|
alt="error icon"
|
||||||
|
class="filter-error" />
|
||||||
|
{{searchError()}}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (endOfResults()) {
|
||||||
|
<div class="results-end">
|
||||||
|
<img src="/icons/warning_icon.svg"
|
||||||
|
alt="warning icon"
|
||||||
|
class="filter-warning" />
|
||||||
|
No more results returned from the provider.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AddNewPageComponent } from './add-new-page.component';
|
||||||
|
|
||||||
|
describe('AddNewPageComponent', () => {
|
||||||
|
let component: AddNewPageComponent;
|
||||||
|
let fixture: ComponentFixture<AddNewPageComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AddNewPageComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AddNewPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { Component, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged, filter, map, scan, Subscription, tap, throttleTime } from 'rxjs';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||||
|
import { MediaSearchItemComponent } from '../media-search-item/media-search-item.component';
|
||||||
|
import { SearchBoxComponent } from "../search-box/search-box.component";
|
||||||
|
import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESULTS = 10;
|
||||||
|
|
||||||
|
const PROVIDER_SETTINGS = {
|
||||||
|
google: {
|
||||||
|
FilterSearchParams: ['inauthor', 'inpublisher', 'intitle', 'isbn', 'subject'],
|
||||||
|
FilterNoUpdateParams: ['maxResults'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'add-new-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MediaSearchItemComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SearchBoxComponent
|
||||||
|
],
|
||||||
|
templateUrl: './add-new-page.component.html',
|
||||||
|
styleUrl: './add-new-page.component.css'
|
||||||
|
})
|
||||||
|
export class AddNewPageComponent implements OnDestroy {
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
private readonly _subscriptions: Subscription[] = [];
|
||||||
|
private readonly _zone = inject(NgZone);
|
||||||
|
|
||||||
|
@ViewChild('scrollbar') private readonly searchContentRef: ElementRef<Element> = {} as ElementRef;
|
||||||
|
|
||||||
|
search = new BehaviorSubject<string>('');
|
||||||
|
filters = new BehaviorSubject<SearchContextDto>(new SearchContextDto());
|
||||||
|
page = new BehaviorSubject<number>(0);
|
||||||
|
results: BookSearchResultDto[] = [];
|
||||||
|
resultsPerPage = signal<number>(10);
|
||||||
|
busy = signal<boolean>(false);
|
||||||
|
endOfResults = signal<boolean>(false);
|
||||||
|
searchError = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._zone.runOutsideAngular(() => {
|
||||||
|
// Subscription for max results.
|
||||||
|
this._subscriptions.push(
|
||||||
|
this.filters.pipe(
|
||||||
|
map(filters => 'maxResults' in filters.values ? parseInt(filters.values['maxResults']) : DEFAULT_MAX_RESULTS)
|
||||||
|
).subscribe(maxResults => {
|
||||||
|
this.resultsPerPage.set(maxResults);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscription for the search bar.
|
||||||
|
this._subscriptions.push(
|
||||||
|
combineLatest({
|
||||||
|
search: this.search.pipe(
|
||||||
|
filter(value => value != null),
|
||||||
|
),
|
||||||
|
filters: this.filters.pipe(
|
||||||
|
map(filters => ({ values: { ...filters.values } }))
|
||||||
|
),
|
||||||
|
page: this.page.pipe(
|
||||||
|
throttleTime(3000, undefined, { leading: false, trailing: true }),
|
||||||
|
),
|
||||||
|
}).pipe(
|
||||||
|
debounceTime(1000),
|
||||||
|
filter(entry => entry.search.length > 1 || this.isUsingSearchParamsInFilters(entry.filters)),
|
||||||
|
scan((acc, next) => {
|
||||||
|
// Different search or filters means resetting to page 0.
|
||||||
|
const searchChanged = acc.search != next.search && next.search && next.search.length > 1;
|
||||||
|
const filtersChanged = this.hasFiltersMismatched(acc.filters, next.filters);
|
||||||
|
if (searchChanged || filtersChanged) {
|
||||||
|
this.results = [];
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
page: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore further page searching if:
|
||||||
|
// - there are no more results;
|
||||||
|
// - user is still busy loading new pages;
|
||||||
|
// - only max results filter changed.
|
||||||
|
if (this.endOfResults() || this.busy() || acc.filters.values['maxResults'] != next.filters.values['maxResults']) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
page: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep searching the same page until error stops.
|
||||||
|
if (this.searchError() != null) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next page.
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
page: Math.min(acc.page + 1, next.page),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
filter(entry => entry.page >= 0),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
).subscribe((entry) => {
|
||||||
|
this.busy.set(true);
|
||||||
|
this.endOfResults.set(false);
|
||||||
|
if (this.searchContentRef) {
|
||||||
|
this.searchContentRef.nativeElement.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._http.get('/api/providers/search',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...entry.filters.values,
|
||||||
|
provider: 'google',
|
||||||
|
search: entry.search!,
|
||||||
|
startIndex: entry.page * this.resultsPerPage(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).subscribe({
|
||||||
|
next: (results: any) => {
|
||||||
|
[].push.apply(this.results, results);
|
||||||
|
|
||||||
|
if (results.length < this.resultsPerPage()) {
|
||||||
|
this.endOfResults.set(true);
|
||||||
|
}
|
||||||
|
this.searchError.set(null);
|
||||||
|
this.busy.set(false);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busy.set(false);
|
||||||
|
if (err instanceof HttpErrorResponse) {
|
||||||
|
if (err.status == 400) {
|
||||||
|
this.searchError.set('Something went wrong when Google received the request.');
|
||||||
|
} else if (err.status == 401) {
|
||||||
|
this.searchError.set('Unauthorized. Refresh the page to login again.');
|
||||||
|
} else if (err.status == 429) {
|
||||||
|
this.searchError.set('Too many requests. Try again in a minute.');
|
||||||
|
} else {
|
||||||
|
this.searchError.set(err.name + ': ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this._subscriptions.forEach(s => s.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
onResultsScroll(event: any): void {
|
||||||
|
const scroll = event.target;
|
||||||
|
const limit = scroll.scrollHeight - scroll.clientHeight;
|
||||||
|
|
||||||
|
// Prevent page changes when:
|
||||||
|
// - new search is happening (emptied results);
|
||||||
|
// - still scrolling through current content.
|
||||||
|
if (scroll.scrollTop == 0 || scroll.scrollTop < limit - 25) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.page.next(this.page.getValue() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFiltersMismatched(prev: SearchContextDto, next: SearchContextDto) {
|
||||||
|
if (prev == next) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key in prev.values) {
|
||||||
|
if (PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key))
|
||||||
|
continue;
|
||||||
|
if (!(key in next.values))
|
||||||
|
return true;
|
||||||
|
if (prev.values[key] != next.values[key])
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key in next.values) {
|
||||||
|
if (!PROVIDER_SETTINGS.google.FilterNoUpdateParams.includes(key) && !(key in prev.values))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUsingSearchParamsInFilters(context: SearchContextDto) {
|
||||||
|
if (!context)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const keys = Object.keys(context.values);
|
||||||
|
return keys.some(key => PROVIDER_SETTINGS.google.FilterSearchParams.includes(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/angular-seshat/src/app/library/library.module.ts
Normal file
12
frontend/angular-seshat/src/app/library/library.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LibraryModule { }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.modal {
|
||||||
|
background-color: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 15px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline;
|
||||||
|
--webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
background-color: hsl(0, 0%, 84%);
|
||||||
|
display: inline;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year {
|
||||||
|
color: grey;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button>img {
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
filter: brightness(0) saturate(100%) invert(42%) sepia(75%) saturate(5087%) hue-rotate(340deg) brightness(101%) contrast(109%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 10px 25px 0;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-image {
|
||||||
|
align-self: start;
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 20px 15px;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe {
|
||||||
|
background-color: aquamarine;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="modal">
|
||||||
|
<div class="header">
|
||||||
|
<div class="subheader">
|
||||||
|
<h2 class="title">{{data.title}}</h2>
|
||||||
|
@if (!isSeries && data.volume != null) {
|
||||||
|
<label class="volume">volume {{data.volume}}</label>
|
||||||
|
}
|
||||||
|
<label class="year">({{data.publishedAt.substring(0, 4)}})</label>
|
||||||
|
</div>
|
||||||
|
<div class="close-button"
|
||||||
|
(click)="dialogRef.close()">
|
||||||
|
<img src="/icons/close_icon.svg"
|
||||||
|
alt="close button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="result-item">
|
||||||
|
<img class="result-image"
|
||||||
|
[src]="data.thumbnail" />
|
||||||
|
<div class="result-info">
|
||||||
|
<p class="body description">{{data.desc}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="footer">
|
||||||
|
<button type="submit"
|
||||||
|
class="subscribe"
|
||||||
|
(click)="subscribe()">{{isSeries ? 'Subscribe' : 'Save'}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MediaItemModalComponent } from './media-item-modal.component';
|
||||||
|
|
||||||
|
describe('MediaItemModalComponent', () => {
|
||||||
|
let component: MediaItemModalComponent;
|
||||||
|
let fixture: ComponentFixture<MediaItemModalComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MediaItemModalComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MediaItemModalComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-media-item-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './media-item-modal.component.html',
|
||||||
|
styleUrl: './media-item-modal.component.css'
|
||||||
|
})
|
||||||
|
export class MediaItemModalComponent implements OnInit {
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
|
||||||
|
readonly data = inject<BookSearchResultDto>(MAT_DIALOG_DATA);
|
||||||
|
readonly dialogRef = inject(MatDialogRef<MediaItemModalComponent>);
|
||||||
|
|
||||||
|
isSeries: boolean = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isSeries = this.data.providerSeriesId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
console.log('data for subscribe:', this.data);
|
||||||
|
if (this.isSeries) {
|
||||||
|
this._http.post('/api/library/series', this.data)
|
||||||
|
.subscribe({
|
||||||
|
next: response => {
|
||||||
|
console.log('subscribe series:', response);
|
||||||
|
},
|
||||||
|
error: err => console.log('error on subscribing series:', err)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._http.post('/api/library/books', this.data)
|
||||||
|
.subscribe({
|
||||||
|
next: response => {
|
||||||
|
console.log('save book:', response);
|
||||||
|
},
|
||||||
|
error: err => console.log('error on saving book:', err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.result-item {
|
||||||
|
background-color: #EEE;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-image {
|
||||||
|
align-self: start;
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
margin-left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
background-color: hsl(0, 0%, 84%);
|
||||||
|
display: inline;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0 5px;
|
||||||
|
margin: 3px;
|
||||||
|
background-color: rgb(199, 199, 199);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-clamp: 4;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacing {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<div class="result-item"
|
||||||
|
(click)="open()">
|
||||||
|
<img class="result-image"
|
||||||
|
[src]="media().thumbnail" />
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="header">
|
||||||
|
<h2 class="title">{{media().title}}</h2>
|
||||||
|
@if (media().volume != null) {
|
||||||
|
<label class="volume">volume {{media().volume}}</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="subheader tags">
|
||||||
|
@for (tag of tags(); track $index) {
|
||||||
|
<label class="tag">{{tag}}</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="body description">{{media().desc}}</p>
|
||||||
|
<span class="spacing"></span>
|
||||||
|
<p class="footer">Metadata provided by {{provider()}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MediaSearchItemComponent } from './media-search-item.component';
|
||||||
|
|
||||||
|
describe('MediaSearchItemComponent', () => {
|
||||||
|
let component: MediaSearchItemComponent;
|
||||||
|
let fixture: ComponentFixture<MediaSearchItemComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MediaSearchItemComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MediaSearchItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
|
import { BookSearchResultDto } from '../../shared/dto/book-search-result.dto';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MediaItemModalComponent } from '../media-item-modal/media-item-modal.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'media-search-item',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatDialogModule,
|
||||||
|
],
|
||||||
|
templateUrl: './media-search-item.component.html',
|
||||||
|
styleUrl: './media-search-item.component.css'
|
||||||
|
})
|
||||||
|
export class MediaSearchItemComponent {
|
||||||
|
private readonly _dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
media = input.required<BookSearchResultDto>();
|
||||||
|
|
||||||
|
tags = computed(() => {
|
||||||
|
const tags = [];
|
||||||
|
if (this.media().language)
|
||||||
|
tags.push(this.media().language);
|
||||||
|
if (this.media().publisher)
|
||||||
|
tags.push(this.media().publisher);
|
||||||
|
if (this.media().authors)
|
||||||
|
tags.push.apply(tags, this.media().authors.map(author => 'author: ' + author));
|
||||||
|
if (this.media().categories)
|
||||||
|
tags.push.apply(tags, this.media().categories);
|
||||||
|
if (this.media().maturityRating.replaceAll('_', ' '))
|
||||||
|
tags.push(this.media().maturityRating);
|
||||||
|
if (this.media().publishedAt) {
|
||||||
|
tags.push(new Date(this.media().publishedAt).getFullYear());
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
});
|
||||||
|
|
||||||
|
provider = computed(() => {
|
||||||
|
const value = this.media().provider;
|
||||||
|
return value.split(' ')
|
||||||
|
.map(s => s[0].toUpperCase() + s.substring(1))
|
||||||
|
.join(' ')
|
||||||
|
.split('-')
|
||||||
|
.map(s => s[0].toUpperCase() + s.substring(1))
|
||||||
|
.join('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
this._dialog.open(MediaItemModalComponent, {
|
||||||
|
data: { ...this.media() }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
.search-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 20px;
|
||||||
|
flex-direction: row;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-side,
|
||||||
|
.center-side,
|
||||||
|
.right-side {
|
||||||
|
align-content: center;
|
||||||
|
height: 40px;
|
||||||
|
border-top: 1px;
|
||||||
|
border-bottom: 1px;
|
||||||
|
border: 1px gray solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-side {
|
||||||
|
border: 1px gray solid;
|
||||||
|
border-right: 0;
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-side {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-side {
|
||||||
|
border: 1px gray solid;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex-grow: 1;
|
||||||
|
outline: none;
|
||||||
|
font-size: 20px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 0 4px 3px rgba(31, 128, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper>img, .icon-button>img {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
filter: brightness(0) saturate(100%) invert(44%) sepia(0%) saturate(167%) hue-rotate(154deg) brightness(90%) contrast(87%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-enabled {
|
||||||
|
background-color: rgb(185, 185, 185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-box {
|
||||||
|
margin: 0 60px 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper, .select-wrapper, .text-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper input, .select-wrapper select, .text-wrapper input {
|
||||||
|
display: inline;
|
||||||
|
height: 20px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-wrapper input {
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper label, .select-wrapper label, .text-wrapper label {
|
||||||
|
display: inline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<div class="search-bar">
|
||||||
|
<div class="icon-wrapper left-side">
|
||||||
|
<img src="/icons/search_icon.svg"
|
||||||
|
alt="search icon" />
|
||||||
|
</div>
|
||||||
|
<div class="icon-wrapper icon-button left-side"
|
||||||
|
[class.icon-enabled]="filtersEnabled.value">
|
||||||
|
<img src="/icons/category_search_icon.svg"
|
||||||
|
alt="advanced search icon"
|
||||||
|
(click)="filtersEnabled.setValue(!filtersEnabled.value)" />
|
||||||
|
</div>
|
||||||
|
<div class="search-box center-side">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="eg. Lord of the Rings"
|
||||||
|
[formControl]="search" />
|
||||||
|
</div>
|
||||||
|
<div class="icon-wrapper icon-button right-side">
|
||||||
|
<img src="/icons/close_icon.svg"
|
||||||
|
alt="clear text button"
|
||||||
|
(click)="search.setValue('')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filters-box"
|
||||||
|
[class.collapsed]="!filtersEnabled.value">
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<label for="languageSelect">Language</label>
|
||||||
|
<select #languageSelect
|
||||||
|
name="languageSelect"
|
||||||
|
(change)="updateFilters('langRestrict', $event.target)">
|
||||||
|
<option value="">All</option>
|
||||||
|
@for (language of provider.languages | keyvalue; track language.key) {
|
||||||
|
<option [value]="language.key">{{language.value}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<label for="orderBySelect">Order By</label>
|
||||||
|
<select #orderBySelect
|
||||||
|
name="orderBySelect"
|
||||||
|
(change)="updateFilters('orderBy', $event.target)">
|
||||||
|
<option value="relevance">Relevance</option>
|
||||||
|
<option value="newest">Newest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<label for="resultsSizeSelect">Results Size</label>
|
||||||
|
<select #resultsSizeSelect
|
||||||
|
name="resultsSizeSelect"
|
||||||
|
(change)="updateFilters('maxResults', $event.target)">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
<option value="40">40</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="text-wrapper">
|
||||||
|
<label for="authorInput">Author</label>
|
||||||
|
<input #authorInput
|
||||||
|
name="authorInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="J. R. R. Tolkien"
|
||||||
|
[formControl]="author" />
|
||||||
|
</div>
|
||||||
|
<div class="text-wrapper">
|
||||||
|
<label for="isbnInput">ISBN</label>
|
||||||
|
<input #isbnInput
|
||||||
|
name="isbnInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="ISBN-10 or ISBN-13"
|
||||||
|
[formControl]="isbn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SearchBoxComponent } from './search-box.component';
|
||||||
|
|
||||||
|
describe('SearchBoxComponent', () => {
|
||||||
|
let component: SearchBoxComponent;
|
||||||
|
let fixture: ComponentFixture<SearchBoxComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SearchBoxComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SearchBoxComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, inject, NgZone, OnDestroy, output, ViewChild } from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { SearchContextDto } from '../../shared/dto/search-context.dto';
|
||||||
|
import { filter, map, Subscription } from 'rxjs';
|
||||||
|
import { ConfigService } from '../../services/config.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'search-box',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './search-box.component.html',
|
||||||
|
styleUrl: './search-box.component.css'
|
||||||
|
})
|
||||||
|
export class SearchBoxComponent implements AfterViewInit, OnDestroy {
|
||||||
|
private readonly _subscriptions: Subscription[] = [];
|
||||||
|
private readonly _zone = inject(NgZone);
|
||||||
|
|
||||||
|
@ViewChild('languageSelect') private readonly languageRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||||
|
@ViewChild('orderBySelect') private readonly orderByRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||||
|
@ViewChild('resultsSizeSelect') private readonly resultsSizeRef: ElementRef<HTMLSelectElement> = {} as ElementRef;
|
||||||
|
@ViewChild('authorInput') private readonly authorRef: ElementRef<HTMLInputElement> = {} as ElementRef;
|
||||||
|
@ViewChild('isbnInput') private readonly isbnRef: ElementRef<HTMLInputElement> = {} as ElementRef;
|
||||||
|
|
||||||
|
config = inject(ConfigService).config;
|
||||||
|
filtersEnabled = new FormControl<boolean>(false);
|
||||||
|
search = new FormControl<string>('');
|
||||||
|
searchOutput = output<string>();
|
||||||
|
filters = new SearchContextDto();
|
||||||
|
filtersOutput = output<SearchContextDto>();
|
||||||
|
isbn = new FormControl<string>('');
|
||||||
|
author = new FormControl<string>('');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._zone.runOutsideAngular(() => {
|
||||||
|
this._subscriptions.push(
|
||||||
|
this.search.valueChanges.pipe(
|
||||||
|
filter(value => value != null),
|
||||||
|
map(value => value!.trim()),
|
||||||
|
filter(value => value.length > 0),
|
||||||
|
).subscribe((value) => this.searchOutput.emit(value!))
|
||||||
|
);
|
||||||
|
|
||||||
|
this._subscriptions.push(
|
||||||
|
this.author.valueChanges.pipe(
|
||||||
|
filter(value => value != null),
|
||||||
|
map(value => value!.trim()),
|
||||||
|
filter(value => value.length > 0),
|
||||||
|
).subscribe((value) => this.updateFilters('inauthor', { value: value }))
|
||||||
|
);
|
||||||
|
|
||||||
|
this._subscriptions.push(
|
||||||
|
this.isbn.valueChanges.pipe(
|
||||||
|
filter(value => value != null),
|
||||||
|
map(value => value!.trim()),
|
||||||
|
map(value => value.length == 10 || value.length >= 13 && value.length <= 15 ? value : ''),
|
||||||
|
filter(value => value.length > 0),
|
||||||
|
).subscribe((value) => this.updateFilters('isbn', { value: value }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
if (this.languageRef && this.languageRef.nativeElement.value)
|
||||||
|
this.updateFilters('langRestrict', this.languageRef.nativeElement);
|
||||||
|
if (this.orderByRef && this.orderByRef.nativeElement.value)
|
||||||
|
this.updateFilters('orderBy', this.orderByRef.nativeElement);
|
||||||
|
if (this.resultsSizeRef && this.resultsSizeRef.nativeElement.value)
|
||||||
|
this.updateFilters('maxResults', this.resultsSizeRef.nativeElement);
|
||||||
|
if (this.authorRef && this.authorRef.nativeElement.value)
|
||||||
|
this.updateFilters('inauthor', this.authorRef.nativeElement);
|
||||||
|
if (this.isbnRef && this.isbnRef.nativeElement.value)
|
||||||
|
this.updateFilters('isbn', this.isbnRef.nativeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this._subscriptions.forEach(s => s.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
get provider() {
|
||||||
|
switch (this.config.providers.default) {
|
||||||
|
case 'google': return this.config.providers.google;
|
||||||
|
default: return this.config.providers.google;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilters(key: string, value: any) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.filters.values[key];
|
||||||
|
} else {
|
||||||
|
this.filters.values[key] = value.value;
|
||||||
|
}
|
||||||
|
this.filtersOutput.emit(this.filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 500px;
|
||||||
|
height: 100%;
|
||||||
|
border: black solid 2px;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
border: gray solid 1px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background-color: white;
|
||||||
|
display: block;
|
||||||
|
width: 400px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus {
|
||||||
|
outline: 5px solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper input {
|
||||||
|
display: inline;
|
||||||
|
height: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper label {
|
||||||
|
display: inline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
height: 50px;
|
||||||
|
margin: 10px auto;
|
||||||
|
border: 1px gray solid;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: block;
|
||||||
|
background-color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #FAFAFA;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: white;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<div class="form">
|
||||||
|
<h1>Login</h1>
|
||||||
|
<div class="messages">
|
||||||
|
<notice [message]="errorMessage"
|
||||||
|
[level]="'error'"
|
||||||
|
[visible]="errorMessage.length > 0" />
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
[formControl]="username" />
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
[formControl]="password" />
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="rememberme"
|
||||||
|
[formControl]="rememberMe">
|
||||||
|
<label for="rememberme">Remember me?</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit"
|
||||||
|
(click)="login()"
|
||||||
|
[disabled]="!forms.valid || waitForResponse">Login</button>
|
||||||
|
</div>
|
||||||
|
@if (config.features.registration) {
|
||||||
|
<div class="register-link">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="/register"
|
||||||
|
(click)="register($event)">Register here.</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginFormComponent } from './login-form.component';
|
||||||
|
|
||||||
|
describe('LoginFormComponent', () => {
|
||||||
|
let component: LoginFormComponent;
|
||||||
|
let fixture: ComponentFixture<LoginFormComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginFormComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginFormComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { NoticeComponent } from "../../shared/notice/notice.component";
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ConfigService } from '../../services/config.service';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { LoadingService } from '../../services/loading.service';
|
||||||
|
import { RedirectionService } from '../../services/redirection.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'login-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NoticeComponent
|
||||||
|
],
|
||||||
|
templateUrl: './login-form.component.html',
|
||||||
|
styleUrl: './login-form.component.css'
|
||||||
|
})
|
||||||
|
export class LoginFormComponent {
|
||||||
|
private readonly _auth = inject(AuthService);
|
||||||
|
private readonly _loading = inject(LoadingService);
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
private readonly _redirect = inject(RedirectionService);
|
||||||
|
private readonly _router = inject(Router);
|
||||||
|
readonly config = inject(ConfigService).config;
|
||||||
|
|
||||||
|
readonly username = new FormControl('', [Validators.required]);
|
||||||
|
readonly password = new FormControl('', [Validators.required]);
|
||||||
|
readonly rememberMe = new FormControl(false);
|
||||||
|
readonly forms = new FormGroup({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResponse: boolean = false;
|
||||||
|
errorMessage: string = '';
|
||||||
|
|
||||||
|
login() {
|
||||||
|
if (this.waitForResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waitForResponse = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
if (!this.forms.valid) {
|
||||||
|
this.waitForResponse = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._http.post('/api/auth/login', {
|
||||||
|
user_login: this.username.value,
|
||||||
|
password: this.password.value,
|
||||||
|
remember_me: this.rememberMe.value,
|
||||||
|
}).subscribe({
|
||||||
|
next: async (response: any) => {
|
||||||
|
this._auth.update(true)
|
||||||
|
.subscribe(async () => await this._redirect.redirect(null));
|
||||||
|
},
|
||||||
|
error: (err) => this.handleLoginErrors(err),
|
||||||
|
complete: () => this.waitForResponse = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoginErrors(err: any) {
|
||||||
|
if (err.status == 400 || err.status == 401) {
|
||||||
|
this.errorMessage = 'Invalid credentials.';
|
||||||
|
} else if (err.status == 403) {
|
||||||
|
this.errorMessage = 'Something went wrong.';
|
||||||
|
} else {
|
||||||
|
this.errorMessage = 'Something went wrong: ' + err.error.statusCode + ' ' + err.error.message;
|
||||||
|
}
|
||||||
|
this.waitForResponse = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
await this._router.navigate(['register']);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/angular-seshat/src/app/login/login.module.ts
Normal file
10
frontend/angular-seshat/src/app/login/login.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
],
|
||||||
|
providers: []
|
||||||
|
})
|
||||||
|
export class LoginModule { }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 500px;
|
||||||
|
height: 100%;
|
||||||
|
border: black solid 2px;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
border: gray solid 1px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background-color: white;
|
||||||
|
display: block;
|
||||||
|
width: 400px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus {
|
||||||
|
outline: 5px solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
height: 50px;
|
||||||
|
margin: 10px auto;
|
||||||
|
border: 1px gray solid;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: block;
|
||||||
|
background-color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #FAFAFA;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: white;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
font-size: 16;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@if (config.features.registration) {
|
||||||
|
<div class="form">
|
||||||
|
<h1>Register</h1>
|
||||||
|
<div class="messages">
|
||||||
|
<notice [message]="errorMessage"
|
||||||
|
[level]="'error'"
|
||||||
|
[visible]="errorMessage.length > 0" />
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
[formControl]="name" />
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
[formControl]="username" />
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
[formControl]="password" />
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="password"
|
||||||
|
placeholder="Password Confirm"
|
||||||
|
[formControl]="passwordConfirmation" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit"
|
||||||
|
(click)="register()"
|
||||||
|
[disabled]="!forms.valid || waitForResponse">Register</button>
|
||||||
|
</div>
|
||||||
|
<div class="register-link">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/login"
|
||||||
|
(click)="login($event)">Login here.</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="center disabled">Registrations have been disabled.</p>
|
||||||
|
<p class="center">You will be redirected to login in 3 seconds.</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { RegisterFormComponent } from './register-form.component';
|
||||||
|
|
||||||
|
describe('RegisterFormComponent', () => {
|
||||||
|
let component: RegisterFormComponent;
|
||||||
|
let fixture: ComponentFixture<RegisterFormComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RegisterFormComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RegisterFormComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { NoticeComponent } from "../../shared/notice/notice.component";
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ConfigService } from '../../services/config.service';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { LoadingService } from '../../services/loading.service';
|
||||||
|
import { RedirectionService } from '../../services/redirection.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'register-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NoticeComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './register-form.component.html',
|
||||||
|
styleUrl: './register-form.component.css'
|
||||||
|
})
|
||||||
|
export class RegisterFormComponent implements OnInit {
|
||||||
|
private readonly _auth = inject(AuthService);
|
||||||
|
private readonly _loading = inject(LoadingService);
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
private readonly _redirect = inject(RedirectionService);
|
||||||
|
private readonly _router = inject(Router);
|
||||||
|
readonly config = inject(ConfigService).config;
|
||||||
|
|
||||||
|
readonly name = new FormControl('', [Validators.required]);
|
||||||
|
readonly username = new FormControl('', [Validators.required]);
|
||||||
|
readonly password = new FormControl('', [Validators.required]);
|
||||||
|
readonly passwordConfirmation = new FormControl('', [Validators.required]);
|
||||||
|
readonly forms = new FormGroup({
|
||||||
|
name: this.name,
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
passwordConfirmation: this.passwordConfirmation
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForResponse: boolean = false;
|
||||||
|
errorMessage: string = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
if (this.waitForResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waitForResponse = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
if (!this.forms.valid) {
|
||||||
|
this.waitForResponse = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.password.value != this.passwordConfirmation.value) {
|
||||||
|
this.errorMessage = 'Password confirmation does not match original password.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._http.post('/api/auth/register', {
|
||||||
|
user_login: this.name.value,
|
||||||
|
user_name: this.username.value,
|
||||||
|
password: this.password.value,
|
||||||
|
}).subscribe({
|
||||||
|
next: async (response: any) => {
|
||||||
|
this._auth.update(true)
|
||||||
|
.subscribe(async () => await this._redirect.redirect(null));
|
||||||
|
},
|
||||||
|
error: (err) => this.handleRegistrationErrors(err),
|
||||||
|
complete: () => this.waitForResponse = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRegistrationErrors(err: any) {
|
||||||
|
if (err.status == 401) {
|
||||||
|
this.errorMessage = 'Invalid credentials.';
|
||||||
|
} else if (err.status == 400 || err.status == 403) {
|
||||||
|
this.errorMessage = 'Something went wrong.';
|
||||||
|
} else {
|
||||||
|
this.errorMessage = 'Something went wrong: ' + err.error.statusCode + ' ' + err.error.message;
|
||||||
|
}
|
||||||
|
this.waitForResponse = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(event: any) {
|
||||||
|
event.preventDefault();
|
||||||
|
await this._router.navigate(['login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/angular-seshat/src/app/register/register.module.ts
Normal file
12
frontend/angular-seshat/src/app/register/register.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class RegisterModule { }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private readonly _http = inject(HttpClient);
|
||||||
|
|
||||||
|
private authenticated = false;
|
||||||
|
private userId: string | null = null;
|
||||||
|
private username: string | null = null;
|
||||||
|
private lastValidationCheck: number = 0;
|
||||||
|
|
||||||
|
|
||||||
|
getUserId() {
|
||||||
|
return this.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername() {
|
||||||
|
return this.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn() {
|
||||||
|
return this.authenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
renew() {
|
||||||
|
return this._http.patch('/api/auth/login', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(force: boolean = false) {
|
||||||
|
if (!force && this.lastValidationCheck && this.lastValidationCheck + 90000 < Date.now()) {
|
||||||
|
return of(this.authenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastValidationCheck = Date.now();
|
||||||
|
const observable = this._http.post('/api/auth/validate', {})
|
||||||
|
observable.subscribe({
|
||||||
|
next: (response: any) => {
|
||||||
|
if (response.validation === null) {
|
||||||
|
this.renew().subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.authenticated = true;
|
||||||
|
this.userId = res.userId;
|
||||||
|
this.username = res.username;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.authenticated = false;
|
||||||
|
this.userId = null;
|
||||||
|
this.username = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authenticated = response.validation;
|
||||||
|
this.userId = response.userId;
|
||||||
|
this.username = response.username;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.authenticated = false;
|
||||||
|
this.userId = null;
|
||||||
|
this.username = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return observable;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user