feat(admin): add column sorting and reset pagination on search (#927)

* feat(admin): add column sorting and reset pagination on search (#918)

- Add server-side column sorting for admin address list (ID, name, created_at, updated_at, mail_count, send_count)
- Reset pagination to page 1 when searching or changing sort order
- Add optional orderBy parameter to handleListQuery with whitelist validation

Closes #918

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add JSDoc warning for orderBy parameter in handleListQuery

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings

- Fix count not resetting to 0 when search returns empty results
- Add source_meta column sorting support
- Use Object.hasOwn to prevent prototype pollution in sort column lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-04-03 01:46:12 +08:00
committed by GitHub
parent db93828a81
commit d2c940aa2c
5 changed files with 69 additions and 16 deletions

View File

@@ -22,7 +22,19 @@ import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>()
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
const { limit, offset, query, sort_by, sort_order } = c.req.query();
const allowedSortColumns: Record<string, string> = {
'id': 'a.id',
'name': 'a.name',
'created_at': 'a.created_at',
'updated_at': 'a.updated_at',
'source_meta': 'a.source_meta',
'mail_count': 'mail_count',
'send_count': 'send_count',
};
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
const orderBy = `${sortColumn} ${sortDirection}`;
if (query) {
return await handleListQuery(c,
`SELECT a.*,`
@@ -31,7 +43,7 @@ api.get('/admin/address', async (c) => {
+ ` FROM address a`
+ ` where name like ?`,
`SELECT count(*) as count FROM address where name like ?`,
[`%${query}%`], limit, offset
[`%${query}%`], limit, offset, orderBy
);
}
return await handleListQuery(c,
@@ -40,7 +52,7 @@ api.get('/admin/address', async (c) => {
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`,
`SELECT count(*) as count FROM address`,
[], limit, offset
[], limit, offset, orderBy
);
})

View File

@@ -483,7 +483,9 @@ export const handleListQuery = async (
c: Context<HonoCustomType>,
query: string, countQuery: string, params: string[],
limit: string | number | undefined | null,
offset: string | number | undefined | null
offset: string | number | undefined | null,
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
orderBy?: string
): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") {
@@ -498,7 +500,8 @@ export const handleListQuery = async (
if (offset == null || offset == undefined || offset < 0) {
return c.text(msgs.InvalidOffsetMsg, 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const orderClause = orderBy || 'id desc';
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();