Skip to content

feat(web): overruns dashboard - #171

Open
StanislavBG wants to merge 32 commits into
midt-bg:mainfrom
StanislavBG:pr/overruns
Open

feat(web): overruns dashboard#171
StanislavBG wants to merge 32 commits into
midt-bg:mainfrom
StanislavBG:pr/overruns

Conversation

@StanislavBG

@StanislavBG StanislavBG commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

What changed

Overruns dashboard (/overruns) and the обзор /trends page, plus a full pass of ydimitrof's review rounds:

  • d694927 — moved the precise MAX_CPV_GROUP_SELECTION cap in cpvGroupSelection (lib/filters.ts) to after comma-split/trim/validation/dedup, keeping only a loose raw-input bound (MAX_CPV_RAW_VALUES) beforehand so leading invalid ?cpv= params can no longer crowd out valid codes.
  • d694927 — sorted cpvSel in the /trends loader to match hrefToggleCpv's own sort, so a reordered ?cpv= URL with the same selection collapses onto one canonical edge-cache key.
  • Confirmed (no code change): TrendYear.year is declared string in @sigma/api-contract, matching the loader's regex-derived year, so the y.year === year toggle comparison is correct as written.
  • Keyboard-focus parity on the trend chart's bars: tabIndex={0} + onFocus mirroring onMouseEnter.
  • Fixed CPV multi-select trimming order (earlier round) — cap applied before flatMap/dedup, since superseded by the post-validation cap above.
  • Removed the unused singleSelectFilters import from trends.tsx (lint fix).
  • Documented and confirmed as-is the partial-index composition in 0002_contracts_overrun_index.sql (annex_count-only partial is sufficient today; composite alternative noted and deferred).
  • Added an invariant test for the CPV-bucket check-order edge case (CPV_BUCKET_WORKS/SERVICESCPV_DIVISION_SET) so future drift fails CI instead of silently falling to other.
  • Merged current main in (resolved a real query-param-canonicalization conflict) and fixed a real unclosed-CSS-block bug introduced by that merge.

How it was tested

  • pnpm --filter web typecheck and pnpm --filter web test — full web suite, including ComboTrendChart, filters.test.ts cpv-cap coverage, and cache-key coverage.
  • pnpm --filter db test — full db suite, including the CPV-bucket invariant test.
  • Manual load of /overruns and /trends after the merge to confirm the CSS fix and param canonicalization didn't regress rendering.

Quality checks

  • CI green on the current head commit (d694927).
  • All outstanding review threads, including ydimitrof's 2026-07-20 round, re-verified against the landed diff and resolved.

@nedda76 nedda76 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.

Преглед на PR #171 (overruns dashboard)

Прегледах PR-а в реалния му обхват — само overruns промените върху trends базата (git diff pr-170...pr-171): новият маршрут /overruns, заявката queries/overruns.ts, lib-овете за графиката/инспектора и cpvBucket в config. Кодът е добре структуриран — чиста геометрия в overruns-chart.ts, споделени SQL константи (DELTA/PCT/SIGNING/OVERRUN_WHERE), маршрутът ползва @sigma/shared форматерите. Едно реално correctness нещо плюс няколко по-малки за подреждане. Виж inline. 🙏

Две бележки извън дифа:

  • Покритие на cpvBucket: bucket-ите (works/goods/services) се водят от ръчно поддържан списък service-дивизии. Днес и 21-те съвпадат с CPV_SECTORS, но нов service-код, добавен в CPV_SECTORS без обновяване на сета, тихо ще попадне в „goods". Струва си тест, който твърди, че всеки код от CPV_SECTORS се резолвва до non-default bucket.
  • Тяло на PR-а: описанието завършва с „🤖 Generated with Claude Code". Конвенцията на репото забранява Anthropic атрибуция в GitHub съдържанието (виж AGENTS.md — чиста история, CI може да grep-ва за това); бих помолила да отпадне.

Comment thread packages/db/src/queries/overruns.ts Outdated
Comment thread packages/db/src/queries/overruns.ts Outdated
Comment thread packages/db/src/queries/overruns.ts
Comment thread packages/db/src/queries/overruns.ts Outdated
Comment thread apps/web/app/routes/overruns.tsx Outdated

@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.

Пуснах целия stack локално и този е силен — проверих го от всеки ъгъл и почти всичко е както трябва. Потвърдих: €1000 подовият праг е удвоен и в JS-а (mapOverrunRows), та near-zero signing не може да гръмне pct-а до Infinity/NaN; NULL-овете (value_suspect/annex_suspect) падат сами през current > signing; растежът по възложител/сектор е €-претеглен (SUM(delta)/SUM(signing), не средно на процентите) и делението е guard-нато; медианата е коректна (window + integer-division прозорец, COALESCE(AVG,0) при n=0); scatter-ът floor-ва log-оста на minPctFloor и пази logSpan || 1; overrunBarGeometry има current<=0 guard; cpvBucket е чист дял (минах всичките 45 division-а срещу CPV каталога + Директива 2014/24/EU — services сетът е пълен, 44/43→goods, 45→works, unknown→other); а /overruns чете само by (keyed).

И най-важното — framing-ът е честен: headline KPI-ят е МЕДИАНА, а средното е само в tooltip с изричното „изкривено нагоре от малкото огромни раздувания". Точно така трябва. 👏

Едно watch-item (детайл инлайн) и една координационна бележка:

  • perf (watch, не блокер): corpus single-pass-ът е единственото пълно сканиране на таблицата.
  • това е PR 3/4 и наследява cache-key промяната от #169 — чийто изтрит CWE-349 drift guard е отделният блокер на стека. /overruns сам не въвежда нов drift (чете само by, keyed), но #169 трябва да се оправи преди което и да е от стека да влезе; merge-редът (dash-base → trends → overruns) така или иначе го налага.

Approve по същество.

Comment thread packages/db/src/queries/overruns.ts
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Благодаря за щателния преглед — всичко адресирано (force-push 4cbab83):

🔴 Анекс хронология: ORDER BY c.id, am.published_at IS NULL, am.published_at, am.natural_key (недатираните най-отзад).
🟠 CPV нормализация: нов споделен cpvDivision() (strip-then-2-char) в @sigma/config; SQL групите се ре-кейват към каноничната дивизия и се сливат в JS — мръсен префикс вече не разделя реална дивизия. Тест добавен.
🟠 Дублиран median SQL: изваден в MEDIAN_PCT_SQL. getOverrunsHeadline НЕ е мъртъв — ползва се от analytics.tsx, та само deduped.
Reuse: SECTOR_LABELSsectorRef; един sectorLabel helper; sortHrefwithParams.
cpvBucket покритие: тест, че всеки CPV_SECTORS код резолвва до non-default bucket.
perf watch (corpus scan): оставено за v1 — precompute-ът е ETL промяна; флагнато.
PR body: махнах Anthropic атрибуцията.

@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.

Върнах се на този след fix-овете — анекс хронологията (nulls last) и median dedup-ът са наред. Но CPV нормализацията е приложена върху грешната стойност, та не затваря докрай ръба, за който беше добавена.

by-sector заявката selectва substr(t.cpv_code, 1, 2) AS division и после JS re-keyва cpvDivision(r.division) (≈ред 524) — т.е. нормализира вече отрязаните 2 символа. Leaderboard-ът го прави правилно върху пълния код: cpvDivision(r.cpv_code) (ред 269). При водещ не-цифров символ двете се разминават:

cpv_code leaderboard by-sector
45000000 45 45
' 45000000' 45 4
' 9000000' 90 9

