diff --git a/internal/ghl/oauth.go b/internal/ghl/oauth.go index 19e6b86..640c473 100644 --- a/internal/ghl/oauth.go +++ b/internal/ghl/oauth.go @@ -15,7 +15,12 @@ import ( "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. type TokenStore interface { @@ -91,9 +96,25 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { 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, `
Connected %d location(s). You can close this tab.
`, installed) + return + } + if tokenResp.LocationID == "" { 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 } @@ -203,6 +224,108 @@ 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, "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 } diff --git a/internal/ghl/types.go b/internal/ghl/types.go index 34b1950..a911c16 100644 --- a/internal/ghl/types.go +++ b/internal/ghl/types.go @@ -10,6 +10,15 @@ type TokenResponse struct { 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 { ContactID string `json:"contactId"` LocationID string `json:"locationId"`