import type { Opportunity, OpportunityRelease } from '@/types/Opportunities'; /** * The opportunities with changed merged in: each replaces the opportunity * with its UUID, in its place, or is added at the end if there's none. */ export function mergeOpportunities( opportunities: readonly Opportunity[], changed: readonly Opportunity[], ): Opportunity[] { const byUuid = new Map(opportunities.map((opportunity) => [opportunity.uuid, opportunity])); for (const opportunity of changed) byUuid.set(opportunity.uuid, opportunity); return [...byUuid.values()]; } /** The opportunity's newest release, or undefined if it has none. */ export function latestRelease(opportunity: Opportunity): OpportunityRelease | undefined { let latest: OpportunityRelease | undefined; for (const release of opportunity.releases ?? []) { if (!latest || Date.parse(release.release_date) > Date.parse(latest.release_date)) { latest = release; } } return latest; } /** The page of a Find a Tender notice; a Find a Tender release's ID is its notice's. */ export function findATenderNoticeUrl(releaseId: string): string { return `https://www.find-tender.service.gov.uk/notice/${encodeURIComponent(releaseId)}`; } // EU Supply's releases are the portal's tenders, as the backend stores them // (internal/eusupply's Tender), with the address of the tender's page. const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const isWebAddress = (address: string): boolean => { try { const { protocol } = new URL(address); return protocol === 'https:' || protocol === 'http:'; } catch { return false; } }; /** * The page of an EU Supply tender: the url in its release's data, as it is, * or undefined if there's none. Only http and https addresses are used, so * a malformed or javascript: one is never opened. */ export function euSupplyTenderUrl(data: string): string | undefined { let tender: unknown; try { tender = JSON.parse(data); } catch { return undefined; } if (!isRecord(tender) || typeof tender.url !== 'string') return undefined; return isWebAddress(tender.url) ? tender.url : undefined; } /** A site an opportunity's releases can be opened on. */ export interface NoticeSite { /** The site's name, for labels: "Open the latest notice on ". */ name: string; /** The page of release on the site, or undefined if it has none. */ urlFor: (release: OpportunityRelease) => string | undefined; } // By the source the backend gives the opportunities fetched from each site. const NOTICE_SITES: Record = { FindATender: { name: 'Find a Tender', urlFor: (release) => findATenderNoticeUrl(release.release_id), }, 'EU Supply - UK': { name: 'EU Supply', urlFor: (release) => euSupplyTenderUrl(release.data), }, }; /** The site the opportunity's releases can be opened on, or undefined for sources without one. */ export function noticeSite(opportunity: Opportunity): NoticeSite | undefined { return NOTICE_SITES[opportunity.source]; } /** * What the opportunity's UUID is called on screen: "OCID" for an OCDS * contracting process's id, and "ID" for the other kinds, such as * eusupply-uk-, which the app makes up itself. */ export function uuidLabel(opportunity: Opportunity): string { return opportunity.uuid_type === 'ocid' ? 'OCID' : 'ID'; }