-
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[BACK-2501] add endpoints for care partner alerting (#657)
* 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
Showing
21 changed files
with
1,515 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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...)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
package alerts |
Oops, something went wrong.