fix: exchange company token for per-location tokens on bulk OAuth install
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
GHL issues a Company-scoped token (userType=Company) for bulk/agency installs even when Target User=Sub-account. This fix handles that case: 1. Detect userType=Company in HandleCallback 2. Call GET /locations/search to enumerate all company locations 3. For each location call POST /oauth/locationToken to get a Location-scoped token (userType=Location, includes locationId) 4. Store each location token individually in MongoDB This allows webhook delivery and status updates to work per-location without requiring the agency admin to re-install per sub-account. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f01138474d
commit
f97f31c8ac
@ -15,7 +15,12 @@ import (
|
|||||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
|
const (
|
||||||
|
ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
|
||||||
|
ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken"
|
||||||
|
ghlLocationsURL = "https://services.leadconnectorhq.com/locations/search"
|
||||||
|
ghlLocationAPIVersion = "2021-07-28"
|
||||||
|
)
|
||||||
|
|
||||||
// TokenStore is the interface OAuthHandler uses for token persistence.
|
// TokenStore is the interface OAuthHandler uses for token persistence.
|
||||||
type TokenStore interface {
|
type TokenStore interface {
|
||||||
@ -91,9 +96,25 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Company-level (bulk) install: exchange company token for per-location tokens.
|
||||||
|
if tokenResp.UserType == "Company" && tokenResp.LocationID == "" {
|
||||||
|
slog.Info("ghl oauth company install — exchanging for location tokens", "company_id", tokenResp.CompanyID)
|
||||||
|
installed, err := h.installAllLocations(ctx, tokenResp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ghl oauth location token exchange failed", "company_id", tokenResp.CompanyID, "err", err)
|
||||||
|
http.Error(w, "failed to exchange company token for location tokens: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("ghl oauth bulk install complete", "company_id", tokenResp.CompanyID, "locations_installed", installed)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>Connected %d location(s). You can close this tab.</p></body></html>`, installed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if tokenResp.LocationID == "" {
|
if tokenResp.LocationID == "" {
|
||||||
slog.Error("ghl oauth token missing locationId", "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
|
slog.Error("ghl oauth token missing locationId", "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
|
||||||
http.Error(w, "GHL token response did not include a locationId — ensure you selected a sub-account (Location), not an Agency, during authorization", http.StatusBadRequest)
|
http.Error(w, "GHL token response did not include a locationId", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +224,108 @@ 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, "raw_body", string(body))
|
slog.Info("ghl token response fields", "location_id", tokenResp.LocationID, "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
|
||||||
|
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.
|
||||||
|
func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) {
|
||||||
|
locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := 0
|
||||||
|
for _, loc := range locations {
|
||||||
|
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, loc.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("ghl location token exchange failed", "location_id", loc.ID, "location_name", loc.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
|
||||||
|
record := &store.TokenRecord{
|
||||||
|
LocationID: loc.ID,
|
||||||
|
CompanyID: companyToken.CompanyID,
|
||||||
|
AccessToken: locToken.AccessToken,
|
||||||
|
RefreshToken: locToken.RefreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
InstalledAt: time.Now(),
|
||||||
|
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)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("ghl location installed", "location_id", loc.ID, "location_name", loc.Name)
|
||||||
|
installed++
|
||||||
|
}
|
||||||
|
return installed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCompanyLocations lists all locations for a company using the company-scoped token.
|
||||||
|
func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessToken, companyID string) ([]LocationInfo, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlLocationsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("companyId", companyID)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
|
||||||
|
req.Header.Set("Version", ghlLocationAPIVersion)
|
||||||
|
|
||||||
|
resp, err := h.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("locations endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var locResp LocationsResponse
|
||||||
|
if err := json.Unmarshal(body, &locResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse locations response: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("ghl company locations fetched", "company_id", companyID, "count", len(locResp.Locations))
|
||||||
|
return locResp.Locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeForLocationToken converts a company-scoped token into a location-scoped token.
|
||||||
|
func (h *OAuthHandler) exchangeForLocationToken(ctx context.Context, companyAccessToken, companyID, locationID string) (*TokenResponse, error) {
|
||||||
|
payload := fmt.Sprintf(`{"companyId":%q,"locationId":%q}`, companyID, locationID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghlLocationTokenURL, strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Version", ghlLocationAPIVersion)
|
||||||
|
|
||||||
|
resp, err := h.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("locationToken endpoint returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp TokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse location token response: %w", err)
|
||||||
|
}
|
||||||
return &tokenResp, nil
|
return &tokenResp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,15 @@ type TokenResponse struct {
|
|||||||
UserType string `json:"userType"`
|
UserType string `json:"userType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocationInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationsResponse struct {
|
||||||
|
Locations []LocationInfo `json:"locations"`
|
||||||
|
}
|
||||||
|
|
||||||
type OutboundMessageWebhook struct {
|
type OutboundMessageWebhook struct {
|
||||||
ContactID string `json:"contactId"`
|
ContactID string `json:"contactId"`
|
||||||
LocationID string `json:"locationId"`
|
LocationID string `json:"locationId"`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user