Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
| Потоци | `/flows` | Парични потоци възложител → компания (суми + брой) |
| Търсене | `/search` | По имена, предмет и идентификатори |

Списъците имат CSV експорт (`/contracts.csv`, `/companies.csv`, `/authorities.csv`), всеки договор — JSON изглед (`/contracts/[id].json`). Има и страници за методология, достъпност, поверителност и impressum, плюс `robots.txt` и sitemap файлове.
Списъците имат CSV експорт (`/contracts.csv`, `/companies.csv`, `/authorities.csv`), всеки договор — JSON изглед (`/contracts/[id].json`), а профилите на институции и компании — RSS фийд с най-новите им договори (`.rss`). Има и страници за методология, достъпност, поверителност и impressum, плюс `robots.txt` и sitemap файлове.

## Накъде върви

Expand Down
125 changes: 125 additions & 0 deletions apps/web/app/lib/feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import type { ContractListItem } from '@sigma/api-contract';
import { contractRssItem, rssDate, rssFeed, xmlEscape } from './feed';

function item(overrides: Partial<ContractListItem> = {}): ContractListItem {
return {
id: 'e:UNP-1:2:eik:111111111',
subject: 'Доставка на техника',
unp: 'UNP-1',
sectorCode: '30',
euFunded: false,
isConsortium: false,
authoritySlug: '123456789',
authorityName: 'Община Пример',
bidderSlug: '111111111',
bidderName: 'Фирма ЕООД',
bidderDisplayName: 'Фирма ЕООД',
bidderKind: 'company',
procedureLabel: 'Открита процедура',
signedAt: '2026-05-15',
bidsReceived: 3,
valueEur: 12345.67,
...overrides,
};
}

describe('xmlEscape', () => {
it('escapes the five XML special characters', () => {
expect(xmlEscape(`<a & "b" & 'c'>`)).toBe('&lt;a &amp; &quot;b&quot; &amp; &apos;c&apos;&gt;');
});
it('passes ordinary Bulgarian text through unchanged', () => {
expect(xmlEscape('Община „Пример“ - договори')).toBe('Община „Пример“ - договори');
});
});

describe('rssDate', () => {
it('renders an ISO day as RFC 822', () => {
expect(rssDate('2026-05-15')).toBe('Fri, 15 May 2026 00:00:00 GMT');
});
it('returns null for null, malformed, and impossible dates', () => {
expect(rssDate(null)).toBeNull();
expect(rssDate('')).toBeNull();
expect(rssDate('15.05.2026')).toBeNull();
expect(rssDate('2026-13-45')).toBeNull();
});
});

describe('contractRssItem', () => {
it('builds an authority-feed item around the winning bidder', () => {
const rss = contractRssItem(item(), 'bidder', 'https://sigma.midt.bg');
expect(rss.title).toBe('Доставка на техника - Фирма ЕООД');
expect(rss.link).toBe('https://sigma.midt.bg/contracts/e:UNP-1:2:eik:111111111');
expect(rss.description).toContain('Изпълнител: Фирма ЕООД');
expect(rss.description).toContain('Подписан: 15.05.2026');
expect(rss.description).toContain('Процедура: Открита процедура');
expect(rss.pubDate).toBe('Fri, 15 May 2026 00:00:00 GMT');
});

it('builds a company-feed item around the buying authority', () => {
const rss = contractRssItem(item(), 'authority', 'https://sigma.midt.bg');
expect(rss.title).toBe('Доставка на техника - Община Пример');
expect(rss.description).toContain('Възложител: Община Пример');
});

it('handles missing value and missing signing date', () => {
const rss = contractRssItem(item({ valueEur: null, signedAt: null }), 'bidder', 'https://x.bg');
expect(rss.description).toContain('Стойност: без обявена стойност');
expect(rss.description).not.toContain('Подписан:');
expect(rss.pubDate).toBeNull();
});
});

