← back to Nationalrealestate
usre: true server-side sort on /api/brokers + /api/firms (whitelisted ORDER BY name/firm/agents/city, covers ALL rows) + wire desk sort to it (drop client page-sort, reset to page 1 on sort) — TK-10590 cycle 3
ce73325410d856256d0ed7ed2fa0db5ec57dfcc9 · 2026-08-15 09:06:10 -0700 · Steve Abrams
Files touched
M public/broker-desk.jsM src/server/index.ts
Diff
commit ce73325410d856256d0ed7ed2fa0db5ec57dfcc9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 15 09:06:10 2026 -0700
usre: true server-side sort on /api/brokers + /api/firms (whitelisted ORDER BY name/firm/agents/city, covers ALL rows) + wire desk sort to it (drop client page-sort, reset to page 1 on sort) — TK-10590 cycle 3
---
public/broker-desk.js | 31 ++++++++++---------------------
src/server/index.ts | 11 +++++++++--
2 files changed, 19 insertions(+), 23 deletions(-)
diff --git a/public/broker-desk.js b/public/broker-desk.js
index 65e06d2..2edd4fb 100644
--- a/public/broker-desk.js
+++ b/public/broker-desk.js
@@ -31,12 +31,12 @@
<div id="tabs"><button data-t="firms" class="active">Firms</button><button data-t="brokers">${AGENTS?'Agents':'Brokers'}</button></div>
<select id="city">${CITIES.map(([v,l])=>`<option value="${v}"${v===S.city?' selected':''}>${l}</option>`).join('')}</select>
<input id="q" placeholder="Search name / firm / license…" style="width:200px">
- <select id="sort" title="Sorts the current page only (server has no sort param)">
- <option value="">Sort (this page)…</option>
- <option value="name">Name A→Z (page)</option>
- <option value="firm">Firm A→Z (page)</option>
- <option value="agents">Agents ↓ (page)</option>
- <option value="city">City A→Z (page)</option>
+ <select id="sort" title="Sort (server-side, across all rows)">
+ <option value="">Sort: default</option>
+ <option value="name">Name A→Z</option>
+ <option value="firm">Firm A→Z</option>
+ <option value="agents">Agents ↓</option>
+ <option value="city">City A→Z</option>
</select>
<label id="densWrap" title="Card density">▦ <input type="range" id="dens" min="200" max="380" step="20" value="${S.dens}"></label>
<span id="count"></span>
@@ -55,6 +55,7 @@
if (MODE) p.set('asset_class', MODE);
if (S.city) p.set('city', S.city);
if (S.q) p.set('q', S.q);
+ if (S.sort) p.set('sort', S.sort); // server-side sort (TK-10590) — covers all rows, not just the page
if (extra) for (const k in extra) p.set(k, extra[k]);
return p;
}
@@ -68,21 +69,9 @@
$('#pgPrev').disabled = S.offset <= 0;
$('#pgNext').disabled = pg >= pgs;
}
- // client-side sort of the current page (the API has no sort param; honest — sorts what's shown)
- function sortRows(rows){
- const k = S.sort; if (!k) return rows;
- const cmp = {
- name: (a,b)=> String(a.name||'').localeCompare(String(b.name||'')),
- firm: (a,b)=> String(a.firm_name||a.name||'').localeCompare(String(b.firm_name||b.name||'')),
- agents:(a,b)=> (b.agent_count||0)-(a.agent_count||0),
- city: (a,b)=> String(a.hq_city||a.city||'').localeCompare(String(b.hq_city||b.city||'')),
- }[k];
- return cmp ? rows.slice().sort(cmp) : rows;
- }
-
async function loadFirms(){
const d = await j('/api/firms?'+params());
- const rows = sortRows(d.rows||[]);
+ const rows = d.rows||[];
setCount(rows.length, d.total||0);
$('#grid').innerHTML = rows.map(f=>`<div class="card firm" data-name="${esc(f.name)}">
<div class="n">${esc(f.name)||'—'}</div>
@@ -104,7 +93,7 @@
}
async function loadBrokers(){
const d = await j('/api/brokers?'+params());
- const rows = sortRows(d.rows||[]);
+ const rows = d.rows||[];
setCount(rows.length, d.total||0);
$('#grid').innerHTML = rows.map(b=>`<div class="card">
<div class="n">${esc(b.name)||'—'}</div>
@@ -115,7 +104,7 @@
$('#tabs').querySelectorAll('button').forEach(b=>b.onclick=()=>{ $('#tabs').querySelectorAll('button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); S.tab=b.dataset.t; S.offset=0; load(); });
$('#city').onchange=()=>{ S.city=$('#city').value; S.offset=0; load(); };
$('#q').oninput=()=>{ S.q=$('#q').value; S.offset=0; clearTimeout(window._t); window._t=setTimeout(load,250); };
- $('#sort').value=S.sort; $('#sort').onchange=()=>{ S.sort=$('#sort').value; LS.set('desk.sort',S.sort); load(); };
+ $('#sort').value=S.sort; $('#sort').onchange=()=>{ S.sort=$('#sort').value; LS.set('desk.sort',S.sort); S.offset=0; load(); };
$('#dens').oninput=()=>{ S.dens=+$('#dens').value; LS.set('desk.dens',S.dens); applyDens(); };
$('#pgPrev').onclick=()=>{ if(S.offset>0){ S.offset=Math.max(0,S.offset-PAGE); load(); } };
$('#pgNext').onclick=()=>{ if((Math.floor(S.offset/PAGE)+1) < Math.ceil(S.total/PAGE)){ S.offset+=PAGE; load(); } };
diff --git a/src/server/index.ts b/src/server/index.ts
index 9312bf3..d23169b 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -385,6 +385,10 @@ app.get('/api/brokers', async (req, res) => {
// asset_class=commercial|residential — RENTV (CRE) requests commercial; usreal/CRCP request residential.
if (req.query.asset_class) { params.push(String(req.query.asset_class).toLowerCase()); conds.push(`b.asset_class = $${params.length}`); }
const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+ // whitelisted server-side sort (TK-10590) — covers ALL rows, not just the page. Values are
+ // fixed literals (never user strings) so no injection surface; unknown sort → default name order.
+ const BROKER_SORT: Record<string,string> = { name:'b.name, b.id', firm:'f.name NULLS LAST, b.name', city:'b.city NULLS LAST, b.name', license:'b.license_no NULLS LAST, b.name' };
+ const brokerOrder = BROKER_SORT[String(req.query.sort || '').toLowerCase()] || 'b.name, b.id';
params.push(limit, offset);
const r = await query(
`SELECT b.id, b.name, b.license_no, b.license_state, b.license_type, b.license_status, b.asset_class,
@@ -400,7 +404,7 @@ app.get('/api/brokers', async (req, res) => {
LEFT JOIN firm f ON f.id = b.firm_id
LEFT JOIN firm_site fs ON fs.firm_id = f.id
${where}
- ORDER BY b.name, b.id
+ ORDER BY ${brokerOrder}
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
);
@@ -424,13 +428,16 @@ app.get('/api/firms', async (req, res) => {
// asset_class=commercial|residential — RENTV (CRE) requests commercial; usreal/CRCP request residential.
if (req.query.asset_class) { params.push(String(req.query.asset_class).toLowerCase()); conds.push(`asset_class = $${params.length}`); }
const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+ // whitelisted server-side sort (TK-10590) — covers ALL rows, not just the page. Fixed literals only.
+ const FIRM_SORT: Record<string,string> = { name:'name', firm:'name', agents:'agent_count DESC NULLS LAST, name', city:'hq_city NULLS LAST, name' };
+ const firmOrder = FIRM_SORT[String(req.query.sort || '').toLowerCase()] || 'agent_count DESC NULLS LAST, name';
params.push(limit, offset);
const r = await query(
`SELECT id, name, license_no, license_state, hq_city, hq_state, agent_count, source, asset_class,
COUNT(*) OVER()::int AS total
FROM firm
${where}
- ORDER BY agent_count DESC NULLS LAST, name
+ ORDER BY ${firmOrder}
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
);
← 53f52a8 usreal broker desk: honest page-scoped sort labels + roster
·
back to Nationalrealestate
·
usre: safe scoped deploy script (backup+smoke+auto-rollback, 14507af →