feat(books): add lens with query engine, filter builder, and icon picker

This commit is contained in:
neon
2026-01-21 18:07:07 -06:00
parent 5e1c211067
commit bc1ee837bf
43 changed files with 2828 additions and 2318 deletions
+4 -3
View File
@@ -24,7 +24,6 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@projectx/types": "workspace:*",
"7z-wasm": "^1.2.0",
"@fastify/cookie": "^11.0.2",
"@fastify/formbody": "^8.0.2",
@@ -39,6 +38,7 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.0",
"@nestjs/websockets": "^11.1.12",
"@projectx/types": "workspace:*",
"bcryptjs": "^3.0.3",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
@@ -51,12 +51,13 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
"pg": "^8.17.1",
"pg": "^8.17.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"unzipper": "^0.12.3"
"unzipper": "^0.12.3",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
+2
View File
@@ -17,6 +17,7 @@ import { BookmarkModule } from './modules/bookmark/bookmark.module';
import { BookModule } from './modules/book/book.module';
import { KoboModule } from './modules/kobo/kobo.module';
import { LibraryModule } from './modules/library/library.module';
import { LensModule } from './modules/lens/lens.module';
import { PathModule } from './modules/path/path.module';
import { MetadataModule } from './modules/metadata/metadata.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
@@ -42,6 +43,7 @@ import { UserModule } from './modules/user/user.module';
LibraryModule,
PathModule,
BookModule,
LensModule,
ScannerModule,
MetadataModule,
KoboModule,
+15 -1
View File
@@ -138,6 +138,19 @@ CREATE TABLE "library_folders" (
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lenses" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" varchar(255) NOT NULL,
"icon" varchar(64),
"filter" jsonb,
"default_sort" jsonb DEFAULT '[]'::jsonb NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "lenses_user_id_name_unique" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "authors" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(500) NOT NULL,
@@ -273,12 +286,13 @@ ALTER TABLE "book_files" ADD CONSTRAINT "book_files_library_folder_id_library_fo
ALTER TABLE "books" ADD CONSTRAINT "books_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "books" ADD CONSTRAINT "books_library_folder_id_library_folders_id_fk" FOREIGN KEY ("library_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_metadata" ADD CONSTRAINT "book_metadata_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "annotations" ADD CONSTRAINT "annotations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "annotations" ADD CONSTRAINT "annotations_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -1,3 +0,0 @@
ALTER TABLE "scan_jobs" DROP CONSTRAINT "scan_jobs_library_id_libraries_id_fk";
--> statement-breakpoint
ALTER TABLE "scan_jobs" ADD CONSTRAINT "scan_jobs_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;
+333 -79
View File
@@ -1,5 +1,5 @@
{
"id": "93dc2d98-8ab8-41bc-8cc1-8cbbe6f34df1",
"id": "58d334ba-ad72-419b-af11-f6c3dbb3ad4c",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -41,7 +41,9 @@
"app_settings_key_unique": {
"name": "app_settings_key_unique",
"nullsNotDistinct": false,
"columns": ["key"]
"columns": [
"key"
]
}
},
"policies": {},
@@ -112,8 +114,12 @@
"name": "password_reset_tokens_user_id_users_id_fk",
"tableFrom": "password_reset_tokens",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -123,7 +129,9 @@
"password_reset_tokens_token_hash_unique": {
"name": "password_reset_tokens_token_hash_unique",
"nullsNotDistinct": false,
"columns": ["token_hash"]
"columns": [
"token_hash"
]
}
},
"policies": {},
@@ -174,7 +182,9 @@
"permissions_name_unique": {
"name": "permissions_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
"columns": [
"name"
]
}
},
"policies": {},
@@ -245,8 +255,12 @@
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -256,7 +270,9 @@
"refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique",
"nullsNotDistinct": false,
"columns": ["token_hash"]
"columns": [
"token_hash"
]
}
},
"policies": {},
@@ -286,8 +302,12 @@
"name": "role_permissions_role_id_roles_id_fk",
"tableFrom": "role_permissions",
"tableTo": "roles",
"columnsFrom": ["role_id"],
"columnsTo": ["id"],
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -295,8 +315,12 @@
"name": "role_permissions_permission_id_permissions_id_fk",
"tableFrom": "role_permissions",
"tableTo": "permissions",
"columnsFrom": ["permission_id"],
"columnsTo": ["id"],
"columnsFrom": [
"permission_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -304,7 +328,10 @@
"compositePrimaryKeys": {
"role_permissions_role_id_permission_id_pk": {
"name": "role_permissions_role_id_permission_id_pk",
"columns": ["role_id", "permission_id"]
"columns": [
"role_id",
"permission_id"
]
}
},
"uniqueConstraints": {},
@@ -363,7 +390,9 @@
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
"columns": [
"name"
]
}
},
"policies": {},
@@ -401,8 +430,12 @@
"name": "user_library_access_user_id_users_id_fk",
"tableFrom": "user_library_access",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -410,8 +443,12 @@
"name": "user_library_access_library_id_libraries_id_fk",
"tableFrom": "user_library_access",
"tableTo": "libraries",
"columnsFrom": ["library_id"],
"columnsTo": ["id"],
"columnsFrom": [
"library_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -419,7 +456,10 @@
"compositePrimaryKeys": {
"user_library_access_user_id_library_id_pk": {
"name": "user_library_access_user_id_library_id_pk",
"columns": ["user_id", "library_id"]
"columns": [
"user_id",
"library_id"
]
}
},
"uniqueConstraints": {},
@@ -450,8 +490,12 @@
"name": "user_roles_user_id_users_id_fk",
"tableFrom": "user_roles",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -459,8 +503,12 @@
"name": "user_roles_role_id_roles_id_fk",
"tableFrom": "user_roles",
"tableTo": "roles",
"columnsFrom": ["role_id"],
"columnsTo": ["id"],
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -468,7 +516,10 @@
"compositePrimaryKeys": {
"user_roles_user_id_role_id_pk": {
"name": "user_roles_user_id_role_id_pk",
"columns": ["user_id", "role_id"]
"columns": [
"user_id",
"role_id"
]
}
},
"uniqueConstraints": {},
@@ -585,12 +636,16 @@
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": ["username"]
"columns": [
"username"
]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
"columns": [
"email"
]
}
},
"policies": {},
@@ -689,8 +744,12 @@
"name": "book_files_book_id_books_id_fk",
"tableFrom": "book_files",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -698,8 +757,12 @@
"name": "book_files_library_folder_id_library_folders_id_fk",
"tableFrom": "book_files",
"tableTo": "library_folders",
"columnsFrom": ["library_folder_id"],
"columnsTo": ["id"],
"columnsFrom": [
"library_folder_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -766,8 +829,12 @@
"name": "books_library_id_libraries_id_fk",
"tableFrom": "books",
"tableTo": "libraries",
"columnsFrom": ["library_id"],
"columnsTo": ["id"],
"columnsFrom": [
"library_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -775,8 +842,12 @@
"name": "books_library_folder_id_library_folders_id_fk",
"tableFrom": "books",
"tableTo": "library_folders",
"columnsFrom": ["library_folder_id"],
"columnsTo": ["id"],
"columnsFrom": [
"library_folder_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -949,8 +1020,12 @@
"name": "library_folders_library_id_libraries_id_fk",
"tableFrom": "library_folders",
"tableTo": "libraries",
"columnsFrom": ["library_id"],
"columnsTo": ["id"],
"columnsFrom": [
"library_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -961,6 +1036,100 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lenses": {
"name": "lenses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"filter": {
"name": "filter",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"default_sort": {
"name": "default_sort",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lenses_user_id_users_id_fk": {
"name": "lenses_user_id_users_id_fk",
"tableFrom": "lenses",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"lenses_user_id_name_unique": {
"name": "lenses_user_id_name_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.authors": {
"name": "authors",
"schema": "",
@@ -1022,8 +1191,12 @@
"name": "book_authors_book_id_books_id_fk",
"tableFrom": "book_authors",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1031,8 +1204,12 @@
"name": "book_authors_author_id_authors_id_fk",
"tableFrom": "book_authors",
"tableTo": "authors",
"columnsFrom": ["author_id"],
"columnsTo": ["id"],
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1040,7 +1217,10 @@
"compositePrimaryKeys": {
"book_authors_book_id_author_id_pk": {
"name": "book_authors_book_id_author_id_pk",
"columns": ["book_id", "author_id"]
"columns": [
"book_id",
"author_id"
]
}
},
"uniqueConstraints": {},
@@ -1138,8 +1318,12 @@
"name": "book_metadata_book_id_books_id_fk",
"tableFrom": "book_metadata",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1173,8 +1357,12 @@
"name": "book_tags_book_id_books_id_fk",
"tableFrom": "book_tags",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1182,8 +1370,12 @@
"name": "book_tags_tag_id_tags_id_fk",
"tableFrom": "book_tags",
"tableTo": "tags",
"columnsFrom": ["tag_id"],
"columnsTo": ["id"],
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1191,7 +1383,10 @@
"compositePrimaryKeys": {
"book_tags_book_id_tag_id_pk": {
"name": "book_tags_book_id_tag_id_pk",
"columns": ["book_id", "tag_id"]
"columns": [
"book_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
@@ -1223,7 +1418,9 @@
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
"columns": [
"name"
]
}
},
"policies": {},
@@ -1306,9 +1503,13 @@
"name": "scan_jobs_library_id_libraries_id_fk",
"tableFrom": "scan_jobs",
"tableTo": "libraries",
"columnsFrom": ["library_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"columnsFrom": [
"library_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@@ -1415,8 +1616,12 @@
"name": "annotations_user_id_users_id_fk",
"tableFrom": "annotations",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1424,8 +1629,12 @@
"name": "annotations_book_id_books_id_fk",
"tableFrom": "annotations",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1500,8 +1709,12 @@
"name": "bookmarks_user_id_users_id_fk",
"tableFrom": "bookmarks",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1509,8 +1722,12 @@
"name": "bookmarks_book_id_books_id_fk",
"tableFrom": "bookmarks",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1585,8 +1802,12 @@
"name": "reader_default_preferences_user_id_users_id_fk",
"tableFrom": "reader_default_preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1661,8 +1882,12 @@
"name": "reader_preferences_user_id_users_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1670,8 +1895,12 @@
"name": "reader_preferences_book_file_id_book_files_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "book_files",
"columnsFrom": ["book_file_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1747,8 +1976,12 @@
"name": "reading_progress_book_file_id_book_files_id_fk",
"tableFrom": "reading_progress",
"tableTo": "book_files",
"columnsFrom": ["book_file_id"],
"columnsTo": ["id"],
"columnsFrom": [
"book_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1756,8 +1989,12 @@
"name": "reading_progress_user_id_users_id_fk",
"tableFrom": "reading_progress",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1765,7 +2002,10 @@
"compositePrimaryKeys": {
"reading_progress_book_file_id_user_id_pk": {
"name": "reading_progress_book_file_id_user_id_pk",
"columns": ["book_file_id", "user_id"]
"columns": [
"book_file_id",
"user_id"
]
}
},
"uniqueConstraints": {},
@@ -1809,8 +2049,12 @@
"name": "oidc_group_mappings_role_id_roles_id_fk",
"tableFrom": "oidc_group_mappings",
"tableTo": "roles",
"columnsFrom": ["role_id"],
"columnsTo": ["id"],
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1820,7 +2064,9 @@
"oidc_group_mappings_oidc_group_claim_unique": {
"name": "oidc_group_mappings_oidc_group_claim_unique",
"nullsNotDistinct": false,
"columns": ["oidc_group_claim"]
"columns": [
"oidc_group_claim"
]
}
},
"policies": {},
@@ -1940,8 +2186,12 @@
"name": "oidc_sessions_user_id_users_id_fk",
"tableFrom": "oidc_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1957,7 +2207,11 @@
"public.library_access_level": {
"name": "library_access_level",
"schema": "public",
"values": ["viewer", "editor", "owner"]
"values": [
"viewer",
"editor",
"owner"
]
}
},
"schemas": {},
@@ -1970,4 +2224,4 @@
"schemas": {},
"tables": {}
}
}
}
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -5,16 +5,9 @@
{
"idx": 0,
"version": "7",
"when": 1769077922285,
"when": 1769086950417,
"tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1769077922285,
"tag": "0001_add_scan_jobs_cascade",
"breakpoints": true
}
]
}
+1
View File
@@ -1,6 +1,7 @@
export * from './auth';
export * from './libraries';
export * from './books';
export * from './lenses';
export * from './metadata';
export * from './scanner';
export * from './reader';
+24
View File
@@ -0,0 +1,24 @@
import { boolean, integer, jsonb, pgTable, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const lenses = pgTable(
'lenses',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
icon: varchar('icon', { length: 64 }),
filter: jsonb('filter'),
defaultSort: jsonb('default_sort').notNull().default([]),
isPublic: boolean('is_public').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(t) => [unique().on(t.userId, t.name)],
);
export type Lens = typeof lenses.$inferSelect;
export type NewLens = typeof lenses.$inferInsert;
@@ -0,0 +1,214 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AnyColumn, SQL, and, eq, gt, gte, ilike, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
const SORT_FIELD_MAP: Record<SortField, AnyColumn> = {
title: bookMetadata.title,
addedAt: books.addedAt,
publishedYear: bookMetadata.publishedYear,
pageCount: bookMetadata.pageCount,
seriesIndex: bookMetadata.seriesIndex,
};
@Injectable()
export class BookQueryBuilder {
constructor(@Inject(DB) private readonly db: Db) {}
buildWhere(filter: GroupRule | null | undefined, ctx: { accessibleLibraryIds: number[]; implicitLibraryId?: number }): SQL | undefined {
if (ctx.accessibleLibraryIds.length === 0) {
return sql`1 = 0`;
}
const clauses: SQL[] = [inArray(books.libraryId, ctx.accessibleLibraryIds)];
if (ctx.implicitLibraryId !== undefined) {
clauses.push(eq(books.libraryId, ctx.implicitLibraryId));
}
if (filter) {
clauses.push(this.groupToSql(filter, 0));
}
return and(...clauses);
}
buildOrderBy(sort: SortSpec[]): SQL[] {
if (sort.length === 0) return [sql`${bookMetadata.title} ASC NULLS LAST`];
const result: SQL[] = [];
for (const { field, dir } of sort) {
const col = SORT_FIELD_MAP[field];
result.push(sql`${col} ${sql.raw(dir.toUpperCase())} NULLS LAST`);
if (field === 'seriesIndex') {
result.push(sql`${bookMetadata.seriesName} ${sql.raw(dir.toUpperCase())} NULLS LAST`);
}
}
return result;
}
private groupToSql(node: GroupRule, depth: number): SQL {
if (depth > 5) throw new BadRequestException('Filter nesting exceeds maximum depth of 5');
const clauses = node.rules.map((r) => (r.type === 'group' ? this.groupToSql(r, depth + 1) : this.ruleToSql(r)));
return node.join === 'AND' ? and(...clauses)! : or(...clauses)!;
}
private ruleToSql(rule: Rule): SQL {
const { field, operator, value, valueTo } = rule;
switch (field) {
case 'title':
return this.textRuleToSql(bookMetadata.title, operator, value as string);
case 'publisher':
return this.textRuleToSql(bookMetadata.publisher, operator, value as string);
case 'language':
return this.textRuleToSql(bookMetadata.language, operator, value as string);
case 'series':
return this.textRuleToSql(bookMetadata.seriesName, operator, value as string);
case 'publishedYear':
return this.numericRuleToSql(bookMetadata.publishedYear, operator, value as number, valueTo as number | undefined);
case 'seriesIndex':
return this.numericRuleToSql(bookMetadata.seriesIndex, operator, value as number, valueTo as number | undefined);
case 'pageCount':
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
case 'author':
return this.authorRuleToSql(operator, value as string[]);
case 'tag':
return this.tagRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
case 'addedAt':
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
default:
throw new BadRequestException(`Unknown filter field: ${field}`);
}
}
private textRuleToSql(col: AnyColumn, operator: string, value?: string): SQL {
switch (operator) {
case 'contains':
return ilike(col, `%${value}%`);
case 'notContains':
return not(ilike(col, `%${value}%`));
case 'startsWith':
return ilike(col, `${value}%`);
case 'endsWith':
return ilike(col, `%${value}`);
case 'eq':
return ilike(col, value!);
case 'notEq':
return not(ilike(col, value!));
case 'isEmpty':
return isNull(col);
case 'isNotEmpty':
return isNotNull(col);
default:
throw new BadRequestException(`Invalid operator '${operator}' for text field`);
}
}
private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
switch (operator) {
case 'eq':
return eq(col, value!);
case 'notEq':
return ne(col, value!);
case 'gt':
return gt(col, value!);
case 'gte':
return gte(col, value!);
case 'lt':
return lt(col, value!);
case 'lte':
return lte(col, value!);
case 'between':
return and(gte(col, value!), lte(col, valueTo!))!;
case 'isEmpty':
return isNull(col);
case 'isNotEmpty':
return isNotNull(col);
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
}
private authorRuleToSql(operator: string, values?: string[]): SQL {
const sq = (whereClause?: SQL) =>
this.db.select({ bookId: bookAuthors.bookId }).from(bookAuthors).innerJoin(authors, eq(bookAuthors.authorId, authors.id)).where(whereClause);
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
return inArray(books.id, sq(or(...values.map((v) => ilike(authors.name, `%${v}%`)))!));
case 'includesAll':
if (!values?.length) return sql`1 = 0`;
return and(...values.map((v) => inArray(books.id, sq(ilike(authors.name, `%${v}%`)))))!;
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
return not(inArray(books.id, sq(or(...values.map((v) => ilike(authors.name, `%${v}%`)))!)));
case 'isEmpty':
return not(inArray(books.id, sq()));
case 'isNotEmpty':
return inArray(books.id, sq());
default:
throw new BadRequestException(`Invalid operator '${operator}' for author field`);
}
}
private tagRuleToSql(operator: string, values?: string[]): SQL {
const sq = (whereClause?: SQL) =>
this.db.select({ bookId: bookTags.bookId }).from(bookTags).innerJoin(tags, eq(bookTags.tagId, tags.id)).where(whereClause);
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
return inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v)))!));
case 'includesAll':
if (!values?.length) return sql`1 = 0`;
return and(...values.map((v) => inArray(books.id, sq(eq(tags.name, v)))))!;
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
return not(inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v)))!)));
case 'isEmpty':
return not(inArray(books.id, sq()));
case 'isNotEmpty':
return inArray(books.id, sq());
default:
throw new BadRequestException(`Invalid operator '${operator}' for tag field`);
}
}
private formatRuleToSql(operator: string, values?: string[]): SQL {
const sq = (whereClause?: SQL) => this.db.select({ bookId: bookFiles.bookId }).from(bookFiles).where(whereClause);
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
return inArray(books.id, sq(inArray(bookFiles.format, values)));
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
return not(inArray(books.id, sq(inArray(bookFiles.format, values))));
default:
throw new BadRequestException(`Invalid operator '${operator}' for format field`);
}
}
private dateRuleToSql(operator: string, value?: string | number, valueTo?: string): SQL {
switch (operator) {
case 'before':
return lt(books.addedAt, new Date(value as string));
case 'after':
return gt(books.addedAt, new Date(value as string));
case 'between':
return and(gte(books.addedAt, new Date(value as string)), lte(books.addedAt, new Date(valueTo!)))!;
case 'withinLast':
return sql`${books.addedAt} >= NOW() - (${value} * INTERVAL '1 day')`;
default:
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
}
}
}
+7 -6
View File
@@ -5,25 +5,26 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from './book.service';
import { GetBooksDto } from './dto/get-books.dto';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { SaveProgressDto } from './dto/save-progress.dto';
import { SearchBooksDto } from './dto/search-books.dto';
import type { BookQuery } from '@projectx/types';
@Controller('books')
export class BookController {
constructor(private readonly bookService: BookService) {}
@Get()
getCards(@Query() dto: GetBooksDto, @CurrentUser() user: RequestUser) {
return this.bookService.getCards(dto, user);
}
// Must be before @Get(':id') so NestJS does not treat 'search' as an :id param
@Get('search')
searchBooks(@Query() dto: SearchBooksDto, @CurrentUser() user: RequestUser) {
return this.bookService.searchAcrossLibraries(dto.q, dto.limit ?? 10, user);
}
@Post('query')
globalQuery(@Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
return this.bookService.globalQuery(user, query);
}
@Get(':id/cover')
async getCover(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const coverPath = await this.bookService.getCoverPath(id, user);
+5 -4
View File
@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { LibraryModule } from '../library/library.module';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookController } from './book.controller';
import { BookRepository } from './book.repository';
import { BookService } from './book.service';
@Module({
imports: [LibraryModule],
imports: [forwardRef(() => LibraryModule)],
controllers: [BookController],
providers: [BookService, BookRepository],
exports: [BookService, BookRepository],
providers: [BookService, BookRepository, BookQueryBuilder],
exports: [BookService, BookRepository, BookQueryBuilder],
})
export class BookModule {}
+6 -11
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, eq, ilike, inArray, sql } from 'drizzle-orm';
import { SQL, and, count, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -12,13 +12,8 @@ type Db = NodePgDatabase<typeof schema>;
export class BookRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findCards(libraryId: number, opts: { page: number; size: number; search?: string }) {
const { page, size, search } = opts;
const offset = page * size;
const searchFilter = search?.trim() ? ilike(bookMetadata.title, `%${search.trim()}%`) : undefined;
const where = and(eq(books.libraryId, libraryId), searchFilter);
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number }) {
const { where, orderBy, limit, offset } = opts;
const [rows, [{ total }]] = await Promise.all([
this.db
@@ -33,8 +28,8 @@ export class BookRepository {
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where)
.orderBy(bookMetadata.title)
.limit(size)
.orderBy(...orderBy)
.limit(limit)
.offset(offset),
this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
]);
@@ -132,7 +127,7 @@ export class BookRepository {
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(and(inArray(books.libraryId, libraryIds), ilike(bookMetadata.title, `%${q}%`)))
.where(and(inArray(books.libraryId, libraryIds), sql`${bookMetadata.title} ILIKE ${'%' + q + '%'}`))
.orderBy(bookMetadata.title)
.limit(limit);
+37 -15
View File
@@ -3,12 +3,13 @@ import { ConfigService } from '@nestjs/config';
import { access, readdir, stat } from 'fs/promises';
import { basename, join } from 'path';
import type { BookCard, BookQuery, BooksPage } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { LibraryService } from '../library/library.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookRepository } from './book.repository';
import { BookCardDto } from './dto/book-card.dto';
import { BookDetailDto } from './dto/book-detail.dto';
import { GetBooksDto } from './dto/get-books.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
@Injectable()
export class BookService {
@@ -17,6 +18,7 @@ export class BookService {
constructor(
private readonly bookRepo: BookRepository,
private readonly libraryService: LibraryService,
private readonly queryBuilder: BookQueryBuilder,
private readonly config: ConfigService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
@@ -39,11 +41,11 @@ export class BookService {
return file;
}
async getCards(dto: GetBooksDto, user: RequestUser): Promise<{ items: BookCardDto[]; total: number; page: number; size: number }> {
await this.libraryService.verifyUserAccess(user.id, dto.libraryId, this.isSuperuser(user));
const { libraryId, page = 0, size = 50, search } = dto;
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards(libraryId, { page, size, search });
private assembleBookCards(
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
@@ -58,7 +60,7 @@ export class BookService {
filesByBook.set(row.bookId, list);
}
const items: BookCardDto[] = rows.map((row) => ({
return rows.map((row) => ({
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
@@ -67,8 +69,33 @@ export class BookService {
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
}));
}
return { items, total, page, size };
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
offset: query.pagination.page * query.pagination.size,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
}
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
const libs = await this.libraryService.findAll(user);
const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: query.pagination.size,
offset: query.pagination.page * query.pagination.size,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page: query.pagination.page, size: query.pagination.size };
}
async getCoverPath(id: number, user: RequestUser): Promise<string | null> {
@@ -108,12 +135,7 @@ export class BookService {
return this.bookRepo.findProgress(userId, fileId);
}
async saveProgress(
userId: number,
fileId: number,
dto: { cfi?: string | null; pageNumber?: number | null; percentage: number },
user: RequestUser,
) {
async saveProgress(userId: number, fileId: number, dto: SaveProgressDto, user: RequestUser) {
await this.verifyFileAccess(fileId, user);
await this.bookRepo.upsertProgress(userId, fileId, dto.cfi ?? null, dto.pageNumber ?? null, dto.percentage);
}
@@ -0,0 +1,105 @@
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { z } from 'zod';
import type { BookQuery, GroupRule, Rule } from '@projectx/types';
// Inlined here to avoid a runtime require() of the @projectx/types TS source.
// Keep in sync with packages/types/src/query.ts.
const RULE_FIELDS = [
'title',
'publisher',
'language',
'series',
'seriesIndex',
'publishedYear',
'pageCount',
'author',
'tag',
'format',
'addedAt',
] as const;
const RULE_OPERATORS = [
'contains',
'notContains',
'startsWith',
'endsWith',
'eq',
'notEq',
'isEmpty',
'isNotEmpty',
'gt',
'gte',
'lt',
'lte',
'between',
'includesAny',
'includesAll',
'excludesAll',
'before',
'after',
'withinLast',
] as const;
const SORT_FIELDS = ['title', 'addedAt', 'publishedYear', 'pageCount', 'seriesIndex'] as const;
const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
publisher: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
addedAt: ['before', 'after', 'between', 'withinLast'],
};
const ruleSchema: z.ZodType<Rule> = z
.object({
type: z.literal('rule'),
field: z.enum(RULE_FIELDS),
operator: z.enum(RULE_OPERATORS),
value: z.union([z.string(), z.number(), z.array(z.string()).max(20), z.array(z.number()).max(20)]).optional(),
valueTo: z.union([z.string(), z.number()]).optional(),
})
.refine((rule) => !!VALID_OPERATORS_BY_FIELD[rule.field]?.includes(rule.operator), {
message: 'Operator is not valid for this field',
}) as z.ZodType<Rule>;
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
z.object({
type: z.literal('group'),
join: z.enum(['AND', 'OR']),
rules: z.array(maxDepth <= 1 ? ruleSchema : z.union([ruleSchema, z.lazy(() => groupRuleSchema(maxDepth - 1))])).min(1),
}) as z.ZodType<GroupRule>;
const bookQuerySchema = z.object({
filter: groupRuleSchema(5).optional(),
sort: z
.array(
z.object({
field: z.enum(SORT_FIELDS),
dir: z.enum(['asc', 'desc']),
}),
)
.max(5)
.default([]),
pagination: z
.object({
page: z.number().int().min(0).default(0),
size: z.number().int().min(1).max(200).default(50),
})
.default({ page: 0, size: 50 }),
});
@Injectable()
export class BookQueryPipe implements PipeTransform {
transform(value: unknown): BookQuery {
const result = bookQuerySchema.safeParse(value ?? {});
if (!result.success) throw new BadRequestException(result.error.flatten());
return result.data as BookQuery;
}
}
@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import type { GroupRule, Rule } from '@projectx/types';
// Constants inlined to avoid a runtime require() of the @projectx/types TS source.
// Keep in sync with packages/types/src/query.ts.
const RULE_FIELDS = [
'title',
'publisher',
'language',
'series',
'seriesIndex',
'publishedYear',
'pageCount',
'author',
'tag',
'format',
'addedAt',
] as const;
const RULE_OPERATORS = [
'contains',
'notContains',
'startsWith',
'endsWith',
'eq',
'notEq',
'isEmpty',
'isNotEmpty',
'gt',
'gte',
'lt',
'lte',
'between',
'includesAny',
'includesAll',
'excludesAll',
'before',
'after',
'withinLast',
] as const;
const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
publisher: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
addedAt: ['before', 'after', 'between', 'withinLast'],
};
const ruleSchema: z.ZodType<Rule> = z
.object({
type: z.literal('rule'),
field: z.enum(RULE_FIELDS),
operator: z.enum(RULE_OPERATORS),
value: z.union([z.string(), z.number(), z.array(z.string().min(1)).min(1).max(20), z.array(z.number()).min(1).max(20)]).optional(),
valueTo: z.union([z.string(), z.number()]).optional(),
})
.refine((rule) => !!VALID_OPERATORS_BY_FIELD[rule.field]?.includes(rule.operator), {
message: 'Operator is not valid for this field',
}) as z.ZodType<Rule>;
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>
z.object({
type: z.literal('group'),
join: z.enum(['AND', 'OR']),
rules: z.array(maxDepth <= 1 ? ruleSchema : z.union([ruleSchema, z.lazy(() => groupRuleSchema(maxDepth - 1))])).min(1),
}) as z.ZodType<GroupRule>;
export function validateGroupRule(value: unknown): GroupRule | null {
if (value === null || value === undefined) return null;
const result = groupRuleSchema(5).safeParse(value);
if (!result.success) throw new BadRequestException({ message: 'Invalid filter', errors: result.error.flatten() });
return result.data;
}
@@ -0,0 +1,32 @@
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SortSpecDto {
@IsString()
field: string;
@IsString()
dir: 'asc' | 'desc';
}
export class CreateLensDto {
@IsString()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
filter?: Record<string, unknown>;
@IsArray()
@ValidateNested({ each: true })
@Type(() => SortSpecDto)
defaultSort: SortSpecDto[];
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}
@@ -0,0 +1,27 @@
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { SortSpecDto } from './create-lens.dto';
export class UpdateLensDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
filter?: Record<string, unknown>;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SortSpecDto)
defaultSort?: SortSpecDto[];
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}
@@ -0,0 +1,48 @@
import { Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { CreateLensDto } from './dto/create-lens.dto';
import { UpdateLensDto } from './dto/update-lens.dto';
import { LensService } from './lens.service';
@Controller('lenses')
export class LensController {
constructor(private readonly lensService: LensService) {}
@Get()
findAll(@CurrentUser() user: RequestUser) {
return this.lensService.findAll(user);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.lensService.findOne(id, user);
}
@Post()
create(@Body() dto: CreateLensDto, @CurrentUser() user: RequestUser) {
return this.lensService.create(dto, user);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLensDto, @CurrentUser() user: RequestUser) {
return this.lensService.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.lensService.remove(id, user);
}
@Get(':id/books')
executeLens(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: RequestUser,
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
) {
return this.lensService.executeLens(id, user, page, size);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { LensController } from './lens.controller';
import { LensRepository } from './lens.repository';
import { LensService } from './lens.service';
@Module({
imports: [BookModule, LibraryModule],
controllers: [LensController],
providers: [LensService, LensRepository],
})
export class LensModule {}
@@ -0,0 +1,45 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { lenses } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class LensRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findAllForUser(userId: number) {
return this.db
.select()
.from(lenses)
.where(or(eq(lenses.userId, userId), eq(lenses.isPublic, true)))
.orderBy(lenses.name);
}
findById(id: number) {
return this.db.select().from(lenses).where(eq(lenses.id, id)).limit(1);
}
insert(values: typeof lenses.$inferInsert) {
return this.db.insert(lenses).values(values).returning();
}
update(id: number, userId: number, values: Partial<typeof lenses.$inferInsert>) {
return this.db
.update(lenses)
.set({ ...values, updatedAt: sql`now()` })
.where(and(eq(lenses.id, id), eq(lenses.userId, userId)))
.returning();
}
delete(id: number, userId: number) {
return this.db
.delete(lenses)
.where(and(eq(lenses.id, id), eq(lenses.userId, userId)))
.returning();
}
}
+128
View File
@@ -0,0 +1,128 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { basename } from 'path';
import type { BookCard, BooksPage, GroupRule, SortSpec } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryBuilder } from '../book/book-query-builder.service';
import { BookRepository } from '../book/book.repository';
import { validateGroupRule } from '../book/utils/group-rule.validator';
import { LibraryService } from '../library/library.service';
import { CreateLensDto } from './dto/create-lens.dto';
import { UpdateLensDto } from './dto/update-lens.dto';
import { LensRepository } from './lens.repository';
@Injectable()
export class LensService {
constructor(
private readonly lensRepo: LensRepository,
private readonly bookRepo: BookRepository,
private readonly queryBuilder: BookQueryBuilder,
private readonly libraryService: LibraryService,
) {}
private isSuperuser(user: RequestUser): boolean {
return user.roles.some((r) => r.isSuperuser);
}
private assembleBookCards(
rows: { id: number; status: string; folderPath: string; title: string | null; seriesName: string | null; seriesIndex: number | null }[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
return rows.map((row) => ({
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
}));
}
findAll(user: RequestUser) {
return this.lensRepo.findAllForUser(user.id);
}
async findOne(id: number, user: RequestUser) {
const [lens] = await this.lensRepo.findById(id);
if (!lens) throw new NotFoundException('Lens not found');
if (!lens.isPublic && lens.userId !== user.id && !this.isSuperuser(user)) {
throw new ForbiddenException('No access to this lens');
}
return lens;
}
async create(dto: CreateLensDto, user: RequestUser) {
const filter = validateGroupRule(dto.filter);
const [lens] = await this.lensRepo.insert({
userId: user.id,
name: dto.name,
icon: dto.icon ?? null,
filter,
defaultSort: (dto.defaultSort as SortSpec[]) ?? [],
isPublic: dto.isPublic ?? false,
});
return lens;
}
async update(id: number, dto: UpdateLensDto, user: RequestUser) {
const [lens] = await this.lensRepo.findById(id);
if (!lens) throw new NotFoundException('Lens not found');
if (lens.userId !== user.id && !this.isSuperuser(user)) throw new ForbiddenException('Cannot modify this lens');
const filter = validateGroupRule(dto.filter);
const [updated] = await this.lensRepo.update(id, lens.userId, {
name: dto.name,
icon: dto.icon,
filter: filter ?? undefined,
defaultSort: dto.defaultSort as SortSpec[] | undefined,
isPublic: dto.isPublic,
});
return updated;
}
async remove(id: number, user: RequestUser) {
const [lens] = await this.lensRepo.findById(id);
if (!lens) throw new NotFoundException('Lens not found');
if (lens.userId !== user.id && !this.isSuperuser(user)) throw new ForbiddenException('Cannot delete this lens');
await this.lensRepo.delete(id, lens.userId);
}
async executeLens(id: number, user: RequestUser, page: number, size: number): Promise<BooksPage> {
const [lens] = await this.lensRepo.findById(id);
if (!lens) throw new NotFoundException('Lens not found');
if (!lens.isPublic && lens.userId !== user.id && !this.isSuperuser(user)) {
throw new ForbiddenException('No access to this lens');
}
const libs = await this.libraryService.findAll(user);
const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
const { rows, authorRows, fileRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: size,
offset: page * size,
});
return { items: this.assembleBookCards(rows, authorRows, fileRows), total, page, size };
}
}
@@ -1,8 +1,11 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import type { BookQuery } from '@projectx/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookService } from '../book/book.service';
import { CreateLibraryDto } from './dto/create-library.dto';
import { GrantLibraryAccessDto } from './dto/grant-library-access.dto';
import { PrescanLibraryDto } from './dto/prescan-library.dto';
@@ -13,7 +16,10 @@ import { LibraryService } from './library.service';
@Controller('libraries')
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
constructor(
private readonly libraryService: LibraryService,
private readonly bookService: BookService,
) {}
@Get()
findAll(@CurrentUser() user: RequestUser) {
@@ -26,6 +32,11 @@ export class LibraryController {
return this.libraryService.verifyUserAccess(user.id, id, isSuperuser).then(() => this.libraryService.findOne(id));
}
@Post(':id/books')
queryBooks(@Param('id', ParseIntPipe) libraryId: number, @Body(BookQueryPipe) query: BookQuery, @CurrentUser() user: RequestUser) {
return this.bookService.queryForLibrary(user, libraryId, query);
}
@Post()
@RequirePermission('manage_libraries')
create(@Body() dto: CreateLibraryDto) {
+4 -3
View File
@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { ScannerModule } from '../scanner/scanner.module';
import { LibraryController } from './library.controller';
import { LibraryRepository } from './library.repository';
import { LibraryService } from './library.service';
@Module({
imports: [ScannerModule],
imports: [ScannerModule, forwardRef(() => BookModule)],
controllers: [LibraryController],
providers: [LibraryService, LibraryRepository],
exports: [LibraryService],
exports: [LibraryService, LibraryRepository],
})
export class LibraryModule {}