describe('rssFeed', () => {
const opts = {
title: 'Община <Пример> - нови договори',
description: 'Най-новите договори & анекси',
siteLink: 'https://sigma.midt.bg/authorities/123456789',
selfLink: 'https://sigma.midt.bg/authorities/123456789.rss',
items: [
contractRssItem(
item({ subject: 'А/Б "проект" <спешен>' }),
'bidder',
'https://sigma.midt.bg',
),
contractRssItem(item({ signedAt: null, valueEur: null }), 'bidder', 'https://sigma.midt.bg'),
],
};

it('escapes user-controlled text everywhere it lands', () => {
const xml = rssFeed(opts);
expect(xml).toContain('<title>Община &lt;Пример&gt; - нови договори</title>');
expect(xml).toContain('Най-новите договори &amp; анекси');
expect(xml).toContain('А/Б &quot;проект&quot; &lt;спешен&gt;');
expect(xml).not.toMatch(/<спешен>/);
});

it('links the feed to itself and to the profile', () => {
const xml = rssFeed(opts);
expect(xml).toContain(
'<atom:link href="https://sigma.midt.bg/authorities/123456789.rss" rel="self" type="application/rss+xml"/>',
);
expect(xml).toContain('<link>https://sigma.midt.bg/authorities/123456789</link>');
});

it('uses the contract URL as a permalink guid and skips pubDate for undated items', () => {
const xml = rssFeed(opts);
expect(xml).toContain(
'<guid isPermaLink="true">https://sigma.midt.bg/contracts/e:UNP-1:2:eik:111111111</guid>',
);
expect(xml.match(/<pubDate>/g)).toHaveLength(2); // channel + the one dated item
});

it('stays deterministic: channel pubDate is the newest item date, no wall clock', () => {
const xml = rssFeed(opts);
expect(xml).toContain('<pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>');
expect(rssFeed(opts)).toBe(xml);
});

it('renders a valid empty channel when the entity has no contracts', () => {
const xml = rssFeed({ ...opts, items: [] });
expect(xml).toContain('<channel>');
expect(xml).not.toContain('<item>');
expect(xml).not.toContain('<pubDate>');
});
});
104 changes: 104 additions & 0 deletions apps/web/app/lib/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ContractListItem } from '@sigma/api-contract';
import { date, money } from '@sigma/shared';

// RSS 2.0 for the entity profile feeds ("следи тази институция/фирма" without an account).
// Hand-rolled on purpose: the format is tiny, the itemset is capped at one page, and every value
// passes through xmlEscape - a templating dependency would be more surface than the format itself.

const XML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
};

