diff --git a/.verify/screenshot.png b/.verify/screenshot.png index 1e42c599..a249441d 100644 Binary files a/.verify/screenshot.png and b/.verify/screenshot.png differ diff --git a/Directory.Build.props b/Directory.Build.props index 6a74e506..869a1b4c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,10 @@ - $(WarningsNotAsErrors);SM0012;SM0039;NU5104 + + $(WarningsNotAsErrors);SM0012;SM0039;NU5104;NU1903 true true diff --git a/Directory.Packages.props b/Directory.Packages.props index 76f08c71..3cc49ba0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,12 @@ true + + true @@ -22,7 +28,7 @@ - + @@ -97,5 +103,17 @@ Include="Microsoft.SemanticKernel.Connectors.Postgres" Version="1.51.0-preview" /> + + + + + + + + diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/AuditQueryRequest.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/AuditQueryRequest.cs index c994b8e1..de971dec 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/AuditQueryRequest.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs.Contracts/AuditQueryRequest.cs @@ -14,6 +14,15 @@ public class AuditQueryRequest public string? SearchText { get; set; } public int? Page { get; set; } public int? PageSize { get; set; } + + /// + /// Opt-in keyset cursor. When set (and the default Timestamp-descending ordering + /// is used), the page is fetched via WHERE Timestamp < Before instead of + /// OFFSET, skipping the per-request COUNT(*) and the O(offset) row-skip that + /// make deep pages slow. Pass the Timestamp of the last item from the + /// previous page to fetch the next one. + /// + public DateTimeOffset? Before { get; set; } public string? SortBy { get; set; } public bool? SortDescending { get; set; } diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs index eb39a65c..5d651f70 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogService.cs @@ -13,16 +13,60 @@ public sealed partial class AuditLogService( ILogger logger ) : IAuditLogContracts { + private static readonly string[] CustomSortColumns = + [ + "UserId", + "Module", + "Path", + "StatusCode", + "DurationMs", + ]; + public async Task> QueryAsync(AuditQueryRequest request) { var query = BuildQuery(request); - var totalCount = await query.CountAsync(); - var sortBy = request.EffectiveSortBy; var sortDesc = request.EffectiveSortDescending; var page = request.EffectivePage; var pageSize = request.EffectivePageSize; + var provider = DatabaseProviderDetector.Detect( + dbOptions.Value.DefaultConnection, + dbOptions.Value.Provider + ); + var isDefaultSort = sortDesc && !CustomSortColumns.Contains(sortBy); + + // Keyset (cursor) pagination: when the caller supplies a cursor and uses the + // default Timestamp-descending ordering, page via WHERE Timestamp < cursor + // instead of OFFSET. This avoids both the per-request COUNT(*) and the O(offset) + // row-skip that make deep pages slow, using the IX_AuditEntries_Timestamp index. + // The ordering (Timestamp DESC, Id DESC) matches the offset default below, so a + // cursor page is a true continuation of the offset first page. SQLite cannot + // ORDER BY DateTimeOffset, so keyset only applies on managed providers (SQLite + // requests fall through to Id-ordered offset paging). TotalCount is -1 (not + // computed) in cursor mode. Note: rows sharing the exact boundary Timestamp can + // be skipped (strict <); with microsecond-resolution UtcNow this is rare. + if (request.Before.HasValue && isDefaultSort && provider != DatabaseProvider.Sqlite) + { + var cursor = request.Before.Value; + var keysetItems = await query + .Where(e => e.Timestamp < cursor) + .OrderByDescending(e => e.Timestamp) + .ThenByDescending(e => e.Id) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(); + + return new PagedResult + { + Items = keysetItems, + TotalCount = -1, + Page = page, + PageSize = pageSize, + }; + } + + var totalCount = await query.CountAsync(); // Apply sorting query = sortBy switch @@ -40,9 +84,16 @@ public async Task> QueryAsync(AuditQueryRequest request) "DurationMs" => sortDesc ? query.OrderByDescending(e => e.DurationMs) : query.OrderBy(e => e.DurationMs), - // SQLite does not support DateTimeOffset in ORDER BY, so sort by Id - // (auto-increment, correlates with insertion order) as a fallback. - _ => sortDesc ? query.OrderByDescending(e => e.Id) : query.OrderBy(e => e.Id), + // Default (Timestamp) sort. SQLite cannot ORDER BY DateTimeOffset, so it + // falls back to Id (auto-increment, insertion order). Managed providers order + // by Timestamp with Id as a tiebreaker — this is the ordering keyset cursor + // pagination continues, so the offset first page and cursor pages agree. + _ when provider == DatabaseProvider.Sqlite => sortDesc + ? query.OrderByDescending(e => e.Id) + : query.OrderBy(e => e.Id), + _ => sortDesc + ? query.OrderByDescending(e => e.Timestamp).ThenByDescending(e => e.Id) + : query.OrderBy(e => e.Timestamp).ThenBy(e => e.Id), }; var items = await query diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/en.json b/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/en.json index 8f5f1df4..0055e837 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/en.json +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/en.json @@ -1,6 +1,9 @@ { "Browse.Title": "Audit Logs", "Browse.TotalEntries": "{count} total entries", + "Browse.Newest": "Newest", + "Browse.NextPage": "Next page", + "Browse.PrevPage": "Previous page", "Browse.ExportCsv": "Export CSV", "Browse.ExportJson": "Export JSON", "Browse.QuickRange": "Quick range:", @@ -30,7 +33,6 @@ "Browse.ColPath": "Path", "Browse.ColStatus": "Status", "Browse.ColDuration": "Duration", - "Browse.Showing": "Showing {start}\u2013{end} of {total} entries", "Dashboard.Title": "Audit Dashboard", "Dashboard.Description": "System activity overview and metrics", "Dashboard.FilterFrom": "From", diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/keys.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/keys.ts index 6d9a2cf0..d896e591 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/keys.ts +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Locales/keys.ts @@ -28,8 +28,10 @@ export const AuditLogsKeys = { FilterSourceAll: 'Browse.FilterSourceAll', FilterSourcePlaceholder: 'Browse.FilterSourcePlaceholder', FilterTo: 'Browse.FilterTo', + Newest: 'Browse.Newest', + NextPage: 'Browse.NextPage', + PrevPage: 'Browse.PrevPage', QuickRange: 'Browse.QuickRange', - Showing: 'Browse.Showing', Title: 'Browse.Title', TotalEntries: 'Browse.TotalEntries', }, diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx index 8f3b7e4b..6017921e 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx @@ -27,7 +27,25 @@ interface Props { filters: AuditQueryRequest; } -function buildFilterParams(f: Partial, page?: number): Record { +// Cursor trail persisted across Inertia (server-rendered) navigations within the +// same browser session, so "Previous" can return to an already-visited cursor. +const CURSOR_STACK_KEY = 'auditlogs-browse-cursors'; +function readCursorStack(): string[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.sessionStorage.getItem(CURSOR_STACK_KEY); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } +} +function writeCursorStack(stack: string[]): void { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(CURSOR_STACK_KEY, JSON.stringify(stack)); + } +} + +function buildFilterParams(f: Partial, before?: string): Record { const params: Record = {}; if (f.from) params.from = String(f.from); if (f.to) params.to = String(f.to); @@ -36,7 +54,8 @@ function buildFilterParams(f: Partial, page?: number): Record if (f.module) params.module = f.module; if (f.searchText) params.searchText = f.searchText; - if (page && page > 1) params.page = String(page); + // Keyset cursor: fetch the page of entries older than `before`. + if (before) params.before = before; return params; } @@ -49,8 +68,14 @@ export default function Browse({ result, filters }: Props) { const [module, setModule] = useState(filters.module ?? ''); const [searchText, setSearchText] = useState(filters.searchText ?? ''); - const totalPages = Math.max(1, Math.ceil(result.totalCount / result.pageSize)); - const currentPage = result.page; + const before = filters.before ? String(filters.before) : undefined; + const isFirstPage = !before; + // Total is only computed for the first page (offset); keyset pages report -1. + const knownTotal = result.totalCount >= 0; + const items = result.items; + // A full page implies more (older) rows likely exist. + const canNext = items.length >= result.pageSize; + const nextCursor = items.length > 0 ? String(items[items.length - 1].timestamp) : undefined; function currentFilters() { return { @@ -63,36 +88,56 @@ export default function Browse({ result, filters }: Props) { }; } + // Any change of filter set resets the cursor trail and returns to the newest page. + function navigateNewest(f: Partial) { + writeCursorStack([]); + router.get('/audit-logs/browse', buildFilterParams(f)); + } + function applyFilters(e?: FormEvent) { e?.preventDefault(); - router.get('/audit-logs/browse', buildFilterParams(currentFilters())); + navigateNewest(currentFilters()); } function clearFilters() { + writeCursorStack([]); router.get('/audit-logs/browse'); } function applyDatePreset(hours: number) { const now = new Date(); const past = new Date(now.getTime() - hours * 60 * 60 * 1000); - const toLocal = now.toISOString().slice(0, 16); - const fromLocal = past.toISOString().slice(0, 16); - router.get( - '/audit-logs/browse', - buildFilterParams({ - ...currentFilters(), - from: fromLocal, - to: toLocal, - }), - ); + navigateNewest({ + ...currentFilters(), + from: past.toISOString().slice(0, 16), + to: now.toISOString().slice(0, 16), + }); } - function goToPage(page: number) { - router.get('/audit-logs/browse', buildFilterParams(currentFilters(), page), { - preserveState: true, + function goNext() { + if (!nextCursor) return; + const stack = readCursorStack(); + stack.push(before ?? ''); // remember current cursor ('' marks the first page) + writeCursorStack(stack); + router.get('/audit-logs/browse', buildFilterParams(currentFilters(), nextCursor), { + preserveScroll: true, }); } + function goPrev() { + const stack = readCursorStack(); + const prev = stack.pop(); + writeCursorStack(stack); + const target = prev && prev.length > 0 ? prev : undefined; + router.get('/audit-logs/browse', buildFilterParams(currentFilters(), target), { + preserveScroll: true, + }); + } + + function goNewest() { + navigateNewest(currentFilters()); + } + function exportLogs(format: string) { const query = new URLSearchParams({ ...buildFilterParams(currentFilters()), @@ -105,17 +150,18 @@ export default function Browse({ result, filters }: Props) { from || to || source !== '__all__' || action !== '__all__' || module || searchText, ); - const startItem = (currentPage - 1) * result.pageSize + 1; - const endItem = Math.min(currentPage * result.pageSize, result.totalCount); - return ( + )} + - {paginationRange(currentPage, totalPages).map((p) => - p === 'ellipsis-start' || p === 'ellipsis-end' ? ( - - ... - - ) : ( - - ), - )} - diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts index e729ea2d..1ce2816e 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts @@ -65,6 +65,7 @@ export interface AuditExportRequest { searchText: string; page: number | null; pageSize: number | null; + before: string | null; sortBy: string; sortDescending: boolean | null; effectivePage: number; @@ -86,6 +87,7 @@ export interface AuditQueryRequest { searchText: string; page: number | null; pageSize: number | null; + before: string | null; sortBy: string; sortDescending: boolean | null; effectivePage: number; diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/BackgroundJobsContractsService.cs b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/BackgroundJobsContractsService.cs index 9f4bf44e..222ab1ff 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/BackgroundJobsContractsService.cs +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Services/BackgroundJobsContractsService.cs @@ -100,6 +100,7 @@ public async Task> GetRecurringJobsAsync(Cancella .JobQueueEntries.AsNoTracking() .Where(e => e.RecurringName != null) .OrderByDescending(e => e.CreatedAt) + .Take(500) // recurring-jobs dashboard is consumed wholesale; bound defensively .ToListAsync(ct); var now = DateTime.UtcNow; diff --git a/modules/Email/src/SimpleModule.Email.Contracts/QueryEmailMessagesRequest.cs b/modules/Email/src/SimpleModule.Email.Contracts/QueryEmailMessagesRequest.cs index b3d911c9..6c8f1769 100644 --- a/modules/Email/src/SimpleModule.Email.Contracts/QueryEmailMessagesRequest.cs +++ b/modules/Email/src/SimpleModule.Email.Contracts/QueryEmailMessagesRequest.cs @@ -12,6 +12,14 @@ public class QueryEmailMessagesRequest public string? Subject { get; set; } public DateTimeOffset? DateFrom { get; set; } public DateTimeOffset? DateTo { get; set; } + + /// + /// Opt-in keyset cursor. When set (with the default CreatedAt-descending ordering), + /// the page is fetched via WHERE CreatedAt < Before instead of OFFSET, + /// skipping the per-request COUNT(*) and the O(offset) row-skip. Pass the + /// CreatedAt of the last item from the previous page to fetch the next one. + /// + public DateTimeOffset? Before { get; set; } public string? SortBy { get; set; } public bool? SortDescending { get; set; } diff --git a/modules/Email/src/SimpleModule.Email/EmailService.cs b/modules/Email/src/SimpleModule.Email/EmailService.cs index 03dd53b3..7815dc04 100644 --- a/modules/Email/src/SimpleModule.Email/EmailService.cs +++ b/modules/Email/src/SimpleModule.Email/EmailService.cs @@ -110,10 +110,41 @@ QueryEmailMessagesRequest request if (request.DateTo.HasValue) query = query.Where(m => m.CreatedAt <= request.DateTo.Value); + var sortDescending = request.EffectiveSortDescending; + var sortBy = request.EffectiveSortBy; + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + // Keyset (cursor) pagination on CreatedAt (indexed): opt-in via request.Before + // for the default CreatedAt-descending ordering. Skips the per-request COUNT(*) + // and the OFFSET row-skip (see AuditLogService for rationale). Non-cursor + // requests keep the exact prior behavior; TotalCount is -1 in cursor mode. + if ( + request.Before.HasValue + && sortDescending + && sortBy is not ("To" or "Subject" or "Status") + ) + { + var cursor = request.Before.Value; + var keysetItems = await query + .Where(m => m.CreatedAt < cursor) + .OrderByDescending(m => m.CreatedAt) + .ThenByDescending(m => m.Id) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = keysetItems, + TotalCount = -1, + Page = page, + PageSize = pageSize, + }; + } + var totalCount = await query.CountAsync(); - var sortDescending = request.EffectiveSortDescending; - query = request.EffectiveSortBy switch + query = sortBy switch { "To" => sortDescending ? query.OrderByDescending(m => m.To) : query.OrderBy(m => m.To), "Subject" => sortDescending @@ -122,14 +153,13 @@ QueryEmailMessagesRequest request "Status" => sortDescending ? query.OrderByDescending(m => m.Status) : query.OrderBy(m => m.Status), + // Default (CreatedAt) sort with Id tiebreaker — matches the keyset cursor + // ordering above so the offset first page and cursor pages agree. "CreatedAt" or _ => sortDescending - ? query.OrderByDescending(m => m.CreatedAt) - : query.OrderBy(m => m.CreatedAt), + ? query.OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id) + : query.OrderBy(m => m.CreatedAt).ThenBy(m => m.Id), }; - var page = request.EffectivePage; - var pageSize = request.EffectivePageSize; - var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); return new PagedResult diff --git a/modules/Email/src/SimpleModule.Email/Locales/en.json b/modules/Email/src/SimpleModule.Email/Locales/en.json index 9209ba52..aec29dd7 100644 --- a/modules/Email/src/SimpleModule.Email/Locales/en.json +++ b/modules/Email/src/SimpleModule.Email/Locales/en.json @@ -66,9 +66,9 @@ "History.FilterDateTo": "To Date", "History.AllStatuses": "All Statuses", "History.Showing": "Showing", - "History.Of": "of", "History.Previous": "Previous", "History.Next": "Next", + "History.Newest": "Newest", "History.FilterApply": "Apply", "History.FilterClear": "Clear", "History.EmptyWithFilters": "No emails match the current filters.", diff --git a/modules/Email/src/SimpleModule.Email/Locales/keys.ts b/modules/Email/src/SimpleModule.Email/Locales/keys.ts index 181f9fb9..26c7d062 100644 --- a/modules/Email/src/SimpleModule.Email/Locales/keys.ts +++ b/modules/Email/src/SimpleModule.Email/Locales/keys.ts @@ -73,8 +73,8 @@ export const EmailKeys = { FilterStatus: 'History.FilterStatus', FilterSubject: 'History.FilterSubject', FilterTo: 'History.FilterTo', + Newest: 'History.Newest', Next: 'History.Next', - Of: 'History.Of', Previous: 'History.Previous', Showing: 'History.Showing', Title: 'History.Title', diff --git a/modules/Email/src/SimpleModule.Email/Pages/History.tsx b/modules/Email/src/SimpleModule.Email/Pages/History.tsx index 94dce3f2..8d8311d9 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/History.tsx +++ b/modules/Email/src/SimpleModule.Email/Pages/History.tsx @@ -43,25 +43,47 @@ interface PagedResult { pageSize: number; } +interface Filters { + status?: string; + to?: string; + subject?: string; + dateFrom?: string; + dateTo?: string; + before?: string; +} + interface Props { result: PagedResult; - filters: { - status?: string; - to?: string; - subject?: string; - dateFrom?: string; - dateTo?: string; - }; + filters: Filters; +} + +// Cursor trail persisted across Inertia navigations within the browser session, so +// "Previous" can return to an already-visited cursor. +const CURSOR_STACK_KEY = 'email-history-cursors'; +function readCursorStack(): string[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.sessionStorage.getItem(CURSOR_STACK_KEY); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } +} +function writeCursorStack(stack: string[]): void { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(CURSOR_STACK_KEY, JSON.stringify(stack)); + } } -function buildFilterParams(f: Props['filters'], page?: number): Record { +function buildFilterParams(f: Filters, before?: string): Record { const params: Record = {}; if (f.status) params.status = f.status; if (f.to) params.to = f.to; if (f.subject) params.subject = f.subject; if (f.dateFrom) params.dateFrom = f.dateFrom; if (f.dateTo) params.dateTo = f.dateTo; - if (page && page > 1) params.page = String(page); + // Keyset cursor: fetch the page of messages older than `before`. + if (before) params.before = before; return params; } @@ -73,10 +95,15 @@ export default function History({ result, filters }: Props) { const [dateFrom, setDateFrom] = useState(filters.dateFrom ?? ''); const [dateTo, setDateTo] = useState(filters.dateTo ?? ''); - const totalPages = Math.max(1, Math.ceil(result.totalCount / result.pageSize)); - const currentPage = result.page; + const before = filters.before ? String(filters.before) : undefined; + const isFirstPage = !before; + // Total is only computed for the first page (offset); keyset pages report -1. + const knownTotal = result.totalCount >= 0; + const items = result.items; + const canNext = items.length >= result.pageSize; + const nextCursor = items.length > 0 ? String(items[items.length - 1].createdAt) : undefined; - function currentFilters(): Props['filters'] { + function currentFilters(): Filters { return { status: status !== '__all__' ? status : undefined, to: to || undefined, @@ -86,25 +113,46 @@ export default function History({ result, filters }: Props) { }; } + function navigateNewest(f: Filters) { + writeCursorStack([]); + router.get('/email/history', buildFilterParams(f)); + } + function applyFilters(e?: FormEvent) { e?.preventDefault(); - router.get('/email/history', buildFilterParams(currentFilters())); + navigateNewest(currentFilters()); } function clearFilters() { + writeCursorStack([]); router.get('/email/history'); } - function goToPage(page: number) { - router.get('/email/history', buildFilterParams(currentFilters(), page), { - preserveState: true, + function goNext() { + if (!nextCursor) return; + const stack = readCursorStack(); + stack.push(before ?? ''); // remember current cursor ('' marks the first page) + writeCursorStack(stack); + router.get('/email/history', buildFilterParams(currentFilters(), nextCursor), { + preserveScroll: true, }); } - const hasActiveFilters = Boolean(status !== '__all__' || to || subject || dateFrom || dateTo); + function goPrev() { + const stack = readCursorStack(); + const prev = stack.pop(); + writeCursorStack(stack); + const target = prev && prev.length > 0 ? prev : undefined; + router.get('/email/history', buildFilterParams(currentFilters(), target), { + preserveScroll: true, + }); + } - const startItem = (currentPage - 1) * result.pageSize + 1; - const endItem = Math.min(currentPage * result.pageSize, result.totalCount); + function goNewest() { + navigateNewest(currentFilters()); + } + + const hasActiveFilters = Boolean(status !== '__all__' || to || subject || dateFrom || dateTo); return ( - {result.items.length === 0 ? ( + {items.length === 0 ? ( - {result.items.map((m) => ( + {items.map((m) => ( {m.to} {m.subject} @@ -203,16 +251,23 @@ export default function History({ result, filters }: Props) { )} - {result.totalCount > 0 && ( + {items.length > 0 && (
- {t(EmailKeys.History.Showing)} {startItem}-{endItem} {t(EmailKeys.History.Of)}{' '} - {result.totalCount.toLocaleString()} + {knownTotal + ? `${t(EmailKeys.History.Showing)} ${result.totalCount.toLocaleString()}` + : null}
)} diff --git a/modules/Email/src/SimpleModule.Email/Pages/components/HistoryPagination.tsx b/modules/Email/src/SimpleModule.Email/Pages/components/HistoryPagination.tsx index 60a2e4a7..6c0c6291 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/components/HistoryPagination.tsx +++ b/modules/Email/src/SimpleModule.Email/Pages/components/HistoryPagination.tsx @@ -30,68 +30,51 @@ function ChevronRight() { ); } -/** Build a compact pagination range: 1 ... 4 5 [6] 7 8 ... 20 */ -function paginationRange( - current: number, - total: number, -): (number | 'ellipsis-start' | 'ellipsis-end')[] { - if (total <= 7) { - return Array.from({ length: total }, (_, i) => i + 1); - } - const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = []; - pages.push(1); - if (current > 3) pages.push('ellipsis-start'); - const start = Math.max(2, current - 1); - const end = Math.min(total - 1, current + 1); - for (let i = start; i <= end; i++) pages.push(i); - if (current < total - 2) pages.push('ellipsis-end'); - pages.push(total); - return pages; +interface Props { + /** Whether a previous (newer) page exists in the cursor trail. */ + canPrev: boolean; + /** Whether the current page is full, implying more (older) rows exist. */ + canNext: boolean; + /** Show the "jump to newest" reset action (hidden on the first page). */ + showNewest: boolean; + newestLabel: string; + prevLabel: string; + nextLabel: string; + onPrev: () => void; + onNext: () => void; + onNewest: () => void; } +/** + * Keyset (cursor) pagination control. Keyset paging is sequential — it walks + * newest → older via a `before` cursor — so the UI exposes Newest / Previous / Next + * rather than arbitrary page jumps. This avoids the per-request COUNT(*) and + * deep-OFFSET row-skip on large tables. + */ export function HistoryPagination({ - currentPage, - totalPages, - onGoToPage, -}: { - currentPage: number; - totalPages: number; - onGoToPage: (page: number) => void; -}) { - if (totalPages <= 1) return null; + canPrev, + canNext, + showNewest, + newestLabel, + prevLabel, + nextLabel, + onPrev, + onNext, + onNewest, +}: Props) { + if (!canPrev && !canNext) return null; + return (
- + )} + - {paginationRange(currentPage, totalPages).map((p) => - p === 'ellipsis-start' || p === 'ellipsis-end' ? ( - - ... - - ) : ( - - ), - )} -
diff --git a/modules/Email/src/SimpleModule.Email/types.ts b/modules/Email/src/SimpleModule.Email/types.ts index 2fb76a76..748b41f1 100644 --- a/modules/Email/src/SimpleModule.Email/types.ts +++ b/modules/Email/src/SimpleModule.Email/types.ts @@ -72,6 +72,7 @@ export interface QueryEmailMessagesRequest { subject: string; dateFrom: string | null; dateTo: string | null; + before: string | null; sortBy: string; sortDescending: boolean | null; effectivePage: number; diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs index ea431ccd..956c7a05 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs @@ -65,7 +65,12 @@ public async Task> GetAllEnabledAsync( public async Task> GetAllFlagsAsync() { var definitions = registry.GetAllDefinitions(); - var dbFlags = await db.FeatureFlags.AsNoTracking().ToListAsync(); + // Feature flags are a small, code-defined config set consumed wholesale; bound defensively. + var dbFlags = await db + .FeatureFlags.AsNoTracking() + .OrderBy(f => f.Name) + .Take(500) + .ToListAsync(); var dbMap = dbFlags.ToDictionary(f => f.Name); var result = new List(); diff --git a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/IFileStorageContracts.cs b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/IFileStorageContracts.cs index d5dd11c9..2394c991 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/IFileStorageContracts.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage.Contracts/IFileStorageContracts.cs @@ -2,7 +2,12 @@ namespace SimpleModule.FileStorage.Contracts; public interface IFileStorageContracts { - Task> GetFilesAsync(string? folder = null, string? userId = null); + Task> GetFilesAsync( + string? folder = null, + string? userId = null, + int skip = 0, + int take = 30 + ); Task GetFileByIdAsync(FileStorageId id); Task UploadFileAsync( Stream content, diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/GetAllEndpoint.cs b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/GetAllEndpoint.cs index 54c6eed1..001350ae 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/GetAllEndpoint.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/GetAllEndpoint.cs @@ -17,10 +17,23 @@ public class GetAllEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( Route, - (string? folder, HttpContext context, IFileStorageContracts files) => + ( + string? folder, + int? skip, + int? take, + HttpContext context, + IFileStorageContracts files + ) => { var userId = context.User.GetScopedUserId(); - return CrudEndpoints.GetAll(() => files.GetFilesAsync(folder, userId)); + return CrudEndpoints.GetAll(() => + files.GetFilesAsync( + folder, + userId, + Math.Max(0, skip ?? 0), + Math.Clamp(take ?? 30, 1, 200) + ) + ); } ) .RequirePermission(FileStoragePermissions.View); diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/EntityConfigurations/StoredFileConfiguration.cs b/modules/FileStorage/src/SimpleModule.FileStorage/EntityConfigurations/StoredFileConfiguration.cs index 257e3490..309c4c24 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/EntityConfigurations/StoredFileConfiguration.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/EntityConfigurations/StoredFileConfiguration.cs @@ -19,5 +19,11 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(f => f.Folder); builder.HasIndex(f => f.CreatedByUserId); builder.HasIndex(f => new { f.Folder, f.FileName }).IsUnique(); + // The default listing orders by FileName (GetFilesAsync). The composite + // (Folder, FileName) index above cannot satisfy that ordering when filtering + // by "Folder IS NULL" (root folder), so without this the query falls back to a + // bitmap scan of every root file + a top-N sort on each request. A standalone + // FileName index turns it into an ordered index scan (≈15ms → <0.1ms at 80k rows). + builder.HasIndex(f => f.FileName); } } diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs index 56bf075f..101f8222 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs @@ -16,7 +16,9 @@ ILogger logger { public async Task> GetFilesAsync( string? folder = null, - string? userId = null + string? userId = null, + int skip = 0, + int take = 30 ) { var query = db.StoredFiles.AsNoTracking(); @@ -36,7 +38,7 @@ public async Task> GetFilesAsync( query = query.Where(f => f.CreatedByUserId == userId); } - return await query.OrderBy(f => f.FileName).ToListAsync(); + return await query.OrderBy(f => f.FileName).Skip(skip).Take(take).ToListAsync(); } public async Task GetFileByIdAsync(FileStorageId id) @@ -188,7 +190,8 @@ public async Task> GetFoldersAsync( query = query.Where(f => f.Folder!.StartsWith(normalizedParent + "/")); } - var allFolders = await query.Select(f => f.Folder!).Distinct().ToListAsync(); + // Distinct folder paths build a folder tree consumed wholesale; bound defensively. + var allFolders = await query.Select(f => f.Folder!).Distinct().Take(1000).ToListAsync(); return allFolders .Select(f => normalizedParent is not null ? f[(normalizedParent.Length + 1)..] : f) diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/Pages/BrowseEndpoint.cs b/modules/FileStorage/src/SimpleModule.FileStorage/Pages/BrowseEndpoint.cs index 1fc7fb67..b6f91423 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/Pages/BrowseEndpoint.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/Pages/BrowseEndpoint.cs @@ -22,7 +22,7 @@ public void Map(IEndpointRouteBuilder app) { var userId = context.User.GetScopedUserId(); - var files = await fileStorage.GetFilesAsync(folder, userId); + var files = await fileStorage.GetFilesAsync(folder, userId, skip: 0, take: 200); var folders = await fileStorage.GetFoldersAsync(folder, userId); var parentFolder = folder is not null diff --git a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs index cefedd27..5116f341 100644 --- a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs +++ b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs @@ -271,7 +271,9 @@ public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = } public Task> GetSettingValuesAsync( - SettingsFilter? filter = null + SettingsFilter? filter = null, + int skip = 0, + int take = 30 ) { return Task.FromResult>([]); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs index 7e44da80..776d4e9f 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs @@ -74,6 +74,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.AllowPasswordFlow(); } + // Issue access tokens as signed JWTs rather than encrypted JWE. + // Resource-server validation then verifies a signature (cheap, cached + // public key) instead of an RSA-OAEP private-key decrypt on every + // request — a large per-request CPU saving under load. Authorization + // codes and refresh tokens remain encrypted. + options.DisableAccessTokenEncryption(); + options .SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize) .SetTokenEndpointUris(ConnectRouteConstants.ConnectToken) diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs index 0fe75c6a..fb3408fe 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs @@ -2,7 +2,7 @@ namespace SimpleModule.RateLimiting.Contracts; public interface IRateLimitingContracts { - Task> GetAllRulesAsync(); + Task> GetAllRulesAsync(int skip = 0, int take = 30); Task GetRuleByIdAsync(RateLimitRuleId id); Task CreateRuleAsync(CreateRateLimitRuleRequest request); Task UpdateRuleAsync(RateLimitRuleId id, UpdateRateLimitRuleRequest request); diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs index 27a0899c..b9118096 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs @@ -14,8 +14,13 @@ public class GetAllEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( Route, - (IRateLimitingContracts contracts) => - CrudEndpoints.GetAll(contracts.GetAllRulesAsync) + (IRateLimitingContracts contracts, int? skip, int? take) => + CrudEndpoints.GetAll(() => + contracts.GetAllRulesAsync( + Math.Max(0, skip ?? 0), + Math.Clamp(take ?? 30, 1, 500) + ) + ) ) .RequirePermission(RateLimitingPermissions.View); } diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/AdminEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/AdminEndpoint.cs index e725d94d..8bec634e 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/AdminEndpoint.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/AdminEndpoint.cs @@ -18,7 +18,7 @@ public void Map(IEndpointRouteBuilder app) Route, async (IRateLimitingContracts contracts, IRateLimitPolicyRegistry policyRegistry) => { - var rules = await contracts.GetAllRulesAsync(); + var rules = await contracts.GetAllRulesAsync(take: 500); var activePolicies = policyRegistry.GetPolicies(); return Inertia.Render( "RateLimiting/Admin", diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs index 1199dd35..00dff120 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs @@ -11,8 +11,13 @@ public partial class RateLimitingService( ILogger logger ) : IRateLimitingContracts { - public async Task> GetAllRulesAsync() => - await db.Rules.AsNoTracking().OrderBy(r => r.PolicyName).ToListAsync(); + public async Task> GetAllRulesAsync(int skip = 0, int take = 30) => + await db + .Rules.AsNoTracking() + .OrderBy(r => r.PolicyName) + .Skip(skip) + .Take(take) + .ToListAsync(); public async Task GetRuleByIdAsync(RateLimitRuleId id) { diff --git a/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs b/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs index 3ab41777..c96528da 100644 --- a/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs +++ b/modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs @@ -13,7 +13,11 @@ public interface ISettingsContracts Task SetManyAsync(IReadOnlyList updates); Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null); Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null); - Task> GetSettingValuesAsync(SettingsFilter? filter = null); + Task> GetSettingValuesAsync( + SettingsFilter? filter = null, + int skip = 0, + int take = 30 + ); Task GetSettingValueAsync( string key, SettingScope scope, diff --git a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs index 94e8af63..f37f2955 100644 --- a/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Endpoints/Settings/GetSettingsEndpoint.cs @@ -15,7 +15,13 @@ public class GetSettingsEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( Route, - async (ISettingsContracts settings, SettingScope? scope, string? group) => + async ( + ISettingsContracts settings, + SettingScope? scope, + string? group, + int? skip, + int? take + ) => { SettingsFilter? filter = null; if (scope is not null || group is not null) @@ -23,7 +29,11 @@ public void Map(IEndpointRouteBuilder app) => filter = new SettingsFilter { Scope = scope, Group = group }; } - var results = await settings.GetSettingValuesAsync(filter); + var results = await settings.GetSettingValuesAsync( + filter, + Math.Max(0, skip ?? 0), + Math.Clamp(take ?? 30, 1, 500) + ); // This admin list serves global (System/Application) configuration. // User-scoped values are per-user and must not be enumerable here — diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs index fc165d81..d8f4cf0f 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs +++ b/modules/Settings/src/SimpleModule.Settings/Pages/AdminSettingsEndpoint.cs @@ -19,7 +19,7 @@ public void Map(IEndpointRouteBuilder app) async (ISettingsContracts settings, ISettingsDefinitionRegistry registry) => { var definitions = registry.GetDefinitions(); - var settingValues = await settings.GetSettingValuesAsync(); + var settingValues = await settings.GetSettingValuesAsync(take: 500); return Inertia.Render( "Settings/AdminSettings", new { definitions, settings = settingValues } diff --git a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs index 8f9fd1ee..e7fc3c48 100644 --- a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs +++ b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs @@ -29,6 +29,7 @@ public async Task> GetMenuTreeAsync() var entities = await db .PublicMenuItems.Where(e => e.IsVisible) .OrderBy(e => e.SortOrder) + .Take(1000) // navigation tree is consumed wholesale; bound defensively .ToListAsync(ct); return BuildPublicTree(entities, parentId: null); }, @@ -59,7 +60,8 @@ public async Task> GetMenuTreeAsync() public async Task> GetAllAsync() { - var entities = await db.PublicMenuItems.OrderBy(e => e.SortOrder).ToListAsync(); + // Menu items form a navigation tree consumed wholesale; bound defensively. + var entities = await db.PublicMenuItems.OrderBy(e => e.SortOrder).Take(1000).ToListAsync(); return BuildDtoTree(entities, parentId: null); } diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs index eefb23a7..8864f721 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs @@ -232,7 +232,9 @@ public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = DeleteSettingAsync(key, scope, userId); public async Task> GetSettingValuesAsync( - SettingsFilter? filter = null + SettingsFilter? filter = null, + int skip = 0, + int take = 30 ) { var query = db.Settings.AsQueryable(); @@ -252,6 +254,13 @@ public async Task> GetSettingValuesAsync( var entities = await query .AsNoTracking() + // Key is not unique (a key can exist at multiple scopes/users); order by the + // full identity so skip/take paging is deterministic across requests. + .OrderBy(e => e.Key) + .ThenBy(e => e.Scope) + .ThenBy(e => e.UserId) + .Skip(skip) + .Take(take) .Select(e => new { e.Key, diff --git a/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs b/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs index 34dd4645..8d7adbd9 100644 --- a/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs +++ b/modules/Tenants/src/SimpleModule.Tenants.Contracts/ITenantContracts.cs @@ -2,7 +2,7 @@ namespace SimpleModule.Tenants.Contracts; public interface ITenantContracts { - Task> GetAllTenantsAsync(); + Task> GetAllTenantsAsync(int skip = 0, int take = 30); Task GetTenantByIdAsync(TenantId id); Task GetTenantBySlugAsync(string slug); Task GetTenantByHostNameAsync(string hostName); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs index 64e80196..49b7c516 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/Tenants/GetAllEndpoint.cs @@ -14,7 +14,13 @@ public class GetAllEndpoint : IEndpoint public void Map(IEndpointRouteBuilder app) => app.MapGet( Route, - (ITenantContracts contracts) => CrudEndpoints.GetAll(contracts.GetAllTenantsAsync) + (ITenantContracts contracts, int? skip, int? take) => + CrudEndpoints.GetAll(() => + contracts.GetAllTenantsAsync( + Math.Max(0, skip ?? 0), + Math.Clamp(take ?? 30, 1, 500) + ) + ) ) .RequirePermission(TenantsPermissions.View); } diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/BrowseEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Pages/BrowseEndpoint.cs index 16793eb2..0a8fc053 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/BrowseEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/BrowseEndpoint.cs @@ -16,7 +16,7 @@ public void Map(IEndpointRouteBuilder app) Route, async (ITenantContracts contracts) => { - var tenants = (await contracts.GetAllTenantsAsync()).Select(t => new + var tenants = (await contracts.GetAllTenantsAsync(take: 500)).Select(t => new { t.Id, t.Name, diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/ManageEndpoint.cs b/modules/Tenants/src/SimpleModule.Tenants/Pages/ManageEndpoint.cs index 089491fe..168e32eb 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/ManageEndpoint.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/ManageEndpoint.cs @@ -18,7 +18,7 @@ public void Map(IEndpointRouteBuilder app) async (ITenantContracts contracts) => Inertia.Render( "Tenants/Manage", - new { tenants = await contracts.GetAllTenantsAsync() } + new { tenants = await contracts.GetAllTenantsAsync(take: 500) } ) ) .RequirePermission(TenantsPermissions.View); diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs index b5509436..9469130a 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs @@ -13,10 +13,13 @@ public sealed partial class TenantService( ILogger logger ) : ITenantContracts { - public async Task> GetAllTenantsAsync() => + public async Task> GetAllTenantsAsync(int skip = 0, int take = 30) => await db .Tenants.AsNoTracking() .Include(t => t.Hosts) + .OrderBy(t => t.Id) + .Skip(skip) + .Take(take) .Select(t => MapToDto(t)) .ToListAsync(); diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs index 8684a02c..d43f5894 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/IUserContracts.cs @@ -2,7 +2,7 @@ namespace SimpleModule.Users.Contracts; public interface IUserContracts { - Task> GetAllUsersAsync(); + Task> GetAllUsersAsync(int skip = 0, int take = 30); Task GetUserByIdAsync(UserId id); Task GetCurrentUserAsync(UserId userId); Task CreateUserAsync(CreateUserRequest request); diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Users/GetAllEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Users/GetAllEndpoint.cs index e747ee06..d5279834 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Users/GetAllEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Users/GetAllEndpoint.cs @@ -15,8 +15,13 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( Route, - (IUserContracts userContracts) => - CrudEndpoints.GetAll(userContracts.GetAllUsersAsync) + (IUserContracts userContracts, int? skip, int? take) => + CrudEndpoints.GetAll(() => + userContracts.GetAllUsersAsync( + Math.Max(0, skip ?? 0), + Math.Clamp(take ?? 30, 1, 200) + ) + ) ) .WithTags(UsersConstants.ModuleName) .RequireAuthorization(); diff --git a/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs b/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs index c9c881d3..be39a9f1 100644 --- a/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs +++ b/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs @@ -15,9 +15,15 @@ internal sealed partial class ExternalUserService( ILogger logger ) : IUserContracts { - public async Task> GetAllUsersAsync() + public async Task> GetAllUsersAsync(int skip = 0, int take = 30) { - return await db.Set().Select(u => MapToDto(u)).ToListAsync(); + return await db.Set() + .AsNoTracking() + .OrderBy(u => u.Id) + .Skip(skip) + .Take(take) + .Select(u => MapToDto(u)) + .ToListAsync(); } public async Task GetUserByIdAsync(UserId id) diff --git a/modules/Users/src/SimpleModule.Users/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs index 234ac2c5..b6ff14ee 100644 --- a/modules/Users/src/SimpleModule.Users/UserService.cs +++ b/modules/Users/src/SimpleModule.Users/UserService.cs @@ -18,9 +18,14 @@ internal sealed partial class UserService( ILogger logger ) : IUserContracts { - public async Task> GetAllUsersAsync() + public async Task> GetAllUsersAsync(int skip = 0, int take = 30) { - var users = await userManager.Users.ToListAsync(); + var users = await userManager + .Users.AsNoTracking() + .OrderBy(u => u.Id) + .Skip(skip) + .Take(take) + .ToListAsync(); return users.Select(MapToDto); } diff --git a/template/SimpleModule.Host/ClientApp/app.tsx b/template/SimpleModule.Host/ClientApp/app.tsx index 93b80714..e72bd249 100644 --- a/template/SimpleModule.Host/ClientApp/app.tsx +++ b/template/SimpleModule.Host/ClientApp/app.tsx @@ -230,7 +230,10 @@ createInertiaApp({ try { const page = await resolvePage(name); - return resolveLayout(page).default; + // resolveLayout attaches a persistent `layout` to the component (read by Inertia + // at runtime); narrow to ComponentType so the resolver's union matches the + // error-page branch and satisfies Inertia's ComponentResolver type. + return resolveLayout(page).default as React.ComponentType; } catch (err) { showToast({ variant: 'error', diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs index d76afac7..fa4a0fbc 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeUserContracts.cs @@ -7,8 +7,8 @@ public class FakeUserContracts : IUserContracts { public List Users { get; set; } = FakeDataGenerators.UserFaker.Generate(3); - public Task> GetAllUsersAsync() => - Task.FromResult>(Users); + public Task> GetAllUsersAsync(int skip = 0, int take = 30) => + Task.FromResult>(Users.Skip(skip).Take(take).ToList()); public Task GetUserByIdAsync(UserId id) => Task.FromResult(Users.FirstOrDefault(u => u.Id == id));