fix(archives): run search, filter and sort server-side

ArchivesPage fetched one 20-row page and then filtered and sorted only that
array in memory, while "Showing X of 802" / "Page 1 of 41" kept reporting the
full unfiltered count. Searching for an archive that exists but is not on the
current page returned a false "0 results" with no hint the search was
page-scoped.

The backend did not support the params (it read only page/limit and hardcoded
orderBy archived_at desc), so all three are new. Follows adminEvents/crud.js
for the shape and customerAccountsService for the case-insensitive predicate:
whereRaw with a bound parameter, never interpolated, and sortBy whitelisted to
date/name/size before it reaches orderBy. The same applyFilters() closure runs
against both the count query and the row query, so the total cannot drift from
the rows again.

Frontend mirrors EventsListPage: 300ms debounce, reset to page 1 on any query
change, placeholderData so keystrokes don't flash the spinner.

Two interpretation calls:
- sortBy=size orders by summed photo bytes, not the zip's on-disk size. The
  Size column comes from a per-row fs.stat done after pagination and there is
  no archive_size column, so a global sort on the real zip size would stat all
  802 files per request. Ordering is near-identical except for rows whose zip
  is missing. Adding events.archive_size would be a migration, out of scope.
- No LIKE-metacharacter escaping. escapeLikePattern() does .replace(/'/g,"''"),
  which corrupts a bound value ("Sarah's Birthday"), and its backslash escaping
  is a no-op on SQLite without an ESCAPE clause. Matched customerAccountsService
  instead. A literal % typed by an admin acts as a wildcard in a read-only
  search; no injection risk.

Pre-existing and untouched: the four stat cards still aggregate the current
page only.

Refs testplan REPORT.md #9 (Part 3, I.01).
This commit is contained in:
Paul Nothaft
2026-09-01 16:31:09 +02:00
parent 3f6c81a846
commit fc7cb226f4
5 changed files with 424 additions and 50 deletions
+43 -16
View File
@@ -19,26 +19,53 @@ const router = express.Router();
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const { page, limit, offset } = getPagination(req);
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const type = typeof req.query.type === 'string' ? req.query.type.trim() : '';
const sortBy = ['date', 'name', 'size'].includes(req.query.sortBy) ? req.query.sortBy : 'date';
// Get total count
const totalCount = await db('events')
.where('is_archived', formatBoolean(true))
.count('id as count')
// Search and type filtering run in SQL so both the returned rows and
// the total count cover the whole archive table, not just the page the
// client happens to be on. Values are bound, never interpolated.
const applyFilters = (query) => {
if (search) {
query.whereRaw('LOWER(events.event_name) LIKE ?', [`%${search.toLowerCase()}%`]);
}
if (type && type !== 'all') {
query.where('events.event_type', type);
}
return query;
};
// Get total count (of the filtered set, so pagination stays truthful)
const totalCount = await applyFilters(
db('events').where('events.is_archived', formatBoolean(true))
)
.count('events.id as count')
.first();
// Get archived events
const archives = await db('events')
.select(
'events.*',
db.raw('COUNT(DISTINCT photos.id) as photo_count'),
db.raw('SUM(photos.size_bytes) as total_size')
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', formatBoolean(true))
.groupBy('events.id')
.orderBy('events.archived_at', 'desc')
.limit(limit)
.offset(offset);
const archivesQuery = applyFilters(
db('events')
.select(
'events.*',
db.raw('COUNT(DISTINCT photos.id) as photo_count'),
db.raw('SUM(photos.size_bytes) as total_size')
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', formatBoolean(true))
).groupBy('events.id');
if (sortBy === 'name') {
archivesQuery.orderBy('events.event_name', 'asc');
} else if (sortBy === 'size') {
// The zip's on-disk size is only known after the per-row fs.stat below,
// so a global size sort has to use the archived content size instead.
archivesQuery.orderByRaw('COALESCE(SUM(photos.size_bytes), 0) desc');
} else {
archivesQuery.orderBy('events.archived_at', 'desc');
}
const archives = await archivesQuery.limit(limit).offset(offset);
// Check if archive files exist and get their sizes
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');