Skip to content

Conversation

@toddkazakov
Copy link
Contributor

@toddkazakov toddkazakov commented Dec 16, 2025

  • BACK-4162
  • BACK-4164
  • BACK-4165
  • BACK-4041
  • BACK-3959
  • BACK-4166
sequenceDiagram
    actor User
    participant JotForm
    participant Platform
    participant Customer.io
    participant Shopify

    User->>JotForm: Submits survey
    JotForm->>Platform: Webhook call with submission ID
    Platform->>JotForm: Pull submission details
    JotForm-->>Platform: Return submission data
    
    Platform->>Platform: Check eligibility criteria
    Platform->>Customer.io: Check if participant ID exists
    Customer.io-->>Platform: Return participant status
    
    Platform->>Shopify: Generate discount code
    Shopify-->>Platform: Return discount code
    
    Platform->>Customer.io: Send oura_eligibility_survey_completed event
    Customer.io->>Customer.io: Trigger Sizing Kit Campaign
    
    User->>Shopify: Place Sizing Kit Order
    Shopify->>Platform: Send orders/create notification
    Platform->>Customer.io: Send oura_sizing_kit_ordered event
    Shopify->>Platform: Send fulfillments/update or fulfillments/create notification
    Platform->>Shopify: Generate discount code
    Shopify-->>Platform: Return discount code
    Platform->>Shopify: Send oura_sizing_kit_delivered event
    Customer.io->>Customer.io: Trigger Ring Campaign

    User->>Shopify: Place Ring Order
    Shopify->>Platform: Send orders/create notification
    Platform->>Customer.io: Send oura_ring_ordered event
    Shopify->>Platform: Send fulfillments/update or fulfillments/create notification
    Platform->>Shopify: Send oura_ring_delivered event
Loading

@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch from 8e9edd8 to 3935ecd Compare December 16, 2025 14:31
@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch from 3935ecd to 9a99e16 Compare December 16, 2025 14:49
@toddkazakov
Copy link
Contributor Author

/deploy dev1

@toddkazakov
Copy link
Contributor Author

/deploy dev

@tidebot
Copy link
Collaborator

tidebot commented Dec 16, 2025

toddkazakov updated values.yaml file in dev1

@tidebot
Copy link
Collaborator

tidebot commented Dec 16, 2025

toddkazakov updated flux policies file in dev1

@tidebot
Copy link
Collaborator

tidebot commented Dec 16, 2025

toddkazakov deployed platform tk-oura-webhooks-shopify branch to dev1 namespace

@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch from 37d1eac to 27ec01d Compare December 17, 2025 15:03
@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch 6 times, most recently from 91c0bc2 to 6c322c7 Compare December 18, 2025 20:34
@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch from 6c322c7 to a3aa538 Compare December 18, 2025 21:19
@toddkazakov toddkazakov changed the base branch from tk-oura-webhooks to master December 23, 2025 09:50
@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch 3 times, most recently from 3997ec3 to 22aae96 Compare December 23, 2025 10:21
@toddkazakov toddkazakov force-pushed the tk-oura-webhooks-shopify branch from 22aae96 to 9995641 Compare December 23, 2025 14:44
@toddkazakov toddkazakov requested a review from ewollesen January 7, 2026 13:00
@toddkazakov toddkazakov changed the title Add shopify webhooks for OURA Add webhooks for OURA Jan 8, 2026
Copy link
Contributor

@ewollesen ewollesen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things small things that I think can be quickly fixed. Nothing majorly blocking, though the use of the platform's error package might take a fair bit of cut and paste, if it's decided that it should be done at all.

In general, while there are tests, they seem to focus on successful completion, i.e. testing that things work, not that things that shouldn't work don't work, or that things that don't work fail in the expected manner. Given the relatively temporary nature of this code, that's not too concerning, but as always, today's hot fix is tomorrow production code. So... Shrug, something to think about maybe.