export function xmlEscape(value: string): string {
return value.replace(/[&<>"']/g, (ch) => XML_ESCAPES[ch] ?? ch);

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.

Незадължително (устойчивост): xmlEscape екранира 5-те XML entity-та, но не се справя с невалидните за XML 1.0 контролни символи (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F). Ако предмет на договор или име на субект от данните съдържа такъв символ, генерираният фийд става невалиден XML и строги RSS четци го отхвърлят. Обмислете допълнително премахване/заместване на неразрешените контролни символи преди сериализация.

}

/** 'YYYY-MM-DD' -> RFC 822 (RSS pubDate); null for absent or malformed dates. */
export function rssDate(day: string | null): string | null {
if (!day || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const t = Date.parse(`${day}T00:00:00Z`);
return Number.isNaN(t) ? null : new Date(t).toUTCString();
}

export interface RssItem {
title: string;
/** Absolute URL; doubles as the permalink <guid>. */
link: string;
description: string;
pubDate: string | null;
}

/**
* One feed item per contract. `counterparty` picks the side the feed's reader does NOT follow:
* an authority feed lists winners ('bidder'), a company feed lists buyers ('authority').
*/
export function contractRssItem(
item: ContractListItem,
counterparty: 'bidder' | 'authority',
origin: string,
): RssItem {
const other = counterparty === 'bidder' ? item.bidderDisplayName : item.authorityName;
const value = item.valueEur != null ? money(item.valueEur) : 'без обявена стойност';
const parts = [
`${counterparty === 'bidder' ? 'Изпълнител' : 'Възложител'}: ${other}`,
`Стойност: ${value}`,
item.signedAt ? `Подписан: ${date(item.signedAt)}` : null,
`Процедура: ${item.procedureLabel}`,
].filter((p): p is string => p !== null);
return {
title: `${item.subject} - ${other}`,
link: `${origin}/contracts/${item.id}`,
description: parts.join(' · '),
pubDate: rssDate(item.signedAt),

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.

pubDate използва само item.signedAt, но заявката listRecentEntityContracts подрежда по COALESCE(c.signed_at, c.published_at). Договор, който има само published_at, ще излезе без <pubDate>, докато е позициониран като „нов" по подредба. Това създава несъответствие: RSS четците подреждат по pubDate и такива елементи ще се разместят. Ако ContractListItem носи и датата на публикуване, помислете за fallback тук, за да е в синхрон с подредбата (и с описанието в docs/api.md „при липсваща дата - по публикуване").

};
}

export function rssFeed(opts: {
title: string;
description: string;
/** Absolute URL of the HTML profile the feed mirrors. */
siteLink: string;
/** Absolute URL of the feed itself (atom:link rel="self"). */
selfLink: string;
items: RssItem[];
}): string {
const items = opts.items
.map((item) =>
[
' <item>',
` <title>${xmlEscape(item.title)}</title>`,
` <link>${xmlEscape(item.link)}</link>`,
` <guid isPermaLink="true">${xmlEscape(item.link)}</guid>`,
item.pubDate ? ` <pubDate>${xmlEscape(item.pubDate)}</pubDate>` : null,
` <description>${xmlEscape(item.description)}</description>`,
' </item>',
]
.filter((line): line is string => line !== null)
.join('\n'),
)
.join('\n');
// Channel-level pubDate comes from the newest item so the output is a pure function of the data
// (deterministic for tests and for the edge cache) - no "now" timestamp anywhere.
const newest = opts.items.find((item) => item.pubDate != null)?.pubDate;

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.

newest взима първия елемент с ненулев pubDate. Тъй като pubDate идва само от signedAt (виж по-горе), ако най-новият елемент по подредба има само published_at, канал-ниво <pubDate> ще е датата на по-стар елемент — т.е. коментарът „channel pubDate is the newest item date" не е гарантиран във всички случаи. Дребно, но си струва уеднаквяване с логиката за подредба.

return [

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.

newest разчита на това, че opts.items идва подреден с най-новия първи (find връща първия с ненулев pubDate). В момента това е гарантирано от ORDER BY COALESCE(signed_at, published_at) DESC в заявката, но rssFeed е generic и няма как да наложи този инвариант. Ако в бъдеще подаващият промени реда, channel <pubDate> ще стане грешен без предупреждение. Обмислете кратък коментар/@requires за очаквания ред, или изчисляване на max по датите вместо find.

'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
` <title>${xmlEscape(opts.title)}</title>`,
` <link>${xmlEscape(opts.siteLink)}</link>`,
` <description>${xmlEscape(opts.description)}</description>`,
' <language>bg</language>',
newest ? ` <pubDate>${xmlEscape(newest)}</pubDate>` : null,
` <atom:link href="${xmlEscape(opts.selfLink)}" rel="self" type="application/rss+xml"/>`,
items || null,
' </channel>',
'</rss>',
'',
]
.filter((line): line is string => line !== null)
.join('\n');
}
2 changes: 2 additions & 0 deletions apps/web/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export default [
route('analytics', 'routes/analytics.tsx'),
route('companies', 'routes/companies.tsx'),
route('companies.csv', 'routes/companies.csv.tsx'),
route('companies/:eik.rss', 'routes/company.rss.tsx'),
route('companies/:eik', 'routes/company.tsx'),
route('authorities', 'routes/authorities.tsx'),
route('authorities.csv', 'routes/authorities.csv.tsx'),
route('authorities/:eik.rss', 'routes/authority.rss.tsx'),
route('authorities/:eik', 'routes/authority.tsx'),
route('contracts', 'routes/contracts.tsx'),
route('contracts.csv', 'routes/contracts.csv.tsx'),
Expand Down
38 changes: 38 additions & 0 deletions apps/web/app/routes/authority.rss.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { authorityIdFromSlug, getAuthorityHead, listRecentEntityContracts } from '@sigma/db';
import type { Route } from './+types/authority.rss';
import { publicCache } from '../lib/cache';
import { withDataSource } from '../lib/dataSource';
import { contractRssItem, rssFeed } from '../lib/feed';
import { withDbRetry } from '../lib/retry';

// Resource route: RSS 2.0 feed of an authority's newest contracts (/authorities/:eik.rss) - the
// no-account way to follow an entity (docs/api.md). X-Robots-Tag keeps feeds out of search indexes
// (profile pages carry the indexable content; some company profiles are deliberately noindex, #173).
export async function loader({ params, request, context }: Route.LoaderArgs) {
const eik = (params.eik ?? '').replace(/\.rss$/, '');

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.

Ако рутът authorities/:eik.rss вече отделя литералния суфикс .rss от параметъра, .replace(/\.rss$/, '') е излишен (params.eik вече е без .rss). Ако пък параметърът наистина включва .rss, тогава siteLink/selfLink разчитат на този strip — струва си да се потвърди с тест за loader-а, който в момента липсва. Същата бележка важи и за company.rss.tsx.

if (!eik.trim()) return withDataSource(new Response('Not Found', { status: 404 }));
const db = context.cloudflare.env.DB;
const authorityId = authorityIdFromSlug(eik);

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.

Дребна бележка за консистентност (не блокира): company.rss.tsx проверява bidderIdFromSlug(slug) за null и връща 404 преди достъп до базата, докато тук authorityIdFromSlug(eik) се използва без такава проверка. На практика е безопасно — невалиден вход води до getAuthorityHead(...) === null → 404 — но за сметка на едно излишно четене от базата при невалиден slug. Ако authorityIdFromSlug може да върне null, добавянето на аналогичен ранен null-check би направило двата route-а симетрични и би спестило DB заявката.

const { origin } = new URL(request.url);
return withDbRetry(async () => {

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.

Незадължителна бележка (сигурност): origin се извлича от request.url, т.е. от Host хедъра на заявката, и се вгражда в <link>/<guid>/atom:link. Стойностите се екранират чрез xmlEscape, така че няма XML инжекция, но фийдът все пак отразява подадения Host. Уверете се, че edge/CDN слоят валидира Host (или използвайте канонична конфигурирана база), за да не се генерират линкове към чужд домейн при spoof-нат Host хедър.

const head = await getAuthorityHead(db, authorityId);
if (!head) return withDataSource(new Response('Not Found', { status: 404 }));
const contracts = await listRecentEntityContracts(db, { kind: 'authority', authorityId });
const xml = rssFeed({
title: `${head.name} - нови договори - СИГМА`,
description: `Най-новите договори за обществени поръчки, възложени от ${head.name}.`,
siteLink: `${origin}/authorities/${eik}`,

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.

siteLink/selfLink използват суровия параметър eik (след премахване на .rss), а не канонична форма на slug-а. Ако профилът бъде заявен в резолвим, но неканоничен вид (напр. водещи нули или различно форматиране на ЕИК), <link>/atom:link rel=self ще сочат неканоничен URL, различен от HTML профила. Ефектът е малък (фийдовете са noindex), но за консистентност би било по-добре линковете да се строят от канонично изведения идентификатор. Същата бележка важи и за company.rss.tsx.

selfLink: `${origin}/authorities/${eik}.rss`,
items: contracts.map((c) => contractRssItem(c, 'bidder', origin)),
});
return withDataSource(
new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': publicCache(3600),
'X-Robots-Tag': 'noindex',
},
}),
);
});
}
8 changes: 8 additions & 0 deletions apps/web/app/routes/authority.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,17 @@ export default function Authority({ loaderData }: Route.ComponentProps) {
<Link to={`/contracts?authority=${a.eik}`}>
Виж всички / филтрирай / свали като CSV →
</Link>
{' · '}
<a href={`/authorities/${a.eik}.rss`}>Следи новите договори (RSS)</a>
</p>
</Section>
</main>
<link
rel="alternate"
type="application/rss+xml"
title={`${a.name} - нови договори`}
href={`/authorities/${a.eik}.rss`}
/>
</>
);
}
39 changes: 39 additions & 0 deletions apps/web/app/routes/company.rss.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { bidderIdFromSlug, getCompanyHead, listRecentEntityContracts } from '@sigma/db';
import type { Route } from './+types/company.rss';
import { publicCache } from '../lib/cache';
import { withDataSource } from '../lib/dataSource';
import { contractRssItem, rssFeed } from '../lib/feed';
import { withDbRetry } from '../lib/retry';

