From 01cc9c7f05b0c36c8a6496c436ad1bcbd2ace07c Mon Sep 17 00:00:00 2001 From: Trailgenic Date: Tue, 1 Sep 2026 21:09:47 -0700 Subject: [PATCH] Keep permit poller healthy between seasons --- tests/permit-poller.test.mjs | 33 +++++++++++++++- tests/permit-poller.worker.vitest.mjs | 54 +++++++++++++++++++++++++++ workers/permit-poller/README.md | 10 +++++ workers/permit-poller/catalog.js | 15 +++++++- workers/permit-poller/public-site.js | 2 +- workers/permit-poller/service.js | 43 +++++++++++++++++++-- workers/permit-poller/worker.js | 3 ++ 7 files changed, 153 insertions(+), 7 deletions(-) diff --git a/tests/permit-poller.test.mjs b/tests/permit-poller.test.mjs index 6d29930..4a7ca7b 100644 --- a/tests/permit-poller.test.mjs +++ b/tests/permit-poller.test.mjs @@ -45,6 +45,20 @@ test("missing Inyo dates remain unknown rather than becoming false zeroes", asyn assert.deepEqual(records, []); }); +test("Lost Coast parser reads the verified King Range division", () => { + const records = parseInyoAvailability({ + payload: { + "2026-09-10": { + "445864001": { + quota_usage_by_member_daily: { total: 57, remaining: 3 }, + is_walkup: false + } + } + } + }, getPermitProduct("rec_gov_445864_overnight"), ["2026-09-10"]); + assert.deepEqual(records, [{ date: "2026-09-10", remaining: 3 }]); +}); + test("Half Dome parser reads only the Daily division", async () => { const payload = await fixture("division-availability.json"); const records = parseDivisionMonthAvailability( @@ -93,14 +107,25 @@ test("tracker validation normalizes dates and enforces consent and product limit 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); + assert.equal(validateTrackerInput({ + ...valid.value, + permit_id: "rec_gov_445864_overnight", + party_size: 4, + consent: true + }, 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" + "rec_gov_234652_daily", + "rec_gov_445864_overnight" ]); + assert.match( + publicPermitCatalog().find((product) => product.id === "rec_gov_445864_overnight").safety_notice, + /Check tides/ + ); }); test("adapter URLs use the correct Recreation.gov API family", () => { @@ -112,6 +137,10 @@ test("adapter URLs use the correct Recreation.gov API family", () => { buildAvailabilityUrl(getPermitProduct("rec_gov_234652_daily"), "2026-07-01"), /api\/permits\/234652\/availability\/month/ ); + assert.match( + buildAvailabilityUrl(getPermitProduct("rec_gov_445864_overnight"), "2026-09-01"), + /permitinyo\/445864\/availabilityv2\?start_date=2026-09-01&end_date=2026-09-30/ + ); }); test("management tokens hash deterministically and phone responses stay masked", async () => { @@ -151,4 +180,6 @@ test("public signup and compliance pages are served by the alerts Worker", async assert.equal(response.status, 200); assert.equal(response.headers.get("Content-Type"), "text/html; charset=utf-8"); } + const help = await handleHttp(new Request("https://alerts.trailgenic.com/help"), env); + assert.match(await help.text(), /START<\/strong> or UNSTOP/); }); diff --git a/tests/permit-poller.worker.vitest.mjs b/tests/permit-poller.worker.vitest.mjs index 39893f4..c067ef5 100644 --- a/tests/permit-poller.worker.vitest.mjs +++ b/tests/permit-poller.worker.vitest.mjs @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { env } from "cloudflare:test"; import migration from "../workers/permit-poller/migrations/0001_initial.sql?raw"; import { + activePermitIds, cancelTrackerByToken, createTracker, deliverNotification, getTrackerByToken, + healthSnapshot, + pauseExpiredTrackers, pollPermit, verifyTrackerPhone } from "../workers/permit-poller/service.js"; @@ -181,4 +184,55 @@ describe("permit cancellation service with D1", () => { expect(duplicate).toEqual({ status: "skipped" }); expect(sends).toBe(1); }); + + it("treats trackers with only expired dates as healthy idle and pauses them", async () => { + const created = await createTracker( + serviceEnv, + trackerInput(1), + new Date("2026-07-19T12:00:00Z"), + { status: "active" } + ); + const now = new Date("2026-07-21T12:00:00Z"); + + expect(await activePermitIds(serviceEnv, now)).toEqual([]); + expect(await healthSnapshot(serviceEnv, now)).toMatchObject({ + status: "healthy", + poller_status: "idle", + active_trackers: 0 + }); + expect(await pauseExpiredTrackers(serviceEnv, now)).toBe(1); + expect((await getTrackerByToken(serviceEnv, created.manage_token)).status).toBe("paused"); + }); + + it("pauses opted-out recipients without retrying Twilio error 21610", async () => { + const created = 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; + const result = await deliverNotification({ + ...serviceEnv, + TWILIO_ACCOUNT_SID: "AC_test", + TWILIO_AUTH_TOKEN: "secret", + TWILIO_FROM_NUMBER: "+19515550999" + }, notificationId, { + now: new Date("2026-07-19T12:06:00Z"), + fetchImpl: async () => new Response(JSON.stringify({ + code: 21610, + message: "Attempt to send to unsubscribed recipient" + }), { status: 400, headers: { "Content-Type": "application/json" } }) + }); + + expect(result).toEqual({ status: "cancelled", reason: "recipient_opted_out" }); + expect((await getTrackerByToken(serviceEnv, created.manage_token)).status).toBe("paused"); + expect(await env.DB.prepare( + "SELECT status FROM notifications WHERE id = ?" + ).bind(notificationId).first()).toMatchObject({ status: "cancelled" }); + }); }); diff --git a/workers/permit-poller/README.md b/workers/permit-poller/README.md index 6865c38..2660638 100644 --- a/workers/permit-poller/README.md +++ b/workers/permit-poller/README.md @@ -8,6 +8,7 @@ permit inventory satisfies a tracker's exact permit, selected date, and party si - 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` +- King Range Wilderness (Lost Coast) — Recreation.gov facility `445864`, division `445864001` Products without a verified bookable-inventory response are intentionally excluded. Lottery calendars and release-deadline reminders are out of scope. @@ -24,6 +25,15 @@ Lottery calendars and release-deadline reminders are out of scope. 7. An idempotent notification outbox feeds the notification queue. 8. The notification consumer sends Twilio SMS with retries and a dead-letter queue. +Trackers with no current or future dates are automatically paused. They do not make +the health endpoint degraded: a deployed poller with no actionable trackers reports +`healthy` / `idle`. Twilio error `21610` is treated as a recipient opt-out, so the +recipient's active trackers are paused and the notification is not retried. + +Lost Coast alerts include a safety reminder because Recreation.gov inventory does not +mean the coastal route is passable. Hikers must check official tides, marine forecasts, +and weather before booking. + Missing dates and malformed payloads are treated as unknown. They never overwrite the last good snapshot or create availability alerts. diff --git a/workers/permit-poller/catalog.js b/workers/permit-poller/catalog.js index dce4a82..690e6af 100644 --- a/workers/permit-poller/catalog.js +++ b/workers/permit-poller/catalog.js @@ -31,6 +31,18 @@ export const PERMIT_PRODUCTS = Object.freeze([ division_name: "Half Dome Cables (Daily)", booking_url: "https://www.recreation.gov/permits/234652", max_party_size: 6 + }), + Object.freeze({ + id: "rec_gov_445864_overnight", + name: "King Range Wilderness (Lost Coast)", + provider: "recreation.gov", + adapter: "recreation_inyo", + facility_id: "445864", + division_id: "445864001", + division_name: "King Range Wilderness", + booking_url: "https://www.recreation.gov/permits/445864", + max_party_size: 3, + safety_notice: "Lost Coast dates can be impassable. Check tides, marine forecast, and weather before booking." }) ]); @@ -43,5 +55,6 @@ export const publicPermitCatalog = () => PERMIT_PRODUCTS.map((product) => ({ name: product.name, division_name: product.division_name, booking_url: product.booking_url, - max_party_size: product.max_party_size + max_party_size: product.max_party_size, + safety_notice: product.safety_notice || null })); diff --git a/workers/permit-poller/public-site.js b/workers/permit-poller/public-site.js index d8fc01d..84577a9 100644 --- a/workers/permit-poller/public-site.js +++ b/workers/permit-poller/public-site.js @@ -58,6 +58,6 @@ export const publicPage = (pathname, env) => { } if (pathname === "/privacy") return shell({ title: "Privacy Policy", body: `

Privacy Policy

Effective July 19, 2026

Information we collect

When you request a permit alert, TrailGenic collects your mobile number, selected permit product, requested date or dates, party size, phone-verification status, and basic service records needed to operate the alert.

How we use it

We use this information to verify your phone, monitor the dates you selected, send availability alerts, prevent abuse, and operate and improve the service. We do not use your number to send unrelated marketing messages through this permit-alert service.

Service providers

We use Cloudflare to host and protect the service and Twilio to verify phone numbers and deliver SMS. These providers process information only to provide their services to TrailGenic.

Retention and choices

You can opt out of alert messages by replying STOP. We retain records as reasonably necessary to operate, secure, and comply with our obligations for the service. To ask about your information, use the contact information on trailgenic.com.

Updates

We may update this policy as the service develops. The effective date above reflects the latest version.

` }); if (pathname === "/terms") return shell({ title: "Terms & Conditions", body: `

Permit Alert Terms & Conditions

Effective July 19, 2026

What the service does

TrailGenic monitors selected permit inventory and sends SMS alerts when availability appears to meet your requested party size. TrailGenic is independent from Recreation.gov and public-land agencies. We do not make reservations and do not guarantee availability, timing, or successful booking.

SMS terms

By checking the consent box and verifying your phone, you agree to receive recurring automated TrailGenic Permit Alert SMS messages about the permit selections you submit. Message frequency varies based on availability. Message and data rates may apply. Consent is not a condition of purchase.

Reply STOP to any alert to opt out. Reply HELP for help or visit alerts.trailgenic.com/help. Carriers are not liable for delayed or undelivered messages.

Your responsibilities

Submit only your own phone number, keep your permit preferences accurate, and review availability promptly through the official booking provider. Do not use the service to interfere with booking systems or circumvent agency rules.

Changes and contact

We may change or discontinue the service. Questions about these terms can be sent through the contact information on trailgenic.com.

` }); - if (pathname === "/help") return shell({ title: "Help", body: `

Permit Alert Help

Need help with a TrailGenic permit alert?

TrailGenic cannot change or book a permit for you. Use the official booking link in your alert to review and reserve available inventory.

` }); + if (pathname === "/help") return shell({ title: "Help", body: `

Permit Alert Help

Need help with a TrailGenic permit alert?

TrailGenic cannot change or book a permit for you. Use the official booking link in your alert to review and reserve available inventory.

` }); return null; }; diff --git a/workers/permit-poller/service.js b/workers/permit-poller/service.js index 7d2a47b..6509276 100644 --- a/workers/permit-poller/service.js +++ b/workers/permit-poller/service.js @@ -78,7 +78,8 @@ 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 safetyNotice = product.safety_notice ? ` Safety: ${product.safety_notice}` : ""; + return `TrailGenic Alert: ${remaining} spot${remaining === 1 ? "" : "s"} available for ${product.name} on ${displayDate}. Your party: ${partySize}. Book now: ${product.booking_url}${safetyNotice} Reply STOP to opt out.`; }; const insertNotification = async (env, { eventId, trackerId, now = new Date() }) => { @@ -232,6 +233,19 @@ export const activePermitIds = async (env, now = new Date()) => { return rows.map((row) => row.permit_id).filter((id) => getPermitProduct(id)); }; +export const pauseExpiredTrackers = async (env, now = new Date()) => { + const result = await env.DB.prepare( + `UPDATE trackers + SET status = 'paused', updated_at = ? + WHERE status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM tracker_dates d + WHERE d.tracker_id = trackers.id AND d.date >= ? + )` + ).bind(isoNow(now), dateOnly(now)).run(); + return changedRows(result); +}; + const activeDatesForPermit = async (env, permitId, now = new Date()) => { const rows = resultRows(await env.DB.prepare( `SELECT DISTINCT d.date @@ -366,7 +380,13 @@ const sendTwilioSms = async (env, to, body, fetchImpl = fetch) => { 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"}`); + if (!response.ok) { + const providerCode = Number.parseInt(data.code, 10); + const error = new Error(`Twilio returned ${response.status}: ${data.message || "send failed"}`); + error.code = providerCode === 21610 ? "recipient_opted_out" : `twilio_${Number.isInteger(providerCode) ? providerCode : response.status}`; + error.retryable = providerCode !== 21610; + throw error; + } return data.sid || null; }; @@ -404,6 +424,19 @@ export const deliverNotification = async (env, notificationId, { now = new Date( ).bind(providerId, isoNow(now), isoNow(now), notificationId).run(); return { status: "sent", provider_message_id: providerId }; } catch (error) { + if (error.code === "recipient_opted_out") { + await env.DB.batch([ + env.DB.prepare( + `UPDATE notifications SET status = 'cancelled', last_error = ?, updated_at = ? + WHERE id = ?` + ).bind(error.message.slice(0, 500), isoNow(now), notificationId), + env.DB.prepare( + `UPDATE trackers SET status = 'paused', updated_at = ? + WHERE phone_e164 = ? AND status = 'active'` + ).bind(isoNow(now), row.phone_e164) + ]); + return { status: "cancelled", reason: "recipient_opted_out" }; + } 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(); @@ -413,8 +446,10 @@ export const deliverNotification = async (env, notificationId, { now = new Date( export const healthSnapshot = async (env, now = new Date()) => { const active = await env.DB.prepare( - "SELECT COUNT(*) AS count FROM trackers WHERE status = 'active'" - ).first(); + `SELECT COUNT(DISTINCT t.id) AS count + FROM trackers t JOIN tracker_dates d ON d.tracker_id = t.id + WHERE t.status = 'active' AND d.date >= ?` + ).bind(dateOnly(now)).first(); const lastSuccess = await env.DB.prepare( "SELECT MAX(completed_at) AS completed_at FROM poll_runs WHERE status = 'succeeded'" ).first(); diff --git a/workers/permit-poller/worker.js b/workers/permit-poller/worker.js index 7613620..f0d1e03 100644 --- a/workers/permit-poller/worker.js +++ b/workers/permit-poller/worker.js @@ -6,6 +6,7 @@ import { deliverNotification, getTrackerByToken, healthSnapshot, + pauseExpiredTrackers, pollPermit, startPhoneVerification, verifyTrackerPhone, @@ -191,6 +192,8 @@ export default { async scheduled(_controller, env, ctx) { ctx.waitUntil((async () => { + const paused = await pauseExpiredTrackers(env); + if (paused > 0) console.log("Paused expired permit trackers", { tracker_count: paused }); const permitIds = await activePermitIds(env); if (permitIds.length === 0) return; await env.POLL_QUEUE.sendBatch(permitIds.map((permitId) => ({