Skip to content

Commit 61a0934

Browse files
committed
Add jotform webhooks for Oura
1 parent 3b13dea commit 61a0934

File tree

10 files changed

+646
-1
lines changed

10 files changed

+646
-1
lines changed

auth/service/service/service.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"time"
77

88
"github.com/tidepool-org/platform/mailer"
9+
"github.com/tidepool-org/platform/oura/customerio"
10+
"github.com/tidepool-org/platform/oura/jotform"
911

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

@@ -37,6 +39,8 @@ import (
3739
dexcomProvider "github.com/tidepool-org/platform/dexcom/provider"
3840
"github.com/tidepool-org/platform/errors"
3941
"github.com/tidepool-org/platform/events"
42+
jotformAPI "github.com/tidepool-org/platform/oura/jotform/api"
43+
4044
"github.com/tidepool-org/platform/log"
4145
oauthProvider "github.com/tidepool-org/platform/oauth/provider"
4246
"github.com/tidepool-org/platform/platform"
@@ -275,9 +279,35 @@ func (s *Service) initializeRouter() error {
275279
return errors.Wrap(err, "unable to create consent router")
276280
}
277281

282+
s.Logger().Debug("Creating jotform router")
283+
284+
jotformConfig := jotform.Config{}
285+
if err := envconfig.Process("", &jotformConfig); err != nil {
286+
return errors.Wrap(err, "unable to load jotform config")
287+
}
288+
289+
customerIOConfig := customerio.Config{}
290+
if err := envconfig.Process("", &customerIOConfig); err != nil {
291+
return errors.Wrap(err, "unable to load customerio config")
292+
}
293+
customerIOClient, err := customerio.NewClient(customerIOConfig)
294+
if err != nil {
295+
return errors.Wrap(err, "unable to create customerio client")
296+
}
297+
298+
webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, customerIOClient)
299+
if err != nil {
300+
return errors.Wrap(err, "unable to create jotform webhook processor")
301+
}
302+
303+
jotformRouter, err := jotformAPI.NewRouter(webhookProcessor)
304+
if err != nil {
305+
return errors.Wrap(err, "unable to create jotform router")
306+
}
307+
278308
s.Logger().Debug("Initializing routers")
279309

