Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2b4b81c
Add identifier tie breaker to work poll sort to keep serial groups ex…
toddkazakov Aug 11, 2026
bb5b6d5
Add work store reaper to return expired processing work to failing
toddkazakov Aug 11, 2026
e90e706
Reap expired processing work each time the coordinator requests work
toddkazakov Aug 11, 2026
c74abee
Fix work filter types validation reference
toddkazakov Aug 26, 2026
dddd31c
Add upload postprocess work type and producer
toddkazakov Aug 12, 2026
d5e7a65
Add upload postprocess work processor and wire it into the data service
toddkazakov Aug 13, 2026
79840ba
Create upload postprocess work on upload instead of marking summaries…
toddkazakov Aug 13, 2026
f64a7e9
Add outdated summary sweeper replacing the summary update task runner
toddkazakov Aug 14, 2026
ef83b21
Add generic work create and get API for external producers
toddkazakov Aug 18, 2026
6897ad7
Apply code review fixes to work polling, absorption, and reaping
toddkazakov Aug 18, 2026
41dfb9c
Update abbott plugin submodule to postprocess producer head
toddkazakov Aug 20, 2026
dbbae81
Validate work identity on pickup and skip invalid pending work when a…
toddkazakov Aug 24, 2026
358254e
Rename outdated sweep index to avoid conflict with legacy index
toddkazakov Aug 24, 2026
3967a26
Remove outdated sweeper drained by the summary task runners
toddkazakov Aug 25, 2026
ab3e8b4
Add work queue size by type and state prometheus metrics
toddkazakov Aug 25, 2026
cda6fa9
Regenerate mocks
toddkazakov Aug 26, 2026
f76caa7
Update summaries in the clinic service before triggering the EHR sync
toddkazakov Aug 26, 2026
02888a9
Clean up logging and improve comments
toddkazakov Aug 27, 2026
96fc690
Allow fetching custodial users without an email
toddkazakov Aug 27, 2026
5b20992
Tolerate not found responses when deleting patient summaries
toddkazakov Aug 31, 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
2 changes: 1 addition & 1 deletion clinics/clinician.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
api "github.com/tidepool-org/clinic/client"
)

