fix: use installedLocations from bulk token response instead of /locations/search
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

GHL includes installedLocations[] in the company-level token response for
bulk installs. Use those IDs directly to avoid calling /locations/search,
which requires locations.readonly scope that GHL doesn't grant. Falls back
to /locations/search only when the list is absent. Also adds raw_body and
installed_locations fields to token response debug logging.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Head of Product & Engineering 2026-04-06 10:47:40 +02:00
parent 3d1e80cd86
commit 3863e8f0cd
2 changed files with 40 additions and 22 deletions

View File

@ -227,31 +227,48 @@ func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenRe
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err) return nil, fmt.Errorf("failed to parse token response: %w", err)
} }
slog.Info("ghl token response fields", "location_id", tokenResp.LocationID, "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType) slog.Info("ghl token response fields",
"location_id", tokenResp.LocationID,
"company_id", tokenResp.CompanyID,
"user_type", tokenResp.UserType,
"installed_locations", tokenResp.InstalledLocations,
"raw_body", string(body),
)
return &tokenResp, nil return &tokenResp, nil
} }
// installAllLocations fetches all locations for the company and exchanges the company token // installAllLocations exchanges the company token for per-location tokens and stores them.
// for a per-location token for each, storing them all. Returns the count installed. // It first tries to use the InstalledLocations list from the token response (provided by GHL
// for bulk installs). Falls back to GET /locations/search if that list is empty.
func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) { func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) {
locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, companyToken.CompanyID) // Build location ID list from token response when GHL provides it (bulk install path).
if err != nil { locationIDs := companyToken.InstalledLocations
return 0, fmt.Errorf("list locations: %w", err) if len(locationIDs) == 0 {
} slog.Info("ghl bulk install: no installedLocations in token, falling back to locations search", "company_id", companyToken.CompanyID)
if len(locations) == 0 { locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, companyToken.CompanyID)
return 0, fmt.Errorf("no locations found for company %s", companyToken.CompanyID) if err != nil {
return 0, fmt.Errorf("list locations: %w", err)
}
if len(locations) == 0 {
return 0, fmt.Errorf("no locations found for company %s", companyToken.CompanyID)
}
for _, loc := range locations {
locationIDs = append(locationIDs, loc.ID)
}
} else {
slog.Info("ghl bulk install: using installedLocations from token", "company_id", companyToken.CompanyID, "count", len(locationIDs))
} }
installed := 0 installed := 0
for _, loc := range locations { for _, locID := range locationIDs {
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, loc.ID) locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, locID)
if err != nil { if err != nil {
slog.Warn("ghl location token exchange failed", "location_id", loc.ID, "location_name", loc.Name, "err", err) slog.Warn("ghl location token exchange failed", "location_id", locID, "err", err)
continue continue
} }
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second) expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
record := &store.TokenRecord{ record := &store.TokenRecord{
LocationID: loc.ID, LocationID: locID,
CompanyID: companyToken.CompanyID, CompanyID: companyToken.CompanyID,
AccessToken: locToken.AccessToken, AccessToken: locToken.AccessToken,
RefreshToken: locToken.RefreshToken, RefreshToken: locToken.RefreshToken,
@ -260,10 +277,10 @@ func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *To
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
if err := h.store.SaveToken(ctx, record); err != nil { if err := h.store.SaveToken(ctx, record); err != nil {
slog.Warn("ghl location token save failed", "location_id", loc.ID, "err", err) slog.Warn("ghl location token save failed", "location_id", locID, "err", err)
continue continue
} }
slog.Info("ghl location installed", "location_id", loc.ID, "location_name", loc.Name) slog.Info("ghl location installed", "location_id", locID)
installed++ installed++
} }
return installed, nil return installed, nil

View File

@ -1,13 +1,14 @@
package ghl package ghl
type TokenResponse struct { type TokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
LocationID string `json:"locationId"` LocationID string `json:"locationId"`
CompanyID string `json:"companyId"` CompanyID string `json:"companyId"`
UserType string `json:"userType"` UserType string `json:"userType"`
InstalledLocations []string `json:"installedLocations"` // populated on bulk company installs
} }
type LocationInfo struct { type LocationInfo struct {