type segmentMembershipResponse struct {
Identifiers []Identifiers `json:"identifiers"`
IDs []string `json:"ids"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why IDs and Identifiers? Isn't IDs an abbreviation of Identifiers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the response that we get from customer io - we have no control of it.

ctx := req.Context()
responder := request.MustNewResponder(res, req)

if err := req.ParseMultipartForm(multipartMaxMemory); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be simpler I think to call to PostFormValue. It will call the appropriate ParseForm method as needed, and return the first matching field.

OuraRingProductID = "15496765964675" // Todd's test store
OuraRingDiscountCodeTitle = "Oura Ring Discount Code"

DiscountCodeLength = 12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these aren't being manually input, and I think they're not... is there any reason not to use the full output of rand.Text()?

The result contains at least 128 bits of randomness, enough to prevent brute force guessing attacks and to make the likelihood of collisions vanishingly small.

I don't have a problem with 12 characters, but better safe than sorry, especially if no one needs to copy the codes, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are sending those in emails. I'd prefer to keep them short. The worst that can happen is to generate a duplicate value the shopify rejects. The reconciliation process should retry this.

},
}

return f.customerIOClient.SendEvent(ctx, identifiers.ID, sizingKitDelivered)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any sort of retry logic here, maybe that'll be covered by the task / job that gets run 1x / day to reconcile?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job will be run more frequently (every hour or so) and will reprocessed failures.

return nil
}

func (w *WebhookProcessor) ensureConsentRecordExists(ctx context.Context, userID string, submission *SubmissionResponse) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question just to confirm:

The fact that we've received a jotform submission means that they've consented, right?

I seem to remember that was the case, but wanted to be sure.

In theory we're not emailing anyone < 18 years of age, but in the event that we did, do we want to perform any double checks here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the submission is marked as eligible, then we must upsert a consent record. This is the only value that we check. If we don't want to execute this logic, jotform ought to mark the submission is ineligible.

Copy link
Contributor Author

@toddkazakov toddkazakov Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I remembered that if the user is <18 years old, the consent validation will fail because there's no parent/guardian name. So the edge case that you're describing is already handled by the model validation.

In addition to that, the OuraEligibilitySurvey validator explicitly checks for the age

v.String("dateOfBirth", &o.DateOfBirth).NotEmpty().AsTime(time.DateTime).Before(eighteenYearsAgo)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, thanks for double checking. For some reason the jotform consent flow makes me feel a little weird, but I'm pretty sure that legal has had a look at it and given it the green light.

}
case OuraRingProductID:
if err := f.onRingDelivered(ctx, customers.Identifiers[0], event, deliveredProducts.DiscountCode); err != nil {
logger.WithError(err).Warn("unable to send ring delivered event")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically it's preferable to either log the error, or return it, but not both. When you log then return the error, it tends to get logged multiple times which clutters the log output. Consider using errors.Wrap instead of logging.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log messages here have more context than the caller has. I could add this context to the error message, but I'm not sure this improves anything. IMO logging in the caller will increase the complexity while reducing the useful output by serializing the context in the error message. The error is returned so the API responds with a non-2xx code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log messages here have more context than the caller has. I could add this context to the error message, but I'm not sure this improves anything. IMO logging in the caller will increase the complexity while reducing the useful output by serializing the context in the error message. The error is returned so the API responds with a non-2xx code.

I don't see how two log statements, each with only partial info can possibly be less confusing than one log with all the info (even if some of it isn't useful)? But it's getting logged, and that's the key.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message here has structured context, which makes searching and filtering in sumologic easier. There's no established way to pass structured context for logging purposes only to the caller. I could create a new struct and return it, but what's the point? It only needs to know that it has to respond with non-2XX so the request is retried. I could return a completely different error if that helps in any way?

return err
}
default:
logger.Warn("ignoring fulfillment event for unknown product")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What effect will returning nil here have later down the line?

I don't see a test case covering this. Seems like it would be easier to debug this if an error were returned. The error would get logged and the user would likely get some useful message as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a fulfilment that we can't process, so returning a nil will prevent shopify from resending the event. Returning an error will make shopify retry the webhook delivery.

@ewollesen
Copy link
Contributor

I believe there's a typo in your process diagram.

The last event is Send oura_sizing_kit_delivered event, but I think it should be something like Send oura_ring_kit_delivered event?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if this file lived in oura/jotformapi/router.go, then it would have a clear name, and not need any special naming when importing, i.e.

import "github.com/tidepool-org/platform/oura/jotformapi"

instead of

import (
    jotformAPI "github.com/tidepool-org/platform/oura/jotform/api"
)

By convention, go imports are all lowercase, so seeing jotformAPI can throw people off, and make them wonder if it's a variable (the capital letters hint that maybe it is) or an import.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to adhere to established structure and import aliasing in the project even if it is not perfect

Copy link
Contributor

@darinkrauss darinkrauss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments for your consideration.

Comment on lines +6 to +10
appAPIBaseURL string
appAPIKey string
trackAPIBaseURL string
trackAPIKey string
siteID string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Why not just capture the Config in the Client?

}

type Config struct {
AppAPIBaseURL string `envconfig:"TIDEPOOL_CUSTOMERIO_APP_API_BASE_URL" default:"https://api.customer.io"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This implementation is in the oura module, but the environment variable is not. Is this Client and Config specific to ŌURA or not?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is non-specific, then suggest pulling outside of oura. If specific to ŌURA, then suggest using TIDEPOOL_OURA_ prefix to be clear.


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] errors.Wrap?

}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Consider using the client module.

}

// Add the authorization header (Basic Auth for Track API)
req.SetBasicAuth(c.siteID, c.trackAPIKey)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[comment] Weird that some use Basic and other Bearer auth.

return errors.Wrap(err, "unable to create consent router")
}

s.Logger().Debug("Creating jotform router")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Consider pushing clients into various init*Client functions. Also, you can pull out the common userClient found in initializeConsentService.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants