From b6b4fc57822401cb54b45d8d8af90f95c2cc4915 Mon Sep 17 00:00:00 2001
From: DiyanaDimitrova
Date: Sun, 12 Jul 2026 00:28:54 +0300
Subject: [PATCH 1/6] feat(web): homepage flagged-value + risk-signal breakdown
(#218)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A leading number on the homepage: the total EUR running through contracts that
carry a risk signal, with a breakdown by signal type and by category (sector /
authority type), each drillable to the contracts behind it, next to a
non-accusatory methodology link.
Computed LIVE over existing columns under the 1h edge cache — NO schema /
precompute / migration change (ships as a pure Worker deploy per docs/deploy.md).
Signal predicates mirror the per-contract RiskIndicators (riskLogic.ts) so the
homepage number stays consistent with the badge on a contract page: no-competition
(admitted bids = 1), cost growth (current > 1.2x signing), value/date anomaly.
- packages/db/queries/flagged.ts: shared FLAG_SQL predicates (single source of
truth) + getFlaggedValue (de-duplicated total, overlapping by-type, category
breakdowns summing to the total; canonical amount_eur basis, #98).
- /contracts gains a risk-signal filter (flag) and an authority-type filter
(type), query-layer only, so every homepage number drills down; registered in
the CSV cache classifier + the edge cache-key allowlist.
- Homepage section + methodology "Сигнали за риск" (de-dup vs overlap, tone).
- Tests: real-SQLite aggregate + filter narrowing, parse + riskLogic parity.
Closes #218
---
apps/web/app/lib/csv-export.test.ts | 2 +
apps/web/app/lib/csv-export.ts | 10 +-
apps/web/app/lib/filters.test.ts | 29 +++++
apps/web/app/lib/filters.ts | 12 +-
apps/web/app/lib/query-params.ts | 1 +
apps/web/app/routes/home.tsx | 85 +++++++++++++
apps/web/app/routes/methodology.tsx | 56 ++++++++-
apps/web/app/styles/home.css | 71 +++++++++++
packages/api-contract/src/index.ts | 12 ++
packages/db/src/queries/contracts.ts | 20 +++
packages/db/src/queries/flagged.test.ts | 159 ++++++++++++++++++++++++
packages/db/src/queries/flagged.ts | 136 ++++++++++++++++++++
packages/db/src/queries/home.ts | 65 ++++++----
packages/db/src/queries/index.ts | 1 +
packages/db/src/queries/keyset.test.ts | 4 +
15 files changed, 633 insertions(+), 30 deletions(-)
create mode 100644 packages/db/src/queries/flagged.test.ts
create mode 100644 packages/db/src/queries/flagged.ts
diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts
index 105c4778..2dd56383 100644
--- a/apps/web/app/lib/csv-export.test.ts
+++ b/apps/web/app/lib/csv-export.test.ts
@@ -335,6 +335,8 @@ describe('isUnfilteredCsvExport', () => {
bidder: 'acme',
q: 'rail',
bids: 'one',
+ flags: ['no_competition'],
+ authorityTypes: ['министерство'],
types: ['municipality'],
kinds: ['company'],
countBucket: '2-5',
diff --git a/apps/web/app/lib/csv-export.ts b/apps/web/app/lib/csv-export.ts
index 4756efda..e0daf64e 100644
--- a/apps/web/app/lib/csv-export.ts
+++ b/apps/web/app/lib/csv-export.ts
@@ -4,7 +4,15 @@ const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
const CSV_CACHE_CONTROL = 'public, max-age=3600';
const CSV_MULTIPART_PART_SIZE = 8 * 1024 * 1024;
-const ARRAY_FILTERS = ['years', 'sectors', 'procedureGroups', 'kinds', 'types'] as const;
+const ARRAY_FILTERS = [
+ 'years',
+ 'sectors',
+ 'procedureGroups',
+ 'kinds',
+ 'types',
+ 'flags',
+ 'authorityTypes',
+] as const;
// `bids` ('one' | null) is a response-affecting filter: without it here a „само една оферта" export
// was misclassified as unfiltered and served from / written to the shared unfiltered cache object —
// a cache-poisoning variant of #56/#122 on top of the wrong-data bug (#138). hasScalarFilter treats
diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts
index 158f1609..1aa4dbfb 100644
--- a/apps/web/app/lib/filters.test.ts
+++ b/apps/web/app/lib/filters.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { CPV_SECTORS } from '@sigma/config';
+import { FLAG_TYPES } from '@sigma/db';
import {
authorityListFilters,
companyListFilters,
@@ -13,9 +14,37 @@ import {
withParams,
} from './filters';
import { CANONICAL_QUERY_PARAMS } from './query-params';
+import type { RiskFlagType } from './riskLogic';
const sp = (q: string) => new URLSearchParams(q);
+describe('contract risk-signal + authority-type filters (#218)', () => {
+ it('parses valid ?flag tokens and drops unknown ones', () => {
+ const f = contractListFilters(new URLSearchParams('flag=no_competition,all&flag=bogus'));
+ expect(f.flags).toEqual(['no_competition', 'all']);
+ });
+
+ it('leaves flags empty when the param is absent', () => {
+ expect(contractListFilters(new URLSearchParams('')).flags).toEqual([]);
+ });
+
+ it('parses ?type into authorityTypes', () => {
+ const f = contractListFilters(new URLSearchParams('type=министерство&type=община'));
+ expect(f.authorityTypes).toEqual(['министерство', 'община']);
+ });
+
+ it('the flagged.ts signal set matches riskLogic RiskFlagType (homepage ↔ contract-page parity)', () => {
+ // Compile-time: every FLAG_TYPES entry is a valid RiskFlagType. Runtime: the sets are equal.
+ const expected: RiskFlagType[] = [
+ 'no_competition',
+ 'eu_no_competition',
+ 'high_markup',
+ 'anomalies',
+ ];
+ expect(new Set(FLAG_TYPES)).toEqual(new Set(expected));
+ });
+});
+
describe('contractListFilters', () => {
it('parses the bids filter the HTML list and CSV export must share (issue #138)', () => {
const sp = new URLSearchParams('bids=1&year=2025&authority=123');
diff --git a/apps/web/app/lib/filters.ts b/apps/web/app/lib/filters.ts
index 6e5d621b..af93c273 100644
--- a/apps/web/app/lib/filters.ts
+++ b/apps/web/app/lib/filters.ts
@@ -4,7 +4,12 @@
import { CPV_CATEGORIES, CPV_SECTORS, categoryForDivision } from '@sigma/config';
import type { EntityKind } from '@sigma/api-contract';
-import { normalizeAuthoritySort, normalizeCompanySort, normalizeContractSort } from '@sigma/db';
+import {
+ FLAG_TYPES,
+ normalizeAuthoritySort,
+ normalizeCompanySort,
+ normalizeContractSort,
+} from '@sigma/db';
import type { CpvCategory } from '@sigma/config';
import type { FilterCategory, FilterGroup, FilterOption } from '../components/FilterRail';
import { CANONICAL_QUERY_PARAMS, INTENTIONALLY_UNKEYED } from './query-params';
@@ -13,6 +18,8 @@ export const PAGE_SIZE = { contracts: 15, companies: 25, authorities: 25 } as co
export const MAX_MULTI_VALUES = 50;
const KNOWN_SECTORS = new Set(CPV_SECTORS.map((s) => s.code));
+// Risk-signal tokens accepted on ?flag= (#218): each FlagType plus `all` (any signal).
+const KNOWN_FLAGS = new Set([...FLAG_TYPES, 'all']);
function allowedMulti(key: string, value: string): boolean {
if (key === 'sector') return KNOWN_SECTORS.has(value);
@@ -51,6 +58,8 @@ export function contractListFilters(sp: URLSearchParams) {
bidder: sp.get('bidder'),
q: sp.get('q'),
bids: (sp.get('bids') === '1' ? 'one' : null) as 'one' | null,
+ flags: getMulti(sp, 'flag').filter((v) => KNOWN_FLAGS.has(v)),
+ authorityTypes: getMulti(sp, 'type'),
};
}
@@ -191,6 +200,7 @@ export const PARAM_ORDER = [
'eu',
'bids', // /contracts single-bid filter
'value',
+ 'flag',
'authority',
'bidder',
'center', // /network focus entity
diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts
index e7b603a3..9827f598 100644
--- a/apps/web/app/lib/query-params.ts
+++ b/apps/web/app/lib/query-params.ts
@@ -9,6 +9,7 @@ export const CANONICAL_QUERY_PARAMS = new Set([
'count',
'cursor',
'eu',
+ 'flag', // /contracts: risk-signal filter (#218) — changes the result set + headline totals
'funding',
'g',
'kind',
diff --git a/apps/web/app/routes/home.tsx b/apps/web/app/routes/home.tsx
index a20541ed..68be186c 100644
--- a/apps/web/app/routes/home.tsx
+++ b/apps/web/app/routes/home.tsx
@@ -79,9 +79,18 @@ function SingleOfferTable({ items, allHref }: { items: ContractListItem[]; allHr
);
}
+// Bulgarian labels for the risk-signal types (keys mirror flagged.ts FLAG_TYPES / riskLogic.ts).
+const FLAG_LABELS: Record = {
+ no_competition: 'Липса на конкуренция',
+ eu_no_competition: 'Липса на конкуренция (със средства от ЕС)',
+ high_markup: 'Ръст на стойността чрез анекси',
+ anomalies: 'Стойностна или времева аномалия',
+};
+
export default function Home({ loaderData }: Route.ComponentProps) {
const {
totals,
+ flagged,
topCompanies,
topMinistries,
topMunicipalities,
@@ -129,6 +138,82 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{totals.asOf ? `, последен договор ${date(totals.asOf)}` : ''}.
+
+
+ Договори със сигнали за риск
+
+
+ Обща стойност на договорите, при които СИГМА отбелязва поне един структурен сигнал — липса
+ на конкуренция, ръст на стойността чрез анекси или стойностна/времева аномалия. Сигналите
+ са ориентири за преглед, не присъда. Как ги четем →
+
+
+
+
+ ≈ {moneyBare(flagged.totalEur)} €
+ в {count(flagged.contracts)} договора със сигнал →
+
+
Най-активните институции
diff --git a/apps/web/app/routes/methodology.tsx b/apps/web/app/routes/methodology.tsx
index a05f92e2..a939285a 100644
--- a/apps/web/app/routes/methodology.tsx
+++ b/apps/web/app/routes/methodology.tsx
@@ -37,6 +37,7 @@ const TOC = [
['identity', 'Имена, ЕИК, УНП'],
['gaps', 'Известни празнини в полетата'],
['export', 'Сваляне и достъп до данните'],
+ ['flagged', 'Сигнали за риск'],
['contact', 'Поправки и обратна връзка'],
];
@@ -517,8 +518,61 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {
Засега не предлагаме публично API в реално време.
+
+
10. Сигнали за риск
+
+ На началната страница СИГМА показва{' '}
+ общата стойност на договорите със сигнали за риск — договори, при
+ които поне един структурен признак заслужава преглед. Сигналите са{' '}
+ ориентири, не присъда: те не твърдят нарушение, а насочват към договори,
+ които си струва да се погледнат. Всяко число води до самите договори.
+
+
+ Отбелязваме четири признака, същите, които се виждат и на страницата на всеки
+ договор:
+
+
+
Липса на конкуренция
+
+
+ Допусната е една-единствена оферта (приети оферти = 1). Отделяме случаите със
+ средства от ЕС, където правилата за конкуренция са по-строги.
+
+ → приети оферти = 1
+
+
Ръст на стойността чрез анекси
+
+
+ Текущата стойност е нараснала с над 20% спрямо стойността при подписване —
+ индикатор за оскъпяване след сключването.
+
+ Стойност с непотвърдена достоверност (виж речника) или договор, подписан преди
+ датата си на публикуване.
+
+ → value_flag / date_flag
+
+
+
+
Как се смята сумата
+
+ Общата сума е де-дублирана — договор с няколко сигнала се брои
+ веднъж. Разбивката „по вид сигнал" обаче се застъпва (един договор може
+ да попадне в няколко реда), затова сборът ѝ надхвърля общата сума; разбивките по
+ сектор и по тип институция разделят множеството и се сумират до общата сума.
+ Стойностите стъпват на каноничната изчистена стойност в евро (сумираме само
+ договорите с достоверна стойност — договор с непотвърдена стойност се брои в
+ бройката, но с 0 €).
+
+
+
+
-
10. Поправки и обратна връзка
+
11. Поправки и обратна връзка
Грешките поправяме ръчно при сигнал — двойни записи за институция/компания
(изпратете двата ЕИК/линка) или сума, която не отговаря на оригиналния документ
diff --git a/apps/web/app/styles/home.css b/apps/web/app/styles/home.css
index 6f2777ec..9e8da6d0 100644
--- a/apps/web/app/styles/home.css
+++ b/apps/web/app/styles/home.css
@@ -183,3 +183,74 @@
font-size: 12px;
white-space: nowrap;
}
+
+/* Flagged-value section (#218): a lead accent number + three drill-down breakdown columns. */
+.flagged-lead {
+ margin: var(--s-5) 0 var(--s-6);
+}
+.flagged-lead a {
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--s-3);
+ text-decoration: none;
+ color: var(--ink);
+ flex-wrap: wrap;
+}
+.flagged-lead a:hover .flagged-sub {
+ color: var(--accent);
+}
+.flagged-num {
+ font: 600 clamp(30px, 4vw, 44px) / 1 var(--font-serif);
+ color: var(--accent);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+}
+.flagged-sub {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ color: var(--ink-soft);
+}
+.flagged-cols {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
+ gap: var(--s-5) var(--s-7);
+}
+.flagged-h3 {
+ font: 500 10.5px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-mid);
+ margin: 0 0 var(--s-2);
+ padding-bottom: var(--s-2);
+ border-bottom: 1px solid var(--ink);
+}
+.flagged-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.flagged-list a {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: var(--s-3);
+ padding: var(--s-3) 0;
+ border-bottom: 1px solid var(--rule-soft);
+ font: 14px/1.35 var(--font-sans);
+ text-decoration: none;
+ color: var(--ink);
+}
+.flagged-list a:hover {
+ color: var(--accent);
+}
+.flagged-val {
+ color: var(--ink-soft);
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ font-size: 12px;
+ white-space: nowrap;
+}
+.flagged-note {
+ margin-top: var(--s-3);
+}
diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts
index ad7d6d1d..31d80712 100644
--- a/packages/api-contract/src/index.ts
+++ b/packages/api-contract/src/index.ts
@@ -53,8 +53,20 @@ export interface HomeTotals {
refreshedAt: string;
}
+/** Homepage flagged-value summary (issue #218). `totalEur` is de-duplicated (a contract with several
+ * signals counts once); `byType` slices overlap; `bySector`/`byAuthorityType` partition the flagged set. */
+export interface FlaggedValue {
+ totalEur: number;
+ contracts: number;
+ byType: { type: string; eur: number; contracts: number }[];
+ bySector: { code: string; label: string; eur: number; contracts: number }[];
+ byAuthorityType: { typeGroup: string; eur: number; contracts: number }[];
+}
+
export interface HomeData {
totals: HomeTotals;
+ /** Total € running through contracts with a risk signal, + breakdowns (issue #218). */
+ flagged: FlaggedValue;
topCompanies: CompanyListItem[];
topMinistries: AuthorityListItem[];
topMunicipalities: AuthorityListItem[];
diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts
index 4138c927..59f8da0f 100644
--- a/packages/db/src/queries/contracts.ts
+++ b/packages/db/src/queries/contracts.ts
@@ -6,6 +6,7 @@ import { CPV_SECTORS, PROCEDURE_GROUPS, procedureGroup } from '@sigma/config';
import { cleanName, entityName } from '@sigma/shared';
import { csvCell } from './csv';
import { assertCovers } from './filter-guard';
+import { flagPredicate } from './flagged';
import {
authoritySlug,
bareContractId,
@@ -30,6 +31,8 @@ export interface ContractListParams {
bidder?: string | null; // bidder slug
q?: string | null;
bids?: 'one' | null;
+ flags?: string[]; // risk-signal tokens (#218): a FlagType or `all`; OR-combined
+ authorityTypes?: string[]; // authority `type_group` buckets (#218 breakdown drill-down, `?type=`)
cursor?: string | null;
pageSize?: number;
}
@@ -44,6 +47,8 @@ export const CONTRACT_FILTER_KEYS = [
'bidder',
'q',
'bids',
+ 'flags',
+ 'authorityTypes',
] as const satisfies readonly (keyof ContractListParams)[];
// Compile-time completeness guard (issue #138 bug class) — see filter-guard.ts. If this line
@@ -177,6 +182,19 @@ function buildFilters(p: ContractListParams): { sql: string; params: unknown[] }
);
params.push(match);
}
+ if (p.flags?.length) {
+ // Risk-signal filter (#218): OR the requested flag predicates. An unrecognised token yields no
+ // predicate; if none are valid the filter matches nothing (rather than silently showing all).
+ const preds = p.flags
+ .map(flagPredicate)
+ .filter((x): x is string => x != null)
+ .map((x) => `(${x})`);
+ where.push(preds.length ? `(${preds.join(' OR ')})` : '1=0');
+ }
+ if (p.authorityTypes?.length) {
+ where.push(`a.type_group IN (${qs(p.authorityTypes.length)})`);
+ params.push(...p.authorityTypes);
+ }
return { sql: where.length ? ' WHERE ' + where.join(' AND ') : '', params };
}
@@ -192,6 +210,8 @@ function contractFilterSignature(p: ContractListParams): string {
bidder,
q: searchMatchQuery(p.q ?? ''),
bids: p.bids ?? null,
+ flags: p.flags?.length ? [...p.flags].sort() : null,
+ authorityTypes: p.authorityTypes?.length ? [...p.authorityTypes].sort() : null,
} satisfies Record<(typeof CONTRACT_FILTER_KEYS)[number], unknown>;
return filterSignature(filters);
}
diff --git a/packages/db/src/queries/flagged.test.ts b/packages/db/src/queries/flagged.test.ts
new file mode 100644
index 00000000..f451aa2e
--- /dev/null
+++ b/packages/db/src/queries/flagged.test.ts
@@ -0,0 +1,159 @@
+///
+import { DatabaseSync } from 'node:sqlite';
+import { readFileSync, readdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, describe, expect, it } from 'vitest';
+import { getFlaggedValue } from './flagged';
+import { listContracts } from './contracts';
+
+// Integration test against a REAL SQLite built from the production migrations (node:sqlite), so the
+// flag predicates and aggregate are proven to narrow/sum actual rows — the fake-D1 unit tests ignore
+// WHERE clauses. Fixture exercises: de-duplicated total, overlapping by-type, category sums-to-total,
+// and the amount_eur NULL basis (value_suspect contributes to the count but €0).
+const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../migrations');
+const migrations = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort();
+
+// c1 no_competition (община, sector 45, 1000) · c2 eu_no_competition (министерство, 72, 2000)
+// c3 high_markup (община, 45, 1500) · c4 anomalies via value_suspect (министерство, 72, amount NULL)
+// c5 OVERLAP no_competition + high_markup (община, 45, 2000) · c6 clean, unflagged (5000)
+const FIXTURE = `
+INSERT INTO authorities (id, name, bulstat, type_group) VALUES
+ ('auth:obshtina', 'Община', '100000001', 'община'),
+ ('auth:min', 'Министерство', '100000002', 'министерство');
+INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES
+ ('eik:200000001', 'Фирма', '200000001', '200000001', 1, 'company');
+INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES
+ ('t:o45', 'UNP-1', 'Строеж', 'auth:obshtina', '45000000', 'открита процедура', 'awarded'),
+ ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded');
+INSERT INTO contracts
+ (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, bids_rejected, eu_funded,
+ value_flag, date_flag, signing_value_eur, current_value_eur, amount_eur) VALUES
+ ('c1', 't:o45', 'eik:200000001', 1000, 'EUR', '2024-01-01', 1, 0, 0, 'ok', 'ok', 1000, 1000, 1000),
+ ('c2', 't:m72', 'eik:200000001', 2000, 'EUR', '2024-01-02', 1, 0, 1, 'ok', 'ok', 2000, 2000, 2000),
+ ('c3', 't:o45', 'eik:200000001', 1500, 'EUR', '2024-01-03', 3, 0, 0, 'ok', 'ok', 1000, 1500, 1500),
+ ('c4', 't:m72', 'eik:200000001', 9000, 'EUR', '2024-01-04', 3, 0, 0, 'value_suspect', 'ok', NULL, NULL, NULL),
+ ('c5', 't:o45', 'eik:200000001', 2000, 'EUR', '2024-01-05', 1, 0, 0, 'ok', 'ok', 1000, 2000, 2000),
+ ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000);
+`;
+
+function d1(db: DatabaseSync): D1Database {
+ return {
+ prepare(sql: string) {
+ let bound: (string | number | null)[] = [];
+ const stmt = {
+ bind(...params: (string | number | null)[]) {
+ bound = params;
+ return stmt;
+ },
+ async all() {
+ return { results: db.prepare(sql).all(...bound) as T[] };
+ },
+ async first() {
+ return (db.prepare(sql).get(...bound) ?? null) as T | null;
+ },
+ };
+ return stmt;
+ },
+ } as unknown as D1Database;
+}
+
+let open: DatabaseSync | null = null;
+function realDb(): D1Database {
+ const db = new DatabaseSync(':memory:');
+ for (const m of migrations) db.exec(readFileSync(resolve(migrationsDir, m), 'utf8'));
+ db.exec(FIXTURE);
+ open = db;
+ return d1(db);
+}
+afterEach(() => {
+ open?.close();
+ open = null;
+});
+
+const byType = (f: Awaited>, t: string) =>
+ f.byType.find((r) => r.type === t)!;
+
+describe('getFlaggedValue', () => {
+ it('de-duplicates the total (a contract with two signals counts once)', async () => {
+ const f = await getFlaggedValue(realDb());
+ // c1..c5 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0.
+ expect(f.contracts).toBe(5);
+ expect(f.totalEur).toBe(6500); // 1000 + 2000 + 1500 + 0 + 2000
+ });
+
+ it('reports overlapping by-type slices (their sum exceeds the de-duplicated total)', async () => {
+ const f = await getFlaggedValue(realDb());
+ expect(byType(f, 'no_competition')).toMatchObject({ eur: 3000, contracts: 2 }); // c1 + c5
+ expect(byType(f, 'eu_no_competition')).toMatchObject({ eur: 2000, contracts: 1 }); // c2
+ expect(byType(f, 'high_markup')).toMatchObject({ eur: 3500, contracts: 2 }); // c3 + c5
+ expect(byType(f, 'anomalies')).toMatchObject({ eur: 0, contracts: 1 }); // c4 (NULL amount)
+ const sum = f.byType.reduce((n, r) => n + r.eur, 0);
+ expect(sum).toBe(8500);
+ expect(sum).toBeGreaterThan(f.totalEur); // c5 double-counted across two types
+ });
+
+ it('partitions by sector so the slices sum to the total', async () => {
+ const f = await getFlaggedValue(realDb());
+ const s45 = f.bySector.find((s) => s.code === '45')!;
+ const s72 = f.bySector.find((s) => s.code === '72')!;
+ expect(s45).toMatchObject({ eur: 4500, contracts: 3 }); // c1 + c3 + c5
+ expect(s72).toMatchObject({ eur: 2000, contracts: 2 }); // c2 + c4
+ expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(f.totalEur);
+ });
+
+ it('partitions by authority type so the slices sum to the total', async () => {
+ const f = await getFlaggedValue(realDb());
+ const obshtina = f.byAuthorityType.find((a) => a.typeGroup === 'община')!;
+ const min = f.byAuthorityType.find((a) => a.typeGroup === 'министерство')!;
+ expect(obshtina).toMatchObject({ eur: 4500, contracts: 3 });
+ expect(min).toMatchObject({ eur: 2000, contracts: 2 });
+ expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBe(f.totalEur);
+ });
+});
+
+describe('/contracts flag filter', () => {
+ it('flag=no_competition narrows to the single-offer rows (incl. the overlapping one)', async () => {
+ const r = await listContracts(realDb(), { flags: ['no_competition'], pageSize: 10 });
+ expect(r.total).toBe(2); // c1, c5
+ });
+
+ it('flag=high_markup narrows to the cost-growth rows', async () => {
+ const r = await listContracts(realDb(), { flags: ['high_markup'], pageSize: 10 });
+ expect(r.total).toBe(2); // c3, c5
+ });
+
+ it('flag=all matches every flagged contract', async () => {
+ const r = await listContracts(realDb(), { flags: ['all'], pageSize: 10 });
+ expect(r.total).toBe(5); // c1..c5, not the clean c6
+ });
+
+ it('multiple flag tokens are OR-combined (union)', async () => {
+ const r = await listContracts(realDb(), {
+ flags: ['no_competition', 'anomalies'],
+ pageSize: 10,
+ });
+ expect(r.total).toBe(3); // c1, c5 (no_competition) ∪ c4 (anomalies)
+ });
+
+ it('an unrecognised flag token matches nothing (not everything)', async () => {
+ const r = await listContracts(realDb(), { flags: ['bogus'], pageSize: 10 });
+ expect(r.total).toBe(0);
+ });
+
+ it('type= narrows to contracts of that authority type_group', async () => {
+ const r = await listContracts(realDb(), { authorityTypes: ['министерство'], pageSize: 10 });
+ expect(r.total).toBe(3); // c2, c4, c6 (all министерство tenders)
+ });
+
+ it('flag composes with sector/type instead of replacing them', async () => {
+ const r = await listContracts(realDb(), {
+ flags: ['all'],
+ authorityTypes: ['министерство'],
+ pageSize: 10,
+ });
+ expect(r.total).toBe(2); // министерство ∩ flagged = c2, c4
+ });
+});
diff --git a/packages/db/src/queries/flagged.ts b/packages/db/src/queries/flagged.ts
new file mode 100644
index 00000000..6d5d3398
--- /dev/null
+++ b/packages/db/src/queries/flagged.ts
@@ -0,0 +1,136 @@
+// Flagged value (issue #218): the total € running through contracts that carry a risk signal, plus a
+// breakdown by signal type and by category. Computed LIVE over existing columns (no schema/precompute
+// change) — the caller edge-caches it for an hour, same basis as the single-offer scan in home.ts.
+//
+// The signal predicates mirror the per-contract RiskIndicators (apps/web/app/lib/riskLogic.ts) so the
+// homepage number stays consistent with the badge a visitor sees on a contract page. Keep FLAG_TYPES in
+// sync with riskLogic's RiskFlagType (asserted by flagged.test.ts). `c` is the `contracts` alias.
+
+import { CPV_SECTORS } from '@sigma/config';
+import type { FlaggedValue } from '@sigma/api-contract';
+
+// The "unconfirmed value" flags (mirrors details.ts value.suspect: value_suspect | annex_suspect |
+// review | value_low). Kept as a SQL list literal so the fragments read cleanly.
+const SUSPECT_VALUE_FLAGS = "('value_suspect', 'annex_suspect', 'review', 'value_low')";
+
+/** Per-signal SQL predicates — the single source of truth reused by the aggregate and the /contracts
+ * `flag` filter. Each is a self-contained boolean over the `contracts c` row. */
+export const FLAG_SQL = {
+ // Admitted bids (received − rejected) == 1, without EU funding.
+ no_competition:
+ 'c.bids_received IS NOT NULL AND (c.bids_received - COALESCE(c.bids_rejected, 0)) = 1 ' +
+ 'AND (c.eu_funded IS NULL OR c.eu_funded = 0)',
+ // Same, but EU-funded (surfaced separately, like riskLogic).
+ eu_no_competition:
+ 'c.bids_received IS NOT NULL AND (c.bids_received - COALESCE(c.bids_rejected, 0)) = 1 ' +
+ 'AND c.eu_funded = 1',
+ // Current value grew > 20% over the signing value (annex-driven cost growth); non-suspect only, so a
+ // value anomaly isn't double-counted here (mirrors details.ts deltaPct, which is null when suspect).
+ high_markup:
+ `c.value_flag NOT IN ${SUSPECT_VALUE_FLAGS} AND c.signing_value_eur IS NOT NULL ` +
+ 'AND c.signing_value_eur <> 0 AND (c.current_value_eur - c.signing_value_eur) > 0.2 * c.signing_value_eur',
+ // Value or date anomaly.
+ anomalies: `c.date_flag = 'signed_after_publication' OR c.value_flag IN ${SUSPECT_VALUE_FLAGS}`,
+} as const;
+
+export type FlagType = keyof typeof FLAG_SQL;
+export const FLAG_TYPES = Object.keys(FLAG_SQL) as FlagType[];
+
+/** A contract is "flagged" when it carries at least one signal. Each predicate is parenthesised because
+ * `anomalies` is itself an OR. */
+export const ANY_FLAG_SQL = FLAG_TYPES.map((t) => `(${FLAG_SQL[t]})`).join(' OR ');
+
+/** Map a filter token (a FlagType, or `all`) to a WHERE fragment. Unknown tokens → null (ignored). */
+export function flagPredicate(token: string): string | null {
+ if (token === 'all') return ANY_FLAG_SQL;
+ return token in FLAG_SQL ? FLAG_SQL[token as FlagType] : null;
+}
+
+const SECTOR_LABEL = new Map(CPV_SECTORS.map((s) => [s.code, s.short ?? s.label]));
+
+const FROM = `FROM contracts c
+ JOIN tenders t ON t.id = c.tender_id
+ JOIN authorities a ON a.id = t.authority_id`;
+
+interface TotalRow {
+ total_eur: number;
+ total_contracts: number;
+ [k: string]: number;
+}
+interface SectorRow {
+ code: string | null;
+ eur: number;
+ contracts: number;
+}
+interface AuthTypeRow {
+ type_group: string | null;
+ eur: number;
+ contracts: number;
+}
+
+/**
+ * Homepage flagged-value summary. Money (€) sums only trustworthy figures — `SUM(amount_eur)` skips the
+ * NULLs the ETL leaves on unrecoverable `value_suspect` rows (canonical basis, #98) — while the CONTRACT
+ * tally counts every flagged row, so a value-suspect contract is counted but contributes €0. The `byType`
+ * slices OVERLAP (a contract can be both single-offer and cost-growth), so they sum to more than the
+ * de-duplicated total; `bySector`/`byAuthorityType` partition the flagged set, so they sum to the total.
+ */
+export async function getFlaggedValue(db: D1Database): Promise {
+ const typeCols = FLAG_TYPES.flatMap((t) => [
+ `COALESCE(SUM(CASE WHEN (${FLAG_SQL[t]}) THEN c.amount_eur END), 0) AS ${t}_eur`,
+ `COUNT(CASE WHEN (${FLAG_SQL[t]}) THEN 1 END) AS ${t}_n`,
+ ]);
+
+ const [totalRow, sectors, authTypes] = await Promise.all([
+ db
+ .prepare(
+ `SELECT
+ COALESCE(SUM(CASE WHEN (${ANY_FLAG_SQL}) THEN c.amount_eur END), 0) AS total_eur,
+ COUNT(CASE WHEN (${ANY_FLAG_SQL}) THEN 1 END) AS total_contracts,
+ ${typeCols.join(',\n ')}
+ ${FROM}`,
+ )
+ .first(),
+ db
+ .prepare(
+ `SELECT substr(t.cpv_code, 1, 2) AS code,
+ COALESCE(SUM(c.amount_eur), 0) AS eur, COUNT(*) AS contracts
+ ${FROM}
+ WHERE (${ANY_FLAG_SQL}) AND t.cpv_code IS NOT NULL
+ GROUP BY code ORDER BY eur DESC LIMIT 6`,
+ )
+ .all(),
+ db
+ .prepare(
+ `SELECT a.type_group AS type_group,
+ COALESCE(SUM(c.amount_eur), 0) AS eur, COUNT(*) AS contracts
+ ${FROM}
+ WHERE (${ANY_FLAG_SQL}) AND a.type_group IS NOT NULL
+ GROUP BY a.type_group ORDER BY eur DESC`,
+ )
+ .all(),
+ ]);
+
+ const byType = FLAG_TYPES.map((t) => ({
+ type: t,
+ eur: totalRow?.[`${t}_eur`] ?? 0,
+ contracts: totalRow?.[`${t}_n`] ?? 0,
+ }));
+
+ return {
+ totalEur: totalRow?.total_eur ?? 0,
+ contracts: totalRow?.total_contracts ?? 0,
+ byType,
+ bySector: sectors.results
+ .filter((r): r is SectorRow & { code: string } => r.code != null)
+ .map((r) => ({
+ code: r.code,
+ label: SECTOR_LABEL.get(r.code) ?? r.code,
+ eur: r.eur,
+ contracts: r.contracts,
+ })),
+ byAuthorityType: authTypes.results
+ .filter((r): r is AuthTypeRow & { type_group: string } => r.type_group != null)
+ .map((r) => ({ typeGroup: r.type_group, eur: r.eur, contracts: r.contracts })),
+ };
+}
diff --git a/packages/db/src/queries/home.ts b/packages/db/src/queries/home.ts
index fe588134..d0562bab 100644
--- a/packages/db/src/queries/home.ts
+++ b/packages/db/src/queries/home.ts
@@ -6,6 +6,7 @@ import {
type CompanyTotalsRow,
} from './rows';
import { listSingleOfferContracts } from './contracts';
+import { getFlaggedValue } from './flagged';
interface HomeTotalsRow {
contracts: number;
@@ -50,37 +51,47 @@ export async function getHomeData(db: D1Database): Promise {
};
const placeholders = STATE_TYPES.map(() => '?').join(', ');
- const [companies, ministries, municipalities, recentSingleOffer, topSingleOffer, singleOfferRow] =
- await Promise.all([
- db
- .prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`)
- .all(),
- db
- .prepare(
- `SELECT * FROM authority_totals WHERE type_group IN (${placeholders}) ORDER BY spent_eur DESC, authority_id LIMIT 6`,
- )
- .bind(...STATE_TYPES)
- .all(),
- db
- .prepare(
- `SELECT * FROM authority_totals WHERE type_group = 'община' ORDER BY spent_eur DESC, authority_id LIMIT 6`,
- )
- .all(),
- listSingleOfferContracts(db, 'recent', 10),
- listSingleOfferContracts(db, 'value', 10),
- // Money portion of single-offer contracts vs the whole corpus (totals.valueEur is the
- // denominator). Same clean-row basis as the single-offer list above: bids = 1, non-suspect,
- // positive amount. Edge-cached for an hour, so the full scan runs rarely.
- db
- .prepare(
- `SELECT COALESCE(SUM(amount_eur), 0) AS value_eur, COUNT(*) AS contracts
+ const [
+ companies,
+ ministries,
+ municipalities,
+ recentSingleOffer,
+ topSingleOffer,
+ singleOfferRow,
+ flagged,
+ ] = await Promise.all([
+ db
+ .prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`)
+ .all(),
+ db
+ .prepare(
+ `SELECT * FROM authority_totals WHERE type_group IN (${placeholders}) ORDER BY spent_eur DESC, authority_id LIMIT 6`,
+ )
+ .bind(...STATE_TYPES)
+ .all(),
+ db
+ .prepare(
+ `SELECT * FROM authority_totals WHERE type_group = 'община' ORDER BY spent_eur DESC, authority_id LIMIT 6`,
+ )
+ .all(),
+ listSingleOfferContracts(db, 'recent', 10),
+ listSingleOfferContracts(db, 'value', 10),
+ // Money portion of single-offer contracts vs the whole corpus (totals.valueEur is the
+ // denominator). Same clean-row basis as the single-offer list above: bids = 1, non-suspect,
+ // positive amount. Edge-cached for an hour, so the full scan runs rarely.
+ db
+ .prepare(
+ `SELECT COALESCE(SUM(amount_eur), 0) AS value_eur, COUNT(*) AS contracts
FROM contracts WHERE bids_received = 1 AND value_flag = 'ok' AND amount_eur > 0`,
- )
- .first<{ value_eur: number; contracts: number }>(),
- ]);
+ )
+ .first<{ value_eur: number; contracts: number }>(),
+ // Flagged-value summary (#218) — live aggregate over existing columns, under the 1h edge cache.
+ getFlaggedValue(db),
+ ]);
return {
totals,
+ flagged,
topCompanies: companies.results.map(toCompanyListItem),
topMinistries: ministries.results.map(toAuthorityListItem),
topMunicipalities: municipalities.results.map(toAuthorityListItem),
diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts
index 2ed922e7..5ebd027d 100644
--- a/packages/db/src/queries/index.ts
+++ b/packages/db/src/queries/index.ts
@@ -7,6 +7,7 @@ export * from './keyset';
export * from './sectors';
export * from './rows';
export * from './home';
+export * from './flagged';
export * from './methodology';
export * from './companies';
export * from './authorities';
diff --git a/packages/db/src/queries/keyset.test.ts b/packages/db/src/queries/keyset.test.ts
index d82982eb..bd0647f3 100644
--- a/packages/db/src/queries/keyset.test.ts
+++ b/packages/db/src/queries/keyset.test.ts
@@ -6,8 +6,10 @@ import { decodeCursor, encodeCursor, filterSignature, keyset, pageCursors } from
const FILTER_VALUE: Record = {
authority: '000695089',
+ authorityTypes: ['министерство'],
bidder: '103267194',
bids: 'one',
+ flags: ['no_competition'],
countBucket: '2-5',
eu: 'eu',
kinds: ['company'],
@@ -76,6 +78,8 @@ describe('route filter signatures', () => {
'bidder',
'q',
'bids',
+ 'flags',
+ 'authorityTypes',
]);
expect([...COMPANY_FILTER_KEYS]).toEqual([
'kinds',
From ee38c8884014fb517c12def69a48fa5f10d107b4 Mon Sep 17 00:00:00 2001
From: DiyanaDimitrova
Date: Sun, 12 Jul 2026 10:31:30 +0300
Subject: [PATCH 2/6] refactor(web): address #218 review + reuse single-offer
components
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Strict review fixes:
- HIGH: methodology listed "рискови сигнали" as not-yet-built while this PR ships
them (section 10) — remove from the in-development list, point to section 10.
- MEDIUM: the by-sector (top 6) and by-authority-type breakdowns exclude rows
with a NULL cpv_code/type_group and cap sector to 6, so they do NOT necessarily
sum to the total. Correct the flagged.ts docstring + the methodology copy, and
add a NULL-dimension fixture row proving the slices sum to less than the total.
- LOW: the total/by-type aggregate references only c.*, so give it FROM contracts
c (no joins) — fewer rows read on the hottest scan.
UI: render the flagged section with the same components as "Поръчки с една оферта"
— a SingleOfferPortion bar (flagged EUR as a share of all contract value) and a
SingleOfferTable of the top flagged contracts (via listContracts flag=all) — while
keeping the by-type / by-sector / by-authority-type drill-down breakdowns.
Refs #218
---
apps/web/app/routes/home.tsx | 14 +++++----
apps/web/app/routes/methodology.tsx | 18 ++++++------
apps/web/app/styles/home.css | 30 ++-----------------
packages/api-contract/src/index.ts | 2 ++
packages/db/src/queries/flagged.test.ts | 38 +++++++++++++++----------
packages/db/src/queries/flagged.ts | 5 ++--
packages/db/src/queries/home.ts | 6 +++-
7 files changed, 54 insertions(+), 59 deletions(-)
diff --git a/apps/web/app/routes/home.tsx b/apps/web/app/routes/home.tsx
index 68be186c..d1d9e980 100644
--- a/apps/web/app/routes/home.tsx
+++ b/apps/web/app/routes/home.tsx
@@ -91,6 +91,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
const {
totals,
flagged,
+ topFlagged,
topCompanies,
topMinistries,
topMunicipalities,
@@ -148,12 +149,11 @@ export default function Home({ loaderData }: Route.ComponentProps) {
са ориентири за преглед, не присъда. Как ги четем →
-
-
- ≈ {moneyBare(flagged.totalEur)} €
- в {count(flagged.contracts)} договора със сигнал →
-
-
- Място на изпълнение, собственици и свързани лица и{' '}
- рискови сигнали са в процес на разработка за следваща версия —
- изискват пълно сливане с допълнителни източници и отделен аналитичен слой.
+ Място на изпълнение и собственици и свързани лица{' '}
+ са в процес на разработка за следваща версия — изискват пълно сливане с допълнителни
+ източници и отделен аналитичен слой. Първите структурни сигнали за риск вече са
+ налични — виж §10.
Общата сума е де-дублирана — договор с няколко сигнала се брои
веднъж. Разбивката „по вид сигнал" обаче се застъпва (един договор може
- да попадне в няколко реда), затова сборът ѝ надхвърля общата сума; разбивките по
- сектор и по тип институция разделят множеството и се сумират до общата сума.
- Стойностите стъпват на каноничната изчистена стойност в евро (сумираме само
- договорите с достоверна стойност — договор с непотвърдена стойност се брои в
- бройката, но с 0 €).
+ да попадне в няколко реда), затова сборът ѝ надхвърля общата сума. Разбивките по
+ сектор (първите шест) и по тип институция показват къде се концентрира рискът; те
+ не сумират непременно до общата сума — договори без известен сектор или тип
+ институция остават извън тях. Стойностите стъпват на каноничната изчистена
+ стойност в евро (сумираме само договорите с достоверна стойност — договор с
+ непотвърдена стойност се брои в бройката, но с 0 €).
diff --git a/apps/web/app/styles/home.css b/apps/web/app/styles/home.css
index 9e8da6d0..341016a8 100644
--- a/apps/web/app/styles/home.css
+++ b/apps/web/app/styles/home.css
@@ -184,34 +184,10 @@
white-space: nowrap;
}
-/* Flagged-value section (#218): a lead accent number + three drill-down breakdown columns. */
-.flagged-lead {
- margin: var(--s-5) 0 var(--s-6);
-}
-.flagged-lead a {
- display: inline-flex;
- align-items: baseline;
- gap: var(--s-3);
- text-decoration: none;
- color: var(--ink);
- flex-wrap: wrap;
-}
-.flagged-lead a:hover .flagged-sub {
- color: var(--accent);
-}
-.flagged-num {
- font: 600 clamp(30px, 4vw, 44px) / 1 var(--font-serif);
- color: var(--accent);
- font-variant-numeric: tabular-nums;
- letter-spacing: -0.01em;
-}
-.flagged-sub {
- font-family: var(--font-mono);
- font-size: 12px;
- letter-spacing: 0.04em;
- color: var(--ink-soft);
-}
+/* Flagged-value section (#218): a SingleOfferPortion bar + three drill-down breakdown columns + a
+ top-flagged contracts table (SingleOfferTable). Only the breakdown columns need custom styling. */
.flagged-cols {
+ margin-block: var(--s-5);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: var(--s-5) var(--s-7);
diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts
index 31d80712..2c82f5b4 100644
--- a/packages/api-contract/src/index.ts
+++ b/packages/api-contract/src/index.ts
@@ -67,6 +67,8 @@ export interface HomeData {
totals: HomeTotals;
/** Total € running through contracts with a risk signal, + breakdowns (issue #218). */
flagged: FlaggedValue;
+ /** Top flagged contracts by value — for the homepage table (issue #218). */
+ topFlagged: ContractListItem[];
topCompanies: CompanyListItem[];
topMinistries: AuthorityListItem[];
topMunicipalities: AuthorityListItem[];
diff --git a/packages/db/src/queries/flagged.test.ts b/packages/db/src/queries/flagged.test.ts
index f451aa2e..9472a475 100644
--- a/packages/db/src/queries/flagged.test.ts
+++ b/packages/db/src/queries/flagged.test.ts
@@ -22,12 +22,14 @@ const migrations = readdirSync(migrationsDir)
const FIXTURE = `
INSERT INTO authorities (id, name, bulstat, type_group) VALUES
('auth:obshtina', 'Община', '100000001', 'община'),
- ('auth:min', 'Министерство', '100000002', 'министерство');
+ ('auth:min', 'Министерство', '100000002', 'министерство'),
+ ('auth:none', 'Без тип', '100000003', NULL);
INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES
('eik:200000001', 'Фирма', '200000001', '200000001', 1, 'company');
INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES
('t:o45', 'UNP-1', 'Строеж', 'auth:obshtina', '45000000', 'открита процедура', 'awarded'),
- ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded');
+ ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded'),
+ ('t:nocpv', 'UNP-3', 'Без CPV', 'auth:none', NULL, 'открита процедура', 'awarded');
INSERT INTO contracts
(id, tender_id, bidder_id, amount, currency, signed_at, bids_received, bids_rejected, eu_funded,
value_flag, date_flag, signing_value_eur, current_value_eur, amount_eur) VALUES
@@ -36,7 +38,10 @@ INSERT INTO contracts
('c3', 't:o45', 'eik:200000001', 1500, 'EUR', '2024-01-03', 3, 0, 0, 'ok', 'ok', 1000, 1500, 1500),
('c4', 't:m72', 'eik:200000001', 9000, 'EUR', '2024-01-04', 3, 0, 0, 'value_suspect', 'ok', NULL, NULL, NULL),
('c5', 't:o45', 'eik:200000001', 2000, 'EUR', '2024-01-05', 1, 0, 0, 'ok', 'ok', 1000, 2000, 2000),
- ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000);
+ ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000),
+ -- c7: flagged (no_competition) but on a tender with NULL cpv + authority with NULL type_group,
+ -- so it lands in the total/count but in NEITHER the bySector nor byAuthorityType breakdown.
+ ('c7', 't:nocpv', 'eik:200000001', 500, 'EUR', '2024-01-07', 1, 0, 0, 'ok', 'ok', 500, 500, 500);
`;
function d1(db: DatabaseSync): D1Database {
@@ -79,45 +84,48 @@ const byType = (f: Awaited>, t: string) =>
describe('getFlaggedValue', () => {
it('de-duplicates the total (a contract with two signals counts once)', async () => {
const f = await getFlaggedValue(realDb());
- // c1..c5 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0.
- expect(f.contracts).toBe(5);
- expect(f.totalEur).toBe(6500); // 1000 + 2000 + 1500 + 0 + 2000
+ // c1..c5 + c7 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0.
+ expect(f.contracts).toBe(6);
+ expect(f.totalEur).toBe(7000); // 1000 + 2000 + 1500 + 0 + 2000 + 500
});
it('reports overlapping by-type slices (their sum exceeds the de-duplicated total)', async () => {
const f = await getFlaggedValue(realDb());
- expect(byType(f, 'no_competition')).toMatchObject({ eur: 3000, contracts: 2 }); // c1 + c5
+ expect(byType(f, 'no_competition')).toMatchObject({ eur: 3500, contracts: 3 }); // c1 + c5 + c7
expect(byType(f, 'eu_no_competition')).toMatchObject({ eur: 2000, contracts: 1 }); // c2
expect(byType(f, 'high_markup')).toMatchObject({ eur: 3500, contracts: 2 }); // c3 + c5
expect(byType(f, 'anomalies')).toMatchObject({ eur: 0, contracts: 1 }); // c4 (NULL amount)
const sum = f.byType.reduce((n, r) => n + r.eur, 0);
- expect(sum).toBe(8500);
+ expect(sum).toBe(9000);
expect(sum).toBeGreaterThan(f.totalEur); // c5 double-counted across two types
});
- it('partitions by sector so the slices sum to the total', async () => {
+ it('breaks down by sector as top slices that need NOT sum to the total', async () => {
const f = await getFlaggedValue(realDb());
const s45 = f.bySector.find((s) => s.code === '45')!;
const s72 = f.bySector.find((s) => s.code === '72')!;
expect(s45).toMatchObject({ eur: 4500, contracts: 3 }); // c1 + c3 + c5
expect(s72).toMatchObject({ eur: 2000, contracts: 2 }); // c2 + c4
- expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(f.totalEur);
+ // c7 has a NULL cpv_code → excluded from every sector slice, so the slices sum to LESS than total.
+ expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(6500);
+ expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBeLessThan(f.totalEur);
});
- it('partitions by authority type so the slices sum to the total', async () => {
+ it('breaks down by authority type as slices that need NOT sum to the total', async () => {
const f = await getFlaggedValue(realDb());
const obshtina = f.byAuthorityType.find((a) => a.typeGroup === 'община')!;
const min = f.byAuthorityType.find((a) => a.typeGroup === 'министерство')!;
expect(obshtina).toMatchObject({ eur: 4500, contracts: 3 });
expect(min).toMatchObject({ eur: 2000, contracts: 2 });
- expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBe(f.totalEur);
+ // c7's authority has a NULL type_group → excluded, so the slices sum to LESS than total.
+ expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBeLessThan(f.totalEur);
});
});
describe('/contracts flag filter', () => {
it('flag=no_competition narrows to the single-offer rows (incl. the overlapping one)', async () => {
const r = await listContracts(realDb(), { flags: ['no_competition'], pageSize: 10 });
- expect(r.total).toBe(2); // c1, c5
+ expect(r.total).toBe(3); // c1, c5, c7
});
it('flag=high_markup narrows to the cost-growth rows', async () => {
@@ -127,7 +135,7 @@ describe('/contracts flag filter', () => {
it('flag=all matches every flagged contract', async () => {
const r = await listContracts(realDb(), { flags: ['all'], pageSize: 10 });
- expect(r.total).toBe(5); // c1..c5, not the clean c6
+ expect(r.total).toBe(6); // c1..c5, c7 — not the clean c6
});
it('multiple flag tokens are OR-combined (union)', async () => {
@@ -135,7 +143,7 @@ describe('/contracts flag filter', () => {
flags: ['no_competition', 'anomalies'],
pageSize: 10,
});
- expect(r.total).toBe(3); // c1, c5 (no_competition) ∪ c4 (anomalies)
+ expect(r.total).toBe(4); // c1, c5, c7 (no_competition) ∪ c4 (anomalies)
});
it('an unrecognised flag token matches nothing (not everything)', async () => {
diff --git a/packages/db/src/queries/flagged.ts b/packages/db/src/queries/flagged.ts
index 6d5d3398..df2c0283 100644
--- a/packages/db/src/queries/flagged.ts
+++ b/packages/db/src/queries/flagged.ts
@@ -73,7 +73,8 @@ interface AuthTypeRow {
* NULLs the ETL leaves on unrecoverable `value_suspect` rows (canonical basis, #98) — while the CONTRACT
* tally counts every flagged row, so a value-suspect contract is counted but contributes €0. The `byType`
* slices OVERLAP (a contract can be both single-offer and cost-growth), so they sum to more than the
- * de-duplicated total; `bySector`/`byAuthorityType` partition the flagged set, so they sum to the total.
+ * de-duplicated total. `bySector` (top 6) and `byAuthorityType` are TOP slices that need not sum to the
+ * total: flagged rows with a NULL `cpv_code`/`type_group` are excluded, and `bySector` is capped at 6.
*/
export async function getFlaggedValue(db: D1Database): Promise {
const typeCols = FLAG_TYPES.flatMap((t) => [
@@ -88,7 +89,7 @@ export async function getFlaggedValue(db: D1Database): Promise {
COALESCE(SUM(CASE WHEN (${ANY_FLAG_SQL}) THEN c.amount_eur END), 0) AS total_eur,
COUNT(CASE WHEN (${ANY_FLAG_SQL}) THEN 1 END) AS total_contracts,
${typeCols.join(',\n ')}
- ${FROM}`,
+ FROM contracts c`,
)
.first(),
db
diff --git a/packages/db/src/queries/home.ts b/packages/db/src/queries/home.ts
index d0562bab..d3b495a8 100644
--- a/packages/db/src/queries/home.ts
+++ b/packages/db/src/queries/home.ts
@@ -5,7 +5,7 @@ import {
type AuthorityTotalsRow,
type CompanyTotalsRow,
} from './rows';
-import { listSingleOfferContracts } from './contracts';
+import { listContracts, listSingleOfferContracts } from './contracts';
import { getFlaggedValue } from './flagged';
interface HomeTotalsRow {
@@ -59,6 +59,7 @@ export async function getHomeData(db: D1Database): Promise {
topSingleOffer,
singleOfferRow,
flagged,
+ topFlaggedPage,
] = await Promise.all([
db
.prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`)
@@ -87,11 +88,14 @@ export async function getHomeData(db: D1Database): Promise {
.first<{ value_eur: number; contracts: number }>(),
// Flagged-value summary (#218) — live aggregate over existing columns, under the 1h edge cache.
getFlaggedValue(db),
+ // Top flagged contracts by value, for the homepage table (same shape as the single-offer list).
+ listContracts(db, { flags: ['all'], sort: 'value-desc', pageSize: 10 }),
]);
return {
totals,
flagged,
+ topFlagged: topFlaggedPage.items,
topCompanies: companies.results.map(toCompanyListItem),
topMinistries: ministries.results.map(toAuthorityListItem),
topMunicipalities: municipalities.results.map(toAuthorityListItem),
From 6b69786b3982dacb6f1873c570e8b82cd5d48177 Mon Sep 17 00:00:00 2001
From: DiyanaDimitrova
Date: Mon, 13 Jul 2026 16:24:53 +0300
Subject: [PATCH 3/6] fix(web): address government security/GDPR/a11y review of
#218
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Data protection (blocking):
- Exclude natural-person (sole-trader ЕТ) bidders from the homepage
„Договори със сигнали за риск" table: over-fetch and drop names via
isNaturalPersonProfileName so an identifiable individual is never shown
under a risk label on the indexed, edge-cached homepage (mirrors the
existing noindex on sole-trader profiles).
- Add robots noindex to /contracts when a ?flag= filter is active, so
risk-filtered, name-bearing lists stay out of search indexes.
Transparency / accuracy (blocking):
- methodology.tsx §1 no longer claims the site „не маркира фирми като
рискови" (contradicted §10); reworded to: marks contracts with structural
signals, not entities as offenders.
- privacy.tsx: add a „Производни показатели (сигнали за риск)" section
disclosing the derived risk-signal processing, its lawful basis, and the
rectification (Art. 16) / objection (Art. 21) rights (GDPR Art. 13/14).
Security:
- Validate ?type= on /contracts against the closed authority type_group set
(new AUTHORITY_TYPE_GROUPS). /contracts is not rate-limited, so an
unvalidated ?type= let each distinct value mint a fresh edge-cache key and
an uncached full-table scan (cache-cardinality / DoS). Adds a regression test.
Accessibility (WCAG 2.1 AA):
- methodology §10 Callout uses the title prop (renders
) instead of a raw
, restoring the heading outline.
- Parametrize SingleOfferTable's caption so the flagged table has a correct,
distinct accessible name instead of the single-offer caption.
- Add a „Смятате сигнал за грешен?" rectification link to the homepage
flagged section.
---
apps/web/app/lib/filters.test.ts | 7 +++++++
apps/web/app/lib/filters.ts | 10 +++++++++-
apps/web/app/routes/contracts.tsx | 11 +++++++++--
apps/web/app/routes/home.tsx | 20 +++++++++++++++++---
apps/web/app/routes/methodology.tsx | 9 +++++----
apps/web/app/routes/privacy.tsx | 18 ++++++++++++++++++
packages/db/src/queries/home.ts | 10 ++++++++--
packages/db/src/queries/rows.ts | 5 +++++
8 files changed, 78 insertions(+), 12 deletions(-)
diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts
index 1aa4dbfb..660900ac 100644
--- a/apps/web/app/lib/filters.test.ts
+++ b/apps/web/app/lib/filters.test.ts
@@ -33,6 +33,13 @@ describe('contract risk-signal + authority-type filters (#218)', () => {
expect(f.authorityTypes).toEqual(['министерство', 'община']);
});
+ it('drops unknown ?type buckets (cache-cardinality / DoS allow-list guard, #218 review)', () => {
+ const f = contractListFilters(
+ new URLSearchParams('type=министерство&type=