Skip to content
Open
2 changes: 2 additions & 0 deletions apps/web/app/lib/csv-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ describe('isUnfilteredCsvExport', () => {
bidder: 'acme',
q: 'rail',
bids: 'one',
flags: ['no_competition'],
authorityTypes: ['министерство'],
types: ['municipality'],
kinds: ['company'],
countBucket: '2-5',
Expand Down
10 changes: 9 additions & 1 deletion apps/web/app/lib/csv-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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
Expand Down
36 changes: 36 additions & 0 deletions apps/web/app/lib/filters.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,9 +14,44 @@ 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('drops unknown ?type buckets (cache-cardinality / DoS allow-list guard, #218 review)', () => {
const f = contractListFilters(
new URLSearchParams('type=министерство&type=<script>&type=zzz-unbounded'),
);
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<string>(FLAG_TYPES)).toEqual(new Set<string>(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');
Expand Down
20 changes: 19 additions & 1 deletion apps/web/app/lib/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

import { CPV_CATEGORIES, CPV_SECTORS, categoryForDivision } from '@sigma/config';
import type { EntityKind } from '@sigma/api-contract';
import { normalizeAuthoritySort, normalizeCompanySort, normalizeContractSort } from '@sigma/db';
import {
AUTHORITY_TYPE_GROUPS,
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';
Expand All @@ -13,6 +19,12 @@ 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<string>([...FLAG_TYPES, 'all']);
// Authority type_group buckets accepted on ?type=. Bounding this is a cache-cardinality / DoS guard:
// unlike /authorities (rate-limited aggregation page), /contracts is not, so an unvalidated ?type=
// would let each distinct value mint a fresh edge-cache key AND an uncached full-table scan (#218 review).
const KNOWN_TYPES = new Set<string>(AUTHORITY_TYPE_GROUPS);

function allowedMulti(key: string, value: string): boolean {
if (key === 'sector') return KNOWN_SECTORS.has(value);
Expand Down Expand Up @@ -51,6 +63,11 @@ 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)),
// Validate against the closed bucket set: unlike /authorities (a rate-limited aggregation page),
// /contracts is not rate-limited, so an unvalidated ?type= would let each distinct value mint a
// fresh edge-cache key AND an uncached full-table scan (cache-cardinality / DoS guard, #218 review).
authorityTypes: getMulti(sp, 'type').filter((v) => KNOWN_TYPES.has(v)),

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.

Allow-listът тук (KNOWN_TYPES = AUTHORITY_TYPE_GROUPS = Object.keys(TYPE_LABELS)) е коректна и добра защита срещу cache-cardinality/DoS. Една бележка за консистентност: линковете на началната страница по „тип институция" се строят от a.type_group в БД (getFlaggedValue.byAuthorityType). Ако някога в БД попадне type_group извън седемте канонични кофи в TYPE_LABELS, съответният линк ?type=<x> ще бъде отхвърлен тук и изгледът тихо ще покаже ВСИЧКИ маркирани договори вместо конкретния тип (разминаване етикет↔резултат). Днес наборът е затворен и е ОК; струва си малък guard/тест, който да гарантира, че всяка стойност type_group от разбивката е ∈ AUTHORITY_TYPE_GROUPS. Не блокиращо.

};
}

Expand Down Expand Up @@ -191,6 +208,7 @@ export const PARAM_ORDER = [
'eu',
'bids', // /contracts single-bid filter
'value',
'flag',
'authority',
'bidder',
'center', // /network focus entity
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/lib/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 13 additions & 1 deletion apps/web/app/routes/conflicts.loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@ const q = vi.hoisted(() => ({
// DB is the identity sentinel the query mocks assert on, so getDb just returns env.DB unchanged.
getDb: vi.fn((env: { DB: unknown }) => env.DB),
}));
vi.mock('@sigma/db', () => q);
// `../lib/filters` (imported transitively by the conflicts loaders) builds its `?flag=`/`?type=` allow-lists
// from `[...FLAG_TYPES]` and `AUTHORITY_TYPE_GROUPS` at module load (#218), so the mock must carry those
// non-fn constants or the import throws. Pull them from the real module (no drift) while keeping the query
// functions mocked. Spread the real constants first, then `q`, so the mocked fns win. Added via the factory
// (not `q`) so afterEach's mockReset loop over `q` stays fn-only.
vi.mock('@sigma/db', async (importActual) => {
const actual = await importActual<typeof import('@sigma/db')>();
return {
FLAG_TYPES: actual.FLAG_TYPES,
AUTHORITY_TYPE_GROUPS: actual.AUTHORITY_TYPE_GROUPS,
...q,
};
});

import { loader as leaderboardLoader } from './conflicts';
import { loader as officialLoader } from './conflict.official';
Expand Down
11 changes: 9 additions & 2 deletions apps/web/app/routes/contracts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,21 @@ const VALUE_BUCKETS = [
{ value: 'gt100m', label: 'Над 100 млн. €' },
];

export function meta({ matches }: Route.MetaArgs) {
return seoMeta({
export function meta({ matches, location }: Route.MetaArgs) {
const tags = seoMeta({
matches,
path: '/contracts',
title: 'Договори — СИГМА',
description:
'Всеки сключен договор по обществена поръчка. Филтрите са в адреса, има и сваляне в CSV.',
});
// GDPR/ЗЗЛД (#218 review): a risk-signal-filtered list (?flag=…) can surface named natural persons
// (sole-trader ЕТ) under a „сигнали за риск" heading. Keep such views out of search indexes, mirroring
// the noindex on sole-trader company profiles (company.tsx). The unfiltered /contracts stays indexable.
if (new URLSearchParams(location.search).has('flag')) {
tags.push({ name: 'robots', content: 'noindex' });
}
return tags;
}

export function headers() {
Expand Down
108 changes: 106 additions & 2 deletions apps/web/app/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { count, date, moneyBare } from '@sigma/shared';
import { getHomeData, getDb } from '@sigma/db';
import type { ContractListItem } from '@sigma/api-contract';
import type { Route } from './+types/home';
import type { RiskFlagType } from '../lib/riskLogic';
import { PageHeader } from '../components/PageHeader';
import { SmartSearch } from '../components/SmartSearch';
import { TotalsStrip } from '../components/TotalsStrip';
Expand Down Expand Up @@ -33,13 +34,21 @@ export async function loader({ context }: Route.LoaderArgs) {
return getHomeData(getDb(env));
}

function SingleOfferTable({ items, allHref }: { items: ContractListItem[]; allHref: string }) {
function SingleOfferTable({
items,
allHref,
caption = 'Поръчки с една оферта',
}: {
items: ContractListItem[];
allHref: string;
caption?: string;
}) {
if (items.length === 0) return <p className="small muted">Няма данни за този изглед.</p>;
return (
<>
<div className="table-wrap tbl-cards">
<table>
<caption className="sr-only">Поръчки с една оферта</caption>
<caption className="sr-only">{caption}</caption>
<thead>
<tr>
<th scope="col">Дата</th>
Expand Down Expand Up @@ -79,9 +88,21 @@ function SingleOfferTable({ items, allHref }: { items: ContractListItem[]; allHr
);
}

// Bulgarian labels for the risk-signal types. `satisfies Record<RiskFlagType, string>` makes TypeScript
// enforce that every signal type has a label (and none is misspelled) — a renamed/added RiskFlagType now
// fails the build here instead of silently falling through to the raw key at runtime (#236 review).
const FLAG_LABELS = {
no_competition: 'Липса на конкуренция',
eu_no_competition: 'Липса на конкуренция (със средства от ЕС)',
high_markup: 'Ръст на стойността чрез анекси',
anomalies: 'Стойностна или времева аномалия',
} satisfies Record<RiskFlagType, string>;

export default function Home({ loaderData }: Route.ComponentProps) {
const {
totals,
flagged,
topFlagged,
topCompanies,
topMinistries,
topMunicipalities,
Expand Down Expand Up @@ -129,6 +150,89 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{totals.asOf ? `, последен договор ${date(totals.asOf)}` : ''}.
</p>

<section className="section flagged" aria-labelledby="flagged">
<h2 id="flagged">
Договори със <em>сигнали за риск</em>
</h2>
<p className="section-hint">
Обща стойност на договорите, при които СИГМА отбелязва поне един структурен сигнал — липса
на конкуренция, ръст на стойността чрез анекси или стойностна/времева аномалия. Сигналите
са ориентири за преглед, не присъда. <Link to="/methodology#flagged">Как ги четем →</Link>
{' · '}
<Link to="/methodology#contact">Смятате сигнал за грешен? →</Link>
</p>

<SingleOfferPortion
valueEur={flagged.totalEur}
totalEur={totals.valueEur}
scopeLabel="на всички договори"
/>

<div className="flagged-cols">
<div>
<h3 className="flagged-h3">По вид сигнал</h3>
<ul className="flagged-list">
{flagged.byType
.filter((r) => r.contracts > 0)
.map((r) => (
<li key={r.type}>
<Link to={`/contracts?flag=${r.type}&sort=value-desc`}>
<span>{FLAG_LABELS[r.type as RiskFlagType] ?? r.type}</span>
<span className="flagged-val">
{moneyBare(r.eur)} € · {count(r.contracts)}
</span>
</Link>
</li>
))}
</ul>
<p className="small muted flagged-note">
Един договор може да носи няколко сигнала, затова редовете тук се застъпват и сборът
им надхвърля общата (де-дублирана) сума.
</p>
</div>

<div>
<h3 className="flagged-h3">По сектор</h3>
<ul className="flagged-list">
{flagged.bySector.map((s) => (
<li key={s.code}>
<Link to={`/contracts?flag=all&sector=${s.code}&sort=value-desc`}>
<span>{s.label}</span>
<span className="flagged-val">
{moneyBare(s.eur)} € · {count(s.contracts)}
</span>
</Link>
</li>
))}
</ul>
</div>

<div>
<h3 className="flagged-h3">По тип институция</h3>
<ul className="flagged-list">
{flagged.byAuthorityType.map((a) => (
<li key={a.typeGroup}>
<Link
to={`/contracts?flag=all&type=${encodeURIComponent(a.typeGroup)}&sort=value-desc`}
>
<span>{a.typeGroup}</span>
<span className="flagged-val">
{moneyBare(a.eur)} € · {count(a.contracts)}
</span>
</Link>
</li>
))}
</ul>
</div>
</div>

<SingleOfferTable
items={topFlagged}
caption="Договори с най-висока стойност със сигнали за риск"
allHref="/contracts?flag=all&sort=value-desc"
/>
</section>

<section className="section" aria-labelledby="find-yours">
<h2 id="find-yours">
Най-активните <em>институции</em>
Expand Down
29 changes: 22 additions & 7 deletions apps/web/app/routes/methodology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,10 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {
</table>
</div>
<p className="small muted mt-s3">
<strong>Място на изпълнение</strong>, <strong>собственици и свързани лица</strong> и{' '}
<strong>рискови сигнали</strong> са в процес на разработка за следваща версия —
изискват пълно сливане с допълнителни източници и отделен аналитичен слой.
<strong>Място на изпълнение</strong> и <strong>собственици и свързани лица</strong>{' '}
са в процес на разработка за следваща версия — изискват пълно сливане с допълнителни
източници и отделен аналитичен слой. Първите структурни сигнали за риск вече са
налични — виж <a href="#flagged">§10</a>.
</p>
</section>

Expand All @@ -536,10 +537,12 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {
<section className="section" aria-labelledby="flagged">
<h2 id="flagged">10. Сигнали за риск</h2>
<p>
На страницата на всеки договор СИГМА отбелязва{' '}
<strong>структурни сигнали за риск</strong>, когато поне един признак заслужава
преглед. Сигналите са <em>ориентири</em>, не присъда: те не твърдят нарушение, а
насочват към договори, които си струва да се погледнат по-внимателно.
На началната страница СИГМА показва{' '}
<strong>общата стойност на договорите със сигнали за риск</strong> — договори, при
които поне един структурен признак заслужава преглед. Същите сигнали се отбелязват и
на страницата на всеки договор. Сигналите са <em>ориентири</em>, не присъда: те не
твърдят нарушение, а насочват към договори, които си струва да се погледнат
по-внимателно. Всяко число води до самите договори.
</p>
<p>Отбелязваме четири признака:</p>
<dl className="glossary">
Expand Down Expand Up @@ -577,6 +580,18 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {
<span className="src">→ value_flag / date_flag</span>
</dd>
</dl>
<Callout title="Как се смята сумата">
<p className="m-0">
Общата сума е <strong>де-дублирана</strong> — договор с няколко сигнала се брои
веднъж. Разбивката „по вид сигнал" обаче се <em>застъпва</em> (един договор може
да попадне в няколко реда), затова сборът ѝ надхвърля общата сума. Разбивките по
сектор (първите шест) и по тип институция показват къде се концентрира рискът; те
не сумират непременно до общата сума — договори без известен сектор или тип
институция остават извън тях. Стойностите стъпват на каноничната изчистена
стойност в евро (сумираме само договорите с достоверна стойност — договор с
непотвърдена стойност се брои в бройката, но с 0 €).
</p>
</Callout>
<p>
<strong>Ограничения.</strong> Сигналите стъпват на публични данни и структурни
признаци, не на разследване. Възможни са грешки или празнини в източника (виж{' '}
Expand Down
Loading