// Resource route: RSS 2.0 feed of a company's newest contracts (/companies/:eik.rss) - the
// no-account way to follow an entity (docs/api.md). X-Robots-Tag keeps feeds out of search indexes
// (profile pages carry the indexable content; some company profiles are deliberately noindex, #173).
export async function loader({ params, request, context }: Route.LoaderArgs) {
const slug = (params.eik ?? '').replace(/\.rss$/, '');
if (!slug.trim()) return withDataSource(new Response('Not Found', { status: 404 }));

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.

Тук има проверка if (!bidderId) return 404 след bidderIdFromSlug, докато в authority.rss.tsx няма аналог (защото authorityIdFromSlug не връща null). Асиметрията е коректна, но липсват тестове, които да покрият тези 404 клонове за двата loader-а — препоръчително предвид прага за покритие на нов код.

const bidderId = bidderIdFromSlug(slug);
if (!bidderId) return withDataSource(new Response('Not Found', { status: 404 }));
const db = context.cloudflare.env.DB;
const { origin } = new URL(request.url);
return withDbRetry(async () => {
const head = await getCompanyHead(db, bidderId);
if (!head) return withDataSource(new Response('Not Found', { status: 404 }));
const contracts = await listRecentEntityContracts(db, { kind: 'company', bidderId });
const xml = rssFeed({
title: `${head.name} - нови договори - СИГМА`,
description: `Най-новите договори за обществени поръчки, спечелени от ${head.name}.`,
siteLink: `${origin}/companies/${slug}`,
selfLink: `${origin}/companies/${slug}.rss`,
items: contracts.map((c) => contractRssItem(c, 'authority', origin)),
});
return withDataSource(
new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': publicCache(3600),
'X-Robots-Tag': 'noindex',
},
}),
);
});
}
8 changes: 8 additions & 0 deletions apps/web/app/routes/company.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,17 @@ export default function Company({ loaderData }: Route.ComponentProps) {
<Link to={`/contracts?bidder=${c.slug}`}>
Виж всички / филтрирай / свали като CSV →
</Link>
{' · '}
<a href={`/companies/${c.slug}.rss`}>Следи новите договори (RSS)</a>
</p>
</Section>
</main>
<link
rel="alternate"
type="application/rss+xml"
title={`${c.displayName} - нови договори`}
href={`/companies/${c.slug}.rss`}
/>
</>
);
}
15 changes: 14 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ HTML-страниците, всеки списък и всеки договор
Това е единственият per-entity JSON днес; за институции/компании ползвайте CSV
списъците или HTML профилите.