280-
if err = s.API().InitializeRouters(apiRouter, v1Router, consentV1Router); err != nil {
310+
if err = s.API().InitializeRouters(apiRouter, v1Router, consentV1Router, jotformRouter); err != nil {
281311
return errors.Wrap(err, "unable to initialize routers")
282312
}
283313

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/google/uuid v1.6.0
1818
github.com/gowebpki/jcs v1.0.1
1919
github.com/hashicorp/go-uuid v1.0.3
20+
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865
2021
github.com/kelseyhightower/envconfig v1.4.0
2122
github.com/lestrrat-go/jwx/v2 v2.1.4
2223
github.com/mitchellh/go-homedir v1.1.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
116116
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
117117
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
118118
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
119+
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865 h1:s/ZhV8gjiS///H7S/qm1iI/6YhMNx91t76S+61Q9tNU=
120+
github.com/jotform/jotform-api-go/v2 v2.0.0-20220216084719-035fd932c865/go.mod h1:VgH1fhSKgJgeRtI9FkzACcW+v3ZZQHVHxXQqX1fBnQM=
119121
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
120122
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
121123
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=

oura/customerio/client.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package customerio
2+
3+
const appAPIBaseURL = "https://api.customer.io"
4+
const trackAPIBaseURL = "https://track.customer.io/api/"
5+
6+
type Client struct {
7+
appAPIKey string
8+
trackAPIKey string
9+
siteID string
10+
appAPIBaseURL string
11+
trackAPIBaseURL string
12+
}
13+
14+
type Config struct {
15+
AppAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_KEY"`
16+
TrackAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_KEY"`
17+
SiteID string `envconfig:"TIDEPOOL_CUSTOMERIO_SITE_ID"`
18+
SegmentID string `envconfig:"TIDEPOOL_CUSTOMERIO_SEGMENT_ID"`
19+
}
20+
21+
func NewClient(config Config) (*Client, error) {
22+
return &Client{
23+
appAPIKey: config.AppAPIKey,
24+
trackAPIKey: config.TrackAPIKey,
25+
siteID: config.SiteID,
26+
appAPIBaseURL: appAPIBaseURL,
27+
trackAPIBaseURL: trackAPIBaseURL,
28+
}, nil
29+
}

oura/customerio/customer.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package customerio
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
)
10+
11+
const (
12+
IDTypeCIOID IDType = "cio_id"
13+
IDTypeUserID IDType = "id"
14+
)
15+
16+
type IDType string
17+
18+
type Customer struct {
19+
Identifiers `json:",inline"`
20+
Attributes `json:",inline"`
21+
}
22+
23+
type Attributes struct {
24+
Phase1 string `json:"phase1,omitempty"`
25+
UserID string `json:"user_id"`
26+
27+
OuraSizingKitDiscountCode string `json:"oura_sizing_kit_discount_code,omitempty"`
28+
OuraRingDiscountCode string `json:"oura_ring_discount_code,omitempty"`
29+
OuraParticipantID string `json:"oura_participant_id,omitempty"`
30+
31+
Update bool `json:"_update,omitempty"`
32+
}
33+
34+
type customerResponse struct {
35+
Customer struct {
36+
ID string
37+
Identifiers Identifiers `json:"identifiers"`
38+
Attributes Attributes `json:"attributes"`
39+
} `json:"customer"`
40+
}
41+
42+
type entityRequest struct {
43+
Type string `json:"type"`
44+
Identifiers map[string]string `json:"identifiers"`
45+
Action string `json:"action"`
46+
Attributes Attributes `json:"attributes,omitempty"`
47+
}
48+
49+
type errorResponse struct {
50+
Errors []struct {
51+
Reason string `json:"reason,omitempty"`
52+
Field string `json:"field,omitempty"`
53+
Message string `json:"message,omitempty"`
54+
} `json:"errors,omitempty"`
55+
}
56+
57+
func (c *Client) GetCustomer(ctx context.Context, cid string, typ IDType) (*Customer, error) {
58+
url := fmt.Sprintf("%s/v1/customers/%s/attributes", c.appAPIBaseURL, cid)
59+
60+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create request: %w", err)
63+
}
64+
65+
// Add query parameter for id_type if using cio_id
66+
q := req.URL.Query()
67+
q.Add("id_type", string(typ))
68+
req.URL.RawQuery = q.Encode()
69+
70+
// Add authorization header
71+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.appAPIKey))
72+
req.Header.Set("Content-Type", "application/json")
73+
74+
resp, err := http.DefaultClient.Do(req)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to execute request: %w", err)
77+
}
78+
defer resp.Body.Close()
79+
80+
if resp.StatusCode == http.StatusNotFound {
81+
return nil, nil
82+
} else if resp.StatusCode != http.StatusOK {
83+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
84+
}
85+
86+
var response customerResponse
87+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
88+
return nil, fmt.Errorf("failed to decode response: %w", err)
89+
}
90+
91+
return &Customer{
92+
Identifiers: response.Customer.Identifiers,
93+
Attributes: response.Customer.Attributes,
94+
}, nil
95+
}
96+
97+
func (c *Client) UpdateCustomer(ctx context.Context, customer Customer) error {
98+
url := fmt.Sprintf("%s/v2/entity", c.trackAPIBaseURL)
99+
100+
// Prepare the request body
101+
reqBody := entityRequest{
102+
Type: "person",
103+
Identifiers: map[string]string{"cio_id": customer.CID},
104+
Action: "identify",
105+
Attributes: customer.Attributes,
106+
}
107+
reqBody.Attributes.Update = true
108+
109+
jsonBody, err := json.Marshal(reqBody)
110+
if err != nil {
111+
return fmt.Errorf("failed to marshal request body: %w", err)
112+
}
113+
114+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonBody))
115+
if err != nil {
116+
return fmt.Errorf("failed to create request: %w", err)
117+
}
118+
119+
// Add the authorization header (Basic Auth for Track API)
120+
req.SetBasicAuth(c.siteID, c.trackAPIKey)
121+
req.Header.Set("Content-Type", "application/json")
122+
123+
resp, err := http.DefaultClient.Do(req)
124+
if err != nil {
125+
return fmt.Errorf("failed to execute request: %w", err)
126+
}
127+
defer resp.Body.Close()
128+
129+
if resp.StatusCode != http.StatusOK {
130+
var errResp errorResponse
131+
if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil && len(errResp.Errors) > 0 {
132+
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errResp.Errors[0].Message)
133+
}
134+
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
135+
}
136+
137+
return nil
138+
}

