Compare commits
30 Commits
8f0ca1ce58
...
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 | |||
| 6b5bfa963e | |||
| 969829da20 | |||
| 64ebdfd6f4 | |||
| a44cd89072 |
@@ -11,6 +11,8 @@ DROP TABLE IF EXISTS book_origins;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS refresh_tokens;
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS series_subscriptions;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS users;
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS books;
|
DROP TABLE IF EXISTS books;
|
||||||
@@ -23,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,
|
||||||
@@ -51,9 +54,8 @@ 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)
|
UNIQUE NULLS NOT DISTINCT (provider_series_id, provider_book_id, book_volume)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE books
|
ALTER TABLE books
|
||||||
@@ -61,7 +63,7 @@ ALTER COLUMN added_at
|
|||||||
SET DEFAULT now ();
|
SET DEFAULT now ();
|
||||||
|
|
||||||
-- CREATE INDEX books_series_id_idx ON books (series_id);
|
-- CREATE INDEX books_series_id_idx ON books (series_id);
|
||||||
CREATE INDEX books_provider_series_id_idx ON books (provider_series_id);
|
CREATE INDEX books_provider_provider_series_id_idx ON books (provider, provider_series_id);
|
||||||
|
|
||||||
-- CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
|
-- CREATE INDEX books_isbn_idx ON books USING HASH (isbn);
|
||||||
CREATE INDEX books_book_title_idx ON books (book_title);
|
CREATE INDEX books_book_title_idx ON books (book_title);
|
||||||
@@ -111,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),
|
||||||
@@ -128,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
596
backend/nestjs-seshat-api/package-lock.json
generated
596
backend/nestjs-seshat-api/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.0",
|
"@nestjs/axios": "^4.0.0",
|
||||||
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
@@ -19,14 +20,17 @@
|
|||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"bullmq": "^5.41.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"nestjs-pino": "^4.3.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
|
"pino-http": "^10.4.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
@@ -101,6 +105,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/core/node_modules/rxjs": {
|
||||||
|
"version": "7.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
|
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics": {
|
"node_modules/@angular-devkit/schematics": {
|
||||||
"version": "17.3.11",
|
"version": "17.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz",
|
||||||
@@ -213,6 +227,16 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||||
|
"version": "7.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||||
|
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.26.2",
|
"version": "7.26.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||||
@@ -937,6 +961,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1574,6 +1604,84 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@nestjs/axios": {
|
"node_modules/@nestjs/axios": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz",
|
||||||
@@ -1585,6 +1693,34 @@
|
|||||||
"rxjs": "^7.0.0"
|
"rxjs": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/bull-shared": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/bullmq": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/bull-shared": "^11.0.2",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
||||||
|
"bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "10.4.9",
|
"version": "10.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||||
@@ -2424,17 +2560,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz",
|
||||||
"integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==",
|
"integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.10.0",
|
"@eslint-community/regexpp": "^4.10.0",
|
||||||
"@typescript-eslint/scope-manager": "8.24.1",
|
"@typescript-eslint/scope-manager": "8.25.0",
|
||||||
"@typescript-eslint/type-utils": "8.24.1",
|
"@typescript-eslint/type-utils": "8.25.0",
|
||||||
"@typescript-eslint/utils": "8.24.1",
|
"@typescript-eslint/utils": "8.25.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.24.1",
|
"@typescript-eslint/visitor-keys": "8.25.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"ignore": "^5.3.1",
|
"ignore": "^5.3.1",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -2454,16 +2590,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz",
|
||||||
"integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==",
|
"integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.24.1",
|
"@typescript-eslint/scope-manager": "8.25.0",
|
||||||
"@typescript-eslint/types": "8.24.1",
|
"@typescript-eslint/types": "8.25.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.24.1",
|
"@typescript-eslint/typescript-estree": "8.25.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.24.1",
|
"@typescript-eslint/visitor-keys": "8.25.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2479,14 +2615,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz",
|
||||||
"integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==",
|
"integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.24.1",
|
"@typescript-eslint/types": "8.25.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.24.1"
|
"@typescript-eslint/visitor-keys": "8.25.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -2497,14 +2633,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz",
|
||||||
"integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==",
|
"integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/typescript-estree": "8.24.1",
|
"@typescript-eslint/typescript-estree": "8.25.0",
|
||||||
"@typescript-eslint/utils": "8.24.1",
|
"@typescript-eslint/utils": "8.25.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"ts-api-utils": "^2.0.1"
|
"ts-api-utils": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -2521,9 +2657,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz",
|
||||||
"integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==",
|
"integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2535,14 +2671,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz",
|
||||||
"integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==",
|
"integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.24.1",
|
"@typescript-eslint/types": "8.25.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.24.1",
|
"@typescript-eslint/visitor-keys": "8.25.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -2562,16 +2698,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz",
|
||||||
"integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==",
|
"integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"@typescript-eslint/scope-manager": "8.24.1",
|
"@typescript-eslint/scope-manager": "8.25.0",
|
||||||
"@typescript-eslint/types": "8.24.1",
|
"@typescript-eslint/types": "8.25.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.24.1"
|
"@typescript-eslint/typescript-estree": "8.25.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -2586,13 +2722,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.24.1",
|
"version": "8.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz",
|
||||||
"integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==",
|
"integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.24.1",
|
"@typescript-eslint/types": "8.25.0",
|
||||||
"eslint-visitor-keys": "^4.2.0"
|
"eslint-visitor-keys": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3068,6 +3204,15 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.7.9",
|
"version": "1.7.9",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||||
@@ -3425,6 +3570,21 @@
|
|||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "5.41.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.7.tgz",
|
||||||
|
"integrity": "sha512-eZbKJSx15bflfzKRiR+dKeLTr/M/YKb4cIp73OdU79PEMHQ6aEFUtbG6R+f0KvLLznI/O01G581U2Eqli6S2ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"msgpackr": "^1.11.2",
|
||||||
|
"node-abort-controller": "^3.1.1",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -3811,6 +3971,15 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -4062,6 +4231,18 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4171,6 +4352,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -4190,6 +4380,16 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-newline": {
|
"node_modules/detect-newline": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||||
@@ -4323,9 +4523,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.102",
|
"version": "1.5.104",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz",
|
||||||
"integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==",
|
"integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -4943,6 +5143,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-redact": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-safe-stringify": {
|
"node_modules/fast-safe-stringify": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
@@ -5137,12 +5346,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.0",
|
"cross-spawn": "^7.0.6",
|
||||||
"signal-exit": "^4.0.1"
|
"signal-exit": "^4.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5326,17 +5535,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.7",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"es-object-atoms": "^1.0.0",
|
"es-object-atoms": "^1.1.1",
|
||||||
"function-bind": "^1.1.2",
|
"function-bind": "^1.1.2",
|
||||||
"get-proto": "^1.0.0",
|
"get-proto": "^1.0.1",
|
||||||
"gopd": "^1.2.0",
|
"gopd": "^1.2.0",
|
||||||
"has-symbols": "^1.1.0",
|
"has-symbols": "^1.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.2",
|
||||||
@@ -5724,6 +5933,30 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "^1.1.1",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -6962,12 +7195,24 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isboolean": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
@@ -7045,6 +7290,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
@@ -7287,6 +7541,37 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz",
|
||||||
|
"integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "1.4.4-lts.1",
|
"version": "1.4.4-lts.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz",
|
||||||
@@ -7346,17 +7631,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nestjs-pino": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-Mym5k8X0prO4dqZoMC11EsZq3lggeJg9Z3gTUl4JvRXGkCz2Owu3ZUOiitDdX1znowPWVXOse/1Q+kufi2SlZA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"pino": "^7.5.0 || ^8.0.0 || ^9.0.0",
|
||||||
|
"pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-abort-controller": {
|
"node_modules/node-abort-controller": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz",
|
||||||
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
|
"integrity": "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
@@ -7403,6 +7703,21 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"node-gyp-build-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -7461,6 +7776,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -7896,6 +8220,55 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "9.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz",
|
||||||
|
"integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"fast-redact": "^3.1.1",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^4.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-http": {
|
||||||
|
"version": "10.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.4.0.tgz",
|
||||||
|
"integrity": "sha512-vjQsKBE+VN1LVchjbfLE7B6nBeGASZNRNKsR68VS0DolTm5R3zo+47JX1wjm0O96dcbvA7vnqt8YqOWlG5nN0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"pino": "^9.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pirates": {
|
"node_modules/pirates": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||||
@@ -8035,9 +8408,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.5.1",
|
"version": "3.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
|
||||||
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==",
|
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8097,6 +8470,22 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -8193,6 +8582,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/randombytes": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -8281,6 +8676,36 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
@@ -8510,9 +8935,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
@@ -8538,6 +8963,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -8841,6 +9275,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
@@ -8911,6 +9354,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -9355,6 +9804,15 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/through": {
|
"node_modules/through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
@@ -9434,9 +9892,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-jest": {
|
"node_modules/ts-jest": {
|
||||||
"version": "29.2.5",
|
"version": "29.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz",
|
||||||
"integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==",
|
"integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9447,7 +9905,7 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"lodash.memoize": "^4.1.2",
|
"lodash.memoize": "^4.1.2",
|
||||||
"make-error": "^1.3.6",
|
"make-error": "^1.3.6",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.7.1",
|
||||||
"yargs-parser": "^21.1.1"
|
"yargs-parser": "^21.1.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.0",
|
"@nestjs/axios": "^4.0.0",
|
||||||
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
@@ -30,14 +31,17 @@
|
|||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"bullmq": "^5.41.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"nestjs-pino": "^4.3.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
|
"pino-http": "^10.4.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import { UsersModule } from './users/users.module';
|
|||||||
import { UserEntity } from './users/entities/users.entity';
|
import { UserEntity } from './users/entities/users.entity';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req } from './logging.serializers';
|
import { serialize_token, serialize_user_short, serialize_user_long, serialize_res, serialize_req, serialize_job } from './logging.serializers';
|
||||||
|
import { BooksModule } from './books/books.module';
|
||||||
import { ProvidersModule } from './providers/providers.module';
|
import { ProvidersModule } from './providers/providers.module';
|
||||||
|
import { SeriesModule } from './series/series.module';
|
||||||
|
import { LibraryModule } from './library/library.module';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { AssetModule } from './asset/asset.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +26,17 @@ import { ProvidersModule } from './providers/providers.module';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useClass: DatabaseOptions
|
useClass: DatabaseOptions
|
||||||
}),
|
}),
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
connection: {
|
||||||
|
host: config.get('REDIS_HOST') ?? 'localhost',
|
||||||
|
port: config.get('REDIS_PORT') ?? 6379,
|
||||||
|
password: config.get('REDIS_PASSWORD'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
TypeOrmModule.forFeature([UserEntity]),
|
TypeOrmModule.forFeature([UserEntity]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -36,12 +52,14 @@ import { ProvidersModule } from './providers/providers.module';
|
|||||||
user: value => serialize_user_long(value),
|
user: value => serialize_user_long(value),
|
||||||
access_token: value => serialize_token(value),
|
access_token: value => serialize_token(value),
|
||||||
refresh_token: value => serialize_token(value),
|
refresh_token: value => serialize_token(value),
|
||||||
|
job: value => serialize_job(value),
|
||||||
req: value => serialize_req(value),
|
req: value => serialize_req(value),
|
||||||
res: value => serialize_res(value),
|
res: value => serialize_res(value),
|
||||||
} : {
|
} : {
|
||||||
user: value => serialize_user_short(value),
|
user: value => serialize_user_short(value),
|
||||||
access_token: value => serialize_token(value),
|
access_token: value => serialize_token(value),
|
||||||
refresh_token: value => serialize_token(value),
|
refresh_token: value => serialize_token(value),
|
||||||
|
job: value => serialize_job(value),
|
||||||
req: value => serialize_req(value),
|
req: value => serialize_req(value),
|
||||||
res: value => serialize_res(value),
|
res: value => serialize_res(value),
|
||||||
},
|
},
|
||||||
@@ -54,7 +72,12 @@ import { ProvidersModule } from './providers/providers.module';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
ProvidersModule
|
BooksModule,
|
||||||
|
ProvidersModule,
|
||||||
|
SeriesModule,
|
||||||
|
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);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigController } from './config.controller';
|
||||||
|
|
||||||
|
describe('ConfigController', () => {
|
||||||
|
let controller: ConfigController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ConfigController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<ConfigController>(ConfigController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
@@ -197,7 +220,7 @@ export class AuthController {
|
|||||||
msg: 'Failed to register due to duplicate userLogin.',
|
msg: 'Failed to register due to duplicate userLogin.',
|
||||||
});
|
});
|
||||||
|
|
||||||
response.statusCode = 400;
|
response.statusCode = 409;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error_message: 'Username already exist.',
|
error_message: 'Username already exist.',
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
26
backend/nestjs-seshat-api/src/books/books.module.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BooksService } from './books.service';
|
||||||
|
import { BookEntity } from './entities/book.entity';
|
||||||
|
import { BookOriginEntity } from './entities/book-origin.entity';
|
||||||
|
import { BookStatusEntity } from './entities/book-status.entity';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { SeriesModule } from 'src/series/series.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
BookEntity,
|
||||||
|
BookOriginEntity,
|
||||||
|
BookStatusEntity,
|
||||||
|
]),
|
||||||
|
SeriesModule,
|
||||||
|
HttpModule,
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
exports: [
|
||||||
|
BooksService
|
||||||
|
],
|
||||||
|
providers: [BooksService]
|
||||||
|
})
|
||||||
|
export class BooksModule { }
|
||||||
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/books/books.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { BooksService } from './books.service';
|
||||||
|
|
||||||
|
describe('BooksService', () => {
|
||||||
|
let service: BooksService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [BooksService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<BooksService>(BooksService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
123
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
123
backend/nestjs-seshat-api/src/books/books.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { BookEntity } from './entities/book.entity';
|
||||||
|
import { DeleteResult, In, InsertResult, Repository } from 'typeorm';
|
||||||
|
import { BookOriginEntity } from './entities/book-origin.entity';
|
||||||
|
import { BookStatusEntity } from './entities/book-status.entity';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { CreateBookDto } from './dto/create-book.dto';
|
||||||
|
import { CreateBookOriginDto } from './dto/create-book-origin.dto';
|
||||||
|
import { CreateBookStatusDto } from './dto/create-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()
|
||||||
|
export class BooksService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BookEntity)
|
||||||
|
private bookRepository: Repository<BookEntity>,
|
||||||
|
@InjectRepository(BookOriginEntity)
|
||||||
|
private bookOriginRepository: Repository<BookOriginEntity>,
|
||||||
|
@InjectRepository(BookStatusEntity)
|
||||||
|
private bookStatusRepository: Repository<BookStatusEntity>,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
|
||||||
|
async createBook(book: CreateBookDto): Promise<InsertResult> {
|
||||||
|
const entity = this.bookRepository.create(book);
|
||||||
|
return await this.bookRepository.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into(BookEntity)
|
||||||
|
.values(entity)
|
||||||
|
.returning('book_id')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addBookOrigin(origin: CreateBookOriginDto): Promise<InsertResult> {
|
||||||
|
return await this.bookOriginRepository.insert(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBookOrigin(origin: BookOriginDto[]): Promise<DeleteResult> {
|
||||||
|
return await this.bookOriginRepository.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
whereFactory: {
|
||||||
|
bookOriginId: In(origin.map(o => o.bookOriginId)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBookStatus(status: DeleteBookStatusDto): Promise<DeleteResult> {
|
||||||
|
return await this.bookStatusRepository.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
whereFactory: status,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBooksByIds(bookIds: UUID[]): Promise<BookEntity[]> {
|
||||||
|
return await this.bookRepository.find({
|
||||||
|
where: {
|
||||||
|
bookId: In(bookIds)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
.innerJoin('s.book', 'b')
|
||||||
|
.where('s.user_id = :id', { id: userId })
|
||||||
|
.andWhere('b.provider = :provider', { provider: series.provider })
|
||||||
|
.andWhere('b.providerSeriesId = :id', { id: series.providerSeriesId })
|
||||||
|
.addSelect(['b.book_title', 'b.book_desc', 'b.book_volume', 'b.provider', 'b.providerSeriesId'])
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBookStatusesTrackedBy(subscription: SeriesSubscriptionDto): Promise<any> {
|
||||||
|
return await this.bookRepository.createQueryBuilder('b')
|
||||||
|
.where('b.provider = :provider', { provider: subscription.provider })
|
||||||
|
.andWhere(`b.provider_series_id = :id`, { id: subscription.providerSeriesId })
|
||||||
|
.leftJoin('b.statuses', 's')
|
||||||
|
.where(`s.user_id = :id`, { id: subscription.userId })
|
||||||
|
.addSelect(['s.state'])
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBook(bookId: UUID, update: CreateBookDto) {
|
||||||
|
return await this.bookRepository.update({
|
||||||
|
bookId,
|
||||||
|
}, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBookOrigin(bookOriginId: UUID, update: CreateBookOriginDto) {
|
||||||
|
return await this.bookOriginRepository.update({
|
||||||
|
bookOriginId
|
||||||
|
}, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBookStatus(status: CreateBookStatusDto) {
|
||||||
|
status.modifiedAt = new Date();
|
||||||
|
await this.bookStatusRepository.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.values(status)
|
||||||
|
.orUpdate(['state', 'modified_at'], ['user_id', 'book_id'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class BookOriginDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
bookOriginId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||||
|
|
||||||
|
export class CreateBookOriginDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Transform(({ value }) => value as BookOriginType)
|
||||||
|
type: BookOriginType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
export class CreateBookStatusDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsOptional()
|
||||||
|
readonly userId: UUID;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Min(0)
|
||||||
|
@Max(6)
|
||||||
|
state: number;
|
||||||
|
|
||||||
|
modifiedAt: Date;
|
||||||
|
}
|
||||||
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
35
backend/nestjs-seshat-api/src/books/dto/create-book.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBookDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerBookId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
desc: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@IsPositive()
|
||||||
|
volume: number | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@IsDate()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Transform(({ value }) => new Date(value))
|
||||||
|
publishedAt: Date
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
export class DeleteBookStatusDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsOptional()
|
||||||
|
readonly userId: UUID;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||||
|
|
||||||
|
export class UpdateBookOriginDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookOriginId: UUID;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Transform(({ value }) => value as BookOriginType)
|
||||||
|
type: BookOriginType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
40
backend/nestjs-seshat-api/src/books/dto/update-book.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
export class UpdateBookDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerBookId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
desc: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@IsPositive()
|
||||||
|
volume: number | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@IsDate()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Transform(({ value }) => new Date(value))
|
||||||
|
publishedAt: Date
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||||
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||||
|
import { BookEntity } from './book.entity';
|
||||||
|
|
||||||
|
@Entity("book_origins")
|
||||||
|
@Unique(['bookOriginId', 'type', 'value'])
|
||||||
|
export class BookOriginEntity {
|
||||||
|
@PrimaryColumn({ name: 'book_origin_id' })
|
||||||
|
readonly bookOriginId: UUID;
|
||||||
|
|
||||||
|
@Column({ name: 'book_id' })
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@Column({ name: 'origin_type' })
|
||||||
|
type: BookOriginType;
|
||||||
|
|
||||||
|
@Column({ name: 'origin_value' })
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
@OneToOne(type => BookEntity, book => book.metadata)
|
||||||
|
@JoinColumn({
|
||||||
|
name: 'book_id',
|
||||||
|
referencedColumnName: 'bookId',
|
||||||
|
})
|
||||||
|
book: BookEntity;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { UserEntity } from 'src/users/entities/users.entity';
|
||||||
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
|
||||||
|
import { BookEntity } from './book.entity';
|
||||||
|
|
||||||
|
@Entity("book_statuses")
|
||||||
|
export class BookStatusEntity {
|
||||||
|
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
||||||
|
readonly userId: UUID;
|
||||||
|
|
||||||
|
@Column({ name: 'state', type: 'smallint' })
|
||||||
|
state: number;
|
||||||
|
|
||||||
|
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||||
|
addedAt: Date
|
||||||
|
|
||||||
|
@Column({ name: 'modified_at', type: 'timestamptz', nullable: false })
|
||||||
|
modifiedAt: Date;
|
||||||
|
|
||||||
|
@OneToOne(type => BookEntity, book => book.statuses)
|
||||||
|
@JoinColumn({
|
||||||
|
name: 'book_id',
|
||||||
|
referencedColumnName: 'bookId',
|
||||||
|
})
|
||||||
|
book: BookEntity;
|
||||||
|
|
||||||
|
@OneToOne(type => UserEntity, user => user.bookStatuses)
|
||||||
|
@JoinColumn({
|
||||||
|
name: 'user_id',
|
||||||
|
referencedColumnName: 'userId',
|
||||||
|
})
|
||||||
|
user: UserEntity;
|
||||||
|
}
|
||||||
53
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
53
backend/nestjs-seshat-api/src/books/entities/book.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||||
|
import { BookOriginEntity } from './book-origin.entity';
|
||||||
|
import { BookStatusEntity } from './book-status.entity';
|
||||||
|
import { SeriesEntity } from 'src/series/entities/series.entity';
|
||||||
|
|
||||||
|
@Entity("books")
|
||||||
|
@Unique(['providerSeriesId', 'providerBookId'])
|
||||||
|
export class BookEntity {
|
||||||
|
@PrimaryColumn({ name: 'book_id', type: 'uuid' })
|
||||||
|
readonly bookId: UUID;
|
||||||
|
|
||||||
|
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'provider_book_id', type: 'text', nullable: false })
|
||||||
|
providerBookId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'book_title', type: 'text', nullable: false })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ name: 'book_desc', type: 'text', nullable: true })
|
||||||
|
desc: string;
|
||||||
|
|
||||||
|
@Column({ name: 'book_volume', type: 'integer', nullable: true })
|
||||||
|
volume: number;
|
||||||
|
|
||||||
|
@Column({ name: 'provider', type: 'varchar', nullable: false })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'published_at', type: 'timestamptz', nullable: false })
|
||||||
|
publishedAt: Date
|
||||||
|
|
||||||
|
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||||
|
addedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(type => BookOriginEntity, origin => origin.book)
|
||||||
|
metadata: BookOriginEntity[];
|
||||||
|
|
||||||
|
@OneToMany(type => BookStatusEntity, status => status.book)
|
||||||
|
statuses: BookStatusEntity[];
|
||||||
|
|
||||||
|
@OneToOne(type => SeriesEntity, series => series.volumes)
|
||||||
|
@JoinColumn([{
|
||||||
|
name: 'provider_series_id',
|
||||||
|
referencedColumnName: 'providerSeriesId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider',
|
||||||
|
referencedColumnName: 'provider',
|
||||||
|
}])
|
||||||
|
series: SeriesEntity;
|
||||||
|
}
|
||||||
201
backend/nestjs-seshat-api/src/library/library.consumer.ts
Normal file
201
backend/nestjs-seshat-api/src/library/library.consumer.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
|
||||||
|
import { OnQueueEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { GoogleSearchContext } from 'src/providers/contexts/google.search.context';
|
||||||
|
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||||
|
import { ProvidersService } from 'src/providers/providers.service';
|
||||||
|
import { SeriesSubscriptionJobDto } from 'src/series/dto/series-subscription-job.dto';
|
||||||
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
|
@Processor('library')
|
||||||
|
export class LibraryConsumer extends WorkerHost {
|
||||||
|
constructor(
|
||||||
|
private readonly library: LibraryService,
|
||||||
|
private readonly provider: ProvidersService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job, token?: string): Promise<any> {
|
||||||
|
this.logger.info({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.process.name,
|
||||||
|
job: job,
|
||||||
|
msg: 'Started task on queue.',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (job.name == 'new_series') {
|
||||||
|
const series: SeriesSubscriptionJobDto = job.data;
|
||||||
|
const books = await this.search(job, series, null);
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
for (let book of books) {
|
||||||
|
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 adding series.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
counter++;
|
||||||
|
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({
|
||||||
|
class: LibraryConsumer.name,
|
||||||
|
method: this.process.name,
|
||||||
|
job: job,
|
||||||
|
msg: 'Completed task on queue.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
score: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: book,
|
||||||
|
score: (!!book.providerSeriesId ? 50 : 0) + (book.title == series.title ? 25 : 0) + (book.url.startsWith('https://play.google.com/store/books/details?') ? 10 : 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LibraryController } from './library.controller';
|
||||||
|
|
||||||
|
describe('LibraryController', () => {
|
||||||
|
let controller: LibraryController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [LibraryController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<LibraryController>(LibraryController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
477
backend/nestjs-seshat-api/src/library/library.controller.ts
Normal file
477
backend/nestjs-seshat-api/src/library/library.controller.ts
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Body, Controller, Delete, Get, Patch, Post, Put, Request, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { BooksService } from 'src/books/books.service';
|
||||||
|
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||||
|
import { SeriesService } from 'src/series/series.service';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
import { UpdateBookDto } from 'src/books/dto/update-book.dto';
|
||||||
|
import { UpdateBookOriginDto } from 'src/books/dto/update-book-origin.dto';
|
||||||
|
import { LibraryService } from './library.service';
|
||||||
|
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
||||||
|
import { SeriesDto } from 'src/series/dto/series.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)
|
||||||
|
@Controller('library')
|
||||||
|
export class LibraryController {
|
||||||
|
constructor(
|
||||||
|
private readonly books: BooksService,
|
||||||
|
private readonly series: SeriesService,
|
||||||
|
private readonly library: LibraryService,
|
||||||
|
@InjectQueue('library') private readonly jobs: Queue,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
@Get('series')
|
||||||
|
async getSeries(
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await this.series.getAllSeries(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('series')
|
||||||
|
async createSeries(
|
||||||
|
@Request() req,
|
||||||
|
@Body() body: CreateSeriesDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.library.addSeries(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QueryFailedError) {
|
||||||
|
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.
|
||||||
|
response.statusCode = 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Series subscription already exists.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.updateSeries.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to update a series.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Something went wrong.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAccessAdminGuard)
|
||||||
|
@Delete('series')
|
||||||
|
async deleteSeries(
|
||||||
|
@Request() req,
|
||||||
|
@Body() body: SeriesDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const del = await this.series.deleteSeries(body);
|
||||||
|
return {
|
||||||
|
success: del && del.affected > 0,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QueryFailedError) {
|
||||||
|
if (err.driverError.code == '23503') {
|
||||||
|
this.logger.warn({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.deleteSeries.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to delete a series.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'The series does not exist.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.deleteSeries.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to delete a series.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Something went wrong.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('series/subscriptions')
|
||||||
|
async getSeriesSubscriptions(
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await this.series.getSeriesSubscribedBy(req.user.userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('series/subscribe')
|
||||||
|
async subscribe(
|
||||||
|
@Request() req,
|
||||||
|
@Body() body: SeriesDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.library.addSubscription({
|
||||||
|
...body,
|
||||||
|
userId: req.user.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QueryFailedError) {
|
||||||
|
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.
|
||||||
|
response.statusCode = 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Series subscription already exists.',
|
||||||
|
};
|
||||||
|
} 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.
|
||||||
|
response.statusCode = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Series does not exist.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Something went wrong.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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')
|
||||||
|
async getBooksFromUser(
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await this.library.findBooks(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('books')
|
||||||
|
async createBook(
|
||||||
|
@Request() req,
|
||||||
|
@Body() body: BookSearchResultDto,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
if (body.provider && body.providerSeriesId) {
|
||||||
|
this.logger.warn({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.createBook.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create book due to book being part of a series.',
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'This book is part of a seris. Use the series route to create a series.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await this.books.createBook(body),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QueryFailedError) {
|
||||||
|
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.
|
||||||
|
response.statusCode = 409;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'The book has already been added previously.',
|
||||||
|
};
|
||||||
|
} else if (err.driverError.code == '23503') {
|
||||||
|
this.logger.warn({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.createBook.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create book.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Series is missing.
|
||||||
|
response.statusCode = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Series does not exist.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error({
|
||||||
|
class: LibraryController.name,
|
||||||
|
method: this.createBook.name,
|
||||||
|
user: req.user,
|
||||||
|
body: body,
|
||||||
|
msg: 'Failed to create a book.',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.statusCode = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error_message: 'Something went wrong while adding the book.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('books')
|
||||||
|
async updateBook(
|
||||||
|
@Body() body: UpdateBookDto,
|
||||||
|
) {
|
||||||
|
const data = { ...body };
|
||||||
|
delete data['bookId'];
|
||||||
|
|
||||||
|
const result = await this.books.updateBook(body.bookId, data);
|
||||||
|
return {
|
||||||
|
success: result && result.affected > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('books/origins')
|
||||||
|
async deleteBookOrigin(
|
||||||
|
@Body() body: BookOriginDto[],
|
||||||
|
) {
|
||||||
|
const result = await this.books.deleteBookOrigin(body);
|
||||||
|
return {
|
||||||
|
success: result && result.affected > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('books/origins')
|
||||||
|
async updateBookOrigin(
|
||||||
|
@Body() body: UpdateBookOriginDto,
|
||||||
|
) {
|
||||||
|
const data = { ...body };
|
||||||
|
delete data['bookOriginId'];
|
||||||
|
|
||||||
|
const result = await this.books.updateBookOrigin(body.bookOriginId, data);
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/nestjs-seshat-api/src/library/library.module.ts
Normal file
38
backend/nestjs-seshat-api/src/library/library.module.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LibraryService } from './library.service';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BookOriginEntity } from 'src/books/entities/book-origin.entity';
|
||||||
|
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
|
||||||
|
import { BookEntity } from 'src/books/entities/book.entity';
|
||||||
|
import { ProvidersModule } from 'src/providers/providers.module';
|
||||||
|
import { SeriesModule } from 'src/series/series.module';
|
||||||
|
import { SeriesEntity } from 'src/series/entities/series.entity';
|
||||||
|
import { SeriesSubscriptionEntity } from 'src/series/entities/series-subscription.entity';
|
||||||
|
import { BooksService } from 'src/books/books.service';
|
||||||
|
import { SeriesService } from 'src/series/series.service';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { LibraryConsumer } from './library.consumer';
|
||||||
|
import { LibraryController } from './library.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
BookEntity,
|
||||||
|
BookOriginEntity,
|
||||||
|
BookStatusEntity,
|
||||||
|
SeriesEntity,
|
||||||
|
SeriesSubscriptionEntity,
|
||||||
|
]),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: 'library',
|
||||||
|
}),
|
||||||
|
SeriesModule,
|
||||||
|
HttpModule,
|
||||||
|
ProvidersModule,
|
||||||
|
],
|
||||||
|
exports: [LibraryService],
|
||||||
|
providers: [LibraryService, BooksService, SeriesService, LibraryConsumer],
|
||||||
|
controllers: [LibraryController]
|
||||||
|
})
|
||||||
|
export class LibraryModule { }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
|
describe('LibraryService', () => {
|
||||||
|
let service: LibraryService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [LibraryService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<LibraryService>(LibraryService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
150
backend/nestjs-seshat-api/src/library/library.service.ts
Normal file
150
backend/nestjs-seshat-api/src/library/library.service.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { BooksService } from 'src/books/books.service';
|
||||||
|
import { BookSearchResultDto } from 'src/providers/dto/book-search-result.dto';
|
||||||
|
import { CreateSeriesDto } from 'src/series/dto/create-series.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 { BookOriginType } from 'src/shared/enums/book_origin_type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LibraryService {
|
||||||
|
constructor(
|
||||||
|
private readonly books: BooksService,
|
||||||
|
private readonly series: SeriesService,
|
||||||
|
@InjectQueue('library') private readonly jobs: Queue,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
|
||||||
|
async addSeries(series: CreateSeriesDto) {
|
||||||
|
const result = await this.series.addSeries(series);
|
||||||
|
|
||||||
|
this.logger.debug({
|
||||||
|
class: LibraryService.name,
|
||||||
|
method: this.addSubscription.name,
|
||||||
|
series: series,
|
||||||
|
msg: 'Series saved to database.',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jobs.add('new_series', series);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubscription(series: SeriesSubscriptionDto) {
|
||||||
|
return await this.series.addSeriesSubscription({
|
||||||
|
userId: series.userId,
|
||||||
|
providerSeriesId: series.providerSeriesId,
|
||||||
|
provider: series.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addBook(book: BookSearchResultDto) {
|
||||||
|
this.logger.debug({
|
||||||
|
class: LibraryService.name,
|
||||||
|
method: this.addBook.name,
|
||||||
|
book: book,
|
||||||
|
msg: 'Saving book to database...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookData = await this.books.createBook({
|
||||||
|
title: book.title,
|
||||||
|
desc: book.desc,
|
||||||
|
providerSeriesId: book.providerSeriesId,
|
||||||
|
providerBookId: book.providerBookId,
|
||||||
|
volume: book.volume,
|
||||||
|
provider: book.provider,
|
||||||
|
publishedAt: book.publishedAt,
|
||||||
|
});
|
||||||
|
const bookId = bookData.identifiers[0]['bookId'];
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
|
||||||
|
if (book.authors && book.authors.length > 0) {
|
||||||
|
tasks.push(book.authors.map(author => this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.AUTHOR,
|
||||||
|
value: author,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
if (book.categories && book.categories.length > 0) {
|
||||||
|
tasks.push(book.categories.map(category => this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.CATEGORY,
|
||||||
|
value: category
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
if (book.language) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.LANGUAGE,
|
||||||
|
value: book.language,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (book.maturityRating) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.MATURITY_RATING,
|
||||||
|
value: book.maturityRating,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (book.thumbnail) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.PROVIDER_THUMBNAIL,
|
||||||
|
value: book.thumbnail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (book.url) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.PROVIDER_URL,
|
||||||
|
value: book.url,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('ISBN_10' in book.industryIdentifiers) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.ISBN_10,
|
||||||
|
value: book.industryIdentifiers['ISBN_10'],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('ISBN_13' in book.industryIdentifiers) {
|
||||||
|
tasks.push(this.books.addBookOrigin({
|
||||||
|
bookId,
|
||||||
|
type: BookOriginType.ISBN_10,
|
||||||
|
value: book.industryIdentifiers['ISBN_13'],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
this.logger.info({
|
||||||
|
class: LibraryService.name,
|
||||||
|
method: this.addBook.name,
|
||||||
|
book: book,
|
||||||
|
msg: 'Book saved to database.',
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,3 +80,15 @@ export function serialize_res(value) {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serialize_job(value) {
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
data: value.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
class GoogleSearchContext extends SearchContext {
|
import { SearchContext } from "./search.context";
|
||||||
|
|
||||||
|
export class GoogleSearchContext extends SearchContext {
|
||||||
constructor(searchQuery: string, params: { [key: string]: string }) {
|
constructor(searchQuery: string, params: { [key: string]: string }) {
|
||||||
super('google', searchQuery, params);
|
super('google', searchQuery, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -19,15 +21,123 @@ 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 + '&' + searchQueryParam;
|
return [queryParams, 'q=' + searchQueryParam].filter(q => q.length > 2).join('&');
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
get orderBy(): 'newest' | 'relevant' {
|
||||||
const resultsPerPage = parseInt(this.params['maxResults']) ?? 10;
|
return this.params['orderBy'] as 'newest' | 'relevant' ?? 'relevant';
|
||||||
const index = parseInt(this.params['startIndex']) ?? 0;
|
}
|
||||||
|
|
||||||
|
set orderBy(value: 'newest' | 'relevant' | null) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['orderBy'];
|
||||||
|
} else {
|
||||||
|
this.params['orderBy'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxResults(): number {
|
||||||
|
return 'maxResults' in this.params ? parseInt(this.params['maxResults']) : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
set maxResults(value: string) {
|
||||||
|
if (!value || isNaN(parseInt(value))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.params['maxResults'] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get startIndex(): number {
|
||||||
|
return 'startIndex' in this.params ? parseInt(this.params['startIndex']) : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
set startIndex(value: string) {
|
||||||
|
if (!value || isNaN(parseInt(value))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.params['startIndex'] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get intitle(): string {
|
||||||
|
return 'intitle' in this.params ? this.params['intitle'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set intitle(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['intitle'];
|
||||||
|
} else {
|
||||||
|
this.params['intitle'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get inpublisher(): string {
|
||||||
|
return 'inpublisher' in this.params ? this.params['inpublisher'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set inpublisher(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['inpublisher'];
|
||||||
|
} else {
|
||||||
|
this.params['inpublisher'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get inauthor(): string {
|
||||||
|
return 'inauthor' in this.params ? this.params['inauthor'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set inauthor(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['inauthor'];
|
||||||
|
} else {
|
||||||
|
this.params['inauthor'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isbn(): string {
|
||||||
|
return 'isbn' in this.params ? this.params['isbn'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set isbn(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['isbn'];
|
||||||
|
} else {
|
||||||
|
this.params['isbn'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get subject(): string {
|
||||||
|
return 'subject' in this.params ? this.params['subject'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set subject(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
delete this.params['subject'];
|
||||||
|
} else {
|
||||||
|
this.params['subject'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
abstract class SearchContext {
|
export abstract class SearchContext {
|
||||||
provider: string;
|
provider: string;
|
||||||
search: string;
|
search: string;
|
||||||
params: { [key: string]: string };
|
params: { [key: string]: string };
|
||||||
@@ -9,6 +9,7 @@ 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,28 +1,35 @@
|
|||||||
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 { 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 = '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)
|
||||||
.pipe(
|
.pipe(
|
||||||
timeout({ first: 5000 }),
|
timeout({ first: 5000 }),
|
||||||
map(this.transform),
|
map(value => this.transform(value)),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
|
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultQueryParams = '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();
|
||||||
|
|
||||||
@@ -30,7 +37,24 @@ export class GoogleService {
|
|||||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
|
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
|
||||||
.pipe(
|
.pipe(
|
||||||
timeout({ first: 5000 }),
|
timeout({ first: 5000 }),
|
||||||
map(this.transform),
|
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;
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,47 +64,75 @@ export class GoogleService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: any[] = response.data.items;
|
return response.data.items
|
||||||
return items.map((item: any) => {
|
.map(item => this.extract(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
title: item.volumeInfo.title,
|
title: item.volumeInfo.title,
|
||||||
desc: item.volumeInfo.description,
|
desc: item.volumeInfo.description,
|
||||||
volume: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber),
|
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: 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'
|
||||||
}
|
};
|
||||||
|
|
||||||
if (result.providerSeriesId) {
|
|
||||||
let regex = null;
|
|
||||||
switch (result.publisher) {
|
|
||||||
case 'J-Novel Club':
|
|
||||||
regex = new RegExp(/(?<title>.+?):?\sVolume\s(?<volume>\d+)/);
|
|
||||||
case 'Yen Press LLC':
|
|
||||||
regex = new RegExp(/(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>\w+)\)/);
|
|
||||||
default:
|
|
||||||
regex = new RegExp(/(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const 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();
|
||||||
if (!result.volume) {
|
if (!result.volume || isNaN(result.volume)) {
|
||||||
result.volume = parseInt(match.groups['volume']);
|
result.volume = parseInt(match.groups['volume'], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
switch (publisher) {
|
||||||
|
case 'J-Novel Club':
|
||||||
|
return /^(?<title>.+?):?(?:\s\((?<media_type>\w+)\))?(?:\sVolume\s(?<volume>\d+))?$/i;
|
||||||
|
case 'Yen On':
|
||||||
|
case 'Yen Press':
|
||||||
|
case 'Yen Press LLC':
|
||||||
|
return /^(?:(?<title>.+?)(?:,?\sVol\.?\s(?<volume>\d+))(?:\s\((?<media_type>[\w\s]+)\))?)$/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:
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ProvidersService } from './providers.service';
|
|||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { ProvidersController } from './providers.controller';
|
import { ProvidersController } from './providers.controller';
|
||||||
import { BooksService } from 'src/books/books/books.service';
|
import { BooksService } from 'src/books/books.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { GoogleService } from './google/google.service';
|
import { GoogleService } from './google/google.service';
|
||||||
import { BookSearchResultDto } from './dto/book-search-result.dto';
|
import { BookSearchResultDto } from './dto/book-search-result.dto';
|
||||||
|
import { GoogleSearchContext } from './contexts/google.search.context';
|
||||||
|
import { SearchContext } from './contexts/search.context';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProvidersService {
|
export class ProvidersService {
|
||||||
@@ -10,7 +12,7 @@ export class ProvidersService {
|
|||||||
|
|
||||||
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
|
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
|
||||||
let params: { [key: string]: string } = {};
|
let params: { [key: string]: string } = {};
|
||||||
if (providerName == 'google') {
|
if (providerName.toLowerCase() == 'google') {
|
||||||
return new GoogleSearchContext(searchQuery, params);
|
return new GoogleSearchContext(searchQuery, params);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -28,7 +30,7 @@ export class ProvidersService {
|
|||||||
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
|
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
|
||||||
switch (context.provider.toLowerCase()) {
|
switch (context.provider.toLowerCase()) {
|
||||||
case 'google':
|
case 'google':
|
||||||
return await this.google.search(context);
|
return await this.google.search(context as GoogleSearchContext);
|
||||||
default:
|
default:
|
||||||
throw Error('Invalid provider name.');
|
throw Error('Invalid provider name.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { SeriesDto } from './series.dto';
|
||||||
|
|
||||||
|
export class CreateSeriesDto extends SeriesDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsOptional()
|
||||||
|
mediaType: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { SeriesSubscriptionDto } from './series-subscription.dto';
|
||||||
|
|
||||||
|
export class SeriesSubscriptionJobDto extends SeriesSubscriptionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
mediaType: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
import { SeriesDto } from './series.dto';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
export class SeriesSubscriptionDto extends SeriesDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: UUID;
|
||||||
|
}
|
||||||
11
backend/nestjs-seshat-api/src/series/dto/series.dto.ts
Normal file
11
backend/nestjs-seshat-api/src/series/dto/series.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SeriesDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { CreateSeriesDto } from './create-series.dto';
|
||||||
|
|
||||||
|
export class UpdateSeriesDto extends CreateSeriesDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
seriesId: UUID;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, Unique } from 'typeorm';
|
||||||
|
import { SeriesEntity } from './series.entity';
|
||||||
|
|
||||||
|
@Entity("series_subscriptions")
|
||||||
|
export class SeriesSubscriptionEntity {
|
||||||
|
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
|
||||||
|
readonly userId: UUID;
|
||||||
|
|
||||||
|
@PrimaryColumn({ name: 'provider_series_id', type: 'text' })
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@PrimaryColumn({ name: 'provider', type: 'text' })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||||
|
addedAt: Date;
|
||||||
|
|
||||||
|
@OneToOne(type => SeriesEntity, series => series.subscriptions)
|
||||||
|
@JoinColumn([{
|
||||||
|
name: 'provider_series_id',
|
||||||
|
referencedColumnName: 'providerSeriesId',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider',
|
||||||
|
referencedColumnName: 'provider',
|
||||||
|
}])
|
||||||
|
series: SeriesSubscriptionEntity[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { BookEntity } from 'src/books/entities/book.entity';
|
||||||
|
import { Column, Entity, OneToMany, PrimaryColumn, Unique } from 'typeorm';
|
||||||
|
import { SeriesSubscriptionEntity } from './series-subscription.entity';
|
||||||
|
|
||||||
|
@Entity("series")
|
||||||
|
@Unique(['provider', 'providerSeriesId'])
|
||||||
|
export class SeriesEntity {
|
||||||
|
@PrimaryColumn({ name: 'series_id', type: 'uuid' })
|
||||||
|
readonly seriesId: UUID;
|
||||||
|
|
||||||
|
@Column({ name: 'provider_series_id', type: 'text', nullable: true })
|
||||||
|
providerSeriesId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'series_title', type: 'text', nullable: false })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ name: 'media_type', type: 'text', nullable: true })
|
||||||
|
mediaType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'provider', type: 'text', nullable: false })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'added_at', type: 'timestamptz', nullable: false })
|
||||||
|
addedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(type => BookEntity, book => [book.provider, book.providerSeriesId])
|
||||||
|
volumes: BookEntity[];
|
||||||
|
|
||||||
|
@OneToMany(type => SeriesSubscriptionEntity, subscription => [subscription.provider, subscription.providerSeriesId])
|
||||||
|
subscriptions: SeriesSubscriptionEntity[];
|
||||||
|
}
|
||||||
24
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
24
backend/nestjs-seshat-api/src/series/series.module.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SeriesService } from './series.service';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { SeriesEntity } from './entities/series.entity';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { ProvidersModule } from 'src/providers/providers.module';
|
||||||
|
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
SeriesEntity,
|
||||||
|
SeriesSubscriptionEntity,
|
||||||
|
]),
|
||||||
|
HttpModule,
|
||||||
|
ProvidersModule,
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
exports: [
|
||||||
|
SeriesService,
|
||||||
|
],
|
||||||
|
providers: [SeriesService]
|
||||||
|
})
|
||||||
|
export class SeriesModule { }
|
||||||
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
18
backend/nestjs-seshat-api/src/series/series.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SeriesService } from './series.service';
|
||||||
|
|
||||||
|
describe('SeriesService', () => {
|
||||||
|
let service: SeriesService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [SeriesService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<SeriesService>(SeriesService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
62
backend/nestjs-seshat-api/src/series/series.service.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { SeriesEntity } from './entities/series.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { CreateSeriesDto } from './dto/create-series.dto';
|
||||||
|
import { SeriesDto } from './dto/series.dto';
|
||||||
|
import { SeriesSubscriptionEntity } from './entities/series-subscription.entity';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
import { SeriesSubscriptionDto } from './dto/series-subscription.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SeriesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SeriesEntity)
|
||||||
|
private readonly seriesRepository: Repository<SeriesEntity>,
|
||||||
|
@InjectRepository(SeriesSubscriptionEntity)
|
||||||
|
private readonly seriesSubscriptionRepository: Repository<SeriesSubscriptionEntity>,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
|
||||||
|
async addSeries(series: CreateSeriesDto) {
|
||||||
|
return await this.seriesRepository.insert(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSeriesSubscription(series: SeriesSubscriptionDto) {
|
||||||
|
return await this.seriesSubscriptionRepository.insert(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSeries(series: SeriesDto) {
|
||||||
|
return await this.seriesRepository.delete(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSeriesSubscription(subscription: SeriesSubscriptionDto) {
|
||||||
|
return await this.seriesSubscriptionRepository.delete(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeries(series: SeriesDto) {
|
||||||
|
return await this.seriesRepository.findOne({
|
||||||
|
where: series
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllSeries() {
|
||||||
|
return await this.seriesRepository.find()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeriesSubscribedBy(userId: UUID) {
|
||||||
|
return await this.seriesRepository.createQueryBuilder('s')
|
||||||
|
.select(['s.seriesId', 's.providerSeriesId', 's.provider', 's.title', 's.mediaType', 's.addedAt'])
|
||||||
|
.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) {
|
||||||
|
return await this.seriesRepository.upsert(series, ['provider', 'providerSeriesId']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export enum BookOriginType {
|
||||||
|
AUTHOR = 1,
|
||||||
|
ILLUSTRATOR = 2,
|
||||||
|
CATEGORY = 10,
|
||||||
|
LANGUAGE = 11,
|
||||||
|
MEDIA_TYPE = 12,
|
||||||
|
MATURITY_RATING = 20,
|
||||||
|
PROVIDER_THUMBNAIL = 30,
|
||||||
|
PROVIDER_URL = 31,
|
||||||
|
// 4x - Ratings
|
||||||
|
ISBN_10 = 40,
|
||||||
|
ISBN_13 = 41,
|
||||||
|
// 1xx - User-defined
|
||||||
|
TAGS = 100,
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { IsNotEmpty } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginUserDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
readonly username: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
readonly password: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { UUID } from "crypto";
|
import { UUID } from "crypto";
|
||||||
import { BookStatusEntity } from 'src/books/books/entities/book-status.entity';
|
import { BookStatusEntity } from 'src/books/entities/book-status.entity';
|
||||||
import { BigIntTransformer } from 'src/shared/transformers/bigint';
|
import { BigIntTransformer } from 'src/shared/transformers/bigint';
|
||||||
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
|
import { StringToLowerCaseTransformer } from 'src/shared/transformers/string';
|
||||||
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|||||||
@@ -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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user