Тоест същият договор попада в различен CPV сектор в класацията спрямо таблицата по сектори, а fix-ът не постига заявената цел („stray leading char"). cpv_code се пази суров (ingest-ът го взима като text, normalize-raw.sql не го trim-ва), та водещ боклук може да стигне до сервирания tenders.

Защитната поправка: selectни пълния t.cpv_code в by-sector заявката и пусни cpvDivision() върху него (както на ред 269) — тогава двете повърхности съвпадат. Дребно и зависи от това колко мръсни CPV кода реално има, но е дефанзивно.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Отразено в cdfe174:

  • by-sector заявката вече селектира пълния t.cpv_code (GROUP BY по него) и cpvDivision() се изпълнява върху пълния код в JS — същата нормализация като leaderboard-а (ред ~269), двете повърхности вече не се разминават при мръсен водещ символ ( 45000000 → 45 и на двете).
  • merge-логиката в JS остава — кодовете се сгъват към каноничната дивизия, secLimit се прилага след сливането.
  • добавен тест: мръсен CPV минава през by-sector пътя и се слива в дивизия 45, с етикет идентичен на leaderboard-а.
  • pnpm --filter @sigma/db test (overruns: 29/29) и typecheck — зелени.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Perf доуточнение върху предишния fix (97f5d99): GROUP BY върху пълния cpv_code връщаше ред на уникален код (582 на локалния корпус за 15-редова таблица). Сега SQL-ът групира по SECTOR_KEY_SQL — replace-верига маха реалните сепаратори и взима substr(clean,1,2) само когато резултатът е изцяло цифров (в този случай изразът е доказуемо ≡ cpvDivision()); всичко още мръсно пада като суров ключ към JS re-key-а. Резултатният сет е обратно с размер на дивизия (≤~100 реда). Интеграционен тест (overruns-sql.test.ts) заковава SQL≡JS еквивалентността върху целия dirt-корпус + unit тест пази срещу връщане на substr(t.cpv_code/GROUP BY t.cpv_code. 199/199 db теста.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Рестакнат върху преработения #170 („обзор“) — старите trends къмити (95de011 линията) отпадат от историята, за да не възкръсне старият trends.tsx при мърдж. Собственият diff е непроменен: diff спрямо pr/trends показва само overruns файловете. Нов head: 8af3677; typecheck 7/7, тестовете зелени.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

По молбата — SECTOR_KEY_SQL (overruns.ts:176), проверено локално:

  • Ключът чисти разделителите и взима substr(clean,1,2) само при GLOB '[0-9][0-9]*', иначе fallback към суровия код. Никога не слива две различни дивизии в един ключ; под-разделените фрагменти (мръсни кодове) се сгъват обратно в JS през cpvDivision, при което risk/signing/count се СУМИРАТ наново (sectorAgg, редове 539–548), а growth = risk/signing се смята СЛЕД сгъването. Коректно и в lock-step с leaderboard-а.
  • OVERRUN_WHERE изисква annex_count > 0 (ред 156) + current > signing + signing >= 1000, тъй че pct = delta/signing е безопасно и дефиницията „раздут чрез анекс" се спазва.
  • Партишън индексът idx_contracts_overrun ... WHERE annex_count > 0 реално се ползва от предиката (EXPLAIN: SEARCH c USING INDEX idx_contracts_overrun). Валиден.

Approve; merge след #170. Same 0002-миграция бележка като на #170 — преномерирай на 0003 спрямо #188.

@ydimitrof

Copy link
Copy Markdown
Contributor

Преглед на PR #171 — „overruns dashboard" (pr/overruns). Прегледах дифа спрямо main изцяло, с акцент върху сигурност, SQL и целостта на данните, и прочетох цялата дискусия по PR-а.

Сигурност / SQL (OWASP A03 — Injection)

Прегледах всяка заявка в packages/db/src/queries/overruns.ts и новите пасажи в trend.ts. Чисто:

  • Няма нанизване на потребителски вход в SQL. Всички стойности минават през .bind(...)LIMIT (клампнат в clampLimit), IN (...) плейсхолдъри в getOverrunAnnexes, CPV диапазоните в cpvGroupsClause ((t.cpv_code >= ? AND t.cpv_code < ?)). Единствените интерполирани фрагменти (OVERRUN_WHERE, DELTA, PCT, SECTOR_KEY_SQL) са изградени само от литерали и имена на колони, не от заявка на клиента.
  • SECTOR_KEY_SQL оперира върху колоната t.cpv_code, не върху вход от URL — няма инжекционна повърхност. Проверих и претендираната еквивалентност SECTOR_KEY_SQL ≡ cpvDivision: SQL взима substr(clean,1,2) само при GLOB '[0-9][0-9]*' (където изтриването на сепаратори доказуемо не разбърква цифрите), иначе fallback към суровия код, който JS re-key-ът (cpvDivision) сгъва в каноничната дивизия. Двете повърхности вече съвпадат при мръсен водещ символ — ръбът от предишния кръг е затворен коректно.
  • Валидация на входа (OWASP A03/allowlist). /overruns чете само by (строго percent|absolute). /trends минава angle/step/sort/cpvSort през pick() allowlist, year през /^20\d\d$/, а ?cpv през cpvGroupSelection — валидни 5-цифрени кодове, дедуп, капнати на 10, преди да стигнат SQL или да породят кеш-ключ (защита срещу неограничен fan-out и CWE-349).
  • XSS (A03): всички изведени стойности минават през React escaping; SVG координатите са числови (toFixed/round1). Няма dangerouslySetInnerHTML, няма сурови href от данни — линковете ползват slug helpers.
  • Няма тайни, няма нови зависимости, няма мрежови/SSRF повърхности, няма промени по auth. cache-key.ts добавя новите параметри (by, cpv, cohort, …) в allow-list-а коректно.

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

Потвърждавам наблюденията на колегите: €1000 подов праг двойно защитен (WHERE + mapOverrunRows), делението delta/signing е безопасно, растежът е €-претеглен (SUM(delta)/SUM(signing)), медианата през window pass с COALESCE(AVG,0), а геометрията (overrunBarGeometry, scatterGeometry) е guard-ната срещу деление на нула / NaN / Infinity. Framing-ът е честен — headline е медиана, средното само в tooltip с уговорка. cpvGroupRange дава коректна половин-отворена горна граница дори за кодове, завършващи на 9.

Забележки (не блокери)

  1. Номерация на миграцията. 0002_contracts_overrun_index.sql се сблъсква с 0002_contract_health.sql от feat: индекс на качеството на договорите (ETL оценка 0..1 + страница) #188 (вече отбелязано от @lyubomir-bozhinov). Преди мърдж да се преномерира на 0003, за да не се дублира версията на веригата.
  2. CI не се отчита на клона (no checks reported on 'pr/overruns'). Не можах да пусна пакетните тестове локално (node_modules липсва в worktree-а). Разчитам на докладваните от контрибутора 199/199 db + зелен build, но преди мърдж искам да видя реален зелен CI на PR-а.
  3. Покритие на cpvBucket (A04, поддръжка): нова service-дивизия, добавена в CPV_SECTORS без обновяване на CPV_BUCKET_SERVICES, тихо пада в „goods". Смекчено с добавения тест, който твърди non-default bucket за всеки код.
  4. perf watch: corpus single-pass сканирането е единственото пълно сканиране; прието за отложено към v1 — приемливо.

Обхватът е чист (само overruns файловете спрямо pr/trends), тестовете придружават кода, атрибуцията в тялото е премахната по молба. Съгласявам се по същество с двамата collaborator-и — от гледна точка на сигурност и цялост на данните няма блокери в самия diff. Единственото условие преди мърдж е преномерирането на миграцията и зелен CI, плюс изчакване на #170/#169 по merge-реда.

ВЕРДИКТ: APPROVE (по същество) — при условие че миграцията се преномерира на 0003 и CI мине зелено; мърдж след #170 по реда на стека.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebase-нат върху main с css split (ov-* → styles/pages.css; 0002_contracts_overrun_index остава валиден номер), prettier-чист, линеен. Старите нишки резолвнати — всички находки бяха адресирани и препотвърдени в последвалите прегледи.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

Добавени са ⓘ дефиниции на колоните в двете таблици („Кои институции раздуват най-много" и „Раздуване по сектори") — какво означава метриката и как се смята (растежът е претеглен по €, не средно на процентите; включени са само договори с анекс, текуща > подписана и подписана ≥ 1000 €). Плюс дишащо поле вдясно в редовете на „Договори по мащаб". Нов head: 52687a6.

@StanislavBG
StanislavBG force-pushed the pr/overruns branch 2 times, most recently from 108d095 to b99bb14 Compare July 3, 2026 17:36
@ydimitrof

Copy link
Copy Markdown
Contributor

Преглед на PR #171 — „overruns dashboard" (pr/overruns)

Прегледах целия diff локално с акцент върху сигурност, SQL и целостта на данните, прочетох цялата дискусия и препотвърдих находките на колегите независимо. PR-ът е част 3/4 от стека (dash-base → trends → overruns → analyze) и целѝ main заради кръстосаното форк-стакване, затова четох собствения обхват спрямо pr/trends.

Сигурност (OWASP A03 — Injection / XSS)

Чисто. Проверих всяка заявка ред по ред:

  • Няма конкатенация на потребителски вход в SQL. Всички стойности минават през .bind(...): LIMIT (клампнат в clampLimit), IN (...) плейсхолдърите в getOverrunAnnexes, CPV полу-отворените диапазони (t.cpv_code >= ? AND t.cpv_code < ?). Единствените интерполирани фрагменти — OVERRUN_WHERE, DELTA, PCT, SIGNING, SECTOR_KEY_SQL, CPV_CLEAN — са изградени само от литерали и имена на колони, никога от заявка на клиента.
  • SECTOR_KEY_SQL оперира върху колоната t.cpv_code, не върху URL вход — няма инжекционна повърхност. Препроверих претендираната еквивалентност SECTOR_KEY_SQL ≡ cpvDivision: substr(clean,1,2) се взима само при GLOB '[0-9][0-9]*' (където изтриването на сепаратори доказуемо не разбърква реда на цифрите), иначе fallback към суровия код, който JS re-key-ът (cpvDivision, ред 541) сгъва в каноничната дивизия и пресумира risk/signing/count. Двете повърхности (leaderboard и by-sector) вече съвпадат при мръсен водещ символ — ръбът от предишния кръг е затворен коректно.
  • Валидация на входа (allowlist). /overruns чете само by (строго percent|absolute, ред 49). CPV мулти-селектът минава през /^\d{5}$/, дедуп и cap на 10 (cpvGroupSelection / validCpvGroups) преди SQL или кеш-ключ. cpvGroupRange дава коректна горна граница дори за кодове, завършващи на 9 (проверих лексикографски: "45999999" < "4599:").
  • XSS: няма dangerouslySetInnerHTML, innerHTML, eval или document.write в новите компоненти/lib-ове; всички изведени стойности минават през React escaping, SVG координатите са числови (round1/toFixed). Линковете ползват slug helpers — няма сурови href от данни.
  • Кеш-ключ (CWE-349): cache-key.ts добавя by, cpv, angle, step, cohort, metric, cpvSort, page в allow-list-а коректно; drift-гардът в теста пази инварианта.
  • Няма тайни, няма нови зависимости, няма мрежови/SSRF повърхности, няма промени по auth.

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

Потвърждавам наблюденията на @lyubomir-bozhinov и @nedda76: €1000 подовият праг е двойно защитен (WHERE ред 158 + mapOverrunRows ред 277), делението delta/signing е безопасно, растежът по възложител/сектор е €-претеглен (SUM(delta)/SUM(signing), не средно на процентите), медианата е през window pass с COALESCE(AVG,0) при n=0, а геометрията (overrunBarGeometry current<=0 guard, scatterGeometry) е guard-ната срещу деление на нула / NaN / Infinity. Framing-ът е честен — headline е медиана, средното е само в tooltip. Индексът idx_contracts_overrun ... WHERE annex_count > 0 реално се ползва от предиката.

Забележки (не блокери)

  1. Номерация на миграцията. 0002_contracts_overrun_index.sql се сблъсква с 0002_contract_health.sql от feat: индекс на качеството на договорите (ETL оценка 0..1 + страница) #188. Преди мърдж да се преномерира на 0003, за да не се дублира версията на веригата.
  2. Покритие на cpvBucket (A04, поддръжка). CPV_BUCKET_SERVICES е ръчно поддържан сет. Добавеният тест твърди „non-default bucket", но „goods" също е non-default — тоест нова service-дивизия, добавена в CPV_SECTORS без обновяване на сета, ще падне тихо в „goods" и тестът няма да я хване. Струва си тестът да закове точния очакван bucket за представителни service/works кодове, не просто ≠ other.
  3. CI не се отчита на клона. Не можах да пусна пакетните тестове локално (node_modules липсва в worktree-а); разчитам на докладваните 199/199 db + зелен build, но искам реален зелен CI на PR-а преди мърдж.

Обхватът е чист (само overruns файловете спрямо pr/trends), тестовете придружават кода, Anthropic атрибуцията е премахната по молба. От гледна точка на сигурност, OWASP съответствие и цялост на данните няма блокери в самия diff — потвърждавам по същество прегледите на двамата collaborator-и.

ВЕРДИКТ: APPROVE (по същество) — при условие че миграцията се преномерира на 0003 и CI мине зелено; мърдж след #170/#169 по реда на стека.

@ydimitrof

Copy link
Copy Markdown
Contributor

Преглед на PR #171 — „overruns dashboard" (pr/overruns)

Прегледах отново PR-а на текущия връх b99bb14 изцяло локално, с акцент върху сигурност, SQL инжекции и целостта на данните, препрочетох цялата дискусия и препотвърдих находките на колегите независимо. Това е част 3/4 от стека (dash-base → trends → overruns → analyze) и цели main заради кръстосаното форк-стакване, затова четох собствения обхват срещу pr/trends (git diff pr170...HEAD).

Обхват

Собственият diff е чист — само overruns файловете: routes/overruns.tsx, lib-овете overruns-chart.ts/overruns-inspector.ts, queries/overruns.ts, cpvBucket/cpvDivision в @sigma/config, миграцията 0002 и придружаващите тестове. Изтриването на .lens-* от layout.css не е в собствения diff на този PR — идва от базата на #169 и остава блокерът на онзи PR, не на този.

Сигурност (OWASP A03 — Injection / XSS)

Чисто. Проверих всяка заявка ред по ред в overruns.ts:

  • Няма конкатенация на потребителски вход в SQL. Всички стойности минават през .bind(...): LIMIT (клампнат в clampLimit), IN (...) плейсхолдърите в getOverrunAnnexes. Единствените интерполирани фрагменти — OVERRUN_WHERE, DELTA, PCT, SIGNING, SECTOR_KEY_SQL, CPV_CLEAN — са изградени само от литерали и имена на колони, никога от заявка на клиента.
  • SECTOR_KEY_SQL оперира върху колоната t.cpv_code, не върху URL вход — няма инжекционна повърхност. Препотвърдих SECTOR_KEY_SQL ≡ cpvDivision: substr(clean,1,2) само при GLOB '[0-9][0-9]*', иначе fallback към суровия код, който JS re-key-ът (cpvDivision) сгъва в каноничната дивизия и пресумира risk/signing/count. Leaderboard и by-sector съвпадат при мръсен водещ символ.
  • Валидация на входа (allowlist): /overruns чете само by (строго percent|absolute, loader ред 49).
  • XSS: нула dangerouslySetInnerHTML / innerHTML / eval / document.write в новите компоненти и lib-ове. Новият MetricInfo рендира само string props през React escaping, translate е числово изчислен от getBoundingClientRect, popover-ът е aria-hidden с чист CSS reveal; SVG координатите са числови (round1/toFixed). Линковете ползват slug helpers.
  • Кеш-ключ (CWE-349): cache-key.ts добавя by, cpv, cohort, step, metric, cpvSort, page в allow-list-а коректно.
  • Няма тайни, няма нови зависимости, няма мрежови/SSRF повърхности, няма промени по auth, няма злонамерен код.

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

Потвърждавам наблюденията на @lyubomir-bozhinov и @nedda76: €1000 подовият праг е двойно защитен (WHERE + mapOverrunRows), делението delta/signing е безопасно, растежът е €-претеглен (SUM(delta)/SUM(signing), не средно на процентите), медианата е през window pass с COALESCE(AVG,0) при n=0, геометрията (overrunBarGeometry current<=0 guard, scatterGeometry) е guard-ната срещу деление на нула / NaN / Infinity. cpvBucket е детерминиран дял с CPV_DIVISION_SET guard (unknown → other). Framing-ът е честен — headline е медиана, средното само в tooltip. Индексът idx_contracts_overrun ... WHERE annex_count > 0 реално се ползва от предиката.

Забележки (не блокери)

  1. Номерация на миграцията. 0002_contracts_overrun_index.sql се сблъсква с 0002_contract_health.sql от feat: индекс на качеството на договорите (ETL оценка 0..1 + страница) #188. Преди мърдж да се преномерира на 0003.
  2. Покритие на cpvBucket (A04, поддръжка). Тестът твърди „non-default bucket", но „goods" също е non-default — нова service-дивизия, добавена в CPV_SECTORS без обновяване на CPV_BUCKET_SERVICES, ще падне тихо в „goods" без тестът да я хване. Струва си да закове точния очакван bucket за представителни service/works кодове.
  3. CI не се отчита на клона / локални тестове. Worktree-ът няма node_modules, та не можах да пусна пакетните тестове сам; разчитам на докладваните 199/199 db + зелен build, но искам реален зелен CI на PR-а преди мърдж.

Обхватът е чист, тестовете придружават кода, Anthropic атрибуцията е премахната по молба. От гледна точка на сигурност, OWASP съответствие и цялост на данните няма блокери в самия diff.

ВЕРДИКТ: APPROVE (по същество) — при условие че миграцията се преномерира на 0003 и CI мине зелено; мърдж след #169/#170 по реда на стека (лещовата .lens-* регресия си остава блокерът на #169).

@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): overruns dashboard

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

PR-ът въвежда ново публично, кеширано (publicCache(1800)) табло „Раздуване" (overruns) в apps/web и свързаните с него пренаписвания. Основните части са:

  • Нови маршрути: routes/overruns.tsx (табло с раздувания по орган/сектор и inspector за анекси) и пренаписан routes/trends.tsx в изглед „Договори — обзор" с три ъгъла на гледане (време / CPV / кръстосано). И двата са SSR-safe, без-JS, управлявани изцяло през query string.
  • Чиста логика/математика, изнесена за тестируемост: overruns-chart.ts, overruns-inspector.ts, ComboTrendChart/TrendChart (рефакториран към TrendGranularity).
  • Валидация на вход и кеш-хигиена: filters.ts (cpvGroupSelection с whitelist ^\d{5}$, dedupe, таван 10) и cache-key.ts (премахнат g, добавен step).
  • DB слой: нова заявка overruns.ts, SECTOR_KEY_SQL, config (cpvBucket/cpvDivision), миграция 0002_contracts_overrun_index.sql (частичен индекс по annex_count > 0).
  • Стилове: голям прираст в styles/components.css (+904) и styles/pages.css (+3080).
  • Обширни, смислени unit тестове върху чистите модули, SQL хелперите и drift guard-овете.

Сигурност (Phase 0 / OWASP) — ЧИСТО

Във всички прегледани партиди: няма хардкоднати тайни, нови/променени URL адреси, нови зависимости или злонамерени шаблони (eval, dangerouslySetInnerHTML, обфускация, backdoor). Обработката на вход всъщност е сигурностно подобрение: cpvGroupSelection, pick() whitelist-и, строга валидация на year (/^20\d\d$/), нормализация на by до 'percent'|'absolute' — предотвратяват SQL-scope отравяне и разрастване на cache-key варианти (CWE-349). XSS повърхността е покрита от автоматичното React екраниране.

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

  1. Кеш колизия при миграция на gstep (cache-key.ts): g е премахнат от allow-list-а; ако trends.tsx все още чете sp.get('g'), два изгледа с различна гранулярност биха споделили кеш запис. Drift guard-ът в cache-key.test.ts проваля CI при все още консумиран g, така че е покрито — моля потвърдете, че trends.tsx е мигриран към step.
  2. Отслабен dead-key guard (cache-key.test.ts): вече се допуска allow-list ключ без консуматор, ако носи route-бележка. Съзнателен компромис, но донякъде отслабва откриването на мъртъв код.
  3. pages.css (+3080) не бе прегледан — diff-ът липсва (бинарен/твърде голям). Нужен е ръчен преглед за url()/@import към външни домейни и за дублиран/мъртъв код.
  4. Дребни устойчивости: non-null assertion rows[selected] ?? rows[0]! и key={s.code} (възможен празен низ) в overruns.tsx; дедупликация на missing през Set в trends.tsx; повтарящ се шрифтов литерал и възможен мъртъв .trend-col в components.css.
  5. UX уточнение: при ъгъл „Време × CPV" изборът на година филтрира само списъка договори, но не и графиката — потвърдете, че е желаното поведение.
  6. Достъпност: tooltip в ComboTrendChart е само onMouseEnter (недостъпен с клавиатура) — приемливо, тъй като данните дублират в достъпни year-карти.

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

Няма потвърдени блокиращи проблеми в прегледаните файлове. Единствено условие преди пълно одобрение: ръчен преглед на непрегледания pages.css (партида 5) и потвърждение, че trends.tsx е мигриран към step (точка 1). Пълните гейтове за покритие и интеграция на продукционния SQL с маршрутите се затварят едва при консолидация на всички партиди.

Обща оценка

Чиста, добре структурирана и добре тествана работа с осезаема сигурностна закалка при обработката на вход. 5 от 7 партиди са APPROVE; 2 са COMMENT само поради липсващ diff и междупартидни зависимости, а не поради дефекти. Препоръка: одобрение при затваряне на двете условия по-горе.

Comment thread apps/web/app/components/ComboTrendChart.tsx
Comment thread apps/web/app/lib/filters.ts
# Conflicts:
#	apps/web/app/lib/filters.test.ts
…rity

Steps 1-4 of the requested review fixes (CSS section naming, .fs-btn/
.trend-fs-btn consolidation, --paper-raised token, cache-key dead-entry
test) were already landed in an earlier push on this branch; verified
against current HEAD and left untouched. Only the ComboTrendChart
onFocus mirror was outstanding.

@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): overruns dashboard

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

