-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): RSS фийдове с най-новите договори на профилите (институции и компании) #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
edacf32
083f1b9
5e225c6
85f8d54
c994987
cc0b192
c6db83d
c132f8b
eab4dd7
17fcacb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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('<a & "b" & 'c'>'); | ||
| }); | ||
| 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>Община <Пример> - нови договори</title>'); | ||
| expect(xml).toContain('Най-новите договори & анекси'); | ||
| expect(xml).toContain('А/Б "проект" <спешен>'); | ||
| 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>', | ||
| ); | ||
| }); | ||
| }); |
| 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> = { | ||
| '&': '&', | ||
| '<': '<', | ||
| '>': '>', | ||
| '"': '"', | ||
| "'": ''', | ||
| }; | ||
|
|
||
| // 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 = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g; | ||
|
|
||
| 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), | ||
| }; | ||
| } | ||
|
|
||
| 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 [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| '<?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'); | ||
| } | ||
There was a problem hiding this comment.
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), докато SQLCOALESCE(signed_at, published_at)също третира '' като не-null — т.е. поведението е консистентно със заявката. При реалните данни колоната е NULL, затова на практика е безопасно; отбелязвам само защото семантичното намерение е „няма дата на подписване → ползвай публикуване", което??не покрива за празен низ.