[object Object]

← 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

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 →