Добавя ново табло „Overruns" (преразходи по договори) в уеб приложението и рефакторира свързаната страница „Trends/Договори — обзор". Промяната обхваща целия стек:

  • Презентационен слой (React/SVG): нови графики и компоненти — ComboTrendChart, TrendChart (с добавена quarter гранулярност), FullscreenButton/useFullscreen, MetricInfo, чиста геометрия за диаграми (overruns-chart.ts) и inspector-логика (overruns-inspector.ts).
  • Route-ове: overruns.tsx (ново табло с scatter/inspector) и рефакториран trends.tsx с строго валидиране на URL-вход.
  • Стилове: components.css, pages.css (+3080 реда), tokens.css.
  • Инфраструктура/данни: разширяване на CACHE_QUERY_PARAMS в workers/cache-key.ts, api-contract, config (CPV нормализация: cpvDivision/cpvBucket), нова миграция 0002_contracts_overrun_index.sql и нови/променени SQL заявки в packages/db (overruns.ts, trend.ts).

Обща оценка

Кодът е с високо качество, атомарен и фокусиран върху обхвата на тикета. Сигурността е чиста по цялата верига и е основната силна страна на PR-а.

Сигурност (Phase 0 / OWASP) — ЧИСТО ✅

  • Няма твърдо кодирани тайни, нови външни URL адреси, нови зависимости или обфускирани/backdoor шаблони в нито една партида.
  • Без XSS/инжекции: целият текст от БД се рендира като React children (авто-escape); няма dangerouslySetInnerHTML/eval.
  • SQL инжекции: всички потребителски входове минават през ? placeholder-и и .bind(); интерполираните SQL низове (OVERRUN_WHERE, SECTOR_KEY_SQL, PCT, DELTA и др.) са само от твърдо кодирани константи. CPV групите се валидират с /^\d{5}$/ преди SQL (тест потвърждава отхвърляне на 45'--).
  • Защита срещу отравяне на кеша (CWE-349): cpvGroupSelection налага allowlist, дедупликация и таван (MAX_CPV_GROUP_SELECTION = 10); CACHE_QUERY_PARAMS е коректно разширен и покрит с тестове.
  • Устойчивост: ограничени лимити (clampLimit, MAX_LIMIT = 200), защита срещу деление на нула на всички нива, N+1 избягнат чрез една bounded IN-list заявка.

Най-важни находки (за адресиране преди merge)

  1. [trends.tsx] Неизползван импорт singleSelectFilters (мъртъв код). Нарушава правилото „NO DEAD CODE" и вероятно ще счупи lint-а (no-unused-vars). Единствената находка, маркирана като REQUEST_CHANGES.
  2. [overruns.ts] Уточняване на authorityEik. В mapOverrunRows полето authorityEik получава authoritySlug(...) — същата стойност като slug-а. За bidder-а има отделна реална ЕИК колона, но за възложителя не. Моля потвърдете, че slug-ът наистина е ЕИК, или добавете реална ЕИК колона в leaderboardSql.
  3. Тестово покритие за нов код. overruns.ts (561 реда) и чистите функции в trends.tsx (pick, logMax и др.) нямат видими unit тестове в своите партиди. trend.ts и слоят config/queries са отлично покрити. При изискване за покритие ≥90% това трябва да се осигури преди merge.

По-дребни, незадължителни находки

  • [trends.tsx] Несъответствие в „Обобщение" при активен CPV филтър: „обща стойност"/„договори" са филтрирани, но „CPV групи" (stats.totalGroups) е глобален топ — може да обърка; означете като глобален контекст или приведете към обхвата.
  • [trends.tsx] Възможни дубликати към getCpvGroupMedians — дедупликирайте missing през Set.
  • Клавиатурна достъпност: интерактивните scatter-точки (<circle onClick>) и SVG правоъгълниците в ComboTrendChart не са фокусируеми/нямат клавиатурен обработчик. Смекчено от паралелните списъци/бутони и role="img"; нисък приоритет.
  • [pages.css] Липсва видим patch (+3080 реда) — изисква ръчна проверка за дублиране/мъртъв CSS и външни url() препратки.
  • [CSS] Модерни функции (color-mix(in oklch), backdrop-filter, min()) — потвърдете спрямо матрицата за поддържани браузъри; повторение на шрифтовия стек в ~15 правила.
  • [миграция 0002] Частичен индекс покрива само annex_count; обмислете composite индекс, ако агрегатите станат тесни.

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

Няма блокиращи проблеми по сигурност или коректност. Единственото формално блокиране е находка №1 (мъртъв код в trends.tsx) заради строгите quality gates. След премахване на неизползвания импорт, потвърждаване на ЕИК-логиката (№2) и осигуряване на тестово покритие за overruns.ts (№3), PR-ът е готов за merge.

Обща препоръка: REQUEST_CHANGES — само заради мъртвия код; всичко останало са уточнения/препоръки. Качеството и сигурността са на много високо ниво (~9.3/10).

Comment thread apps/web/app/components/ComboTrendChart.tsx Outdated
Comment thread apps/web/app/lib/filters.ts
Comment thread apps/web/app/routes/trends.tsx Outdated
Comment thread packages/db/migrations/0002_contracts_overrun_index.sql Outdated
Comment thread packages/config/src/index.ts

@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.

Прегледах стриктно на връх a1acc38. Архитектурата е solid: споделен OVERRUN_WHERE (leaderboard и агрегати не могат да се разминат), €-претеглен SUM(delta)/SUM(signing) (а не AVG(pct)), SECTOR_KEY_SQL ≡ cpvDivision доказан на dirty корпуса, no N+1, коректно ORDER BY на анексите (недатираните последни).

Блокер (все още отворен на HEAD — причината за CHANGES_REQUESTED на @ydimitrof):

  • apps/web/app/routes/trends.tsx:17singleSelectFilters е import-нат, но не се вика никъде (ползва се само cpvGroupSelection, ред 63). Dead import → no-dead-code/lint блокер. Fix: import { cpvGroupSelection } from '../lib/filters';

Major (accuracy/framing, ново):

  • packages/db/src/queries/overruns.ts:154OVERRUN_WHERE няма value_flag филтър. signing/current IS NOT NULL изключват value_suspect/annex_suspect, но review-флагнати договори (напр. ≥10× прогноза, с попълнени EUR) влизат в /overruns. Разминава се с #239, който gate-ва целия anomaly корпус на value_flag = 'ok'. Решение за автора: или AND c.value_flag IN ('ok','review') + уговорка в методологията, или само 'ok' за консистентност с #239. В момента е тихо включване, недокументирано в бележката под таблицата.

Дребни:

  • apps/web/app/routes/overruns.tsx:437 и :503{count(r.annexCount)} анекса дава „1 анекса" при annexCount === 1; ползвайте plural(r.annexCount, 'анекс', 'анекса') (helper-ът съществува в @sigma/shared).
  • packages/db/migrations/0002_contracts_overrun_index.sql — номерът се сблъсква с #212/#170/#172; преномерирай на следващия свободен (0003+) преди merge, иначе wrangler d1 migrations apply дава duplicate-version.

#239 overlap: вече няма файлов конфликт; семантичното припокриване е легитимно (/overruns = пълен подреден корпус, по-нисък праг; #239 annex_growth = scored сигнал, ok-only).

Изисквам промени (блокерът + решението за review-флаговете).

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jul 18, 2026
- import.mjs: gate checkContractFeaturesIntegrity in runFullDerive/runSliceDerive
  too, matching ship-domain.mjs, so a local derive enforces the same
  contract_features invariants as the daily prod ETL.
- precompute.sql: stop guessing an FX rate from today's date for tenders with
  a NULL published_at — leave estimated_value_eur NULL explicitly instead.
- validate-health.mjs: exclude the synthetic NA bucket from the >60%-NULL
  threshold check (it's informational-only, out of the check's stated
  2020-2026 scope); guard the decile-correlation check against zero-variance
  pillars instead of printing NaN.
- trendAxis.ts: drop the stale TODO(midt-bg#170), already tracked by the issue itself.

Reply drafts for the 4 confirmation-only threads (no code change needed):

- filters.ts qualityRankingControls: confirmed — quality.ts's rankSql call
  binds every rank param exclusively via `.prepare(...).bind(minScored,
  ...rangeParams, top)` (packages/db/src/queries/quality.ts:235-236), never
  string-interpolated; rankFrom/rankTo/rankDir are re-validated in
  qualityRankingControls (regex + numeric clamp) before reaching the query.
- analytics.tsx getQualitySummary(...).catch(...): thanks, confirmed.
- 0003_contract_health.sql numbering: confirmed both — (1) 0002_contracts_overrun_index
  (PR midt-bg#170/midt-bg#171, not yet merged) adds no column/table these ALTERs read, so
  applying 0003 first is safe; (2) every environment applies migrations only
  via `wrangler d1 migrations apply` (tracked, once-only), never raw
  re-execution.
- ship-domain.mjs DROP+CREATE window: acknowledged as a real operational risk,
  tracked as a separate follow-up per PRD 530 — not re-litigated here.
Make the ComboTrendChart bar keyboard-focusable so its existing onFocus
handler actually fires, trim the raw ?cpv query values to the selection
cap before the flatMap/map chain, drop an unused filters import in
trends.tsx, document the deferred composite-index tradeoff on the
overrun partial index, and add an invariant test guarding
CPV_BUCKET_WORKS/SERVICES against drifting out of CPV_DIVISION_SET.
# Conflicts:
#	apps/web/app/styles/components.css
#	apps/web/workers/cache-key.test.ts
#	apps/web/workers/cache-key.ts
#	packages/config/src/index.test.ts
…merge

Merging origin/main into pr/overruns surfaced a stale 'g' entry in
PARAM_ORDER (superseded by 'step' on main's query-params refactor);
add the missing /trends and /overruns params (angle, step, cpvSort,
cur, by) alongside it.
…ct resolution

.list-search-btn:hover was missing its closing brace after a hand-merge
of two branches' additive CSS blocks, swallowing the EU-benchmark
indicator block comment and rules into the hover rule and breaking
CssSyntaxError in pnpm lint.

@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-слоя стриктно — силна работа. Медианата (rn IN ((n+1)/2,(n+2)/2)), €-претегленият растеж (SUM(delta)/SUM(signing), не средно от проценти), CPV-огледалото SECTOR_KEY_SQL, параметризацията и защитите от деление на нула държат; всички колони съществуват в обслужващата схема, а миграцията добавя само идемпотентен частичен индекс. Две неща:

1. Зависимост от #257 (евро-анекси), тук с разрушителна посока. Предикатът current_value_eur > signing_value_eur и делтата четат current_value_eur, който за договор в лева с евро-анекс от 2026 е наполовина (÷1,95583). Ефектът е от „подценена делта ~20×" (удвояване изглежда като +5% и потъва в класацията) до „изпада напълно" (наполовинената текуща пада под подписаната). И си личи на самата страница: инспекторът на анексите конвертира всеки анекс по неговата am.currency (коректно) и показва вдигане до €X, а редът в класацията показва €X/2. Не е дефект на този PR, но /overruns е най-потърпевш — редно е да влезе след #257.

2. Физически лица (политика, не блокер). /overruns е индексируема (seoMeta, без noindex) и показва име+ЕИК; entityName не маскира физически лица. Съвпада с досегашното поведение (flows/competition правят същото) — не е регресия. Но изгледът by=percent изкарва отгоре малките високопроцентни договори, а именно там излизат ЕТ/физически лица, при това под рискова рамка („раздуване"). Същият клас като #236 — струва си да се реши на ниво политика за целия сайт, не тук.

Механиката — одобрявам. Останалото е координация с #257.

@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): overruns dashboard

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

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

Добавя таблото „overruns“ (раздути договори) към уеб приложението заедно със свързания „trends“ обзор. Промяната обхваща: SSR графики и UI помощни компоненти (ComboTrendChart, TrendChart, FullscreenButton, MetricInfo), чиста геометрия (overruns-chart, overruns-inspector), парсване/валидиране на URL параметри (filters, query-params), маршрутите /overruns и /trends, презентационен CSS (components.css, pages.css), нови DB заявки в packages/db (агрегати за раздуване, CPV фасетиране, перцентили, медиани, списък с договори), нов частичен индекс с миграция, типове в api-contract и токени в tokens.css. Придружено е от силни, нетривиални тестове.

Сигурност (Фаза 0) — ЧИСТО

  • Няма твърдо кодирани тайни, нови/променени външни URL адреси, нови зависимости или злонамерени шаблони (eval, dangerouslySetInnerHTML, обфускация, backdoor). PEG = 1.95583 е официалният фиксиран курс BGN→EUR, не тайна.
  • SQL инжекции — покрито (OWASP A03): всички потребителски стойности минават през параметризирани заявки (? + .bind(...)); интерполираните в SQL низове са статични константи или избор между два фиксирани литерала (by/sort). CPV групите се валидират с /^\d{5}$/, деупликират и ограничават по брой — защита срещу отравяне на edge-cache ключ (CWE-349) и неограничена SQL работа. Тестовете реално изпълняват SQL в SQLite и покриват опити като cpv=%27-- и "45'--".
  • XSS: всички стойности минават през JSX escaping; няма href от потребителски вход. Изнасянето на inline style= в CSS пази маршрута CSP-чист — добра практика.

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

  • Геометричните/агрегационните функции са чисти и SSR-безопасни, с честни предпазители срещу деление на нула, NaN и Infinity (двойна защита в SQL и JS за прага signing_value_eur, Math.max(1, …), > 0 ? … : 0).
  • OVERRUN_WHERE е дефиниран веднъж и споделен; SECTOR_KEY_SQL огледално отговаря на cpvDivision, а JS re-key гарантира консистентност между leaderboard и секторна разбивка. Медианата е коректна за четно/нечетно n. Поправката на YoY (String(Number(year) - 1)) е коректна.
  • key={by} remount-ва компонента и нулира selected; защитни fallback-и и празни състояния са добре обработени. Достъпността е над средното ниво (aria-*, role, sr-only, :focus-visible, touch targets).

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

Новият частичен индекс idx_contracts_overrun е обоснован; страницата прави фиксиран брой ограничени заявки; тестовете изрично пазят срещу дублиран COUNT(*) и N+1 при анексите.

Точки за потвърждение преди APPROVE (не блокиращи)

  1. Тип на годината (trends.tsx): сравнението y.year === year разчита TrendYear.year да е string; ако е number, картите никога няма да са активни. Моля потвърдете типа.
  2. Знаменател в getCpvGroupStats: totalGroups брои групи в tenders без филтър amount_eur > 0, докато groups идват от договори с > 0 — вероятно умишлено, но да се документира, за да не изглежда като разминаване.

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

  • cpvGroupSelection: .slice() преди валидацията може да отреже валидни кодове при враждебен вход (поведението остава безопасно).
  • Каноничност на CPV за кеша: нормализирайте (сортирайте) cpvSel в loader-а, за да не се разпилява edge-cache при различен ред на ?cpv.
  • Възможни RangeError при spread върху голям масив (Math.max(1, ...rows.map())) — reduce премахва риска; възможна колизия на React key при празен code; дублирана магическа стойност 24; дедупликация на missing през Set.
  • Секторната GROUP BY sector_key няма SQL LIMIT (secLimit се прилага чак в JS) — нищожен риск, но обмислете таван/мониторинг.
  • Миграцията 0002_contracts_overrun_index.sql няма down-стъпка — добавете DROP INDEX, ако проектът не е само-forward.
  • CSS: непоследователни font fallback-и; повтарящ се стек 'IBM Plex Mono', var(--font-mono) (капсулирайте в токен); проверете color-mix/backdrop-filter спрямо матрицата от браузъри и z-index скалата.

Ограничение на прегледа

Партида 5 (pages.css, +3080/−3) не беше достъпна за инспекция (липсващ diff/бинарен). За нея сигурността и качеството не са верифицирани — препоръчва се ръчна проверка за външни url()/@import, легаси вектори и дублиране/мъртви селектори, преди окончателно сливане.

Обобщено: кодът е с високо качество, добре структуриран, параметризиран и OWASP-съобразен, с честна обработка на гранични данни и силно тестово покритие. Няма блокиращи проблеми. Препоръка: адресирайте точки 1 и 2, потвърдете съдържанието на pages.css, след което PR-ът е готов за одобрение.

Comment thread apps/web/app/lib/filters.ts Outdated
Comment thread apps/web/app/routes/trends.tsx
Comment thread apps/web/app/routes/trends.tsx Outdated
…e key

- cpvGroupSelection: keep a loose raw-input bound (MAX_CPV_RAW_VALUES) to
  cap flood work, but apply the precise MAX_CPV_GROUP_SELECTION slice only
  after comma-split + trim + validation + dedup, so valid codes are never
  truncated by leading invalid ?cpv= params.
- trends loader: sort cpvSel before use so a reordered ?cpv= URL produces
  the same canonical edge-cache key, matching hrefToggleCpv's own sort.

@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): overruns dashboard

ОБЩ ВЕРДИКТ: COMMENT (незадължителни бележки) — няма блокиращи проблеми. Сигурността е чиста във всичките 7 партиди; един пакет (query слой) получи APPROVE. Одобрете след потвърждаване на дребните точки по-долу.

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

Добавя ново табло „Overruns" и преработва страницата „Тренд във времето" в „Договори — обзор" с три ъгъла на гледане (време / CPV / кръстосано). Включва:

  • Нови маршрути apps/web/app/routes/overruns.tsx и trends.tsx — чисти, SSR/no-JS съвместими презентационни компоненти (всички контроли са GET <Link>).
  • Изнесена чиста геометрия/логика в lib/overruns-chart, overruns-inspector и др., с задълбочени unit тестове (clamp-ове, враждебни входове, празни/изродени случаи).
  • CPV мулти-селекция с валидация, SVG разпределителни ленги и scatter графики.
  • Нов query слой в @sigma/db (overruns.ts, trend.ts) с 620+ реда тестове, адитивна миграция (CREATE INDEX IF NOT EXISTS), промени в api-contract/config.
  • Голям обем нови стилове: components.css (+905), pages.css (+3080), tokens.css.

Сигурност — ЧИСТО във всички партиди ✅

  • Няма твърдо кодирани тайни, нови зависимости, промени по URL адреси, eval/dangerouslySetInnerHTML, обфускация или бекдори.
  • SQL инжекция (OWASP A03) — оценено и отхвърлено: целият SQL се сглобява от константи; всички стойности от повикващия минават параметрично през .bind(?) / IN (?, …); лимитите през clampLimit; by/ORDER BY избират между фиксирани низове. CPV входът се валидира с /^\d{5}$/ преди SQL. CPV фасетирането интерполира само структурата на клаузата, не операнди.
  • Отравяне на кеш-ключ (CWE-349): смекчено — строга валидация, дедупликация, двоен лимит, сортиране и канонична подредба на CPV параметрите.
  • XSS: няма — целият изход минава през JSX escaping.

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

  • Навсякъде защити срещу деление на нула, log(0) и NaN (clamp-ове, Math.max(1, …), > 0 guard-ове, COALESCE).
  • SECTOR_KEY_SQL е доказано огледало на cpvDivision; медианите са коректни за нечетно/четно n; статусите се извеждат само от реални дати.
  • Тестовото покритие е смислено и насочено към разкриване на дефекти, не тривиално.

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

  1. Кросс-пакетна съгласуваност ?gstep/angle: уверете се, че няма останали четци на ?g= и че старите кеширани линкове се игнорират (потвърдете с cache-key.test.ts).
  2. YoY върху нулево-запълнени gap-години (trend.ts ~ред 220): липсваща година дава −100% YoY. Потвърдете, че това е желаното продуктово поведение и че фронтендът визуално различава „дупка" от реален спад.
  3. Консистентност на филтъра по стойност: тренд scope() използва amount_eur IS NOT NULL, докато listOverviewContracts/разпределенията използват amount_eur > 0 — потвърдете, че тоталите не се разминават.
  4. Достъпност: фокусируеми <rect>/<circle> вътре в role="img" SVG може да не се обявяват от екранни четци; смекчено чрез достъпни алтернативи (year-карти, бутони с aria-pressed) — струва си проверка с екранен четец.
  5. Документация vs. поведение: JSDoc за big в overruns-chart.ts и коментарите за „~30 rows" в GROUP_DIST_SQL са леко подвеждащи (поведението е коректно).
  6. CSS поддръжка: дублиран селектор .trend-chart-panel--full .trend-chart-body; смесване на литерал 'IBM Plex Mono' с токен; възможен мъртъв .trend-col; голямото нарастване на pages.css (+3080) си струва проверка за дублиране.
  7. Дребни оптимизации: възможни дублирани CPV групи в missing към getCpvGroupMedians; topGrowthCode ръбов случай при празен код; rows[0]! non-null assertion.

Отворени за верификация точки

  • pages.css (+3080/−3): diff-ът не беше наличен в партида 5 — окончателната преценка изисква ръчна проверка върху пълния diff (външни url()/@import, дублиране, размер).
  • Тестово покритие за loader-логиката на самите route-ове (pick, relLabel, logMax) не се видя в прегледаните партиди — потвърдете, че е добавено, за да се спази изискването за тестове.

Кодът е с високо качество (~9.5/10 в query слоя), чиста функционална декомпозиция, консистентно именуване, без частична имплементация/TODO/дублиране. Няма находки, изискващи промяна преди merge — само изброените потвърждения.

Comment thread apps/web/app/components/ComboTrendChart.tsx Outdated
Comment thread apps/web/app/lib/overruns-chart.ts
Comment thread apps/web/app/routes/trends.tsx Outdated
Comment thread packages/db/migrations/0002_contracts_overrun_index.sql Outdated
Comment thread packages/db/src/queries/overruns.ts
Comment thread packages/db/src/queries/overruns.ts
Comment thread packages/db/src/queries/trend.ts

@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.

Ре-верифицирах на HEAD (d6949279): новият комит („cap cpv selection after validation + canonical /trends cache key") е валидация/кеш-ключ — не пипа OVERRUN_WHERE или стойностната база. Двете ми бележки стоят: (1) зависимост от #257 (евро-анекси), където /overruns е най-потърпевш; (2) физически лица под рискова рамка на индексируема класация. За (2) вече има прецедент — #237 добавя noindex за договори с изпълнител ЕТ; същият модел е редно да се приложи и тук. Механиката остава одобрена.

Address ydimitrof's review on PR midt-bg#171:
- overruns-chart.ts: sample x-axis ticks evenly across the log range
  (keeping first+last) instead of the lowest 5, so wide ranges label
  their upper end too
- overruns-chart.ts: correct the `big` field's JSDoc to match its real
  definition (>=50% of the corpus's max € overrun, not top-half-by-€)
- ComboTrendChart.tsx: drop tabIndex from bars — they were focusable
  children of an opaque role="img" svg, producing nameless focus stops;
  hover tooltip remains for pointer users
- trends.tsx: dedup CPV groups via a Set before getCpvGroupMedians to
  avoid redundant lookups when several contracts share a group
…ed composite

Replace the annex_count-only partial index with PR midt-bg#169's composite
(signing_value_eur, current_value_eur) index and its EXPLAIN QUERY PLAN
benchmark comment, so both PRs converge on one canonical migration
instead of shipping conflicting copies of 0002_contracts_overrun_index.sql
…ter RSC CVE

Bump postcss to ^8.5.18 (GHSA-r28c-9q8g-f849, path traversal via
sourceMappingURL auto-load) and valibot to ^1.4.2 (GHSA-5qjj-4xww-7phc,
flatten() crash on inherited-property keys) via pnpm-workspace.yaml
overrides — both patch-level, non-breaking fixes.

Add osv-scanner.toml with a suppression for GHSA-qwww-vcr4-c8h2
(react-router CSRF in unstable RSC code paths only), following the
same documented, time-boxed pattern as the existing sharp suppression.
This app doesn't use RSC (verified via repo-wide grep) and no fix
exists in the 7.x line; the fix requires a major 7.x->8.x bump that is
out of scope here.
- apps/web/app/routes/trends.tsx: kept pr/overruns's full obzor
  (time/cpv/cross lens) rewrite, applied main's getDb() read-only D1
  chokepoint guard instead of direct context.cloudflare.env.DB access
- apps/web/app/routes/overruns.tsx: this branch's own pre-existing
  direct env.DB usage also needed the getDb() chokepoint guard (caught
  by main's readonly-db-chokepoint.test.ts after merging)
- osv-scanner.toml: kept both independently-added suppressions
  (react-router RSC CSRF from this branch, sharp/miniflare from main)
- packages/db migrations: renumbered this branch's
  0002_contracts_overrun_index.sql to 0003 to resolve a numbering
  collision with main's 0002_current_value_currency.sql; updated
  migrations.test.ts to load both in the chain
- pnpm-lock.yaml: regenerated via pnpm install
todorkolev added a commit that referenced this pull request Jul 29, 2026
… + hardening) (#212)

* perf(db): ordering indexes for the non-default list sorts

The list pages keyset-paginate with ORDER BY <sortExpr> <dir>, <id> <dir> LIMIT N.
Six user-selectable sorts had no matching index, so the planner fell back to a
full table SCAN + temp-B-tree ORDER BY on every page (D1 bills rows scanned):

  /contracts   date-desc, date-asc   (idx_contracts_signed is on the bare column,
                                       not the COALESCE(signed_at, ...) expr the query uses)
  /companies   count, authorities
  /authorities count, avg

Add one index per missing sort, matching the exact ORDER BY expression plus the
keyset id tiebreak, so SQLite walks the index and stops at LIMIT. Additive,
idempotent; rollup tables are DELETE+INSERT-refreshed so the indexes survive ships.
A sqlite3 EXPLAIN QUERY PLAN test proves each sort full-scans before and index-walks
after.

* fix(web): escape < in the JSON-LD data island (defense-in-depth)

root.tsx embeds JSON-LD via dangerouslySetInnerHTML with a raw JSON.stringify.
JSON.stringify does not escape '<', so a '</script>' in any string value would
close the <script> element early (stored XSS) — the exact sink the project's own
review standard (docs/review-security.md) requires be escaped. Today only the
request origin reaches the graph (new URL() cannot make it carry '</script>'), so
this is not currently exploitable; the jsonLdScript helper closes the sink
pre-emptively for any DB/user-derived field added later. A unit test proves '<' is
escaped, U+2028/U+2029 are escaped, and the output stays JSON-equivalent.

* chore(db): renumber list-sort-indexes migration 0002 → 0005

De-conflict the migration number: 0002 is claimed by the contracts_overrun_index
family (#169/#170/#171/#172), 0003 by #188 (contract_health), and 0004 by #210
(cpv_division_stats). 0005 is the next free number. Additive/idempotent, so final
merge order stays the maintainer's call; this just removes the known 0002 clash.

* test(db): apply all migrations + cover keyset pages; guard jsonLdScript(undefined)

Address the review notes on the list-sort-indexes PR:

1. The sort-index test now applies EVERY migration on the branch (discovered from
   the migrations dir), not a hardcoded 0000/0001/000N subset. The "BEFORE" base is
   exactly the real served schema minus this PR's index, and the test survives any
   renumbering. (Confirmed: company_totals/authority_totals are created in 0000 and
   nothing between affects these sort plans.)

2. Each sort now asserts the plan on the keyset page too - the real paginated path
   `WHERE (expr <cmp> ? OR (expr = ? AND id <cmp> ?))`, not only the first page.
   Full-scans BEFORE and index-walks (no temp B-tree) AFTER, on both pages.

3. jsonLdScript now returns "null" when JSON.stringify yields undefined (undefined /
   function / symbol) instead of throwing on the following .replace - defense-in-depth
   for the documented "safe for any future field" helper. Covered by a test.

* refactor(web): rename jsonLdScript → serializeJsonForScript + document sort-index sync

Address the (non-blocking) review nits:

- Rename jsonLdScript to serializeJsonForScript: the helper returns a serialized
  JSON string safe to embed in an inline <script>, not a <script> element (review
  ydimitrof). Updates root.tsx and the test.

- Document the sentinel sync: the COALESCE defaults in queries/contracts.ts SORTS
  ('' / '9999-99') must stay byte-identical to the expression indexes, or SQLite
  silently drops the index and falls back to a full scan + temp-B-tree sort. Added
  reciprocal SYNC comments in the migration and the SORTS map, both noting that
  list-sort-indexes.test.ts's EXPLAIN assertions catch a drift.

* docs(db): state the boundaries of the sort-index guarantee (review)

Document the two known limits of the EXPLAIN-plan proof, per review: (1) the local
sqlite3 CLI planner is not version-identical to Cloudflare D1's (a strong
indication, not a bit-exact production proof; the binary itself is a pre-existing
suite-wide dependency), and (2) the index-walk guarantee covers the UNFILTERED
sort paths - with an active filter the planner may prefer the filter's index and
temp-sort the much smaller filtered set, which is the correct trade. Comment-only.

* chore: drop internal review-marker traces from code comments

Remove the '(review ydimitrof)' attribution artifacts from json-ld.ts and
list-sort-indexes.test.ts comments; the explanations stay. Comment-only.

* refactor(web): share one JSON-for-script serializer between the JSON-LD island and .json route

The .json contract endpoint had its own safeJson escaper, a second implementation
of the same <script>/separator escaping as serializeJsonForScript - a DRY smell the
comment itself admitted, and a drift risk (one could add a U+2028 escape the other
lacks). Route it through the shared serializer instead. It escapes every `<` (vs the
old `</`-only form) - JSON-equivalent, harmless for the JSON body, strictly safer.

Also document, in the shared helper, why `>` and `&` are deliberately left unescaped
(only `<` can start a token in a script raw-text context), with a test that locks it.

* fix(web): set nosniff on the .json route; add planner-independent sentinel-sync test

- contract.json.tsx: the actual MIME-sniffing defense is X-Content-Type-Options:
  nosniff, not the content escaping. The worker already sets it globally
  (baseSecurityHeaders); set it explicitly on this resource route too so it is safe
  on its own, and correct the comment that over-credited the escaping (review).

- Add sort-index-sentinel-sync.test.ts: the date-sort index only matches while its
  COALESCE sentinel is byte-identical to SORTS in queries/contracts.ts. A .sql
  migration can't import a TS constant, so guard the coupling with a static
  cross-file check of the sentinels ('' and '9999-99') that fails on drift
  regardless of the DB engine - independent of the local sqlite3 planner the EXPLAIN
  test relies on (review).

* chore: drop stray review-marker artifacts from this PR's comments

Remove the bare '(review ...)' attribution notes I left in contract.json.tsx and
sort-index-sentinel-sync.test.ts; the explanations stay. The pre-existing
'(review #80)' issue references elsewhere are an established convention and are
untouched. Comment-only.

* test(db): cover filtered list sorts and guard the sqlite3 dependency

Two review follow-ups on the ordering-index test:

- Filtered sorts were documented as out of scope, leaving the reader unable to
  tell whether an active list filter makes the ordering index redundant. It does
  not: with a sector (tenders.cpv_code) or eu-funded filter the planner still
  walks idx_contracts_signed_desc and drops the sort step, while the pre-index
  baseline sorts the whole table. Asserted both directions.
- A missing sqlite3 CLI surfaced as an opaque ENOENT. Probe it in beforeAll and
  fail with the fix. Deliberately not a skip: this is a perf/cost gate, and
  silently passing it on an image without sqlite3 would retire the gate.

---------

Co-authored-by: Rumen Slavov <26761822+B353N@users.noreply.github.com>
Co-authored-by: todorkolev <tkolev@obecto.com>
@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