/**
* Pure normalization from Grants.gov's wire shapes to our `grants` schema.
* No fetch, no I/O — everything here is a deterministic function of its
* inputs so it can be unit tested against fixture JSON without a network.
*/
import type { NewGrantInput } from '@novelpad/outreach-core/server';
import type {
GrantsGovApplicantType,
GrantsGovOpportunityDetail,
GrantsGovSearchHit,
} from '#~/sources/grants-gov/client.js';
const GRANTS_GOV_DATE_RE = /^(\d{2})\/(\d{2})\/(\d{4})$/;
/**
* Parses a Grants.gov date. The typical wire format is `MM/DD/YYYY`; a
* handful of fields have historically arrived as ISO-ish strings, so that's
* accepted as a fallback. Anything that doesn't parse to a real calendar
* date — including out-of-range components and month/day rollover (e.g.
* "02/30/2024") — returns `null` rather than a garbage `Date`.
*/
export function parseGrantsGovDate(
value: string | null | undefined,
): Date | null {
if (value == null) return null;
const trimmed = value.trim();
if (trimmed === '') return null;
const match = GRANTS_GOV_DATE_RE.exec(trimmed);
if (match != null) {
const month = Number(match[1]);
const day = Number(match[2]);
const year = Number(match[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const date = new Date(Date.UTC(year, month - 1, day));
const rolledOver =
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day;
return rolledOver ? null : date;
}
const fallback = new Date(trimmed);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
/**
* Parses an award amount that may arrive as a number or as a formatted
* string (`"$1,500,000"`, `"1500000"`). Schema stores whole dollars — award
* amounts are never sub-dollar, so the result is rounded.
*/
export function parseDollarAmount(
value: string | number | null | undefined,
): number | null {
if (value == null) return null;
if (typeof value === 'number') {
return Number.isFinite(value) ? Math.round(value) : null;
}
const cleaned = value.replace(/[$,]/g, '').trim();
if (cleaned === '') return null;
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? Math.round(parsed) : null;
}
/** Same coercion rules as `parseDollarAmount`, for plain counts (not money). */
export function parseCount(
value: string | number | null | undefined,
): number | null {
return parseDollarAmount(value);
}
/**
* Strips HTML tags from a synopsis description, converting common block
* boundaries to newlines and decoding the handful of entities Grants.gov
* actually emits. Not a general-purpose HTML sanitizer — synopses are
* federal agency copy, not untrusted user content.
*/
export function stripHtml(html: string | null | undefined): string | null {
if (html == null) return null;
const text = html
.replace(/
/gi, '\n')
.replace(/<\/(p|div|li)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /gi, ' ')
.replace(/&/gi, '&')
.replace(/</gi, '<')
.replace(/>/gi, '>')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return text === '' ? null : text;
}
/** Extracts non-empty eligibility descriptions from `synopsis.applicantTypes`. */
export function normalizeApplicantTypes(
applicantTypes: ReadonlyArray | null | undefined,
): string[] | null {
if (applicantTypes == null || applicantTypes.length === 0) return null;
const descriptions = applicantTypes
.map((type) => type.description?.trim())
.filter((description): description is string => Boolean(description));
return descriptions.length === 0 ? null : descriptions;
}
function firstNonEmpty(
...values: ReadonlyArray
): string | null {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return null;
}
/**
* Resolves the funder display name: prefer the detail response's agency
* name (most complete), fall back to the search hit's agency name, then
* either response's agency code, then a literal placeholder so we never
* write an empty `funder`.
*/
function resolveFunder(
hit: GrantsGovSearchHit,
detail: GrantsGovOpportunityDetail,
): string {
return (
firstNonEmpty(
detail.agencyDetails?.agencyName,
detail.synopsis?.agencyName,
hit.agency,
) ??
firstNonEmpty(detail.agencyDetails?.agencyCode, hit.agencyCode) ??
'Unknown federal agency'
);
}
/**
* Combines a `search2` hit with its `fetchOpportunity` detail into an
* insertable grant row. `sourceUrl` is the schema's upsert key, so it's
* derived purely from the opportunity id and stable across re-crawls.
*
* `geographicScope` is deliberately left `null`: federal opportunities are
* national by default and Grants.gov doesn't give us a reliable field to
* derive a narrower scope from — better to leave it unset than guess.
*/
export function normalizeGrantsGovOpportunity(
hit: GrantsGovSearchHit,
detail: GrantsGovOpportunityDetail,
): NewGrantInput {
const synopsis = detail.synopsis;
return {
sourceUrl: `https://www.grants.gov/search-results-detail/${hit.id}`,
source: 'grants_gov',
title: detail.opportunityTitle ?? hit.title,
funder: resolveFunder(hit, detail),
synopsis: stripHtml(synopsis?.synopsisDesc),
eligibilityEntityTypes: normalizeApplicantTypes(
synopsis?.applicantTypes,
),
geographicScope: null,
programAreas: null,
awardFloor: parseDollarAmount(synopsis?.awardFloor),
awardCeiling: parseDollarAmount(synopsis?.awardCeiling),
expectedAwardsCount: parseCount(synopsis?.expectedNumberOfAwards),
openDate: parseGrantsGovDate(hit.openDate ?? synopsis?.postingDate),
closeDate: parseGrantsGovDate(hit.closeDate ?? synopsis?.responseDate),
matchRequirement: false,
applicationEffortEstimate: 'unknown',
applicationFormSupported: false,
status: 'open',
lastVerifiedAt: new Date(),
};
}