diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b4521..3ae4c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Award announcements** (Tango API 5.14.0). `listAwardAnnouncements(options)` and `getAwardAnnouncement(uuid, options)` over `/api/news/award_announcements/`, plus `AwardAnnouncement` and `AwardAnnouncementAwardee` model types, a registered shape schema with the `awardees(...)` and `organization(...)` expands, and the `AWARD_ANNOUNCEMENTS_MINIMAL` / `AWARD_ANNOUNCEMENTS_COMPREHENSIVE` defaults. Available at every tier. Every row names its `source`: `official_release` is the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. All twenty of the API's filters are typed options on `ListAwardAnnouncementsOptions`, including `source`, `agency`, `piid`, `awardee`, `uei` and `include_withdrawn`, and `ordering` takes `published_date`, `amount` or `rank`. `text` can pass 16,000 characters, so the list default leaves it out and the detail default includes it. A list hides announcements DoD later withdrew unless `include_withdrawn` is `true`; `getAwardAnnouncement()` still returns them. The `alerts.award_announcement.match` event works through `createWebhookAlert({ query_type: "award_announcement", ... })`, which needed no SDK change. + ## [1.5.0] - 2026-10-29 ### Added diff --git a/README.md b/README.md index 266625c..70c96f8 100644 --- a/README.md +++ b/README.md @@ -203,13 +203,14 @@ The Node.js client mirrors the Python SDK's high-level API. Selected highlights: - `getForecast(id, options)` / `getOpportunity(opportunityId, options)` / `getNotice(noticeId, options)` / `getGrant(grantId, options)` - `searchOpportunityAttachments(options)` -**GSA eLibrary / GSA eBuy / Protests / Contract Appeals / Federal Register / IT Dashboard / LCATs** +**GSA eLibrary / GSA eBuy / Protests / Contract Appeals / Federal Register / Award Announcements / IT Dashboard / LCATs** - `listGsaElibraryContracts(options)` / `getGsaElibraryContract(uuid, options)` - `listEbuyRequests(options)` / `getEbuyRequest(rfqId, options)` / `getEbuyAttachmentUrl(rfqId, docSeqNum)` / `getEbuyAccess()` - `listProtests(options)` / `getProtest(caseId)` - `listContractAppeals(options)` / `getContractAppeal(uuid, options)` - `listFederalRegisterDocuments(options)` / `getFederalRegisterDocument(uuid, options)` +- `listAwardAnnouncements(options)` / `getAwardAnnouncement(uuid, options)` (`makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text) - `listItDashboard(options)` / `getItDashboard(uii)` - `listLcats(options)` / `listIdvLcats(key, options)` diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index 55ba630..7b231a7 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -1,9 +1,7 @@ { - "_comment": "Accepted SDK coverage gaps vs the API contract. Gaps listed here downgrade from error to warning in scripts/check-filter-shape-conformance.ts. Each entry is tracked backlog: remove it in the same PR that closes the gap in the SDK. `missing_filters` maps a resource to filter params the mapped method does not expose; `unimplemented_resources` lists contract resources with no SDK method at all. events and news are content endpoints with no list method and stay baselined permanently (tango-python does the same). The assistance/* resources (financial-assistance awards) are not yet ported.", + "_comment": "Accepted SDK coverage gaps vs the API contract. Gaps listed here downgrade from error to warning in scripts/check-filter-shape-conformance.ts. Each entry is tracked backlog: remove it in the same PR that closes the gap in the SDK. `missing_filters` maps a resource to filter params the mapped method does not expose; `unimplemented_resources` lists contract resources with no SDK method at all. The assistance/* resources (financial-assistance awards) are not yet ported.", "missing_filters": {}, "unimplemented_resources": [ - "events", - "news", "assistance/direct_payments", "assistance/grants", "assistance/loans", diff --git a/contracts/filter_shape_contract.json b/contracts/filter_shape_contract.json index 1217b54..6c5cb49 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "5.13.0", + "api_version": "5.14.0", "description": "Canonical API filter/shape contract. Downstream consumers (SDK, MCP) should validate their conformance against this manifest.", "schema_version": 2 }, @@ -9039,64 +9039,6 @@ }, "swagger_params": [] }, - "events": { - "basename": "g2x-events", - "prefix": "events", - "resource_key": "events", - "runtime": { - "filter_params": [ - "is_free", - "is_virtual", - "organizer", - "search", - "source", - "start_date_after", - "start_date_before", - "state" - ], - "filter_params_detail": { - "is_free": { - "type": "boolean" - }, - "is_virtual": { - "type": "boolean" - }, - "organizer": { - "lookup": "icontains", - "type": "string" - }, - "search": { - "type": "string" - }, - "source": { - "type": "string" - }, - "start_date_after": { - "lookup": "gte", - "type": "date" - }, - "start_date_before": { - "lookup": "lte", - "type": "date" - }, - "state": { - "lookup": "iexact", - "type": "string" - } - }, - "ordering_aliases": [], - "ordering_fields": [], - "pagination": { - "max_page_size": 100 - }, - "shape": null, - "shape_flat_paths": [], - "shape_gated_by_level": {}, - "shape_supported": false, - "shape_tier_required": null - }, - "swagger_params": [] - }, "exclusions": { "basename": "exclusion", "prefix": "exclusions", @@ -12717,51 +12659,209 @@ "show_shapes" ] }, - "news": { - "basename": "g2x-news", - "prefix": "news", - "resource_key": "news", + "news/award_announcements": { + "basename": "awardannouncement", + "prefix": "news/award_announcements", + "resource_key": "news/award_announcements", "runtime": { "filter_params": [ - "published_after", - "published_before", + "action_type", + "agency", + "amount_max", + "amount_min", + "announcement_type", + "award_date_after", + "award_date_before", + "awardee", + "civilian", + "component", + "include_withdrawn", + "is_modification", + "is_multiple_award", + "piid", + "published_date_after", + "published_date_before", "search", + "small_business", "source", - "tag", - "vertical" + "uei" ], "filter_params_detail": { - "published_after": { + "action_type": { + "type": "string" + }, + "agency": { + "type": "string" + }, + "amount_max": { + "lookup": "lte", + "type": "number" + }, + "amount_min": { "lookup": "gte", - "type": "datetime" + "type": "number" + }, + "announcement_type": { + "type": "string" + }, + "award_date_after": { + "lookup": "gte", + "type": "date" }, - "published_before": { + "award_date_before": { "lookup": "lte", - "type": "datetime" + "type": "date" }, - "search": { + "awardee": { "type": "string" }, - "source": { + "civilian": { + "type": "boolean" + }, + "component": { "type": "string" }, - "tag": { + "include_withdrawn": { + "type": "boolean" + }, + "is_modification": { + "type": "boolean" + }, + "is_multiple_award": { + "type": "boolean" + }, + "piid": { "type": "string" }, - "vertical": { - "lookup": "iexact", + "published_date_after": { + "lookup": "gte", + "type": "date" + }, + "published_date_before": { + "lookup": "lte", + "type": "date" + }, + "search": { + "type": "string" + }, + "small_business": { + "type": "boolean" + }, + "source": { + "choices": [ + "makegov_generated", + "official_release" + ], + "choices_count": 2, + "type": "choice" + }, + "uei": { "type": "string" } }, "ordering_aliases": [], - "ordering_fields": [], + "ordering_fields": [ + "amount", + "published_date", + "rank" + ], "pagination": { "max_page_size": 100 }, - "shape": null, - "shape_flat_paths": [], + "shape": { + "expands": { + "awardees": { + "expands": {}, + "fields": [ + "location", + "name", + "small_business", + "uei" + ] + }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + } + }, + "fields": [ + "action_type", + "amount", + "announcement_type", + "award_date", + "award_kind", + "award_uuids", + "awardee_count", + "civilian", + "component", + "contracting_activity", + "corrections", + "is_modification", + "is_multiple_award", + "is_withdrawn", + "obligated_at_award", + "piids", + "published_date", + "source", + "source_name", + "source_record_id", + "source_url", + "text", + "title", + "uuid", + "value_kind" + ] + }, + "shape_flat_paths": [ + "action_type", + "amount", + "announcement_type", + "award_date", + "award_kind", + "award_uuids", + "awardee_count", + "awardees", + "awardees.location", + "awardees.name", + "awardees.small_business", + "awardees.uei", + "civilian", + "component", + "contracting_activity", + "corrections", + "is_modification", + "is_multiple_award", + "is_withdrawn", + "obligated_at_award", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", + "piids", + "published_date", + "source", + "source_name", + "source_record_id", + "source_url", + "text", + "title", + "uuid", + "value_kind" + ], "shape_gated_by_level": {}, - "shape_supported": false, + "shape_supported": true, "shape_tier_required": null }, "swagger_params": [] diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 4683c3c..407e7c9 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -659,6 +659,76 @@ Takes the document's `uuid`, not its `document_number`. To look one up by number --- +## Award Announcements + +Federal award announcements from two sources, named on every row by `source`. Available at every tier. + +- `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published. +- `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV. They are written by MakeGov from FPDS data with a language model and are not government text. + +A generated announcement is dropped when an official release covers the same award. + +### `listAwardAnnouncements(options?)` + +```ts +const announcements = await client.listAwardAnnouncements({ + source: "official_release", + agency: "Navy", + amount_min: 100000000, + ordering: "-amount", + limit: 25, +}); + +for (const item of announcements.results) { + console.log(item.published_date, item.amount, item.value_kind, item.title); +} +``` + +#### Parameters (Award Announcements) + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `search` | `string` | Full-text search over the title and text; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase. | +| `source` | `string` | `official_release` or `makegov_generated`. | +| `agency` | `string` | A Tango agency name, abbreviation, code or organization key, e.g. `Navy`. OR several with `\|`. | +| `component` | `string` | The section header of the official release as printed, e.g. `NAVY`. Case-insensitive exact match; OR several with `\|`. Generated announcements have no component. | +| `published_date_after` / `_before` | `string` | Publication date range (`YYYY-MM-DD`). | +| `award_date_after` / `_before` | `string` | Award date range (`YYYY-MM-DD`). | +| `amount_min` / `amount_max` | `number \| string` | Amount range in USD. | +| `piid` | `string` | A contract number named in the announcement, e.g. `N00019-25-C-0070` or `N0001925C0070`. Dashes and case are ignored. OR several with `\|`. | +| `awardee` | `string` | Case-insensitive substring match on the awardee names, at least 3 characters. | +| `uei` | `string` | An exact UEI of an awardee. Official releases print no UEI, so this matches only where one was resolved. | +| `action_type` | `string` | Action type, e.g. `new_award`. OR several with `\|`. | +| `announcement_type` | `string` | Announcement type. OR several with `\|`. | +| `civilian` | `boolean` | Announces a civilian award. | +| `small_business` | `boolean` | `true` keeps announcements with at least one small-business awardee, `false` keeps those whose awardees are stated not to be small. Generated announcements state neither, so both exclude them. | +| `is_modification` | `boolean` | Announces a modification to an existing contract. | +| `is_multiple_award` | `boolean` | Names more than one awardee. | +| `include_withdrawn` | `boolean` | Include announcements DoD later withdrew, which a list otherwise hides. | +| `ordering` | `string` | `published_date` (default `-published_date`, or `-rank` under `search`), `amount` or `rank`. | + +`rank` ordering requires a non-empty `search`. Announcements with no stated amount sort last under `-amount`. The standard `page` / `limit` / `shape` / `flat` / `flatLists` / `joiner` options apply, and the list defaults to `ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL`. + +`amount` is not comparable across `value_kind`s: a ceiling is an upper bound, and a modification value is a change. Compare it only within one kind. + +### `getAwardAnnouncement(uuid, options?)` + +```ts +const announcement = await client.getAwardAnnouncement("00000000-0000-0000-0000-000000000001", { + shape: "uuid,source,title,text,awardees(name,uei),organization(agency_name,office_name)", +}); +``` + +Defaults to `ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE`, and a withdrawn announcement stays retrievable here. + +- `text` is in the detail default but not the list default, because a paragraph can pass 16,000 characters. Name it in `shape` to get it on a list. +- `awardees(...)` and `organization(...)` are the two expands, and only the detail default includes them. +- `published_date` and `award_date` are parsed to `Date`, and `amount` and `obligated_at_award` are normalized to strings. The `AwardAnnouncement` interface in `@makegov/tango-node/models` lists every field. + +Alerts: create one with `createWebhookAlert({ query_type: "award_announcement", ... })`, which delivers `alerts.award_announcement.match`. + +--- + ## IT Dashboard ### `listItDashboard(options?)` diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 8847a53..c14a363 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -103,6 +103,8 @@ import { TangoClient, ShapeConfig } from "@makegov/tango-node"; | `ShapeConfig.SBIR_SOLICITATIONS_MINIMAL` | `listSbirSolicitations()` | | `ShapeConfig.FEDERAL_REGISTER_MINIMAL` | `listFederalRegisterDocuments()` | | `ShapeConfig.FEDERAL_REGISTER_COMPREHENSIVE` | `getFederalRegisterDocument()` | +| `ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL` | `listAwardAnnouncements()` | +| `ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE` | `getAwardAnnouncement()` | These are plain strings — you can use them directly or as a starting point: diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 6904e41..672cc6d 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -80,7 +80,7 @@ console.log(info.event_types); ``` The list is served by the API, so the SDK hardcodes no event types and a new one needs no SDK release. -`alerts.federal_register.match` and `alerts.ebuy_request.match` are among them, so `createWebhookAlert({ query_type: "federal_register", ... })` and `query_type: "ebuy_request"` work. +`alerts.federal_register.match`, `alerts.ebuy_request.match` and `alerts.award_announcement.match` are among them, so `createWebhookAlert({ query_type: "federal_register", ... })`, `query_type: "ebuy_request"` and `query_type: "award_announcement"` work. ### 2. See what a payload looks like diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index 1049ba1..c380f99 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -70,6 +70,7 @@ export const RESOURCE_TO_METHOD: Record = { protests: "listProtests", contract_appeals: "listContractAppeals", federal_register: "listFederalRegisterDocuments", + "news/award_announcements": "listAwardAnnouncements", "ebuy/requests": "listEbuyRequests", psc: "listPsc", mas_sins: "listMasSins", @@ -100,9 +101,6 @@ export const RESOURCE_TO_METHOD: Record = { idvs_lcats: "listIdvLcats", otidvs_awards: "listOtidvAwards", vehicles_orders: "listVehicleOrders", - // Genuinely absent from the SDK: content endpoints with no shaping and no list method — permanently baselined. - events: null, - news: null, // Not yet ported: financial-assistance awards, baselined in contracts/conformance_baseline.json. "assistance/direct_payments": null, "assistance/grants": null, @@ -149,6 +147,8 @@ const SHAPE_CONFIG_ENTRIES: ShapeEntry[] = [ { shapeName: "EXCLUSIONS_MINIMAL", modelName: "Exclusion" }, { shapeName: "SBIR_TOPICS_MINIMAL", modelName: "SbirTopic" }, { shapeName: "SBIR_SOLICITATIONS_MINIMAL", modelName: "SbirSolicitation" }, + { shapeName: "AWARD_ANNOUNCEMENTS_MINIMAL", modelName: "AwardAnnouncement" }, + { shapeName: "AWARD_ANNOUNCEMENTS_COMPREHENSIVE", modelName: "AwardAnnouncement" }, ]; // --------------------------------------------------------------------------- diff --git a/scripts/check-shape-coverage.ts b/scripts/check-shape-coverage.ts index 1af7e1c..763d6df 100644 --- a/scripts/check-shape-coverage.ts +++ b/scripts/check-shape-coverage.ts @@ -68,6 +68,7 @@ export const RESOURCE_TO_MODEL: Record = { protests: "Protest", contract_appeals: "ContractAppeal", federal_register: "FederalRegisterDocument", + "news/award_announcements": "AwardAnnouncement", "ebuy/requests": "EbuyRequest", offices: "Office", assistance_listings: "AssistanceListing", @@ -83,8 +84,6 @@ export const RESOURCE_TO_MODEL: Record = { "sbir/solicitations": "SbirSolicitation", "sled/opportunities": "SledOpportunity", "sled/forecasts": "SledForecast", - events: "Event", - news: "News", }; // --------------------------------------------------------------------------- diff --git a/src/client.ts b/src/client.ts index 9cfb2c4..770125f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1075,6 +1075,59 @@ export interface ListFederalRegisterDocumentsOptions extends ListOptionsBase { [key: string]: unknown; } +/** + * Award announcement list options. + * + * Federal award announcements from two sources, named on every row: `official_release` is the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. + */ +export interface ListAwardAnnouncementsOptions extends ListOptionsBase { + /** Separator for flattened keys. Only meaningful alongside `flat`. */ + joiner?: string; + /** Full-text search over the title and text; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase. Results rank by relevance unless `ordering` is given. */ + search?: string; + /** `official_release` or `makegov_generated`. */ + source?: "official_release" | "makegov_generated"; + /** A Tango agency name, abbreviation, code or organization key, e.g. `Navy`. Multi-value OR via `|`. */ + agency?: string; + /** The section header of the official release as printed, e.g. `NAVY`. Case-insensitive exact match, multi-value OR via `|`. Generated announcements have no component. */ + component?: string; + /** Published on or after (YYYY-MM-DD). */ + published_date_after?: string; + /** Published on or before (YYYY-MM-DD). */ + published_date_before?: string; + /** Awarded on or after (YYYY-MM-DD). */ + award_date_after?: string; + /** Awarded on or before (YYYY-MM-DD). */ + award_date_before?: string; + /** Amount at least, in USD. */ + amount_min?: number | string; + /** Amount at most, in USD. */ + amount_max?: number | string; + /** A contract number named in the announcement, e.g. `N00019-25-C-0070` or `N0001925C0070`. Dashes and case are ignored. Multi-value OR via `|`. */ + piid?: string; + /** Case-insensitive substring match on the awardee names, at least 3 characters. */ + awardee?: string; + /** An exact UEI of an awardee. Official releases print no UEI, so this matches only where one was resolved. */ + uei?: string; + /** Action type, e.g. `new_award`. Multi-value OR via `|`. */ + action_type?: string; + /** Announcement type. Multi-value OR via `|`. */ + announcement_type?: string; + /** Announces a civilian award. */ + civilian?: boolean; + /** `true` keeps announcements with at least one small-business awardee, `false` keeps those whose awardees are stated not to be small. Generated announcements state neither, so both exclude them. */ + small_business?: boolean; + /** Announces a modification to an existing contract. */ + is_modification?: boolean; + /** Names more than one awardee. */ + is_multiple_award?: boolean; + /** Include announcements DoD later withdrew, which a list otherwise hides. */ + include_withdrawn?: boolean; + /** Sort field (published_date, amount, rank). Defaults to `-published_date`, or `-rank` under `search`; `rank` requires a non-empty `search`. Announcements with no stated amount sort last under `-amount`. */ + ordering?: string; + [key: string]: unknown; +} + /** * GSA eBuy request list options. * @@ -2871,6 +2924,36 @@ export class TangoClient { ); } + /** + * List award announcements (`/api/news/award_announcements/`). Available at every tier. + * + * `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published. + * `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV; they are written by MakeGov from FPDS data with a language model and are not government text. + * + * A generated announcement is dropped when an official release covers the same award. Announcements DoD later withdrew are hidden unless `include_withdrawn` is `true`. + */ + async listAwardAnnouncements(options: ListAwardAnnouncementsOptions = {}): Promise>> { + return this._shapedPaginatedList("/api/news/award_announcements/", "AwardAnnouncement", ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL, options); + } + + /** + * Get a single award announcement by uuid (`/api/news/award_announcements/{uuid}/`). + * + * A withdrawn announcement stays retrievable here. Check `source` before quoting `text`: `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text. + */ + async getAwardAnnouncement( + uuid: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!uuid) throw new TangoValidationError("Award announcement uuid is required"); + return this._shapedGet( + `/api/news/award_announcements/${encodeURIComponent(uuid)}/`, + "AwardAnnouncement", + ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE, + options, + ); + } + /** * List GSA eBuy requests (`/api/ebuy/requests/`). Requires the Pro tier or above. * diff --git a/src/config.ts b/src/config.ts index 028160d..9d3dea9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,21 @@ export const ShapeConfig = { // Default for listProtests() PROTESTS_MINIMAL: "case_id,case_number,title,source_system,outcome,filed_date", + // Default for listAwardAnnouncements(). Mirrors the API's own list default. + // `text` is left out on purpose: a paragraph can pass 16,000 characters. + AWARD_ANNOUNCEMENTS_MINIMAL: + "uuid,source,source_name,source_url,published_date,award_date,title,amount," + + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification," + + "is_multiple_award,is_withdrawn,awardees(*)", + + // Default for getAwardAnnouncement(). Mirrors the API's own retrieve default. + AWARD_ANNOUNCEMENTS_COMPREHENSIVE: + "uuid,source,source_name,source_url,published_date,award_date,title,amount," + + "value_kind,action_type,announcement_type,awardee_count,piids,is_modification," + + "is_multiple_award,is_withdrawn,awardees(*),source_record_id,text,obligated_at_award," + + "award_kind,award_uuids,civilian,component,contracting_activity,corrections," + + "organization(*)", + // Default for listFederalRegisterDocuments(). Mirrors the API's own list default. FEDERAL_REGISTER_MINIMAL: "uuid,document_number,publication_date,type,subtype,title,abstract,action," + diff --git a/src/index.ts b/src/index.ts index 2254635..ac2ce6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export type { ListProtestsOptions, ListContractAppealsOptions, ListFederalRegisterDocumentsOptions, + ListAwardAnnouncementsOptions, ListEbuyRequestsOptions, ListItDashboardOptions, ListMetricsOptions, diff --git a/src/models/AwardAnnouncement.ts b/src/models/AwardAnnouncement.ts new file mode 100644 index 0000000..10e5446 --- /dev/null +++ b/src/models/AwardAnnouncement.ts @@ -0,0 +1,67 @@ +import type { OrganizationOfficePayload } from "./Contract.js"; + +/** One awardee named in an announcement. */ +export interface AwardAnnouncementAwardee { + name?: string | null; + /** Official releases print no UEI, so this is set only where one was resolved. */ + uei?: string | null; + location?: string | null; + /** Whether the release states the awardee is a small business. Generated announcements state neither, so it is `null` on them. */ + small_business?: boolean | null; +} + +/** + * Award announcement (`/api/news/award_announcements/`) from one of two sources, named on every row by `source`. + * + * `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published. + * + * `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV. They are written by MakeGov from FPDS data with a language model and are not government text. + * + * The endpoint uses shape-on-demand: which fields appear depends on the `?shape=` query param, so EVERY field is optional. + * + * Date fields are typed as the API's ISO strings; a shaped response from the client parses them to `Date` and normalizes decimals to strings. + */ +export interface AwardAnnouncement { + uuid?: string; + /** `official_release` or `makegov_generated`. */ + source?: string | null; + source_name?: string | null; + /** Link to the published release. `null` on a generated announcement. */ + source_url?: string | null; + source_record_id?: string | null; + published_date?: string | null; + award_date?: string | null; + title?: string | null; + /** The announcement's text. A paragraph can pass 16,000 characters, so a list carries it only when named in `shape`. */ + text?: string | null; + /** The announced dollar figure. Read it with `value_kind`: figures of different kinds are not comparable. */ + amount?: string | null; + /** What `amount` is, e.g. `face_value`, `ceiling`, `combined_ceiling`, `estimated_value`, `modification_value`, `cumulative_face_value` or `unknown`. */ + value_kind?: string | null; + obligated_at_award?: string | null; + /** What the announcement announces, e.g. `new_award`, `modification`, `option_exercise`, `order`, `correction` or `unknown`. */ + action_type?: string | null; + announcement_type?: string | null; + award_kind?: string | null; + awardee_count?: number | null; + /** Contract numbers named in the announcement, reduced to upper-case letters and digits. */ + piids?: string[] | null; + award_uuids?: string[] | null; + /** Announces a civilian award. */ + civilian?: boolean | null; + /** The section header of the official release as printed, e.g. `NAVY`. `null` on a generated announcement. */ + component?: string | null; + contracting_activity?: string | null; + /** Announces a modification to an existing contract. */ + is_modification?: boolean | null; + /** Names more than one awardee. */ + is_multiple_award?: boolean | null; + /** DoD's published corrections to this announcement, in publish order. */ + corrections?: Array> | null; + /** DoD later withdrew the announcement. A list hides it unless `include_withdrawn` is `true`; retrieving it by `uuid` always works. */ + is_withdrawn?: boolean | null; + /** The awardees. Not in the list default; expand with `awardees(*)`. */ + awardees?: AwardAnnouncementAwardee[] | null; + /** The awarding organization. Not in the list default; expand with `organization(*)`. */ + organization?: OrganizationOfficePayload | null; +} diff --git a/src/models/index.ts b/src/models/index.ts index affb17b..24a5d79 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,4 +1,5 @@ export type { Agency } from "./Agency.js"; +export type { AwardAnnouncement, AwardAnnouncementAwardee } from "./AwardAnnouncement.js"; export type { BudgetAccount, BudgetSourceAnomaly, BudgetSourceAnomalyRow, BudgetSourceAnomalySource } from "./BudgetAccount.js"; export type { Contract, diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index bb8d22c..763694f 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -6188,6 +6188,231 @@ export const EBUY_REQUEST_SCHEMA: FieldSchemaMap = { }, }; +// One awardee named in an award announcement. +export const AWARD_ANNOUNCEMENT_AWARDEE_SCHEMA: FieldSchemaMap = { + name: { + name: "name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + uei: { + name: "uei", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + location: { + name: "location", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + small_business: { + name: "small_business", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +// Award announcement. Shape-on-demand, so every field is optional. +export const AWARD_ANNOUNCEMENT_SCHEMA: FieldSchemaMap = { + uuid: { + name: "uuid", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + source: { + name: "source", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + source_name: { + name: "source_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + source_url: { + name: "source_url", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + source_record_id: { + name: "source_record_id", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + published_date: { + name: "published_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + award_date: { + name: "award_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + title: { + name: "title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + text: { + name: "text", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + amount: { + name: "amount", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + value_kind: { + name: "value_kind", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + obligated_at_award: { + name: "obligated_at_award", + type: "Decimal", + isOptional: true, + isList: false, + nestedModel: null, + }, + action_type: { + name: "action_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + announcement_type: { + name: "announcement_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + award_kind: { + name: "award_kind", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + awardee_count: { + name: "awardee_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + piids: { + name: "piids", + type: "str", + isOptional: true, + isList: true, + nestedModel: null, + }, + award_uuids: { + name: "award_uuids", + type: "str", + isOptional: true, + isList: true, + nestedModel: null, + }, + civilian: { + name: "civilian", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + component: { + name: "component", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + contracting_activity: { + name: "contracting_activity", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + is_modification: { + name: "is_modification", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + is_multiple_award: { + name: "is_multiple_award", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + corrections: { + name: "corrections", + type: "dict", + isOptional: true, + isList: true, + nestedModel: null, + }, + is_withdrawn: { + name: "is_withdrawn", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + awardees: { + name: "awardees", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "AwardAnnouncementAwardee", + }, + organization: { + name: "organization", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "OrganizationOffice", + }, +}; + // Federal Register document. Shape-on-demand, so every field is optional. export const FEDERAL_REGISTER_DOCUMENT_SCHEMA: FieldSchemaMap = { uuid: { @@ -6531,6 +6756,8 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { ProtestDocket: PROTEST_DOCKET_SCHEMA, ContractAppeal: CONTRACT_APPEAL_SCHEMA, FederalRegisterDocument: FEDERAL_REGISTER_DOCUMENT_SCHEMA, + AwardAnnouncement: AWARD_ANNOUNCEMENT_SCHEMA, + AwardAnnouncementAwardee: AWARD_ANNOUNCEMENT_AWARDEE_SCHEMA, EbuyRequest: EBUY_REQUEST_SCHEMA, EbuyAttachment: EBUY_ATTACHMENT_SCHEMA, Agency: AGENCY_SCHEMA, diff --git a/tests/scripts/conformance.test.ts b/tests/scripts/conformance.test.ts index 7ff18ee..30b8f7f 100644 --- a/tests/scripts/conformance.test.ts +++ b/tests/scripts/conformance.test.ts @@ -136,12 +136,11 @@ describe("check-filter-shape-conformance script", () => { }); it("fails against the vendored contract when the baseline is withheld", () => { - // Proves the gate has teeth: the permanently-baselined content endpoints - // (events, news) error without their baseline entries. + // Proves the gate has teeth: the not-yet-ported assistance resources error without their baseline entries. const result = runConformance({ manifestPath: VENDORED_CONTRACT, baselinePath: null }); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some((e) => /events/.test(e))).toBe(true); - expect(result.errors.some((e) => /news/.test(e))).toBe(true); + expect(result.errors.some((e) => /assistance\/grants/.test(e))).toBe(true); + expect(result.errors.some((e) => /assistance\/loans/.test(e))).toBe(true); }); }); diff --git a/tests/scripts/shape-coverage.test.ts b/tests/scripts/shape-coverage.test.ts index 71d5893..d3c6825 100644 --- a/tests/scripts/shape-coverage.test.ts +++ b/tests/scripts/shape-coverage.test.ts @@ -40,7 +40,7 @@ describe("check-shape-coverage script", () => { expect(findings[0].name).toBe("no shape tree published"); }); - it("does not flag a resource that genuinely has no shaping (news/events)", () => { + it("does not flag a resource that genuinely has no shaping", () => { const findings = collectGaps(contractFor({ shape: null, shape_supported: false }), null); expect(findings).toEqual([]); }); diff --git a/tests/unit/client.award-announcements.test.ts b/tests/unit/client.award-announcements.test.ts new file mode 100644 index 0000000..66723eb --- /dev/null +++ b/tests/unit/client.award-announcements.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for the award announcements endpoint (`/api/news/award_announcements/`). + * + * Covers the request contract (path, filters under the API's own param names, default shapes, the uuid detail route) via the injected fetchImpl mock, plus the shape schema the SDK registers for the resource. + */ + +import { readFileSync } from "node:fs"; +import { TangoClient } from "../../src/client.js"; +import type { ListAwardAnnouncementsOptions } from "../../src/client.js"; +import { ShapeConfig } from "../../src/config.js"; +import type { AwardAnnouncement } from "../../src/models/index.js"; +import { SchemaRegistry } from "../../src/shapes/schema.js"; + +type RecordedCall = { url: string }; + +function makeClient(body: unknown = { count: 0, next: null, previous: null, results: [] }): { client: TangoClient; calls: RecordedCall[] } { + const calls: RecordedCall[] = []; + const fetchImpl = (async (url: string | URL) => { + calls.push({ url: String(url) }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(body); + }, + }; + }) as unknown as typeof fetch; + const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); + return { client, calls }; +} + +function params(calls: RecordedCall[]): URLSearchParams { + return new URL(calls[0].url).searchParams; +} + +const UUID = "7d1e3a5c-2b4f-5a6e-8c0d-9f1b3d5e7a9c"; + +const ANNOUNCEMENT: AwardAnnouncement = { + uuid: UUID, + source: "official_release", + source_name: "Department of War contracts", + source_url: "https://example.gov/contracts/2026-10-22", + published_date: "2026-10-22", + award_date: "2026-10-21", + title: "Example Systems Corp., Arlington, Virginia", + text: "Example Systems Corp., Arlington, Virginia, is awarded a $125,000,000 contract.", + amount: "125000000.00", + value_kind: "face_value", + obligated_at_award: "1500000.00", + action_type: "new_award", + awardee_count: 1, + piids: ["N0001925C0070"], + award_uuids: ["0b9d2c1e-5f4a-4c8b-9a37-2d6e8f1a4b90"], + civilian: false, + component: "NAVY", + corrections: [{ publish_date: "2026-10-23", text: "Correction.", changes: [] }], + is_withdrawn: false, + awardees: [{ name: "Example Systems Corp.", uei: null, location: "Arlington, Virginia", small_business: true }], + organization: { organization_id: "c4a8f6de-0000-4000-8000-000000000001", agency_name: "Department of the Navy" }, +}; + +describe("TangoClient: award announcements", () => { + it("listAwardAnnouncements sends every filter under the API's param name", async () => { + const filters: ListAwardAnnouncementsOptions = { + search: '"hypersonic"', + source: "makegov_generated", + agency: "Navy|Army", + component: "NAVY", + published_date_after: "2026-09-01", + published_date_before: "2026-09-30", + award_date_after: "2026-08-01", + award_date_before: "2026-08-31", + amount_min: 100000000, + amount_max: "500000000", + piid: "N00019-25-C-0070", + awardee: "lockheed", + uei: "ABC123DEF456", + action_type: "new_award|order", + announcement_type: "new_vehicle", + civilian: true, + small_business: true, + is_modification: true, + is_multiple_award: true, + include_withdrawn: true, + ordering: "-amount", + }; + const { client, calls } = makeClient(); + await client.listAwardAnnouncements(filters); + + expect(new URL(calls[0].url).pathname).toBe("/api/news/award_announcements/"); + expect(Object.fromEntries(params(calls))).toMatchObject({ + search: '"hypersonic"', + source: "makegov_generated", + agency: "Navy|Army", + component: "NAVY", + published_date_after: "2026-09-01", + published_date_before: "2026-09-30", + award_date_after: "2026-08-01", + award_date_before: "2026-08-31", + amount_min: "100000000", + amount_max: "500000000", + piid: "N00019-25-C-0070", + awardee: "lockheed", + uei: "ABC123DEF456", + action_type: "new_award|order", + announcement_type: "new_vehicle", + civilian: "true", + small_business: "true", + is_modification: "true", + is_multiple_award: "true", + include_withdrawn: "true", + ordering: "-amount", + }); + }); + + it("sends a false boolean filter rather than dropping it", async () => { + const { client, calls } = makeClient(); + await client.listAwardAnnouncements({ small_business: false, is_modification: false }); + expect(params(calls).get("small_business")).toBe("false"); + expect(params(calls).get("is_modification")).toBe("false"); + }); + + it("defaults the list to AWARD_ANNOUNCEMENTS_MINIMAL", async () => { + const { client, calls } = makeClient(); + await client.listAwardAnnouncements(); + expect(params(calls).get("shape")).toBe(ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL); + }); + + it("parses dates and normalizes the amount on a list row", async () => { + const { client } = makeClient({ count: 1, next: null, previous: null, results: [ANNOUNCEMENT] }); + const page = await client.listAwardAnnouncements(); + const row = page.results[0]; + expect(row.published_date).toBeInstanceOf(Date); + expect(row.amount).toBe("125000000.00"); + expect(row.piids).toEqual(["N0001925C0070"]); + expect(row).not.toHaveProperty("text"); + }); + + it("getAwardAnnouncement uses the uuid route and the comprehensive default", async () => { + const { client, calls } = makeClient(ANNOUNCEMENT); + const row = await client.getAwardAnnouncement(UUID); + expect(new URL(calls[0].url).pathname).toBe(`/api/news/award_announcements/${UUID}/`); + expect(params(calls).get("shape")).toBe(ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE); + expect(row.text).toBe(ANNOUNCEMENT.text); + expect(row.award_date).toBeInstanceOf(Date); + expect(row.corrections).toEqual(ANNOUNCEMENT.corrections); + expect(row.awardees).toEqual([{ name: "Example Systems Corp.", uei: null, location: "Arlington, Virginia", small_business: true }]); + expect((row.organization as { agency_name: string }).agency_name).toBe("Department of the Navy"); + }); + + it("getAwardAnnouncement honors a shape with explicit expands", async () => { + const { client, calls } = makeClient({ uuid: UUID, awardees: [{ name: "Example Systems Corp." }] }); + const row = await client.getAwardAnnouncement(UUID, { shape: "uuid,awardees(name)" }); + expect(params(calls).get("shape")).toBe("uuid,awardees(name)"); + expect(row.awardees).toEqual([{ name: "Example Systems Corp." }]); + }); + + it("getAwardAnnouncement rejects an empty uuid before issuing a request", async () => { + const { client, calls } = makeClient(); + await expect(client.getAwardAnnouncement("")).rejects.toThrow("uuid is required"); + expect(calls).toHaveLength(0); + }); +}); + +describe("AwardAnnouncement shape schema", () => { + const registry = new SchemaRegistry(); + const contract = JSON.parse(readFileSync(new URL("../../contracts/filter_shape_contract.json", import.meta.url), "utf8")); + const shape = contract.resources["news/award_announcements"].runtime.shape; + const served: string[] = [...shape.fields, ...Object.keys(shape.expands)]; + + it("the vendored contract lists the resource's shape fields and expands", () => { + expect(served.length).toBeGreaterThanOrEqual(27); + expect(served).toEqual(expect.arrayContaining(["value_kind", "awardees", "organization"])); + }); + + it("knows every field and expand the contract serves", () => { + const known = registry.getSchema("AwardAnnouncement").fields; + expect(served.filter((field) => known[field] === undefined)).toEqual([]); + }); + + it.each(["AWARD_ANNOUNCEMENTS_MINIMAL", "AWARD_ANNOUNCEMENTS_COMPREHENSIVE"] as const)("%s names only fields the contract serves", (preset) => { + const names = ShapeConfig[preset].split(",").map((field) => field.replace("(*)", "")); + expect(names.filter((field) => !served.includes(field))).toEqual([]); + }); + + it("the list default leaves out text and the detail default includes it", () => { + expect(ShapeConfig.AWARD_ANNOUNCEMENTS_MINIMAL.split(",")).not.toContain("text"); + expect(ShapeConfig.AWARD_ANNOUNCEMENTS_COMPREHENSIVE.split(",")).toContain("text"); + }); + + it.each(["piids", "award_uuids", "corrections", "awardees"])("%s is a list", (field) => { + expect(registry.getField("AwardAnnouncement", field).isList).toBe(true); + }); + + it.each(["piid", "awardee", "uei", "amount_min", "include_withdrawn"])("%s is a filter and never a response field", (field) => { + expect(registry.getSchema("AwardAnnouncement").fields[field]).toBeUndefined(); + }); +});