ci: automated deploy to GCP VM (WIF + IAP) - #453
Closed
timwangmusic wants to merge 99 commits into
Closed
Conversation
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>
…and_yarn/lodash-4.17.23
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
…lan-generation-H79MZ
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>
…and_yarn/lodash-4.18.1
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
…-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
…rtainment-types
* 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>
…ets only 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
…) 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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aitZGthghNeJEdjYFsZT8
…ites 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
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
Owner
Author
|
Opened against the wrong repo (fork parent); reopening on offerbee-ai. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Push-to-main auto-deploy for the planner backend, now that the GCE VM infra exists (PR #3).
main: gate ongo.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 toplanner-vm, and rundeploy.shover an IAP tunnel.workflow_dispatchwith ataginput redeploys an existing image (rollback) without building.workflow_callso the deploy job reuses lint + build + test as its gate.Security
taginput is validated against the docker-tag charset (^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$) before it reaches any shell command — closes the injection vector raised in the PR add test cases for min priority queue and min spanning tree under go test framework #3 review.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 hasroles/iam.serviceAccountUseron the runtime SA (required forgcloud compute ssh).After merge
The deploy job runs on the merge commit: builds that SHA, deploys to
planner-vm, andhttps://geo.offerbee.ai/healthzshould 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