Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
61a0934
Add jotform webhooks for Oura
toddkazakov Dec 4, 2025
599118c
Use the configured jotform API base url
toddkazakov Dec 5, 2025
48c809f
Ensure user exists before creating consent
toddkazakov Dec 8, 2025
01c769f
Parse date time fields correctly
toddkazakov Dec 8, 2025
aea76a4
Add tests for jotform webhooks
toddkazakov Dec 9, 2025
e29bcbf
Process oura sizing kit fulfillment webhooks from shopify
toddkazakov Dec 12, 2025
da418c4
Add tests for fulfillment event webhooks
toddkazakov Dec 15, 2025
9a99e16
Add shopify webhook routes
toddkazakov Dec 16, 2025
80d6b51
Use fulfillment created/updated instead of fulfillment_events to allo…
toddkazakov Dec 17, 2025
0242687
Use test store product id
toddkazakov Dec 17, 2025
20a5de8
Use product GID when creating discount code
toddkazakov Dec 17, 2025
a4b8333
Configure discount codes correctly
toddkazakov Dec 17, 2025
a951e35
Fix default customer.io track api base url
toddkazakov Dec 17, 2025
27ec01d
Use id instead of cid when sending events to customer io
toddkazakov Dec 17, 2025
d0d7ec6
Remove some of the unused fields of the fulfillment event
toddkazakov Dec 17, 2025
a3aa538
Add webhook for orders create events to notify customer.io of placed …
toddkazakov Dec 18, 2025
9995641
Add tests for shopify orders
toddkazakov Dec 23, 2025
a9e257a
Generate account linking code when ring is delivered
toddkazakov Jan 5, 2026
6b33383
Add oura account linking token expiration time
toddkazakov Jan 5, 2026
8af6266
Address code review feedback
toddkazakov Jan 9, 2026
d0e99a7
Reorganize the oura releated code
toddkazakov Jan 12, 2026
7b057b0
Use the client module to implement the customerio client
toddkazakov Jan 12, 2026
002e3c0
Remove unused variable
toddkazakov Jan 12, 2026
e52c935
Remove import alias
toddkazakov Jan 13, 2026
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
155 changes: 96 additions & 59 deletions auth/service/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import (
"net/http"
"time"

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

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

Expand Down Expand Up @@ -71,21 +72,24 @@ func (c *confirmationClientConfig) Load() error {
type Service struct {
*serviceService.Service
domain string
appValidator *appvalidate.Validator
authClient *Client
authStore *authStoreMongo.Store
workStructuredStore *workStoreStructuredMongo.Store
confirmationClient confirmationClient.ClientWithResponsesInterface
customerIOClient *customerio.Client
dataClient dataClient.Client
dataSourceClient *dataSourceClient.Client
confirmationClient confirmationClient.ClientWithResponsesInterface
taskClient task.Client
workClient *workService.Client
providerFactory *providerFactory.Factory
authClient *Client
userEventsHandler events.Runner
deviceCheck apple.DeviceCheck
appValidator *appvalidate.Validator
partnerSecrets *appvalidate.PartnerSecrets
providerFactory *providerFactory.Factory
shopifyClient shopify.Client
taskClient task.Client
twiistServiceAccountAuthorizer auth.ServiceAccountAuthorizer
userEventsHandler events.Runner
userClient user.Client
consentService consent.Service
workClient *workService.Client
workStructuredStore *workStoreStructuredMongo.Store
}

func New() *Service {
Expand Down Expand Up @@ -141,6 +145,9 @@ func (s *Service) Initialize(provider application.Provider) error {
if err := s.initializeAuthClient(); err != nil {
return err
}
if err := s.initializeUserClient(); err != nil {
return err
}
if err := s.initializeConsentService(); err != nil {
return err
}
Expand All @@ -159,6 +166,12 @@ func (s *Service) Initialize(provider application.Provider) error {
if err := s.initializeTwiistServiceAccountAuthorizer(); err != nil {
return err
}
if err := s.initializeCustomerIOClient(); err != nil {
return err
}
if err := s.initializeShopifyClient(); err != nil {
return err
}
if err := s.initializeRouter(); err != nil {
return err
}
Expand Down Expand Up @@ -260,85 +273,119 @@ func (s *Service) terminateDomain() {
}
}

func (s *Service) initializeRouter() error {
s.Logger().Debug("Creating api router")

apiRouter, err := authServiceApi.NewRouter(s)
func (s *Service) initializeUserClient() error {
s.Logger().Debug("Initializing user client")
var err error
s.userClient, err = userClient.NewDefaultClient(userClient.Params{
ConfigReporter: s.ConfigReporter(),
Logger: s.Logger(),
UserAgent: s.UserAgent(),
})
if err != nil {
return errors.Wrap(err, "unable to create api router")
return errors.Wrap(err, "unable to create user client")
}
return nil
}

s.Logger().Debug("Creating v1 router")
func (s *Service) initializeShopifyClient() error {
shopifyConfig := shopify.ClientConfig{}
if err := envconfig.Process("", &shopifyConfig); err != nil {
return errors.Wrap(err, "unable to load shopify config")
}

v1Router, err := authServiceApiV1.NewRouter(s)
var err error
s.shopifyClient, err = shopifyClient.New(context.Background(), shopifyConfig)
if err != nil {
return errors.Wrap(err, "unable to create v1 router")
return errors.Wrap(err, "unable to create shopify client")
}

s.Logger().Debug("Creating consent router")
return nil
}
func (s *Service) initializeCustomerIOClient() error {
customerIOConfig := customerio.Config{}
if err := envconfig.Process("", &customerIOConfig); err != nil {
return errors.Wrap(err, "unable to load customerio config")
}

consentV1Router, err := consentApiV1.NewRouter(s.consentService)
var err error
s.customerIOClient, err = customerio.NewClient(customerIOConfig, s.Logger())
if err != nil {
return errors.Wrap(err, "unable to create consent router")
return errors.Wrap(err, "unable to create customerio client")
}

s.Logger().Debug("Creating jotform router")
return nil
}

func (s *Service) createJotformRouter() (*jotformAPI.Router, error) {
jotformConfig := jotform.Config{}
if err := envconfig.Process("", &jotformConfig); err != nil {
return errors.Wrap(err, "unable to load jotform config")
return nil, 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")
webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, s.customerIOClient, s.userClient, s.shopifyClient)
if err != nil {
return nil, errors.Wrap(err, "unable to create jotform webhook processor")
}
customerIOClient, err := customerio.NewClient(customerIOConfig, s.Logger())

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

s.Logger().Debug("Initializing user client")
usrClient, err := userClient.NewDefaultClient(userClient.Params{
ConfigReporter: s.ConfigReporter(),
Logger: s.Logger(),
UserAgent: s.UserAgent(),
})
return jotformRouter, nil
}

func (s *Service) createShopifyRouter() (*shopifyAPI.Router, error) {
fulfillmentEventProcessor, err := shopify.NewFulfillmentEventProcessor(s.Logger(), s.customerIOClient, s.shopifyClient, s.AuthServiceClient())
if err != nil {
return errors.Wrap(err, "unable to create user client")
return nil, errors.Wrap(err, "unable to create fulfillment event processor")
}

shopifyConfig := shopify.ClientConfig{}
if err := envconfig.Process("", &shopifyConfig); err != nil {
return errors.Wrap(err, "unable to load shopify config")
ordersCreateEventProcessor, err := shopify.NewOrdersCreateEventProcessor(s.Logger(), s.customerIOClient)
if err != nil {
return nil, errors.Wrap(err, "unable to create orders create event processor")
}

shopifyClnt, err := shopifyClient.New(context.Background(), shopifyConfig)
shopifyRouter, err := shopifyAPI.NewRouter(fulfillmentEventProcessor, ordersCreateEventProcessor)
if err != nil {
return errors.Wrap(err, "unable to create shopify client")
return nil, errors.Wrap(err, "unable to create shopify router")
}

webhookProcessor, err := jotform.NewWebhookProcessor(jotformConfig, s.Logger(), s.consentService, customerIOClient, usrClient, shopifyClnt)
return shopifyRouter, nil
}

func (s *Service) initializeRouter() error {
s.Logger().Debug("Creating api router")

apiRouter, err := authServiceApi.NewRouter(s)
if err != nil {
return errors.Wrap(err, "unable to create jotform webhook processor")
return errors.Wrap(err, "unable to create api router")
}

jotformRouter, err := jotformAPI.NewRouter(webhookProcessor)
s.Logger().Debug("Creating v1 router")

v1Router, err := authServiceApiV1.NewRouter(s)
if err != nil {
return errors.Wrap(err, "unable to create jotform router")
return errors.Wrap(err, "unable to create v1 router")
}

fulfillmentEventProcessor, err := shopify.NewFulfillmentEventProcessor(s.Logger(), customerIOClient, shopifyClnt, s.AuthServiceClient())
s.Logger().Debug("Creating consent router")

consentV1Router, err := consentApiV1.NewRouter(s.consentService)
if err != nil {
return errors.Wrap(err, "unable to create fulfillment event processor")
return errors.Wrap(err, "unable to create consent router")
}

ordersCreateEventProcessor, err := shopify.NewOrdersCreateEventProcessor(s.Logger(), customerIOClient)
s.Logger().Debug("Creating jotform router")

jotformRouter, err := s.createJotformRouter()
if err != nil {
return errors.Wrap(err, "unable to create orders create event processor")
return errors.Wrap(err, "unable to create jotform router")
}

shopifyRouter, err := shopifyAPI.NewRouter(fulfillmentEventProcessor, ordersCreateEventProcessor)
s.Logger().Debug("Creating shopify router")

shopifyRouter, err := s.createShopifyRouter()
if err != nil {
return errors.Wrap(err, "unable to create shopify router")
}
Expand Down Expand Up @@ -430,24 +477,14 @@ func (s *Service) initializeConsentService() error {
return errors.Wrap(err, "unable to create bddp sharer")
}

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")
}

s.Logger().Debug("Initializing mailer")
mailr, err := mailer.Client()
if err != nil {
return errors.Wrap(err, "unable to create mailer")
}

s.Logger().Debug("Initializing consent mailer")
consentMailer, err := consentService.NewConsentMailer(mailr, usrClient, s.Logger())
consentMailer, err := consentService.NewConsentMailer(mailr, s.userClient, s.Logger())
if err != nil {
return errors.Wrap(err, "unable to create consent mailer")
}
Expand Down
96 changes: 96 additions & 0 deletions customerio/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package customerio

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

"github.com/tidepool-org/platform/client"
"github.com/tidepool-org/platform/errors"
"github.com/tidepool-org/platform/log"
"github.com/tidepool-org/platform/request"
)

type Client struct {
appClient *client.Client
trackClient *client.Client
config Config
logger log.Logger
httpClient *http.Client
}

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"`
SiteID string `envconfig:"TIDEPOOL_CUSTOMERIO_SITE_ID"`
TrackAPIBaseURL string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_BASE_URL" default:"https://track.customer.io"`
TrackAPIKey string `envconfig:"TIDEPOOL_CUSTOMERIO_TRACK_API_KEY"`
}

func NewClient(config Config, logger log.Logger) (*Client, error) {
errorParser := newErrorResponseParser()

appClient, err := client.NewWithErrorParser(&client.Config{
Address: config.AppAPIBaseURL,
}, errorParser)
if err != nil {
return nil, errors.Wrap(err, "failed to create app API client")
}

trackClient, err := client.NewWithErrorParser(&client.Config{
Address: config.TrackAPIBaseURL,
}, errorParser)
if err != nil {
return nil, errors.Wrap(err, "failed to create track API client")
}

return &Client{
appClient: appClient,
trackClient: trackClient,
config: config,
logger: logger,
httpClient: http.DefaultClient,
}, nil
}

// appAPIAuthMutator returns a request mutator for App API authentication (Bearer token)
func (c *Client) appAPIAuthMutator() *request.HeaderMutator {
return request.NewHeaderMutator("Authorization", fmt.Sprintf("Bearer %s", c.config.AppAPIKey))
}

// trackAPIAuthMutator returns a request mutator for Track API authentication (Basic auth)
func (c *Client) trackAPIAuthMutator() *request.HeaderMutator {
auth := c.config.SiteID + ":" + c.config.TrackAPIKey
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
return request.NewHeaderMutator("Authorization", basicAuth)
}

// errorResponseParser implements client.ErrorResponseParser for Customer.io API errors
type errorResponseParser struct{}

func newErrorResponseParser() *errorResponseParser {
return &errorResponseParser{}
}

func (p *errorResponseParser) ParseErrorResponse(ctx context.Context, res *http.Response, req *http.Request) error {
var errResp errorResponse
if err := json.NewDecoder(res.Body).Decode(&errResp); err != nil {
return nil
}

if len(errResp.Errors) > 0 {
return errors.Newf("API error (status %d): %s", res.StatusCode, errResp.Errors[0].Message)
Copy link
Contributor

Choose a reason for hiding this comment

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

Has there ever been more than one error here? If so, it might be useful to in some way at least log the other errors—they might provide useful hints as to what went wrong.

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've only seen a single error during testing

}

return nil
}

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