func IsPrescriber(clinician *api.Clinician) bool {
func IsPrescriber(clinician *api.ClinicianV1) bool {
if clinician == nil {
return false
}
Expand Down
11 changes: 11 additions & 0 deletions clinics/clinics_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package clinics_test

import (
"testing"

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

func TestSuite(t *testing.T) {
test.Test(t)
}
86 changes: 70 additions & 16 deletions clinics/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ const ErrorCodeClinicClientFailure = "clinic-client-failure"
var ClientModule = fx.Provide(NewClient)

type Client interface {
GetClinic(ctx context.Context, clinicID string) (*clinic.Clinic, error)
GetClinician(ctx context.Context, clinicID, clinicianID string) (*clinic.Clinician, error)
GetEHRSettings(ctx context.Context, clinicId string) (*clinic.EHRSettings, error)
SharePatientAccount(ctx context.Context, clinicID, patientID string) (*clinic.Patient, error)
ListEHREnabledClinics(ctx context.Context) ([]clinic.Clinic, error)
GetClinic(ctx context.Context, clinicID string) (*clinic.ClinicV1, error)
GetClinician(ctx context.Context, clinicID, clinicianID string) (*clinic.ClinicianV1, error)
GetEHRSettings(ctx context.Context, clinicId string) (*clinic.EhrSettingsV1, error)
SharePatientAccount(ctx context.Context, clinicID, patientID string) (*clinic.PatientV1, error)
ListEHREnabledClinics(ctx context.Context) ([]clinic.ClinicV1, error)
SyncEHRData(ctx context.Context, clinicID string) error
GetPatients(ctx context.Context, clinicId string, userToken string, params *clinic.ListPatientsParams, injectedParams url.Values) ([]clinic.Patient, error)
GetPatient(ctx context.Context, clinicID, patientID string) (*clinic.Patient, error)
SyncEHRDataForPatient(ctx context.Context, patientID string) error
GetPatients(ctx context.Context, clinicId string, userToken string, params *clinic.ListPatientsParams, injectedParams url.Values) ([]clinic.PatientV1, error)
GetPatient(ctx context.Context, clinicID, patientID string) (*clinic.PatientV1, error)
UpdatePatientSummary(ctx context.Context, patientID string, patientSummary *clinic.PatientSummaryV1) error
DeletePatientSummary(ctx context.Context, summaryID string) error
}

type config struct {
Expand Down Expand Up @@ -75,7 +78,7 @@ func NewClient(authClient auth.ExternalAccessor) (Client, error) {
}, nil
}

func (d *defaultClient) GetClinician(ctx context.Context, clinicID, clinicianID string) (*clinic.Clinician, error) {
func (d *defaultClient) GetClinician(ctx context.Context, clinicID, clinicianID string) (*clinic.ClinicianV1, error) {
response, err := d.httpClient.GetClinicianWithResponse(ctx, clinic.ClinicId(clinicID), clinic.ClinicianId(clinicianID))
if err != nil {
return nil, err
Expand All @@ -93,7 +96,7 @@ func (d *defaultClient) GetClinician(ctx context.Context, clinicID, clinicianID
return response.JSON200, nil
}

func (d *defaultClient) GetClinic(ctx context.Context, clinicID string) (*clinic.Clinic, error) {
func (d *defaultClient) GetClinic(ctx context.Context, clinicID string) (*clinic.ClinicV1, error) {
response, err := d.httpClient.GetClinicWithResponse(ctx, clinic.ClinicId(clinicID))
if err != nil {
return nil, err
Expand All @@ -111,11 +114,11 @@ func (d *defaultClient) GetClinic(ctx context.Context, clinicID string) (*clinic
return response.JSON200, nil
}

func (d *defaultClient) ListEHREnabledClinics(ctx context.Context) ([]clinic.Clinic, error) {
func (d *defaultClient) ListEHREnabledClinics(ctx context.Context) ([]clinic.ClinicV1, error) {
offset := 0
batchSize := 1000

clinics := make([]clinic.Clinic, 0)
clinics := make([]clinic.ClinicV1, 0)
for {
response, err := d.httpClient.ListClinicsWithResponse(ctx, &clinic.ListClinicsParams{
EhrEnabled: pointer.FromBool(true),
Expand Down Expand Up @@ -147,7 +150,7 @@ func (d *defaultClient) ListEHREnabledClinics(ctx context.Context) ([]clinic.Cli
return clinics, nil
}

func (d *defaultClient) GetEHRSettings(ctx context.Context, clinicId string) (*clinic.EHRSettings, error) {
func (d *defaultClient) GetEHRSettings(ctx context.Context, clinicId string) (*clinic.EhrSettingsV1, error) {
response, err := d.httpClient.GetEHRSettingsWithResponse(ctx, clinicId)
if err != nil {
return nil, err
Expand All @@ -162,10 +165,10 @@ func (d *defaultClient) GetEHRSettings(ctx context.Context, clinicId string) (*c
return response.JSON200, nil
}

func (d *defaultClient) SharePatientAccount(ctx context.Context, clinicID, patientID string) (*clinic.Patient, error) {
func (d *defaultClient) SharePatientAccount(ctx context.Context, clinicID, patientID string) (*clinic.PatientV1, error) {
permission := make(map[string]interface{}, 0)
body := clinic.CreatePatientFromUserJSONRequestBody{
Permissions: &clinic.PatientPermissions{
Permissions: &clinic.PatientPermissionsV1{
Note: &permission,
View: &permission,
},
Expand Down Expand Up @@ -203,7 +206,58 @@ func (d *defaultClient) SyncEHRData(ctx context.Context, clinicID string) error
return nil
}

func (d *defaultClient) GetPatient(ctx context.Context, clinicID, patientID string) (*clinic.Patient, error) {
// SyncEHRDataForPatient reports no error when the clinic service reports the patient has no active
// subscription to any clinic enabled for an electronic health record, which it does as not found. Most
// users are not such a patient, so reporting that as a failure would fail the work of nearly every user.
func (d *defaultClient) SyncEHRDataForPatient(ctx context.Context, patientID string) error {
response, err := d.httpClient.SyncEHRDataForPatientWithResponse(ctx, clinic.PatientId(patientID))
if err != nil {
return err
}
if response.StatusCode() != http.StatusAccepted && response.StatusCode() != http.StatusNotFound {
err = errors.Preparedf(ErrorCodeClinicClientFailure,
"Unexpected status code from clinic service",
"unexpected response status code %v from %v", response.StatusCode(), response.HTTPResponse.Request.URL)
err = errors.WithMeta(err, response.HTTPResponse)
return err
}
return nil
}

func (d *defaultClient) UpdatePatientSummary(ctx context.Context, patientID string, patientSummary *clinic.PatientSummaryV1) error {
response, err := d.httpClient.UpdatePatientSummaryWithResponse(ctx, clinic.PatientId(patientID), *patientSummary)
if err != nil {
return err
}
if response.StatusCode() != http.StatusOK && response.StatusCode() != http.StatusNoContent && response.StatusCode() != http.StatusNotFound {
err = errors.Preparedf(ErrorCodeClinicClientFailure,
"Unexpected status code from clinic service",
"unexpected response status code %v from %v", response.StatusCode(), response.HTTPResponse.Request.URL)
err = errors.WithMeta(err, response.HTTPResponse)
return err
}
return nil
}

// DeletePatientSummary reports no error when the clinic service reports the summary as not found,
// so that a delete resent by retried work targeting an already deleted summary does not fail the
// work forever.
func (d *defaultClient) DeletePatientSummary(ctx context.Context, summaryID string) error {
response, err := d.httpClient.DeletePatientSummaryWithResponse(ctx, clinic.SummaryId(summaryID))
if err != nil {
return err
}
if response.StatusCode() != http.StatusOK && response.StatusCode() != http.StatusNoContent && response.StatusCode() != http.StatusNotFound {
err = errors.Preparedf(ErrorCodeClinicClientFailure,
"Unexpected status code from clinic service",
"unexpected response status code %v from %v", response.StatusCode(), response.HTTPResponse.Request.URL)
err = errors.WithMeta(err, response.HTTPResponse)
return err
}
return nil
}

func (d *defaultClient) GetPatient(ctx context.Context, clinicID, patientID string) (*clinic.PatientV1, error) {
response, err := d.httpClient.GetPatientWithResponse(ctx, clinic.ClinicId(clinicID), clinic.PatientId(patientID))
if err != nil {
return nil, err
Expand All @@ -218,7 +272,7 @@ func (d *defaultClient) GetPatient(ctx context.Context, clinicID, patientID stri
return response.JSON200, nil
}

func (d *defaultClient) GetPatients(ctx context.Context, clinicId string, userToken string, params *clinic.ListPatientsParams, injectedParams url.Values) ([]clinic.Patient, error) {
func (d *defaultClient) GetPatients(ctx context.Context, clinicId string, userToken string, params *clinic.ListPatientsParams, injectedParams url.Values) ([]clinic.PatientV1, error) {
response, err := d.httpClient.ListPatientsWithResponse(ctx, clinicId, params, func(ctx context.Context, req *http.Request) error {
if len(injectedParams) != 0 {
q := req.URL.Query()
Expand Down
150 changes: 150 additions & 0 deletions clinics/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package clinics_test

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
clinic "github.com/tidepool-org/clinic/client"
"go.mongodb.org/mongo-driver/bson/primitive"

authTest "github.com/tidepool-org/platform/auth/test"
"github.com/tidepool-org/platform/clinics"
summaryTest "github.com/tidepool-org/platform/summary/test"
userTest "github.com/tidepool-org/platform/user/test"
)

var _ = Describe("Client", func() {
var server *httptest.Server
var requestPath string
var requestBody []byte
var responseStatusCode int
var client clinics.Client
var patientID string

BeforeEach(func() {
patientID = userTest.RandomUserID()
requestPath = ""
requestBody = nil

server = httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
requestPath = req.URL.Path
var err error
requestBody, err = io.ReadAll(req.Body)
Expect(err).ToNot(HaveOccurred())
res.WriteHeader(responseStatusCode)
}))
GinkgoT().Setenv("TIDEPOOL_CLINIC_CLIENT_ADDRESS", server.URL)

externalAccessor := authTest.NewExternalAccessor()
externalAccessor.ServerSessionTokenOutputs = []authTest.ServerSessionTokenOutput{{Token: authTest.NewSessionToken()}}

var err error
client, err = clinics.NewClient(externalAccessor)
Expect(err).ToNot(HaveOccurred())
})

AfterEach(func() {
server.Close()
})

Context("SyncEHRDataForPatient", func() {
BeforeEach(func() {
responseStatusCode = http.StatusAccepted
})

It("requests a synchronization for the patient", func() {
Expect(client.SyncEHRDataForPatient(context.Background(), patientID)).To(Succeed())
Expect(requestPath).To(Equal("/v1/patients/" + patientID + "/ehr/sync"))
})

// The clinic service reports a patient with no active subscription to any clinic enabled for an
// electronic health record as not found. Most users are not such a patient, so reporting that as
// a failure would fail the work of nearly every user.
It("returns no error when the patient has no active subscription", func() {
responseStatusCode = http.StatusNotFound
Expect(client.SyncEHRDataForPatient(context.Background(), patientID)).To(Succeed())
})

It("returns an error when the clinic service reports an unexpected status", func() {
responseStatusCode = http.StatusInternalServerError
err := client.SyncEHRDataForPatient(context.Background(), patientID)
Expect(err).To(MatchError(ContainSubstring("unexpected response status code 500")))
})
})

Context("UpdatePatientSummary", func() {
var patientSummary *clinic.PatientSummaryV1

BeforeEach(func() {
responseStatusCode = http.StatusOK
cgm := summaryTest.RandomCGMSummary(patientID)
cgm.ID = primitive.NewObjectID()
patientSummary = clinics.NewPatientSummary(cgm, nil)
})

It("updates the summary of the patient", func() {
Expect(client.UpdatePatientSummary(context.Background(), patientID, patientSummary)).To(Succeed())
Expect(requestPath).To(Equal("/v1/patients/" + patientID + "/summary"))

decoded := &clinic.PatientSummaryV1{}
Expect(json.Unmarshal(requestBody, decoded)).To(Succeed())
Expect(decoded.CgmStats).ToNot(BeNil())
Expect(decoded.CgmStats.Id).To(Equal(patientSummary.CgmStats.Id))
Expect(decoded.BgmStats).To(BeNil())
})

// The clinic service reports a user who is not a patient of any clinic as no change. Most
// users are not, so reporting that as a failure would fail the work of nearly every user.
It("returns no error when the user is not a patient of any clinic", func() {
responseStatusCode = http.StatusNoContent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW, the clinic service responds with 200 OK, not 204 No Content:

https://github.com/tidepool-org/clinic/blob/master/api/patients.go#L295
https://tidepool.redocly.app/reference/clinic.v1/clinics/updatepatientsummary

Line 232 in clinic/service.go could also be simplified to match.

The ec.NoContent just refers to the body, the status code returned is that which is passed as its argument (in this case, http.StatusOK).

Expect(client.UpdatePatientSummary(context.Background(), patientID, patientSummary)).To(Succeed())
})

It("returns no error when the clinic service reports not found", func() {
responseStatusCode = http.StatusNotFound
Expect(client.UpdatePatientSummary(context.Background(), patientID, patientSummary)).To(Succeed())
})

It("returns an error when the clinic service reports an unexpected status", func() {
responseStatusCode = http.StatusInternalServerError
err := client.UpdatePatientSummary(context.Background(), patientID, patientSummary)
Expect(err).To(MatchError(ContainSubstring("unexpected response status code 500")))
})
})

Context("DeletePatientSummary", func() {
var summaryID string

BeforeEach(func() {
responseStatusCode = http.StatusOK
summaryID = primitive.NewObjectID().Hex()
})

It("deletes the summary from every patient record holding it", func() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test doesn't test what it says it will, and should be renamed to reflect what it's testing.

Expect(client.DeletePatientSummary(context.Background(), summaryID)).To(Succeed())
Expect(requestPath).To(Equal("/v1/summaries/" + summaryID + "/clinics"))
})

It("returns no error when no patient record holds the summary", func() {
responseStatusCode = http.StatusNoContent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This also doesn't return 204 NoContent, but rather 200 OK.

https://github.com/tidepool-org/clinic/blob/master/api/patients.go#L295

Expect(client.DeletePatientSummary(context.Background(), summaryID)).To(Succeed())
})

// A delete resent by retried work may target a summary the clinic service already deleted
It("returns no error when the summary is not found", func() {
responseStatusCode = http.StatusNotFound
Expect(client.DeletePatientSummary(context.Background(), summaryID)).To(Succeed())
})

It("returns an error when the clinic service reports an unexpected status", func() {
responseStatusCode = http.StatusInternalServerError
err := client.DeletePatientSummary(context.Background(), summaryID)
Expect(err).To(MatchError(ContainSubstring("unexpected response status code 500")))
})
})
})
Loading