-
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 5 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,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('<a & "b" & 'c'>'); | ||
| }); | ||
| 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>Община <Пример> - нови договори</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>'); | ||
| }); | ||
| }); |
| 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> = { | ||
| '&': '&', | ||
| '<': '<', | ||
| '>': '>', | ||
| '"': '"', | ||
| "'": ''', | ||
| }; | ||
|
|
||
| export function xmlEscape(value: string): string { | ||
| return value.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). | ||
| pubDate: rssDate(item.signedAt ?? item.publishedAt), | ||
|
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. Дребно: |
||
| }; | ||
| } | ||
|
|
||
| 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; | ||
|
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.
|
||
| 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'); | ||
| } | ||
| 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'); | ||
| }); | ||
| }); |
| 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$/, ''); | ||
|
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. Ако рутът |
||
| if (!eik.trim()) return withDataSource(new Response('Not Found', { status: 404 })); | ||
| const db = context.cloudflare.env.DB; | ||
| const authorityId = authorityIdFromSlug(eik); | ||
|
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. Дребна бележка за консистентност (не блокира): |
||
| const { origin } = new URL(request.url); | ||
| return withDbRetry(async () => { | ||
|
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. Незадължителна бележка (сигурност): |
||
| 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}`, | ||
|
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.
|
||
| 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', | ||
| }, | ||
| }), | ||
| ); | ||
| }); | ||
| } | ||
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.
Незадължително (устойчивост):
xmlEscapeекранира 5-те XML entity-та, но не се справя с невалидните за XML 1.0 контролни символи (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F). Ако предмет на договор или име на субект от данните съдържа такъв символ, генерираният фийд става невалиден XML и строги RSS четци го отхвърлят. Обмислете допълнително премахване/заместване на неразрешените контролни символи преди сериализация.