Skip to content

ci: automated deploy to GCP VM (WIF + IAP) - #453

Closed
timwangmusic wants to merge 99 commits into
timwangmusic:mainfrom
offerbee-ai:ci/deploy-workflow
Closed

ci: automated deploy to GCP VM (WIF + IAP)#453
timwangmusic wants to merge 99 commits into
timwangmusic:mainfrom
offerbee-ai:ci/deploy-workflow

Conversation

@timwangmusic

Copy link
Copy Markdown
Owner

What

Push-to-main auto-deploy for the planner backend, now that the GCE VM infra exists (PR #3).

  • deploy.yml — on push to main: gate on go.yml, then WIF-auth (no JSON keys), build + push the image to Artifact Registry (us-west1-docker.pkg.dev/offerbee-planner/planner/backend), scp the deploy files to planner-vm, and run deploy.sh over an IAP tunnel. workflow_dispatch with a tag input redeploys an existing image (rollback) without building.
  • go.yml — gains workflow_call so the deploy job reuses lint + build + test as its gate.

Security

Infra already in place (from PR #3 bootstrap, run against offerbee-planner, us-west1)

Repo variables set: GCP_PROJECT, GCP_WIF_PROVIDER, GCP_DEPLOYER_SA, GCP_ZONE, GCP_VM, GCP_IMAGE. Deployer SA has roles/iam.serviceAccountUser on the runtime SA (required for gcloud compute ssh).

After merge

The deploy job runs on the merge commit: builds that SHA, deploys to planner-vm, and https://geo.offerbee.ai/healthz should stay 200 serving the new image. First run is the end-to-end proof of the pipeline.

🤖 Generated with Claude Code

https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8

tim-eternos and others added 30 commits December 27, 2025 22:37
Replace traditional card list layout with modern vertical swiper interface
for better UX and natural chronological flow of travel plans.

Key changes:
- Integrate Swiper.js v11 for smooth vertical scrolling
- Create favorites slide as first card with gradient background
- Convert travel plans to individual swipeable cards
- Add mouse wheel, keyboard, and touch gesture support
- Implement consistent card widths (500px max) for uniform appearance
- Add navigation arrows and pagination dots
- Support dark mode throughout the new design

Technical improvements:
- Replace "load more" pagination with swiper navigation
- Refactor card rendering into modular slide creation functions
- Add cache-busting query params for JS files
- Clean up button layout with flexbox
- Add proper spacing and min-height constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](lodash/lodash@4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Use FRONTEND_URL env var for OAuth callback and email verification
redirects instead of hardcoded /v1 paths. This allows the frontend
to be hosted separately (e.g., on Vercel) from the Go backend.

Also changes redirect status codes from 308/301 (permanent) to 302
(temporary) to prevent browsers from caching stale redirect targets.
…redirects

fix: use configurable frontend URL with temporary redirects
When the frontend is hosted on a different domain than the backend,
the JWT cookie set during OAuth callback is not accessible to the
frontend. This change passes the JWT token as a query parameter in
the redirect URL so the frontend can set the cookie on its own domain.
The Google OAuth callback only authenticated existing users but never
created accounts for new ones. When a user signed in with Google for the
first time without a pre-existing account, Authenticate() failed to find
the user by email and the callback silently redirected to /login.

Now, when authentication fails for an OAuth user, the callback auto-
registers them with a username derived from their email prefix plus a
short UUID suffix to avoid collisions, then retries authentication.
…uto-register

fix(oauth): auto-register new users on Google SSO login
…r handling

1. Fix dummy IsOpenBetween to actually validate against place opening hours
   using ParseTimeInterval, replacing the stub that only checked duration fit
2. Remove deprecated ScoreOld scoring algorithm, standardizing on Score with
   constant distance normalization across all code paths
3. Surface errors from concurrent place detail fetches instead of silently
   returning zero-value entries to the client

https://claude.ai/code/session_0143hMakqyrD11uhtxaVZ3Fe
Truncate each slot's candidates to the top 30 (by PlaceScore) to reduce
the combinatorial search space, and group places into spatial clusters
so the solver naturally produces geographically coherent plans.

https://claude.ai/code/session_0143hMakqyrD11uhtxaVZ3Fe
Recompute cluster centers as the mean of their members after initial
greedy assignment, removing bias from score-based sort order. Add a 3km
floor to clusterRadius to prevent over-fragmentation with small search
radii.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lan-generation-H79MZ

Improve plan generation with per-slot truncation and spatial clustering
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](lodash/lodash@4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Nearby search was category-only (Visit/Eatery over 6 Google place types),
so brand-level queries like "Dunkin' near a coordinate" were not expressible.
This adds:

- Keyword (brand) support in PlaceSearchRequest: passes Keyword to Google
  Maps NearbySearch with the place type unset, and caches results under
  brand-scoped Redis geo keys (placeIDs:brand:{brandKey}) so cached brand
  searches stay separate from category buckets
- StrictNameMatch filtering: drops keyword results whose names don't match
  the brand (Google matches keywords against reviews and other content),
  applied before results reach the brand cache
- Brand-aware last-search-time freshness tracking per city
- POST /v1/nearby-places: accepts {brands[], location, radius, limit},
  reverse-geocodes once, fans out per-brand searches concurrently, and
  returns operational places per brand sorted by distance
- processLocation short-circuit when a location already has precise
  coordinates and city-level info, so fan-out callers geocode once

Motivation: OfferBee location-based notifications need to resolve
merchant-brand locations near a user to register geofences.
golangci-lint-action@v3 caps golangci-lint at v1.64.8 (the final v1,
frozen March 2025), which can no longer typecheck the module graph under
current Go toolchains — every third-party import was reported as
'undefined' even though go build/test passed.

- checkout@v6, setup-go@v6 (Node 24, removes deprecation warnings)
- go-version-file: go.mod instead of a floating ^1.23 constraint
- golangci-lint-action@v9 (golangci-lint v2)
- only-new-issues: true so PRs are judged on their own diff, since the
  repo has never been linted with v2 defaults
- checkout before setup-go, required for go-version-file
Introduces ColdStartSearchRadius (8000m) for external maps searches that
populate the cache, separate from MaxSearchRadius (16000m) which remains
the cap on caller-requested radii. Halves the area fetched per Google
Maps spend; queries beyond 5 miles of a cold cache's center are served
from whatever is cached until the next refresh, matching the existing
per-city freshness model.
Place Details is the dominant Google Maps cost: previously every nearby
search result missing opening hours triggered a details call, including
results strict brand matching would later discard.

- PlaceSearchRequest.DetailsLimit: when set, only the N candidates
  nearest the request location get details, with a running budget
  across result pages; zero preserves previous behavior, so the trip
  planning flow (which needs hours for scheduling) is unaffected
- results failing strict brand-name matching never get details
- /v1/nearby-places sets DetailsLimit to the per-brand limit
searchPlaceDetails sized its results slice by len(placeIdMap) but indexed
it by search-result index. The map used to contain every result (nearby
search never returns opening hours), so the sizes coincided; with the
details budget filtering candidates the map became sparse and result
index 8 overran a length-5 slice (panic during /v1/nearby-places).

Each goroutine now writes to its own compact slot while recording the
result index in PlaceDetailsSearchResult.idx. Also guard against nil
res entries, which previously caused a nil dereference whenever a
details API call failed.
- POI.Place.KnownClosedOnDay: true only when cached hours explicitly say
  Closed for that weekday; unknown/default hours are not treated as closed
- POI.WeekdayFromTime maps Go's Sunday=0 convention to POI's Monday=0
- endpoint accepts optional localTime (RFC3339 with the caller's UTC
  offset) so clients in other timezones get the right weekday; defaults
  to server time
…by-places

Add brand keyword search and POST /v1/nearby-places endpoint
The endpoint shipped unauthenticated: the v1 group has no auth middleware
and the handler did no check, yet each request triggers a reverse geocode
plus up to 25 concurrent Google Maps searches on a cold cache — an open
cost/abuse hole for a machine-facing API.

Enforce the existing PAT machinery per the repo's per-handler convention:
UserAuthentication (PAT Bearer header first, JWT cookie fallback) at the
top of getNearbyPlaces, returning 401 with the standard {"error": ...}
shape on failure so API clients read a consistent error field.

Tests cover no-credentials and invalid-token rejection, plus a valid PAT
(minted against miniredis) passing auth and reaching request validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUUQn7nozF8iJERCzu7tWE
…ces-pat-auth

Require authentication on POST /v1/nearby-places
Verification and password-reset emails hard-coded links to
https://www.unwind.dev (the Vercel frontend), which does not expose the
/v1/verify and /v1/reset-password routes. As a result clicking the link
never reached the Go backend handler and the code was never verified.

Point these links at the backend host that actually serves the handlers,
via a new backendBaseURL helper driven by the BACKEND_URL env var
(defaulting to the production Heroku backend, and testing-vp for the
testing environment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMdtsSLHkqYYb8VaQYLPom
tim-eternos and others added 28 commits July 30, 2026 21:40
…-search

feat: place text-search with durable cache insert + unified type classification
… redundant confirm Details, persist closures

Four API-cost fixes plus one freshness fix that costs nothing, from the
2026-08-01 geo cost/freshness audit. No wire-contract changes.

- Reverse-geocode results are now cached in Redis per ~8 km search cell
  (30-day expiry, matching Geocoding caching terms). This was the ONLY
  Google call a warm nearby scan made — processLocation buys it on
  every request — so a warm scan's Google spend drops to zero. The
  RedisClient.ReverseGeocode stub is now real; processLocation and the
  reverse-geocoding endpoint route through the cached path.

- detailed_search_fields drops name and user_ratings_total: no Details
  consumer reads either (both arrive free with every Nearby/Text Search
  result), and user_ratings_total alone pulls every Details call into
  the Atmosphere billing tier. The AddUserRatingsTotal migration passes
  its own field list and is unaffected. Pinned by
  TestDetailedSearchFieldsMask.

- place-search confirm skips its Place Details call when the cached
  record's details are current (same placeDetailsAreCurrent rule the
  nearby path trusts) — re-confirming a place we already hold was a
  full-price max-tier call for data restoreCachedDetails restores
  anyway.

- Stale-photo recovery requests only the photos field instead of the
  full Details mask.

- Cold searches now PERSIST permanently-closed places instead of
  filtering them out before the cache write. The old order discarded
  the closure signal entirely: the stale record kept OPERATIONAL status
  and cache membership forever. The response-side filter is unchanged
  (closures never reach callers); the read-side Operational filter now
  retires them from cache serves at zero extra API cost.

Not included (deliberately): the hoursKnown wire flag (API change),
text-search caching and timeout salvage (larger changes), and the
MapsLastSearchTime dead-field purge (Redis ops, not code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjdGnZM6cUBeg6jWwoQfU3
…es flag

Now that closures are persisted in the cache, the old contract was one
forgotten field away from breaking planners: filtering only happened
when a caller remembered BusinessStatus: POI.Operational, so a
zero-value PlaceSearchRequest would serve permanently-closed places
from both cache reads and cold-search responses.

The zero value is now safe: non-Operational places are always filtered
unless the caller explicitly opts in with IncludeClosedPlaces. All
three production call sites (matcher, both planner handlers) already
requested Operational filtering, so their behavior is unchanged — the
field they set is simply gone. The RemovePlaces migration test opts in:
it verifies raw bucket contents and its fixtures carry no Status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjdGnZM6cUBeg6jWwoQfU3
…free-wins

Cut Google Maps spend: reverse-geocode cache, Details mask trim, confirm skip, closure persistence
GetPlaceTypes(Visit) grows from 4 to 10 Google place types, adding
movie_theater, bowling_alley, zoo, aquarium, stadium and
tourist_attraction. These were already in placeTypeToCategory (they
classify and round-trip to Visit, and could enter the cache via
text-search confirms) but were never fetched by a category scan, so
nearby-by-category results contained none of these venue types.

Cost note: a cold Visit search's per-round fan-out rises from 4 to 10
concurrent Nearby calls. Warm cells are unaffected; cells with a fresh
Visit marker pick up the new types when their marker ages out (14 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjdGnZM6cUBeg6jWwoQfU3
* docs: design for Apple Maps Server API SDK

Apple becomes a selectable primary geo provider with Google as fallback,
motivated by Apple's 25k/day free quota and by directions/ETA being
net-new capability here.

Records two findings that shape the design. Apple exposes no photo,
opening hours, rating, or price level at any tier, so those POI.Place
fields stay Google-only. And Apple's PoiCategory enum collapses all
retail to Store, which is the exact distinction offerbee's bestCard
keys reward selection off — so category specificity is carried in the
free-text q parameter and results are tagged with the requested type
rather than the returned poiCategory.

Documents credential sourcing: only Team ID and Key ID reach the JWT,
the Maps ID is unused by the Server API, and the private key value
accepts raw PEM or base64 so .env loaders that flatten newlines cannot
break the token exchange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k

* feat(applemaps): types, typed errors, and token lifecycle

Steps 0-2 of the Phase 1 plan.

Guards the .p8 first: *.p8 is ignored before any Apple code lands, so
there is no window in which a key could be committed.

types.go covers all 22 documented objects. Fields are pointers only where
a zero value is both meaningful and indistinguishable from absent —
Eta.DistanceMeters (0 metres is a real distance) and Route.HasTolls
(Apple documents true/false/undefined as three states). StepPaths follows
Apple's prose description as a slice of polylines; its machine-readable
schema contradicts that by annotating a flat Location array, and step 8
settles which is right against the live API.

AllPoiCategories has 77 entries, not the 75 the design doc claimed.
Apple's own reference undercounts: a formatting bug swallows Bakery into
the Aquarium line, so counting the list's terms misses it.

TokenSource holds its mutex across the /v1/token exchange rather than
only around the cache read. /v1/token draws on the same 25k/day quota as
every other endpoint, so a 50-goroutine cold start would otherwise spend
50 calls learning one token; a test asserts it spends exactly one.

QuotaError is deliberately not retryable — the quota resets daily, so
retrying inside a request cannot succeed and only burns more of an
already-exhausted budget.

ParsePrivateKey accepts raw PEM or base64-encoded PEM. Raw newlines
survive heroku config:set and Docker --env-file but are flattened by many
.env loaders, which would turn a correct key into an unparseable one at
deploy time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k

* feat(applemaps): client, all 10 endpoints, and live-probe corrections

Completes Phase 1. Steps 3-8 of the plan.

client.go separates two retry behaviours deliberately. A 401 is retried
exactly once after invalidating the token, because the likely cause is a
token revoked before its stated expiry and one fresh exchange either
fixes it or proves the credentials wrong; a test asserts the retry
actually re-exchanges rather than resending the rejected token. A 5xx
retries with backoff. A 429 retries with neither, since a daily quota
cannot be waited out inside one request.

SearchAll owns pagination and nothing else -- no radius filter, no
ranking -- so the package stays free of POI concepts and liftable into
its own module. package_test.go enforces that mechanically by parsing
every file's imports, because an accidental POI import compiles fine and
would only surface later when someone tries to extract the package.

ResolveRoute bounds-checks every index it follows. DirectionsResponse
arrives flattened, so routes reach steps and steps reach polylines
through indexes that all come from the network; trusting them would turn
a truncated upstream response into a panic that kills the process. Nine
cases cover out-of-range, negative, and missing arrays.

Two corrections from probing the live API, both of which contradicted
Apple's published documentation:

ErrorResponse does not match its schema. Apple documents message and
details at the top level, but the wire format nests them under an "error"
key. Decoding only the documented shape silently produced empty error
messages, losing the reason for every failure. UnmarshalJSON now accepts
both forms.

TransportType has four values, not the three MapKit implies. Cycling is
accepted; Bicycle is rejected with HTTP 400. Apple's 400 does not
enumerate valid values, so each candidate was probed individually and
AllTransportTypes records the confirmed set. All four verified live with
plausible travel times.

The probe also confirmed stepPaths is an array of polylines, settling the
contradiction between Apple's prose and its machine-readable schema in
favour of the prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k

* fix(applemaps): pagination was broken against the live API

Answering "have we tested this" honestly: the unit suite passed and every
endpoint had been called live, but SearchAll had never been exercised
against Apple -- only Search, one page. Probing it found two more
undocumented rules, either of which broke every multi-page search.

First, enablePagination is rejected once a pageToken is present: "Cannot
specify parameter [enablePagination] in search request by pageToken". It
opts into pagination and belongs to the first request only. Second, after
fixing that, Apple rejected q the same way -- a page request may carry no
other parameter at all, because the token encodes the whole original
query.

The fix is structural rather than a comment. PageToken is gone from
SearchRequest, replaced by SearchPage(ctx, token), so a request that
mixes a token with a query is now unrepresentable rather than merely
discouraged.

The deeper problem was the test double. It accepted any parameter
combination, making it more permissive than the service it stood in for,
so it could not have caught either bug -- the suite was green while
production was broken. It now rejects a pageToken request carrying
anything else, using Apple's own status and message. Reverting the fix
makes four tests fail; before, none did.

Also closes two coverage gaps that were hiding behind stubs: the backoff
schedule is now asserted to double from 200ms, and sleepContext itself is
tested for both elapsing and aborting on cancellation. It had 0% coverage
because every test replaced it.

Verified live: 3 pages, 60 places, no duplicates across pages, Truncated
correctly set against Apple's totalPageCount of 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k

* docs: Phase 2 plan derived from offerbee's actual field usage

Audited all five geo-service call sites in ~/code/offerbee rather than
working from the design's assumptions. Three of the design's decisions
were wrong and are corrected here.

Hours must be left empty for Apple places, not filled with
POI.DefaultOpeningHours as the design said. offerbee's isOpenAtParts
returns "unknown" only when hours are not exactly 7 entries, and both
consumers keep unknown places. Seven copies of the "8:30 am - 9:30 pm"
placeholder instead parse as a real window, so a place open until
midnight gets filtered out at 22:00 because our invented hours claimed it
closed at 21:30. Empty degrades honestly; defaults lie.

The category map becomes an allowlist rather than a best-effort table.
cardRewards.ts deliberately maps hardware_store, electronics_store,
convenience_store and others to no reward, because crediting a
department-store or grocery bonus there is worse than the base rate.
Apple collapses all of them into Store, and its q matching is loose --
measured at 23 loosely related results for q=Golden Gate Bridge -- so
tagging results with the requested type would credit a grocery bonus to a
convenience store on a card that explicitly excludes them. That is wrong
money advice, not a ranking regression. Apple now serves only the 15
types with an unambiguous equivalent; everything else routes to Google.

Requests carrying localTime route to Google outright, since open-now
filtering is meaningless without real hours.

Also: offerbee consumes five endpoints, not the two the design named, and
priceLevel is read nowhere in the entire codebase -- so the one Apple gap
the design worried about costs nothing, while the gap it dismissed
(hours) is load-bearing in both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJuZpNMJnG4RGqKnZzxF5k

* fix(applemaps): correct four defects found auditing against Apple's schema

Three of these are silent: they produce wrong data or spend quota without
ever surfacing an error.

Autocomplete coordinates decoded to (0, 0). /v1/searchAutocomplete spells a
coordinate {"lat","lng"}; every other endpoint uses {"latitude","longitude"},
and Apple documents only the one Location type. encoding/json drops unknown
keys, so each suggestion arrived as a non-nil zero coordinate — a real point
off the coast of Ghana that a caller cannot distinguish from an answer.
Location now decodes either spelling, preferring the documented one. The old
fixture hardcoded the wrong shape, so the suite had been asserting the bug
was correct behaviour.

AddressCategory was missing AdministrativeArea. Apple's reference page renders
six values as five, running the second into the Country bullet; the /v1/search
parameter documentation confirms it independently by using the value in its
own example. The state/province level was inexpressible.

StructuredAddress dropped subAdministrativeArea. Absent from Apple's published
schema, present in live responses — Apple's own searchAutocomplete example
returns "San Francisco County" on most results. This is the admin-area-2 slot
the Phase 2 geocode adapter needs.

A concurrent 401 burst spent one token exchange per goroutine. Invalidate
cleared unconditionally, so each goroutine's 401 discarded the token the
previous one had just fetched: N exchanges against a 25,000/day quota shared
with a production app, and retries racing over which token they held.
Invalidation is now generation-guarded, so a 401 can only discard the token
that drew it.

Also, three cases that Apple answers with an opaque 400, now caught locally
for the same reason MaxETADestinations already is:

- Directions rejects TransportTypeTransit, which is valid for ETAs only.
  This matches MapKit, which gives transit travel times but no transit
  turn-by-turn.
- SearchRegionPriority is a typed enum rather than a bare string; Apple
  accepts exactly "default" and "required".
- The address-category filters validate their SearchResultTypeAddress
  prerequisite. SearchAutocompleteRequest gains a validate and rejects them
  outright, since SearchACResultType has no address member and so no such
  request can be legal.

Each of the three decode and concurrency defects was confirmed to reproduce
before being fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj

* fix(applemaps): address major findings from PR review

A malformed token TTL caused unbounded re-exchange. exchangeLocked trusted
expiresInSeconds as given. A response missing the field, or stating a lifetime
at or below tokenRefreshMargin, put expiry inside the refresh margin, so every
subsequent call found the cached token stale and exchanged again — one degraded
response shape turned into permanent double consumption of a quota shared with
MapKit JS. The TTL is now sanitised at exchange time: a non-positive value falls
back to the 1800 seconds Apple is observed to issue, and a lifetime too short
for the fixed margin gets half of itself instead, so the token stays cacheable.
The refresh instant is stored rather than derived on each read, since the margin
now depends on the lifetime of that particular token.

Invalidation ordering gains a deterministic test. The concurrent test asserts an
exchange count across 50 goroutines, which cannot guarantee which interleaving
it exercised; this pins the case directly — two callers take one token, the
first invalidates and refreshes, and the second's late invalidation naming the
discarded token must leave the replacement alone.

The remaining findings are in the phase plans, which describe work not yet
written:

- The Phase 2 adapter would have stopped paginating on the first page that
  contributed nothing after the radius filter. searchLocation and searchRegion
  are hints, not constraints, so a later page can hold in-radius results that an
  earlier empty one says nothing about. Paginate to the cap, then filter.

- Price-constrained searches must route to Google. "priceLevel is read nowhere
  in offerbee" settled the response side only; on the request side
  matching/matcher.go:83 carries PriceLevel into the search, where
  nearby_search.go turns it into a provider-applied filter for pricey eateries
  and filterPlacesOnPriceLevel demands an exact match. Apple places carry level
  zero, so a non-zero price filter discards every one of them — the call is
  spent and the results are thrown away. canServe now rejects it.

- The quota counter observes only our own calls, while the 25,000 is per team
  and shared with MapKit JS, so a threshold on our count alone measures nothing
  reliable. It now requires an explicit external-consumption allowance, and
  counts at the HTTP transport boundary so retries and /v1/token exchanges are
  included rather than only logical searches.

- The live probe step said to commit raw Apple responses as testdata fixtures.
  Apple's terms on caching and on mixing providers are an open question in this
  same document, so the step now records observed response shape and builds
  fixtures from Apple's published examples, which is what was actually done.

Also corrects the TransportType checklist, which omitted Cycling and the
directions/ETAs split.

Both behavioural changes were confirmed to reproduce before being fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WGSuRPA8YK814rwbCpStj

---------

Co-authored-by: tim-eternos <tim@uare.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…) and boot provisioner

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
…rors

- Use mktemp with target directory for atomicity (same-fs rename vs copy)
- Capture previous IMAGE_TAG before rendering new .env for rollback
- Replace install with chmod+mv for atomic file operation
- Add rollback logic on failed health check (restores previous tag if different)
- Change all curl -sf to curl -sfS for error visibility in CI logs
- Add explicit error handling in secret fetch loop with secret name in message
- Add || true to docker image prune to not fail on pruning errors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
…ork, snapshots)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
…ployer actAs, log rotation

Final-review fix wave: deploy.sh now gates verification on an app-level wget through the web container (60s retry, re-verified rollback) instead of a Caddy probe that succeeds even when web is down; the env contract and env.production gained AWS_REGION, without which the S3 SDK has no region on GCE; gcp-bootstrap.sh grants the deployer SA roles/iam.serviceAccountUser on the runtime SA so CI's gcloud compute ssh/scp works; and all three compose services (caddy, web, redis) carry json-file log rotation (10m x 3) to bound disk usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
Keyword (brand) searches query Google with LocationTypeAny, so
parsePlacesSearchResponse stamped every result with LocationType "".
The write path is a blind upsert into the shared placeDetails:{id}
records that category reads hydrate from, so each brand search wiped
the type off previously typed records — a McDonald's cached by an
Eatery search was then served with no type, and downstream consumers
(OfferBee best-card) scored it at base rate instead of dining.

Two changes, both gap-filling only:

- parsePlacesSearchResponse: when the searched type is LocationTypeAny,
  derive LocationType from POI.PrimaryLocationType(place.Types) — the
  same machinery ReclassifyForCategory already uses on category reads.
  Typed searches keep their searched-type stamp.
- restoreCachedDetails: restore the stored LocationType when the
  rebuilt record has none, same "fill a gap, never overwrite" rule as
  URL/Summary/Address/Hours.

Existing blank-typed records stay blank until a fresh search rewrites
them; this stops new wipes and types new keyword results at the source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BmBJ2x6b7V36vywSMrYDy
…rch-location-type

Derive LocationType from the primary Google type for keyword searches
…t bootstrap steps

Bootstrap script now auto-seeds secrets on first run: JWT_SIGNING_SECRET is generated, MAPS_CLIENT_API_KEY is taken from env or prompted interactively, and optional integrations (OAuth, SendGrid, OpenAI, Geonames, AWS) seeded with changeme placeholders. Rerun is safe—seeded values never overwritten. Task 5 rewritten with new-project bootstrap steps, Task 7 Step 3 augmented with OAuth setup for fresh projects, Task 9 marked skippable per 2026-08-11 ruling (empty Redis acceptable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
VM planner-vm running in us-west1-a on static IP 35.252.90.146. AR repo,
static IP, and snapshot policy recreated in us-west1; us-central1
leftovers deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
…obes

Production's 100-req/hour rate limiter was exhausted by the docker healthcheck
(30s interval) and deploy verification, causing health flaps. Gin builds route
handler chains at registration time, so registering /healthz before the
rate-limiter middleware excludes it from the limiter's scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
- deploy.sh: rollback restores the complete previous .env (config and
  secrets, not just the tag) and retries its verification; backup is
  dropped once a deploy verifies
- Dockerfile: run as non-root user
- gcp-bootstrap.sh: IAP SSH firewall rule scoped to the planner-web tag
- startup-script.sh: base tools install independently of the docker guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
feat(deploy): GCP VM deployment scaffolding
- deploy.yml: WIF auth (no keys), build+push image to Artifact Registry,
  ship deploy files and run deploy.sh on planner-vm over an IAP tunnel.
  workflow_dispatch with a `tag` input redeploys/rolls back an existing
  image without rebuilding. Image tag validated against the docker-tag
  charset before entering any shell command.
- go.yml: workflow_call trigger so deploy gates on lint + build + test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
@timwangmusic

Copy link
Copy Markdown
Owner Author

Opened against the wrong repo (fork parent); reopening on offerbee-ai.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants