Skip to content

Commit

Permalink
[BACK-2501] add endpoints for care partner alerting (#657)
Browse files Browse the repository at this point in the history
* adds care team alerting endpoints and models to the data service

There's no particular dependency between data and the alerts, but this seems
the best place to store it.

BACK-2501
  • Loading branch information
ewollesen authored Oct 2, 2023
1 parent ae7c953 commit 37a1f1f
Show file tree
Hide file tree
Showing 21 changed files with 1,515 additions and 8 deletions.
165 changes: 165 additions & 0 deletions alerts/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package alerts

// Data models for care team alerts.

import (
"bytes"
"context"
"time"

"github.com/tidepool-org/platform/data/blood/glucose"
"github.com/tidepool-org/platform/structure"
"github.com/tidepool-org/platform/user"
)

// Config models a user's desired alerts.
type Config struct {
// UserID receives the alerts, and owns this Config.
UserID string `json:"userId" bson:"userId"`
// FollowedID is the user whose data generates alerts, and has granted
// UserID permission to that data.
FollowedID string `json:"followedId" bson:"followedId"`
UrgentLow *WithThreshold `json:"urgentLow,omitempty" bson:"urgentLow,omitempty"`
Low *WithDelayAndThreshold `json:"low,omitempty" bson:"low,omitempty"`
High *WithDelayAndThreshold `json:"high,omitempty" bson:"high,omitempty"`
NotLooping *WithDelay `json:"notLooping,omitempty" bson:"notLooping,omitempty"`
NoCommunication *WithDelay `json:"noCommunication,omitempty" bson:"noCommunication,omitempty"`
}

func (c Config) Validate(validator structure.Validator) {
validator.String("UserID", &c.UserID).Using(user.IDValidator)
validator.String("FollowedID", &c.FollowedID).Using(user.IDValidator)
if c.Low != nil {
c.Low.Validate(validator)
}
if c.UrgentLow != nil {
c.UrgentLow.Validate(validator)
}
if c.High != nil {
c.High.Validate(validator)
}
if c.NotLooping != nil {
c.NotLooping.Validate(validator)
}
if c.NoCommunication != nil {
c.NoCommunication.Validate(validator)
}
}

// Base describes the minimum specifics of a desired alert.
type Base struct {
// Enabled controls whether notifications should be sent for this alert.
Enabled bool
// Repeat is measured in minutes.
Repeat DurationMinutes `json:"repeat"`
}

func (b Base) Validate(validator structure.Validator) {
validator.Bool("enabled", &b.Enabled)
dur := b.Repeat.Duration()
validator.Duration("repeat", &dur).GreaterThan(0 * time.Minute)
}

// DelayMixin adds a configurable delay.
type DelayMixin struct {
// Delay is measured in minutes.
Delay DurationMinutes `json:"delay,omitempty"`
}

func (d DelayMixin) Validate(validator structure.Validator) {
dur := d.Delay.Duration()
validator.Duration("delay", &dur).GreaterThan(0 * time.Minute)
}

// ThresholdMixin adds a configurable threshold.
type ThresholdMixin struct {
// Threshold is compared the current value to determine if an alert should
// be triggered.
Threshold `json:"threshold"`
}

func (t ThresholdMixin) Validate(validator structure.Validator) {
t.Threshold.Validate(validator)
}

// WithThreshold extends Base with ThresholdMixin.
type WithThreshold struct {
Base `bson:",inline"`
ThresholdMixin `bson:",inline"`
}

func (d WithThreshold) Validate(validator structure.Validator) {
d.Base.Validate(validator)
d.ThresholdMixin.Validate(validator)
}

// WithDelay extends Base with DelayMixin.
type WithDelay struct {
Base `bson:",inline"`
DelayMixin `bson:",inline"`
}

func (d WithDelay) Validate(validator structure.Validator) {
d.Base.Validate(validator)
d.DelayMixin.Validate(validator)
}

// WithDelayAndThreshold extends Base with both DelayMixin and ThresholdMixin.
type WithDelayAndThreshold struct {
Base `bson:",inline"`
DelayMixin `bson:",inline"`
ThresholdMixin `bson:",inline"`
}

func (d WithDelayAndThreshold) Validate(validator structure.Validator) {
d.Base.Validate(validator)
d.DelayMixin.Validate(validator)
d.ThresholdMixin.Validate(validator)
}

// DurationMinutes reads a JSON integer and converts it to a time.Duration.
//
// Values are specified in minutes.
type DurationMinutes time.Duration

func (m *DurationMinutes) UnmarshalJSON(b []byte) error {
if bytes.Equal(b, []byte("null")) || len(b) == 0 {
*m = DurationMinutes(0)
return nil
}
d, err := time.ParseDuration(string(b) + "m")
if err != nil {
return err
}
*m = DurationMinutes(d)
return nil
}

func (m DurationMinutes) Duration() time.Duration {
return time.Duration(m)
}

// ValueWithUnits binds a value to its units.
//
// Other types can extend it to parse and validate the Units.
type ValueWithUnits struct {
Value float64 `json:"value"`
Units string `json:"units"`
}

// Threshold is a value measured in either mg/dL or mmol/L.
type Threshold ValueWithUnits

// Validate implements structure.Validatable
func (t Threshold) Validate(validator structure.Validator) {
validator.String("units", &t.Units).OneOf(glucose.MgdL, glucose.MmolL)
}

// Repository abstracts persistent storage for Config data.
type Repository interface {
Get(ctx context.Context, conf *Config) (*Config, error)
Upsert(ctx context.Context, conf *Config) error
Delete(ctx context.Context, conf *Config) error

EnsureIndexes() error
}
226 changes: 226 additions & 0 deletions alerts/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package alerts

import (
"bytes"
"fmt"
"strings"
"testing"
"time"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"

"github.com/tidepool-org/platform/data/blood/glucose"
"github.com/tidepool-org/platform/request"
"github.com/tidepool-org/platform/test"
)

func TestSuite(t *testing.T) {
test.Test(t)
}

const (
mockUserID1 = "008c7f79-6545-4466-95fb-34e3ba728d38"
mockUserID2 = "b1880201-30d5-4190-92bb-6afcf08ca15e"
)

var _ = Describe("Config", func() {
It("parses all the things", func() {
buf := buff(`{
"userId": "%s",
"followedId": "%s",
"low": {
"enabled": true,
"repeat": 30,
"delay": 10,
"threshold": {
"units": "mg/dL",
"value": 123.4
}
},
"urgentLow": {
"enabled": false,
"repeat": 30,
"threshold": {
"units": "mg/dL",
"value": 456.7
}
},
"high": {
"enabled": false,
"repeat": 30,
"delay": 5,
"threshold": {
"units": "mmol/L",
"value": 456.7
}
},
"notLooping": {
"enabled": true,
"repeat": 32,
"delay": 4
},
"noCommunication": {
"enabled": true,
"repeat": 33,
"delay": 6
}
}`, mockUserID1, mockUserID2)
conf := &Config{}
err := request.DecodeObject(nil, buf, conf)
Expect(err).ToNot(HaveOccurred())
Expect(conf.UserID).To(Equal(mockUserID1))
Expect(conf.FollowedID).To(Equal(mockUserID2))
Expect(conf.High.Enabled).To(Equal(false))
Expect(conf.High.Repeat).To(Equal(DurationMinutes(30 * time.Minute)))
Expect(conf.High.Delay).To(Equal(DurationMinutes(5 * time.Minute)))
Expect(conf.High.Threshold.Value).To(Equal(456.7))
Expect(conf.High.Threshold.Units).To(Equal(glucose.MmolL))
Expect(conf.Low.Enabled).To(Equal(true))
Expect(conf.Low.Repeat).To(Equal(DurationMinutes(30 * time.Minute)))
Expect(conf.Low.Delay).To(Equal(DurationMinutes(10 * time.Minute)))
Expect(conf.Low.Threshold.Value).To(Equal(123.4))
Expect(conf.Low.Threshold.Units).To(Equal(glucose.MgdL))
Expect(conf.UrgentLow.Enabled).To(Equal(false))
Expect(conf.UrgentLow.Repeat).To(Equal(DurationMinutes(30 * time.Minute)))
Expect(conf.UrgentLow.Threshold.Value).To(Equal(456.7))
Expect(conf.UrgentLow.Threshold.Units).To(Equal(glucose.MgdL))
Expect(conf.NotLooping.Enabled).To(Equal(true))
Expect(conf.NotLooping.Repeat).To(Equal(DurationMinutes(32 * time.Minute)))
Expect(conf.NotLooping.Delay).To(Equal(DurationMinutes(4 * time.Minute)))
Expect(conf.NoCommunication.Enabled).To(Equal(true))
Expect(conf.NoCommunication.Repeat).To(Equal(DurationMinutes(33 * time.Minute)))
Expect(conf.NoCommunication.Delay).To(Equal(DurationMinutes(6 * time.Minute)))
})

Context("urgentLow", func() {
It("validates threshold units", func() {
buf := buff(`{"urgentLow": {"threshold": {"units":"%s","value":42}}`, "garbage")
threshold := &Threshold{}
err := request.DecodeObject(nil, buf, threshold)
Expect(err).To(MatchError("json is malformed"))
})
It("validates repeat minutes (negative)", func() {
buf := buff(`{
"userId": "%s",
"followedId": "%s",
"urgentLow": {
"enabled": false,
"repeat": -11,
"threshold": {
"units": "%s",
"value": 1
}
}
}`, mockUserID1, mockUserID2, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("value -11m0s is not greater than 0s"))
})
It("validates repeat minutes (string)", func() {
buf := buff(`{
"userId": "%s",
"followedId": "%s",
"urgentLow": {
"enabled": false,
"repeat": "a",
"threshold": {
"units": "%s",
"value": 1
}
}
}`, mockUserID1, mockUserID2, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("json is malformed"))
})
})

Context("low", func() {
It("rejects a blank repeat", func() {
buf := buff(`{
"userId": "%s",
"followedId": "%s",
"low": {
"enabled": true,
"delay": 10,
"threshold": {
"units": "mg/dL",
"value": 123.4
}
}
}`, mockUserID1, mockUserID2)
conf := &Config{}
err := request.DecodeObject(nil, buf, conf)
Expect(err).To(HaveOccurred())
})
})
})

var _ = Describe("Duration", func() {
It("parses 42", func() {
d := DurationMinutes(0)
err := d.UnmarshalJSON([]byte(`42`))
Expect(err).To(BeNil())
Expect(d.Duration()).To(Equal(42 * time.Minute))
})
It("parses 0", func() {
d := DurationMinutes(time.Minute)
err := d.UnmarshalJSON([]byte(`0`))
Expect(err).To(BeNil())
Expect(d.Duration()).To(Equal(time.Duration(0)))
})
It("parses null as 0 minutes", func() {
d := DurationMinutes(time.Minute)
err := d.UnmarshalJSON([]byte(`null`))
Expect(err).To(BeNil())
Expect(d.Duration()).To(Equal(time.Duration(0)))
})
It("parses an empty value as 0 minutes", func() {
d := DurationMinutes(time.Minute)
err := d.UnmarshalJSON([]byte(``))
Expect(err).To(BeNil())
Expect(d.Duration()).To(Equal(time.Duration(0)))
})
})

var _ = Describe("Threshold", func() {
It("accepts mg/dL", func() {
buf := buff(`{"units":"%s","value":42}`, glucose.MgdL)
threshold := &Threshold{}
err := request.DecodeObject(nil, buf, threshold)
Expect(err).To(BeNil())
Expect(threshold.Value).To(Equal(42.0))
Expect(threshold.Units).To(Equal(glucose.MgdL))
})
It("accepts mmol/L", func() {
buf := buff(`{"units":"%s","value":42}`, glucose.MmolL)
threshold := &Threshold{}
err := request.DecodeObject(nil, buf, threshold)
Expect(err).To(BeNil())
Expect(threshold.Value).To(Equal(42.0))
Expect(threshold.Units).To(Equal(glucose.MmolL))
})
It("rejects lb/gal", func() {
buf := buff(`{"units":"%s","value":42}`, "lb/gal")
err := request.DecodeObject(nil, buf, &Threshold{})
Expect(err).Should(HaveOccurred())
})
It("rejects blank units", func() {
buf := buff(`{"units":"","value":42}`)
err := request.DecodeObject(nil, buf, &Threshold{})
Expect(err).Should(HaveOccurred())
})
It("is case-sensitive with respect to Units", func() {
badUnits := strings.ToUpper(glucose.MmolL)
buf := buff(`{"units":"%s","value":42}`, badUnits)
err := request.DecodeObject(nil, buf, &Threshold{})
Expect(err).Should(HaveOccurred())
})

})

// buff is a helper for generating a JSON []byte representation.
func buff(format string, args ...interface{}) *bytes.Buffer {
return bytes.NewBufferString(fmt.Sprintf(format, args...))
}
1 change: 1 addition & 0 deletions alerts/repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package alerts
Loading

0 comments on commit 37a1f1f

Please sign in to comment.