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
@@ -0,0 +1,209 @@
/**
* The archives list must resolve search / type filter / sort in SQL.
*
* Before this, GET /admin/archives ignored every query param except page and
* limit: the UI fetched one 20-row page and filtered it in JavaScript while
* the pagination footer kept reporting the unfiltered server-side total. An
* archive that matched the search but lived on another page came back as a
* false "0 results". These tests pin the params the route now honours, and
* — the part that actually made the bug visible — that `pagination.total`
* describes the *filtered* set.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-archquery-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'archquery-test-secret';
const request = require('supertest');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('GET /admin/archives query params (#I.01)', () => {
let db; let cleanup; let app; let token;
// name, type, archived_at, photo sizes
const fixtures = [
['Alpha Wedding', 'wedding', '2026-01-05T10:00:00.000Z', [300]],
['Bravo Birthday', 'birthday', '2026-02-05T10:00:00.000Z', [100]],
['Charlie Wedding', 'wedding', '2026-03-05T10:00:00.000Z', [500, 400]],
['Delta Corporate', 'corporate', '2026-04-05T10:00:00.000Z', [200]],
['Echo WEDDING Gala', 'wedding', '2026-05-05T10:00:00.000Z', [50]],
];
const list = async (query) => {
const res = await request(app)
.get('/admin/archives')
.query(query)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
return res.body;
};
const names = (body) => body.archives.map((a) => a.eventName);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'super_admin' }).first();
const inserted = await db('admin_users').insert({
username: 'arch-admin',
email: '[email protected]',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const adminId = inserted[0]?.id ?? inserted[0];
token = jwt.sign(
{ id: adminId, username: 'arch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
let i = 0;
for (const [eventName, eventType, archivedAt, sizes] of fixtures) {
const slug = `arch-${i++}`;
const ev = await db('events').insert({
slug,
event_type: eventType,
event_name: eventName,
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 0,
is_archived: 1,
is_draft: 0,
archived_at: archivedAt,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
let p = 0;
for (const size of sizes) {
await db('photos').insert({
event_id: eventId,
filename: `${slug}-${p++}.jpg`,
path: `events/archived/${slug}.jpg`,
type: 'individual',
size_bytes: size,
uploaded_at: new Date().toISOString(),
});
}
}
// A live event that must never surface in the archives list.
await db('events').insert({
slug: 'not-archived',
event_type: 'wedding',
event_name: 'Alpha Live Wedding',
event_date: '2026-08-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_token: 'tok-not-archived',
share_link: '/gallery/not-archived/tok-not-archived',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
});
app = buildRouteApp('/admin/archives', require('../../src/routes/adminArchives'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('no params: every archive, newest first, unfiltered total', async () => {
const body = await list({});
expect(names(body)).toEqual([
'Echo WEDDING Gala', 'Delta Corporate', 'Charlie Wedding', 'Bravo Birthday', 'Alpha Wedding',
]);
expect(body.pagination.total).toBe(5);
});
test('search filters in SQL and the total describes the filtered set', async () => {
const body = await list({ search: 'wedding' });
// Case-insensitive, matches "Echo WEDDING Gala" too, and never the
// non-archived "Alpha Live Wedding".
expect(names(body).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(body.pagination.total).toBe(3);
expect(body.pagination.totalPages).toBe(1);
});
test('search reaches rows that are not on page 1 — the actual bug', async () => {
// limit=2 puts "Alpha Wedding" (oldest) on page 3 of the unfiltered list.
// Client-side filtering of page 1 returned nothing for this query.
const body = await list({ search: 'alpha', limit: 2, page: 1 });
expect(names(body)).toEqual(['Alpha Wedding']);
expect(body.pagination.total).toBe(1);
});
test('search with no match returns an empty page and a zero total', async () => {
const body = await list({ search: 'zzz-nothing' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
expect(body.pagination.totalPages).toBe(0);
});
test('type filter narrows the rows and the total; "all" is a no-op', async () => {
const filtered = await list({ type: 'wedding' });
expect(names(filtered).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(filtered.pagination.total).toBe(3);
const all = await list({ type: 'all' });
expect(all.pagination.total).toBe(5);
});
test('search and type filter combine', async () => {
const body = await list({ search: 'wedding', type: 'birthday' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
});
test('sortBy=name orders across the whole set, not just the page', async () => {
const page1 = await list({ sortBy: 'name', limit: 2, page: 1 });
expect(names(page1)).toEqual(['Alpha Wedding', 'Bravo Birthday']);
const page3 = await list({ sortBy: 'name', limit: 2, page: 3 });
expect(names(page3)).toEqual(['Echo WEDDING Gala']);
});
test('sortBy=size orders by archived content size, largest first', async () => {
const body = await list({ sortBy: 'size' });
expect(names(body)).toEqual([
'Charlie Wedding', // 900
'Alpha Wedding', // 300
'Delta Corporate', // 200
'Bravo Birthday', // 100
'Echo WEDDING Gala' // 50
]);
});
test('an unknown sortBy falls back to the date ordering', async () => {
const body = await list({ sortBy: 'events.id; drop table events' });
expect(names(body)[0]).toBe('Echo WEDDING Gala');
expect(body.pagination.total).toBe(5);
});
test('quotes in the search are bound as a value, not injected as SQL', async () => {
const body = await list({ search: '\'; DROP TABLE events; --' });
expect(body.archives).toEqual([]);
// The table is still there.
expect((await list({})).pagination.total).toBe(5);
});
});
+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');