Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ This project follows [Semantic Versioning](https://semver.org/).

### Added

- **GSA eBuy requests** (Tango API 5.1.0). Four methods: `listEbuyRequests(options)` over `/api/ebuy/requests/`, `getEbuyRequest(rfqId, options)`, `getEbuyAttachmentUrl(rfqId, docSeqNum)` and `getEbuyAccess()`. Every filter the API accepts is a typed option on `ListEbuyRequestsOptions` (`search`, `rfq_id`, `reference_number`, `request_type`, `status`, `sin`, `schedule`, `buyer_agency`, `agency`, `contract_number`, the `issue_date_*` and `close_date_*` bounds, `ordering`; `agency` needs Tango API 5.3.0), with new `EbuyRequestRecord`, `EbuyAttachmentRecord` and `EbuyAccess` types and registered `EbuyRequest` / `EbuyAttachment` shape schemas.

Results are scoped to the GSA schedule contracts linked to the caller's account, and an account with none gets an empty list rather than an error; `getEbuyAccess()` reports `enabled`, a `reason` (`tier_required` or `no_contract_grant`) and the caller's own `contracts`. `status` is frozen at the last-seen state, so `Open` means "open the last time it was seen" — `last_seen` is the staleness signal.

`getEbuyAttachmentUrl()` returns the short-lived presigned URL the download endpoint redirects to, without following it. An attachment that is an external link throws the new `TangoEbuyAttachmentLinkError`, a `TangoValidationError` whose `url` is the link target. The HTTP client gained `getRedirectLocation()` to support it.

- **Boards-of-contract-appeals decisions** (Tango API 4.26.0). `listContractAppeals(options)` and `getContractAppeal(uuid, options)` over `/api/contract_appeals/`, with every filter the API accepts declared as a typed option on `ListContractAppealsOptions` (`search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, the `decision_date_after` / `_before` pair, `listed`, `document_id`, `ordering`), the new `ContractAppealRecord` return type, and a registered `ContractAppeal` shape schema so the typed shape API resolves the resource's fields.

These are Contract Disputes Act decisions from the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests remain the separate `listProtests()` resource, and the two do not overlap.
Expand All @@ -29,7 +35,8 @@ This project follows [Semantic Versioning](https://semver.org/).
### Changed

- Re-vendored `contracts/filter_shape_contract.json` (schema_version 2, 48 resources) and regenerated `src/shapes/generatedOverlay.ts` from it — 359 fields across 25 containers, 73 nested schemas.
- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. eBuy requests are in the contract without an SDK method yet, so it is baselined as a tracked gap.
- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win.
- Wrapping eBuy maps `ebuy/requests` in the conformance gate and removes it from both coverage baselines.
- Baselined 14 reverse-shape-coverage gaps in `contracts/shape_coverage_baseline.json`, matching tango-python. All 14 are the nested sub-resource routes above, which reuse the parent resource's model rather than carrying one of their own; none is SLED, and none is a regression — they became visible only with the re-vendored contract.

### Fixed
Expand All @@ -41,6 +48,7 @@ This project follows [Semantic Versioning](https://semver.org/).

### Documentation

- New **GSA eBuy** section in `docs/API_REFERENCE.md` covering all four methods, the full filter table, and the data caveats (the frozen `status`, the empty-list-not-error scoping, the short-lived attachment URL). `README.md`'s method and error lists gained the new methods and error.
- New **Contract Appeals** section in `docs/API_REFERENCE.md` covering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-null `decision_text`). `README.md`'s method list gained both methods.
- New **State & Local (SLED) — Beta** section in `docs/API_REFERENCE.md` covering all six methods, both defaults that surprise people, and the new `ShapeConfig` constants.
- `docs/WEBHOOKS.md` troubleshooting gained the date-lapse rule and its one exception. An exclusion or a DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — but `alerts.sled_opportunity.match` **does** fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ 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 / Protests / Contract Appeals / IT Dashboard / LCATs**
**GSA eLibrary / GSA eBuy / Protests / Contract Appeals / 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)`
- `listItDashboard(options)` / `getItDashboard(uii)`
Expand Down Expand Up @@ -291,6 +292,7 @@ Errors are surfaced as typed exceptions, aligned with the Python SDK:
- `TangoAuthError` – Authentication problems (e.g., invalid API key, 401).
- `TangoNotFoundError` – Resource not found (404).
- `TangoValidationError` – Invalid request parameters (400). Exposes the API's structured 400 payload via `issues` and `availableFields` (see the [API Reference](docs/API_REFERENCE.md#error-types)).
- `TangoEbuyAttachmentLinkError` – A `TangoValidationError` subclass thrown by `getEbuyAttachmentUrl()` when the attachment is an external link; its `url` is the link target.
- `TangoRateLimitError` – Rate limit exceeded (429).
- `TangoTimeoutError` – Request exceeded the configured `timeoutMs`.

Expand Down
1 change: 0 additions & 1 deletion contracts/conformance_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"_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).",
"missing_filters": {},
"unimplemented_resources": [
"ebuy/requests",
"events",
"news"
]
Expand Down
3 changes: 1 addition & 2 deletions contracts/shape_coverage_baseline.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
{
"description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check-shape-coverage.ts fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.",
"count": 15,
"count": 14,
"known_gaps": [
"unmapped_resource|agencies_contracts_awarding|(root)|(no model mapped)",
"unmapped_resource|agencies_contracts_funding|(root)|(no model mapped)",
"unmapped_resource|contracts_subawards|(root)|(no model mapped)",
"unmapped_resource|ebuy/requests|(root)|(no model mapped)",
"unmapped_resource|entities_contracts|(root)|(no model mapped)",
"unmapped_resource|entities_idvs|(root)|(no model mapped)",
"unmapped_resource|entities_lcats|(root)|(no model mapped)",
Expand Down
78 changes: 78 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,83 @@ const contract = await client.getGsaElibraryContract("00000000-0000-0000-0000-00

---

## GSA eBuy

Requests for quotes, proposals and information (RFQs, RFPs, RFIs) posted to GSA eBuy under GSA schedule contracts. Requires the Pro tier or above; below it the request endpoints return 403.

eBuy results are scoped to the GSA schedule contracts linked to your account. A caller with no linked contract gets an **empty list, not an error** — call `getEbuyAccess()` to tell "no access" from "no matches".

### `listEbuyRequests(options?)`

```ts
const requests = await client.listEbuyRequests({
sin: "54151S",
status: "Open",
close_date_after: "2026-10-01",
limit: 25,
});
```

#### Parameters (GSA eBuy)

| Name | Type | Description |
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `search` | `string` | Full-text search over title, description, reference number, request id and attachment text. Ranks by relevance unless `ordering` is given. |
| `rfq_id` | `string` | eBuy request id, exact (e.g. `RFQ1835158`). |
| `reference_number` | `string` | The buyer's own solicitation number; dashed and undashed spellings both match. |
| `request_type` | `string` | `RFQ`, `RFP` or `RFI`. |
| `status` | `string` | `Open` or `Cancelled` — frozen at the last-seen state (see below). |
| `sin` | `string` | Special Item Number the request was posted under. |
| `schedule` | `string` | GSA schedule the request was posted under. |
| `buyer_agency` | `string` | Buying department as fed, free text. |
| `agency` | `string` | Agency name, abbreviation or code, including every sub-agency and office beneath it (Tango API 5.3.0+). |
| `contract_number` | `string` | Narrow to one of your own linked contracts. A contract you do not hold returns nothing rather than an error. |
| `issue_date_after` / `_before` | `string` | `YYYY-MM-DD`, inclusive. |
| `close_date_after` / `_before` | `string` | `YYYY-MM-DD`, inclusive. |
| `ordering` | `string` | `issue_date` (default `-issue_date`), `close_date`, `last_seen`, or `modified`; prefix `-` for descending. |

Every filter except `contract_number` and the date bounds accepts `|` for OR. The standard `page` / `limit` / `shape` / `flat` / `flatLists` / `joiner` options apply. The default list shape is `rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,buyer_agency_code,reference_number,issue_date,close_date,attachment_count,link_count,last_seen`.

Three properties of the data worth knowing:

- **`status` is frozen at the last-seen state.** eBuy only carries currently-active requests, so a request that closes stops appearing rather than getting a final row. `Open` means "open the last time it was seen" — use `last_seen` for staleness.
- **The contract number a request was posted under is never returned** in any payload.
- **`buyer_agency_code`** and several buyer and contracting-officer fields are sparse on older requests.

### `getEbuyRequest(rfqId, options?)`

```ts
const request = await client.getEbuyRequest("RFQ1835158");
for (const attachment of request.attachments ?? []) {
console.log(attachment.doc_seq_num, attachment.doc_name, attachment.is_link);
}
```

Returns an `EbuyRequestRecord`. The default shape is every field plus `organization(*)` (the buying office, in the same shape as other resources' awarding office) and `attachments(*)`. A request outside your contract scope throws `TangoNotFoundError`, the same as an id that does not exist.

### `getEbuyAttachmentUrl(rfqId, docSeqNum)`

```ts
const url = await client.getEbuyAttachmentUrl("RFQ1835158", 3852759);
const res = await fetch(url);
```

Returns a presigned download URL for one attachment without downloading it. The URL expires after about five minutes, so fetch it promptly rather than storing it.

- An attachment with `is_link: true` is an external link, not a stored document: the call throws `TangoEbuyAttachmentLinkError`, whose `url` is the link target.
- A document that has not been captured yet throws `TangoNotFoundError`.

### `getEbuyAccess()`

```ts
const access = await client.getEbuyAccess();
// { enabled: false, reason: "no_contract_grant", contracts: [] }
```

Returns `{ enabled, reason, contracts }`. `reason` is `"tier_required"` below the Pro tier, `"no_contract_grant"` when no contract is linked to your account, and `null` when `enabled` is true; `tier_required` wins when both apply. `contracts` lists your own active grants, sorted.

---

## Protests

### `listProtests(options?)`
Expand Down Expand Up @@ -1059,6 +1136,7 @@ All thrown by async methods:
- `TangoRateLimitError`
- `TangoTimeoutError`
- `TangoValidationError`
- `TangoEbuyAttachmentLinkError` (a `TangoValidationError` with a `url`)
- `ShapeError`
- `ShapeParseError`
- `ShapeValidationError`
Expand Down
3 changes: 1 addition & 2 deletions scripts/check-filter-shape-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ export const RESOURCE_TO_METHOD: Record<string, string | null> = {
offices: "listOffices",
protests: "listProtests",
contract_appeals: "listContractAppeals",
// eBuy requests are published by the API but not yet ported — baselined as a tracked gap.
"ebuy/requests": null,
"ebuy/requests": "listEbuyRequests",
psc: "listPsc",
mas_sins: "listMasSins",
departments: "listDepartments",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-shape-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const RESOURCE_TO_MODEL: Record<string, string> = {
budget_accounts: "BudgetAccount",
protests: "Protest",
contract_appeals: "ContractAppeal",
"ebuy/requests": "EbuyRequest",
offices: "Office",
assistance_listings: "AssistanceListing",
business_types: "BusinessType",
Expand Down
Loading
Loading