From b9bebd09b40d5c0af2e85680ae3b962a302cc131 Mon Sep 17 00:00:00 2001 From: Trailgenic Date: Sun, 19 Jul 2026 13:40:54 -0700 Subject: [PATCH] Rebuild permit cancellation alert worker --- .github/workflows/deploy.yml | 47 +- .gitignore | 3 + package.json | 8 +- scripts/deploy-permit-poller.mjs | 91 ++++ scripts/permit-poller-live-check.mjs | 32 ++ .../permit-poller/division-availability.json | 20 + .../permit-poller/inyo-availability.json | 20 + tests/permit-poller.test.mjs | 136 ++++++ tests/permit-poller.worker.vitest.mjs | 184 ++++++++ vitest.permit-poller.config.mjs | 13 + workers/permit-poller/README.md | 63 +++ workers/permit-poller/adapters.js | 137 ++++++ workers/permit-poller/catalog.js | 47 ++ .../permit-poller/migrations/0001_initial.sql | 82 ++++ workers/permit-poller/service.js | 438 +++++++++++++++++ workers/permit-poller/worker.js | 440 +++++++----------- workers/permit-poller/wrangler.jsonc | 52 ++- 17 files changed, 1527 insertions(+), 286 deletions(-) create mode 100644 .gitignore create mode 100644 scripts/deploy-permit-poller.mjs create mode 100644 scripts/permit-poller-live-check.mjs create mode 100644 tests/fixtures/permit-poller/division-availability.json create mode 100644 tests/fixtures/permit-poller/inyo-availability.json create mode 100644 tests/permit-poller.test.mjs create mode 100644 tests/permit-poller.worker.vitest.mjs create mode 100644 vitest.permit-poller.config.mjs create mode 100644 workers/permit-poller/README.md create mode 100644 workers/permit-poller/adapters.js create mode 100644 workers/permit-poller/catalog.js create mode 100644 workers/permit-poller/migrations/0001_initial.sql create mode 100644 workers/permit-poller/service.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 528a5fa..d8a681d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,15 @@ -name: Deploy Tool Registry +name: Verify and Deploy Workers on: push: branches: [main] + pull_request: workflow_dispatch: concurrency: - group: deploy-tool-registry-${{ github.ref }} + group: deploy-workers-${{ github.ref }} cancel-in-progress: true jobs: - deploy: - name: Verify then deploy tool-registry Worker + verify: + name: Verify workers runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -20,10 +21,25 @@ jobs: - run: npm run lint - run: npm run validate:json - run: npm test + - run: npm run test:permit-poller - run: npm run test:workers - run: npm run validate:registry - run: npm run wrangler:dry-run + - run: npm run wrangler:dry-run:permit-poller - run: git diff --check + + deploy-tool-registry: + name: Deploy tool-registry Worker + if: github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + - run: npm ci - name: Deploy tool-registry only uses: cloudflare/wrangler-action@v4 with: @@ -35,3 +51,26 @@ jobs: run: sleep 25 - name: Run live acceptance harness run: node scripts/live-acceptance.mjs + + deploy-permit-poller: + name: Provision and deploy permit poller + if: github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + TWILIO_FROM_NUMBER: ${{ secrets.TWILIO_FROM_NUMBER }} + TWILIO_VERIFY_SERVICE_SID: ${{ secrets.TWILIO_VERIFY_SERVICE_SID }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + - run: npm ci + - name: Provision resources, migrate D1, and deploy + run: npm run deploy:permit-poller diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..458ba60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +workers/permit-poller/wrangler.deploy.json diff --git a/package.json b/package.json index 1d6e016..9559533 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,16 @@ "private": true, "type": "module", "scripts": { - "lint": "node --check tool-registry/worker.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs", + "lint": "node --check tool-registry/worker.js && node --check workers/permit-poller/worker.js && node --check workers/permit-poller/catalog.js && node --check workers/permit-poller/adapters.js && node --check workers/permit-poller/service.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs && node --check scripts/permit-poller-live-check.mjs && node --check scripts/deploy-permit-poller.mjs", "test": "node --test tests/registry.test.mjs", "test:workers": "vitest run --config vitest.config.mjs", + "test:permit-poller": "node --test tests/permit-poller.test.mjs && vitest run --config vitest.permit-poller.config.mjs", + "test:permit-poller:live": "node scripts/permit-poller-live-check.mjs", + "deploy:permit-poller": "node scripts/deploy-permit-poller.mjs", "validate:json": "node scripts/validate-json.mjs", "validate:registry": "node scripts/validate-registry.mjs", - "wrangler:dry-run": "wrangler deploy --config tool-registry/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-worker-dry-run" + "wrangler:dry-run": "wrangler deploy --config tool-registry/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-worker-dry-run", + "wrangler:dry-run:permit-poller": "wrangler deploy --config workers/permit-poller/wrangler.jsonc --dry-run --outdir /tmp/trailgenic-permit-poller-dry-run" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/scripts/deploy-permit-poller.mjs b/scripts/deploy-permit-poller.mjs new file mode 100644 index 0000000..c741831 --- /dev/null +++ b/scripts/deploy-permit-poller.mjs @@ -0,0 +1,91 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceConfigPath = path.join(root, "workers/permit-poller/wrangler.jsonc"); +const deployConfigPath = path.join(root, "workers/permit-poller/wrangler.deploy.json"); +const databaseName = "trailgenic-permit-poller"; +const queueNames = [ + "trailgenic-permit-polls-dlq", + "trailgenic-permit-notifications-dlq", + "trailgenic-permit-polls", + "trailgenic-permit-notifications", +]; +const secretNames = [ + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_FROM_NUMBER", + "TWILIO_VERIFY_SERVICE_SID", + "TURNSTILE_SECRET_KEY", +]; + +const missingSecrets = secretNames.filter((name) => !process.env[name]); +if (missingSecrets.length) { + throw new Error(`Missing required deployment secrets: ${missingSecrets.join(", ")}`); +} + +const wrangler = (...args) => + execFileSync("npx", ["wrangler", ...args], { + cwd: root, + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "inherit"], + }); + +const listDatabases = () => JSON.parse(wrangler("d1", "list", "--json")); +let database = listDatabases().find((candidate) => candidate.name === databaseName); + +if (!database) { + console.log(`Creating D1 database ${databaseName}`); + wrangler("d1", "create", databaseName); + database = listDatabases().find((candidate) => candidate.name === databaseName); +} + +const databaseId = database?.uuid || database?.id; +if (!databaseId) throw new Error(`Unable to resolve D1 database ID for ${databaseName}`); + +for (const queueName of queueNames) { + const result = spawnSync("npx", ["wrangler", "queues", "create", queueName], { + cwd: root, + encoding: "utf8", + env: process.env, + }); + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + if (result.status === 0) { + console.log(`Created queue ${queueName}`); + } else if (/already exists|already been taken|code\s*10010/i.test(output)) { + console.log(`Queue ${queueName} already exists`); + } else { + process.stderr.write(output); + throw new Error(`Unable to create queue ${queueName}`); + } +} + +const config = JSON.parse(readFileSync(sourceConfigPath, "utf8")); +config.d1_databases[0].database_id = databaseId; +writeFileSync(deployConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + +try { + console.log("Applying D1 migrations"); + wrangler("d1", "migrations", "apply", databaseName, "--remote", "--config", deployConfigPath); + + console.log("Deploying permit poller"); + wrangler("deploy", "--config", deployConfigPath); + + const secretPayload = Object.fromEntries(secretNames.map((name) => [name, process.env[name]])); + const secretResult = spawnSync("npx", ["wrangler", "secret", "bulk", "--config", deployConfigPath], { + cwd: root, + encoding: "utf8", + env: process.env, + input: JSON.stringify(secretPayload), + stdio: ["pipe", "inherit", "inherit"], + }); + if (secretResult.status !== 0) throw new Error("Unable to install permit poller secrets"); + + console.log("Redeploying with production secrets"); + wrangler("deploy", "--config", deployConfigPath); +} finally { + rmSync(deployConfigPath, { force: true }); +} diff --git a/scripts/permit-poller-live-check.mjs b/scripts/permit-poller-live-check.mjs new file mode 100644 index 0000000..24d170b --- /dev/null +++ b/scripts/permit-poller-live-check.mjs @@ -0,0 +1,32 @@ +import { fetchAvailability } from "../workers/permit-poller/adapters.js"; +import { PERMIT_PRODUCTS } from "../workers/permit-poller/catalog.js"; + +const today = new Date().toISOString().slice(0, 10); +const failures = []; + +for (const product of PERMIT_PRODUCTS) { + try { + const result = await fetchAvailability(product, `${today.slice(0, 7)}-01`, [today]); + console.log(JSON.stringify({ + permit_id: product.id, + adapter: product.adapter, + http_status: result.httpStatus, + parsed_records: result.records.length, + status: "ok" + })); + } catch (error) { + failures.push(product.id); + console.error(JSON.stringify({ + permit_id: product.id, + adapter: product.adapter, + status: "failed", + code: error.code || "unexpected_error", + http_status: error.httpStatus || null, + message: error.message + })); + } +} + +if (failures.length > 0) { + throw new Error(`Permit source validation failed: ${failures.join(", ")}`); +} diff --git a/tests/fixtures/permit-poller/division-availability.json b/tests/fixtures/permit-poller/division-availability.json new file mode 100644 index 0000000..c677753 --- /dev/null +++ b/tests/fixtures/permit-poller/division-availability.json @@ -0,0 +1,20 @@ +{ + "payload": { + "permit_id": "234652", + "availability": { + "31": { + "division_id": "31", + "date_availability": { + "2026-07-20T00:00:00Z": { "total": 275, "remaining": 3 }, + "2026-07-21T00:00:00Z": { "total": 275, "remaining": 0 } + } + }, + "32": { + "division_id": "32", + "date_availability": { + "2026-07-20T00:00:00Z": { "total": 300, "remaining": 100 } + } + } + } + } +} diff --git a/tests/fixtures/permit-poller/inyo-availability.json b/tests/fixtures/permit-poller/inyo-availability.json new file mode 100644 index 0000000..a30e7ae --- /dev/null +++ b/tests/fixtures/permit-poller/inyo-availability.json @@ -0,0 +1,20 @@ +{ + "payload": { + "2026-07-20": { + "166": { + "quota_usage_by_member_daily": { "total": 60, "remaining": 2 }, + "is_walkup": false + }, + "406": { + "quota_usage_by_member_daily": { "total": 100, "remaining": 5 }, + "is_walkup": false + } + }, + "2026-07-21": { + "406": { + "quota_usage_by_member_daily": { "total": 100, "remaining": 1 }, + "is_walkup": false + } + } + } +} diff --git a/tests/permit-poller.test.mjs b/tests/permit-poller.test.mjs new file mode 100644 index 0000000..0d0d949 --- /dev/null +++ b/tests/permit-poller.test.mjs @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + buildAvailabilityUrl, + parseDivisionMonthAvailability, + parseInyoAvailability, + SourceResponseError +} from "../workers/permit-poller/adapters.js"; +import { getPermitProduct, publicPermitCatalog } from "../workers/permit-poller/catalog.js"; +import { + crossesPartyThreshold, + hashToken, + maskPhone, + validateTrackerInput +} from "../workers/permit-poller/service.js"; +import { handleHttp } from "../workers/permit-poller/worker.js"; + +const fixture = async (name) => JSON.parse(await readFile( + new URL(`./fixtures/permit-poller/${name}`, import.meta.url), "utf8" +)); + +test("Whitney parser keeps Day Use and Overnight inventory separate", async () => { + const payload = await fixture("inyo-availability.json"); + const dates = ["2026-07-20", "2026-07-21", "2026-07-22"]; + const dayUse = parseInyoAvailability(payload, getPermitProduct("rec_gov_445860_day_use"), dates); + const overnight = parseInyoAvailability(payload, getPermitProduct("rec_gov_445860_overnight"), dates); + assert.deepEqual(dayUse, [ + { date: "2026-07-20", remaining: 5 }, + { date: "2026-07-21", remaining: 1 } + ]); + assert.deepEqual(overnight, [ + { date: "2026-07-20", remaining: 2 }, + { date: "2026-07-21", remaining: 0 } + ]); +}); + +test("missing Inyo dates remain unknown rather than becoming false zeroes", async () => { + const payload = await fixture("inyo-availability.json"); + const records = parseInyoAvailability( + payload, + getPermitProduct("rec_gov_445860_day_use"), + ["2026-08-01"] + ); + assert.deepEqual(records, []); +}); + +test("Half Dome parser reads only the Daily division", async () => { + const payload = await fixture("division-availability.json"); + const records = parseDivisionMonthAvailability( + payload, + getPermitProduct("rec_gov_234652_daily"), + ["2026-07-20", "2026-07-21", "2026-07-22"] + ); + assert.deepEqual(records, [ + { date: "2026-07-20", remaining: 3 }, + { date: "2026-07-21", remaining: 0 } + ]); +}); + +test("adapter rejects schema drift instead of erasing the last known snapshot", () => { + assert.throws( + () => parseDivisionMonthAvailability( + { payload: { availability: {} } }, + getPermitProduct("rec_gov_234652_daily"), + ["2026-07-20"] + ), + (error) => error instanceof SourceResponseError && error.code === "division_missing" + ); +}); + +test("party thresholds trigger only when inventory becomes sufficient", () => { + assert.equal(crossesPartyThreshold(0, 3, 1), true); + assert.equal(crossesPartyThreshold(0, 3, 3), true); + assert.equal(crossesPartyThreshold(0, 3, 4), false); + assert.equal(crossesPartyThreshold(2, 3, 2), false); + assert.equal(crossesPartyThreshold(3, 3, 3), false); + assert.equal(crossesPartyThreshold(null, 3, 1), false); +}); + +test("tracker validation normalizes dates and enforces consent and product limits", () => { + const now = new Date("2026-07-19T12:00:00.000Z"); + const valid = validateTrackerInput({ + phone: "+19515550123", + permit_id: "rec_gov_445860_day_use", + party_size: 2, + dates: ["2026-07-21", "2026-07-20", "2026-07-21"], + consent: true + }, now); + assert.equal(valid.ok, true); + assert.deepEqual(valid.value.dates, ["2026-07-20", "2026-07-21"]); + + assert.equal(validateTrackerInput({ ...valid.value, consent: false }, now).ok, false); + assert.equal(validateTrackerInput({ ...valid.value, consent: true, party_size: 16 }, now).ok, false); + assert.equal(validateTrackerInput({ ...valid.value, consent: true, dates: ["2026-07-18"] }, now).ok, false); +}); + +test("catalog exposes only verified cancellation-inventory products", () => { + assert.deepEqual(publicPermitCatalog().map((product) => product.id), [ + "rec_gov_445860_day_use", + "rec_gov_445860_overnight", + "rec_gov_234652_daily" + ]); +}); + +test("adapter URLs use the correct Recreation.gov API family", () => { + assert.match( + buildAvailabilityUrl(getPermitProduct("rec_gov_445860_overnight"), "2026-07-01"), + /permitinyo\/445860\/availabilityv2\?start_date=2026-07-01&end_date=2026-07-31/ + ); + assert.match( + buildAvailabilityUrl(getPermitProduct("rec_gov_234652_daily"), "2026-07-01"), + /api\/permits\/234652\/availability\/month/ + ); +}); + +test("management tokens hash deterministically and phone responses stay masked", async () => { + assert.equal(await hashToken("token-a"), await hashToken("token-a")); + assert.notEqual(await hashToken("token-a"), await hashToken("token-b")); + assert.equal(maskPhone("+19515550123"), "+1******0123"); +}); + +test("HTTP catalog is no-store and rejects unapproved browser origins", async () => { + const env = { ALLOWED_ORIGINS: "https://www.trailgenic.com" }; + const ok = await handleHttp(new Request("https://alerts.trailgenic.com/permits", { + headers: { Origin: "https://www.trailgenic.com" } + }), env); + assert.equal(ok.status, 200); + assert.equal(ok.headers.get("Cache-Control"), "no-store"); + assert.equal(ok.headers.get("Access-Control-Allow-Origin"), "https://www.trailgenic.com"); + + const bad = await handleHttp(new Request("https://alerts.trailgenic.com/permits", { + headers: { Origin: "https://evil.example" } + }), env); + assert.equal(bad.status, 403); +}); diff --git a/tests/permit-poller.worker.vitest.mjs b/tests/permit-poller.worker.vitest.mjs new file mode 100644 index 0000000..39893f4 --- /dev/null +++ b/tests/permit-poller.worker.vitest.mjs @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import migration from "../workers/permit-poller/migrations/0001_initial.sql?raw"; +import { + cancelTrackerByToken, + createTracker, + deliverNotification, + getTrackerByToken, + pollPermit, + verifyTrackerPhone +} from "../workers/permit-poller/service.js"; + +const resetDatabase = async () => { + await env.DB.exec(` + DROP TABLE IF EXISTS notifications; + DROP TABLE IF EXISTS availability_events; + DROP TABLE IF EXISTS availability_snapshots; + DROP TABLE IF EXISTS tracker_dates; + DROP TABLE IF EXISTS trackers; + DROP TABLE IF EXISTS poll_runs; + `); + for (const statement of migration.split(";").map((part) => part.trim()).filter(Boolean)) { + await env.DB.prepare(statement).run(); + } +}; + +const trackerInput = (partySize) => ({ + phone: partySize === 1 ? "+19515550101" : "+19515550102", + permit_id: "rec_gov_445860_day_use", + party_size: partySize, + dates: ["2026-07-20"] +}); + +const inyoFetch = (remaining) => async () => new Response(JSON.stringify({ + payload: { + "2026-07-20": { + "406": { + quota_usage_by_member_daily: { total: 100, remaining }, + is_walkup: false + } + } + } +}), { status: 200, headers: { "Content-Type": "application/json" } }); + +describe("permit cancellation service with D1", () => { + let queued; + let serviceEnv; + + beforeEach(async () => { + await resetDatabase(); + queued = []; + serviceEnv = { + DB: env.DB, + NOTIFICATION_QUEUE: { + send: async (body) => queued.push(body) + } + }; + }); + + it("alerts only trackers whose party threshold was crossed", async () => { + await createTracker(serviceEnv, trackerInput(1), new Date("2026-07-19T12:00:00Z"), { status: "active" }); + await createTracker(serviceEnv, trackerInput(2), new Date("2026-07-19T12:00:00Z"), { status: "active" }); + + const first = await pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:05:00Z"), + fetchImpl: inyoFetch(1) + }); + expect(first.notifications_queued).toBe(1); + expect(queued).toHaveLength(1); + + const second = await pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:10:00Z"), + fetchImpl: inyoFetch(2) + }); + expect(second.notifications_queued).toBe(1); + expect(queued).toHaveLength(2); + + const unchanged = await pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:15:00Z"), + fetchImpl: inyoFetch(2) + }); + expect(unchanged.notifications_queued).toBe(0); + expect(queued).toHaveLength(2); + + const notifications = await env.DB.prepare( + "SELECT status FROM notifications ORDER BY created_at" + ).all(); + expect(notifications.results.map((row) => row.status)).toEqual(["queued", "queued"]); + }); + + it("preserves the last good snapshot when the provider schema changes", async () => { + await createTracker(serviceEnv, trackerInput(1), new Date("2026-07-19T12:00:00Z"), { status: "active" }); + await pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:05:00Z"), + fetchImpl: inyoFetch(2) + }); + queued.length = 0; + + await expect(pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:10:00Z"), + fetchImpl: async () => new Response(JSON.stringify({ changed: true }), { status: 200 }) + })).rejects.toMatchObject({ code: "invalid_schema" }); + + const snapshot = await env.DB.prepare( + "SELECT remaining FROM availability_snapshots WHERE permit_id = ? AND date = ?" + ).bind("rec_gov_445860_day_use", "2026-07-20").first(); + expect(snapshot.remaining).toBe(2); + expect(queued).toHaveLength(0); + const failed = await env.DB.prepare( + "SELECT error_code FROM poll_runs WHERE status = 'failed' ORDER BY started_at DESC LIMIT 1" + ).first(); + expect(failed.error_code).toBe("invalid_schema"); + }); + + it("uses bearer management tokens without exposing the phone number", async () => { + const created = await createTracker(serviceEnv, trackerInput(1), new Date("2026-07-19T12:00:00Z")); + const tracker = await getTrackerByToken(serviceEnv, created.manage_token); + expect(tracker.phone_masked).toBe("+1******0101"); + expect(tracker).not.toHaveProperty("phone_e164"); + expect(await cancelTrackerByToken(serviceEnv, created.manage_token)).toBe(true); + expect((await getTrackerByToken(serviceEnv, created.manage_token)).status).toBe("cancelled"); + }); + + it("keeps a tracker pending until Twilio Verify approves the phone", async () => { + const created = await createTracker(serviceEnv, trackerInput(1), new Date("2026-07-19T12:00:00Z")); + expect(created.status).toBe("pending"); + expect((await getTrackerByToken(serviceEnv, created.manage_token)).status).toBe("pending"); + const verifyEnv = { + ...serviceEnv, + TWILIO_ACCOUNT_SID: "AC_test", + TWILIO_AUTH_TOKEN: "secret", + TWILIO_VERIFY_SERVICE_SID: "VA_test" + }; + const verified = await verifyTrackerPhone(verifyEnv, created.manage_token, "123456", { + now: new Date("2026-07-19T12:01:00Z"), + fetchImpl: async (_url, init) => { + expect(init.body.get("Code")).toBe("123456"); + return new Response(JSON.stringify({ status: "approved" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + }); + expect(verified).toEqual({ approved: true, status: "active" }); + expect((await getTrackerByToken(serviceEnv, created.manage_token)).status).toBe("active"); + }); + + it("delivers an idempotent Twilio message from the notification outbox", async () => { + await createTracker(serviceEnv, trackerInput(1), new Date("2026-07-19T12:00:00Z"), { status: "active" }); + await pollPermit(serviceEnv, "rec_gov_445860_day_use", { + now: new Date("2026-07-19T12:05:00Z"), + fetchImpl: inyoFetch(2) + }); + const notificationId = queued[0].notification_id; + let sends = 0; + let smsBody = ""; + const deliveryEnv = { + ...serviceEnv, + TWILIO_ACCOUNT_SID: "AC_test", + TWILIO_AUTH_TOKEN: "secret", + TWILIO_FROM_NUMBER: "+19515550999" + }; + const sent = await deliverNotification(deliveryEnv, notificationId, { + now: new Date("2026-07-19T12:06:00Z"), + fetchImpl: async (_url, init) => { + sends += 1; + smsBody = init.body.get("Body"); + return new Response(JSON.stringify({ sid: "SM_test" }), { + status: 201, + headers: { "Content-Type": "application/json" } + }); + } + }); + expect(sent).toEqual({ status: "sent", provider_message_id: "SM_test" }); + expect(smsBody).toContain("2 spots available for Mt. Whitney Day Use"); + expect(smsBody).toContain("Your party: 1"); + + const duplicate = await deliverNotification(deliveryEnv, notificationId, { + fetchImpl: async () => { throw new Error("must not send twice"); } + }); + expect(duplicate).toEqual({ status: "skipped" }); + expect(sends).toBe(1); + }); +}); diff --git a/vitest.permit-poller.config.mjs b/vitest.permit-poller.config.mjs new file mode 100644 index 0000000..854d638 --- /dev/null +++ b/vitest.permit-poller.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./workers/permit-poller/wrangler.jsonc" } + }) + ], + test: { + include: ["tests/permit-poller.worker.vitest.mjs"] + } +}); diff --git a/workers/permit-poller/README.md b/workers/permit-poller/README.md new file mode 100644 index 0000000..6865c38 --- /dev/null +++ b/workers/permit-poller/README.md @@ -0,0 +1,63 @@ +# TrailGenic Permit Cancellation Alerts + +This Worker powers one product: SMS alerts when validated, immediately bookable +permit inventory satisfies a tracker's exact permit, selected date, and party size. + +## Initial verified products + +- Mt. Whitney Day Use — Recreation.gov facility `445860`, division `406` +- Mt. Whitney Overnight — Recreation.gov facility `445860`, division `166` +- Half Dome Daily — Recreation.gov facility `234652`, division `31` + +Products without a verified bookable-inventory response are intentionally excluded. +Lottery calendars and release-deadline reminders are out of scope. + +## Architecture + +1. Cron identifies permit products with active, non-expired trackers. +2. The poll queue serializes product checks. +3. Product-specific adapters validate Recreation.gov responses. +4. Twilio Verify confirms ownership before a tracker becomes active. +5. D1 stores inventory snapshots and detects inventory increases. +6. Trackers match when `previous_remaining < party_size <= current_remaining`, or + when their first validated observation already has sufficient inventory. +7. An idempotent notification outbox feeds the notification queue. +8. The notification consumer sends Twilio SMS with retries and a dead-letter queue. + +Missing dates and malformed payloads are treated as unknown. They never overwrite the +last good snapshot or create availability alerts. + +## Deployment + +The GitHub Actions deployment is idempotent. It creates the D1 database and four queues +when absent, resolves the live database ID into a temporary ignored config, applies D1 +migrations, installs Worker secrets, and deploys the Worker. The placeholder +`database_id` in the committed config is never used for production deployment. + +For a manual deployment with the five secrets exported in the environment: + +```sh +npm run deploy:permit-poller +``` + +Configure these secrets before enabling the public tracker endpoint: + +- `TWILIO_ACCOUNT_SID` +- `TWILIO_AUTH_TOKEN` +- `TWILIO_FROM_NUMBER` +- `TWILIO_VERIFY_SERVICE_SID` +- `TURNSTILE_SECRET_KEY` + +The public site must include the Turnstile response as `turnstile_token` and explicit +SMS consent as `consent: true` when calling `POST /trackers`. + +## HTTP API + +- `GET /permits` — supported inventory products +- `POST /trackers` — create an exact date/party-size tracker +- `POST /trackers/verify` — confirm the Twilio Verify code and activate the tracker +- `GET /trackers/manage` — inspect a tracker with a bearer management token +- `DELETE /trackers/manage` — cancel a tracker with the same token +- `GET /health` — database, poller, and Twilio configuration health + +Tracker responses never expose the full phone number. All responses use `no-store`. diff --git a/workers/permit-poller/adapters.js b/workers/permit-poller/adapters.js new file mode 100644 index 0000000..cf57188 --- /dev/null +++ b/workers/permit-poller/adapters.js @@ -0,0 +1,137 @@ +const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export class SourceResponseError extends Error { + constructor(message, { code = "source_error", httpStatus = null } = {}) { + super(message); + this.name = "SourceResponseError"; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export const monthBounds = (dateString) => { + if (!DATE_RE.test(dateString)) throw new TypeError(`Invalid date: ${dateString}`); + const [year, month] = dateString.split("-").map(Number); + const start = new Date(Date.UTC(year, month - 1, 1)); + const end = new Date(Date.UTC(year, month, 0)); + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + startISO: start.toISOString() + }; +}; + +export const buildAvailabilityUrl = (product, monthDate) => { + const { startDate, endDate, startISO } = monthBounds(monthDate); + if (product.adapter === "recreation_inyo") { + return `https://www.recreation.gov/api/permitinyo/${product.facility_id}/availabilityv2?start_date=${startDate}&end_date=${endDate}&commercial_acct=false`; + } + if (product.adapter === "recreation_division_month") { + return `https://www.recreation.gov/api/permits/${product.facility_id}/availability/month?start_date=${encodeURIComponent(startISO)}`; + } + throw new SourceResponseError(`Unsupported adapter: ${product.adapter}`, { code: "unsupported_adapter" }); +}; + +const ensurePayloadObject = (response) => { + if (!response || typeof response !== "object" || !response.payload || typeof response.payload !== "object" || Array.isArray(response.payload)) { + throw new SourceResponseError("Availability payload is missing or malformed", { code: "invalid_schema" }); + } + return response.payload; +}; + +const normalizeRemaining = (value, context) => { + if (!Number.isInteger(value) || value < 0) { + throw new SourceResponseError(`Invalid remaining count at ${context}`, { code: "invalid_schema" }); + } + return value; +}; + +export const parseInyoAvailability = (response, product, requestedDates) => { + const payload = ensurePayloadObject(response); + const records = []; + for (const date of requestedDates) { + const day = payload[date]; + // A missing day is unknown/unreleased, not proof of zero inventory. + if (!day || typeof day !== "object" || Array.isArray(day)) continue; + const division = day[product.division_id]; + if (!division) { + // When the provider returned the day but omitted this division, it has no inventory. + records.push({ date, remaining: 0 }); + continue; + } + const remaining = division?.quota_usage_by_member_daily?.remaining; + records.push({ + date, + remaining: normalizeRemaining(remaining, `${date}.${product.division_id}.quota_usage_by_member_daily.remaining`) + }); + } + return records; +}; + +export const parseDivisionMonthAvailability = (response, product, requestedDates) => { + const payload = ensurePayloadObject(response); + const availability = payload.availability; + if (!availability || typeof availability !== "object" || Array.isArray(availability)) { + throw new SourceResponseError("Division availability map is missing or malformed", { code: "invalid_schema" }); + } + const division = availability[product.division_id]; + if (!division || typeof division !== "object" || Array.isArray(division)) { + throw new SourceResponseError(`Expected division ${product.division_id} is missing`, { code: "division_missing" }); + } + const dateAvailability = division.date_availability; + if (!dateAvailability || typeof dateAvailability !== "object" || Array.isArray(dateAvailability)) { + throw new SourceResponseError(`Date availability for division ${product.division_id} is missing`, { code: "invalid_schema" }); + } + const records = []; + for (const date of requestedDates) { + const slot = dateAvailability[`${date}T00:00:00Z`] || dateAvailability[date]; + // Missing dates remain unknown so parser/source changes cannot create false transitions. + if (!slot || typeof slot !== "object") continue; + records.push({ + date, + remaining: normalizeRemaining(slot.remaining, `${product.division_id}.${date}.remaining`) + }); + } + return records; +}; + +export const parseAvailability = (response, product, requestedDates) => { + if (product.adapter === "recreation_inyo") { + return parseInyoAvailability(response, product, requestedDates); + } + if (product.adapter === "recreation_division_month") { + return parseDivisionMonthAvailability(response, product, requestedDates); + } + throw new SourceResponseError(`Unsupported adapter: ${product.adapter}`, { code: "unsupported_adapter" }); +}; + +export const fetchAvailability = async (product, monthDate, requestedDates, fetchImpl = fetch) => { + const url = buildAvailabilityUrl(product, monthDate); + let response; + try { + response = await fetchImpl(url, { + headers: { Accept: "application/json", "User-Agent": BROWSER_UA }, + signal: AbortSignal.timeout(15_000) + }); + } catch (error) { + throw new SourceResponseError(`Availability request failed: ${error.message}`, { code: "network_error" }); + } + if (!response.ok) { + throw new SourceResponseError(`Recreation.gov returned ${response.status}`, { + code: response.status === 429 ? "rate_limited" : "http_error", + httpStatus: response.status + }); + } + let body; + try { + body = await response.json(); + } catch { + throw new SourceResponseError("Availability response was not valid JSON", { code: "invalid_json", httpStatus: response.status }); + } + return { + url, + httpStatus: response.status, + records: parseAvailability(body, product, requestedDates) + }; +}; diff --git a/workers/permit-poller/catalog.js b/workers/permit-poller/catalog.js new file mode 100644 index 0000000..dce4a82 --- /dev/null +++ b/workers/permit-poller/catalog.js @@ -0,0 +1,47 @@ +export const PERMIT_PRODUCTS = Object.freeze([ + Object.freeze({ + id: "rec_gov_445860_day_use", + name: "Mt. Whitney Day Use", + provider: "recreation.gov", + adapter: "recreation_inyo", + facility_id: "445860", + division_id: "406", + division_name: "Mt. Whitney Day Use (All Routes)", + booking_url: "https://www.recreation.gov/permits/445860", + max_party_size: 15 + }), + Object.freeze({ + id: "rec_gov_445860_overnight", + name: "Mt. Whitney Overnight", + provider: "recreation.gov", + adapter: "recreation_inyo", + facility_id: "445860", + division_id: "166", + division_name: "Mt. Whitney Trail (Overnight)", + booking_url: "https://www.recreation.gov/permits/445860", + max_party_size: 15 + }), + Object.freeze({ + id: "rec_gov_234652_daily", + name: "Half Dome Daily Permit", + provider: "recreation.gov", + adapter: "recreation_division_month", + facility_id: "234652", + division_id: "31", + division_name: "Half Dome Cables (Daily)", + booking_url: "https://www.recreation.gov/permits/234652", + max_party_size: 6 + }) +]); + +const PRODUCT_BY_ID = new Map(PERMIT_PRODUCTS.map((product) => [product.id, product])); + +export const getPermitProduct = (permitId) => PRODUCT_BY_ID.get(permitId) || null; + +export const publicPermitCatalog = () => PERMIT_PRODUCTS.map((product) => ({ + id: product.id, + name: product.name, + division_name: product.division_name, + booking_url: product.booking_url, + max_party_size: product.max_party_size +})); diff --git a/workers/permit-poller/migrations/0001_initial.sql b/workers/permit-poller/migrations/0001_initial.sql new file mode 100644 index 0000000..71dcff0 --- /dev/null +++ b/workers/permit-poller/migrations/0001_initial.sql @@ -0,0 +1,82 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS trackers ( + id TEXT PRIMARY KEY, + manage_token_hash TEXT NOT NULL UNIQUE, + phone_e164 TEXT NOT NULL, + permit_id TEXT NOT NULL, + party_size INTEGER NOT NULL CHECK (party_size > 0), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'paused', 'cancelled')), + consent_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS trackers_active_permit + ON trackers (status, permit_id, party_size); + +CREATE TABLE IF NOT EXISTS tracker_dates ( + tracker_id TEXT NOT NULL REFERENCES trackers(id) ON DELETE CASCADE, + date TEXT NOT NULL, + PRIMARY KEY (tracker_id, date) +); + +CREATE INDEX IF NOT EXISTS tracker_dates_lookup + ON tracker_dates (date, tracker_id); + +CREATE TABLE IF NOT EXISTS availability_snapshots ( + permit_id TEXT NOT NULL, + date TEXT NOT NULL, + remaining INTEGER NOT NULL CHECK (remaining >= 0), + observed_at TEXT NOT NULL, + PRIMARY KEY (permit_id, date) +); + +CREATE TABLE IF NOT EXISTS availability_events ( + id TEXT PRIMARY KEY, + permit_id TEXT NOT NULL, + date TEXT NOT NULL, + previous_remaining INTEGER, + current_remaining INTEGER NOT NULL CHECK (current_remaining >= 0), + detected_at TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('inventory_increase', 'current_match')) +); + +CREATE INDEX IF NOT EXISTS availability_events_lookup + ON availability_events (permit_id, date, detected_at); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES availability_events(id), + tracker_id TEXT NOT NULL REFERENCES trackers(id), + status TEXT NOT NULL CHECK (status IN ('queued', 'sending', 'retry', 'sent', 'cancelled')), + attempts INTEGER NOT NULL DEFAULT 0, + provider_message_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sent_at TEXT, + UNIQUE (event_id, tracker_id) +); + +CREATE INDEX IF NOT EXISTS notifications_status + ON notifications (status, created_at); + +CREATE TABLE IF NOT EXISTS poll_runs ( + id TEXT PRIMARY KEY, + permit_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')), + started_at TEXT NOT NULL, + completed_at TEXT, + http_status INTEGER, + inventory_count INTEGER NOT NULL DEFAULT 0, + notifications_queued INTEGER NOT NULL DEFAULT 0, + error_code TEXT, + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS poll_runs_health + ON poll_runs (status, completed_at); + +CREATE INDEX IF NOT EXISTS poll_runs_permit + ON poll_runs (permit_id, started_at); diff --git a/workers/permit-poller/service.js b/workers/permit-poller/service.js new file mode 100644 index 0000000..7d2a47b --- /dev/null +++ b/workers/permit-poller/service.js @@ -0,0 +1,438 @@ +import { fetchAvailability, SourceResponseError } from "./adapters.js"; +import { getPermitProduct } from "./catalog.js"; + +const PHONE_RE = /^\+[1-9]\d{7,14}$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const encoder = new TextEncoder(); + +const isoNow = (now = new Date()) => now.toISOString(); +const dateOnly = (now = new Date()) => isoNow(now).slice(0, 10); + +const resultRows = (result) => result?.results || []; +const changedRows = (result) => result?.meta?.changes || 0; + +const validCalendarDate = (value) => { + if (!DATE_RE.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +}; + +const addUtcDays = (date, days) => { + const copy = new Date(date.getTime()); + copy.setUTCDate(copy.getUTCDate() + days); + return copy; +}; + +export const validateTrackerInput = (body, now = new Date()) => { + if (!body || typeof body !== "object") return { ok: false, error: "Request body must be JSON." }; + if (!PHONE_RE.test(body.phone || "")) return { ok: false, error: "phone must use E.164 format." }; + const product = getPermitProduct(body.permit_id); + if (!product) return { ok: false, error: "Unknown or unsupported permit_id." }; + if (!Number.isInteger(body.party_size) || body.party_size < 1 || body.party_size > product.max_party_size) { + return { ok: false, error: `party_size must be between 1 and ${product.max_party_size}.` }; + } + if (!Array.isArray(body.dates) || body.dates.length < 1 || body.dates.length > 31) { + return { ok: false, error: "dates must contain between 1 and 31 dates." }; + } + if (body.consent !== true) return { ok: false, error: "Explicit SMS consent is required." }; + + const today = dateOnly(now); + const lastAllowed = dateOnly(addUtcDays(now, 366)); + const dates = [...new Set(body.dates)]; + if (dates.some((date) => !validCalendarDate(date) || date < today || date > lastAllowed)) { + return { ok: false, error: `Each date must be valid and between ${today} and ${lastAllowed}.` }; + } + dates.sort(); + return { + ok: true, + value: { + phone: body.phone, + permit_id: product.id, + party_size: body.party_size, + dates + } + }; +}; + +const randomToken = () => { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +}; + +export const hashToken = async (token) => { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(token)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const maskPhone = (phone) => { + if (!PHONE_RE.test(phone || "")) return "redacted"; + return `${phone.slice(0, 2)}******${phone.slice(-4)}`; +}; + +export const crossesPartyThreshold = (previousRemaining, currentRemaining, partySize) => + Number.isInteger(previousRemaining) && previousRemaining < partySize && currentRemaining >= partySize; + +const notificationText = ({ product, date, partySize, remaining }) => { + const displayDate = new Intl.DateTimeFormat("en-US", { + month: "short", day: "numeric", year: "numeric", timeZone: "UTC" + }).format(new Date(`${date}T00:00:00.000Z`)); + return `TrailGenic Alert: ${remaining} spot${remaining === 1 ? "" : "s"} available for ${product.name} on ${displayDate}. Your party: ${partySize}. Book now: ${product.booking_url} Reply STOP to opt out.`; +}; + +const insertNotification = async (env, { eventId, trackerId, now = new Date() }) => { + const notificationId = crypto.randomUUID(); + const createdAt = isoNow(now); + const result = await env.DB.prepare( + `INSERT OR IGNORE INTO notifications + (id, event_id, tracker_id, status, attempts, created_at, updated_at) + VALUES (?, ?, ?, 'queued', 0, ?, ?)` + ).bind(notificationId, eventId, trackerId, createdAt, createdAt).run(); + + if (changedRows(result) > 0) { + await env.NOTIFICATION_QUEUE.send({ type: "notification", notification_id: notificationId }); + return notificationId; + } + return null; +}; + +const enqueueCurrentMatches = async (env, trackerId, permitId, partySize, dates, now = new Date()) => { + if (!env.NOTIFICATION_QUEUE) return; + for (const date of dates) { + const snapshot = await env.DB.prepare( + "SELECT remaining, observed_at FROM availability_snapshots WHERE permit_id = ? AND date = ?" + ).bind(permitId, date).first(); + if (!snapshot || snapshot.remaining < partySize) continue; + const eventId = `snapshot:${permitId}:${date}:${snapshot.observed_at}`; + await env.DB.prepare( + `INSERT OR IGNORE INTO availability_events + (id, permit_id, date, previous_remaining, current_remaining, detected_at, event_type) + VALUES (?, ?, ?, NULL, ?, ?, 'current_match')` + ).bind(eventId, permitId, date, snapshot.remaining, isoNow(now)).run(); + await insertNotification(env, { eventId, trackerId, now }); + } +}; + +export const createTracker = async (env, input, now = new Date(), { status = "pending" } = {}) => { + if (!new Set(["pending", "active"]).has(status)) throw new TypeError(`Invalid initial tracker status: ${status}`); + const trackerId = crypto.randomUUID(); + const manageToken = randomToken(); + const tokenHash = await hashToken(manageToken); + const timestamp = isoNow(now); + const statements = [ + env.DB.prepare( + `INSERT INTO trackers + (id, manage_token_hash, phone_e164, permit_id, party_size, status, consent_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind(trackerId, tokenHash, input.phone, input.permit_id, input.party_size, status, timestamp, timestamp, timestamp), + ...input.dates.map((date) => env.DB.prepare( + "INSERT INTO tracker_dates (tracker_id, date) VALUES (?, ?)" + ).bind(trackerId, date)) + ]; + await env.DB.batch(statements); + if (status === "active") { + await enqueueCurrentMatches(env, trackerId, input.permit_id, input.party_size, input.dates, now); + } + return { + id: trackerId, + manage_token: manageToken, + phone_masked: maskPhone(input.phone), + permit_id: input.permit_id, + party_size: input.party_size, + dates: input.dates, + status, + created_at: timestamp + }; +}; + +const twilioVerifyRequest = async (env, path, form, fetchImpl = fetch) => { + const { TWILIO_ACCOUNT_SID: accountSid, TWILIO_AUTH_TOKEN: authToken, TWILIO_VERIFY_SERVICE_SID: serviceSid } = env; + if (!accountSid || !authToken || !serviceSid) throw new Error("Twilio Verify is not configured"); + const response = await fetchImpl(`https://verify.twilio.com/v2/Services/${serviceSid}/${path}`, { + method: "POST", + headers: { + Authorization: `Basic ${btoa(`${accountSid}:${authToken}`)}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(form), + signal: AbortSignal.timeout(15_000) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`Twilio Verify returned ${response.status}: ${data.message || "request failed"}`); + return data; +}; + +export const startPhoneVerification = async (env, phone, fetchImpl = fetch) => { + if (!PHONE_RE.test(phone || "")) throw new TypeError("Invalid phone number"); + const result = await twilioVerifyRequest(env, "Verifications", { To: phone, Channel: "sms" }, fetchImpl); + return { status: result.status || "pending" }; +}; + +export const verifyTrackerPhone = async (env, token, code, { now = new Date(), fetchImpl = fetch } = {}) => { + if (!/^\d{4,10}$/.test(code || "")) return { approved: false, reason: "invalid_code" }; + const tokenHash = await hashToken(token); + const tracker = await env.DB.prepare( + `SELECT id, phone_e164, permit_id, party_size, status FROM trackers WHERE manage_token_hash = ?` + ).bind(tokenHash).first(); + if (!tracker || tracker.status === "cancelled") return { approved: false, reason: "not_found" }; + if (tracker.status === "active") return { approved: true, status: "active" }; + const result = await twilioVerifyRequest(env, "VerificationCheck", { + To: tracker.phone_e164, + Code: code + }, fetchImpl); + if (result.status !== "approved") return { approved: false, reason: result.status || "not_approved" }; + + await env.DB.prepare( + "UPDATE trackers SET status = 'active', updated_at = ? WHERE id = ? AND status = 'pending'" + ).bind(isoNow(now), tracker.id).run(); + const dates = resultRows(await env.DB.prepare( + "SELECT date FROM tracker_dates WHERE tracker_id = ? ORDER BY date" + ).bind(tracker.id).all()).map((row) => row.date); + await enqueueCurrentMatches(env, tracker.id, tracker.permit_id, tracker.party_size, dates, now); + return { approved: true, status: "active" }; +}; + +export const getTrackerByToken = async (env, token) => { + const tokenHash = await hashToken(token); + const tracker = await env.DB.prepare( + `SELECT id, phone_e164, permit_id, party_size, status, created_at, updated_at + FROM trackers WHERE manage_token_hash = ?` + ).bind(tokenHash).first(); + if (!tracker) return null; + const dates = resultRows(await env.DB.prepare( + "SELECT date FROM tracker_dates WHERE tracker_id = ? ORDER BY date" + ).bind(tracker.id).all()).map((row) => row.date); + return { + id: tracker.id, + phone_masked: maskPhone(tracker.phone_e164), + permit_id: tracker.permit_id, + party_size: tracker.party_size, + dates, + status: tracker.status, + created_at: tracker.created_at, + updated_at: tracker.updated_at + }; +}; + +export const cancelTrackerByToken = async (env, token, now = new Date()) => { + const tokenHash = await hashToken(token); + const result = await env.DB.prepare( + "UPDATE trackers SET status = 'cancelled', updated_at = ? WHERE manage_token_hash = ? AND status != 'cancelled'" + ).bind(isoNow(now), tokenHash).run(); + return changedRows(result) > 0; +}; + +export const activePermitIds = async (env, now = new Date()) => { + const rows = resultRows(await env.DB.prepare( + `SELECT DISTINCT t.permit_id + FROM trackers t JOIN tracker_dates d ON d.tracker_id = t.id + WHERE t.status = 'active' AND d.date >= ?` + ).bind(dateOnly(now)).all()); + return rows.map((row) => row.permit_id).filter((id) => getPermitProduct(id)); +}; + +const activeDatesForPermit = async (env, permitId, now = new Date()) => { + const rows = resultRows(await env.DB.prepare( + `SELECT DISTINCT d.date + FROM trackers t JOIN tracker_dates d ON d.tracker_id = t.id + WHERE t.status = 'active' AND t.permit_id = ? AND d.date >= ? + ORDER BY d.date` + ).bind(permitId, dateOnly(now)).all()); + return rows.map((row) => row.date); +}; + +const groupDatesByMonth = (dates) => { + const groups = new Map(); + for (const date of dates) { + const month = `${date.slice(0, 7)}-01`; + if (!groups.has(month)) groups.set(month, []); + groups.get(month).push(date); + } + return groups; +}; + +const applyInventoryRecord = async (env, product, record, now = new Date()) => { + const previous = await env.DB.prepare( + "SELECT remaining FROM availability_snapshots WHERE permit_id = ? AND date = ?" + ).bind(product.id, record.date).first(); + const observedAt = isoNow(now); + await env.DB.prepare( + `INSERT INTO availability_snapshots (permit_id, date, remaining, observed_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(permit_id, date) DO UPDATE SET remaining = excluded.remaining, observed_at = excluded.observed_at` + ).bind(product.id, record.date, record.remaining, observedAt).run(); + + // A tracker's first validated observation should alert when inventory is already + // sufficient. Schema validation and exact date/product matching prevent floods. + if ((!previous && record.remaining === 0) || (previous && record.remaining <= previous.remaining)) return 0; + + const previousRemaining = previous?.remaining ?? 0; + + const eventId = crypto.randomUUID(); + await env.DB.prepare( + `INSERT INTO availability_events + (id, permit_id, date, previous_remaining, current_remaining, detected_at, event_type) + VALUES (?, ?, ?, ?, ?, ?, 'inventory_increase')` + ).bind(eventId, product.id, record.date, previous ? previous.remaining : null, record.remaining, observedAt).run(); + + const trackers = resultRows(await env.DB.prepare( + `SELECT t.id + FROM trackers t JOIN tracker_dates d ON d.tracker_id = t.id + WHERE t.status = 'active' AND t.permit_id = ? AND d.date = ? + AND t.party_size > ? AND t.party_size <= ?` + ).bind(product.id, record.date, previousRemaining, record.remaining).all()); + + let queued = 0; + for (const tracker of trackers) { + if (await insertNotification(env, { eventId, trackerId: tracker.id, now })) queued += 1; + } + return queued; +}; + +const startPollRun = async (env, product, now) => { + const id = crypto.randomUUID(); + await env.DB.prepare( + `INSERT INTO poll_runs (id, permit_id, status, started_at) + VALUES (?, ?, 'running', ?)` + ).bind(id, product.id, isoNow(now)).run(); + return id; +}; + +const finishPollRun = async (env, runId, fields, now) => { + await env.DB.prepare( + `UPDATE poll_runs SET status = ?, completed_at = ?, http_status = ?, inventory_count = ?, + notifications_queued = ?, error_code = ?, error_message = ? WHERE id = ?` + ).bind( + fields.status, + isoNow(now), + fields.httpStatus ?? null, + fields.inventoryCount ?? 0, + fields.notificationsQueued ?? 0, + fields.errorCode ?? null, + fields.errorMessage?.slice(0, 500) ?? null, + runId + ).run(); +}; + +export const pollPermit = async (env, permitId, { + now = new Date(), + fetchImpl = fetch +} = {}) => { + const product = getPermitProduct(permitId); + if (!product) throw new Error(`Unsupported permit: ${permitId}`); + const runId = await startPollRun(env, product, now); + let inventoryCount = 0; + let notificationsQueued = 0; + let lastHttpStatus = null; + try { + const dates = await activeDatesForPermit(env, permitId, now); + for (const [month, monthDates] of groupDatesByMonth(dates)) { + const result = await fetchAvailability(product, month, monthDates, fetchImpl); + lastHttpStatus = result.httpStatus; + inventoryCount += result.records.length; + for (const record of result.records) { + notificationsQueued += await applyInventoryRecord(env, product, record, now); + } + } + await finishPollRun(env, runId, { + status: "succeeded", httpStatus: lastHttpStatus, + inventoryCount, notificationsQueued + }, now); + return { run_id: runId, permit_id: permitId, status: "succeeded", inventory_count: inventoryCount, notifications_queued: notificationsQueued }; + } catch (error) { + await finishPollRun(env, runId, { + status: "failed", + httpStatus: error instanceof SourceResponseError ? error.httpStatus : lastHttpStatus, + inventoryCount, + notificationsQueued, + errorCode: error instanceof SourceResponseError ? error.code : "unexpected_error", + errorMessage: error.message + }, now); + throw error; + } +}; + +const sendTwilioSms = async (env, to, body, fetchImpl = fetch) => { + const { TWILIO_ACCOUNT_SID: accountSid, TWILIO_AUTH_TOKEN: authToken, TWILIO_FROM_NUMBER: from } = env; + if (!accountSid || !authToken || !from) throw new Error("Twilio is not configured"); + const response = await fetchImpl(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, { + method: "POST", + headers: { + Authorization: `Basic ${btoa(`${accountSid}:${authToken}`)}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ To: to, From: from, Body: body }), + signal: AbortSignal.timeout(15_000) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`Twilio returned ${response.status}: ${data.message || "send failed"}`); + return data.sid || null; +}; + +export const deliverNotification = async (env, notificationId, { now = new Date(), fetchImpl = fetch } = {}) => { + const claimed = await env.DB.prepare( + `UPDATE notifications SET status = 'sending', attempts = attempts + 1, updated_at = ? + WHERE id = ? AND status IN ('queued', 'retry')` + ).bind(isoNow(now), notificationId).run(); + if (changedRows(claimed) === 0) return { status: "skipped" }; + + const row = await env.DB.prepare( + `SELECT n.id, t.phone_e164, t.party_size, t.status AS tracker_status, + e.permit_id, e.date, e.current_remaining + FROM notifications n + JOIN trackers t ON t.id = n.tracker_id + JOIN availability_events e ON e.id = n.event_id + WHERE n.id = ?` + ).bind(notificationId).first(); + if (!row || row.tracker_status !== "active") { + await env.DB.prepare( + "UPDATE notifications SET status = 'cancelled', updated_at = ? WHERE id = ?" + ).bind(isoNow(now), notificationId).run(); + return { status: "cancelled" }; + } + const product = getPermitProduct(row.permit_id); + if (!product) throw new Error(`Notification references unsupported permit: ${row.permit_id}`); + const body = notificationText({ + product, date: row.date, partySize: row.party_size, remaining: row.current_remaining + }); + try { + const providerId = await sendTwilioSms(env, row.phone_e164, body, fetchImpl); + await env.DB.prepare( + `UPDATE notifications SET status = 'sent', provider_message_id = ?, sent_at = ?, + updated_at = ?, last_error = NULL WHERE id = ?` + ).bind(providerId, isoNow(now), isoNow(now), notificationId).run(); + return { status: "sent", provider_message_id: providerId }; + } catch (error) { + await env.DB.prepare( + `UPDATE notifications SET status = 'retry', last_error = ?, updated_at = ? WHERE id = ?` + ).bind(error.message.slice(0, 500), isoNow(now), notificationId).run(); + throw error; + } +}; + +export const healthSnapshot = async (env, now = new Date()) => { + const active = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM trackers WHERE status = 'active'" + ).first(); + const lastSuccess = await env.DB.prepare( + "SELECT MAX(completed_at) AS completed_at FROM poll_runs WHERE status = 'succeeded'" + ).first(); + const lastFailure = await env.DB.prepare( + "SELECT permit_id, completed_at, error_code FROM poll_runs WHERE status = 'failed' ORDER BY completed_at DESC LIMIT 1" + ).first(); + const activeCount = active?.count || 0; + const lastSuccessAt = lastSuccess?.completed_at || null; + const staleAfter = new Date(now.getTime() - 20 * 60 * 1000).toISOString(); + const pollerStatus = activeCount === 0 ? "idle" : lastSuccessAt && lastSuccessAt >= staleAfter ? "operational" : "degraded"; + return { + status: pollerStatus === "degraded" ? "degraded" : "healthy", + poller_status: pollerStatus, + database_status: "operational", + sms_status: env.TWILIO_ACCOUNT_SID && env.TWILIO_AUTH_TOKEN && env.TWILIO_FROM_NUMBER && env.TWILIO_VERIFY_SERVICE_SID ? "configured" : "not_configured", + active_trackers: activeCount, + last_success_at: lastSuccessAt, + last_failure: lastFailure || null, + checked_at: isoNow(now) + }; +}; diff --git a/workers/permit-poller/worker.js b/workers/permit-poller/worker.js index 1fc0a2b..da13b1c 100644 --- a/workers/permit-poller/worker.js +++ b/workers/permit-poller/worker.js @@ -1,311 +1,209 @@ -const PERMITS = [ - { permit_id: "rec_gov_445860_day_use", name: "Mt. Whitney Day Use", rec_gov_facility_id: "445860", permit_type: "inyo", booking_url: "https://www.recreation.gov/permits/445860" }, - { permit_id: "rec_gov_445860_overnight", name: "Mt. Whitney Overnight", rec_gov_facility_id: "445860", permit_type: "inyo", booking_url: "https://www.recreation.gov/permits/445860" }, - { permit_id: "rec_gov_234652_day_use", name: "Half Dome Day Hike", rec_gov_facility_id: "234652", permit_type: "division", booking_url: "https://www.recreation.gov/permits/234652" }, - { permit_id: "rec_gov_233358_overnight", name: "Enchantments", rec_gov_facility_id: "233273", permit_type: "division", booking_url: "https://www.recreation.gov/permits/233273" }, - { permit_id: "rec_gov_4675310_spring", name: "Angels Landing Spring (Mar 1–May 31)", rec_gov_facility_id: "4675310", permit_type: "division", booking_url: "https://www.recreation.gov/permits/4675310" }, - { permit_id: "rec_gov_4675324_summer", name: "Angels Landing Summer (Jun 1–Aug 31)", rec_gov_facility_id: "4675324", permit_type: "division", booking_url: "https://www.recreation.gov/permits/4675324" }, - { permit_id: "rec_gov_4675325_fall", name: "Angels Landing Fall (Sep 1–Nov 30)", rec_gov_facility_id: "4675325", permit_type: "division", booking_url: "https://www.recreation.gov/permits/4675325" }, - { permit_id: "rec_gov_4675326_winter", name: "Angels Landing Winter (Dec 1–Feb 28)", rec_gov_facility_id: "4675326", permit_type: "division", booking_url: "https://www.recreation.gov/permits/4675326" }, - { permit_id: "rec_gov_445859_overnight", name: "John Muir Trail", rec_gov_facility_id: "445859", permit_type: "division", booking_url: "https://www.recreation.gov/permits/445859" }, - { permit_id: "rec_gov_234628_day_use", name: "The Wave (Coyote Buttes North)", rec_gov_facility_id: "274309", permit_type: "division", booking_url: "https://www.recreation.gov/permits/274309" }, - { permit_id: "rec_gov_445858_overnight", name: "Grand Canyon Rim-to-Rim Overnight", rec_gov_facility_id: "445858", permit_type: "division", booking_url: "https://www.recreation.gov/permits/445858" } -]; - -const jsonHeaders = { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Cache-Control": "public, max-age=3600" -}; - -const phoneRegex = /^\+[1-9]\d{7,14}$/; -const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; - -const firstOfMonth = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); -const lastOfMonth = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)); -const ymd = (d) => d.toISOString().slice(0, 10); - -// Division permits (e.g. Half Dome): payload.availability.{division}.date_availability.{date}.remaining -const extractDivisionDates = (resp) => { - const divisionMap = resp?.payload?.availability || {}; - const out = new Set(); - for (const division of Object.values(divisionMap)) { - const dateAvail = division?.date_availability; - if (!dateAvail || typeof dateAvail !== "object") continue; - for (const [dateStr, slot] of Object.entries(dateAvail)) { - if (slot && typeof slot.remaining === "number" && slot.remaining > 0) { - out.add(dateStr.slice(0, 10)); - } - } - } - return Array.from(out).sort(); -}; - -// Inyo permits (e.g. Whitney): availabilityv2 payload shape not yet observed populated. -// Defensive recursive walk: collect any date-keyed node that exposes remaining>0 or an Available status. -const extractInyoDates = (resp) => { - const payload = resp?.payload; - const out = new Set(); - if (!payload || typeof payload !== "object") return []; - const dateRe = /(\d{4}-\d{2}-\d{2})/; - const walk = (node, inheritedDate) => { - if (!node || typeof node !== "object") return; - for (const [key, val] of Object.entries(node)) { - const m = typeof key === "string" ? key.match(dateRe) : null; - const dateForChild = m ? m[1] : inheritedDate; - if (val && typeof val === "object") { - const remaining = - typeof val.remaining === "number" ? val.remaining : - typeof val.remaining_count === "number" ? val.remaining_count : - typeof val.total_remaining === "number" ? val.total_remaining : undefined; - const isAvail = val.is_available === true || val.status === "Available"; - if (dateForChild && ((typeof remaining === "number" && remaining > 0) || isAvail)) { - out.add(dateForChild); - } - walk(val, dateForChild); - } - } +import { publicPermitCatalog } from "./catalog.js"; +import { + activePermitIds, + cancelTrackerByToken, + createTracker, + deliverNotification, + getTrackerByToken, + healthSnapshot, + pollPermit, + startPhoneVerification, + verifyTrackerPhone, + validateTrackerInput +} from "./service.js"; + +const JSON_TYPE = "application/json; charset=utf-8"; + +const allowedOrigins = (env) => new Set( + (env.ALLOWED_ORIGINS || "https://trailgenic.com,https://www.trailgenic.com") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean) +); + +const corsHeaders = (request, env) => { + const origin = request.headers.get("Origin"); + if (!origin || !allowedOrigins(env).has(origin)) return { Vary: "Origin" }; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Authorization,Content-Type", + Vary: "Origin" }; - walk(payload, null); - return Array.from(out).sort(); }; -const getPermitStateKey = (permitId) => `state:${permitId}`; -const getSubscriptionKey = (phone) => `sub:${phone}`; - -const maskPhone = (phone) => { - if (!phone || !phoneRegex.test(phone)) { - return "redacted"; +const json = (request, env, body, status = 200) => new Response(JSON.stringify(body, null, 2), { + status, + headers: { + "Content-Type": JSON_TYPE, + "Cache-Control": "no-store", + ...corsHeaders(request, env) } - - return `${phone.slice(0, 2)}******${phone.slice(-4)}`; -}; +}); const readJson = async (request) => { - try { return await request.json(); } catch { return null; } + try { return await request.json(); } + catch { return null; } }; -const respond = (payload, status = 200, headers = jsonHeaders) => - new Response(JSON.stringify(payload, null, 2), { status, headers }); +const bearerToken = (request) => { + const value = request.headers.get("Authorization") || ""; + return value.startsWith("Bearer ") ? value.slice(7).trim() : null; +}; -const sendTwilioSMS = async (env, to, body) => { - const accountSid = env.TWILIO_ACCOUNT_SID; - const authToken = env.TWILIO_AUTH_TOKEN; - const fromNumber = env.TWILIO_FROM_NUMBER; - if (!accountSid || !authToken || !fromNumber) { - console.log("Twilio credentials missing; skipping SMS send."); - return; - } - const endpoint = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`; - const auth = btoa(`${accountSid}:${authToken}`); - const bodyPayload = new URLSearchParams({ To: to, From: fromNumber, Body: body }); - const res = await fetch(endpoint, { +const verifyTurnstile = async (request, env, token) => { + if (env.REQUIRE_TURNSTILE === "false") return true; + if (!env.TURNSTILE_SECRET_KEY || !token) return false; + const form = new FormData(); + form.set("secret", env.TURNSTILE_SECRET_KEY); + form.set("response", token); + const remoteIp = request.headers.get("CF-Connecting-IP"); + if (remoteIp) form.set("remoteip", remoteIp); + const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", - headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/x-www-form-urlencoded" }, - body: bodyPayload + body: form, + signal: AbortSignal.timeout(10_000) }); - if (!res.ok) { - const errText = await res.text(); - console.log(`Twilio send failed for ${maskPhone(to)}: ${res.status} ${errText}`); - } -}; - -const listAllSubscriptions = async (env) => { - const subscriptions = []; - let cursor; - do { - const page = await env.SUBSCRIPTIONS.list({ prefix: "sub:", cursor }); - for (const key of page.keys) { - const raw = await env.SUBSCRIPTIONS.get(key.name); - if (!raw) continue; - try { subscriptions.push(JSON.parse(raw)); } - catch { console.log(`Invalid subscription JSON at key ${key.name}`); } - } - cursor = page.list_complete ? undefined : page.cursor; - } while (cursor); - return subscriptions; + if (!response.ok) return false; + const result = await response.json(); + return result.success === true; }; -const notifySubscribers = async (env, permit, availableDates) => { - const subscriptions = await listAllSubscriptions(env); - const matched = subscriptions.filter( - (sub) => Array.isArray(sub.permit_ids) && sub.permit_ids.includes(permit.permit_id) - ); - const dateStr = availableDates.join(", "); - const smsBody = `TrailGenic Alert: ${permit.name} slot opened — ${dateStr}. Book now: ${permit.booking_url}`; - for (const sub of matched) { - if (!sub.phone || !phoneRegex.test(sub.phone)) continue; - await sendTwilioSMS(env, sub.phone, smsBody); +const handleHttp = async (request, env) => { + const url = new URL(request.url); + const origin = request.headers.get("Origin"); + if (origin && !allowedOrigins(env).has(origin)) { + return json(request, env, { error: "Origin not allowed." }, 403); } -}; -const fetchPermitAvailability = async (permit, monthDate) => { - const type = permit.permit_type || "division"; - let endpoint; - if (type === "inyo") { - const start = ymd(firstOfMonth(monthDate)); - const end = ymd(lastOfMonth(monthDate)); - endpoint = `https://www.recreation.gov/api/permitinyo/${permit.rec_gov_facility_id}/availabilityv2?start_date=${start}&end_date=${end}&commercial_acct=false`; - } else { - const startISO = firstOfMonth(monthDate).toISOString(); - endpoint = `https://www.recreation.gov/api/permits/${permit.rec_gov_facility_id}/availability/month?start_date=${encodeURIComponent(startISO)}`; - } - const res = await fetch(endpoint, { headers: { "Accept": "application/json", "User-Agent": BROWSER_UA } }); - if (!res.ok) { - throw new Error(`Recreation.gov returned ${res.status} for ${permit.rec_gov_facility_id} (${type})`); + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders(request, env) }); } - return await res.json(); -}; -const extractDates = (resp, permit) => - (permit.permit_type === "inyo") ? extractInyoDates(resp) : extractDivisionDates(resp); - -const pollPermit = async (env, permit, now = new Date()) => { - const thisMonth = firstOfMonth(now); - const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); - - let currentPayload, nextPayload; - try { - currentPayload = await fetchPermitAvailability(permit, thisMonth); - nextPayload = await fetchPermitAvailability(permit, nextMonth); - } catch (error) { - console.log(`Failed availability check for ${permit.permit_id}: ${error.message}`); - return { permit_id: permit.permit_id, status: "error", error: error.message }; + if (request.method === "GET" && url.pathname === "/health") { + try { + const health = await healthSnapshot(env); + return json(request, env, { entity: "TrailGenic", service: "permit-cancellation-alerts", ...health }, health.status === "healthy" ? 200 : 503); + } catch (error) { + return json(request, env, { + entity: "TrailGenic", + service: "permit-cancellation-alerts", + status: "unhealthy", + database_status: "unavailable", + checked_at: new Date().toISOString(), + error: error.message + }, 503); + } } - // Log raw Inyo payloads when non-empty so we can confirm the populated shape from logs. - if (permit.permit_type === "inyo") { - const cp = JSON.stringify(currentPayload?.payload || {}); - if (cp.length > 2) console.log(`Inyo raw ${permit.permit_id} (current): ${cp.slice(0, 800)}`); - const np = JSON.stringify(nextPayload?.payload || {}); - if (np.length > 2) console.log(`Inyo raw ${permit.permit_id} (next): ${np.slice(0, 800)}`); + if (request.method === "GET" && url.pathname === "/permits") { + return json(request, env, { permits: publicPermitCatalog() }); } - const todayStr = new Date().toISOString().slice(0, 10); - const combinedDates = Array.from(new Set([ - ...extractDates(currentPayload, permit), - ...extractDates(nextPayload, permit) - ])).filter((d) => d >= todayStr).sort(); + if (request.method === "POST" && url.pathname === "/trackers") { + const body = await readJson(request); + if (!await verifyTurnstile(request, env, body?.turnstile_token)) { + return json(request, env, { error: "Human verification failed." }, 403); + } + const validation = validateTrackerInput(body); + if (!validation.ok) return json(request, env, { error: validation.error }, 400); + const tracker = await createTracker(env, validation.value); + try { + await startPhoneVerification(env, validation.value.phone); + } catch (error) { + await cancelTrackerByToken(env, tracker.manage_token); + throw error; + } + return json(request, env, { + ok: true, + tracker, + management: { + method: "Authorization: Bearer ", + endpoint: `${url.origin}/trackers/manage` + } + }, 201); + } - const stateKey = getPermitStateKey(permit.permit_id); - const previousRaw = await env.PERMIT_STATE.get(stateKey); - const isFirstRun = !previousRaw; - const previousDates = previousRaw ? (JSON.parse(previousRaw).dates || []) : []; - const previousDateSet = new Set(previousDates); - const newDates = combinedDates.filter((d) => !previousDateSet.has(d)); + if (request.method === "POST" && url.pathname === "/trackers/verify") { + const token = bearerToken(request); + if (!token) return json(request, env, { error: "A tracker management token is required." }, 401); + const body = await readJson(request); + const verification = await verifyTrackerPhone(env, token, body?.code); + return verification.approved + ? json(request, env, { ok: true, status: "active" }) + : json(request, env, { error: "Verification code was not approved.", reason: verification.reason }, 400); + } - if (!isFirstRun && newDates.length > 0) { - console.log(`New availability for ${permit.permit_id}: ${newDates.join(", ")}`); - await notifySubscribers(env, permit, newDates); + if (url.pathname === "/trackers/manage" && (request.method === "GET" || request.method === "DELETE")) { + const token = bearerToken(request); + if (!token) return json(request, env, { error: "A tracker management token is required." }, 401); + if (request.method === "GET") { + const tracker = await getTrackerByToken(env, token); + return tracker ? json(request, env, { tracker }) : json(request, env, { error: "Tracker not found." }, 404); + } + const cancelled = await cancelTrackerByToken(env, token); + return cancelled ? json(request, env, { ok: true }) : json(request, env, { error: "Active tracker not found." }, 404); } - await env.PERMIT_STATE.put(stateKey, JSON.stringify({ - permit_id: permit.permit_id, - permit_type: permit.permit_type || "division", - dates: combinedDates, - updated_at: new Date().toISOString() - })); + return json(request, env, { error: "Not found." }, 404); +}; - return { permit_id: permit.permit_id, checked_dates: combinedDates.length, new_dates: newDates, status: "ok" }; +const handlePollMessage = async (message, env) => { + const permitId = message.body?.permit_id; + if (message.body?.type !== "poll" || typeof permitId !== "string") { + throw new Error("Invalid poll message"); + } + return pollPermit(env, permitId); }; -const runPollCycle = async (env) => { - const results = []; - for (const permit of PERMITS) { - results.push(await pollPermit(env, permit)); +const handleNotificationMessage = async (message, env) => { + const notificationId = message.body?.notification_id; + if (message.body?.type !== "notification" || typeof notificationId !== "string") { + throw new Error("Invalid notification message"); } - return results; + return deliverNotification(env, notificationId); }; export default { - async scheduled(_event, env, _ctx) { - const results = await runPollCycle(env); - console.log("Poll cycle complete:", JSON.stringify(results)); - }, - async fetch(request, env) { - const url = new URL(request.url); - - if (request.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type" - } - }); - } - - if (url.pathname === "/health") { - return respond({ - entity: "TrailGenic", - status: "healthy", - poller_status: "operational", - sms_status: "operational", - kv_status: "operational", - region: "global", - infrastructure: { platform: "Cloudflare Workers", protocol: "WebMCP", agent_ready: true }, - last_checked: new Date().toISOString() - }, 200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" }); - } - - if (url.pathname === "/permits/subscribe" && request.method === "POST") { - // TODO: add rate limiting. - // TODO: add phone verification / opt-in confirmation. - // TODO: add abuse prevention before public launch. - const body = await readJson(request); - if (!body || !body.phone || !phoneRegex.test(body.phone)) { - return respond({ error: "Invalid phone. Use E.164 format (e.g., +15551234567)." }, 400); - } - if (!Array.isArray(body.permit_ids) || body.permit_ids.length === 0) { - return respond({ error: "permit_ids must be a non-empty array." }, 400); - } - const validPermitIds = new Set(PERMITS.map((p) => p.permit_id)); - const invalid = body.permit_ids.filter((id) => !validPermitIds.has(id)); - if (invalid.length > 0) { - return respond({ error: "Invalid permit_ids provided.", invalid }, 400); - } - const subscription = { - phone: body.phone, - permit_ids: body.permit_ids, - date_range: body.date_range || null, - updated_at: new Date().toISOString() - }; - await env.SUBSCRIPTIONS.put(getSubscriptionKey(body.phone), JSON.stringify(subscription)); - return respond({ - ok: true, - subscription: { - ...subscription, - phone: undefined, - phone_masked: maskPhone(body.phone) - } - }, 200); + try { + return await handleHttp(request, env); + } catch (error) { + console.error("Permit alert HTTP error", { name: error.name, message: error.message }); + return json(request, env, { error: "Internal service error." }, 500); } + }, - if (request.method === "GET" && url.pathname.startsWith("/permits/subscriptions/")) { - return respond({ error: "Not found" }, 404); - } + async scheduled(_controller, env, ctx) { + ctx.waitUntil((async () => { + const permitIds = await activePermitIds(env); + if (permitIds.length === 0) return; + await env.POLL_QUEUE.sendBatch(permitIds.map((permitId) => ({ + body: { type: "poll", permit_id: permitId } + }))); + console.log("Scheduled permit polls", { permit_count: permitIds.length }); + })()); + }, - if (url.pathname === "/permits/unsubscribe" && request.method === "DELETE") { - // TODO: add rate limiting. - // TODO: add phone verification / opt-in confirmation. - // TODO: add abuse prevention before public launch. - const body = await readJson(request); - if (!body || !body.phone || !phoneRegex.test(body.phone)) { - return respond({ error: "Invalid phone. Use E.164 format (e.g., +15551234567)." }, 400); + async queue(batch, env) { + const isNotificationQueue = batch.queue === "trailgenic-permit-notifications"; + for (const message of batch.messages) { + try { + if (isNotificationQueue) await handleNotificationMessage(message, env); + else await handlePollMessage(message, env); + message.ack(); + } catch (error) { + console.error("Permit queue message failed", { + queue: batch.queue, + message_id: message.id, + name: error.name, + code: error.code || null, + message: error.message + }); + message.retry({ delaySeconds: 60 }); } - await env.SUBSCRIPTIONS.delete(getSubscriptionKey(body.phone)); - return respond({ ok: true, phone_masked: maskPhone(body.phone) }, 200); - } - - if (request.method === "GET" && url.pathname.startsWith("/permits/state/")) { - const permitId = decodeURIComponent(url.pathname.replace("/permits/state/", "")); - const data = await env.PERMIT_STATE.get(getPermitStateKey(permitId)); - if (!data) return respond({ permit_id: permitId, dates: [], status: "empty" }, 200); - return respond(JSON.parse(data), 200); } - - return respond({ error: "Not found" }, 404); } }; + +export { handleHttp }; diff --git a/workers/permit-poller/wrangler.jsonc b/workers/permit-poller/wrangler.jsonc index af3bde4..529fe2f 100644 --- a/workers/permit-poller/wrangler.jsonc +++ b/workers/permit-poller/wrangler.jsonc @@ -2,18 +2,52 @@ "name": "trailgenic-permit-poller", "main": "worker.js", "compatibility_date": "2026-02-18", + "observability": { + "enabled": true + }, "triggers": { - "crons": ["*/10 * * * *"] + "crons": ["*/5 * * * *"] }, - "kv_namespaces": [ - { - "binding": "PERMIT_STATE", - "id": "3ca75450ec9545d98c9e57e67a557c0f" - }, + "d1_databases": [ { - "binding": "SUBSCRIPTIONS", - "id": "93c8c2e78b504bd8a728f1b4b01006e1" + "binding": "DB", + "database_name": "trailgenic-permit-poller", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" } ], - "vars": {} + "queues": { + "producers": [ + { + "binding": "POLL_QUEUE", + "queue": "trailgenic-permit-polls" + }, + { + "binding": "NOTIFICATION_QUEUE", + "queue": "trailgenic-permit-notifications" + } + ], + "consumers": [ + { + "queue": "trailgenic-permit-polls", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "trailgenic-permit-polls-dlq", + "max_concurrency": 1 + }, + { + "queue": "trailgenic-permit-notifications", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "trailgenic-permit-notifications-dlq", + "max_concurrency": 5 + } + ] + }, + "vars": { + "ALLOWED_ORIGINS": "https://trailgenic.com,https://www.trailgenic.com", + "REQUIRE_TURNSTILE": "true" + } }