feat(reader): add settings system with per-book delta and defaults

This commit is contained in:
neon
2026-01-16 02:00:38 -06:00
parent 86ff97bc82
commit 244d38afe8
53 changed files with 9554 additions and 5677 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
);
+2
View File
@@ -19,6 +19,7 @@ import { KoboModule } from './modules/kobo/kobo.module';
import { LibraryModule } from './modules/library/library.module';
import { PathModule } from './modules/path/path.module';
import { MetadataModule } from './modules/metadata/metadata.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
import { RoleModule } from './modules/role/role.module';
import { ScannerModule } from './modules/scanner/scanner.module';
import { SeedModule } from './modules/seed/seed.module';
@@ -47,6 +48,7 @@ import { UserModule } from './modules/user/user.module';
BookmarkModule,
AnnotationModule,
CbzModule,
ReaderPreferencesModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
@@ -215,6 +215,22 @@ CREATE TABLE "bookmarks" (
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reader_default_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"format_group" varchar(10) NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reader_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"book_file_id" integer NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reading_progress" (
"book_file_id" integer NOT NULL,
"user_id" integer NOT NULL,
@@ -267,6 +283,9 @@ ALTER TABLE "annotations" ADD CONSTRAINT "annotations_user_id_users_id_fk" FOREI
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
ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reader_default_preferences" ADD CONSTRAINT "reader_default_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reader_preferences" ADD CONSTRAINT "reader_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reader_preferences" ADD CONSTRAINT "reader_preferences_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oidc_group_mappings" ADD CONSTRAINT "oidc_group_mappings_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
@@ -275,6 +294,8 @@ CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USIN
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint
CREATE UNIQUE INDEX "rp_user_file_idx" ON "reader_preferences" USING btree ("user_id","book_file_id");--> statement-breakpoint
CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "oidc_sessions_user_id_idx" ON "oidc_sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
+231 -218
View File
@@ -1,5 +1,5 @@
{
"id": "05501c4f-0a30-4fac-961f-d84a298725a2",
"id": "93dc2d98-8ab8-41bc-8cc1-8cbbe6f34df1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -41,9 +41,7 @@
"app_settings_key_unique": {
"name": "app_settings_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
"columns": ["key"]
}
},
"policies": {},
@@ -114,12 +112,8 @@
"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"
}
@@ -129,9 +123,7 @@
"password_reset_tokens_token_hash_unique": {
"name": "password_reset_tokens_token_hash_unique",
"nullsNotDistinct": false,
"columns": [
"token_hash"
]
"columns": ["token_hash"]
}
},
"policies": {},
@@ -182,9 +174,7 @@
"permissions_name_unique": {
"name": "permissions_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
"columns": ["name"]
}
},
"policies": {},
@@ -255,12 +245,8 @@
"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"
}
@@ -270,9 +256,7 @@
"refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique",
"nullsNotDistinct": false,
"columns": [
"token_hash"
]
"columns": ["token_hash"]
}
},
"policies": {},
@@ -302,12 +286,8 @@
"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"
},
@@ -315,12 +295,8 @@
"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"
}
@@ -328,10 +304,7 @@
"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": {},
@@ -390,9 +363,7 @@
"roles_name_unique": {
"name": "roles_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
"columns": ["name"]
}
},
"policies": {},
@@ -430,12 +401,8 @@
"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"
},
@@ -443,12 +410,8 @@
"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"
}
@@ -456,10 +419,7 @@
"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": {},
@@ -490,12 +450,8 @@
"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"
},
@@ -503,12 +459,8 @@
"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"
}
@@ -516,10 +468,7 @@
"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": {},
@@ -636,16 +585,12 @@
"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": {},
@@ -744,12 +689,8 @@
"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"
},
@@ -757,12 +698,8 @@
"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"
}
@@ -829,12 +766,8 @@
"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"
},
@@ -842,12 +775,8 @@
"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"
}
@@ -1020,12 +949,8 @@
"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"
}
@@ -1097,12 +1022,8 @@
"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"
},
@@ -1110,12 +1031,8 @@
"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"
}
@@ -1123,10 +1040,7 @@
"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": {},
@@ -1224,12 +1138,8 @@
"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"
}
@@ -1263,12 +1173,8 @@
"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"
},
@@ -1276,12 +1182,8 @@
"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"
}
@@ -1289,10 +1191,7 @@
"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": {},
@@ -1324,9 +1223,7 @@
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
"columns": ["name"]
}
},
"policies": {},
@@ -1409,12 +1306,8 @@
"name": "scan_jobs_library_id_libraries_id_fk",
"tableFrom": "scan_jobs",
"tableTo": "libraries",
"columnsFrom": [
"library_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["library_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1522,12 +1415,8 @@
"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"
},
@@ -1535,12 +1424,8 @@
"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"
}
@@ -1615,12 +1500,8 @@
"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"
},
@@ -1628,12 +1509,169 @@
"name": "bookmarks_book_id_books_id_fk",
"tableFrom": "bookmarks",
"tableTo": "books",
"columnsFrom": [
"book_id"
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reader_default_preferences": {
"name": "reader_default_preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"format_group": {
"name": "format_group",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"rdp_user_format_idx": {
"name": "rdp_user_format_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "format_group",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"columnsTo": [
"id"
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reader_default_preferences_user_id_users_id_fk": {
"name": "reader_default_preferences_user_id_users_id_fk",
"tableFrom": "reader_default_preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reader_preferences": {
"name": "reader_preferences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"book_file_id": {
"name": "book_file_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"rp_user_file_idx": {
"name": "rp_user_file_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "book_file_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reader_preferences_user_id_users_id_fk": {
"name": "reader_preferences_user_id_users_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"reader_preferences_book_file_id_book_files_id_fk": {
"name": "reader_preferences_book_file_id_book_files_id_fk",
"tableFrom": "reader_preferences",
"tableTo": "book_files",
"columnsFrom": ["book_file_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1709,12 +1747,8 @@
"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"
},
@@ -1722,12 +1756,8 @@
"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"
}
@@ -1735,10 +1765,7 @@
"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": {},
@@ -1782,12 +1809,8 @@
"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"
}
@@ -1797,9 +1820,7 @@
"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": {},
@@ -1919,12 +1940,8 @@
"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"
}
@@ -1940,11 +1957,7 @@
"public.library_access_level": {
"name": "library_access_level",
"schema": "public",
"values": [
"viewer",
"editor",
"owner"
]
"values": ["viewer", "editor", "owner"]
}
},
"schemas": {},
@@ -1957,4 +1970,4 @@
"schemas": {},
"tables": {}
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1768523691867,
"when": 1768596999296,
"tag": "0000_baseline",
"breakpoints": true
}
+37 -1
View File
@@ -1,4 +1,4 @@
import { index, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { bookFiles, books } from './books';
import { users } from './auth';
@@ -69,3 +69,39 @@ export const annotations = pgTable(
export type AnnotationRow = typeof annotations.$inferSelect;
export type NewAnnotation = typeof annotations.$inferInsert;
export const readerDefaultPreferences = pgTable(
'reader_default_preferences',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
formatGroup: varchar('format_group', { length: 10 }).notNull(),
settings: jsonb('settings').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(t) => [uniqueIndex('rdp_user_format_idx').on(t.userId, t.formatGroup)],
);
export type ReaderDefaultPreference = typeof readerDefaultPreferences.$inferSelect;
export type NewReaderDefaultPreference = typeof readerDefaultPreferences.$inferInsert;
export const readerPreferences = pgTable(
'reader_preferences',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookFileId: integer('book_file_id')
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
settings: jsonb('settings').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(t) => [uniqueIndex('rp_user_file_idx').on(t.userId, t.bookFileId)],
);
export type ReaderPreference = typeof readerPreferences.$inferSelect;
export type NewReaderPreference = typeof readerPreferences.$inferInsert;
+1 -4
View File
@@ -12,10 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req: any) => req?.cookies?.access_token ?? null,
]),
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), (req: any) => req?.cookies?.access_token ?? null]),
secretOrKey: config.get<string>('auth.jwtSecret')!,
});
}
@@ -0,0 +1,6 @@
import { IsObject } from 'class-validator';
export class UpsertPreferenceDto {
@IsObject()
settings: Record<string, unknown>;
}
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Put } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UpsertPreferenceDto } from './dto/upsert-preference.dto';
import { ReaderPreferencesService } from './reader-preferences.service';
@Controller('reader')
export class ReaderPreferencesController {
constructor(private readonly readerPreferencesService: ReaderPreferencesService) {}
@Get('preferences/:bookFileId')
async getPreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @CurrentUser() user: RequestUser) {
const pref = await this.readerPreferencesService.getPreference(user.id, bookFileId);
return { settings: pref?.settings ?? null, isCustomized: !!pref };
}
@Put('preferences/:bookFileId')
@HttpCode(204)
async upsertPreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @Body() dto: UpsertPreferenceDto, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.upsertPreference(user.id, bookFileId, dto.settings);
}
@Delete('preferences/:bookFileId')
@HttpCode(204)
async deletePreference(@Param('bookFileId', ParseIntPipe) bookFileId: number, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.deletePreference(user.id, bookFileId);
}
@Get('defaults')
async getAllDefaults(@CurrentUser() user: RequestUser) {
const rows = await this.readerPreferencesService.getAllDefaults(user.id);
return Object.fromEntries(rows.map((r) => [r.formatGroup, r.settings]));
}
@Put('defaults/:formatGroup')
@HttpCode(204)
async upsertDefault(@Param('formatGroup') formatGroup: string, @Body() dto: UpsertPreferenceDto, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.upsertDefault(user.id, formatGroup, dto.settings);
}
@Delete('defaults/:formatGroup')
@HttpCode(204)
async deleteDefault(@Param('formatGroup') formatGroup: string, @CurrentUser() user: RequestUser) {
await this.readerPreferencesService.deleteDefault(user.id, formatGroup);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ReaderPreferencesController } from './reader-preferences.controller';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
import { ReaderPreferencesService } from './reader-preferences.service';
@Module({
controllers: [ReaderPreferencesController],
providers: [ReaderPreferencesRepository, ReaderPreferencesService],
})
export class ReaderPreferencesModule {}
@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class ReaderPreferencesRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findPreference(userId: number, bookFileId: number) {
return this.db.query.readerPreferences.findFirst({
where: and(eq(schema.readerPreferences.userId, userId), eq(schema.readerPreferences.bookFileId, bookFileId)),
});
}
async upsertPreference(userId: number, bookFileId: number, settings: Record<string, unknown>) {
await this.db
.insert(schema.readerPreferences)
.values({ userId, bookFileId, settings, updatedAt: new Date() })
.onConflictDoUpdate({
target: [schema.readerPreferences.userId, schema.readerPreferences.bookFileId],
set: { settings, updatedAt: new Date() },
});
}
async deletePreference(userId: number, bookFileId: number) {
await this.db
.delete(schema.readerPreferences)
.where(and(eq(schema.readerPreferences.userId, userId), eq(schema.readerPreferences.bookFileId, bookFileId)));
}
async findAllDefaults(userId: number) {
return this.db.query.readerDefaultPreferences.findMany({
where: eq(schema.readerDefaultPreferences.userId, userId),
});
}
async upsertDefault(userId: number, formatGroup: string, settings: Record<string, unknown>) {
await this.db
.insert(schema.readerDefaultPreferences)
.values({ userId, formatGroup, settings, updatedAt: new Date() })
.onConflictDoUpdate({
target: [schema.readerDefaultPreferences.userId, schema.readerDefaultPreferences.formatGroup],
set: { settings, updatedAt: new Date() },
});
}
async deleteDefault(userId: number, formatGroup: string) {
await this.db
.delete(schema.readerDefaultPreferences)
.where(and(eq(schema.readerDefaultPreferences.userId, userId), eq(schema.readerDefaultPreferences.formatGroup, formatGroup)));
}
}
@@ -0,0 +1,43 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ReaderPreferencesRepository } from './reader-preferences.repository';
const VALID_FORMAT_GROUPS = ['epub', 'pdf', 'cbx'] as const;
type FormatGroup = (typeof VALID_FORMAT_GROUPS)[number];
function assertValidFormatGroup(formatGroup: string): asserts formatGroup is FormatGroup {
if (!VALID_FORMAT_GROUPS.includes(formatGroup as FormatGroup)) {
throw new BadRequestException(`Invalid format group "${formatGroup}". Must be one of: ${VALID_FORMAT_GROUPS.join(', ')}`);
}
}
@Injectable()
export class ReaderPreferencesService {
constructor(private readonly repo: ReaderPreferencesRepository) {}
async getPreference(userId: number, bookFileId: number) {
return this.repo.findPreference(userId, bookFileId);
}
async upsertPreference(userId: number, bookFileId: number, settings: Record<string, unknown>) {
await this.repo.upsertPreference(userId, bookFileId, settings);
}
async deletePreference(userId: number, bookFileId: number) {
await this.repo.deletePreference(userId, bookFileId);
}
async getAllDefaults(userId: number) {
return this.repo.findAllDefaults(userId);
}
async upsertDefault(userId: number, formatGroup: string, settings: Record<string, unknown>) {
assertValidFormatGroup(formatGroup);
await this.repo.upsertDefault(userId, formatGroup, settings);
}
async deleteDefault(userId: number, formatGroup: string) {
assertValidFormatGroup(formatGroup);
await this.repo.deleteDefault(userId, formatGroup);
}
}
+7 -1
View File
@@ -158,7 +158,13 @@ export class UserRepository {
}
async update(id: number, data: Partial<Pick<typeof schema.users.$inferInsert, 'name' | 'email' | 'active' | 'settings'>>) {
const [user] = await this.db.update(schema.users).set(data).where(eq(schema.users.id, id)).returning({
const { settings, ...rest } = data;
const setData: Record<string, unknown> = { ...rest };
if (settings !== undefined) {
// Merge into existing settings rather than replacing the whole object
setData.settings = sql`${schema.users.settings} || ${JSON.stringify(settings)}::jsonb`;
}
const [user] = await this.db.update(schema.users).set(setData).where(eq(schema.users.id, id)).returning({
id: schema.users.id,
username: schema.users.username,
name: schema.users.name,
+1 -4
View File
@@ -17,9 +17,6 @@ describe('AppController (e2e)', () => {
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
});
});