Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion auth/service/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"time"

"github.com/tidepool-org/platform/mailer"
"github.com/tidepool-org/platform/oura/customerio"
"github.com/tidepool-org/platform/oura/jotform"

userClient "github.com/tidepool-org/platform/user/client"

Expand Down Expand Up @@ -37,6 +39,8 @@ import (
dexcomProvider "github.com/tidepool-org/platform/dexcom/provider"
"github.com/tidepool-org/platform/errors"
"github.com/tidepool-org/platform/events"
jotformAPI "github.com/tidepool-org/platform/oura/jotform/api"

"github.com/tidepool-org/platform/log"
oauthProvider "github.com/tidepool-org/platform/oauth/provider"
"github.com/tidepool-org/platform/platform"
Expand Down Expand Up @@ -275,9 +279,45 @@ func (s *Service) initializeRouter() error {
return errors.Wrap(err, "unable to create consent router")
}

s.Logger().Debug("Creating jotform router")

jotformConfig := jotform.Config{}
if err := envconfig.Process("", &jotformConfig); err != nil {
return errors.Wrap(err, "unable to load jotform config")
}

customerIOConfig := customerio.Config{}
if err := envconfig.Process("", &customerIOConfig); err != nil {
return errors.Wrap(err, "unable to load customerio config")
}
customerIOClient, err := customerio.NewClient(customerIOConfig, s.Logger())
if err != nil {
return errors.Wrap(err, "unable to create customerio client")
}

s.Logger().Debug("Initializing user client")
usrClient, err := userClient.NewDefaultClient(userClient.Params{
ConfigReporter: s.ConfigReporter(),
Logger: s.Logger(),
UserAgent: s.UserAgent(),
})
if err != nil {
return errors.Wrap(err, "unable to create user client")
}

webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, customerIOClient, usrClient)
if err != nil {
return errors.Wrap(err, "unable to create jotform webhook processor")
}

jotformRouter, err := jotformAPI.NewRouter(webhookProcessor)
if err != nil {
return errors.Wrap(err, "unable to create jotform router")
}

s.Logger().Debug("Initializing routers")

if err = s.API().InitializeRouters(apiRouter, v1Router, consentV1Router); err != nil {
if err = s.API().InitializeRouters(apiRouter, v1Router, consentV1Router, jotformRouter); err != nil {
return errors.Wrap(err, "unable to initialize routers")
}

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gowebpki/jcs v1.0.1
github.com/hashicorp/go-uuid v1.0.3
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865
github.com/kelseyhightower/envconfig v1.4.0
github.com/lestrrat-go/jwx/v2 v2.1.4
github.com/mitchellh/go-homedir v1.1.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865 h1:s/ZhV8gjiS///H7S/qm1iI/6YhMNx91t76S+61Q9tNU=
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865/go.mod h1:VgH1fhSKgJgeRtI9FkzACcW+v3ZZQHVHxXQqX1fBnQM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
Expand Down
32 changes: 32 additions & 0 deletions oura/customerio/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package customerio

import "github.com/tidepool-org/platform/log"

type Client struct {
appAPIBaseURL string
appAPIKey string
trackAPIBaseURL string
trackAPIKey string
siteID string
logger log.Logger
}

type Config struct {
AppAPIBaseURL string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_BASE_URL" default:"https://api.customer.io"`
AppAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_KEY"`
SegmentID string `envconfig:"TIDEPOOL_CUSTOMERIO_SEGMENT_ID"`
SiteID string `envconfig:"TIDEPOOL_CUSTOMERIO_SITE_ID"`
TrackAPIBaseURL string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_BASE_URL" default:"https://track.customer.io/api/"`
TrackAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_KEY"`
}

func NewClient(config Config, logger log.Logger) (*Client, error) {
return &Client{
appAPIKey: config.AppAPIKey,
trackAPIKey: config.TrackAPIKey,
siteID: config.SiteID,
appAPIBaseURL: config.AppAPIBaseURL,
trackAPIBaseURL: config.TrackAPIBaseURL,
logger: logger,
}, nil
}
140 changes: 140 additions & 0 deletions oura/customerio/customer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package customerio

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)

const (
IDTypeCIOID IDType = "cio_id"
IDTypeUserID IDType = "id"
)

type IDType string

type Customer struct {
Identifiers `json:",inline"`
Attributes `json:",inline"`
}

type Attributes struct {
Phase1 string `json:"phase1,omitempty"`
UserID string `json:"user_id"`

OuraSizingKitDiscountCode string `json:"oura_sizing_kit_discount_code,omitempty"`
OuraRingDiscountCode string `json:"oura_ring_discount_code,omitempty"`
OuraParticipantID string `json:"oura_participant_id,omitempty"`

Update bool `json:"_update,omitempty"`
}

