Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
157 changes: 157 additions & 0 deletions apps/web/app/lib/feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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('Община „Пример“ - договори');
});
it('drops XML-1.0-invalid control characters but keeps tab / newline / CR', () => {
const c = (n: number) => String.fromCharCode(n);
// U+0000, U+0008, U+001F are illegal in XML 1.0 → stripped; the surrounding text survives.
expect(xmlEscape(`a${c(0)}b${c(8)}c${c(0x1f)}d`)).toBe('abcd');
// TAB (U+0009), LF (U+000A), CR (U+000D) are legal → preserved verbatim.
expect(xmlEscape(`x${c(9)}y${c(10)}z${c(13)}w`)).toBe(`x${c(9)}y${c(10)}z${c(13)}w`);
});
});

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>');
});

it('channel pubDate is the MAX item date, not the first — even if items are mis-ordered', () => {
// rssFeed cannot enforce the caller's newest-first order, so it computes the max defensively.
const older = contractRssItem(item({ signedAt: '2026-01-10' }), 'bidder', 'https://x.bg');
const newer = contractRssItem(item({ signedAt: '2026-08-20' }), 'bidder', 'https://x.bg');
// Newest is SECOND in the array → a `find`-first would wrongly pick the older one. Anchor on the
// channel's <language> line (only the channel pubDate follows it) to target the channel, not items.
const xml = rssFeed({ ...opts, items: [older, newer] });
expect(xml).toContain(
'<language>bg</language>\n <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>',
);
});
});
121 changes: 121 additions & 0 deletions apps/web/app/lib/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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;',
};

// Control characters that are NOT legal in XML 1.0 even when entity-escaped: everything below U+0020
// except TAB (U+0009), LF (U+000A) and CR (U+000D). A stray one in a source subject/name would make
// the whole feed invalid XML and strict readers reject it, so drop them before escaping (review
// ydimitrof). There is no meaningful replacement — they carry no display value.
const XML_INVALID_CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
Comment thread
B353N marked this conversation as resolved.
Outdated

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

/** '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). `??` (not `||`) is deliberate: it mirrors SQL COALESCE, which treats an empty string
// as PRESENT (only NULL falls through). Real data has NULL signed_at, so the two never disagree —
// and keeping the same rule here holds pubDate in sync with the row's ordering position.
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 is the newest item's date, computed as the MAX over all items rather than
// trusting input order: rssFeed is generic and cannot enforce the caller's newest-first ordering, so
// a future reordering must not silently yield a wrong channel <pubDate> (review ydimitrof). Still a
// pure function of the data — no "now" timestamp anywhere (RFC-822 dates compare via Date.parse).
const newest = opts.items.reduce<string | undefined>((max, item) => {
if (!item.pubDate) return max;
return max === undefined || Date.parse(item.pubDate) > Date.parse(max) ? item.pubDate : max;
}, undefined);
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
Loading