Skip to content

feat(web): force-directed network graph (progressive enhancement) (#142) - #144

Open
StanislavBG wants to merge 19 commits into
midt-bg:mainfrom
StanislavBG:feat/network-force-layout
Open

feat(web): force-directed network graph (progressive enhancement) (#142)#144
StanislavBG wants to merge 19 commits into
midt-bg:mainfrom
StanislavBG:feat/network-force-layout

Conversation

@StanislavBG

@StanislavBG StanislavBG commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

What changed

Force-directed network graph (progressive enhancement) plus a full round of review fixes: percent-encoded center slugs (defense-in-depth), a shared countCounterpartyEdges/linkHop helper so direct-edge counts and force-layout linkDistance are direction-independent, null-preserving COUNT-failure handling (never fabricates a count as 0), and EIK-shape validation for authority slugs matching the existing bidder-slug check.

How it was tested

  • pnpm --filter web test and pnpm --filter db test — unit coverage for the new helpers, slug validation, and null-total propagation.
  • EXPLAIN QUERY PLAN confirmed flow_pairs lookups stay index seeks, not full scans.

Quality checks

  • CI green. No fabricated counts anywhere in the network UI; COUNT failures surface as "unknown," never a silent zero.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Follow-up: the graph capped direct counterparties at 6, and the relations table was built from the same bounded set — so a hub's real counterparties (Sofia: 310) were unreachable and the cap read as "all there is".

  • New getEntityCounterparties — keyset-paginated exhaustive list (same cursor scheme as the contracts list); counterpartyTotal (COUNT(*)) added to getEntityNetwork.
  • /network now renders a paginated „Всички преки контрагенти (N)" table below the graph.
  • Graph caption names the cap ("показва N от общо M") + links to the full list — truncation is no longer silent, on every page the graph appears.
  • Steep edge value-labels stay horizontal instead of rotating bottom-to-top.
  • Tests added for the new query, cursor, and total.

Local: /network renders 200, default hub shows 310 counterparties paginated by 25.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Силна работа. Data слоят е коректен (няма промяна в GROUP BY/SUM/JOIN на ego-заявката); counterpartyTotal е добавен като required в контракта и getEntityNetwork го попълва по всички пътища, така че authority.tsx/company.tsx компилират без промяна; SSR-ът е запазен както трябва (физиката е в useEffect, interactive пуска client-only markup-а след mount, seed-ът е същата детерминистична функция и на сървъра — без hydration mismatch); симулацията оперира върху копия, не върху props-овете; prefers-reduced-motion е спазен на ниво физика; d3 модулите са легитимни (^3.0.0, заключени, без изненади в lock-а). 👏

Едно нещо, което е добре да се оправи (D1 cost):

Един и същ COUNT(*) се изпълнява два пъти на зареждане на /network. getEntityNetwork вече прави SELECT COUNT(*) … FROM flow_pairs WHERE ${centerCol} = ? и връща counterpartyTotal (а в route-а data.counterpartyTotal е в обхват). После getEntityCounterparties прави абсолютно същия COUNT(*) пак, и route-ът ползва неговия total. D1 таксува сканирани редове — това е същото броене два пъти на заявка. getEntityCounterparties може да приеме вече известния total (или route-ът да ползва data.counterpartyTotal за пагинацията), и втората заявка отпада.

Дребни (LOW, не блокират):

  • getEntityCounterparties тества само kind:'authority'; company-центърът (neighborCol='authority_id', друг ORDER BY) не минава през тест.
  • getKey={(r) => \${r.from}-${r.to}`}` ползва етикети, не ID-та — два различни (възложител, фирма) с еднакви имена след де-брандиране ще дадат key collision (React warning, не грешка в данните).

Mergeable, без блокери. Но горните подобрения са добре дошли.

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jun 27, 2026
…y slug

Review feedback on midt-bg#144:
- The same COUNT(*) over flow_pairs ran twice per /network load (getEntityNetwork
  for counterpartyTotal, then getEntityCounterparties for its total). D1 bills per
  row scanned, so it was the same count paid twice. getEntityCounterparties now
  takes an optional total; the route passes data.counterpartyTotal and the second
  scan is gone (standalone callers still count once, in parallel).
- /network table getKey now keys on the slug hrefs, not display labels (two
  de-branded same-name entities could collide).
- test the company-centre counterparties path (authority_id keyset + ORDER BY)
  and the caller-supplied-total shortcut.
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Review notes addressed in 1eb38be:

  • D1 cost — removed the duplicate COUNT(*). getEntityCounterparties now takes an optional total; the /network route passes data.counterpartyTotal (the count getEntityNetwork already ran), so the second identical scan is gone. Standalone callers still count once, in parallel.
  • getKey — the relations table now keys on the slug hrefs, not display labels (two de-branded same-name entities can't collide).
  • tests — added a company-centre counterparties case (the authority_id keyset + ORDER BY) and a caller-supplied-total case.

This PR also contains #140's commits and replaces its table with the shared linked counterpartyRows/networkColumns, so #140 is being closed as superseded.

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jun 27, 2026
…tabIndex (review)

Review feedback on midt-bg#144:
- **perf** — a client re-centre (`fetcher.load`) re-ran the whole loader and
  threw away the centre-picker options + the counterparties page. The re-centre
  URL now carries `g=1`; the loader skips both for graph-only fetches, so a node
  click no longer pays for queries nothing reads.
- **correctness** — bind the counterparties cursor to the centre
  (`filterSignature({ center })`): a cursor minted for centre A is otherwise
  structurally valid on B, so a shared/edited `?center=B&cursor=<from A>` would
  mispaginate; now it decodes to null → page 1.
- **a11y** — graph node <a>s get `tabIndex={-1}`: a role=img SVG must not hold
  interactive descendants, and the relations table is the keyboard/AT path.
- tests: forward→backward keyset round-trip (before/reverse) and the cross-centre
  cursor reset. Caption link uses the raw token, matching recentre().
@nedda76

nedda76 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

@StanislavBG — също дребно по линкването: PR-ът реализира #142 (force-directed граф като прогресивно подобрение), но го споменава като проза, без closing keyword. Би ли добавил ред Closes #142 в описанието, за да се затвори issue-то автоматично при merge и да се вържат двете? 🙏

@ydimitrof

Copy link
Copy Markdown
Contributor

Много добра работа, а историята със сигурността и целостта на данните издържа при внимателен преглед. 👏

Издърпах клона и проверих локално:

  • Няма повърхност за SQL инжекция. Всяка стойност в getEntityCounterparties / getEntityNetwork е подадена като параметър (bind); единствените интерполирани идентификатори са твърдо зададените тернарни authority_id/bidder_id и колоните на keyset-а, които минават през allowlist-а и regex-а за идентификатори в keyset.ts. Защитата с MAX_CURSOR_CHARS и обвързаният към центъра filterSignature курсор са добри предпазни мерки — ръчно редактиран ?center=B&cursor=<от A> чисто се връща на страница 1, вместо да обърка пагинацията.
  • Няма XSS / опасни sink-ове никъде в diff-а (без dangerouslySetInnerHTML/innerHTML/eval); етикетите се рендират като escape-нат текст, а href-овете идват от slug помощниците.
  • Зависимостите са наредd3-force/-drag/-zoom/-selection ^3.0.0 са каноничните пакети със съвпадащ integrity в lock файла и без install скриптове; --frozen-lockfile + pnpm audit --audit-level=high → 0.
  • Тестове/typecheck — typecheck-ът на web + db + api-contract е чист, минават 168 db теста и 59 web lib теста. d3 жизненият цикъл се освобождава коректно при unmount и prefers-reduced-motion е спазен.

Едно нещо за оправяне преди merge: клонът в момента е в конфликт с main (app.css, NetworkGraph.tsx, package.json, pnpm-lock.yaml). Тъй като #140 вече е затворен/заменен, първоначалният план „първо merge на #140" отпада — би ли направил rebase върху най-новия main, за да получим чист merge?

Дребно, не блокира: добавянето на Closes #142 в описанието (по молбата на @nedda76) ще затвори issue-то автоматично при merge.

След rebase-а с удоволствие ще одобря. Благодаря за внимателното описание и покритието с тестове.

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jul 2, 2026
…y slug

Review feedback on midt-bg#144:
- The same COUNT(*) over flow_pairs ran twice per /network load (getEntityNetwork
  for counterpartyTotal, then getEntityCounterparties for its total). D1 bills per
  row scanned, so it was the same count paid twice. getEntityCounterparties now
  takes an optional total; the route passes data.counterpartyTotal and the second
  scan is gone (standalone callers still count once, in parallel).
- /network table getKey now keys on the slug hrefs, not display labels (two
  de-branded same-name entities could collide).
- test the company-centre counterparties path (authority_id keyset + ORDER BY)
  and the caller-supplied-total shortcut.
StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jul 2, 2026
…tabIndex (review)

Review feedback on midt-bg#144:
- **perf** — a client re-centre (`fetcher.load`) re-ran the whole loader and
  threw away the centre-picker options + the counterparties page. The re-centre
  URL now carries `g=1`; the loader skips both for graph-only fetches, so a node
  click no longer pays for queries nothing reads.
- **correctness** — bind the counterparties cursor to the centre
  (`filterSignature({ center })`): a cursor minted for centre A is otherwise
  structurally valid on B, so a shared/edited `?center=B&cursor=<from A>` would
  mispaginate; now it decodes to null → page 1.
- **a11y** — graph node <a>s get `tabIndex={-1}`: a role=img SVG must not hold
  interactive descendants, and the relations table is the keyboard/AT path.
- tests: forward→backward keyset round-trip (before/reverse) and the cross-centre
  cursor reset. Caption link uses the raw token, matching recentre().
@StanislavBG
StanislavBG force-pushed the feat/network-force-layout branch from bcb4934 to 9ec970f Compare July 2, 2026 00:25
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest main — new head is 9ec970f.

Conflicts resolved:

  • apps/web/app/components/NetworkGraph.tsx — kept main's centre-node label suppression (fix(web): без застъпване на етикета на центъра в графа на мрежата #124 / 6797c93) together with this branch's shape/label extraction, label collision guard and anchor-wrapped nodes.
  • apps/web/package.json — merged main's ai dependency with the d3 additions (d3-force, d3-drag, d3-zoom, d3-selection ^3.0.0).
  • pnpm-lock.yaml — took main's lockfile and regenerated with pnpm install so the d3 entries are re-added consistently.
  • apps/web/app/app.css auto-merged cleanly (both main's and this PR's rules preserved).

Verification on the rebased head: pnpm --filter @sigma/web typecheck clean · web tests 309/309 · db tests 170/170 · pnpm audit --audit-level=high → 0.

Also added Closes #142 to the PR body as requested.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

След чистия rebase (413af0b, mergeable) — data слоят е коректен (без промяна в GROUP BY/тоталите), графът е прогресивно подобрение над SSR fallback-а. От наша страна — одобрявам; формалният approve е на @ydimitrof по договорката. Адресира #142 (добавете closing keyword в описанието, ако липсва).

@ydimitrof

Copy link
Copy Markdown
Contributor

Провери всичко необходимо. Изготвям финалния преглед.


Преглед на PR #144 — Force-directed граф като прогресивно подобрение (feat/network-force-layout)

Благодаря за изчерпателното описание, тестовото покритие и вниманието към сигурността. Издърпах ребейзнатия клон (413af0b) и прегледах дифа ред по ред — с фокус върху SQL инжекции, XSS, целостта на данните и OWASP съответствие. По-долу е обобщението.

Съответствие с issue #142

Изпълнението покрива и трите заявени ограничения от issue-то дословно:

  • SSR не е счупен. Физиката е изцяло client-only (useEffect след mount); статичният радиален seed е една и съща детерминистична чиста функция на сървъра и на клиента → без hydration mismatch, no-JS <a> fallback-ите остават.
  • Достъпност запазена. SVG остава role="img" + <title>; таблицата с връзки е клавиатурният/AT път; графовите node-анкери получават tabIndex={-1}, за да не държи role="img" интерактивни наследници; prefers-reduced-motion се уважава на ниво физика (сетълва веднъж без анимация).
  • Производителност ограничена. Симулацията е с горна граница (alphaDecay → alphaMin, после stop() — без безкраен RAF); ~15 възела; чист teardown при unmount (sim.stop(), махнати drag/zoom listener-и, нулиран zoomRef).

Сигурност и цялост на данните (OWASP)

  • SQL инжекция — няма повърхност (A03). Всяка стойност в getEntityNetwork / getEntityCounterparties минава като bind-параметър. Единствените интерполирани идентификатори (centerCol/neighborCol) са твърдо зададени тернарни (authority_id/bidder_id), а keyset колоните минават през allowlist-а + IDENTIFIER regex-а в keyset.ts. IN (...) за hop-2 ползва генерирани ? placeholder-и. Няма конкатенация на потребителски вход.
  • DoS през cursor — предпазено. MAX_CURSOR_CHARS = 512 реже свръхголеми ?cursor преди atob/JSON.parse пайплайна.
  • Мис-пагинация / IDOR по cursor — затворено. Cursor-ът е обвързан към центъра чрез filterSignature({ center }); ръчно редактиран ?center=B&cursor=<от A> декодира до null → страница 1, вместо да обърка пагинацията. Некриптографският checksum е коректно документиран като correctness-, не auth-контрол (всички данни са публични — няма confidentiality риск).
  • Cache poisoning — предотвратено. Добре оформен, но несъществуващ ?center хвърля 404 вместо да edge-кешне празен 200.
  • XSS — няма опасни sink-ове (A03). Никъде няма dangerouslySetInnerHTML / innerHTML / eval; етикетите се рендират като escape-нат JSX текст, а всички href идват от slug помощниците (heroHref, centerToken — base64url, URL-safe).
  • Зависимости — чисти (A06). d3-force/-drag/-zoom/-selection ^3.0.0 са каноничните пакети, заключени в lockfile-а, без install скриптове; @types/* са в devDeps. pnpm audit --audit-level=high → 0.

Качество и адресиран ревю

  • Дублираният COUNT(*) на /network е премахнат (route-ът подава data.counterpartyTotal); графовият re-centre вече носи g=1 и пропуска picker-а + counterparties страницата — не се плаща за заявки, които никой не чете.
  • getKey вече ключова по slug href-овете, не по display етикети (без key-колизия при де-брандирани еднакви имена).
  • Чиста геометрия/физика извлечена в network-layout.ts (+ тестове, вкл. инварианта SSR==first-render seed); d3 жизненият цикъл е изолиран в useForceGraph.ts; ?center граматиката е единствен източник в network-center.ts.
  • Точността е коректна: всички графови стойности са graph-scoped („в графа"), а информационната карта показва реалния брой преки контрагенти за центъра — без представяне като lifetime тотали.

Дребни, незадължителни бележки (не блокират)

  • getEntityCounterparties покрива и authority, и company-центъра в тестовете — добро. Ако искате, добавете и case, при който подаденият opts.total се разминава с реалния COUNT(*), за да се документира, че total-ът е чисто дисплей и не влияе на keyset границите.

Ребейзът върху най-новия main е чист, Closes #142 е добавен, конфликтите (NetworkGraph.tsx, package.json, pnpm-lock.yaml, app.css) са коректно разрешени с запазена centre-label супресия от #124. Typecheck чист · web 309/309 · db 170/170 · audit 0.

Отлична, добре защитена и добре тествана работа. Нямам блокиращи забележки.

ВЕРДИКТ: ОДОБРЯВАМ ✅ — готово за merge (OWASP-съответствие потвърдено, няма SQL/XSS/injection повърхност, целостта на данните е запазена).

…collision-guarded

Upgrades the static ego-graph into an explorer (progressive enhancement, URL stays static):
- each edge shows its € value, with a pure-CSS on/off toggle (no JS);
- with JS, clicking a node re-centres the graph in place — fetches that node's own
  ego-network via the /network loader (useFetcher) and redraws without changing the URL;
  'Отвори профила' opens the focused node's page, 'Върни се в началото' resets to the
  URL's entity; without JS a node click falls back to opening its profile (crawler-safe);
- node labels are de-cluttered per side and edge labels biased toward the outer node so
  they no longer overlap each other or the hub;
- the connections table's entity cells are now links — the accessible navigation path.

Hardening from review: reset browse state when the page's data changes; an intent ref so
Reset cancels an in-flight load; only adopt valid (>=2-node) results; inline error + move
focus to Reset after a re-centre.
… with midt-bg#124

Addresses review on midt-bg#140:
- Extract the a:/c: `?center` grammar (centerToken + parseCenter) into one shared
  module apps/web/app/lib/network-center.ts so the link and loader halves can't
  drift; add network-center.test.ts (round-trip per node kind incl. name-keyed
  base64url slug, malformed-token rejection, isAdoptableNetwork). Follows the
  repo's pure-logic lib/*.test.ts convention.
- Drop the duplicated inline grammar in network.tsx (dropdown centerValue) and
  NetworkGraph.tsx (centerToken).
- Centre node renders no text label (already named in title/dropdown/legend),
  aligning with midt-bg#124 to avoid a render-block conflict.
…dt-bg#142)

On hydration the static SSR ego-graph animates via d3-force, with d3-drag to move
nodes, d3-zoom pan/zoom, discoverable +/↺/− zoom controls and a full-screen (⤢)
toggle. SSR and the first client render keep the deterministic radial layout (no
hydration mismatch); the sim is bounded (alpha decay → settles, no infinite RAF),
re-inits on the midt-bg#140 browse re-centre, and pins the centre. Hovering a node fills
a side Information Card (like /map) and emphasises that node + its neighbours,
dimming the rest; edges show the € value plus the contract count ('дог.') when >1.
Reduced-motion settles once without animation; a drag still repaints.

The connections table stays the keyboard/AT path: /network now reuses the shared
networkColumns/networkRows from lib/entity-tables (dropping the duplicated inline
table), which also links its cells to each entity's profile.

Pure geometry/physics in lib/network-layout.ts (+tests); d3 lifecycle in
lib/useForceGraph.ts. Deps: d3-force/-drag/-zoom (+ d3-selection, their required
.call() companion).
The network graph capped direct counterparties at HOP1 (6) for readability,
but the /network relations table was built from the same bounded edge set —
so a big hub's real counterparties (e.g. Sofia: 310) were unreachable and the
cap read as "this is all there is". That is a silent truncation.

- packages/db: add getEntityCounterparties — a keyset-paginated, exhaustive
  list of the centre's direct counterparties (same cursor scheme as the
  contracts list); add counterpartyTotal (COUNT(*)) to getEntityNetwork.
- web /network: render an exhaustive, paginated „Всички преки контрагенти (N)"
  table below the graph; the in-graph „Връзки в графа" table stays as the
  literal picture (hop-1 + hop-2 edges).
- NetworkGraph: caption names the cap („показва N от общо M преки контрагента")
  with a link to the full list, so the truncation is never silent — on every
  page the graph appears (entity profiles + /network).
- also keep steep edge value-labels horizontal instead of rotating them
  bottom-to-top against the viewBox.
- tests for the new query, the cursor, and counterpartyTotal.
The hover card's „Връзки в графа" used the in-graph degree, which for the
centre is the HOP1 cap (6) — misleading next to the full list of 310. Reuse
the counterpartyTotal already on NetworkData: for the centre show „Преки
контрагенти" = the true total; other nodes keep the in-graph degree (their
real degree is unknown in this bounded ego view).
…t graph-edge copy

/network had two tables: „Връзки в графа" (just re-listed the drawn edges) and
the full paginated counterparty list. That second table was confusing and the
edge copy was redundant with the graph itself. Collapse to a single, full,
paginated „Всички връзки (N)" table — the exhaustive connection list — and use
„връзки" wording throughout (graph caption + centre info card) instead of
„контрагенти". Entity profile pages keep their compact in-graph table plus the
„Виж всички" link into /network#links.
…y slug

Review feedback on midt-bg#144:
- The same COUNT(*) over flow_pairs ran twice per /network load (getEntityNetwork
  for counterpartyTotal, then getEntityCounterparties for its total). D1 bills per
  row scanned, so it was the same count paid twice. getEntityCounterparties now
  takes an optional total; the route passes data.counterpartyTotal and the second
  scan is gone (standalone callers still count once, in parallel).
- /network table getKey now keys on the slug hrefs, not display labels (two
  de-branded same-name entities could collide).
- test the company-centre counterparties path (authority_id keyset + ORDER BY)
  and the caller-supplied-total shortcut.
…tabIndex (review)

Review feedback on midt-bg#144:
- **perf** — a client re-centre (`fetcher.load`) re-ran the whole loader and
  threw away the centre-picker options + the counterparties page. The re-centre
  URL now carries `g=1`; the loader skips both for graph-only fetches, so a node
  click no longer pays for queries nothing reads.
- **correctness** — bind the counterparties cursor to the centre
  (`filterSignature({ center })`): a cursor minted for centre A is otherwise
  structurally valid on B, so a shared/edited `?center=B&cursor=<from A>` would
  mispaginate; now it decodes to null → page 1.
- **a11y** — graph node <a>s get `tabIndex={-1}`: a role=img SVG must not hold
  interactive descendants, and the relations table is the keyboard/AT path.
- tests: forward→backward keyset round-trip (before/reverse) and the cross-centre
  cursor reset. Caption link uses the raw token, matching recentre().
@StanislavBG
StanislavBG force-pushed the feat/network-force-layout branch from 413af0b to 67c7b40 Compare July 3, 2026 15:12
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebase-нат върху новия main (linear); 232-те CSS реда са преместени в styles/pages.css при съществуващите .network-/.net- правила, app.css остава само @imports; prettier-чист, lint/typecheck 7/7, 360 web теста зелени, frozen install и audit чисти.
@ydimitrof — обещаният approve; @todorkolev — готов за merge след него.

@ydimitrof

Copy link
Copy Markdown
Contributor

Преглед на PR #144 — Force-directed граф като прогресивно подобрение (feat/network-force-layout)

Благодаря за изчерпателното описание, задълбоченото тестово покритие и вниманието към сигурността. Издърпах клона и прегледах дифа ред по ред локално, с фокус върху SQL инжекции, XSS, целостта на данните, злонамерен код и OWASP съответствие. По-долу е независимото ми обобщение.

Съответствие с issue #142

Изпълнението покрива дословно и трите заявени ограничения от issue-то, а Closes #142 вече е добавен в описанието:

  • SSR не е счупен. Физиката е изцяло client-only (useEffect след mount в useForceGraph.ts); статичният радиален seed е една и съща детерминистична чиста функция на сървъра и клиента → без hydration mismatch, no-JS <a> fallback-ите остават.
  • Достъпност запазена. SVG остава role="img" + <title>; таблицата с връзки е клавиатурният/AT път; графовите node-анкери получават tabIndex={-1}.
  • Производителност ограничена. Симулацията е с горна граница (alphaDecay → alphaMin, после sim.stop() — без безкраен RAF); prefers-reduced-motion сетълва синхронно веднъж без анимация; чист teardown при unmount (sim.on('tick', null), sim.stop(), махнати drag/zoom listener-и, нулиран zoomRef).

Сигурност и цялост на данните (OWASP)

  • SQL инжекция — няма повърхност (A03). Всяка стойност в getEntityNetwork / getEntityCounterparties минава като bind-параметър. Единствените интерполирани идентификатори (centerCol/neighborCol) са твърдо зададени тернарни (authority_id/bidder_id); keyset колоните минават през allowlist-а ['authority_id','bidder_id'] + IDENTIFIER regex-а в keyset.ts; hop-2 IN (...) ползва генерирани ? placeholder-и. Проследих и authorityIdFromSlug — връща 'auth:' + slug без валидация, но резултатът винаги отива като bind-параметър (несъществуващ център → 404), така че няма повърхност за инжекция.
  • DoS през cursor — предпазено. MAX_CURSOR_CHARS = 512 реже свръхголеми ?cursor преди atob/JSON.parse пайплайна.
  • Мис-пагинация / IDOR по cursor — затворено. Cursor-ът е обвързан към центъра чрез filterSignature({ center: p.id }); ръчно редактиран ?center=B&cursor=<от A> декодира до null → страница 1. Некриптографският checksum е коректно документиран като correctness-, не auth-контрол (всички данни са публични — няма confidentiality риск).
  • Cache poisoning — предотвратено. Добре оформен, но несъществуващ ?center хвърля 404 вместо да edge-кешне празен 200 (headers()publicCache(1800)).
  • XSS — няма опасни sink-ове (A03/A07). Нито dangerouslySetInnerHTML/innerHTML/eval/document.write в diff-а. d3 жизненият цикъл пише позиции само в React state (setPositions/setTransform), рендирани като escape-нат JSX; всички href идват от slug помощниците (heroHref, centerToken — base64url, URL-safe).
  • Зависимости — чисти (A06). d3-force/-drag/-zoom/-selection ^3.0.0 са каноничните пакети, заключени в lockfile-а, без install скриптове, @types/* в devDeps. Няма злонамерен/обфускиран код в дифа.
  • Цялост на данните. Няма промяна в GROUP BY/SUM/JOIN на ego-заявката; counterpartyTotal е честен COUNT(*). Всички графови стойности са graph-scoped („в графа"), а картата показва реалния брой преки контрагенти за центъра — без представяне като lifetime тотали.

Адресирано ревю (потвърдено в кода)

  • Дублираният COUNT(*) на /network е премахнат (route-ът подава total: data.counterpartyTotal; standalone callers броят веднъж, паралелно).
  • Графовият re-centre носи g=1 и loader-ът пропуска picker-а + counterparties страницата — не се плаща за заявки, които никой не чете.
  • getKey ключова по slug href-овете, не по display етикети — без key-колизия при де-брандирани еднакви имена.
  • Геометрия/физика в network-layout.ts (+тестове), d3 lifecycle в useForceGraph.ts, ?center граматиката единствена в network-center.ts.

Дребна, незадължителна бележка (не блокира)

  • getEntityCounterparties покрива и authority, и company-центъра в тестовете — добро. Ако желаете, добавете case, при който подаденият opts.total умишлено се разминава с реалния COUNT(*), за да документира, че total-ът е чисто дисплей и не влияе на keyset границите.

Ребейзът върху най-новия main е чист, конфликтите (NetworkGraph.tsx, package.json, pnpm-lock.yaml, app.cssstyles/pages.css) са коректно разрешени със запазена centre-label супресия от #124. Typecheck чист · web тестове зелени · db тестове зелени · pnpm audit --audit-level=high → 0.

Отлична, добре защитена и добре тествана работа. Нямам блокиращи забележки.

ВЕРДИКТ: ОДОБРЯВАМ ✅ — готово за merge (OWASP-съответствие потвърдено; няма SQL/XSS/injection повърхност; целостта на данните е запазена; няма следи от злонамерен код).

@StanislavBG

Copy link
Copy Markdown
Contributor Author

@todorkolev готов за ревю 🙏 — rebase-нат на main, CI зелен, prettier-чист, CSS промените в styles/* (app.css само @import). Резолвнати нишки. Approve-ни когато ти е удобно.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Одобрявам. 67c7b40: accuracy чисто (flow_pairs заявките ползват idx_flow_pairs_authority/_bidder — без full scan; won_eur/contracts са pre-aggregated, без дивизия), cache-key чисто (center/g/cursor в allow-list), SSR-safe (matchMedia typeof-guard, d3 само в post-mount effects, без module-scope window/document), без XSS (heroHref фиксира схемата).

Mobile — една бележка за втори поглед на 360px: touch-action: none на .network-svg.is-interactive (след hydration) — вертикален swipe, започнал върху графа, пани/зумва графа вместо да скролва страницата; на телефон графът пълни голяма част от viewport-а. Escape: fullscreen + таблицата отдолу. Плюс: hover-only картата (като #141) и sub-44px контроли (30×30 zoom). Layout-ът иначе wrap-ва на 1 колона и не бута viewport-а. Не блокер (mitigation-ите съществуват), но струва си проба на 360px.

.net-zoom buttons were 30px with only a 4px gap between them — below the touch floor, and too
tight for the invisible ::after overlay trick (the two hit areas would overlap). Grow them
directly and widen the gap to match.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Проверих b737113: zoom бутоните вече са 44×44 на mobile (.net-zoom button, coarse-pointer media query). touch-action: none остава — умишлено (d3-zoom държи жестовете; fullscreen + таблицата отдолу са escape пътят), както отбелязах. Approve остава.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR — feat(web): force-directed network graph (progressive enhancement) (#142)

Какво прави PR-ът

Добавя force-directed мрежова графика с progressive enhancement към уеб приложението. SSR рендира статична графика на база детерминистичен seed, а d3-физиката (drag/zoom, симулация) стартира едва след hydration в post-mount ефект — без hydration mismatch. Zoom контролите се появяват само след hydration, а таблицата остава каноничната клавиатурна пътека. На бекенда се добавя нова заявка getEntityCounterparties (keyset пагинация) и COUNT(*) за общия брой контрагенти, заедно с нови интерфейси в api-contract (NetworkCounterparty, NetworkCounterpartyPage, counterpartyTotal). Изнесени са чисти помощници (network-center.ts, network-layout.ts) с добро unit покритие.

Обща оценка

Промяната е чиста, добре структурирана и добре документирана. Progressive enhancement е коректно реализиран, ресурсите (fullscreen listener, d3 sim, drag, zoom) се почистват коректно, prefers-reduced-motion се уважава, а race условията при re-centre са обмислени (pending ref). Сигурност: чисто — няма тайни, нови URL адреси или злонамерен код; новите d3 зависимости са утвърдени и фиксирани в lock файла, @types/* са само в devDependencies. Тестовото покритие на новата loader/query логика е смислено и нетривиално (пагинация, курсори, company-център, отхвърляне на чужд курсор).

Най-важни забележки (не блокиращи)

  1. Коректност — броене на директни връзки: edges.filter((e) => e.from === center.id) приема, че всички ръбове до центъра са ориентирани from = center, но ориентацията в топологията е произволна (затова старата networkRows нормализира authority→company). Ако центърът е фирма и ръбовете са authority → center, броят става 0 и надписът „показва 0 от общо N" е грешен. Препоръка: бройте по двата края.
  2. Коректност — резервен total: totalRow?.n ?? hop1.length подменя реалния брой с изчертания cap (≤6) при липсващ/провален COUNT, което би показало грешно „top N of M".
  3. Производителност: getEntityNetwork вече винаги пуска допълнителен COUNT(*) при всяко зареждане (паралелизиран). Тъй като D1 таксува на прочетен ред, потвърдете, че flow_pairs.authority_id/bidder_id са индексирани.
  4. Дребни: при !center рано се връща counterpartyTotal: 0, въпреки че стойността вече е изчислена (може да се преизползва); role="img" SVG съдържа интерактивни <a> наследници (ARIA несъответствие), но е смекчено с tabIndex={-1} и документирано като приемливо.

Блокиращи проблеми

Няма блокиращи проблеми по сигурността. Логиката и тестовете изглеждат солидни. Препоръчва се да се адресират бележки 1 и 2 (коректност на броенето/total), а ако тестовете за самия loader на network.tsx (пагинация, g=1 разклонение, 404 при несъществуващ център) липсват, покритието да се допълни.

Comment thread apps/web/app/components/NetworkGraph.tsx
Comment thread apps/web/app/routes/network.tsx Outdated
Comment thread packages/db/src/queries/network.ts
Comment thread packages/db/src/queries/network.ts Outdated
…total honest

Edge orientation in flow_pairs is arbitrary, so directShown (and the
/network relations hint) undercounted when direct edges pointed
neighbour→centre. Extract a shared countDirectEdges helper used by both
NetworkGraph and the /network route so they can't diverge again.

Also stop masking a failed COUNT(*) as the HOP1 draw cap: getEntityNetwork
now returns counterpartyTotal: null on failure instead of hop1.length, and
getEntityCounterparties only reuses a passed total when it's a real number,
re-querying otherwise so a failure is never silently reported as a small M.

Confirmed flow_pairs.authority_id / bidder_id are both covering-index
seeks (EXPLAIN QUERY PLAN), not full scans, for the per-load COUNT(*).
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Addressed in 80035b2:

  • directShown / relations hint counted edges by e.from only, undercounting when the topology oriented the direct edge neighbour→centre. Extracted a shared countDirectEdges(edges, centerId) helper (apps/web/app/lib/network-center.ts) used by both NetworkGraph.tsx and network.tsx so they can't diverge.
  • counterpartyTotal no longer falls back to hop1.length (the HOP1=6 draw cap) when COUNT(*) fails — it's now null ("unknown"), threaded through NetworkData/getEntityCounterparties (which now only reuses a passed total when it's a real number, otherwise re-queries) so a failure is never rendered as a fabricated small M.
  • Confirmed flow_pairs.authority_id / bidder_id are both indexed (idx_flow_pairs_authority, idx_flow_pairs_bidder) — EXPLAIN QUERY PLAN on both shows SEARCH ... USING COVERING INDEX, not a scan.

Typecheck + tests green for apps/web and packages/db; pnpm format/pnpm lint clean.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обобщено ревю на PR #142 — feat(web): force-directed мрежов граф (progressive enhancement)

Какво прави PR-ът

Добавя интерактивен force-directed мрежов граф на страницата /network, реализиран чрез прогресивно подобрение: SSR/no-JS вариант с <a> fallback, а физиката (d3) стартира едва след mount, за да няма hydration mismatch. Архитектурата е чисто разделена — network-center (граматика на ?center), network-layout (чиста геометрия/физика) и useForceGraph (жизнен цикъл на d3). Към графа е добавена и изчерпателна, keyset-пагинирана таблица с контрагенти, преизползваща съществуващите компоненти (DataTable, Pagination, Section). Добавени зависимости: d3-drag, d3-force, d3-selection, d3-zoom (^3.0.0) + @types/*.

Обща оценка

Промяната е с високо качество, атомарна и изцяло в обхвата на #142 — без scope creep, без мъртъв код и без частични TODO-реализации. Тестовете са смислени и реално разкриват дефекти (round-trip на граматиката, детерминизъм, немутиране на входа, пълен count vs. drawn cap, null при провал на COUNT, нормализация authority→company, next/prev курсори, обвързване на курсора към центъра).

Сигурност (Фаза 0 — CLEAN)

  • Няма твърдо кодирани тайни.
  • SQL инжекция: суровият SQL в getEntityCounterparties интерполира само фиксирани литерали (authority_id/bidder_id от булев тернар) и allowlist-нати имена на колони (regex IDENTIFIER); всички стойности са bound параметри. Курсорът е ограничен по дължина (MAX_CURSOR_CHARS = 512) и обвързан с центъра чрез filterSignature — форжнат курсор се нулира до страница 1. Няма инжекционна повърхност. ✅
  • Без XSS: няма dangerouslySetInnerHTML; всички етикети минават през React escaping.
  • Зависимости: d3-* са легитимни, широко използвани пакети; pnpm-lock.yaml съдържа коректни integrity хешове.
  • OWASP A01/A03/A06 — без установени рискове (всички данни са публични).

Находки (незадължителни, НЕ блокиращи)

  1. Непоследователност при провал на COUNT(*): в getEntityNetwork провалът дава null („неизвестно"), а в getEntityCounterparties се свежда до total: totalRow?.n ?? 0 (валидна нула). Приемливо предвид NetworkCounterpartyPage.total: number, но е добре да е съзнателно решение.
  2. Slug в URL без encodeURIComponent (centerToken/recentre): днес безопасно (граматиката е URL-safe и тествана), но е крехък инвариант.
  3. Козметично: bias t = 0.62 на етикета на реброто приема, че to е винаги по-периферният край; при обратно ориентирано ребро етикетът може визуално да клони към грешния край.
  4. За потвърждение: при смяна на центъра през dropdown-а на /network да се провери, че cursor от URL се нулира, за да не се ползва stale cursor за първата страница на новия списък.
  5. Формулировката „N от общо —" при counterpartyTotal === null (деградира меко, без NaN/crash).

Блокиращи концерни

Няма. Сигурността, целостта на данните и коректността са потвърдени в двете партиди. Единствената препоръка преди merge е да се потвърди зелен CI и покритие (≥90%), тъй като това не може да се верифицира само от дифа.

ВЕРДИКТ: COMMENT — сигурна, качествена промяна, готова за merge; остават само дребни незадължителни бележки и потвърждение на CI.

Comment thread apps/web/app/lib/network-center.ts Outdated
Comment thread apps/web/app/components/NetworkGraph.tsx Outdated
Comment thread packages/db/src/queries/network.ts
Defense-in-depth per review: centerToken now percent-encodes the entity
slug before embedding it in the `?center=` token, and parseCenter decodes
it and falls back to null on a malformed %-escape instead of throwing.
Also clarify the truncation caption when counterpartyTotal is unknown
(COUNT failed) so it reads unambiguously instead of a bare em dash.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревю на PR #142 — force-directed мрежова графика (прогресивно подобрение)

Какво прави PR-ът

Добавя интерактивна force-directed мрежова графика като прогресивно подобрение над съществуващия SSR/без-JS изглед. Без JavaScript графиката остава напълно функционална: същите статични позиции, <a> fallback-и и таблица с връзки за клавиатура/помощни технологии. Физиката (d3-force/drag/zoom) стартира едва в ефект след mount, така че няма hydration mismatch. Чистата геометрия/физика е изнесена в network-layout.ts (с детерминистични unit тестове), а ?center граматиката — в network-center.ts с round-trip тестове. От страна на данните е добавено COUNT(*) в getEntityNetwork и нов помощник getEntityCounterparties с keyset пагинация, като семантиката „null = неизвестно“ е внимателно запазена.

Обща оценка

Висококачествена, добре документирана и добре тествана промяна. Фокусирана върху issue #142 без scope creep; без мъртъв код, TODO-та или дублиране; консистентно именуване и чисто разделяне SSR/клиент.

Сигурност (ЧИСТА)

Няма открити проблеми в двете партиди:

  • SQL инжекция: Няма. Имената на колоните са фиксирани в кода и се извеждат само от p.kind, никога от вход на потребителя. Всички стойности минават през bound параметри. Помощникът keyset() валидира колоните срещу regex + allowlist (authority_id, bidder_id).
  • DoS/ресурси: Курсорът е ограничен до 512 символа преди atob/JSON.parse; LIMIT/HOP1/HOP2_SCAN капват сканирането.
  • Курсорна цялостност: Курсорът е връзан към конкретния център и се нулира при смяна на центъра (покрито с тест). Токенът нарочно не е HMAC — всички данни са публични.
  • XSS: Данните се рендерират през React (екранирани), без dangerouslySetInnerHTML. parseCenter обвива decodeURIComponent в try/catch и отхвърля неправилни токени.
  • Зависимости: Добавени d3-drag, d3-force, d3-selection, d3-zoom (^3) + @types — утвърдени пакети; pnpm-lock.yaml е консистентен, без закачени уязвими версии.

Основни находки (неблокиращи, заслужават внимание)

  1. useForceGraph.ts — link дистанция vs. ориентация на ребрата. linkDistance((l.target).hop) приема, че target винаги е по-периферният възел, но ребрата не са гарантирано ориентирани център→периферия (нормализацията authority→company не пипа data.edges). За hop-2 ребро, съхранено като hop2→hop1, това дава грешна дължина.
  2. NetworkGraph.tsx — защита при counterpartyTotal === null. В картата за център се рендерира count(hoveredRelations), където hoveredRelations може да е null при неуспешен COUNT. Да се потвърди, че count приема number | null, или да се подаде fallback.
  3. getEntityCounterparties — маскиране на провал. При самостоятелно извикване връща total: totalRow?.n ?? 0 — ако COUNT се провали, това представя провала като „0 контрагента“, което противоречи на грижливо запазената null=„неизвестно“ семантика в getEntityNetwork. Съзнателен избор (типът е number), но си струва да се отбележи.
  4. network-center.ts — асиметрична валидация. За c: slug има null-guard през bidderIdFromSlug, но за a: id-то се приема безусловно. Поведението е същото като преди (само преместено), но заслужава изравняване.

Тестово покритие

Чистите helper-и (layout, center round-trip) и логиката в слоя с данни са отлично покрити (null vs. cap, провал на COUNT, център-компания, напред/назад през keyset, отхвърляне на чужд курсор). Основната липса: интерактивната логика в useForceGraph.ts (sim lifecycle, drag/zoom, reduced-motion) и разклоненията в NetworkGraph.tsx (adopt/reset/hover/fullscreen) нямат тестове. При праг ≥90% за нов код препоръчвам jsdom + testing-library тестове поне за adopt/reset и re-centre потока.

Дребни наблюдения

  • CSS transition: opacity по време на симулация може да добави пребоядисване при много възли, но е зад prefers-reduced-motion: no-preference — приемливо.
  • Новото COUNT(*) добавя по една заявка на зареждане; паралелизирано е, но маршрутът може да подаде total към getEntityCounterparties, за да избегне втори скан.

Присъда: COMMENT

Кодът е с високо качество, без проблеми по сигурността, SQL или целостта на данните. Преди APPROVE препоръчвам да се затвори липсата в тестовото покритие на хука/компонента и да се потвърдят находки 1 и 2.

Comment thread apps/web/app/lib/useForceGraph.ts Outdated
Comment thread apps/web/app/lib/network-center.ts Outdated
Comment thread packages/db/src/queries/network.ts Outdated
- lib/useForceGraph: linkDistance now reads Math.max(source.hop, target.hop)
  instead of target.hop alone, since edges aren't normalised by direction and
  target isn't guaranteed to be the more-peripheral end (extracted as a pure,
  tested linkHop helper)
- lib/network-center + db/queries/identity: a: authority slugs now validate
  against the EIK shape (isValidEikSlug) before acceptance, same as c:
  company slugs already do via bidderIdFromSlug
- db/queries/network (getEntityCounterparties) + api-contract: preserve
  total: null on a failed COUNT(*) instead of coalescing to 0, matching
  getEntityNetwork's counterpartyTotal convention
- routes/network + lib/filters + components/Pagination: handle a null
  counterparty total the same way NetworkGraph already handles a null
  counterpartyTotal (no fabricated count in the section title/hint); widen
  pageNav/PageNav to accept/report an unknown total and gate Next on the
  cursor alone rather than fabricating a page bound from a rows.length floor

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR #142 — feat(web): force-directed мрежова графика (прогресивно подобрение)

ВЕРДИКТ: APPROVE с коментари — няма блокиращи проблеми.

Какво прави PR-ът

Въвежда force-directed мрежова графика с прогресивно подобрение: чиста геометрия/физика (network-layout.ts), изолиран d3 lifecycle (useForceGraph.ts) и единствен източник на ?center граматиката (network-center.ts), плюс keyset-базирана пагинация на контрагентите в слоя @sigma/db. Добавят се зависимостите d3-force, d3-drag, d3-selection, d3-zoom (^3.0.0) със съответните @types/*.

Сигурност — ЧИСТО

  • Тайни/URL-и/зловреден код: няма hardcoded креденшъли, няма нови външни URL-и (само относителни вътрешни пътища), няма eval/dangerouslySetInnerHTML/обфускация или backdoor-и.
  • SQL инжекция (OWASP A03) — безопасно. Входът е стриктно валидиран преди заявка (parseCenter + isValidEikSlug/bidderIdFromSlug, невалиден токен → null). В @sigma/db всички стойности се подават като bound параметри (.bind(...)); имената на колони са твърдо зададени литерали, а keyset() валидира sortCol/idCol чрез regex и явен allowlist. Никакъв интерполиран вход не достига до SQL. Това затваря и двата gate-а, отворени в партида 1.
  • Устойчивост на курсора: защита срещу DoS (MAX_CURSOR_CHARS = 512 преди atob/JSON.parse); курсорът е обвързан с конкретния център, така че курсор от център A не може да странира погрешно център B.
  • Зависимости (OWASP A06): новите d3 пакети са канонични, пиннати и заключени в pnpm-lock.yaml с integrity хешове.

Цялост на данните

  • Обработката на неизвестен total (null) спрямо реална 0 е обмислена и последователна между графа, таблицата и DB слоя — отказ на COUNT(*) се запазва като „неизвестно“, а не се маскира като нула. Добре документирано и тествано.
  • countDirectEdges брои ребра независимо от ориентацията; ключовете в таблицата са по slug (fromHref-toHref), не по етикет — без колизии при де-брандирани имена.
  • Детерминираният seedPositions е чист и тестван байт-за-байт → без hydration mismatch.

Производителност

COUNT(*) и страницата с редове вървят паралелно (Promise.all); дублиращо сканиране на D1 се избягва чрез подаване на вече изчисления total. Keyset пагинацията е O(1) на всяка дълбочина.

Достъпност / CSS

Внимателна работа: 44px touch мишени при pointer: coarse, focus-visible контури, семантичен dl, съобразяване с prefers-reduced-motion. Cleanup спира симулацията и маха всички слушатели — без ресурсни течове.

Незадължителни бележки (неблокиращи)

  1. Reduced-motion не е реактивен: prefers-reduced-motion се чете еднократно при init; смяна по време на сесия не се отразява до re-init.
  2. Фокус при „Върни се в началото“: при reset() бутонът се демонтира и фокусът пада към <body> вместо към смислен елемент (напр. заглавието на секцията).
  3. Пре-render на цялата графа за всеки tick: приемливо за ~15 възела (и коментирано в кода); при значително по-големи графи би било кандидат за императивно писане по DOM.
  4. network.ts: малка бележка относно връщането на counterpartyTotal: 0 в клона if (!center) — виж inline коментара.
  5. Тестово покритие: чистата логика е добре покрита; d3 DOM-lifecycle-ът (drag/zoom/reduced-motion) остава непокрит от unit тестове — заслужава поне интеграционен/e2e тест за drag→no-navigate и re-centre поведението.

Заключение

Висококачествен, добре коментиран и добре тестван код с ясно разделение на слоеве. Няма блокиращи проблеми по сигурност или цялост на данните. Препоръка: APPROVE.

Comment thread apps/web/app/lib/network-center.ts Outdated
Comment thread apps/web/app/lib/useForceGraph.ts Outdated
Comment thread apps/web/app/routes/network.tsx
Comment thread packages/db/src/queries/network.ts Outdated
- centerToken now handles authority/company as explicit cases and throws
  on any other kind instead of silently minting a company token
- useForceGraph now subscribes to prefers-reduced-motion change events so
  a mid-session flip is honoured, reusing SiteHeader's matchMedia pattern
- getEntityNetwork's centre-load-failure early exit preserves the
  already-computed counterpartyTotal (incl. null) instead of fabricating 0

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR #142 — batch 2/2 (feat(web): force-directed network graph)

Прегледани файлове: apps/web/app/routes/network.tsx, apps/web/app/styles/pages.css, apps/web/package.json, packages/api-contract/src/index.ts, packages/db/src/queries/identity.ts, packages/db/src/queries/network.test.ts, packages/db/src/queries/network.ts, pnpm-lock.yaml.

Забележка: това е чернова за Ваша проверка преди изпращане. Не са публикувани inline коментари (по Ваше указание). Verdict-ът е COMMENT — нито блокирам, нито автоматично одобрявам, тъй като покритието на тестовете и CI не могат да бъдат потвърдени локално.


Фаза 0 — Задължителен security скан (SQL/OWASP)

РЕЗУЛТАТ: ЧИСТ (CLEAN) — без блокиращи находки.

  • SQL инжекция (проверено срещу зависимостта packages/db/src/queries/keyset.ts): Новата заявка в getEntityCounterparties конструира SQL чрез интерполация само на имена на колони (won_eur, authority_id/bidder_id). Тези имена:
    • идват от булев флаг isAuth, а не от вход на потребителя;
    • минават през assertSafeColumn с IDENTIFIER regex + явен allowlist allowedIdCols: ['authority_id','bidder_id'] в keyset().
      Всички стойности (p.id, cursor параметри, pageSize+1) са параметризирани през .bind(...). Няма възможност за SQL инжекция.
  • Обработка на cursor (OWASP: недоверен вход): decodeCursor отхвърля cursor > MAX_CURSOR_CHARS (512) преди atob/JSON.parse (защита срещу изчерпване на памет), и обвързва cursor-а към sort/filter подпис. Cursor, издаден за център auth:C, декодира до null при център auth:X → връща се на страница 1 вместо грешно странициране. Тестът „drops a cursor minted for a different centre" го покрива.
  • Секрети: няма hardcoded ключове/пароли/токени.
  • URL промени: няма.
  • Зловреден код / backdoors / обфускация: няма.
  • Зависимости: добавени са d3-drag, d3-force, d3-selection, d3-zoom (^3.0.0) + @types/*. Това са каноничните официални D3 пакети; pnpm-lock.yaml съдържа валидни integrity хешове и коректно дърво на транзитивните зависимости (d3-dispatch, d3-quadtree, d3-timer и т.н.). Обосновани от изискването за force-directed граф. Одобрени.

Коректност и цялост на данните

Реализацията е издържана и добре обмислена по отношение на цялостта на данните:

  • Разграничаване на „неизвестно" от „нула": counterpartyTotal/total остават null, когато самият COUNT(*) се провали (D1 грешка), вместо да бъдат подменени с HOP1 cap-а или с фалшива 0. Конвенцията е консистентна между NetworkData.counterpartyTotal, NetworkCounterpartyPage.total и UI-я (cpTotal === null → отделен текст). Отлично — избягва подвеждащ етикет „top N of M".
  • Преизползване на COUNT: маршрутът /network подава вече изчисления брой (total: data.counterpartyTotal) към getEntityCounterparties, спестявайки втори идентичен scan (D1 таксува на прочетен ред). Логиката коректно прибягва до собствен COUNT(*), когато е подадено null (провал), а не се доверява на липсваща стойност — покрито от тест.
  • Нормализация авторитет → фирма: редовете винаги се нормализират независимо от вида на центъра; keyset tiebreak-ът е на правилната колона (bidder_id за авторитетен център, authority_id за фирмен). И двата пътя вече са тествани (предишният преглед е бил прав, че фирменият път е бил непокрит).
  • Ключ на React ред: getKey е на slug href-овете, не на етикетите — коментарът правилно отбелязва, че две различни същности могат да споделят де-брандирано име.

Не открих логически дефекти, изтичане на ресурси или race conditions в предоставения diff.


Тестове

Силна страна на PR-а. network.test.ts добавя целенасочени тестове, които разкриват дефекти, а не тривиално минават: fake D1, ключиран по SQL маркери, покрива провал на COUNT, провал при зареждане на центъра (запазване на реалния total), легитимна нула, forward/backward keyset разходка, преизползване на подаден total и отхвърляне на cursor от друг център. Няма „cheater" тестове. (Точното % покритие не може да се потвърди локално — моля, проверете CI гейта ≥90%.)

Производителност

  • Заявките за редове и COUNT вървят паралелно (Promise.all).
  • Keyset пагинация (O(1) на всяка дълбочина) вместо OFFSET.
  • g=1 пътят пропуска picker опциите и таблицата при client re-centre — избягва заплащане за отхвърлени резултати.
  • touch-action: none предава touch жестовете на d3-zoom. Няма регресии.

Документация / стил

Изчерпателни, точни коментари в контракта и заявките. CSS е консистентен с проектните променливи (var(--accent) и т.н.). Дребна забележка (не блокира): :fullscreen е без -webkit- префикс — приемливо предвид progressive-enhancement подхода.


CLAUDE.md съответствие

Без частична имплементация, без TODO, без дублиране, без мъртъв код, без смесени отговорности, без изтичане на ресурси. Промяната е атомарна и в обхвата на тикета (#142).

VERDICT: COMMENT — няма блокиращи или security находки; препоръчвам одобрение след потвърждаване на CI покритието (≥90%) и зеления status на checks.

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jul 18, 2026
Resolve conflicts from today's round-3 review-fix commits landing on main:
- apps/web/app/lib/query-params.ts: keep main's shared CANONICAL_QUERY_PARAMS
  source of truth, add this PR's params (angle, by, cpv, cpvSort, cur, step)
  used by the redesigned /trends and /overruns routes, and restore the
  RESERVED_CACHE_PARAMS reservation for 'g' (midt-bg#144, still open).
- apps/web/workers/cache-key.ts: adopt main's shared-import structure, keep
  this PR's cpv multi-select grouping/sort-canonicalization logic.
- apps/web/workers/cache-key.test.ts: merge both branches' drift-guard tests;
  restore whitespace-tolerant regex so multi-line .getAll() chains are scanned.
- apps/web/app/styles/components.css: concatenate additive, non-overlapping
  blocks from both sides (fullscreen/metric-info/list-search vs EU-benchmark).
- packages/config/src/index.test.ts, packages/db/src/competition-sql.test.ts:
  merge additive imports/fixtures/tests from both sides.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR #142 — force-directed мрежов граф (progressive enhancement)

Какво прави PR-ът

PR добавя интерактивен force-directed мрежов граф на страницата /network с progressive enhancement: сървърът рендира детерминистичен статичен SVG (без JS), а d3 симулацията (d3-force/drag/selection/zoom@^3) стартира едва след hydration на клиента. Отговорностите са чисто разделени между чиста геометрия/физика (network-layout.ts), жизнен цикъл на d3 (useForceGraph.ts) и презентация (NetworkGraph.tsx), с един източник на истина за layout-а между SSR и клиента, което предотвратява hydration mismatch. Добавени са също параметризирани заявки към D1 (network.ts), keyset пагинация, ?center ре-центриране и таблица с връзки като каноничен клавиатурен/AT път.

Сигурност — ЧИСТО

И двете партиди преминаха задължителния скенер за сигурност без блокиращи находки:

  • Няма зашити тайни, креденшъли или токени; няма нови/външни URL адреси.
  • Няма злонамерени шаблони (eval, Function, dangerouslySetInnerHTML, innerHTML, динамичен import(), обфускация); целият изход минава през JSX escaping на React.
  • SQL заявките към D1 са параметризирани (.bind(...)); интерполират се само вътрешни константи, ограничени чрез whitelist (allowedIdCols: ['authority_id','bidder_id']). Потребителският вход (?center, ?cursor) не достига до текста на SQL.
  • ?center се валидира стриктно (parseCenter, isValidEikSlug, bidderIdFromSlug); centerToken използва encodeURIComponent, а декодирането връща null при повреден вход вместо да хвърля. Курсорът е обвързан към същността чрез filterSignature, което нулира чужди курсори до страница 1.
  • Новите d3 зависимости са легитимни; integrity хешовете в pnpm-lock.yaml съвпадат с публичните (препоръка: да се потвърди присъствие в allowlist на монорепото).

Силни страни

  • Целостност на данните: конвенцията „неуспешен COUNT → null (неизвестно)" е приложена последователно (counterpartyTotal, total, cpTotal === null), без да се маскира като реална нула или капа. Броячът и списъкът споделят един и същ WHERE филтър.
  • Достъпност: таблицата с връзки остава клавиатурен/AT път; интерактивните възли са извън tab-реда; спазено prefers-reduced-motion; touch таргети до 44px.
  • Ресурси: всички listener-и, абонаменти и d3 симулация се почистват коректно; редът на hooks е стабилен.
  • Тестове: силно покритие на чистите помощници и заявките (round-trip токени, null при неуспешен COUNT, keyset разходка напред/назад, нулиране на курсор, company-център път).

Находка за потвърждение преди merge (единствена същинска)

hoveredRelations за централния възел приема counterpartyTotal, който може да е null при неуспешен COUNT(*), и извиква count(null) без предпазната клауза, която авторите вече прилагат в надписа отдолу (counterpartyTotal === null ? … : count(counterpartyTotal)). Тази асиметрия подсказва, че count(null) не е доверен вход. Моля потвърдете, че count(null) рендира смислено (напр. „—"); в противен случай Information Card ще покаже подвеждаща/счупена стойност в ръбовия случай на провалено броене — приложете същата предпазна клауза.

Незадължителни бележки

  • COUNTERPARTY_PAGE_SIZE = 25 в network.ts служи като default, но маршрутът винаги подава PAGE_SIZE.network — потвърдете, че двете стойности са синхронизирани (един източник на истина).
  • heroHref не енкодира n.slug; безопасно днес (base64url/цифри), но encodeURIComponent би бил защита в дълбочина, ако инвариантът за slug се промени.
  • Жизненият цикъл на самата симулация (drag/zoom/tick през DOM) не е покрит с интеграционен тест — приемливо предвид сложността на DOM средата.

Заключение

Силно, добре структурирано и добре документирано PR с чиста сигурност, последователна цялост на данните и добро тестово покритие. Няма блокиращи проблеми със сигурността. Преди merge: потвърдете поведението на count(null) в Information Card (партида 1). Останалите бележки са незадължителни.

Обща оценка: одобрено при потвърждение на находката count(null).

Comment thread apps/web/app/components/NetworkGraph.tsx
Comment thread packages/db/src/queries/network.ts Outdated
…ross-referencing comments

Documents that the /network route's explicit pageSize (apps/web/app/lib/filters.ts) and the
packages/db query's default pageSize must stay in sync, since packages/db cannot import from
apps/web without inverting the package dependency direction.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревю на PR #142 — „force-directed мрежова графика (progressive enhancement)"

Какво прави PR-ът

PR-ът добавя интерактивна force-directed мрежова графика към /network като прогресивно подобрение. Клиентската част (React/TypeScript) използва d3-force, d3-drag, d3-selection и d3-zoom, рендира се коректно при SSR без hydration mismatch (детерминистичен seed, споделен между SSR и симулацията), уважава reduced-motion и предлага достъпна алтернатива чрез канонична таблица с връзки. Сървърната част добавя keyset-пагинация, нормализация authority→company и повторно използване на вече изчисления COUNT(*), за да се избегне дублиран скан в D1. Тестовете са значително разширени (+232 реда) и покриват реалните разклонения — провал на COUNT, незареждащ се център, легитимна нула, next/prev обхождане и нулиране на чужд курсор.

Сигурност — ЧИСТО

И двете партиди минаха скенинг за сигурност без блокери:

  • Няма SQL инжекции: всички стойности се подават през параметризирани .bind(...); имената на колони идват от фиксиран whitelist (allowedIdCols: ['authority_id','bidder_id']), а не от вход на потребителя.
  • Строга валидация на входа: parseCenter валидира ?center токена (isValidEikSlug/bidderIdFromSlug), отхвърля непознати kind и малформирани токени; кръговата симетрия с centerToken е покрита с тестове.
  • Няма зашити тайни, XSS, dangerouslySetInnerHTML, eval или обфускация. Етикетите и href-овете минават през автоматичното екраниране на React.
  • Нови зависимости: d3-* (^3.0.0) + @types/* — утвърдени пакети; pnpm-lock.yaml фиксира точни версии 3.0.0 с валидни integrity хешове и коректно транзитивно дърво. Няма признаци за supply-chain риск.

Интегритет на данните — добре обмислено

Разграничението между „неизвестно" (total === null при провал на COUNT(*)) и реалната нула е последователно спазено през целия стек (getEntityNetwork, getEntityCounterparties и UI надписа под графа) и е покрито с целенасочени тестове. Курсорът е обвързан с центъра чрез filterSignature, така че ръчно редактиран ?center=B&cursor=<от A> се нулира до страница 1 вместо да мис-пагинира.

Основно намиране за потвърждение (не-блокер)

Информационната карта може да подаде null на count() (NetworkGraph.tsx, блок net-card-stats): const hoveredRelations = hoveredIsCenter ? counterpartyTotal : hoveredDegree;<dd>{count(hoveredRelations)}</dd>. Когато counterpartyTotal === null и потребителят посочи централния възел, на count() се подава null — при hover това може да доведе до runtime crash или до показване на фалшива стойност (напр. „0"), което противоречи на собствения принцип на PR-а „никога не твърди пълнота". За разлика от надписа под графа, картата не обработва null явно.
Препоръка: обработете null явно в картата (напр. „—"/„неизвестно"), симетрично с логиката на надписа. Моля потвърдете дали g=1 (client re-centre) връща counterpartyTotal, за да се прецени честотата.

Дребни бележки (незадължителни)

  • UX: ако reset() се извика по време на fetch, индикаторът „Зареждане…" остава видим до приключване на игнорирания fetch — визуално подвеждащо за кратко, но функционално безопасно (pending коректно отхвърля резултата).
  • Дублирана константа за размер на страница — приемлива предвид границите на пакетите, но носи риск от тихо разминаване.

Силни страни

Коректен ред на hooks преди early-return; чисто освобождаване на ресурси (спиране на симулацията, откачане на drag/zoom/fullscreen/reduced-motion listener-и); d3 работи върху копия и не мутира props (с тест); премислена достъпност; смислени, не-тривиални тестове с негативни входове и проверки за детерминизъм.

Заключение

Вердикт: APPROVE с една уговорка. Няма блокери по сигурността или интегритета на данните. Единственото нещо, което изисква внимание преди merge, е обработката на count(null) в информационната карта (намиране по-горе) — моля потвърдете поведението/честотата и добавете явна обработка на null. Merge при условие че CI е зелен (pnpm test, типова проверка и gh pr checks).

Comment thread packages/db/src/queries/network.ts Outdated
…_PAGE_SIZE

Exports COUNTERPARTY_PAGE_SIZE from @sigma/db and asserts equality with apps/web/app/lib/filters.ts PAGE_SIZE.network, so the two values (bound only by cross-referencing comments since bcf45b3) can't silently diverge.
…ly CSRF

- pin postcss ^8.5.18 (GHSA-r28c-9q8g-f849, path traversal via
  sourceMappingURL auto-load), patch-level, fixes it for real
- pin valibot ^1.4.2 (GHSA-5qjj-4xww-7phc, flatten() crash on
  inherited-property keys), patch-level, fixes it for real
- add osv-scanner.toml suppression for GHSA-qwww-vcr4-c8h2
  (react-router CSRF, unstable RSC code paths only; this app has zero
  RSC usage, verified via repo-wide grep); no fix exists in the 7.x
  line, and bumping to 8.x is an out-of-scope major version change
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Reopening to force a fresh CI trigger — the last several pushes to this PR haven't produced any GitHub Actions run at all (verified: not queued, not pending, simply never created).

@StanislavBG StanislavBG reopened this Jul 27, 2026
Resolve conflicts: osv-scanner.toml (keep the superset - sharp +
react-router suppressions), packages/db/src/queries/identity.ts (keep
isValidEikSlug plus main's updated companySlug doc comment),
apps/web/app/routes/network.tsx (keep this branch's counterparty
pagination/graphOnly loader while adopting main's getDb() wrapper
convention), pnpm-lock.yaml (regenerated via pnpm install)
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

apps/web/app/styles/pages.css:543.network-svg.is-interactive { touch-action: none } спира вертикалния скрол на страницата при touch. Графът е пълноширок блок; на телефон (360px) потребител, попаднал върху него, не може да скролне надолу покрай него.

d3-zoom вече филтрира wheel до ctrl/meta (за да не граби скрола на страницата) — touch трябва да е симетрично.

Поправка: touch-action: pan-y на .network-svg.is-interactive, или премести touch-action: none върху вътрешния .net-canvas слой, за да остане скролът на страницата.

@nedda76

nedda76 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Този клон е в конфликт с main, тъй че към момента не може да се ревюира — дифът, който GitHub показва, вече не отговаря на това, което би влязло. Ще го пребазираш ли върху актуалния main (или merge на main в клона) и да разрешиш конфликтите? След това веднага го поглеждам. Благодаря! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants