Files
grant-outreach-engine/apps/outreach-worker
Croissant Le Doux 8d57d57557 fix(ingestion): survive first contact with real data sources
NHDOJ: parser rebuilt for the real 8-column registry layout (Reg. No. |
Charity Name | Address | City | State | Zip | Status | Report Due) with
single-letter G/X/S statuses; Reg. No. is the stable upsert key (new
orgs.registration_number column + partial unique index, enum gains
'suspended' via idempotent ADD VALUE); out-of-state registrants keep
their real state. Akamai-safe fetch headers + NHDOJ_REGISTRY_PDF_PATH
local-file override.

ProPublica: zero-hit state-scoped searches return 404, not an empty
list — map to no-candidates instead of failure (tripped the systemic-
failure breaker at 60/200 on first contact). Enrichment queue now
prioritizes NH good-standing orgs over the out-of-state tail.

PND: feed retired upstream (HTML shell on every historical path) —
documented as rework candidate, low priority.

run-once.ts: supervised one-off runner through the durable DBOS
handles (workflow modules now export run*Now accessors); drop the
double pool.end() after DBOS.shutdown().

First supervised run: 200 Grants.gov opportunities (1 auto-expired),
13,632 orgs from the 427-page registry, enrichment at failed=0 with
121/200 EIN resolution in the NH-priority batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:20:47 -04:00
..

@novelpad/outreach-worker

DBOS executor process for the HelmDocs grant-match outreach engine. Runs the scheduled workflows that keep the outreach schema's grant catalog fresh — nightly ingestion from upstream sources and an hourly expiry sweep — decoupled from any HTTP-facing app in this monorepo (mirrors the workflow-worker split in novelpad-desktop).

Boot order (load-bearing)

src/main.ts imports ./workflows/ingest-grants.js and ./workflows/expire-grants.js before calling DBOS.launch(). Each of those modules calls DBOS.registerWorkflow + DBOS.registerScheduled at module-evaluation time — DBOS only dispatches scheduled/queued jobs for functions that were registered before launch, so importing them after launch (or not at all) silently means the cron jobs never fire.

The db handle is then threaded into each workflow module via its set*Deps injector (setIngestGrantsDeps / setExpireGrantsDeps), also before launch — DBOS serializes scheduled-function arguments, so a Drizzle client can't be passed through the scheduler call itself. This is the same module-scope-registry pattern novelpad-desktop's workflow-worker uses for setStartDeps.

1. import workflow modules       → registers ingestGrants / expireGrants
2. build pg Pool + drizzle(db)
3. set*Deps({ db })              → populates each workflow's registry
4. DBOS.setConfig(...)
5. DBOS.launch()                 → scheduler starts firing

Workflows

  • ingestGrants (0 3 * * *, nightly) — fetchGrantsGov (stub; real implementation calls the Grants.gov Search2 API, POST https://api.grants.gov/v1/api/search2) → normalize → upsert via serverInsertGrants from @novelpad/outreach-core/server. NH state postings and 990-PF extracts land as additional fetch+normalize steps later.
  • expireGrants (0 * * * *, hourly) — marks grants whose close date has passed as closed via serverExpireClosedGrants, so they drop out of the active match/scoring pool.

Both are registered as a DBOS workflow and a scheduled function referencing the same function object (DBOS.registerWorkflow then DBOS.registerScheduled) — see the doc comments in src/workflows/*.ts for why the dual registration is required.

Scripts

  • yarn devnode --env-file=.env --env-file-if-exists=.env.local --import tsx/esm ./src/main.ts
  • yarn build — esbuild bundle to build/main.js (--packages=external)
  • yarn start — run the built bundle
  • yarn typechecktsc --noEmit

Environment

Copy .env.example to .env and fill in DATABASE_URL (required — main.ts throws on boot without it) and GCP_SERVICE_ACCOUNT_KEY_PATH (used by future ingestion/scoring steps that call Google-hosted APIs).