mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): add trigram indexes for search and fix header z-index
- enable pg_trgm extension in db module on startup - add GIN trigram indexes on book_metadata title, series_name and authors name - refactor book search query to use LEFT JOIN instead of correlated EXISTS subquery - fix jwt-auth guard to use /api/v1/ prefix for password-change allowlist - regenerate baseline migration to incorporate schema index changes - fix AppHeader sticky z-index from z-10 to z-30 so search dropdown renders above book card series badges
This commit is contained in:
@@ -42,7 +42,12 @@ const mobileSearchInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedIndex = ref(-1)
|
||||
|
||||
const globalSearchQuery = ref('')
|
||||
const { results: globalResults, loading: globalSearchLoading, settled: globalSearchSettled, clear: clearGlobalSearch } = useGlobalSearch(globalSearchQuery)
|
||||
const {
|
||||
results: globalResults,
|
||||
loading: globalSearchLoading,
|
||||
settled: globalSearchSettled,
|
||||
clear: clearGlobalSearch,
|
||||
} = useGlobalSearch(globalSearchQuery)
|
||||
|
||||
const showDropdown = computed(
|
||||
() =>
|
||||
@@ -135,7 +140,7 @@ function formatBadgeClass(fmt: string): string {
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border/50 bg-background/70 backdrop-blur-xl backdrop-saturate-150 px-3 shadow-sm sticky top-0 z-10"
|
||||
class="flex h-14 shrink-0 items-center gap-2 border-b border-border/50 bg-background/70 backdrop-blur-xl backdrop-saturate-150 px-3 shadow-sm sticky top-0 z-30"
|
||||
>
|
||||
<!-- Mobile: search active overlay -->
|
||||
<template v-if="mobileSearchOpen">
|
||||
@@ -162,7 +167,10 @@ function formatBadgeClass(fmt: string): string {
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-72 overflow-y-auto"
|
||||
>
|
||||
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
|
||||
<div v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">
|
||||
<div
|
||||
v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0"
|
||||
class="p-3 text-xs text-muted-foreground text-center"
|
||||
>
|
||||
No results
|
||||
</div>
|
||||
<button
|
||||
@@ -174,7 +182,12 @@ function formatBadgeClass(fmt: string): string {
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/60',
|
||||
]"
|
||||
>
|
||||
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-16 w-12 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
|
||||
<BookCoverImage
|
||||
:book-id="result.id"
|
||||
type="thumbnail"
|
||||
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
|
||||
:alt="result.title ?? ''"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
<template v-for="seg in highlightSegments(result.title, globalSearchQuery)" :key="seg.text + seg.match">
|
||||
@@ -246,7 +259,10 @@ function formatBadgeClass(fmt: string): string {
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
|
||||
<div v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">
|
||||
<div
|
||||
v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0"
|
||||
class="p-3 text-xs text-muted-foreground text-center"
|
||||
>
|
||||
No results
|
||||
</div>
|
||||
<button
|
||||
@@ -258,7 +274,12 @@ function formatBadgeClass(fmt: string): string {
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/60',
|
||||
]"
|
||||
>
|
||||
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-16 w-12 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
|
||||
<BookCoverImage
|
||||
:book-id="result.id"
|
||||
type="thumbnail"
|
||||
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
|
||||
:alt="result.title ?? ''"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
<template v-for="seg in highlightSegments(result.title, globalSearchQuery)" :key="seg.text + seg.match">
|
||||
@@ -302,7 +323,12 @@ function formatBadgeClass(fmt: string): string {
|
||||
<!-- Mobile: search icon -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground" @click="mobileSearchOpen = true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
@click="mobileSearchOpen = true"
|
||||
>
|
||||
<Search :size="15" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -336,22 +362,22 @@ function formatBadgeClass(fmt: string): string {
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<PopoverContent class="w-72 p-4" align="end">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Accent</span>
|
||||
<AccentPicker />
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Accent</span>
|
||||
<AccentPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Radius</span>
|
||||
<RadiusPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Background</span>
|
||||
<BackgroundPicker />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Radius</span>
|
||||
<RadiusPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-xs text-muted-foreground">Background</span>
|
||||
<BackgroundPicker />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>Appearance</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -26,8 +26,8 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
if (user.isDefaultPassword) {
|
||||
const req = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const url = req.url.split('?')[0];
|
||||
const isChangePassword = url === '/api/auth/change-password' && req.method === 'POST';
|
||||
const isMe = url === '/api/auth/me' && req.method === 'GET';
|
||||
const isChangePassword = url === '/api/v1/auth/change-password' && req.method === 'POST';
|
||||
const isMe = url === '/api/v1/auth/me' && req.method === 'GET';
|
||||
if (!isChangePassword && !isMe) {
|
||||
throw new ForbiddenException('Password change required');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DB = Symbol('DB');
|
||||
{
|
||||
provide: DB,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
useFactory: async (config: ConfigService) => {
|
||||
const pool = new Pool({
|
||||
connectionString: config.get('db.url'),
|
||||
max: 20,
|
||||
@@ -20,6 +20,7 @@ export const DB = Symbol('DB');
|
||||
connectionTimeoutMillis: 5_000,
|
||||
statement_timeout: 30_000,
|
||||
});
|
||||
await pool.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
return drizzle(pool, { schema });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -564,6 +564,10 @@ ALTER TABLE "staging_files" ADD CONSTRAINT "staging_files_target_library_id_libr
|
||||
ALTER TABLE "staging_files" ADD CONSTRAINT "staging_files_target_folder_id_library_folders_id_fk" FOREIGN KEY ("target_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
|
||||
CREATE INDEX "authors_name_trgm_idx" ON "authors" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
|
||||
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> 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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "a0c5b139-eebd-4476-86e8-21be6cdee148",
|
||||
"id": "9b81eebf-1c40-4c73-baad-cd6109207d0f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -1755,7 +1755,24 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"email_templates_system_name_unique": {
|
||||
"name": "email_templates_system_name_unique",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"email_templates\".\"user_id\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"email_templates_user_id_users_id_fk": {
|
||||
"name": "email_templates_user_id_users_id_fk",
|
||||
@@ -2093,7 +2110,24 @@
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"authors_name_trgm_idx": {
|
||||
"name": "authors_name_trgm_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last",
|
||||
"opclass": "gin_trgm_ops"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "gin",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
@@ -2360,7 +2394,40 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"bm_title_trgm_idx": {
|
||||
"name": "bm_title_trgm_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "title",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last",
|
||||
"opclass": "gin_trgm_ops"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "gin",
|
||||
"with": {}
|
||||
},
|
||||
"bm_series_trgm_idx": {
|
||||
"name": "bm_series_trgm_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "series_name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last",
|
||||
"opclass": "gin_trgm_ops"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "gin",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"book_metadata_book_id_books_id_fk": {
|
||||
"name": "book_metadata_book_id_books_id_fk",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,9 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1772377654994,
|
||||
"when": 1772457969998,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1774716959924,
|
||||
"tag": "0001_fix_system_template_unique",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { customType, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { customType, index, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
const embedding256 = customType<{ data: number[]; driverData: string }>({
|
||||
dataType: () => 'vector(256)',
|
||||
@@ -8,37 +8,50 @@ const embedding256 = customType<{ data: number[]; driverData: string }>({
|
||||
|
||||
import { books } from './books';
|
||||
|
||||
export const bookMetadata = pgTable('book_metadata', {
|
||||
bookId: integer('book_id')
|
||||
.primaryKey()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 1000 }),
|
||||
subtitle: varchar('subtitle', { length: 1000 }),
|
||||
description: text('description'),
|
||||
isbn10: varchar('isbn10', { length: 10 }),
|
||||
isbn13: varchar('isbn13', { length: 13 }),
|
||||
publisher: varchar('publisher', { length: 500 }),
|
||||
publishedYear: integer('published_year'),
|
||||
language: varchar('language', { length: 10 }),
|
||||
pageCount: integer('page_count'),
|
||||
seriesName: varchar('series_name', { length: 500 }),
|
||||
seriesIndex: real('series_index'),
|
||||
rating: integer('rating'),
|
||||
coverSource: varchar('cover_source', { length: 9 }),
|
||||
googleBooksId: varchar('google_books_id', { length: 50 }),
|
||||
goodreadsId: varchar('goodreads_id', { length: 50 }),
|
||||
amazonId: varchar('amazon_id', { length: 20 }),
|
||||
hardcoverId: varchar('hardcover_id', { length: 50 }),
|
||||
openLibraryId: varchar('open_library_id', { length: 50 }),
|
||||
embedding: embedding256('embedding'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
export const bookMetadata = pgTable(
|
||||
'book_metadata',
|
||||
{
|
||||
bookId: integer('book_id')
|
||||
.primaryKey()
|
||||
.references(() => books.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 1000 }),
|
||||
subtitle: varchar('subtitle', { length: 1000 }),
|
||||
description: text('description'),
|
||||
isbn10: varchar('isbn10', { length: 10 }),
|
||||
isbn13: varchar('isbn13', { length: 13 }),
|
||||
publisher: varchar('publisher', { length: 500 }),
|
||||
publishedYear: integer('published_year'),
|
||||
language: varchar('language', { length: 10 }),
|
||||
pageCount: integer('page_count'),
|
||||
seriesName: varchar('series_name', { length: 500 }),
|
||||
seriesIndex: real('series_index'),
|
||||
rating: integer('rating'),
|
||||
coverSource: varchar('cover_source', { length: 9 }),
|
||||
googleBooksId: varchar('google_books_id', { length: 50 }),
|
||||
goodreadsId: varchar('goodreads_id', { length: 50 }),
|
||||
amazonId: varchar('amazon_id', { length: 20 }),
|
||||
hardcoverId: varchar('hardcover_id', { length: 50 }),
|
||||
openLibraryId: varchar('open_library_id', { length: 50 }),
|
||||
embedding: embedding256('embedding'),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('bm_title_trgm_idx').using('gin', t.title.op('gin_trgm_ops')),
|
||||
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
|
||||
],
|
||||
);
|
||||
|
||||
export const authors = pgTable('authors', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
});
|
||||
export const authors = pgTable(
|
||||
'authors',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
sortName: varchar('sort_name', { length: 500 }),
|
||||
},
|
||||
(t) => [
|
||||
index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
|
||||
],
|
||||
);
|
||||
|
||||
export const bookAuthors = pgTable(
|
||||
'book_authors',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SQL, and, count, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { SQL, and, count, eq, inArray, isNotNull, or, sql } from 'drizzle-orm';
|
||||
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
@@ -159,6 +159,15 @@ export class BookRepository {
|
||||
async searchAcrossLibraries(libraryIds: number[], q: string, limit: number) {
|
||||
if (libraryIds.length === 0) return [];
|
||||
|
||||
const pattern = '%' + q + '%';
|
||||
|
||||
const matchedAuthors = this.db
|
||||
.selectDistinct({ bookId: bookAuthors.bookId })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(sql`${authors.name} ILIKE ${pattern}`)
|
||||
.as('matched_authors');
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
@@ -170,19 +179,15 @@ export class BookRepository {
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.leftJoin(matchedAuthors, eq(matchedAuthors.bookId, books.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(books.libraryId, libraryIds),
|
||||
sql`(
|
||||
${bookMetadata.title} ILIKE ${'%' + q + '%'}
|
||||
OR ${bookMetadata.seriesName} ILIKE ${'%' + q + '%'}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM ${bookAuthors}
|
||||
JOIN ${authors} ON ${authors.id} = ${bookAuthors.authorId}
|
||||
WHERE ${bookAuthors.bookId} = ${books.id}
|
||||
AND ${authors.name} ILIKE ${'%' + q + '%'}
|
||||
)
|
||||
)`,
|
||||
or(
|
||||
sql`${bookMetadata.title} ILIKE ${pattern}`,
|
||||
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
|
||||
isNotNull(matchedAuthors.bookId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(bookMetadata.title)
|
||||
|
||||
Reference in New Issue
Block a user