Repository: amp-labs/connectors
Pull request #1588 (2025-04-23 15:18:18)

[CON-1651] feat: Added fireflies connector
## Checklist
- [x] Created PR from non-main branch

## Testing GraphQL

URL: http://localhost:4444/

### READ

**Get users**

```
 { "query": "{ users { name user_id integrations } }" } 
```
![image](https://github.com/user-attachments/assets/3167d1ea-a6a2-493d-a26e-bbce8b3e070b)

### CREATE

**Add to Live**

```
{
    "query": "mutation AddToLiveMeeting($meetingLink: String!) { addToLiveMeeting(meeting_link: $meetingLink) { success } }",
    "variables": {
      "meetingLink": "https://meet.google.com/yyg-bivu-ojn"
    }
  }
```
![image](https://github.com/user-attachments/assets/362b36ca-2c35-41f3-82c6-3a914be89ff1)




Patch:
diff --git a/common/header.go b/common/header.go
index c4a0ba99..00bbeda4 100644
--- a/common/header.go
+++ b/common/header.go
@@ -107,35 +107,35 @@ type headerAuthClient struct {
 
 func (c *headerAuthClient) Do(req *http.Request) (*http.Response, error) {
 	// This allows us to attach headers without modifying the input
-	req = req.Clone(req.Context())
+	req2 := req.Clone(req.Context())
 
 	for _, header := range c.headers {
-		header.ApplyToRequest(req)
+		header.ApplyToRequest(req2)
 	}
 
 	if c.dynamicHeaders != nil {
-		hdrs, err := c.dynamicHeaders(*req)
+		hdrs, err := c.dynamicHeaders(*req2)
 		if err != nil {
 			return nil, err
 		}
 
 		for _, header := range hdrs {
-			header.ApplyToRequest(req)
+			header.ApplyToRequest(req2)
 		}
 	}
 
-	modifier, hasModifier := getRequestModifier(req.Context()) //nolint:contextcheck
+	modifier, hasModifier := getRequestModifier(req2.Context()) //nolint:contextcheck
 	if hasModifier {
-		modifier(req)
+		modifier(req2)
 	}
 
-	rsp, err := c.client.Do(req)
+	rsp, err := c.client.Do(req2)
 	if err != nil {
 		return rsp, err
 	}
 
 	if c.debug != nil {
-		c.debug(req, cloneResponse(rsp))
+		c.debug(req2, cloneResponse(rsp))
 	}
 
 	// Certain providers return 401 when the credentials has been invalidated.
diff --git a/common/query.go b/common/query.go
index 70d9f697..3db0256b 100644
--- a/common/query.go
+++ b/common/query.go
@@ -2,12 +2,80 @@ package common
 
 import (
 	"context"
+	"fmt"
 	"net/http"
+	"net/url"
+)
+
+// QueryParamMode determines how the query param should be applied to the request.
+type QueryParamMode int
+
+const (
+	// QueryParamModeUnset is the default mode. It appends the QueryParam to the request.
+	queryParamModeUnset = iota
+
+	// QueryParamModeAppend appends the QueryParam to the request.
+	QueryParamModeAppend
+
+	// QueryParamModeOverwrite unconditionally overwrites the QueryParam in the request.
+	QueryParamModeOverwrite
+
+	// QueryParamModeSetIfMissing sets the QueryParam in the request if it is not already set.
+	QueryParamModeSetIfMissing
 )
 
 type QueryParam struct {
-	Key   string
-	Value string
+	Key   string         `json:"key"`
+	Value string         `json:"value"`
+	Mode  QueryParamMode `json:"mode"`
+}
+
+func (q QueryParam) ApplyToRequest(vals *url.Values) {
+	switch q.Mode {
+	case QueryParamModeOverwrite:
+		vals.Set(q.Key, q.Value)
+	case QueryParamModeSetIfMissing:
+		if !vals.Has(q.Key) {
+			vals.Add(q.Key, q.Value)
+		}
+	case QueryParamModeAppend:
+		fallthrough
+	case queryParamModeUnset:
+		fallthrough
+	default:
+		vals.Add(q.Key, q.Value)
+	}
+}
+
+func (q QueryParam) equals(other QueryParam) bool {
+	return q.Key == other.Key &&
+		q.Value == other.Value &&
+		q.Mode == other.Mode
+}
+
+func (q QueryParam) String() string {
+	return fmt.Sprintf("%s: %s", q.Key, q.Value)
+}
+
+type QueryParams []QueryParam
+
+func (q QueryParams) Has(target QueryParam) bool {
+	for _, qp := range q {
+		if qp.equals(target) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (q QueryParams) ApplyToRequest(req *http.Request) {
+	query := req.URL.Query()
+	for _, p := range q {
+		p.ApplyToRequest(&query)
+	}
+
+	req.URL.RawQuery = query.Encode()
 }
 
 type QueryParamAuthClientOption func(params *queryParamClientParams)
@@ -92,34 +160,30 @@ func newQueryParamAuthClient( //nolint:ireturn
 
 type queryParamAuthClient struct {
 	client       *http.Client
-	params       []QueryParam
+	params       QueryParams
 	debug        func(req *http.Request, rsp *http.Response)
 	unauthorized func(params []QueryParam, req *http.Request, rsp *http.Response) (*http.Response, error)
 }
 
 func (c *queryParamAuthClient) Do(req *http.Request) (*http.Response, error) {
 	// This allows us to modify query params without mutating the input
-	req = req.Clone(req.Context())
+	req2 := req.Clone(req.Context())
 
-	query := req.URL.Query()
-	for _, p := range c.params {
-		query.Add(p.Key, p.Value)
-	}
-
-	req.URL.RawQuery = query.Encode()
+	// Add on the query parameters
+	c.params.ApplyToRequest(req2)
 
 	modifier, hasModifier := getRequestModifier(req.Context()) //nolint:contextcheck
 	if hasModifier {
-		modifier(req)
+		modifier(req2)
 	}
 
-	rsp, err := c.client.Do(req)
+	rsp, err := c.client.Do(req2)
 	if err != nil {
 		return rsp, err
 	}
 
 	if c.debug != nil {
-		c.debug(req, cloneResponse(rsp))
+		c.debug(req2, cloneResponse(rsp))
 	}
 
 	// Certain providers return 401 when the credentials has been invalidated.
diff --git a/providers/fireflies.go b/providers/fireflies.go
new file mode 100644
index 00000000..ac55e189
--- /dev/null
+++ b/providers/fireflies.go
@@ -0,0 +1,42 @@
+package providers
+
+const Fireflies Provider = "fireflies"
+
+func init() {
+	// Fireflies Configuration
+	SetInfo(Fireflies, ProviderInfo{
+		DisplayName: "Fireflies",
+		AuthType:    ApiKey,
+		BaseURL:     "https://api.fireflies.ai/graphql",
+		ApiKeyOpts: &ApiKeyOpts{
+			AttachmentType: "header",
+			Header: &ApiKeyOptsHeader{
+				Name:        "Authorization",
+				ValuePrefix: "Bearer ",
+			},
+			DocsURL: "https://docs.fireflies.ai/fundamentals/authorization#acquiring-a-token",
+		},
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1745419046/media/fireflies.ai_1745419046.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1745419015/media/fireflies.ai_1745419014.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1745419046/media/fireflies.ai_1745419046.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1745419073/media/fireflies.ai_1745419073.svg",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	})
+}
diff --git a/providers/salesforce/subscriptionEvent.go b/providers/salesforce/subscriptionEvent.go
index 7d55ed41..8aec9997 100644
--- a/providers/salesforce/subscriptionEvent.go
+++ b/providers/salesforce/subscriptionEvent.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/goutils"
@@ -170,6 +171,156 @@ func (s SubscriptionEvent) EventTimeStampNano() (int64, error) {
 	return int64(num), nil
 }
 
+// compoundField represents a compound field in Salesforce.
+type compoundField struct {
+	Object string // The object name
+	Field  string // The field name
+}
+
+// Note that this list was obtained by running this SOQL query:
+// SELECT EntityDefinition.QualifiedApiName,QualifiedApiName
+// FROM FieldDefinition
+// WHERE IsCompound = true AND EntityDefinition.QualifiedApiName LIKE '%'
+//
+// I then massaged the results in to actual Go code.
+var compoundFields = []compoundField{ //nolint:gochecknoglobals
+	{Object: "Account", Field: "BillingAddress"},
+	{Object: "Account", Field: "Name"},
+	{Object: "Account", Field: "ShippingAddress"},
+	{Object: "AccountChangeEvent", Field: "BillingAddress"},
+	{Object: "AccountChangeEvent", Field: "Name"},
+	{Object: "AccountChangeEvent", Field: "ShippingAddress"},
+	{Object: "AccountCleanInfo", Field: "Address"},
+	{Object: "AccountCleanInfoChangeEvent", Field: "Address"},
+	{Object: "Address", Field: "Address"},
+	{Object: "AlternativePaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "Asset", Field: "Address"},
+	{Object: "AssetChangeEvent", Field: "Address"},
+	{Object: "CardPaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "CartDeliveryGroup", Field: "DeliverToAddress"},
+	{Object: "CartDeliveryGroupChangeEvent", Field: "DeliverToAddress"},
+	{Object: "Contact", Field: "MailingAddress"},
+	{Object: "Contact", Field: "Name"},
+	{Object: "Contact", Field: "OtherAddress"},
+	{Object: "ContactChangeEvent", Field: "MailingAddress"},
+	{Object: "ContactChangeEvent", Field: "Name"},
+	{Object: "ContactChangeEvent", Field: "OtherAddress"},
+	{Object: "ContactCleanInfo", Field: "Address"},
+	{Object: "ContactCleanInfoChangeEvent", Field: "Address"},
+	{Object: "ContactPointAddress", Field: "Address"},
+	{Object: "ContactPointAddressChangeEvent", Field: "Address"},
+	{Object: "Contract", Field: "BillingAddress"},
+	{Object: "ContractChangeEvent", Field: "BillingAddress"},
+	{Object: "DandBCompany", Field: "Address"},
+	{Object: "DandBCompany", Field: "MailingAddress"},
+	{Object: "DigitalWallet", Field: "PaymentMethodAddress"},
+	{Object: "FulfillmentOrder", Field: "FulfilledToAddress"},
+	{Object: "FulfillmentOrderChangeEvent", Field: "FulfilledToAddress"},
+	{Object: "Individual", Field: "Name"},
+	{Object: "IndividualChangeEvent", Field: "Name"},
+	{Object: "Lead", Field: "Address"},
+	{Object: "Lead", Field: "Name"},
+	{Object: "LeadChangeEvent", Field: "Address"},
+	{Object: "LeadChangeEvent", Field: "Name"},
+	{Object: "LeadCleanInfo", Field: "Address"},
+	{Object: "LegalEntity", Field: "LegalEntityAddress"},
+	{Object: "Location", Field: "Location"},
+	{Object: "LocationChangeEvent", Field: "Location"},
+	{Object: "Name", Field: "Name"},
+	{Object: "Opportunity", Field: "Fiscal"},
+	{Object: "Order", Field: "BillingAddress"},
+	{Object: "Order", Field: "ShippingAddress"},
+	{Object: "OrderChangeEvent", Field: "BillingAddress"},
+	{Object: "OrderChangeEvent", Field: "ShippingAddress"},
+	{Object: "Organization", Field: "Address"},
+	{Object: "PaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "RecentlyViewed", Field: "Name"},
+	{Object: "ResourceAbsence", Field: "Address"},
+	{Object: "ResourceAbsenceChangeEvent", Field: "Address"},
+	{Object: "ReturnOrder", Field: "ShipFromAddress"},
+	{Object: "ReturnOrderChangeEvent", Field: "ShipFromAddress"},
+	{Object: "ServiceAppointment", Field: "Address"},
+	{Object: "ServiceAppointmentChangeEvent", Field: "Address"},
+	{Object: "ServiceContract", Field: "BillingAddress"},
+	{Object: "ServiceContract", Field: "ShippingAddress"},
+	{Object: "ServiceContractChangeEvent", Field: "BillingAddress"},
+	{Object: "ServiceContractChangeEvent", Field: "ShippingAddress"},
+	{Object: "ServiceTerritory", Field: "Address"},
+	{Object: "ServiceTerritoryChangeEvent", Field: "Address"},
+	{Object: "ServiceTerritoryMember", Field: "Address"},
+	{Object: "ServiceTerritoryMemberChangeEvent", Field: "Address"},
+	{Object: "User", Field: "Address"},
+	{Object: "User", Field: "Name"},
+	{Object: "UserChangeEvent", Field: "Address"},
+	{Object: "UserChangeEvent", Field: "Name"},
+	{Object: "WebCart", Field: "BillingAddress"},
+	{Object: "WebCartChangeEvent", Field: "BillingAddress"},
+	{Object: "WorkOrder", Field: "Address"},
+	{Object: "WorkOrderChangeEvent", Field: "Address"},
+	{Object: "WorkOrderLineItem", Field: "Address"},
+	{Object: "WorkOrderLineItemChangeEvent", Field: "Address"},
+}
+
+// Maps object name -> field name -> empty struct.
+// NB: All names are lowercase to allow case-insensitive matching.
+var compositePrefixMap map[string]map[string]struct{} //nolint:gochecknoglobals
+
+func init() {
+	compositePrefixMap = make(map[string]map[string]struct{}, len(compoundFields))
+
+	for _, field := range compoundFields {
+		obj := strings.ToLower(field.Object)
+		fld := strings.ToLower(field.Field)
+
+		if _, ok := compositePrefixMap[obj]; !ok {
+			compositePrefixMap[obj] = make(map[string]struct{})
+		}
+
+		compositePrefixMap[obj][fld] = struct{}{}
+	}
+}
+
+// isStandardCompoundField checks if the given object and field
+// are part of the standard compound fields defined in Salesforce.
+//
+// See https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/compound_fields.htm
+func isStandardCompoundField(obj, field string) bool {
+	fields, ok := compositePrefixMap[strings.ToLower(obj)]
+	if !ok {
+		return false
+	}
+
+	_, ok = fields[strings.ToLower(field)]
+
+	return ok
+}
+
+func (s SubscriptionEvent) normalizeUpdatedFieldName(name string) (string, error) {
+	if !strings.Contains(name, ".") {
+		return name, nil
+	}
+
+	// Compound fields look like "Field.Subfield"
+	// We're interested in the rightmost part, but to validate
+	// that indeed it's a compound field, we have to consider the
+	// leftmost part first.
+	parts := strings.SplitN(name, ".", 2) //nolint:mnd,gomnd
+	if len(parts) < 2 {                   //nolint:mnd,gomnd
+		return parts[0], nil
+	}
+
+	obj, err := s.ObjectName()
+	if err != nil {
+		return "", fmt.Errorf("failed to get object name: %w", err)
+	}
+
+	if !isStandardCompoundField(obj, parts[0]) {
+		return name, nil
+	}
+
+	return parts[1], nil
+}
+
 func (s SubscriptionEvent) UpdatedFields() ([]string, error) {
 	registry, err := s.asMap()
 	if err != nil {
@@ -200,7 +351,12 @@ func (s SubscriptionEvent) UpdatedFields() ([]string, error) {
 			)
 		}
 
-		fields[i] = str
+		fieldName, err := s.normalizeUpdatedFieldName(str)
+		if err != nil {
+			return nil, fmt.Errorf("failed to normalize field named %q: %w", str, err)
+		}
+
+		fields[i] = fieldName
 	}
 
 	return fields, nil


Test patch:
diff --git a/providers/salesforce/subscriptionEvent_test.go b/providers/salesforce/subscriptionEvent_test.go
index 82fbf416..4209ace1 100644
--- a/providers/salesforce/subscriptionEvent_test.go
+++ b/providers/salesforce/subscriptionEvent_test.go
@@ -9,6 +9,83 @@ import (
 	"gotest.tools/v3/assert"
 )
 
+func TestSubscriptionEventUpdateUser(t *testing.T) {
+	t.Parallel()
+
+	data := testutils.DataFromFile(t, "subscription/update_user.json")
+
+	event := SubscriptionEvent{}
+	if err := json.Unmarshal(data, &event); err != nil {
+		t.Fatalf("failed to start a test, cannot parse data; error (%v)", err)
+	}
+
+	eventType, err := event.EventType()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, eventType, common.SubscriptionEventTypeUpdate, "EventType should be Update")
+
+	rawEventType, err := event.RawEventName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, rawEventType, "UPDATE", "RawEventName should be UPDATE")
+
+	objectName, err := event.ObjectName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, objectName, "User", "ObjectName should be User")
+
+	workspace, err := event.Workspace()
+
+	assert.NilError(t, err, "error should be nil")
+	assert.Equal(t, workspace, "", "Workspace should be empty")
+
+	fields, err := event.UpdatedFields()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, len(fields), 3, "should have one updated field")
+	assert.Equal(t, fields[0], "LastModifiedDate", "first field name should be LastModifiedDate")
+	assert.Equal(t, fields[1], "LastModifiedById", "second field name should be LastModifiedById")
+	assert.Equal(t, fields[2], "FirstName", "third field name should be FirstName")
+}
+
+func TestSubscriptionEventUpdateContact(t *testing.T) {
+	t.Parallel()
+
+	data := testutils.DataFromFile(t, "subscription/update_contact.json")
+
+	event := SubscriptionEvent{}
+	if err := json.Unmarshal(data, &event); err != nil {
+		t.Fatalf("failed to start a test, cannot parse data; error (%v)", err)
+	}
+
+	eventType, err := event.EventType()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, eventType, common.SubscriptionEventTypeUpdate, "EventType should be Update")
+
+	rawEventType, err := event.RawEventName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, rawEventType, "UPDATE", "RawEventName should be UPDATE")
+
+	objectName, err := event.ObjectName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, objectName, "Contact", "ObjectName should be Contact")
+
+	workspace, err := event.Workspace()
+
+	assert.NilError(t, err, "error should be nil")
+	assert.Equal(t, workspace, "", "Workspace should be empty")
+
+	fields, err := event.UpdatedFields()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, len(fields), 2, "should have one updated field")
+	assert.Equal(t, fields[0], "LastModifiedDate", "first field name should be LastModifiedDate")
+	assert.Equal(t, fields[1], "LastName", "second field name should be LastName")
+}
+
 func TestSubscriptionEventProperties(t *testing.T) {
 	t.Parallel()
 
diff --git a/providers/salesforce/test/subscription/update_contact.json b/providers/salesforce/test/subscription/update_contact.json
new file mode 100644
index 00000000..9bed7dbc
--- /dev/null
+++ b/providers/salesforce/test/subscription/update_contact.json
@@ -0,0 +1,1 @@
+{"LastModifiedDate":"2025-04-24T13:18:26.000Z","Name":{"LastName":"Youngest"},"ChangeEventHeader":{"commitNumber":1745500706683617281,"commitUser":"005ak00000EpjsfAAB","sequenceNumber":1,"entityName":"Contact","changeType":"UPDATE","changedFields":["LastModifiedDate","Name.LastName"],"changeOrigin":"com/salesforce/api/soap/63.0;client=SfdcInternalAPI/","transactionKey":"0000be70-a756-e601-9202-32967602a6be","commitTimestamp":1745500706000,"recordIds":["003ak00000IiFInAAN"]}}
\ No newline at end of file
diff --git a/providers/salesforce/test/subscription/update_user.json b/providers/salesforce/test/subscription/update_user.json
new file mode 100644
index 00000000..a8c7f833
--- /dev/null
+++ b/providers/salesforce/test/subscription/update_user.json
@@ -0,0 +1,23 @@
+{
+  "ChangeEventHeader": {
+    "changeOrigin": "",
+    "changeType": "UPDATE",
+    "changedFields": [
+      "LastModifiedDate",
+      "LastModifiedById",
+      "Name.FirstName"
+    ],
+    "commitNumber": 1745433814682845200,
+    "commitTimestamp": 1745433814000,
+    "commitUser": "005ak00000EpjsfAAB",
+    "entityName": "User",
+    "recordId": "005ak00000GGsRRAA1",
+    "sequenceNumber": 1,
+    "transactionKey": "0000819a-2c4f-e547-290a-8ac966ffa9b9"
+  },
+  "LastModifiedById": "005ak00000EpjsfAAB",
+  "LastModifiedDate": "2025-04-23T18:43:34.000Z",
+  "Name": {
+    "FirstName": "BUTTERBALL"
+  }
+}