type customerResponse struct {
Customer struct {
ID string
Identifiers Identifiers `json:"identifiers"`
Attributes Attributes `json:"attributes"`
} `json:"customer"`
}

type entityRequest struct {
Type string `json:"type"`
Identifiers map[string]string `json:"identifiers"`
Action string `json:"action"`
Attributes Attributes `json:"attributes,omitempty"`
}

type errorResponse struct {
Errors []struct {
Reason string `json:"reason,omitempty"`
Field string `json:"field,omitempty"`
Message string `json:"message,omitempty"`
} `json:"errors,omitempty"`
}

func (c *Client) GetCustomer(ctx context.Context, cid string, typ IDType) (*Customer, error) {
url := fmt.Sprintf("%s/v1/customers/%s/attributes", c.appAPIBaseURL, cid)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Add query parameter for id_type if using cio_id
q := req.URL.Query()
q.Add("id_type", string(typ))
req.URL.RawQuery = q.Encode()

// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.appAPIKey))
req.Header.Set("Content-Type", "application/json")

c.logger.WithField("cid", cid).WithField("url", req.URL.String()).Debug("fetching customer")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, nil
} else if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var response customerResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &Customer{
Identifiers: response.Customer.Identifiers,
Attributes: response.Customer.Attributes,
}, nil
}

func (c *Client) UpdateCustomer(ctx context.Context, customer Customer) error {
url := fmt.Sprintf("%s/v2/entity", c.trackAPIBaseURL)

// Prepare the request body
reqBody := entityRequest{
Type: "person",
Identifiers: map[string]string{"cio_id": customer.CID},
Action: "identify",
Attributes: customer.Attributes,
}
reqBody.Attributes.Update = true

jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

// Add the authorization header (Basic Auth for Track API)
req.SetBasicAuth(c.siteID, c.trackAPIKey)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
var errResp errorResponse
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil && len(errResp.Errors) > 0 {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Errors[0].Message)
}
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

return nil
}
70 changes: 70 additions & 0 deletions oura/customerio/segment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package customerio

import (
"context"
"encoding/json"
"fmt"
"net/http"
)

type Identifiers struct {
Email string `json:"email"`
ID string `json:"id"`
CID string `json:"cio_id"`
}

type segmentMembershipResponse struct {
Identifiers []Identifiers `json:"identifiers"`
IDs []string `json:"ids"`
Next string `json:"next,omitempty"`
}

func (c *Client) ListCustomersInSegment(ctx context.Context, segmentID string) ([]Identifiers, error) {
var allIdentifiers []Identifiers
start := ""

for {
url := fmt.Sprintf("%s/v1/segments/%s/membership", c.appAPIBaseURL, segmentID)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Add pagination parameter if available
if start != "" {
q := req.URL.Query()
q.Add("start", start)
req.URL.RawQuery = q.Encode()
}

// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.appAPIKey))
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var response segmentMembershipResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

allIdentifiers = append(allIdentifiers, response.Identifiers...)

// Check if there are more pages
if response.Next == "" {
break
}
start = response.Next
}

return allIdentifiers, nil
}
57 changes: 57 additions & 0 deletions oura/jotform/api/router.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"fmt"
"net/http"

"github.com/ant0ine/go-json-rest/rest"

"github.com/tidepool-org/platform/log"
"github.com/tidepool-org/platform/oura/jotform"
"github.com/tidepool-org/platform/request"
)

const (
multipartMaxMemory = 1000000 // 1MB
)

type Router struct {
webhookProcessor *jotform.WebhookProcessor
}

func NewRouter(webhookProcessor *jotform.WebhookProcessor) (*Router, error) {
return &Router{
webhookProcessor: webhookProcessor,
}, nil
}

func (r *Router) Routes() []*rest.Route {
return []*rest.Route{
rest.Post("/v1/partners/jotform/submission", r.HandleJotformSubmission),
}
}

func (r *Router) HandleJotformSubmission(res rest.ResponseWriter, req *rest.Request) {
ctx := req.Context()
responder := request.MustNewResponder(res, req)

if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
responder.Error(http.StatusInternalServerError, fmt.Errorf("unable to parse form data"))
return
}

values, ok := req.MultipartForm.Value["submissionID"]
if !ok || len(values) == 0 || len(values[0]) == 0 {
responder.Error(http.StatusBadRequest, fmt.Errorf("missing submission ID"))
return
}

err := r.webhookProcessor.ProcessSubmission(req.Context(), values[0])
if err != nil {
log.LoggerFromContext(ctx).WithError(err).Error("unable to process submission")
responder.Error(http.StatusInternalServerError, err)
return
}

responder.Empty(http.StatusOK)
}
11 changes: 11 additions & 0 deletions oura/jotform/jotform_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package jotform_test

import (
"testing"

"github.com/tidepool-org/platform/test"
)

func TestSuite(t *testing.T) {
test.Test(t)
}
Loading
Loading