Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
138 changes: 138 additions & 0 deletions apps/web/app/lib/feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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',
publishedAt: null,
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();
});

it('falls back to publishedAt for pubDate when there is no signing date', () => {
// Mirrors the query's ORDER BY COALESCE(signed_at, published_at): an item ordered by publish date
// must still carry a <pubDate> so readers can sort it. The description omits „Подписан:" (no signing).
const rss = contractRssItem(
item({ signedAt: null, publishedAt: '2026-05-10' }),
'bidder',
'https://x.bg',
);
expect(rss.pubDate).toBe('Sun, 10 May 2026 00:00:00 GMT');
expect(rss.description).not.toContain('Подписан:');
});
});

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>');
});
});
107 changes: 107 additions & 0 deletions apps/web/app/lib/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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(' · '),
// Fall back to publishedAt when there is no signing date, matching the query's
// `ORDER BY COALESCE(signed_at, published_at)`: an item positioned as "new" by publish date must
// carry a <pubDate> so readers can order it, and the channel pubDate stays the true newest (review ydimitrof).
pubDate: rssDate(item.signedAt ?? item.publishedAt),

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.

Дребно: item.signedAt ?? item.publishedAt използва nullish coalescing, така че празен низ '' в signedAt НЕ би паднал към publishedAt (rssDate('') → null), докато SQL COALESCE(signed_at, published_at) също третира '' като не-null — т.е. поведението е консистентно със заявката. При реалните данни колоната е NULL, затова на практика е безопасно; отбелязвам само защото семантичното намерение е „няма дата на подписване → ползвай публикуване", което ?? не покрива за празен низ.

};
}

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
92 changes: 92 additions & 0 deletions apps/web/app/routes/authority.rss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { loader } from './authority.rss';

const contractRow = {
id: 'c:e:UNP-9:1:eik:222222222',
subject: 'Ремонт на път',
unp: 'UNP-9',
cpv_code: '45000000',
eu_funded: 0,
authority_id: 'auth:123456789',
authority_name: 'Община Пример',
bidder_id: 'eik:222222222',
bidder_name: 'Пътстрой ЕООД',
bidder_kind: 'company',
procedure_type: 'Открита процедура',
signed_at: '2026-05-15',
published_at: '2026-05-10',
bids_received: 2,
amount_eur: 1000,
};

function fakeDb(head: { name: string } | null, rows: unknown[] = []): D1Database {
return {
prepare(sql: string) {
const stmt = {
bind() {
return stmt;
},
async first<T>() {
return (sql.includes('authority_totals') ? head : null) as T;
},
async all<T>() {
return { results: rows as T[] };
},
};
return stmt;
},
} as unknown as D1Database;
}

function call(url: string, eik: string, db: D1Database) {
return loader({
request: new Request(url),
params: { eik },
context: { cloudflare: { env: { DB: db } } },
} as unknown as Parameters<typeof loader>[0]);
}

describe('authority.rss loader', () => {
it('serves an RSS feed with self/site links and bidder-side items', async () => {
const res = await call(
'https://sigma.midt.bg/authorities/123456789.rss',
'123456789',
fakeDb({ name: 'Община Пример' }, [contractRow]),
);
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/rss+xml');
expect(res.headers.get('X-Robots-Tag')).toBe('noindex');

const body = await res.text();
expect(body).toContain('<title>Община Пример - нови договори - СИГМА</title>');
expect(body).toContain('<link>https://sigma.midt.bg/authorities/123456789</link>');
expect(body).toContain(
'<atom:link href="https://sigma.midt.bg/authorities/123456789.rss" rel="self" type="application/rss+xml"/>',
);
// An authority feed lists the WINNER (counterparty = 'bidder').
expect(body).toContain('Изпълнител: Пътстрой ЕООД');
// contractSlug strips the leading 'c:' from the id.
expect(body).toContain('<link>https://sigma.midt.bg/contracts/e:UNP-9:1:eik:222222222</link>');
});

it('404s for an authority absent from the rollup', async () => {
const res = await call('https://sigma.midt.bg/authorities/999.rss', '999', fakeDb(null));
expect(res.status).toBe(404);
});

it('404s for an empty eik', async () => {
const res = await call('https://sigma.midt.bg/authorities/.rss', '.rss', fakeDb({ name: 'x' }));
expect(res.status).toBe(404);
});

it('strips a .rss suffix left in the param so the links are not doubled', async () => {
const res = await call(
'https://sigma.midt.bg/authorities/123456789.rss',
'123456789.rss',
fakeDb({ name: 'Община Пример' }),
);
const body = await res.text();
expect(body).toContain('<link>https://sigma.midt.bg/authorities/123456789</link>');
expect(body).not.toContain('123456789.rss.rss');
});
});
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',
},
}),
);
});
}
Loading