@pipeworx/mcp-grantconnect-au 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +20 -0
- package/server.json +18 -0
- package/src/index.ts +1156 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pipeworx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @pipeworx/grantconnect-au
|
|
2
|
+
|
|
3
|
+
GrantConnect (Australia) MCP — Commonwealth grant opportunities and grants awarded, from grants.gov.au. Keyless (Pipeworx-hosted).
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- `au_grants_search(query, agency, category, recipient_state, selection_process, min_value, max_value, awarded_from, awarded_to, sort, order, limit, offset)` — awarded grants. Free text is a case-insensitive substring across `recipient_name`, `purpose`, `grant_program`, `grant_activity`; multiple words are AND-ed. Newest first by default, `sort: "value"` ranks by AUD.
|
|
8
|
+
- `au_grant_award(ga_id)` — one award in full, **with the linked grant opportunity inlined** when the award records a `go_id`. GrantConnect publishes awards and opportunities as separate reports; this is the join.
|
|
9
|
+
- `au_grants_by_recipient(recipient_name | abn, awarded_from, awarded_to, limit, offset)` — every award to one organisation plus the funding total: total AUD, award count, first/last award date, per-agency breakdown. ABN is accepted spaced (`48 008 389 151`) or unspaced.
|
|
10
|
+
- `au_grant_opportunities_open(query, agency, category, closing_within_days, limit, offset)` — opportunities whose `close_date` is still in the future, soonest first, with `days_until_close` computed at request time.
|
|
11
|
+
- `au_grants_top_recipients(agency, category, recipient_state, awarded_from, awarded_to, limit, scan_limit)` — recipients ranked by total AUD. Reports `rows_scanned`, `total_matching_awards` and `ranking_covers_all_matching_awards`.
|
|
12
|
+
- `grantconnect_coverage()` — which reports/date windows are actually loaded, row counts, min/max dates, and any ingest windows in `error`.
|
|
13
|
+
|
|
14
|
+
## Auth
|
|
15
|
+
|
|
16
|
+
None. Pipeworx hosts the data; the gateway injects `_supabaseUrl` / `_supabaseKey`.
|
|
17
|
+
|
|
18
|
+
## Data sources
|
|
19
|
+
|
|
20
|
+
- GrantConnect — https://www.grants.gov.au (Commonwealth Grants Rules and Guidelines reporting: grant opportunities + grants awarded)
|
|
21
|
+
- Mirrored into Pipeworx Postgres by `workers/grantconnect-ingest`; queried here through PostgREST (`au_grant_awards`, `au_grant_opportunities`, `grantconnect_ingest_state`)
|
|
22
|
+
|
|
23
|
+
## Gotchas
|
|
24
|
+
|
|
25
|
+
GrantConnect has no JSON API — it publishes dated report downloads only, which is why this pack reads a Pipeworx mirror rather than the site, and why `grantconnect_coverage` matters: while the ingest backfills, a year you ask about may simply not be loaded yet, and every tool returns `{ found: false, reason, hint }` rather than an empty-looking answer. Recipient identity is messy at source: names are the legal entity as the recipient typed it (so one organisation appears under several spellings), and `recipient_abn` is published spaced and is blank on some rows — `au_grants_by_recipient` normalises the ABN and reports `matched_recipient_names` so you can see what was actually aggregated. `is_aggregate` rows bundle many small grants into one record (`aggregate_number` says how many), so award counts are not payment counts. Because PostgREST aggregate functions are disabled on this project, `au_grants_top_recipients` ranks from a bounded scan taken largest-award-first and states `rows_scanned` versus `total_matching_awards` — treat a ranking with `ranking_covers_all_matching_awards: false` as a ranking of the big-ticket end, not the whole table. Finally, `close_date` on opportunities carries a real time of day and addenda can move it: `addenda_count > 0` means the notice was amended after publication.
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"grantconnect-au": {
|
|
35
|
+
"url": "https://gateway.pipeworx.io/grantconnect-au/mcp"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or connect to the full Pipeworx gateway for access to all 1394+ data sources:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"pipeworx": {
|
|
47
|
+
"url": "https://gateway.pipeworx.io/mcp"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Using with ask_pipeworx
|
|
54
|
+
|
|
55
|
+
Instead of calling tools directly, you can ask questions in plain English:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
ask_pipeworx({ question: "your question about GrantConnect (Australia) data" })
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The gateway picks the right tool and fills the arguments automatically.
|
|
62
|
+
|
|
63
|
+
## More
|
|
64
|
+
|
|
65
|
+
- [All tools and guides](https://github.com/pipeworx-io/examples)
|
|
66
|
+
- [pipeworx.io](https://pipeworx.io)
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pipeworx/mcp-grantconnect-au",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GrantConnect (Australia) MCP — Commonwealth grant opportunities and grants",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "grantconnect-au"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/pipeworx-io/mcp-grantconnect-au"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.7.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.pipeworx-io/grantconnect-au",
|
|
4
|
+
"title": "GrantConnect (Australia)",
|
|
5
|
+
"description": "GrantConnect (Australia) MCP — Commonwealth grant opportunities and grants",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://pipeworx.io/packs/grantconnect-au",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/pipeworx-io/mcp-grantconnect-au",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://gateway.pipeworx.io/grantconnect-au/mcp"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
interface McpToolDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
inputSchema: {
|
|
5
|
+
type: 'object';
|
|
6
|
+
properties: Record<string, unknown>;
|
|
7
|
+
required?: string[];
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface McpToolExport {
|
|
12
|
+
tools: McpToolDefinition[];
|
|
13
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
14
|
+
meter?: { credits: number };
|
|
15
|
+
cost?: Record<string, unknown>;
|
|
16
|
+
provider?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GrantConnect (Australia) MCP — Commonwealth grant opportunities and grants
|
|
21
|
+
* awarded, from grants.gov.au. GrantConnect publishes no JSON API (only dated
|
|
22
|
+
* CSV/report downloads), so Pipeworx ingests it into its own Postgres
|
|
23
|
+
* (workers/grantconnect-ingest) and this pack queries that store via PostgREST.
|
|
24
|
+
*
|
|
25
|
+
* Two tables:
|
|
26
|
+
* au_grant_awards — one row per awarded grant (PK ga_id, joins to go_id)
|
|
27
|
+
* au_grant_opportunities — one row per grant opportunity (PK go_id)
|
|
28
|
+
*
|
|
29
|
+
* The distinctive capability is the award -> opportunity join: GrantConnect
|
|
30
|
+
* publishes the two as separate reports, so "which advertised call produced this
|
|
31
|
+
* payment" normally requires stitching them together by hand.
|
|
32
|
+
*
|
|
33
|
+
* The pack is stateless (the gateway handles auth + rate limiting) and never
|
|
34
|
+
* throws for an expected-empty result — it returns { found: false, reason, hint }.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const AWARDS_TABLE = 'au_grant_awards';
|
|
39
|
+
const OPPS_TABLE = 'au_grant_opportunities';
|
|
40
|
+
const INGEST_TABLE = 'grantconnect_ingest_state';
|
|
41
|
+
|
|
42
|
+
// Free-text is matched across these award columns (all trigram-indexed except
|
|
43
|
+
// the two program columns, which are short enough to scan behind other filters).
|
|
44
|
+
const AWARD_TEXT_COLUMNS = ['recipient_name', 'purpose', 'grant_program', 'grant_activity'];
|
|
45
|
+
|
|
46
|
+
const tools: McpToolExport['tools'] = [
|
|
47
|
+
{
|
|
48
|
+
name: 'au_grants_search',
|
|
49
|
+
description:
|
|
50
|
+
'Search awarded Australian Commonwealth government grants from GrantConnect (grants.gov.au) — every grant a federal agency has paid out, with recipient organisation, funding agency, grant program and activity, value in AUD, approval and start/end dates, recipient state and suburb, selection process, and the go_id of the opportunity it was awarded under. Free-text query is matched as a case-insensitive substring across recipient_name, purpose, grant_program and grant_activity. Filter by agency (substring, e.g. "Department of Social Services"), category (e.g. "Social Inclusion"), recipient_state (ACT, NSW, VIC, QLD, SA, WA, TAS, NT), selection_process (e.g. "Open Competitive", "Demand Driven"), min_value/max_value in AUD, and an awarded_from/awarded_to date range on the publish date. Answers "who received Australian federal grant money for X, how much, and when". Sort by value or date; newest first by default.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
query: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description:
|
|
57
|
+
'Free text, matched case-insensitively as a substring against recipient_name, purpose, grant_program and grant_activity. Multiple words are AND-ed, e.g. "youth homelessness".',
|
|
58
|
+
},
|
|
59
|
+
agency: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Funding agency, substring match, e.g. "Social Services", "Department of Health".',
|
|
62
|
+
},
|
|
63
|
+
category: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Grant category, substring match, e.g. "Social Inclusion", "Academic Research", "Child Care".',
|
|
66
|
+
},
|
|
67
|
+
recipient_state: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'Australian state/territory of the recipient: ACT, NSW, VIC, QLD, SA, WA, TAS, NT.',
|
|
70
|
+
},
|
|
71
|
+
selection_process: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
description:
|
|
74
|
+
'How the grant was selected, substring match: "Open Competitive", "Demand Driven", "Targeted or Restricted Competitive", "Open Non-competitive", "Closed Non-Competitive".',
|
|
75
|
+
},
|
|
76
|
+
min_value: { type: ['number', 'string'], description: 'Only awards worth at least this many AUD.' },
|
|
77
|
+
max_value: { type: ['number', 'string'], description: 'Only awards worth at most this many AUD.' },
|
|
78
|
+
awarded_from: { type: 'string', description: 'Earliest publish date, ISO YYYY-MM-DD.' },
|
|
79
|
+
awarded_to: { type: 'string', description: 'Latest publish date, ISO YYYY-MM-DD.' },
|
|
80
|
+
sort: { type: 'string', description: 'Sort key: "date" (default, publish date) or "value" (AUD).' },
|
|
81
|
+
order: { type: 'string', description: 'Sort direction: "desc" (default) or "asc".' },
|
|
82
|
+
limit: { type: ['number', 'string'], description: 'Max awards to return (1-100, default 25).' },
|
|
83
|
+
offset: { type: ['number', 'string'], description: 'Rows to skip, for paging (default 0).' },
|
|
84
|
+
},
|
|
85
|
+
required: [],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'au_grant_award',
|
|
90
|
+
description:
|
|
91
|
+
'Full record for one awarded Australian Commonwealth grant, looked up by its GrantConnect award id (ga_id, e.g. "GA572409"). Returns recipient organisation with ABN and address, funding agency, PBS program, grant program and activity, the stated purpose, value in AUD, approval/start/end dates, selection process, delivery location, aggregate-grant flags and confidentiality flags — and, when the award records a go_id, the linked grant opportunity is fetched and inlined (title, close date, selection process, category, agency) so the advertised call that produced the payment is visible in the same answer.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
ga_id: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'GrantConnect award id, e.g. "GA572409". A bare number is accepted and prefixed with GA.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ['ga_id'],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'au_grants_by_recipient',
|
|
105
|
+
description:
|
|
106
|
+
'Every Australian Commonwealth grant awarded to one organisation, matched by recipient name substring or by exact Australian Business Number (ABN, accepted spaced as "48 008 389 151" or unspaced as "48008389151"). Returns the individual awards plus a funding total: total AUD received, number of awards, first and last award dates, and a per-agency breakdown of which departments funded them. Answers "how much Commonwealth grant money has this charity, university, council or company received, and from whom".',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
recipient_name: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'Organisation name, case-insensitive substring, e.g. "YWCA Canberra", "Monash University".',
|
|
113
|
+
},
|
|
114
|
+
abn: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: 'Australian Business Number, spaced or unspaced. Matched exactly and preferred over recipient_name.',
|
|
117
|
+
},
|
|
118
|
+
awarded_from: { type: 'string', description: 'Earliest publish date, ISO YYYY-MM-DD.' },
|
|
119
|
+
awarded_to: { type: 'string', description: 'Latest publish date, ISO YYYY-MM-DD.' },
|
|
120
|
+
limit: { type: ['number', 'string'], description: 'Max individual awards listed (1-100, default 25). The totals cover more rows than are listed.' },
|
|
121
|
+
offset: { type: ['number', 'string'], description: 'Rows to skip in the listed awards (default 0).' },
|
|
122
|
+
},
|
|
123
|
+
required: [],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'au_grant_opportunities_open',
|
|
128
|
+
description:
|
|
129
|
+
'Australian Commonwealth grant opportunities that are still open for application — GrantConnect opportunities whose close date is in the future, soonest-closing first, with days remaining computed at request time. Each row carries go_id, title, funding agency, selection process, publish and close dates, primary and secondary category, contact email, addenda count and co-sponsor. Filter by category (e.g. "Regional Development"), agency substring, or free-text title match. Answers "what Australian federal grants can I still apply for, and when do they close".',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
query: { type: 'string', description: 'Free text matched case-insensitively against the opportunity title.' },
|
|
134
|
+
agency: { type: 'string', description: 'Funding agency, substring match, e.g. "Infrastructure", "Department of Health".' },
|
|
135
|
+
category: {
|
|
136
|
+
type: 'string',
|
|
137
|
+
description: 'Category title, substring match against the primary or secondary category, e.g. "Regional Development".',
|
|
138
|
+
},
|
|
139
|
+
closing_within_days: {
|
|
140
|
+
type: ['number', 'string'],
|
|
141
|
+
description: 'Only opportunities closing within this many days from now.',
|
|
142
|
+
},
|
|
143
|
+
limit: { type: ['number', 'string'], description: 'Max opportunities (1-100, default 25).' },
|
|
144
|
+
offset: { type: ['number', 'string'], description: 'Rows to skip, for paging (default 0).' },
|
|
145
|
+
},
|
|
146
|
+
required: [],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'au_grants_top_recipients',
|
|
151
|
+
description:
|
|
152
|
+
'Rank the organisations that received the most Australian Commonwealth grant money, by total AUD awarded, with award counts and the agencies that funded them. Filter by agency, category, recipient_state, and an awarded_from/awarded_to date range. The response states how many award rows were scanned and whether that covered every matching row, so a ranking drawn from the largest awards can be told apart from a ranking over the complete set. Answers "who are the biggest recipients of federal grant funding in this portfolio, state or year".',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
agency: { type: 'string', description: 'Funding agency, substring match, e.g. "Department of Social Services".' },
|
|
157
|
+
category: { type: 'string', description: 'Grant category, substring match, e.g. "Social Inclusion".' },
|
|
158
|
+
recipient_state: { type: 'string', description: 'Recipient state/territory: ACT, NSW, VIC, QLD, SA, WA, TAS, NT.' },
|
|
159
|
+
awarded_from: { type: 'string', description: 'Earliest publish date, ISO YYYY-MM-DD.' },
|
|
160
|
+
awarded_to: { type: 'string', description: 'Latest publish date, ISO YYYY-MM-DD.' },
|
|
161
|
+
limit: { type: ['number', 'string'], description: 'How many recipients to rank (1-100, default 20).' },
|
|
162
|
+
scan_limit: {
|
|
163
|
+
type: ['number', 'string'],
|
|
164
|
+
description:
|
|
165
|
+
'How many award rows to read before ranking, largest-value first (1000-20000, default 5000). Raise it for a ranking that covers more of the matching set.',
|
|
166
|
+
},
|
|
167
|
+
include_aggregate: {
|
|
168
|
+
type: 'boolean',
|
|
169
|
+
description:
|
|
170
|
+
'Default false. GrantConnect files bundled disclosures under the literal recipient name "Aggregate" when the individual recipients are withheld; those rows are excluded from the ranking because they are a reporting artefact rather than an organisation. Set true to count them as one pseudo-recipient.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
required: [],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'grantconnect_coverage',
|
|
178
|
+
description:
|
|
179
|
+
'What GrantConnect data Pipeworx actually holds right now: per-report ingest windows with their status and row counts, the earliest and latest dates present in the awarded-grants and grant-opportunities tables, total row counts, and any ingest windows that ended in error. Use this before trusting a date-bounded answer about Australian grants — it shows which years are loaded and which are still backfilling.',
|
|
180
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
/* ------------------------------------------------------------------ */
|
|
185
|
+
/* PostgREST plumbing */
|
|
186
|
+
/* ------------------------------------------------------------------ */
|
|
187
|
+
|
|
188
|
+
interface SupabaseConfig {
|
|
189
|
+
url: string;
|
|
190
|
+
key: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function pg<T>(cfg: SupabaseConfig, table: string, query: string): Promise<T> {
|
|
194
|
+
const res = await fetch(`${cfg.url}/rest/v1/${table}?${query}`, {
|
|
195
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}` },
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const text = await res.text();
|
|
199
|
+
throw new Error(`Supabase ${table}: ${res.status} ${text}`);
|
|
200
|
+
}
|
|
201
|
+
return res.json() as Promise<T>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Same as pg(), but also returns the exact number of rows matching the filters
|
|
205
|
+
// (ignoring limit/offset) from PostgREST's Content-Range header. Aggregate
|
|
206
|
+
// functions are disabled on the project, so this header is how the pack reports
|
|
207
|
+
// "how many rows really matched" without a count() call.
|
|
208
|
+
async function pgWithCount<T>(
|
|
209
|
+
cfg: SupabaseConfig,
|
|
210
|
+
table: string,
|
|
211
|
+
query: string,
|
|
212
|
+
): Promise<{ rows: T[]; total: number | null }> {
|
|
213
|
+
const res = await fetch(`${cfg.url}/rest/v1/${table}?${query}`, {
|
|
214
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}`, Prefer: 'count=exact' },
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
const text = await res.text();
|
|
218
|
+
throw new Error(`Supabase ${table}: ${res.status} ${text}`);
|
|
219
|
+
}
|
|
220
|
+
const rows = (await res.json()) as T[];
|
|
221
|
+
const range = res.headers.get('content-range') ?? '';
|
|
222
|
+
const n = Number(range.split('/')[1]);
|
|
223
|
+
return { rows, total: Number.isFinite(n) ? n : null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function pgCount(
|
|
227
|
+
cfg: SupabaseConfig,
|
|
228
|
+
table: string,
|
|
229
|
+
query: string,
|
|
230
|
+
selectColumn: string,
|
|
231
|
+
): Promise<number | null> {
|
|
232
|
+
const res = await fetch(`${cfg.url}/rest/v1/${table}?${query}&select=${selectColumn}&limit=1`, {
|
|
233
|
+
headers: { apikey: cfg.key, Authorization: `Bearer ${cfg.key}`, Prefer: 'count=exact' },
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok) return null;
|
|
236
|
+
const range = res.headers.get('content-range') ?? '';
|
|
237
|
+
const n = Number(range.split('/')[1]);
|
|
238
|
+
return Number.isFinite(n) ? n : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clampInt(v: unknown, lo: number, hi: number, dflt: number): number {
|
|
242
|
+
const n = Number(v);
|
|
243
|
+
if (!Number.isFinite(n)) return dflt;
|
|
244
|
+
return Math.min(Math.max(Math.trunc(n), lo), hi);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function str(v: unknown): string {
|
|
248
|
+
return typeof v === 'string' ? v.trim() : v === undefined || v === null ? '' : String(v).trim();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Strip characters that carry meaning inside a PostgREST filter expression
|
|
252
|
+
// (comma, parens, quotes) or inside LIKE (%, _) so a user phrase can never
|
|
253
|
+
// change the shape of the query it lands in.
|
|
254
|
+
function ilikeValue(raw: string): string {
|
|
255
|
+
return raw
|
|
256
|
+
.replace(/[,()"'\\%_*]/g, ' ')
|
|
257
|
+
.replace(/\s+/g, ' ')
|
|
258
|
+
.trim();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ilikeFilter(column: string, raw: string): string | null {
|
|
262
|
+
const v = ilikeValue(raw);
|
|
263
|
+
if (!v) return null;
|
|
264
|
+
return `${column}=ilike.*${encodeURIComponent(v)}*`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Free text across several columns, as PostgREST logic groups — one `or(...)`
|
|
268
|
+
// group per word, so every word must appear somewhere in any order.
|
|
269
|
+
function freeTextGroups(raw: string, columns: string[]): string[] {
|
|
270
|
+
const cleaned = ilikeValue(raw);
|
|
271
|
+
if (!cleaned) return [];
|
|
272
|
+
const tokens = cleaned
|
|
273
|
+
.split(' ')
|
|
274
|
+
.filter((t) => t.length >= 2)
|
|
275
|
+
.slice(0, 6);
|
|
276
|
+
return tokens.map(
|
|
277
|
+
(tok) => `or(${columns.map((c) => `${c}.ilike.*${encodeURIComponent(tok)}*`).join(',')})`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Fold every logic group into ONE query parameter. PostgREST's handling of a
|
|
282
|
+
// repeated top-level `or=` differs between versions, so anything beyond a single
|
|
283
|
+
// group is emitted as one `and=(...)` rather than two `or=` params.
|
|
284
|
+
function logicParam(groups: string[]): string | null {
|
|
285
|
+
if (groups.length === 0) return null;
|
|
286
|
+
if (groups.length === 1) {
|
|
287
|
+
const g = groups[0];
|
|
288
|
+
const open = g.indexOf('(');
|
|
289
|
+
return `${g.slice(0, open)}=(${g.slice(open + 1, -1)})`;
|
|
290
|
+
}
|
|
291
|
+
return `and=(${groups.join(',')})`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isoDate(raw: string): string | null {
|
|
295
|
+
const v = raw.trim();
|
|
296
|
+
if (!v) return null;
|
|
297
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v);
|
|
298
|
+
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
299
|
+
const d = new Date(v);
|
|
300
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
301
|
+
return d.toISOString().slice(0, 10);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeId(raw: string, prefix: 'GA' | 'GO'): string {
|
|
305
|
+
const v = raw.replace(/\s+/g, '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
306
|
+
if (!v) return '';
|
|
307
|
+
if (/^\d+$/.test(v)) return `${prefix}${v}`;
|
|
308
|
+
return v;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// GrantConnect publishes ABNs spaced 2-3-3-3 ("48 008 389 151"), but callers
|
|
312
|
+
// paste them either way. Return both forms so the filter can match whichever
|
|
313
|
+
// the ingest stored.
|
|
314
|
+
function abnForms(raw: string): string[] {
|
|
315
|
+
const digits = raw.replace(/\D/g, '');
|
|
316
|
+
if (digits.length !== 11) return [];
|
|
317
|
+
const spaced = `${digits.slice(0, 2)} ${digits.slice(2, 5)} ${digits.slice(5, 8)} ${digits.slice(8, 11)}`;
|
|
318
|
+
return [spaced, digits];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const AU_STATES = ['ACT', 'NSW', 'VIC', 'QLD', 'SA', 'WA', 'TAS', 'NT'];
|
|
322
|
+
|
|
323
|
+
function normalizeState(raw: string): string {
|
|
324
|
+
const v = raw.trim().toUpperCase();
|
|
325
|
+
if (!v) return '';
|
|
326
|
+
if (AU_STATES.includes(v)) return v;
|
|
327
|
+
const long: Record<string, string> = {
|
|
328
|
+
'AUSTRALIAN CAPITAL TERRITORY': 'ACT',
|
|
329
|
+
'NEW SOUTH WALES': 'NSW',
|
|
330
|
+
VICTORIA: 'VIC',
|
|
331
|
+
QUEENSLAND: 'QLD',
|
|
332
|
+
'SOUTH AUSTRALIA': 'SA',
|
|
333
|
+
'WESTERN AUSTRALIA': 'WA',
|
|
334
|
+
TASMANIA: 'TAS',
|
|
335
|
+
'NORTHERN TERRITORY': 'NT',
|
|
336
|
+
};
|
|
337
|
+
return long[v] ?? '';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ------------------------------------------------------------------ */
|
|
341
|
+
/* Row shapes */
|
|
342
|
+
/* ------------------------------------------------------------------ */
|
|
343
|
+
|
|
344
|
+
interface AwardRow {
|
|
345
|
+
ga_id: string;
|
|
346
|
+
go_id: string | null;
|
|
347
|
+
agency: string | null;
|
|
348
|
+
internal_reference_id?: string | null;
|
|
349
|
+
recipient_name: string | null;
|
|
350
|
+
recipient_abn: string | null;
|
|
351
|
+
pbs_program_name?: string | null;
|
|
352
|
+
grant_program: string | null;
|
|
353
|
+
grant_activity: string | null;
|
|
354
|
+
purpose?: string | null;
|
|
355
|
+
one_off?: boolean | null;
|
|
356
|
+
is_aggregate?: boolean | null;
|
|
357
|
+
aggregate_reason?: string | null;
|
|
358
|
+
aggregate_number?: number | null;
|
|
359
|
+
selection_process: string | null;
|
|
360
|
+
category: string | null;
|
|
361
|
+
confidentiality_contract?: boolean | null;
|
|
362
|
+
confidentiality_outputs?: boolean | null;
|
|
363
|
+
publish_date: string | null;
|
|
364
|
+
approval_date: string | null;
|
|
365
|
+
start_date: string | null;
|
|
366
|
+
end_date: string | null;
|
|
367
|
+
value_aud: number | string | null;
|
|
368
|
+
recipient_suburb?: string | null;
|
|
369
|
+
recipient_city?: string | null;
|
|
370
|
+
recipient_postcode?: string | null;
|
|
371
|
+
recipient_state: string | null;
|
|
372
|
+
recipient_country?: string | null;
|
|
373
|
+
delivery_state?: string | null;
|
|
374
|
+
delivery_postcode?: string | null;
|
|
375
|
+
delivery_country?: string | null;
|
|
376
|
+
contact_name?: string | null;
|
|
377
|
+
first_seen_at?: string | null;
|
|
378
|
+
updated_at?: string | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
interface OpportunityRow {
|
|
382
|
+
go_id: string;
|
|
383
|
+
agency: string | null;
|
|
384
|
+
internal_reference_id?: string | null;
|
|
385
|
+
title: string | null;
|
|
386
|
+
selection_process: string | null;
|
|
387
|
+
publish_date: string | null;
|
|
388
|
+
close_date: string | null;
|
|
389
|
+
addenda_count: number | null;
|
|
390
|
+
fo_reference?: string | null;
|
|
391
|
+
primary_category_code?: string | null;
|
|
392
|
+
primary_category_title: string | null;
|
|
393
|
+
secondary_category_code?: string | null;
|
|
394
|
+
secondary_category_title?: string | null;
|
|
395
|
+
co_sponsored?: boolean | null;
|
|
396
|
+
co_sponsored_with?: string | null;
|
|
397
|
+
contact_email?: string | null;
|
|
398
|
+
first_seen_at?: string | null;
|
|
399
|
+
updated_at?: string | null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const AWARD_LIST_SELECT =
|
|
403
|
+
'select=ga_id,go_id,agency,recipient_name,recipient_abn,grant_program,grant_activity,category,selection_process,value_aud,publish_date,approval_date,start_date,end_date,recipient_city,recipient_state,delivery_state';
|
|
404
|
+
|
|
405
|
+
const OPP_LIST_SELECT =
|
|
406
|
+
'select=go_id,agency,title,selection_process,publish_date,close_date,addenda_count,primary_category_title,secondary_category_title,co_sponsored,co_sponsored_with,contact_email';
|
|
407
|
+
|
|
408
|
+
function num(v: number | string | null | undefined): number | null {
|
|
409
|
+
if (v === null || v === undefined || v === '') return null;
|
|
410
|
+
const n = Number(v);
|
|
411
|
+
return Number.isFinite(n) ? n : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function shapeAward(r: AwardRow) {
|
|
415
|
+
return {
|
|
416
|
+
ga_id: r.ga_id,
|
|
417
|
+
go_id: r.go_id,
|
|
418
|
+
recipient: r.recipient_name,
|
|
419
|
+
recipient_abn: r.recipient_abn,
|
|
420
|
+
agency: r.agency,
|
|
421
|
+
grant_program: r.grant_program,
|
|
422
|
+
grant_activity: r.grant_activity,
|
|
423
|
+
category: r.category,
|
|
424
|
+
selection_process: r.selection_process,
|
|
425
|
+
value_aud: num(r.value_aud),
|
|
426
|
+
publish_date: r.publish_date,
|
|
427
|
+
approval_date: r.approval_date,
|
|
428
|
+
start_date: r.start_date,
|
|
429
|
+
end_date: r.end_date,
|
|
430
|
+
recipient_location: [r.recipient_city, r.recipient_state].filter(Boolean).join(', ') || null,
|
|
431
|
+
recipient_state: r.recipient_state,
|
|
432
|
+
delivery_state: r.delivery_state ?? null,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function shapeOpportunity(r: OpportunityRow) {
|
|
437
|
+
return {
|
|
438
|
+
go_id: r.go_id,
|
|
439
|
+
title: r.title,
|
|
440
|
+
agency: r.agency,
|
|
441
|
+
selection_process: r.selection_process,
|
|
442
|
+
publish_date: r.publish_date,
|
|
443
|
+
close_date: r.close_date,
|
|
444
|
+
primary_category: r.primary_category_title,
|
|
445
|
+
secondary_category: r.secondary_category_title ?? null,
|
|
446
|
+
addenda_count: r.addenda_count,
|
|
447
|
+
co_sponsored: r.co_sponsored ?? null,
|
|
448
|
+
co_sponsored_with: r.co_sponsored_with ?? null,
|
|
449
|
+
contact_email: r.contact_email ?? null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* ------------------------------------------------------------------ */
|
|
454
|
+
/* Shared award filters */
|
|
455
|
+
/* ------------------------------------------------------------------ */
|
|
456
|
+
|
|
457
|
+
function awardFilters(args: Record<string, unknown>): { parts: string[]; applied: Record<string, string> } {
|
|
458
|
+
const parts: string[] = [];
|
|
459
|
+
const groups: string[] = [];
|
|
460
|
+
const applied: Record<string, string> = {};
|
|
461
|
+
|
|
462
|
+
const query = str(args.query ?? args.q ?? args.search ?? args.keyword ?? args.keywords);
|
|
463
|
+
const textGroups = query ? freeTextGroups(query, AWARD_TEXT_COLUMNS) : [];
|
|
464
|
+
if (textGroups.length > 0) {
|
|
465
|
+
groups.push(...textGroups);
|
|
466
|
+
applied.query = query;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const agency = str(args.agency ?? args.department);
|
|
470
|
+
const agencyFilter = agency ? ilikeFilter('agency', agency) : null;
|
|
471
|
+
if (agencyFilter) {
|
|
472
|
+
parts.push(agencyFilter);
|
|
473
|
+
applied.agency = agency;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const category = str(args.category);
|
|
477
|
+
const categoryFilter = category ? ilikeFilter('category', category) : null;
|
|
478
|
+
if (categoryFilter) {
|
|
479
|
+
parts.push(categoryFilter);
|
|
480
|
+
applied.category = category;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const state = normalizeState(str(args.recipient_state ?? args.state));
|
|
484
|
+
if (state) {
|
|
485
|
+
parts.push(`recipient_state=eq.${encodeURIComponent(state)}`);
|
|
486
|
+
applied.recipient_state = state;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const selection = str(args.selection_process);
|
|
490
|
+
const selectionFilter = selection ? ilikeFilter('selection_process', selection) : null;
|
|
491
|
+
if (selectionFilter) {
|
|
492
|
+
parts.push(selectionFilter);
|
|
493
|
+
applied.selection_process = selection;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const minValue = num(str(args.min_value));
|
|
497
|
+
if (minValue !== null) {
|
|
498
|
+
parts.push(`value_aud=gte.${minValue}`);
|
|
499
|
+
applied.min_value = String(minValue);
|
|
500
|
+
}
|
|
501
|
+
const maxValue = num(str(args.max_value));
|
|
502
|
+
if (maxValue !== null) {
|
|
503
|
+
parts.push(`value_aud=lte.${maxValue}`);
|
|
504
|
+
applied.max_value = String(maxValue);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const from = isoDate(str(args.awarded_from ?? args.from_date ?? args.start));
|
|
508
|
+
if (from) {
|
|
509
|
+
parts.push(`publish_date=gte.${from}`);
|
|
510
|
+
applied.awarded_from = from;
|
|
511
|
+
}
|
|
512
|
+
const to = isoDate(str(args.awarded_to ?? args.to_date ?? args.end));
|
|
513
|
+
if (to) {
|
|
514
|
+
parts.push(`publish_date=lte.${to}`);
|
|
515
|
+
applied.awarded_to = to;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const logic = logicParam(groups);
|
|
519
|
+
if (logic) parts.push(logic);
|
|
520
|
+
|
|
521
|
+
return { parts, applied };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/* ------------------------------------------------------------------ */
|
|
525
|
+
/* Tools */
|
|
526
|
+
/* ------------------------------------------------------------------ */
|
|
527
|
+
|
|
528
|
+
async function searchAwards(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
529
|
+
const { parts, applied } = awardFilters(args);
|
|
530
|
+
const limit = clampInt(args.limit, 1, 100, 25);
|
|
531
|
+
const offset = clampInt(args.offset, 0, 100_000, 0);
|
|
532
|
+
const sortKey = str(args.sort).toLowerCase() === 'value' ? 'value_aud' : 'publish_date';
|
|
533
|
+
const dir = str(args.order).toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
534
|
+
|
|
535
|
+
const query = [
|
|
536
|
+
...parts,
|
|
537
|
+
AWARD_LIST_SELECT,
|
|
538
|
+
`order=${sortKey}.${dir}.nullslast,ga_id.desc`,
|
|
539
|
+
`limit=${limit}`,
|
|
540
|
+
`offset=${offset}`,
|
|
541
|
+
].join('&');
|
|
542
|
+
|
|
543
|
+
const { rows, total } = await pgWithCount<AwardRow>(cfg, AWARDS_TABLE, query);
|
|
544
|
+
|
|
545
|
+
if (rows.length === 0) {
|
|
546
|
+
return {
|
|
547
|
+
found: false,
|
|
548
|
+
reason: 'no_matching_awards',
|
|
549
|
+
hint:
|
|
550
|
+
Object.keys(applied).length === 0
|
|
551
|
+
? 'No awarded grants are loaded for these filters. Call grantconnect_coverage to see which GrantConnect reports and date ranges Pipeworx currently holds.'
|
|
552
|
+
: `No awarded grants match ${JSON.stringify(applied)}. Try a shorter query, drop the date range, or check the date coverage with grantconnect_coverage. Agency and category are substring matches, so "Social Services" is safer than a full department title.`,
|
|
553
|
+
filters_applied: applied,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
found: true,
|
|
559
|
+
count: rows.length,
|
|
560
|
+
total_matching: total,
|
|
561
|
+
offset,
|
|
562
|
+
sorted_by: `${sortKey} ${dir}`,
|
|
563
|
+
filters_applied: applied,
|
|
564
|
+
awards: rows.map(shapeAward),
|
|
565
|
+
note: 'value_aud is the published grant value in Australian dollars. go_id links to the grant opportunity the award came from — pass it to au_grant_opportunities_open or use au_grant_award for the inlined opportunity.',
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function awardDetail(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
570
|
+
const gaId = normalizeId(str(args.ga_id ?? args.id ?? args.award_id), 'GA');
|
|
571
|
+
if (!gaId) {
|
|
572
|
+
return {
|
|
573
|
+
found: false,
|
|
574
|
+
reason: 'missing_ga_id',
|
|
575
|
+
hint: 'Provide ga_id, the GrantConnect award id, e.g. "GA572409". Find one with au_grants_search or au_grants_by_recipient.',
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const rows = await pg<AwardRow[]>(
|
|
580
|
+
cfg,
|
|
581
|
+
AWARDS_TABLE,
|
|
582
|
+
`ga_id=eq.${encodeURIComponent(gaId)}&select=*&limit=1`,
|
|
583
|
+
);
|
|
584
|
+
if (rows.length === 0) {
|
|
585
|
+
return {
|
|
586
|
+
found: false,
|
|
587
|
+
reason: 'award_not_found',
|
|
588
|
+
ga_id: gaId,
|
|
589
|
+
hint: `No award ${gaId} is loaded. Ids look like "GA572409". Check the coverage window with grantconnect_coverage, or search by recipient with au_grants_by_recipient.`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const r = rows[0];
|
|
594
|
+
let opportunity: ReturnType<typeof shapeOpportunity> | null = null;
|
|
595
|
+
let opportunityNote: string | null = null;
|
|
596
|
+
|
|
597
|
+
if (r.go_id) {
|
|
598
|
+
const goId = normalizeId(r.go_id, 'GO');
|
|
599
|
+
const opps = await pg<OpportunityRow[]>(
|
|
600
|
+
cfg,
|
|
601
|
+
OPPS_TABLE,
|
|
602
|
+
`go_id=eq.${encodeURIComponent(goId)}&${OPP_LIST_SELECT}&limit=1`,
|
|
603
|
+
);
|
|
604
|
+
if (opps.length > 0) {
|
|
605
|
+
opportunity = shapeOpportunity(opps[0]);
|
|
606
|
+
} else {
|
|
607
|
+
opportunityNote = `The award records go_id ${goId}, but that grant opportunity is outside the opportunities data currently loaded. Call grantconnect_coverage for the loaded window.`;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
opportunityNote = 'This award records no go_id — GrantConnect leaves it blank for grants not awarded under a published opportunity (for example ad hoc or ministerial grants).';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
found: true,
|
|
615
|
+
award: {
|
|
616
|
+
...shapeAward(r),
|
|
617
|
+
internal_reference_id: r.internal_reference_id ?? null,
|
|
618
|
+
pbs_program_name: r.pbs_program_name ?? null,
|
|
619
|
+
purpose: r.purpose ?? null,
|
|
620
|
+
one_off: r.one_off ?? null,
|
|
621
|
+
is_aggregate: r.is_aggregate ?? null,
|
|
622
|
+
aggregate_reason: r.aggregate_reason ?? null,
|
|
623
|
+
aggregate_number: r.aggregate_number ?? null,
|
|
624
|
+
confidentiality_contract: r.confidentiality_contract ?? null,
|
|
625
|
+
confidentiality_outputs: r.confidentiality_outputs ?? null,
|
|
626
|
+
recipient_suburb: r.recipient_suburb ?? null,
|
|
627
|
+
recipient_postcode: r.recipient_postcode ?? null,
|
|
628
|
+
recipient_country: r.recipient_country ?? null,
|
|
629
|
+
delivery_postcode: r.delivery_postcode ?? null,
|
|
630
|
+
delivery_country: r.delivery_country ?? null,
|
|
631
|
+
contact_name: r.contact_name ?? null,
|
|
632
|
+
first_seen_at: r.first_seen_at ?? null,
|
|
633
|
+
updated_at: r.updated_at ?? null,
|
|
634
|
+
},
|
|
635
|
+
opportunity,
|
|
636
|
+
opportunity_note: opportunityNote,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function byRecipient(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
641
|
+
const abnRaw = str(args.abn ?? args.recipient_abn);
|
|
642
|
+
const nameRaw = str(args.recipient_name ?? args.recipient ?? args.name ?? args.organisation ?? args.organization ?? args.query);
|
|
643
|
+
|
|
644
|
+
const parts: string[] = [];
|
|
645
|
+
const groups: string[] = [];
|
|
646
|
+
let matchedBy = '';
|
|
647
|
+
let matchedValue = '';
|
|
648
|
+
|
|
649
|
+
if (abnRaw) {
|
|
650
|
+
const forms = abnForms(abnRaw);
|
|
651
|
+
if (forms.length === 0) {
|
|
652
|
+
return {
|
|
653
|
+
found: false,
|
|
654
|
+
reason: 'invalid_abn',
|
|
655
|
+
hint: 'An ABN is 11 digits, spaced or unspaced, e.g. "48 008 389 151" or "48008389151". Or pass recipient_name instead.',
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// GrantConnect stores the spaced form; the unspaced form is matched too in
|
|
659
|
+
// case the ingest normalised it.
|
|
660
|
+
groups.push(`or(${forms.map((f) => `recipient_abn.eq.${encodeURIComponent(f)}`).join(',')})`);
|
|
661
|
+
matchedBy = 'recipient_abn';
|
|
662
|
+
matchedValue = forms[0];
|
|
663
|
+
} else if (nameRaw) {
|
|
664
|
+
const nameGroups = freeTextGroups(nameRaw, ['recipient_name']);
|
|
665
|
+
if (nameGroups.length === 0) {
|
|
666
|
+
return {
|
|
667
|
+
found: false,
|
|
668
|
+
reason: 'invalid_recipient_name',
|
|
669
|
+
hint: 'Pass a recipient_name of at least two characters, e.g. "YWCA Canberra".',
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
groups.push(...nameGroups);
|
|
673
|
+
matchedBy = 'recipient_name';
|
|
674
|
+
matchedValue = nameRaw;
|
|
675
|
+
} else {
|
|
676
|
+
return {
|
|
677
|
+
found: false,
|
|
678
|
+
reason: 'missing_recipient',
|
|
679
|
+
hint: 'Provide recipient_name (substring, e.g. "YWCA Canberra") or abn (11 digits). Use au_grants_search to find the exact spelling of an organisation name.',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const from = isoDate(str(args.awarded_from));
|
|
684
|
+
if (from) parts.push(`publish_date=gte.${from}`);
|
|
685
|
+
const to = isoDate(str(args.awarded_to));
|
|
686
|
+
if (to) parts.push(`publish_date=lte.${to}`);
|
|
687
|
+
|
|
688
|
+
const logic = logicParam(groups);
|
|
689
|
+
if (logic) parts.push(logic);
|
|
690
|
+
|
|
691
|
+
// Totals are computed over a bounded scan of the matching rows; the listed
|
|
692
|
+
// awards are a separate, paged slice of the same filter.
|
|
693
|
+
const SCAN_CAP = 5000;
|
|
694
|
+
const { rows: scanRows, total } = await pgWithCount<AwardRow>(
|
|
695
|
+
cfg,
|
|
696
|
+
AWARDS_TABLE,
|
|
697
|
+
[
|
|
698
|
+
...parts,
|
|
699
|
+
'select=ga_id,recipient_name,recipient_abn,agency,value_aud,publish_date',
|
|
700
|
+
'order=publish_date.desc.nullslast,ga_id.desc',
|
|
701
|
+
`limit=${SCAN_CAP}`,
|
|
702
|
+
].join('&'),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
if (scanRows.length === 0) {
|
|
706
|
+
return {
|
|
707
|
+
found: false,
|
|
708
|
+
reason: 'no_awards_for_recipient',
|
|
709
|
+
matched_by: matchedBy,
|
|
710
|
+
searched_for: matchedValue,
|
|
711
|
+
hint:
|
|
712
|
+
matchedBy === 'recipient_abn'
|
|
713
|
+
? 'No awards are loaded against that ABN. GrantConnect publishes ABNs spaced (e.g. "48 008 389 151") and leaves them blank on some records — try recipient_name instead, or check grantconnect_coverage.'
|
|
714
|
+
: 'No awards match that organisation name. Names are recorded as the recipient wrote them (legal entity, not trading name) — try a distinctive fragment such as "YWCA" alone, or check grantconnect_coverage for the loaded date range.',
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let totalAud = 0;
|
|
719
|
+
let valuedRows = 0;
|
|
720
|
+
let firstDate: string | null = null;
|
|
721
|
+
let lastDate: string | null = null;
|
|
722
|
+
const byAgency = new Map<string, { agency: string; awards: number; total_aud: number }>();
|
|
723
|
+
const names = new Set<string>();
|
|
724
|
+
const abns = new Set<string>();
|
|
725
|
+
|
|
726
|
+
for (const r of scanRows) {
|
|
727
|
+
const v = num(r.value_aud);
|
|
728
|
+
if (v !== null) {
|
|
729
|
+
totalAud += v;
|
|
730
|
+
valuedRows += 1;
|
|
731
|
+
}
|
|
732
|
+
if (r.publish_date) {
|
|
733
|
+
if (!firstDate || r.publish_date < firstDate) firstDate = r.publish_date;
|
|
734
|
+
if (!lastDate || r.publish_date > lastDate) lastDate = r.publish_date;
|
|
735
|
+
}
|
|
736
|
+
const agency = r.agency ?? 'Unknown';
|
|
737
|
+
const entry = byAgency.get(agency) ?? { agency, awards: 0, total_aud: 0 };
|
|
738
|
+
entry.awards += 1;
|
|
739
|
+
entry.total_aud += v ?? 0;
|
|
740
|
+
byAgency.set(agency, entry);
|
|
741
|
+
if (r.recipient_name) names.add(r.recipient_name);
|
|
742
|
+
if (r.recipient_abn) abns.add(r.recipient_abn);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const limit = clampInt(args.limit, 1, 100, 25);
|
|
746
|
+
const offset = clampInt(args.offset, 0, 100_000, 0);
|
|
747
|
+
const listed = await pg<AwardRow[]>(
|
|
748
|
+
cfg,
|
|
749
|
+
AWARDS_TABLE,
|
|
750
|
+
[
|
|
751
|
+
...parts,
|
|
752
|
+
AWARD_LIST_SELECT,
|
|
753
|
+
'order=publish_date.desc.nullslast,ga_id.desc',
|
|
754
|
+
`limit=${limit}`,
|
|
755
|
+
`offset=${offset}`,
|
|
756
|
+
].join('&'),
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const complete = total === null ? scanRows.length < SCAN_CAP : total <= scanRows.length;
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
found: true,
|
|
763
|
+
matched_by: matchedBy,
|
|
764
|
+
searched_for: matchedValue,
|
|
765
|
+
matched_recipient_names: [...names].slice(0, 20),
|
|
766
|
+
matched_abns: [...abns].slice(0, 20),
|
|
767
|
+
totals: {
|
|
768
|
+
total_aud: Math.round(totalAud * 100) / 100,
|
|
769
|
+
award_count: scanRows.length,
|
|
770
|
+
awards_with_a_published_value: valuedRows,
|
|
771
|
+
first_award_date: firstDate,
|
|
772
|
+
last_award_date: lastDate,
|
|
773
|
+
funding_agencies: [...byAgency.values()]
|
|
774
|
+
.sort((a, b) => b.total_aud - a.total_aud)
|
|
775
|
+
.map((a) => ({ ...a, total_aud: Math.round(a.total_aud * 100) / 100 })),
|
|
776
|
+
},
|
|
777
|
+
totals_cover_all_matching_awards: complete,
|
|
778
|
+
total_matching_awards: total,
|
|
779
|
+
rows_scanned_for_totals: scanRows.length,
|
|
780
|
+
awards: listed.map(shapeAward),
|
|
781
|
+
note: complete
|
|
782
|
+
? 'Totals cover every matching award currently loaded. A recipient may appear under several name spellings — matched_recipient_names shows what was aggregated.'
|
|
783
|
+
: `Totals cover the ${scanRows.length} most recent matching awards out of ${total ?? 'more'}; narrow the date range for a total over the full set.`,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function openOpportunities(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
788
|
+
// Computed per request: a module-scope Date in a Worker freezes at 1970.
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
const nowIso = new Date(now).toISOString();
|
|
791
|
+
|
|
792
|
+
const parts: string[] = [`close_date=gte.${nowIso}`];
|
|
793
|
+
const groups: string[] = [];
|
|
794
|
+
const applied: Record<string, string> = {};
|
|
795
|
+
|
|
796
|
+
const query = str(args.query ?? args.q ?? args.title ?? args.search ?? args.keyword);
|
|
797
|
+
const titleGroups = query ? freeTextGroups(query, ['title']) : [];
|
|
798
|
+
if (titleGroups.length > 0) {
|
|
799
|
+
groups.push(...titleGroups);
|
|
800
|
+
applied.query = query;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const agency = str(args.agency ?? args.department);
|
|
804
|
+
const agencyFilter = agency ? ilikeFilter('agency', agency) : null;
|
|
805
|
+
if (agencyFilter) {
|
|
806
|
+
parts.push(agencyFilter);
|
|
807
|
+
applied.agency = agency;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const category = ilikeValue(str(args.category));
|
|
811
|
+
if (category) {
|
|
812
|
+
const enc = encodeURIComponent(category);
|
|
813
|
+
groups.push(`or(primary_category_title.ilike.*${enc}*,secondary_category_title.ilike.*${enc}*)`);
|
|
814
|
+
applied.category = category;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const withinDays = num(str(args.closing_within_days));
|
|
818
|
+
if (withinDays !== null && withinDays > 0) {
|
|
819
|
+
parts.push(`close_date=lte.${new Date(now + withinDays * 86_400_000).toISOString()}`);
|
|
820
|
+
applied.closing_within_days = String(withinDays);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const logic = logicParam(groups);
|
|
824
|
+
if (logic) parts.push(logic);
|
|
825
|
+
|
|
826
|
+
const limit = clampInt(args.limit, 1, 100, 25);
|
|
827
|
+
const offset = clampInt(args.offset, 0, 100_000, 0);
|
|
828
|
+
|
|
829
|
+
const { rows, total } = await pgWithCount<OpportunityRow>(
|
|
830
|
+
cfg,
|
|
831
|
+
OPPS_TABLE,
|
|
832
|
+
[...parts, OPP_LIST_SELECT, 'order=close_date.asc.nullslast,go_id.asc', `limit=${limit}`, `offset=${offset}`].join('&'),
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
if (rows.length === 0) {
|
|
836
|
+
return {
|
|
837
|
+
found: false,
|
|
838
|
+
reason: 'no_open_opportunities',
|
|
839
|
+
as_of: nowIso,
|
|
840
|
+
filters_applied: applied,
|
|
841
|
+
hint:
|
|
842
|
+
Object.keys(applied).length === 0
|
|
843
|
+
? 'No GrantConnect opportunities with a future close date are loaded. Call grantconnect_coverage to see whether the opportunities report has been ingested and how fresh it is.'
|
|
844
|
+
: 'No open opportunities match these filters. Category is matched as a substring of the primary or secondary category title (e.g. "Regional Development"); try dropping the category or the free-text query, or widen closing_within_days.',
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
found: true,
|
|
850
|
+
as_of: nowIso,
|
|
851
|
+
count: rows.length,
|
|
852
|
+
total_open_matching: total,
|
|
853
|
+
offset,
|
|
854
|
+
filters_applied: applied,
|
|
855
|
+
opportunities: rows.map((r) => {
|
|
856
|
+
const shaped = shapeOpportunity(r);
|
|
857
|
+
const days = r.close_date
|
|
858
|
+
? Math.round(((new Date(r.close_date).getTime() - now) / 86_400_000) * 10) / 10
|
|
859
|
+
: null;
|
|
860
|
+
return { ...shaped, days_until_close: days };
|
|
861
|
+
}),
|
|
862
|
+
note: 'Ordered soonest-closing first. close_date carries a real time of day in the agency\'s published timezone as recorded by GrantConnect. Addenda can move a close date — addenda_count above zero means the notice has been amended.',
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function topRecipients(cfg: SupabaseConfig, args: Record<string, unknown>) {
|
|
867
|
+
const { parts, applied } = awardFilters(args);
|
|
868
|
+
parts.push('value_aud=not.is.null');
|
|
869
|
+
|
|
870
|
+
// GrantConnect publishes bundled disclosures under the literal recipient name
|
|
871
|
+
// "Aggregate" (is_aggregate = true) when it withholds the individual
|
|
872
|
+
// recipients — 161 such rows carry ~$853M between them. They are a reporting
|
|
873
|
+
// artefact, not an organisation, and because the ranking is money-ordered
|
|
874
|
+
// they otherwise land at rank 1 and name "Aggregate" as the country's largest
|
|
875
|
+
// grant recipient. Excluded by default; opt back in with include_aggregate.
|
|
876
|
+
const includeAggregate = args.include_aggregate === true || args.include_aggregate === 'true';
|
|
877
|
+
if (!includeAggregate) parts.push('is_aggregate=not.is.true');
|
|
878
|
+
|
|
879
|
+
const limit = clampInt(args.limit, 1, 100, 20);
|
|
880
|
+
const scanLimit = clampInt(args.scan_limit, 1000, 20_000, 5000);
|
|
881
|
+
const PAGE = 1000;
|
|
882
|
+
|
|
883
|
+
// Aggregate functions are disabled on this PostgREST project, so the ranking
|
|
884
|
+
// is built in the pack from a bounded scan taken largest-award-first. That
|
|
885
|
+
// ordering makes the sample the one that matters for a money ranking, but it
|
|
886
|
+
// is still a sample — rows_scanned / total_matching_awards say so explicitly.
|
|
887
|
+
const scanned: AwardRow[] = [];
|
|
888
|
+
let total: number | null = null;
|
|
889
|
+
for (let offset = 0; offset < scanLimit; offset += PAGE) {
|
|
890
|
+
const page = Math.min(PAGE, scanLimit - offset);
|
|
891
|
+
const res = await pgWithCount<AwardRow>(
|
|
892
|
+
cfg,
|
|
893
|
+
AWARDS_TABLE,
|
|
894
|
+
[
|
|
895
|
+
...parts,
|
|
896
|
+
'select=ga_id,recipient_name,recipient_abn,agency,value_aud,publish_date,recipient_state',
|
|
897
|
+
'order=value_aud.desc.nullslast,ga_id.desc',
|
|
898
|
+
`limit=${page}`,
|
|
899
|
+
`offset=${offset}`,
|
|
900
|
+
].join('&'),
|
|
901
|
+
);
|
|
902
|
+
if (offset === 0) total = res.total;
|
|
903
|
+
scanned.push(...res.rows);
|
|
904
|
+
if (res.rows.length < page) break;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (scanned.length === 0) {
|
|
908
|
+
return {
|
|
909
|
+
found: false,
|
|
910
|
+
reason: 'no_awards_to_rank',
|
|
911
|
+
filters_applied: applied,
|
|
912
|
+
hint: 'No awards with a published value match these filters, so there is nothing to rank. Widen the date range or drop the agency/category filter, and check grantconnect_coverage for the loaded years.',
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
interface Agg {
|
|
917
|
+
recipient: string;
|
|
918
|
+
recipient_abn: string | null;
|
|
919
|
+
total_aud: number;
|
|
920
|
+
award_count: number;
|
|
921
|
+
states: Set<string>;
|
|
922
|
+
agencies: Map<string, number>;
|
|
923
|
+
largest_award_aud: number;
|
|
924
|
+
first_date: string | null;
|
|
925
|
+
last_date: string | null;
|
|
926
|
+
}
|
|
927
|
+
const byRecipientKey = new Map<string, Agg>();
|
|
928
|
+
for (const r of scanned) {
|
|
929
|
+
const name = r.recipient_name ?? 'Unknown recipient';
|
|
930
|
+
const key = (r.recipient_abn ?? name).toString().trim().toLowerCase();
|
|
931
|
+
const v = num(r.value_aud) ?? 0;
|
|
932
|
+
const agg =
|
|
933
|
+
byRecipientKey.get(key) ??
|
|
934
|
+
({
|
|
935
|
+
recipient: name,
|
|
936
|
+
recipient_abn: r.recipient_abn ?? null,
|
|
937
|
+
total_aud: 0,
|
|
938
|
+
award_count: 0,
|
|
939
|
+
states: new Set<string>(),
|
|
940
|
+
agencies: new Map<string, number>(),
|
|
941
|
+
largest_award_aud: 0,
|
|
942
|
+
first_date: null,
|
|
943
|
+
last_date: null,
|
|
944
|
+
} as Agg);
|
|
945
|
+
agg.total_aud += v;
|
|
946
|
+
agg.award_count += 1;
|
|
947
|
+
if (v > agg.largest_award_aud) agg.largest_award_aud = v;
|
|
948
|
+
if (r.recipient_state) agg.states.add(r.recipient_state);
|
|
949
|
+
if (r.agency) agg.agencies.set(r.agency, (agg.agencies.get(r.agency) ?? 0) + v);
|
|
950
|
+
if (r.publish_date) {
|
|
951
|
+
if (!agg.first_date || r.publish_date < agg.first_date) agg.first_date = r.publish_date;
|
|
952
|
+
if (!agg.last_date || r.publish_date > agg.last_date) agg.last_date = r.publish_date;
|
|
953
|
+
}
|
|
954
|
+
byRecipientKey.set(key, agg);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const ranked = [...byRecipientKey.values()]
|
|
958
|
+
.sort((a, b) => b.total_aud - a.total_aud)
|
|
959
|
+
.slice(0, limit)
|
|
960
|
+
.map((a, i) => ({
|
|
961
|
+
rank: i + 1,
|
|
962
|
+
recipient: a.recipient,
|
|
963
|
+
recipient_abn: a.recipient_abn,
|
|
964
|
+
total_aud: Math.round(a.total_aud * 100) / 100,
|
|
965
|
+
award_count: a.award_count,
|
|
966
|
+
largest_award_aud: Math.round(a.largest_award_aud * 100) / 100,
|
|
967
|
+
states: [...a.states],
|
|
968
|
+
first_award_date: a.first_date,
|
|
969
|
+
last_award_date: a.last_date,
|
|
970
|
+
top_agencies: [...a.agencies.entries()]
|
|
971
|
+
.sort((x, y) => y[1] - x[1])
|
|
972
|
+
.slice(0, 5)
|
|
973
|
+
.map(([agency, aud]) => ({ agency, total_aud: Math.round(aud * 100) / 100 })),
|
|
974
|
+
}));
|
|
975
|
+
|
|
976
|
+
const complete = total !== null && total <= scanned.length;
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
found: true,
|
|
980
|
+
filters_applied: applied,
|
|
981
|
+
rows_scanned: scanned.length,
|
|
982
|
+
total_matching_awards: total,
|
|
983
|
+
ranking_covers_all_matching_awards: complete,
|
|
984
|
+
scan_order: 'value_aud desc — the scan takes the largest awards first',
|
|
985
|
+
aggregate_disclosures_excluded: !includeAggregate,
|
|
986
|
+
aggregate_note: includeAggregate
|
|
987
|
+
? 'Bundled "Aggregate" disclosures are INCLUDED, so a row named "Aggregate" is a group of undisclosed recipients rather than one organisation.'
|
|
988
|
+
: 'Bundled "Aggregate" disclosures are excluded — GrantConnect files those under the literal recipient name "Aggregate" when it withholds the individual recipients, so they are not an organisation. Pass include_aggregate: true to count them.',
|
|
989
|
+
distinct_recipients_in_scan: byRecipientKey.size,
|
|
990
|
+
total_aud_in_scan: Math.round(scanned.reduce((s, r) => s + (num(r.value_aud) ?? 0), 0) * 100) / 100,
|
|
991
|
+
recipients: ranked,
|
|
992
|
+
note: complete
|
|
993
|
+
? `Ranking covers all ${scanned.length} matching awards with a published value.`
|
|
994
|
+
: `Ranking is built from the ${scanned.length} largest matching awards out of ${total ?? 'an unknown number of'} total — recipients with many small grants may be under-counted. Raise scan_limit (max 20000) or narrow the filters for a complete ranking.`,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
interface IngestStateRow {
|
|
999
|
+
report?: string | null;
|
|
1000
|
+
window_start?: string | null;
|
|
1001
|
+
window_end?: string | null;
|
|
1002
|
+
status?: string | null;
|
|
1003
|
+
rows_written?: number | null;
|
|
1004
|
+
error?: string | null;
|
|
1005
|
+
message?: string | null;
|
|
1006
|
+
updated_at?: string | null;
|
|
1007
|
+
[k: string]: unknown;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function coverage(cfg: SupabaseConfig) {
|
|
1011
|
+
const [states, awardCount, oppCount] = await Promise.all([
|
|
1012
|
+
pg<IngestStateRow[]>(cfg, INGEST_TABLE, 'select=*&order=window_start.asc&limit=2000').catch(() => null),
|
|
1013
|
+
pgCount(cfg, AWARDS_TABLE, 'ga_id=not.is.null', 'ga_id'),
|
|
1014
|
+
pgCount(cfg, OPPS_TABLE, 'go_id=not.is.null', 'go_id'),
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
const dateBound = async (table: string, column: string, dir: 'asc' | 'desc') => {
|
|
1018
|
+
const rows = await pg<Array<Record<string, string | null>>>(
|
|
1019
|
+
cfg,
|
|
1020
|
+
table,
|
|
1021
|
+
`select=${column}&${column}=not.is.null&order=${column}.${dir}&limit=1`,
|
|
1022
|
+
).catch(() => [] as Array<Record<string, string | null>>);
|
|
1023
|
+
return rows.length > 0 ? rows[0][column] ?? null : null;
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const [awardMin, awardMax, oppMin, oppMax, oppCloseMax] = await Promise.all([
|
|
1027
|
+
dateBound(AWARDS_TABLE, 'publish_date', 'asc'),
|
|
1028
|
+
dateBound(AWARDS_TABLE, 'publish_date', 'desc'),
|
|
1029
|
+
dateBound(OPPS_TABLE, 'publish_date', 'asc'),
|
|
1030
|
+
dateBound(OPPS_TABLE, 'publish_date', 'desc'),
|
|
1031
|
+
dateBound(OPPS_TABLE, 'close_date', 'desc'),
|
|
1032
|
+
]);
|
|
1033
|
+
|
|
1034
|
+
const reports: Array<{
|
|
1035
|
+
report: string;
|
|
1036
|
+
windows: number;
|
|
1037
|
+
earliest_window_start: string | null;
|
|
1038
|
+
latest_window_end: string | null;
|
|
1039
|
+
rows_written: number;
|
|
1040
|
+
status_counts: Record<string, number>;
|
|
1041
|
+
error_windows: Array<{ window_start: string | null; window_end: string | null; detail: string | null }>;
|
|
1042
|
+
}> = [];
|
|
1043
|
+
|
|
1044
|
+
if (states) {
|
|
1045
|
+
const grouped = new Map<string, IngestStateRow[]>();
|
|
1046
|
+
for (const s of states) {
|
|
1047
|
+
const key = String(s.report ?? 'unknown');
|
|
1048
|
+
const list = grouped.get(key) ?? [];
|
|
1049
|
+
list.push(s);
|
|
1050
|
+
grouped.set(key, list);
|
|
1051
|
+
}
|
|
1052
|
+
for (const [report, rows] of grouped) {
|
|
1053
|
+
const statusCounts: Record<string, number> = {};
|
|
1054
|
+
let rowsWritten = 0;
|
|
1055
|
+
let earliest: string | null = null;
|
|
1056
|
+
let latest: string | null = null;
|
|
1057
|
+
const errorWindows: Array<{
|
|
1058
|
+
window_start: string | null;
|
|
1059
|
+
window_end: string | null;
|
|
1060
|
+
detail: string | null;
|
|
1061
|
+
attempts: number | null;
|
|
1062
|
+
last_run_at: string | null;
|
|
1063
|
+
}> = [];
|
|
1064
|
+
for (const r of rows) {
|
|
1065
|
+
const st = String(r.status ?? 'unknown');
|
|
1066
|
+
statusCounts[st] = (statusCounts[st] ?? 0) + 1;
|
|
1067
|
+
rowsWritten += Number(r.rows_written ?? 0) || 0;
|
|
1068
|
+
const ws = r.window_start ?? null;
|
|
1069
|
+
const we = r.window_end ?? null;
|
|
1070
|
+
if (ws && (!earliest || ws < earliest)) earliest = ws;
|
|
1071
|
+
if (we && (!latest || we > latest)) latest = we;
|
|
1072
|
+
if (st.toLowerCase() === 'error' || st.toLowerCase() === 'failed') {
|
|
1073
|
+
errorWindows.push({
|
|
1074
|
+
window_start: ws,
|
|
1075
|
+
window_end: we,
|
|
1076
|
+
// Column is `last_error` (migration 057). The older `error`/`message`
|
|
1077
|
+
// fallbacks are kept only so a schema rename can't blank this out.
|
|
1078
|
+
detail:
|
|
1079
|
+
(r.last_error as string | null) ??
|
|
1080
|
+
(r.error as string | null) ??
|
|
1081
|
+
(r.message as string | null) ??
|
|
1082
|
+
null,
|
|
1083
|
+
attempts: r.attempts != null ? Number(r.attempts) : null,
|
|
1084
|
+
last_run_at: (r.last_run_at as string | null) ?? null,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
reports.push({
|
|
1089
|
+
report,
|
|
1090
|
+
windows: rows.length,
|
|
1091
|
+
earliest_window_start: earliest,
|
|
1092
|
+
latest_window_end: latest,
|
|
1093
|
+
rows_written: rowsWritten,
|
|
1094
|
+
status_counts: statusCounts,
|
|
1095
|
+
error_windows: errorWindows.slice(0, 25),
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
reports.sort((a, b) => a.report.localeCompare(b.report));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const backfilling =
|
|
1102
|
+
(awardCount ?? 0) === 0 && (oppCount ?? 0) === 0
|
|
1103
|
+
? 'No GrantConnect rows are loaded yet — the ingest worker is still backfilling. Every query tool will return found:false until the first windows complete.'
|
|
1104
|
+
: null;
|
|
1105
|
+
|
|
1106
|
+
return {
|
|
1107
|
+
source: 'GrantConnect (grants.gov.au) — Australian Commonwealth grant opportunities and grants awarded, mirrored by Pipeworx because GrantConnect publishes no JSON API.',
|
|
1108
|
+
awarded_grants: {
|
|
1109
|
+
table: AWARDS_TABLE,
|
|
1110
|
+
row_count: awardCount,
|
|
1111
|
+
earliest_publish_date: awardMin,
|
|
1112
|
+
latest_publish_date: awardMax,
|
|
1113
|
+
},
|
|
1114
|
+
grant_opportunities: {
|
|
1115
|
+
table: OPPS_TABLE,
|
|
1116
|
+
row_count: oppCount,
|
|
1117
|
+
earliest_publish_date: oppMin,
|
|
1118
|
+
latest_publish_date: oppMax,
|
|
1119
|
+
latest_close_date: oppCloseMax,
|
|
1120
|
+
},
|
|
1121
|
+
ingest_reports: reports,
|
|
1122
|
+
ingest_state_readable: states !== null,
|
|
1123
|
+
backfill_note: backfilling,
|
|
1124
|
+
note: 'earliest/latest publish dates are the real bounds of the loaded data. A window listed under status "error" was attempted and did not land, so dates inside it are missing even though the surrounding range looks continuous.',
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/* ------------------------------------------------------------------ */
|
|
1129
|
+
|
|
1130
|
+
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1131
|
+
const supabaseUrl = (args._supabaseUrl as string | undefined)?.trim();
|
|
1132
|
+
const supabaseKey = (args._supabaseKey as string | undefined)?.trim();
|
|
1133
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
1134
|
+
throw new Error('grantconnect-au pack requires platform Supabase credentials (operator-configured).');
|
|
1135
|
+
}
|
|
1136
|
+
const cfg: SupabaseConfig = { url: supabaseUrl, key: supabaseKey };
|
|
1137
|
+
|
|
1138
|
+
switch (name) {
|
|
1139
|
+
case 'au_grants_search':
|
|
1140
|
+
return searchAwards(cfg, args);
|
|
1141
|
+
case 'au_grant_award':
|
|
1142
|
+
return awardDetail(cfg, args);
|
|
1143
|
+
case 'au_grants_by_recipient':
|
|
1144
|
+
return byRecipient(cfg, args);
|
|
1145
|
+
case 'au_grant_opportunities_open':
|
|
1146
|
+
return openOpportunities(cfg, args);
|
|
1147
|
+
case 'au_grants_top_recipients':
|
|
1148
|
+
return topRecipients(cfg, args);
|
|
1149
|
+
case 'grantconnect_coverage':
|
|
1150
|
+
return coverage(cfg);
|
|
1151
|
+
default:
|
|
1152
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|