### RSS фийдове на профилите

`GET /authorities/{ЕИК}.rss` и `GET /companies/{slug}.rss` → RSS 2.0
(`application/rss+xml; charset=utf-8`) с най-новите до 50 договора на
институцията / компанията, подредени по дата на подписване (при липсваща дата -
по публикуване). Всеки запис носи предмета, насрещната страна, стойността и
процедурата; `<link>`/`<guid>` водят към страницата на договора. Това е
първата стъпка на „наблюдаваните списъци": следене на субект без акаунт - от
RSS четец или автоматизация. 404 за непознат профил; фийдовете носят
`X-Robots-Tag: noindex` (индексируемото съдържание е HTML профилът, а
разпознатите ЕТ профили са умишлено noindex - вж. бележката за личните данни).

### Sitemap-и

`GET /sitemap.xml` (индекс) + `/sitemap-pages.xml`, `/sitemap-authorities.xml`,
Expand Down Expand Up @@ -90,4 +102,5 @@ endpoint и **няма** обща REST заявка отвъд изброено

Този документ покрива наличното днес. Ако ви трябва формат или endpoint, който
липсва (напр. OCDS пакети, per-entity JSON за институции/компании, годишни
bulk dump-ове), отворете issue — посоката е координирана в `docs/`.
bulk dump-ове, email известия върху RSS фийдовете), отворете issue — посоката е
координирана в `docs/`.
Loading