oura/customerio/segment.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package customerio
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
)
9+
10+
type Identifiers struct {
11+
Email string `json:"email"`
12+
ID string `json:"id"`
13+
CID string `json:"cio_id"`
14+
}
15+
16+
type segmentMembershipResponse struct {
17+
Identifiers []Identifiers `json:"identifiers"`
18+
IDs []string `json:"ids"`
19+
Next string `json:"next,omitempty"`
20+
}
21+
22+
func (c *Client) ListCustomersInSegment(ctx context.Context, segmentID string) ([]Identifiers, error) {
23+
var allIdentifiers []Identifiers
24+
start := ""
25+
26+
for {
27+
url := fmt.Sprintf("%s/v1/segments/%s/membership", c.appAPIBaseURL, segmentID)
28+
29+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to create request: %w", err)
32+
}
33+
34+
// Add pagination parameter if available
35+
if start != "" {
36+
q := req.URL.Query()
37+
q.Add("start", start)
38+
req.URL.RawQuery = q.Encode()
39+
}
40+
41+
// Add authorization header
42+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.appAPIKey))
43+
req.Header.Set("Content-Type", "application/json")
44+
45+
resp, err := http.DefaultClient.Do(req)
46+
if err != nil {
47+
return nil, fmt.Errorf("failed to execute request: %w", err)
48+
}
49+
defer resp.Body.Close()
50+
51+
if resp.StatusCode != http.StatusOK {
52+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
53+
}
54+
55+
var response segmentMembershipResponse
56+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
57+
return nil, fmt.Errorf("failed to decode response: %w", err)
58+
}
59+
60+
allIdentifiers = append(allIdentifiers, response.Identifiers...)
61+
62+
// Check if there are more pages
63+
if response.Next == "" {
64+
break
65+
}
66+
start = response.Next
67+
}
68+
69+
return allIdentifiers, nil
70+
}

oura/jotform/api/router.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/ant0ine/go-json-rest/rest"
8+
9+
"github.com/tidepool-org/platform/log"
10+
"github.com/tidepool-org/platform/oura/jotform"
11+
"github.com/tidepool-org/platform/request"
12+
)
13+
14+
const (
15+
multipartMaxMemory = 1000000 // 1MB
16+
)
17+
18+
type Router struct {
19+
webhookProcessor *jotform.WebhookProcessor
20+
}
21+
22+
func NewRouter(webhookProcessor *jotform.WebhookProcessor) (*Router, error) {
23+
return &Router{
24+
webhookProcessor: webhookProcessor,
25+
}, nil
26+
}
27+
28+
func (r *Router) Routes() []*rest.Route {
29+
return []*rest.Route{
30+
rest.Post("/v1/partners/jotform/submission", r.HandleJotformSubmission),
31+
}
32+
}
33+
34+
func (r *Router) HandleJotformSubmission(res rest.ResponseWriter, req *rest.Request) {
35+
ctx := req.Context()
36+
responder := request.MustNewResponder(res, req)
37+
38+
if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
39+
responder.Error(http.StatusInternalServerError, fmt.Errorf("unable to parse form data"))
40+
return
41+
}
42+
43+
values, ok := req.MultipartForm.Value["submissionID"]
44+
if !ok || len(values) == 0 || len(values[0]) == 0 {
45+
responder.Error(http.StatusBadRequest, fmt.Errorf("missing submission ID"))
46+
return
47+
}
48+
49+
err := r.webhookProcessor.ProcessSubmission(req.Context(), values[0])
50+
if err != nil {
51+
log.LoggerFromContext(ctx).WithError(err).Error("unable to process submission")
52+
responder.Error(http.StatusInternalServerError, err)
53+
return
54+
}
55+
56+
responder.Empty(http.StatusOK)
57+
}

oura/jotform/oura.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package jotform
2+
3+
import (
4+
"time"
5+
6+
"github.com/tidepool-org/platform/structure/validator"
7+
)
8+
9+
type OuraEligibilitySurvey struct {
10+
DateOfBirth string
11+
Name string
12+
}
13+
14+
func (o *OuraEligibilitySurvey) Validate(v *validator.Validator) {
15+
eighteenYearsAgo := time.Now().AddDate(-18, 0, 0)
16+
v.String("dateOfBirth", &o.DateOfBirth).NotEmpty().AsTime(time.DateOnly).Before(eighteenYearsAgo)
17+
v.String("name", &o.Name).NotEmpty()
18+
}

0 commit comments

Comments
 (0)