From 61a093499807b76b302a3eada80ee661d06cbae3 Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Thu, 4 Dec 2025 15:35:08 +0200 Subject: [PATCH 1/5] Add jotform webhooks for Oura --- auth/service/service/service.go | 32 +++++- go.mod | 1 + go.sum | 2 + oura/customerio/client.go | 29 +++++ oura/customerio/customer.go | 138 +++++++++++++++++++++++ oura/customerio/segment.go | 70 ++++++++++++ oura/jotform/api/router.go | 57 ++++++++++ oura/jotform/oura.go | 18 +++ oura/jotform/submission.go | 106 +++++++++++++++++ oura/jotform/webhook.go | 194 ++++++++++++++++++++++++++++++++ 10 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 oura/customerio/client.go create mode 100644 oura/customerio/customer.go create mode 100644 oura/customerio/segment.go create mode 100644 oura/jotform/api/router.go create mode 100644 oura/jotform/oura.go create mode 100644 oura/jotform/submission.go create mode 100644 oura/jotform/webhook.go diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 3d6693951..2f47438ec 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -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" @@ -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" @@ -275,9 +279,35 @@ 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) + if err != nil { + return errors.Wrap(err, "unable to create customerio client") + } + + webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, customerIOClient) + 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") } diff --git a/go.mod b/go.mod index fcb2c4dc0..c3de5d8f0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4034e852d..ec669a2af 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/oura/customerio/client.go b/oura/customerio/client.go new file mode 100644 index 000000000..d44b5ac51 --- /dev/null +++ b/oura/customerio/client.go @@ -0,0 +1,29 @@ +package customerio + +const appAPIBaseURL = "https://api.customer.io" +const trackAPIBaseURL = "https://track.customer.io/api/" + +type Client struct { + appAPIKey string + trackAPIKey string + siteID string + appAPIBaseURL string + trackAPIBaseURL string +} + +type Config struct { + AppAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_KEY"` + TrackAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_KEY"` + SiteID string `envconfig:"TIDEPOOL_CUSTOMERIO_SITE_ID"` + SegmentID string `envconfig:"TIDEPOOL_CUSTOMERIO_SEGMENT_ID"` +} + +func NewClient(config Config) (*Client, error) { + return &Client{ + appAPIKey: config.AppAPIKey, + trackAPIKey: config.TrackAPIKey, + siteID: config.SiteID, + appAPIBaseURL: appAPIBaseURL, + trackAPIBaseURL: trackAPIBaseURL, + }, nil +} diff --git a/oura/customerio/customer.go b/oura/customerio/customer.go new file mode 100644 index 000000000..739b7063b --- /dev/null +++ b/oura/customerio/customer.go @@ -0,0 +1,138 @@ +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") + + 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 +} diff --git a/oura/customerio/segment.go b/oura/customerio/segment.go new file mode 100644 index 000000000..107e40560 --- /dev/null +++ b/oura/customerio/segment.go @@ -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 +} diff --git a/oura/jotform/api/router.go b/oura/jotform/api/router.go new file mode 100644 index 000000000..c7e751d64 --- /dev/null +++ b/oura/jotform/api/router.go @@ -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) +} diff --git a/oura/jotform/oura.go b/oura/jotform/oura.go new file mode 100644 index 000000000..4a35036df --- /dev/null +++ b/oura/jotform/oura.go @@ -0,0 +1,18 @@ +package jotform + +import ( + "time" + + "github.com/tidepool-org/platform/structure/validator" +) + +type OuraEligibilitySurvey struct { + DateOfBirth string + Name string +} + +func (o *OuraEligibilitySurvey) Validate(v *validator.Validator) { + eighteenYearsAgo := time.Now().AddDate(-18, 0, 0) + v.String("dateOfBirth", &o.DateOfBirth).NotEmpty().AsTime(time.DateOnly).Before(eighteenYearsAgo) + v.String("name", &o.Name).NotEmpty() +} diff --git a/oura/jotform/submission.go b/oura/jotform/submission.go new file mode 100644 index 000000000..a25271d59 --- /dev/null +++ b/oura/jotform/submission.go @@ -0,0 +1,106 @@ +package jotform + +import ( + "encoding/json" + "fmt" +) + +type SubmissionResponse struct { + Content Content `json:"content"` +} + +type Content struct { + ID string `json:"id"` + Answers Answers `json:"answers"` +} + +type Answer interface { + Name() string + Answer() string +} + +type BaseAnswer struct { + NameField string `json:"name"` + Order string `json:"order"` + Text string `json:"text"` + Type string `json:"type"` + Sublabels string `json:"sublabels,omitempty"` +} + +type ControlTextbox struct { + BaseAnswer + AnswerField string `json:"answer"` +} + +func (c ControlTextbox) Name() string { + return c.NameField +} + +func (c ControlTextbox) Answer() string { + return c.AnswerField +} + +type ControlFullname struct { + BaseAnswer + AnswerField map[string]string `json:"answer"` + PrettyFormat string `json:"prettyFormat"` +} + +func (c ControlFullname) Name() string { + return c.NameField +} + +func (c ControlFullname) Answer() string { + return c.PrettyFormat +} + +type Answers map[string]Answer + +func (a Answers) GetAnswerTextByName(name string) string { + answer, ok := a[name] + if !ok { + return "" + } + return answer.Answer() +} + +// UnmarshalJSON implements custom unmarshaling for AnswersMap +func (a *Answers) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + *a = make(map[string]Answer) + + for key, rawMsg := range raw { + var typeInfo struct { + Type string `json:"type"` + } + if err := json.Unmarshal(rawMsg, &typeInfo); err != nil { + return fmt.Errorf("failed to unmarshal type for key %s: %w", key, err) + } + + var answer Answer + switch typeInfo.Type { + case "control_textbox": + var ct ControlTextbox + if err := json.Unmarshal(rawMsg, &ct); err != nil { + return fmt.Errorf("failed to unmarshal ControlTextbox for key %s: %w", key, err) + } + answer = ct + case "control_fullname": + var cf ControlFullname + if err := json.Unmarshal(rawMsg, &cf); err != nil { + return fmt.Errorf("failed to unmarshal ControlFullname for key %s: %w", key, err) + } + answer = cf + default: + return fmt.Errorf("unknown answer type: %s for key %s", typeInfo.Type, key) + } + + (*a)[answer.Name()] = answer + } + + return nil +} diff --git a/oura/jotform/webhook.go b/oura/jotform/webhook.go new file mode 100644 index 000000000..7a85f1e72 --- /dev/null +++ b/oura/jotform/webhook.go @@ -0,0 +1,194 @@ +package jotform + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/tidepool-org/platform/consent" + "github.com/tidepool-org/platform/errors" + "github.com/tidepool-org/platform/log" + "github.com/tidepool-org/platform/oura/customerio" + "github.com/tidepool-org/platform/page" + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/structure/validator" +) + +const ( + BaseURL = "https://api.jotform.com" + + EligibleField = "eligible" + NameField = "name" + DateOfBirthField = "dateOfBirth" + + UserIDField = "participantId" + ParticipantIDField = "userId" +) + +type WebhookProcessor struct { + baseURL string + apiKey string + + logger log.Logger + + consentService consent.Service + customerIOClient *customerio.Client +} + +type Config struct { + BaseURL string `envconfig:"TIDEPOOL_JOTFORM_BASE_URL"` + APIKey string `envconfig:"TIDEPOOL_JOTFORM_API_KEY"` +} + +func NewWebhookProcessor(config Config, logger log.Logger, consentService consent.Service, customerIOClient *customerio.Client) (*WebhookProcessor, error) { + return &WebhookProcessor{ + apiKey: config.APIKey, + baseURL: config.BaseURL, + + logger: logger, + + consentService: consentService, + customerIOClient: customerIOClient, + }, nil +} + +func (w *WebhookProcessor) ProcessSubmission(ctx context.Context, submissionID string) error { + logger := w.logger.WithField("submission", submissionID) + submission, err := w.getSubmission(ctx, submissionID) + if err != nil { + return errors.Wrap(err, "failed to get submission") + } + if submission.Content.Answers == nil { + logger.Warn("submission has no answers") + return nil + } + if submission.Content.Answers.GetAnswerTextByName(EligibleField) != "true" { + logger.Warn("submission is not eligible") + return nil + } + userID, err := w.validateUser(ctx, submissionID, submission.Content.Answers) + if err != nil { + logger.WithError(err).Warn("user is invalid") + return nil + } + + if err := w.ensureConsentRecordExists(ctx, userID, submission); err != nil { + logger.WithError(err).Warn("unable to ensure consent record exists") + return err + } + + return nil +} + +// validateUser validates the user id by comparing the participant id from the submission with the participant id from customer.io +// this is required because jotform webhooks are not signed or authenticated +func (w *WebhookProcessor) validateUser(ctx context.Context, submissionID string, answers Answers) (string, error) { + logger := w.logger.WithField("submission", submissionID) + + userID := answers.GetAnswerTextByName(UserIDField) + if userID == "" { + logger.Debugf("submission has no user id") + return "", nil + } + + participantID := answers.GetAnswerTextByName(ParticipantIDField) + if participantID == "" { + logger.Debugf("submission has no participant id") + return "", nil + } + + customer, err := w.customerIOClient.GetCustomer(ctx, userID, customerio.IDTypeUserID) + if err != nil { + return "", errors.Wrap(err, "unable to get customer") + } + + if customer == nil { + return "", errors.New("customer not found") + } + if customer.OuraParticipantID != participantID { + return "", errors.New("participant id mismatch") + } + return userID, nil +} + +func (w *WebhookProcessor) ensureConsentRecordExists(ctx context.Context, userID string, submission *SubmissionResponse) error { + logger := w.logger.WithField("submission", submission.Content.ID) + + survey := OuraEligibilitySurvey{} + if dob, ok := submission.Content.Answers[DateOfBirthField]; ok && dob.Answer() != "" { + survey.DateOfBirth = dob.Answer() + } + if name, ok := submission.Content.Answers[NameField]; ok && name.Answer() != "" { + survey.Name = name.Answer() + } + + v := validator.New(w.logger) + survey.Validate(v) + if err := v.Error(); err != nil { + logger.Warn("consent survey is invalid") + return nil + } + + filter := consent.NewRecordFilter() + filter.Latest = pointer.FromAny(true) + filter.Status = pointer.FromAny(consent.RecordStatusActive) + filter.Type = pointer.FromAny(consent.TypeBigDataDonationProject) + filter.Version = pointer.FromAny(1) + + pagination := page.NewPagination() + + records, err := w.consentService.ListConsentRecords(ctx, userID, filter, pagination) + if err != nil { + return errors.Wrap(err, "unable to list consent records") + } + + if records.Count > 0 { + logger.WithField("userId", userID).Info("consent record already exists") + return nil + } + + create := consent.NewRecordCreate() + create.AgeGroup = consent.AgeGroupEighteenOrOver + create.GrantorType = consent.GrantorTypeOwner + create.OwnerName = survey.Name + create.Type = consent.TypeBigDataDonationProject + create.Version = 1 + + _, err = w.consentService.CreateConsentRecord(ctx, userID, create) + if err != nil { + return errors.Wrap(err, "unable to create consent record") + } + + return nil +} + +func (w *WebhookProcessor) getSubmission(ctx context.Context, submissionID string) (*SubmissionResponse, error) { + url := fmt.Sprintf("%s/v1/submissions/%s", BaseURL, submissionID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request: %w") + } + + // Add authorization header + req.Header.Set("APIKEY", w.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to execute request: %w") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Newf("unexpected status code: %d", resp.StatusCode) + } + + var response SubmissionResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, errors.Wrap(err, "failed to decode response") + } + + return &response, nil +} From 599118cc14bd1dfe1343db720cf325bbe4264083 Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Fri, 5 Dec 2025 14:45:10 +0200 Subject: [PATCH 2/5] Use the configured jotform API base url --- oura/jotform/submission.go | 5 +++-- oura/jotform/webhook.go | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/oura/jotform/submission.go b/oura/jotform/submission.go index a25271d59..495160e79 100644 --- a/oura/jotform/submission.go +++ b/oura/jotform/submission.go @@ -6,7 +6,8 @@ import ( ) type SubmissionResponse struct { - Content Content `json:"content"` + ResponseCode int `json:"responseCode"` + Content Content `json:"content"` } type Content struct { @@ -96,7 +97,7 @@ func (a *Answers) UnmarshalJSON(data []byte) error { } answer = cf default: - return fmt.Errorf("unknown answer type: %s for key %s", typeInfo.Type, key) + continue } (*a)[answer.Name()] = answer diff --git a/oura/jotform/webhook.go b/oura/jotform/webhook.go index 7a85f1e72..dcbe45de8 100644 --- a/oura/jotform/webhook.go +++ b/oura/jotform/webhook.go @@ -16,8 +16,6 @@ import ( ) const ( - BaseURL = "https://api.jotform.com" - EligibleField = "eligible" NameField = "name" DateOfBirthField = "dateOfBirth" @@ -164,7 +162,7 @@ func (w *WebhookProcessor) ensureConsentRecordExists(ctx context.Context, userID } func (w *WebhookProcessor) getSubmission(ctx context.Context, submissionID string) (*SubmissionResponse, error) { - url := fmt.Sprintf("%s/v1/submissions/%s", BaseURL, submissionID) + url := fmt.Sprintf("%s/v1/submission/%s", w.baseURL, submissionID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -190,5 +188,10 @@ func (w *WebhookProcessor) getSubmission(ctx context.Context, submissionID strin return nil, errors.Wrap(err, "failed to decode response") } + // Sometimes the jotform webhook returns a 200 http response with a non-200 response code in the body + if response.ResponseCode != http.StatusOK { + return nil, errors.Newf("unexpected response code: %d", response.ResponseCode) + } + return &response, nil } From 48c809f191a922fefc19ae74ff331b46c58fc82c Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Mon, 8 Dec 2025 14:40:01 +0200 Subject: [PATCH 3/5] Ensure user exists before creating consent --- auth/service/service/service.go | 14 ++++++++++++-- oura/customerio/client.go | 6 +++++- oura/customerio/customer.go | 2 ++ oura/jotform/webhook.go | 33 +++++++++++++++++++++------------ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 2f47438ec..e433063a7 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -290,12 +290,22 @@ func (s *Service) initializeRouter() error { if err := envconfig.Process("", &customerIOConfig); err != nil { return errors.Wrap(err, "unable to load customerio config") } - customerIOClient, err := customerio.NewClient(customerIOConfig) + customerIOClient, err := customerio.NewClient(customerIOConfig, s.Logger()) if err != nil { return errors.Wrap(err, "unable to create customerio client") } - webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, customerIOClient) + 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") } diff --git a/oura/customerio/client.go b/oura/customerio/client.go index d44b5ac51..72e854ad6 100644 --- a/oura/customerio/client.go +++ b/oura/customerio/client.go @@ -1,5 +1,7 @@ package customerio +import "github.com/tidepool-org/platform/log" + const appAPIBaseURL = "https://api.customer.io" const trackAPIBaseURL = "https://track.customer.io/api/" @@ -9,6 +11,7 @@ type Client struct { siteID string appAPIBaseURL string trackAPIBaseURL string + logger log.Logger } type Config struct { @@ -18,12 +21,13 @@ type Config struct { SegmentID string `envconfig:"TIDEPOOL_CUSTOMERIO_SEGMENT_ID"` } -func NewClient(config Config) (*Client, error) { +func NewClient(config Config, logger log.Logger) (*Client, error) { return &Client{ appAPIKey: config.AppAPIKey, trackAPIKey: config.TrackAPIKey, siteID: config.SiteID, appAPIBaseURL: appAPIBaseURL, trackAPIBaseURL: trackAPIBaseURL, + logger: logger, }, nil } diff --git a/oura/customerio/customer.go b/oura/customerio/customer.go index 739b7063b..1cc6a7f9e 100644 --- a/oura/customerio/customer.go +++ b/oura/customerio/customer.go @@ -71,6 +71,8 @@ func (c *Client) GetCustomer(ctx context.Context, cid string, typ IDType) (*Cust 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) diff --git a/oura/jotform/webhook.go b/oura/jotform/webhook.go index dcbe45de8..08a98ceda 100644 --- a/oura/jotform/webhook.go +++ b/oura/jotform/webhook.go @@ -13,6 +13,7 @@ import ( "github.com/tidepool-org/platform/page" "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/structure/validator" + "github.com/tidepool-org/platform/user" ) const ( @@ -20,8 +21,8 @@ const ( NameField = "name" DateOfBirthField = "dateOfBirth" - UserIDField = "participantId" - ParticipantIDField = "userId" + UserIDField = "userId" + ParticipantIDField = "participantId" ) type WebhookProcessor struct { @@ -32,6 +33,7 @@ type WebhookProcessor struct { consentService consent.Service customerIOClient *customerio.Client + userClient user.Client } type Config struct { @@ -39,7 +41,7 @@ type Config struct { APIKey string `envconfig:"TIDEPOOL_JOTFORM_API_KEY"` } -func NewWebhookProcessor(config Config, logger log.Logger, consentService consent.Service, customerIOClient *customerio.Client) (*WebhookProcessor, error) { +func NewWebhookProcessor(config Config, logger log.Logger, consentService consent.Service, customerIOClient *customerio.Client, userClient user.Client) (*WebhookProcessor, error) { return &WebhookProcessor{ apiKey: config.APIKey, baseURL: config.BaseURL, @@ -48,6 +50,7 @@ func NewWebhookProcessor(config Config, logger log.Logger, consentService consen consentService: consentService, customerIOClient: customerIOClient, + userClient: userClient, }, nil } @@ -98,27 +101,33 @@ func (w *WebhookProcessor) validateUser(ctx context.Context, submissionID string customer, err := w.customerIOClient.GetCustomer(ctx, userID, customerio.IDTypeUserID) if err != nil { - return "", errors.Wrap(err, "unable to get customer") + return "", errors.Wrapf(err, "unable to get customer with id %s", userID) } if customer == nil { - return "", errors.New("customer not found") + return "", errors.Newf("customer with id %s not found", userID) } if customer.OuraParticipantID != participantID { - return "", errors.New("participant id mismatch") + return "", errors.Newf("participant id mismatch for user with id %s", userID) } + + usr, err := w.userClient.Get(ctx, userID) + if err != nil { + return "", errors.Wrap(err, "unable to get user") + } + if usr == nil { + return "", errors.New("user not found") + } + return userID, nil } func (w *WebhookProcessor) ensureConsentRecordExists(ctx context.Context, userID string, submission *SubmissionResponse) error { logger := w.logger.WithField("submission", submission.Content.ID) - survey := OuraEligibilitySurvey{} - if dob, ok := submission.Content.Answers[DateOfBirthField]; ok && dob.Answer() != "" { - survey.DateOfBirth = dob.Answer() - } - if name, ok := submission.Content.Answers[NameField]; ok && name.Answer() != "" { - survey.Name = name.Answer() + survey := OuraEligibilitySurvey{ + DateOfBirth: submission.Content.Answers.GetAnswerTextByName(DateOfBirthField), + Name: submission.Content.Answers.GetAnswerTextByName(NameField), } v := validator.New(w.logger) From 01c769fefceda58c9c143c649db2599a9b5da831 Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Mon, 8 Dec 2025 15:34:37 +0200 Subject: [PATCH 4/5] Parse date time fields correctly --- oura/jotform/oura.go | 2 +- oura/jotform/submission.go | 27 +++++++++++++++++++++++++++ oura/jotform/webhook.go | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/oura/jotform/oura.go b/oura/jotform/oura.go index 4a35036df..1072417c7 100644 --- a/oura/jotform/oura.go +++ b/oura/jotform/oura.go @@ -13,6 +13,6 @@ type OuraEligibilitySurvey struct { func (o *OuraEligibilitySurvey) Validate(v *validator.Validator) { eighteenYearsAgo := time.Now().AddDate(-18, 0, 0) - v.String("dateOfBirth", &o.DateOfBirth).NotEmpty().AsTime(time.DateOnly).Before(eighteenYearsAgo) + v.String("dateOfBirth", &o.DateOfBirth).NotEmpty().AsTime(time.DateTime).Before(eighteenYearsAgo) v.String("name", &o.Name).NotEmpty() } diff --git a/oura/jotform/submission.go b/oura/jotform/submission.go index 495160e79..bbd008699 100644 --- a/oura/jotform/submission.go +++ b/oura/jotform/submission.go @@ -55,6 +55,27 @@ func (c ControlFullname) Answer() string { return c.PrettyFormat } +type ControlDateTime struct { + BaseAnswer + AnswerField DateTimeAnswerField `json:"answer"` + PrettyFormat string `json:"prettyFormat"` +} + +func (c ControlDateTime) Name() string { + return c.NameField +} + +func (c ControlDateTime) Answer() string { + return c.AnswerField.DateTime +} + +type DateTimeAnswerField struct { + Year string `json:"year"` + Month string `json:"month"` + Day string `json:"day"` + DateTime string `json:"datetime"` +} + type Answers map[string]Answer func (a Answers) GetAnswerTextByName(name string) string { @@ -96,6 +117,12 @@ func (a *Answers) UnmarshalJSON(data []byte) error { return fmt.Errorf("failed to unmarshal ControlFullname for key %s: %w", key, err) } answer = cf + case "control_datetime": + var cd ControlDateTime + if err := json.Unmarshal(rawMsg, &cd); err != nil { + return fmt.Errorf("failed to unmarshal ControlDateTime for key %s: %w", key, err) + } + answer = cd default: continue } diff --git a/oura/jotform/webhook.go b/oura/jotform/webhook.go index 08a98ceda..f93b737b0 100644 --- a/oura/jotform/webhook.go +++ b/oura/jotform/webhook.go @@ -133,7 +133,7 @@ func (w *WebhookProcessor) ensureConsentRecordExists(ctx context.Context, userID v := validator.New(w.logger) survey.Validate(v) if err := v.Error(); err != nil { - logger.Warn("consent survey is invalid") + logger.WithError(err).Warn("consent survey is invalid") return nil } From aea76a4686f0f55b485cec7b0745244948250fc6 Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Tue, 9 Dec 2025 14:24:47 +0200 Subject: [PATCH 5/5] Add tests for jotform webhooks --- oura/customerio/client.go | 21 +- oura/jotform/jotform_suite_test.go | 11 + oura/jotform/test/fixtures/customer.json | 36 +++ oura/jotform/test/fixtures/submission.json | 87 +++++++ .../submission_participant_mismatch.json | 87 +++++++ oura/jotform/test/jotform.go | 15 ++ oura/jotform/test/stubs.go | 38 +++ oura/jotform/webhook_test.go | 221 ++++++++++++++++++ 8 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 oura/jotform/jotform_suite_test.go create mode 100644 oura/jotform/test/fixtures/customer.json create mode 100644 oura/jotform/test/fixtures/submission.json create mode 100644 oura/jotform/test/fixtures/submission_participant_mismatch.json create mode 100644 oura/jotform/test/jotform.go create mode 100644 oura/jotform/test/stubs.go create mode 100644 oura/jotform/webhook_test.go diff --git a/oura/customerio/client.go b/oura/customerio/client.go index 72e854ad6..a96710066 100644 --- a/oura/customerio/client.go +++ b/oura/customerio/client.go @@ -2,23 +2,22 @@ package customerio import "github.com/tidepool-org/platform/log" -const appAPIBaseURL = "https://api.customer.io" -const trackAPIBaseURL = "https://track.customer.io/api/" - type Client struct { + appAPIBaseURL string appAPIKey string + trackAPIBaseURL string trackAPIKey string siteID string - appAPIBaseURL string - trackAPIBaseURL string logger log.Logger } type Config struct { - AppAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_KEY"` - TrackAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_KEY"` - SiteID string `envconfig:"TIDEPOOL_CUSTOMERIO_SITE_ID"` - SegmentID string `envconfig:"TIDEPOOL_CUSTOMERIO_SEGMENT_ID"` + 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) { @@ -26,8 +25,8 @@ func NewClient(config Config, logger log.Logger) (*Client, error) { appAPIKey: config.AppAPIKey, trackAPIKey: config.TrackAPIKey, siteID: config.SiteID, - appAPIBaseURL: appAPIBaseURL, - trackAPIBaseURL: trackAPIBaseURL, + appAPIBaseURL: config.AppAPIBaseURL, + trackAPIBaseURL: config.TrackAPIBaseURL, logger: logger, }, nil } diff --git a/oura/jotform/jotform_suite_test.go b/oura/jotform/jotform_suite_test.go new file mode 100644 index 000000000..f5bc4a831 --- /dev/null +++ b/oura/jotform/jotform_suite_test.go @@ -0,0 +1,11 @@ +package jotform_test + +import ( + "testing" + + "github.com/tidepool-org/platform/test" +) + +func TestSuite(t *testing.T) { + test.Test(t) +} diff --git a/oura/jotform/test/fixtures/customer.json b/oura/jotform/test/fixtures/customer.json new file mode 100644 index 000000000..82773a708 --- /dev/null +++ b/oura/jotform/test/fixtures/customer.json @@ -0,0 +1,36 @@ +{ + "customer": { + "id": "01234567890", + "identifiers": { + "cio_id": "cio_0987654321", + "email": "james.jellyfish@tidepool.org", + "id": "1aacb960-430c-4081-8b3b-a32688807dc5" + }, + "attributes": { + "Opted In": "Y", + "Recent Data": "Y", + "cio_id": "cio_0987654321", + "created_at": "1750853383", + "email": "james.jellyfish@tidepool.org", + "id": "1aacb960-430c-4081-8b3b-a32688807dc5", + "oura_participant_id": "06336454-cf65-11f0-b3af-fac742a1f967", + "oura_ring_discount_code": "4NSG9996R26C", + "oura_sizing_kit_discount_code": "K25Y7999BGM4" + }, + "timestamps": { + "Opted In": 1750853452, + "Recent Data": 1750853452, + "cio_id": 1750853452, + "created_at": 1750853452, + "email": 0, + "id": 1750853452, + "oura_discount_code": 1764073477, + "oura_participant_id": 1764669325, + "oura_ring_discount_code": 1764669325, + "oura_sizing_kit_discount_code": 1764669325, + "ring_submitted_date": 1750854043 + }, + "unsubscribed": false, + "devices": [] + } +} \ No newline at end of file diff --git a/oura/jotform/test/fixtures/submission.json b/oura/jotform/test/fixtures/submission.json new file mode 100644 index 000000000..e66131b96 --- /dev/null +++ b/oura/jotform/test/fixtures/submission.json @@ -0,0 +1,87 @@ +{ + "responseCode": 200, + "message": "success", + "content": { + "id": "6410095903544943563", + "form_id": "253343950262051", + "ip": "130.204.74.53", + "created_at": "2025-12-08 08:26:30", + "status": "ACTIVE", + "new": "0", + "flag": "0", + "notes": "", + "updated_at": null, + "answers": { + "1": { + "name": "heading", + "order": "1", + "text": "Form", + "type": "control_head" + }, + "2": { + "name": "submit2", + "order": "8", + "text": "Submit", + "type": "control_button" + }, + "3": { + "name": "name", + "order": "2", + "sublabels": "{\"prefix\":\"Prefix\",\"first\":\"First Name\",\"middle\":\"Middle Name\",\"last\":\"Last Name\",\"suffix\":\"Suffix\"}", + "text": "Name", + "type": "control_fullname", + "answer": { + "first": "James", + "last": "Jellyfish" + }, + "prettyFormat": "James Jellyfish" + }, + "4": { + "name": "userId", + "order": "4", + "text": "userId", + "type": "control_textbox", + "answer": "1aacb960-430c-4081-8b3b-a32688807dc5" + }, + "5": { + "name": "participantId", + "order": "5", + "text": "participantId", + "type": "control_textbox", + "answer": "06336454-cf65-11f0-b3af-fac742a1f967" + }, + "8": { + "name": "eligible", + "order": "6", + "text": "eligible", + "type": "control_textbox", + "answer": "true" + }, + "9": { + "name": "dateOfBirth", + "order": "3", + "sublabels": "{\"day\":\"Day\",\"month\":\"Month\",\"year\":\"Year\",\"last\":\"Last Name\",\"hour\":\"Hour\",\"minutes\":\"Minutes\",\"litemode\":\"Date\"}", + "text": "Date Of Birth", + "timeFormat": "AM/PM", + "type": "control_datetime", + "answer": { + "year": "1988", + "month": "11", + "day": "22", + "datetime": "1988-11-22 00:00:00" + }, + "prettyFormat": "1989-11-22" + }, + "10": { + "name": "truevalue", + "order": "7", + "text": "trueValue", + "type": "control_textbox", + "answer": "true" + } + } + }, + "duration": "64.92ms", + "info": null, + "limit-left": 983 +} \ No newline at end of file diff --git a/oura/jotform/test/fixtures/submission_participant_mismatch.json b/oura/jotform/test/fixtures/submission_participant_mismatch.json new file mode 100644 index 000000000..445d8ce01 --- /dev/null +++ b/oura/jotform/test/fixtures/submission_participant_mismatch.json @@ -0,0 +1,87 @@ +{ + "responseCode": 200, + "message": "success", + "content": { + "id": "6410095903544943563", + "form_id": "253343950262051", + "ip": "130.204.74.53", + "created_at": "2025-12-08 08:26:30", + "status": "ACTIVE", + "new": "0", + "flag": "0", + "notes": "", + "updated_at": null, + "answers": { + "1": { + "name": "heading", + "order": "1", + "text": "Form", + "type": "control_head" + }, + "2": { + "name": "submit2", + "order": "8", + "text": "Submit", + "type": "control_button" + }, + "3": { + "name": "name", + "order": "2", + "sublabels": "{\"prefix\":\"Prefix\",\"first\":\"First Name\",\"middle\":\"Middle Name\",\"last\":\"Last Name\",\"suffix\":\"Suffix\"}", + "text": "Name", + "type": "control_fullname", + "answer": { + "first": "James", + "last": "Jellyfish" + }, + "prettyFormat": "James Jellyfish" + }, + "4": { + "name": "userId", + "order": "4", + "text": "userId", + "type": "control_textbox", + "answer": "1aacb960-430c-4081-8b3b-a32688807dc5" + }, + "5": { + "name": "participantId", + "order": "5", + "text": "participantId", + "type": "control_textbox", + "answer": "0123456789" + }, + "8": { + "name": "eligible", + "order": "6", + "text": "eligible", + "type": "control_textbox", + "answer": "true" + }, + "9": { + "name": "dateOfBirth", + "order": "3", + "sublabels": "{\"day\":\"Day\",\"month\":\"Month\",\"year\":\"Year\",\"last\":\"Last Name\",\"hour\":\"Hour\",\"minutes\":\"Minutes\",\"litemode\":\"Date\"}", + "text": "Date Of Birth", + "timeFormat": "AM/PM", + "type": "control_datetime", + "answer": { + "year": "1988", + "month": "11", + "day": "22", + "datetime": "1988-11-22 00:00:00" + }, + "prettyFormat": "1989-11-22" + }, + "10": { + "name": "truevalue", + "order": "7", + "text": "trueValue", + "type": "control_textbox", + "answer": "true" + } + } + }, + "duration": "64.92ms", + "info": null, + "limit-left": 983 +} \ No newline at end of file diff --git a/oura/jotform/test/jotform.go b/oura/jotform/test/jotform.go new file mode 100644 index 000000000..370ab1b6b --- /dev/null +++ b/oura/jotform/test/jotform.go @@ -0,0 +1,15 @@ +package test + +import ( + "fmt" + "os" +) + +func LoadFixture(filename string) (string, error) { + data, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", filename, err) + } + + return string(data), nil +} diff --git a/oura/jotform/test/stubs.go b/oura/jotform/test/stubs.go new file mode 100644 index 000000000..ab3996994 --- /dev/null +++ b/oura/jotform/test/stubs.go @@ -0,0 +1,38 @@ +package test + +import ( + "net/http" + "net/http/httptest" +) + +type Response struct { + StatusCode int + Body string +} + +type StubResponses struct { + responses map[string]Response +} + +func NewStubResponses() *StubResponses { + return &StubResponses{responses: make(map[string]Response)} +} + +func (s *StubResponses) AddResponse(method, path string, Response Response) { + s.responses[method+" "+path] = Response +} + +func NewStubServer(resp *StubResponses) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, ok := resp.responses[r.Method+" "+r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Not Found")) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + w.Write([]byte(response.Body)) + })) +} diff --git a/oura/jotform/webhook_test.go b/oura/jotform/webhook_test.go new file mode 100644 index 000000000..90a981464 --- /dev/null +++ b/oura/jotform/webhook_test.go @@ -0,0 +1,221 @@ +package jotform_test + +import ( + "context" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + "go.uber.org/mock/gomock" + + "github.com/tidepool-org/platform/consent" + "github.com/tidepool-org/platform/page" + storeStructuredMongo "github.com/tidepool-org/platform/store/structured/mongo" + "github.com/tidepool-org/platform/user" + + consentTest "github.com/tidepool-org/platform/consent/test" + "github.com/tidepool-org/platform/log" + logTest "github.com/tidepool-org/platform/log/test" + "github.com/tidepool-org/platform/oura/customerio" + "github.com/tidepool-org/platform/oura/jotform" + jotformTest "github.com/tidepool-org/platform/oura/jotform/test" + userTest "github.com/tidepool-org/platform/user/test" +) + +var _ = Describe("WebhookProcessor", func() { + var ( + ctx context.Context + processor *jotform.WebhookProcessor + logger log.Logger + + consentCtrl *gomock.Controller + consentService *consentTest.MockService + + userCtrl *gomock.Controller + userClient *userTest.MockClient + + customerIOServer *httptest.Server + jotformServer *httptest.Server + + jotformResponses *jotformTest.StubResponses + customerIOResponses *jotformTest.StubResponses + ) + + BeforeEach(func() { + ctx = context.Background() + logger = logTest.NewLogger() + + consentCtrl = gomock.NewController(GinkgoT()) + consentService = consentTest.NewMockService(consentCtrl) + + userCtrl = gomock.NewController(GinkgoT()) + userClient = userTest.NewMockClient(userCtrl) + + jotformResponses = jotformTest.NewStubResponses() + jotformServer = jotformTest.NewStubServer(jotformResponses) + jotformConfig := jotform.Config{ + BaseURL: jotformServer.URL, + } + + customerIOResponses = jotformTest.NewStubResponses() + customerIOServer = jotformTest.NewStubServer(customerIOResponses) + customerIOConfig := customerio.Config{ + AppAPIBaseURL: customerIOServer.URL, + } + customerIOClient, err := customerio.NewClient(customerIOConfig, logger) + Expect(err).ToNot(HaveOccurred()) + + processor, err = jotform.NewWebhookProcessor(jotformConfig, logger, consentService, customerIOClient, userClient) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + jotformServer.Close() + customerIOServer.Close() + consentCtrl.Finish() + userCtrl.Finish() + }) + + Context("ProcessSubmission", func() { + It("should successfully process an eligible submission and create consent record", func() { + submissionID := "6410095903544943563" + userID := "1aacb960-430c-4081-8b3b-a32688807dc5" + + submission, err := jotformTest.LoadFixture("./test/fixtures/submission.json") + Expect(err).ToNot(HaveOccurred()) + + jotformResponses.AddResponse(http.MethodGet, "/v1/submission/"+submissionID, jotformTest.Response{StatusCode: http.StatusOK, Body: submission}) + + customer, err := jotformTest.LoadFixture("./test/fixtures/customer.json") + Expect(err).ToNot(HaveOccurred()) + customerIOResponses.AddResponse(http.MethodGet, "/v1/customers/"+userID+"/attributes", jotformTest.Response{StatusCode: http.StatusOK, Body: customer}) + + usr := &user.User{UserID: &userID} + userClient.EXPECT().Get(gomock.Any(), userID).Return(usr, nil) + + consentService.EXPECT().ListConsentRecords(gomock.Any(), userID, gomock.Any(), gomock.Any()). + Do(func(ctx context.Context, userID string, filter *consent.RecordFilter, pagination *page.Pagination) { + Expect(filter.Type).To(PointTo(Equal("big_data_donation_project"))) + Expect(filter.Version).To(PointTo(Equal(1))) + Expect(filter.Latest).To(PointTo(Equal(true))) + }). + Return(&storeStructuredMongo.ListResult[consent.Record]{ + Count: 0, + }, nil) + + consentService.EXPECT().CreateConsentRecord(gomock.Any(), userID, gomock.Any()). + Do(func(ctx context.Context, userID string, create *consent.RecordCreate) { + Expect(create).ToNot(BeNil()) + Expect(create.Type).To(Equal("big_data_donation_project")) + Expect(create.Version).To(Equal(1)) + Expect(create.OwnerName).To(Equal("James Jellyfish")) + Expect(create.AgeGroup).To(Equal(consent.AgeGroupEighteenOrOver)) + Expect(create.GrantorType).To(Equal(consent.GrantorTypeOwner)) + }). + Return(&consent.Record{ + ID: "1234567890", + UserID: userID, + Status: consent.RecordStatusActive, + AgeGroup: consent.AgeGroupEighteenOrOver, + OwnerName: "James Jellyfish", + GrantorType: "owner", + Type: "big_data_donation_project", + Version: 1, + }, nil) + + err = processor.ProcessSubmission(ctx, submissionID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should successfully process an eligible submission and not attempt to create consent record if one already exists", func() { + submissionID := "6410095903544943563" + userID := "1aacb960-430c-4081-8b3b-a32688807dc5" + + submission, err := jotformTest.LoadFixture("./test/fixtures/submission.json") + Expect(err).ToNot(HaveOccurred()) + + jotformResponses.AddResponse(http.MethodGet, "/v1/submission/"+submissionID, jotformTest.Response{StatusCode: http.StatusOK, Body: submission}) + + customer, err := jotformTest.LoadFixture("./test/fixtures/customer.json") + Expect(err).ToNot(HaveOccurred()) + customerIOResponses.AddResponse(http.MethodGet, "/v1/customers/"+userID+"/attributes", jotformTest.Response{StatusCode: http.StatusOK, Body: customer}) + + usr := &user.User{UserID: &userID} + userClient.EXPECT().Get(gomock.Any(), userID).Return(usr, nil) + + consentService.EXPECT().ListConsentRecords(gomock.Any(), userID, gomock.Any(), gomock.Any()). + Do(func(ctx context.Context, userID string, filter *consent.RecordFilter, pagination *page.Pagination) { + Expect(filter.Type).To(PointTo(Equal("big_data_donation_project"))) + Expect(filter.Version).To(PointTo(Equal(1))) + Expect(filter.Latest).To(PointTo(Equal(true))) + }). + Return(&storeStructuredMongo.ListResult[consent.Record]{ + Count: 1, + Data: []consent.Record{{ + ID: "1234567890", + UserID: userID, + Status: consent.RecordStatusActive, + AgeGroup: consent.AgeGroupEighteenOrOver, + OwnerName: "James Jellyfish", + GrantorType: "owner", + Type: "big_data_donation_project", + Version: 1, + }}, + }, nil) + + err = processor.ProcessSubmission(ctx, submissionID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should successfully process an eligible submission and not return error if the customer doesn't exist", func() { + submissionID := "6410095903544943563" + userID := "1aacb960-430c-4081-8b3b-a32688807dc5" + + submission, err := jotformTest.LoadFixture("./test/fixtures/submission.json") + Expect(err).ToNot(HaveOccurred()) + + jotformResponses.AddResponse(http.MethodGet, "/v1/submission/"+submissionID, jotformTest.Response{StatusCode: http.StatusOK, Body: submission}) + + customerIOResponses.AddResponse(http.MethodGet, "/v1/customers/"+userID+"/attributes", jotformTest.Response{StatusCode: http.StatusNotFound}) + + err = processor.ProcessSubmission(ctx, submissionID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should successfully process an eligible submission and not return error if the customer doesn't have the correct participant id", func() { + submissionID := "6410095903544943563" + userID := "1aacb960-430c-4081-8b3b-a32688807dc5" + + submission, err := jotformTest.LoadFixture("./test/fixtures/submission_participant_mismatch.json") + Expect(err).ToNot(HaveOccurred()) + + jotformResponses.AddResponse(http.MethodGet, "/v1/submission/"+submissionID, jotformTest.Response{StatusCode: http.StatusOK, Body: submission}) + + customerIOResponses.AddResponse(http.MethodGet, "/v1/customers/"+userID+"/attributes", jotformTest.Response{StatusCode: http.StatusNotFound}) + + err = processor.ProcessSubmission(ctx, submissionID) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should successfully process an eligible submission and not return error if the user doesn't exist", func() { + submissionID := "6410095903544943563" + userID := "1aacb960-430c-4081-8b3b-a32688807dc5" + + submission, err := jotformTest.LoadFixture("./test/fixtures/submission.json") + Expect(err).ToNot(HaveOccurred()) + + jotformResponses.AddResponse(http.MethodGet, "/v1/submission/"+submissionID, jotformTest.Response{StatusCode: http.StatusOK, Body: submission}) + + customer, err := jotformTest.LoadFixture("./test/fixtures/customer.json") + Expect(err).ToNot(HaveOccurred()) + customerIOResponses.AddResponse(http.MethodGet, "/v1/customers/"+userID+"/attributes", jotformTest.Response{StatusCode: http.StatusOK, Body: customer}) + + userClient.EXPECT().Get(gomock.Any(), userID).Return(nil, nil) + + err = processor.ProcessSubmission(ctx, submissionID) + Expect(err).ToNot(HaveOccurred()) + }) + }) +})