books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 8b2dfed239
commit 9c46e281c2
65 changed files with 25709 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ import {
jsonb,
primaryKey,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
@@ -25,6 +26,14 @@ export const bookMetadataStatus = booksSchema.enum('metadata_status', [
'manual' // edited by hand; enrichment never overwrites it
]);
// Reading state per work (on books.progress), shown publicly on /trackers/books.
export const bookReadingStatus = booksSchema.enum('reading_status', [
'want_to_read',
'reading',
'finished',
'abandoned'
]);
export const bookWorks = booksSchema.table(
'works',
{
@@ -128,11 +137,16 @@ export const bookProgress = booksSchema.table(
percent: real('percent').notNull().default(0),
startedAt: timestamptz('started_at'),
finishedAt: timestamptz('finished_at'),
status: bookReadingStatus('status').notNull().default('want_to_read'),
rating: smallint('rating'), // 1-5 stars
lastReadAt: timestamptz('last_read_at'), // last time the reader saved a position
...timestamps
},
(t) => [
uniqueIndex('progress_work_id_key').on(t.workId),
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
check('progress_percent_check', sql`${t.percent} between 0 and 100`),
check('progress_rating_check', sql`${t.rating} is null or ${t.rating} between 1 and 5`),
index('progress_status_idx').on(t.status)
]
);
@@ -154,9 +168,42 @@ export const bookNotes = booksSchema.table(
(t) => [index('notes_work_id_idx').on(t.workId)]
);
// One reading session: opened in the reader (source 'reader') or logged by hand ('manual').
export const bookSessions = booksSchema.table(
'sessions',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
startedAt: timestamptz('started_at').notNull().defaultNow(),
endedAt: timestamptz('ended_at'),
startPercent: real('start_percent'),
endPercent: real('end_percent'),
startPage: integer('start_page'),
endPage: integer('end_page'),
pagesRead: integer('pages_read'), // derived on save: page delta, or percent delta x page_count
source: text('source').notNull().default('reader'),
note: text('note'),
...timestamps
},
(t) => [
index('sessions_work_id_idx').on(t.workId),
index('sessions_started_at_idx').on(t.startedAt),
check(
'sessions_percent_check',
sql`(${t.startPercent} is null or ${t.startPercent} between 0 and 100) and (${t.endPercent} is null or ${t.endPercent} between 0 and 100)`
),
check('sessions_end_check', sql`${t.endedAt} is null or ${t.endedAt} >= ${t.startedAt}`)
]
);
export type BookWork = typeof bookWorks.$inferSelect;
export type NewBookWork = typeof bookWorks.$inferInsert;
export type BookFile = typeof bookFiles.$inferSelect;
export type BookShelf = typeof bookShelves.$inferSelect;
export type BookProgress = typeof bookProgress.$inferSelect;
export type BookNote = typeof bookNotes.$inferSelect;
export type BookSession = typeof bookSessions.$inferSelect;
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];