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,13 +227,24 @@ func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenRe
if err := json.Unmarshal(body, &tokenResp); err != nil {
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
}
// installAllLocations fetches all locations for the company and exchanges the company token
// for a per-location token for each, storing them all. Returns the count installed.
// installAllLocations exchanges the company token for per-location tokens and stores them.
// 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) {
// Build location ID list from token response when GHL provides it (bulk install path).
locationIDs := companyToken.InstalledLocations
if len(locationIDs) == 0 {
slog.Info("ghl bulk install: no installedLocations in token, falling back to locations search", "company_id", companyToken.CompanyID)
locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, companyToken.CompanyID)
if err != nil {
return 0, fmt.Errorf("list locations: %w", err)
@ -241,17 +252,23 @@ func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *To
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
for _, loc := range locations {
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, loc.ID)
for _, locID := range locationIDs {
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, locID)
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
}
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
record := &store.TokenRecord{
LocationID: loc.ID,
LocationID: locID,
CompanyID: companyToken.CompanyID,
AccessToken: locToken.AccessToken,
RefreshToken: locToken.RefreshToken,
@ -260,10 +277,10 @@ func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *To
UpdatedAt: time.Now(),
}
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
}
slog.Info("ghl location installed", "location_id", loc.ID, "location_name", loc.Name)
slog.Info("ghl location installed", "location_id", locID)
installed++
}
return installed, nil

View File

@ -8,6 +8,7 @@ type TokenResponse struct {
LocationID string `json:"locationId"`
CompanyID string `json:"companyId"`
UserType string `json:"userType"`
InstalledLocations []string `json:"installedLocations"` // populated on bulk company installs
}
type LocationInfo struct {