feat(settings): redesign users, field rules, file naming and libraries

Rebuilds the users roster, metadata field rules, file naming and metadata
score panels, and replaces the libraries list with an expandable ledger.
Libraries now batch their counts and last scan into one request instead of a
per-library fan-out, and surface scan history from scan_jobs on demand.
This commit is contained in:
neon
2026-08-23 21:00:42 +00:00
parent a3bc499028
commit 5ec34aef23
167 changed files with 30198 additions and 4507 deletions
@@ -0,0 +1 @@
CREATE INDEX "scan_jobs_library_started_at_idx" ON "scan_jobs" USING btree ("library_id","started_at" desc);
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -519,6 +519,13 @@
"when": 1787378660967,
"tag": "0073_widen_font_weights",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1787522267109,
"tag": "0074_add_scan_jobs_library_started_at_index",
"breakpoints": true
}
]
}
}
+1
View File
@@ -22,6 +22,7 @@ export const scanJobs = pgTable(
},
(t) => [
index('scan_jobs_library_status_idx').on(t.libraryId, t.status),
index('scan_jobs_library_started_at_idx').on(t.libraryId, sql`${t.startedAt} desc`),
check('scan_jobs_status_chk', sql`${t.status} in ('running', 'completed', 'failed')`),
check('scan_jobs_triggered_by_chk', sql`${t.triggeredBy} in ('manual', 'watcher', 'schedule')`),
check('scan_jobs_added_count_nonnegative_chk', sql`${t.addedCount} >= 0`),
@@ -13,6 +13,7 @@ describe('LibraryController', () => {
prescan: vi.fn(),
reorder: vi.fn(),
getStats: vi.fn(),
getOverview: vi.fn(),
getAccess: vi.fn(),
grantAccess: vi.fn(),
updateAccess: vi.fn(),
@@ -34,6 +35,15 @@ describe('LibraryController', () => {
vi.resetAllMocks();
});
it('getOverview passes the caller through so the service can scope the result', async () => {
const user = { id: 7, isSuperuser: false } as any;
libraryService.getOverview.mockResolvedValue([]);
await controller.getOverview(user);
expect(libraryService.getOverview).toHaveBeenCalledWith(user);
});
it('writeMetadataToFiles blocks non-dry-run when file write is disabled', async () => {
libraryService.writeMetadataToFiles.mockRejectedValue(new BadRequestException('disabled'));
const reply = {
@@ -33,6 +33,17 @@ export class LibraryController {
return this.libraryService.findAll(user);
}
/**
* Declared before the ':id' route so 'overview' is never parsed as a library id.
* Scan error text can name server paths, so this stays behind ManageLibraries even
* though the per-library stats route is viewer-level.
*/
@Get('overview')
@RequirePermission(Permission.ManageLibraries)
getOverview(@CurrentUser() user: RequestUser) {
return this.libraryService.getOverview(user);
}
@Get(':id')
@RequireLibraryAccess('viewer')
findOne(@Param('id', ParseIntPipe) id: number) {
@@ -144,6 +144,48 @@ describe('LibraryRepository', () => {
});
});
it('getStatsForLibraries aggregates every library in two queries', async () => {
const countChain = {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
groupBy: vi.fn().mockResolvedValue([
{ libraryId: 1, count: 3 },
{ libraryId: 2, count: 1 },
]),
}),
}),
};
const formatChain = {
from: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
groupBy: vi.fn().mockResolvedValue([
{ libraryId: 1, format: 'epub', count: 2, totalSize: '3000' },
{ libraryId: 1, format: 'pdf', count: 1, totalSize: '700' },
{ libraryId: 2, format: 'cbz', count: 1, totalSize: '500' },
]),
}),
}),
}),
};
db.select.mockReturnValueOnce(countChain as any).mockReturnValueOnce(formatChain as any);
const stats = await repo.getStatsForLibraries([1, 2, 3]);
expect(db.select).toHaveBeenCalledTimes(2);
expect(stats.get(1)).toEqual({ totalBooks: 3, totalSizeBytes: 3700, formatCounts: { epub: 2, pdf: 1 } });
expect(stats.get(2)).toEqual({ totalBooks: 1, totalSizeBytes: 500, formatCounts: { cbz: 1 } });
// A library with no present books still gets an entry, so the caller never has to guess.
expect(stats.get(3)).toEqual({ totalBooks: 0, totalSizeBytes: 0, formatCounts: {} });
});
it('getStatsForLibraries issues no query for an empty id list', async () => {
await expect(repo.getStatsForLibraries([])).resolves.toEqual(new Map());
expect(db.select).not.toHaveBeenCalled();
});
it('hasUserAccess returns false when no access row exists', async () => {
(db.query.userLibraryAccess.findFirst as vi.Mock).mockResolvedValue(undefined);
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, getTableColumns, inArray, isNotNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { AccessLevel, ContentFilterRules } from '@bookorbit/types';
import type { AccessLevel, ContentFilterRules, LibraryStats } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { DB } from '../../db';
@@ -178,6 +178,55 @@ export class LibraryRepository {
};
}
/**
* The same aggregates as getStats, for many libraries in two round trips instead of two per
* library. Book counts come from books alone so titles with no primary file still count, while
* sizes and formats need the bookFiles join.
*/
async getStatsForLibraries(libraryIds: number[]) {
if (libraryIds.length === 0) return new Map<number, LibraryStats>();
const [countRows, formatRows] = await Promise.all([
this.db
.select({ libraryId: books.libraryId, count: sql<number>`count(*)::int` })
.from(books)
.where(and(inArray(books.libraryId, libraryIds), eq(books.status, LIBRARY_BOOK_STATUS_PRESENT)))
.groupBy(books.libraryId),
this.db
.select({
libraryId: books.libraryId,
format: bookFiles.format,
count: sql<number>`count(*)::int`,
totalSize: sql<number>`coalesce(sum(${bookFiles.sizeBytes}), 0)::bigint`,
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(inArray(books.libraryId, libraryIds), eq(books.status, LIBRARY_BOOK_STATUS_PRESENT)))
.groupBy(books.libraryId, bookFiles.format),
]);
const sizeByLibrary = new Map<number, bigint>();
const formatsByLibrary = new Map<number, Record<string, number>>();
for (const row of formatRows) {
sizeByLibrary.set(row.libraryId, (sizeByLibrary.get(row.libraryId) ?? 0n) + toBigInt(row.totalSize));
if (!row.format) continue;
const formats = formatsByLibrary.get(row.libraryId);
if (formats) formats[row.format] = row.count;
else formatsByLibrary.set(row.libraryId, { [row.format]: row.count });
}
const countByLibrary = new Map(countRows.map((row) => [row.libraryId, row.count]));
const stats = new Map<number, LibraryStats>();
for (const libraryId of libraryIds) {
stats.set(libraryId, {
totalBooks: countByLibrary.get(libraryId) ?? 0,
totalSizeBytes: toSafeNumber(sizeByLibrary.get(libraryId) ?? 0n),
formatCounts: formatsByLibrary.get(libraryId) ?? {},
});
}
return stats;
}
async hasUserAccess(userId: number, libraryId: number): Promise<boolean> {
const row = await this.db.query.userLibraryAccess.findFirst({
where: and(eq(schema.userLibraryAccess.userId, userId), eq(schema.userLibraryAccess.libraryId, libraryId)),
@@ -49,6 +49,7 @@ describe('LibraryService', () => {
delete: vi.fn(),
findAllFolderPaths: vi.fn(),
getStats: vi.fn(),
getStatsForLibraries: vi.fn(),
updateDisplayOrders: vi.fn(),
getAccessWithUsers: vi.fn(),
grantAccess: vi.fn(),
@@ -57,7 +58,7 @@ describe('LibraryService', () => {
};
const config = { get: vi.fn().mockReturnValue('/books') };
const scannerService = { startScanAsync: vi.fn() };
const scannerService = { startScanAsync: vi.fn(), getLatestScans: vi.fn() };
const fileWatcherService = { startWatcher: vi.fn(), stopWatcher: vi.fn() };
const fileWriteService = {
findNonMissingPrimaryFilesByLibrary: vi.fn(),
@@ -513,6 +514,54 @@ describe('LibraryService', () => {
expect(result.paths[0]).toEqual(expect.objectContaining({ accessible: false, error: 'Path does not exist' }));
});
it('getOverview scopes to the caller and merges stats with the last scan', async () => {
libraryRepo.findAccessibleIdsForUser.mockResolvedValue([{ id: 1 }, { id: 2 }]);
libraryRepo.getStatsForLibraries.mockResolvedValue(
new Map([
[1, { totalBooks: 381, totalSizeBytes: 727, formatCounts: { epub: 305 } }],
[2, { totalBooks: 0, totalSizeBytes: 0, formatCounts: {} }],
]),
);
const lastScan = {
status: 'completed' as const,
triggeredBy: 'manual' as const,
startedAt: '2026-08-23T00:00:00.000Z',
completedAt: '2026-08-23T00:01:00.000Z',
addedCount: 3,
updatedCount: 0,
missingCount: 0,
errorMessage: null,
};
scannerService.getLatestScans.mockResolvedValue(new Map([[1, lastScan]]));
const result = await service.getOverview({ id: 7, isSuperuser: false } as any);
expect(libraryRepo.findAccessibleIdsForUser).toHaveBeenCalledWith(7);
expect(libraryRepo.findAllIds).not.toHaveBeenCalled();
expect(libraryRepo.getStatsForLibraries).toHaveBeenCalledWith([1, 2]);
expect(result).toEqual([
{ libraryId: 1, totalBooks: 381, totalSizeBytes: 727, formatCounts: { epub: 305 }, lastScan },
{ libraryId: 2, totalBooks: 0, totalSizeBytes: 0, formatCounts: {}, lastScan: null },
]);
});
it('getOverview returns nothing and issues no queries when the caller has no libraries', async () => {
libraryRepo.findAllIds.mockResolvedValue([]);
const result = await service.getOverview({ id: 1, isSuperuser: true } as any);
expect(result).toEqual([]);
expect(libraryRepo.getStatsForLibraries).not.toHaveBeenCalled();
expect(scannerService.getLatestScans).not.toHaveBeenCalled();
});
it('getOverview maps repository overflow errors to InternalServerErrorException', async () => {
libraryRepo.findAllIds.mockResolvedValue([{ id: 1 }]);
libraryRepo.getStatsForLibraries.mockRejectedValue(new RangeError('overflow'));
await expect(service.getOverview({ id: 1, isSuperuser: true } as any)).rejects.toBeInstanceOf(InternalServerErrorException);
});
it('getStats maps repository overflow errors to InternalServerErrorException', async () => {
libraryRepo.findById.mockResolvedValue([{ id: 1, name: 'L' }]);
libraryRepo.getStats.mockRejectedValue(new RangeError('overflow'));
+30 -1
View File
@@ -12,7 +12,7 @@ import { readdir, rm, stat } from 'fs/promises';
import { join } from 'path';
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types';
import type { AccessLevel, LibraryFileSyncProgressEvent, OrganizationMode, WriteResult } from '@bookorbit/types';
import type { AccessLevel, LibraryFileSyncProgressEvent, LibraryOverviewEntry, OrganizationMode, WriteResult } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { normalizeIconValue } from '../../common/utils/icon-value.utils';
import type { RequestUser } from '../../common/types/request-user';
@@ -295,6 +295,35 @@ export class LibraryService {
return { paths: results, totalFiles };
}
/**
* Everything the libraries settings page needs about every library the caller can reach, in one
* request: the stats that used to cost a fan-out of one call per library, plus each library's
* most recent scan.
*/
async getOverview(user: RequestUser): Promise<LibraryOverviewEntry[]> {
const libraryIds = await this.findAccessibleLibraryIds(user);
if (libraryIds.length === 0) return [];
let stats: Awaited<ReturnType<LibraryRepository['getStatsForLibraries']>>;
try {
stats = await this.libraryRepo.getStatsForLibraries(libraryIds);
} catch (err) {
if (err instanceof RangeError) {
throw new InternalServerErrorException('Library stats exceed supported size range');
}
throw err;
}
const lastScans = await this.scannerService.getLatestScans(libraryIds);
return libraryIds.map((libraryId) => ({
libraryId,
totalBooks: stats.get(libraryId)?.totalBooks ?? 0,
totalSizeBytes: stats.get(libraryId)?.totalSizeBytes ?? 0,
formatCounts: stats.get(libraryId)?.formatCounts ?? {},
lastScan: lastScans.get(libraryId) ?? null,
}));
}
async getStats(libraryId: number) {
const [existing] = await this.libraryRepo.findById(libraryId);
if (!existing) throw new NotFoundException('Library not found');
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import type { MetadataScoreWeights } from '@bookorbit/types';
import type { MetadataScoreRecalculationState, MetadataScoreWeights } from '@bookorbit/types';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { MetadataScoreRepository } from './metadata-score.repository';
import { MetadataScoreScorer, type ScoreData } from './metadata-score.scorer';
@@ -16,7 +16,7 @@ export enum MetadataRecalculationTrigger {
WEIGHTS_UPDATE = 'weights_update',
}
export type MetadataRecalculationState = 'idle' | 'running' | 'completed' | 'failed';
export type MetadataRecalculationState = MetadataScoreRecalculationState;
export type MetadataRecalculationStatus = {
state: MetadataRecalculationState;
@@ -10,6 +10,15 @@ describe('ScannerController', () => {
expect(scannerService.startScan).toHaveBeenCalledWith(42, 'manual');
});
it('delegates scan history requests with the caller-supplied limit', () => {
const scannerService = { startScan: vi.fn(), refreshCovers: vi.fn(), getScanHistory: vi.fn().mockResolvedValue([]) } as any;
const controller = new ScannerController(scannerService);
void controller.scanHistory(7, 5);
expect(scannerService.getScanHistory).toHaveBeenCalledWith(7, 5);
});
it('delegates cover refresh requests to ScannerService.refreshCovers', () => {
const scannerService = { startScan: vi.fn(), refreshCovers: vi.fn().mockResolvedValue({ queued: 3 }) } as any;
const controller = new ScannerController(scannerService);
@@ -1,6 +1,7 @@
import { Permission } from '@bookorbit/types';
import { Controller, HttpCode, HttpStatus, Param, ParseIntPipe, Post } from '@nestjs/common';
import { Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { RequireLibraryAccess } from '../../common/decorators/require-library-access.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { ScannerService } from './scanner.service';
@@ -15,6 +16,17 @@ export class ScannerController {
return this.scannerService.startScan(libraryId, 'manual');
}
/**
* Scan errors can name server paths, so this needs access to the library itself and not just the
* global permission to manage libraries.
*/
@Get('libraries/:id/scan-history')
@RequirePermission(Permission.ManageLibraries)
@RequireLibraryAccess('viewer')
scanHistory(@Param('id', ParseIntPipe) libraryId: number, @Query('limit', new DefaultValuePipe(5), ParseIntPipe) limit: number) {
return this.scannerService.getScanHistory(libraryId, limit);
}
@Post('libraries/:id/refresh-covers')
@RequirePermission(Permission.ManageLibraries)
@HttpCode(HttpStatus.ACCEPTED)
@@ -374,4 +374,20 @@ describe('ScannerRepository', () => {
await repo.clearDirScanState(21);
expect(db.delete).toHaveBeenCalledTimes(1);
});
it('findRecentScanJobs reads the newest jobs for one library', async () => {
const { repo, queues, db } = makeRepo();
queues.select.push([{ id: 2 }, { id: 1 }]);
await expect(repo.findRecentScanJobs(4, 5)).resolves.toEqual([{ id: 2 }, { id: 1 }]);
expect(db.select).toHaveBeenCalledTimes(1);
});
it('findLatestScanJobs issues no query for an empty id list', async () => {
const { repo, db } = makeRepo();
db.selectDistinctOn = vi.fn();
await expect(repo.findLatestScanJobs([])).resolves.toEqual([]);
expect(db.selectDistinctOn).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, inArray, lt, ne, or, sql } from 'drizzle-orm';
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -52,6 +52,46 @@ export class ScannerRepository {
await this.db.update(scanJobs).set({ status: 'failed', errorMessage, completedAt: new Date() }).where(eq(scanJobs.status, 'running'));
}
/** Newest scan jobs for one library, for the history panel. */
async findRecentScanJobs(libraryId: number, limit: number) {
return this.db
.select({
id: scanJobs.id,
status: scanJobs.status,
triggeredBy: scanJobs.triggeredBy,
startedAt: scanJobs.startedAt,
completedAt: scanJobs.completedAt,
addedCount: scanJobs.addedCount,
updatedCount: scanJobs.updatedCount,
missingCount: scanJobs.missingCount,
errorMessage: scanJobs.errorMessage,
})
.from(scanJobs)
.where(eq(scanJobs.libraryId, libraryId))
.orderBy(desc(scanJobs.startedAt))
.limit(limit);
}
/** Most recent scan job per library, in one round trip. Libraries never scanned are simply absent. */
async findLatestScanJobs(libraryIds: number[]) {
if (libraryIds.length === 0) return [];
return this.db
.selectDistinctOn([scanJobs.libraryId], {
libraryId: scanJobs.libraryId,
status: scanJobs.status,
triggeredBy: scanJobs.triggeredBy,
startedAt: scanJobs.startedAt,
completedAt: scanJobs.completedAt,
addedCount: scanJobs.addedCount,
updatedCount: scanJobs.updatedCount,
missingCount: scanJobs.missingCount,
errorMessage: scanJobs.errorMessage,
})
.from(scanJobs)
.where(inArray(scanJobs.libraryId, libraryIds))
.orderBy(scanJobs.libraryId, desc(scanJobs.startedAt));
}
// ── Library Folders ────────────────────────────────────────────────────────
async findLibraryFolders(libraryId: number) {
@@ -75,6 +75,8 @@ function makeBookFile(overrides: Record<string, unknown> = {}) {
function makeRepo(overrides: Record<string, unknown> = {}) {
return {
failAllRunningJobs: vi.fn().mockResolvedValue(undefined),
findRecentScanJobs: vi.fn().mockResolvedValue([]),
findLatestScanJobs: vi.fn().mockResolvedValue([]),
findLibraryFolders: vi.fn().mockResolvedValue([{ id: 1, path: '/library', libraryId: 1 }]),
findLibrarySettings: vi.fn().mockResolvedValue({
allowedFormats: [],
@@ -201,6 +203,67 @@ beforeEach(() => {
// ── startScan — precondition checks ──────────────────────────────────────────
describe('scan history', () => {
const row = {
id: 3,
status: 'completed',
triggeredBy: 'watcher',
startedAt: new Date('2026-08-23T10:00:00.000Z'),
completedAt: new Date('2026-08-23T10:00:12.000Z'),
addedCount: 4,
updatedCount: 1,
missingCount: 0,
errorMessage: null,
};
it('serialises timestamps and passes the requested limit through', async () => {
const repo = makeRepo({ findRecentScanJobs: vi.fn().mockResolvedValue([row]) });
const { service } = makeService(repo);
await expect(service.getScanHistory(7, 5)).resolves.toEqual([
{
id: 3,
status: 'completed',
triggeredBy: 'watcher',
startedAt: '2026-08-23T10:00:00.000Z',
completedAt: '2026-08-23T10:00:12.000Z',
addedCount: 4,
updatedCount: 1,
missingCount: 0,
errorMessage: null,
},
]);
expect(repo.findRecentScanJobs).toHaveBeenCalledWith(7, 5);
});
it('caps an oversized limit so a caller cannot request an unbounded page', async () => {
const repo = makeRepo();
const { service } = makeService(repo);
await service.getScanHistory(7, 5000);
expect(repo.findRecentScanJobs).toHaveBeenCalledWith(7, 10);
});
it('floors a zero or negative limit so it never reaches SQL', async () => {
const repo = makeRepo();
const { service } = makeService(repo);
await service.getScanHistory(7, -5);
await service.getScanHistory(7, 0);
expect(repo.findRecentScanJobs).toHaveBeenNthCalledWith(1, 7, 1);
expect(repo.findRecentScanJobs).toHaveBeenNthCalledWith(2, 7, 1);
});
it('leaves a never-scanned library with an empty history', async () => {
const repo = makeRepo();
const { service } = makeService(repo);
await expect(service.getScanHistory(7)).resolves.toEqual([]);
});
});
describe('startScan — preconditions', () => {
it('throws ConflictException when a scan is already running for the library', async () => {
const repo = makeRepo();
@@ -8,8 +8,12 @@ import type {
BookTransferredEvent,
CoverRefreshedEvent,
CoverRefreshProgressEvent,
LibraryLastScan,
LibraryScanHistoryEntry,
ScanBooksAddedEvent,
ScanJobStatus,
ScanProgressEvent,
ScanTriggeredBy,
} from '@bookorbit/types';
import { NotificationType } from '@bookorbit/types';
import { AchievementEventsService, ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED } from '../achievement/achievement-events.service';
@@ -185,6 +189,8 @@ function formatBooksRestoredMessage(count: number): string {
return count === 1 ? '1 book was restored on disk.' : `${count} books were restored on disk.`;
}
const SCAN_HISTORY_LIMIT = 10;
@Injectable()
export class ScannerService implements OnApplicationBootstrap {
private readonly logger = new Logger(ScannerService.name);
@@ -199,6 +205,47 @@ export class ScannerService implements OnApplicationBootstrap {
@Optional() private readonly achievementEvents?: AchievementEventsService,
) {}
/**
* Most recent scan per library, keyed by library id. Exposed here rather than on the repository
* so the library module keeps talking to the scanner through its service.
*/
async getLatestScans(libraryIds: number[]): Promise<Map<number, LibraryLastScan>> {
const rows = await this.scannerRepo.findLatestScanJobs(libraryIds);
return new Map(
rows.map((row) => [
row.libraryId,
{
status: row.status as ScanJobStatus,
triggeredBy: row.triggeredBy as ScanTriggeredBy,
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt?.toISOString() ?? null,
addedCount: row.addedCount,
updatedCount: row.updatedCount,
missingCount: row.missingCount,
errorMessage: row.errorMessage,
},
]),
);
}
/** Newest-first scan history for one library, capped so the detail panel stays bounded. */
async getScanHistory(libraryId: number, limit = SCAN_HISTORY_LIMIT): Promise<LibraryScanHistoryEntry[]> {
// Clamped at both ends: a negative limit would reach SQL as a negative LIMIT.
const safeLimit = Math.min(Math.max(Math.trunc(limit) || 1, 1), SCAN_HISTORY_LIMIT);
const rows = await this.scannerRepo.findRecentScanJobs(libraryId, safeLimit);
return rows.map((row) => ({
id: row.id,
status: row.status as ScanJobStatus,
triggeredBy: row.triggeredBy as ScanTriggeredBy,
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt?.toISOString() ?? null,
addedCount: row.addedCount,
updatedCount: row.updatedCount,
missingCount: row.missingCount,
errorMessage: row.errorMessage,
}));
}
// ── Live book emission buffer ──────────────────────────────────────────────
private readonly bookEmitBuffer = new Map<number, number[]>();
private readonly bookEmitTimers = new Map<number, ReturnType<typeof setTimeout>>();
@@ -59,6 +59,18 @@ export class UserController {
}
// Must be before :id routes to avoid named segments being parsed as ints
@Get('summary')
@RequirePermission(Permission.ManageUsers)
summary() {
return this.userService.summary();
}
@Get('attention')
@RequirePermission(Permission.ManageUsers)
findNeedingAttention() {
return this.userService.findNeedingAttention();
}
@Get('assignable')
@RequirePermission(Permission.ManageLibraries)
findAssignable() {
@@ -2,6 +2,7 @@ vi.mock('drizzle-orm', () => ({
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
asc: vi.fn((value: unknown) => ({ op: 'asc', value })),
count: vi.fn(() => ({ op: 'count' })),
desc: vi.fn((value: unknown) => ({ op: 'desc', value })),
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
ilike: vi.fn((left: unknown, right: unknown) => ({ op: 'ilike', left, right })),
inArray: vi.fn((left: unknown, right: unknown) => ({ op: 'inArray', left, right })),
@@ -161,12 +162,17 @@ describe('UserRepository', () => {
const genreFilterWhere = vi.fn().mockResolvedValue([]);
const genreFilterFrom = vi.fn().mockReturnValue({ where: genreFilterWhere });
const libraryAccessGroupBy = vi.fn().mockResolvedValue([{ userId: 10, granted: 3 }]);
const libraryAccessWhere = vi.fn().mockReturnValue({ groupBy: libraryAccessGroupBy });
const libraryAccessFrom = vi.fn().mockReturnValue({ where: libraryAccessWhere });
select
.mockReturnValueOnce({ from: idFrom })
.mockReturnValueOnce({ from: countFrom })
.mockReturnValueOnce({ from: rowsFrom })
.mockReturnValueOnce({ from: tagFilterFrom })
.mockReturnValueOnce({ from: genreFilterFrom });
.mockReturnValueOnce({ from: genreFilterFrom })
.mockReturnValueOnce({ from: libraryAccessFrom });
const result = await repo.findAll({ page: 0, pageSize: 25, sortBy: 'username', sortDir: 'asc' });
@@ -177,9 +183,100 @@ describe('UserRepository', () => {
username: 'alice',
provisioningMethod: 'local',
permissions: ['library_download', 'kobo_sync'],
libraryAccessCount: 3,
});
});
it("summary counts every account regardless of the caller's filter", async () => {
const summaryFrom = vi.fn().mockResolvedValue([{ total: 9, admins: 1, active: 8, inactive: 1, attention: 3 }]);
select.mockReturnValueOnce({ from: summaryFrom });
await expect(repo.summary()).resolves.toEqual({ total: 9, admins: 1, active: 8, inactive: 1, attention: 3 });
expect(summaryFrom).toHaveBeenCalledWith(schema.users);
});
it('summary falls back to zeroes when the aggregate returns no row', async () => {
select.mockReturnValueOnce({ from: vi.fn().mockResolvedValue([]) });
await expect(repo.summary()).resolves.toEqual({ total: 0, admins: 0, active: 0, inactive: 0, attention: 0 });
});
it('findNeedingAttention ranks locked above never-signed-in above default-password', async () => {
const later = new Date(Date.now() + 60 * 60_000);
const rows = [
{
id: 1,
username: 'tom',
name: 'Tom',
avatarUrl: null,
provisioningMethod: 'local',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
lockedUntil: later,
isDefaultPassword: true,
lastAuthenticatedAt: null,
},
{
id: 2,
username: 'jules',
name: 'Jules',
avatarUrl: null,
provisioningMethod: 'local',
createdAt: new Date('2026-02-01T00:00:00.000Z'),
lockedUntil: null,
isDefaultPassword: true,
lastAuthenticatedAt: null,
},
{
id: 3,
username: 'test1',
name: 'test1',
avatarUrl: null,
provisioningMethod: 'local',
createdAt: new Date('2026-03-01T00:00:00.000Z'),
lockedUntil: null,
isDefaultPassword: true,
lastAuthenticatedAt: new Date('2026-03-02T00:00:00.000Z'),
},
];
const rowsLimit = vi.fn().mockResolvedValue(rows);
const rowsOrderBy = vi.fn().mockReturnValue({ limit: rowsLimit });
const rowsWhere = vi.fn().mockReturnValue({ orderBy: rowsOrderBy });
const rowsFrom = vi.fn().mockReturnValue({ where: rowsWhere });
const tokenExpiry = new Date('2026-03-05T00:00:00.000Z');
const tokenOrderBy = vi.fn().mockResolvedValue([
{ userId: 2, expiresAt: tokenExpiry },
{ userId: 2, expiresAt: new Date('2020-01-01T00:00:00.000Z') },
]);
const tokenWhere = vi.fn().mockReturnValue({ orderBy: tokenOrderBy });
const tokenFrom = vi.fn().mockReturnValue({ where: tokenWhere });
select.mockReturnValueOnce({ from: rowsFrom }).mockReturnValueOnce({ from: tokenFrom });
const result = await repo.findNeedingAttention(8);
// an expired lock loses to nothing, but a live one outranks both other reasons
expect(result.map((r) => [r.username, r.reason])).toEqual([
['tom', 'locked'],
['jules', 'neverSignedIn'],
['test1', 'defaultPassword'],
]);
// only the newest unused link is reported, and only for the account that has one
expect(result[1].resetLinkExpiresAt).toBe(tokenExpiry);
expect(result[0].resetLinkExpiresAt).toBeNull();
expect(rowsLimit).toHaveBeenCalledWith(8);
});
it('findNeedingAttention skips the reset-token lookup when nothing is flagged', async () => {
const rowsLimit = vi.fn().mockResolvedValue([]);
const rowsOrderBy = vi.fn().mockReturnValue({ limit: rowsLimit });
const rowsWhere = vi.fn().mockReturnValue({ orderBy: rowsOrderBy });
select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: rowsWhere }) });
await expect(repo.findNeedingAttention(8)).resolves.toEqual([]);
expect(select).toHaveBeenCalledTimes(1);
});
it('findAll filters by search across name, username and email and escapes wildcards', async () => {
const idOffset = vi.fn().mockResolvedValue([]);
const idLimit = vi.fn().mockReturnValue({ offset: idOffset });
+130 -3
View File
@@ -1,18 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { and, asc, count, eq, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import { and, asc, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { hash } from 'bcryptjs';
import { Permission } from '@bookorbit/types';
import type { UserListSortDirection, UserListSortField, UserListState } from '@bookorbit/types';
import type { UserAttentionReason, UserListSortDirection, UserListSortField, UserListState, UserListSummary } from '@bookorbit/types';
import { RequestUser } from '../../common/types/request-user';
import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
/**
* An account needs an administrator's attention when it is locked out, is still on the
* password it was created with, or was provisioned and never arrived. Disabled accounts are
* excluded: there is nothing to repair on an account nobody can sign in to.
*/
function attentionCondition(): SQL {
return and(
eq(schema.users.active, true),
or(
sql`${schema.users.lockedUntil} is not null and ${schema.users.lockedUntil} > now()`,
eq(schema.users.isDefaultPassword, true),
isNull(schema.users.lastAuthenticatedAt),
),
)!;
}
/**
* One reason per account, most urgent first. `neverSignedIn` outranks `defaultPassword`
* because every freshly created account carries the default-password flag until its reset
* link is used - reporting that instead would hide every invite that never landed.
*/
function attentionReason(row: { lockedUntil: Date | null; lastAuthenticatedAt: Date | null }): UserAttentionReason {
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) return 'locked';
if (row.lastAuthenticatedAt === null) return 'neverSignedIn';
return 'defaultPassword';
}
/** Same ranking as `attentionReason`, expressed for the ORDER BY. */
const attentionRank = sql`case
when ${schema.users.lockedUntil} is not null and ${schema.users.lockedUntil} > now() then 0
when ${schema.users.lastAuthenticatedAt} is null then 1
else 2 end`;
export interface UserListQuery {
page: number;
pageSize: number;
@@ -36,7 +69,9 @@ export class UserRepository {
const sortColumn = {
username: schema.users.username,
name: schema.users.name,
email: schema.users.email,
createdAt: schema.users.createdAt,
lastActive: schema.users.lastAuthenticatedAt,
}[query.sortBy];
const direction = query.sortDir === 'desc' ? sql.raw('desc') : sql.raw('asc');
@@ -65,8 +100,11 @@ export class UserRepository {
isSuperuser: schema.users.isSuperuser,
isDefaultPassword: schema.users.isDefaultPassword,
lockedUntil: schema.users.lockedUntil,
failedLoginAttempts: schema.users.failedLoginAttempts,
provisioningMethod: schema.users.provisioningMethod,
avatarUrl: schema.users.avatarUrl,
createdAt: schema.users.createdAt,
lastAuthenticatedAt: schema.users.lastAuthenticatedAt,
permissionName: schema.userPermissions.permissionName,
})
.from(schema.users)
@@ -83,10 +121,14 @@ export class UserRepository {
isSuperuser: boolean;
isDefaultPassword: boolean;
lockedUntil: Date | null;
failedLoginAttempts: number;
provisioningMethod: string;
avatarUrl: string | null;
createdAt: Date;
lastAuthenticatedAt: Date | null;
permissions: Permission[];
hasContentFilters: boolean;
libraryAccessCount: number;
};
const usersMap = new Map<number, UserListItem>();
@@ -101,10 +143,14 @@ export class UserRepository {
isSuperuser: row.isSuperuser,
isDefaultPassword: row.isDefaultPassword,
lockedUntil: row.lockedUntil,
failedLoginAttempts: row.failedLoginAttempts,
provisioningMethod: row.provisioningMethod,
avatarUrl: row.avatarUrl,
createdAt: row.createdAt,
lastAuthenticatedAt: row.lastAuthenticatedAt,
permissions: [],
hasContentFilters: false,
libraryAccessCount: 0,
});
}
if (row.permissionName) {
@@ -113,7 +159,7 @@ export class UserRepository {
}
if (userIds.length > 0) {
const [tagFilterUsers, genreFilterUsers] = await Promise.all([
const [tagFilterUsers, genreFilterUsers, libraryAccessCounts] = await Promise.all([
this.db
.select({ userId: schema.userContentFilterTags.userId })
.from(schema.userContentFilterTags)
@@ -122,6 +168,11 @@ export class UserRepository {
.select({ userId: schema.userContentFilterGenres.userId })
.from(schema.userContentFilterGenres)
.where(inArray(schema.userContentFilterGenres.userId, userIds)),
this.db
.select({ userId: schema.userLibraryAccess.userId, granted: count() })
.from(schema.userLibraryAccess)
.where(inArray(schema.userLibraryAccess.userId, userIds))
.groupBy(schema.userLibraryAccess.userId),
]);
const usersWithFilters = new Set<number>();
for (const r of tagFilterUsers) usersWithFilters.add(r.userId);
@@ -129,12 +180,86 @@ export class UserRepository {
for (const [id, user] of usersMap) {
if (usersWithFilters.has(id)) user.hasContentFilters = true;
}
for (const row of libraryAccessCounts) {
const user = usersMap.get(row.userId);
if (user) user.libraryAccessCount = Number(row.granted);
}
}
const users = userIds.map((id) => usersMap.get(id)).filter((user): user is UserListItem => user !== undefined);
return { users, total: normalizedTotal };
}
/** Counts across every account, deliberately ignoring the caller's current filter. */
async summary(): Promise<UserListSummary> {
const [row] = await this.db
.select({
total: sql<number>`count(*)::int`,
admins: sql<number>`count(*) filter (where ${schema.users.isSuperuser} = true)::int`,
active: sql<number>`count(*) filter (where ${schema.users.active} = true)::int`,
inactive: sql<number>`count(*) filter (where ${schema.users.active} = false)::int`,
attention: sql<number>`count(*) filter (where ${attentionCondition()})::int`,
})
.from(schema.users);
return row ?? { total: 0, admins: 0, active: 0, inactive: 0, attention: 0 };
}
async findNeedingAttention(limit: number) {
const rows = await this.db
.select({
id: schema.users.id,
username: schema.users.username,
name: schema.users.name,
avatarUrl: schema.users.avatarUrl,
provisioningMethod: schema.users.provisioningMethod,
createdAt: schema.users.createdAt,
lockedUntil: schema.users.lockedUntil,
isDefaultPassword: schema.users.isDefaultPassword,
lastAuthenticatedAt: schema.users.lastAuthenticatedAt,
})
.from(schema.users)
.where(attentionCondition())
.orderBy(attentionRank, asc(schema.users.createdAt))
.limit(limit);
if (rows.length === 0) return [];
// Only the newest unused link per account matters; the set is bounded by `limit`.
const tokens = await this.db
.select({
userId: schema.passwordResetTokens.userId,
expiresAt: schema.passwordResetTokens.expiresAt,
})
.from(schema.passwordResetTokens)
.where(
and(
inArray(
schema.passwordResetTokens.userId,
rows.map((r) => r.id),
),
isNull(schema.passwordResetTokens.usedAt),
),
)
.orderBy(desc(schema.passwordResetTokens.createdAt));
const latestLink = new Map<number, Date>();
for (const token of tokens) {
if (!latestLink.has(token.userId)) latestLink.set(token.userId, token.expiresAt);
}
return rows.map((row) => ({
id: row.id,
username: row.username,
name: row.name,
avatarUrl: row.avatarUrl,
provisioningMethod: row.provisioningMethod,
createdAt: row.createdAt,
lockedUntil: row.lockedUntil,
reason: attentionReason(row),
resetLinkExpiresAt: latestLink.get(row.id) ?? null,
}));
}
private buildListFilters(query: UserListQuery): SQL[] {
const filters: SQL[] = [];
@@ -152,6 +277,8 @@ export class UserRepository {
filters.push(eq(schema.users.active, true));
} else if (query.state === 'inactive') {
filters.push(eq(schema.users.active, false));
} else if (query.state === 'attention') {
filters.push(attentionCondition());
}
return filters;
+30 -1
View File
@@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto';
import { Permission } from '@bookorbit/types';
import type { UserSettings } from '@bookorbit/types';
import type { ProvisioningMethod, UserAttentionResponse, UserListSummary, UserSettings } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { ContentFilterRepository } from './content-filter.repository';
@@ -18,6 +18,9 @@ import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse
import { UserRepository, type UserListQuery } from './user.repository';
import { AppSettingsService } from '../app-settings/app-settings.service';
/** The band is a to-do list, not a second roster. */
const ATTENTION_BAND_LIMIT = 8;
@Injectable()
export class UserService {
private readonly achievementEnabledCache = new Map<number, { enabled: boolean; expiresAt: number }>();
@@ -87,6 +90,32 @@ export class UserService {
return this.userRepo.findAssignable();
}
summary(): Promise<UserListSummary> {
return this.userRepo.summary();
}
/**
* The roster's attention band. Capped because the band is a to-do list, not a second
* roster; `total` tells the UI when there is more behind the `attention` filter.
*/
async findNeedingAttention(): Promise<UserAttentionResponse> {
const [rows, summary] = await Promise.all([this.userRepo.findNeedingAttention(ATTENTION_BAND_LIMIT), this.userRepo.summary()]);
return {
items: rows.map((row) => ({
id: row.id,
username: row.username,
name: row.name,
avatarUrl: row.avatarUrl,
provisioningMethod: row.provisioningMethod as ProvisioningMethod,
reason: row.reason,
lockedUntil: row.lockedUntil?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
resetLinkExpiresAt: row.resetLinkExpiresAt?.toISOString() ?? null,
})),
total: summary.attention,
};
}
async findById(id: number) {
const user = await this.userRepo.findByIdWithPermissions(id);
if (!user) throw new NotFoundException('User not found');
@@ -479,6 +479,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/library/library.controller.ts",
"className": "LibraryController",
"methodName": "getOverview",
"httpMethod": "GET",
"path": "/libraries/overview",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/library/library.controller.ts",
"className": "LibraryController",
@@ -587,6 +587,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/scanner/scanner.controller.ts",
"className": "ScannerController",
"methodName": "scanHistory",
"httpMethod": "GET",
"path": "/scanner/libraries/:id/scan-history",
"permissions": ["Permission.ManageLibraries"],
"libraryAccess": ["viewer"],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/scanner/scanner.controller.ts",
"className": "ScannerController",