diff --git a/devtools/pgmocktime/mocker.go b/devtools/pgmocktime/mocker.go
index 1fe77816ea..91eed291e3 100644
--- a/devtools/pgmocktime/mocker.go
+++ b/devtools/pgmocktime/mocker.go
@@ -152,7 +152,7 @@ func (m *Mocker) Close() error { m.db.Close(); return nil }
// AdvanceTime advances the time by the given duration.
func (m *Mocker) AdvanceTime(ctx context.Context, d time.Duration) error {
- m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Millisecond)
+ m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d hours'::interval + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Hour, (d % time.Hour).Milliseconds())
return m.readErr("advance time")
}
diff --git a/devtools/resetdb/datagen.go b/devtools/resetdb/datagen.go
index a884130b17..e0e56eb711 100644
--- a/devtools/resetdb/datagen.go
+++ b/devtools/resetdb/datagen.go
@@ -29,7 +29,7 @@ import (
var (
timeZones = []string{"America/Chicago", "Europe/Berlin", "UTC"}
- rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly}
+ rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly, rotation.TypeMonthly}
)
type AlertLog struct {
diff --git a/gadb/models.go b/gadb/models.go
index 891f2c6a9e..be9ee7f4b8 100644
--- a/gadb/models.go
+++ b/gadb/models.go
@@ -532,9 +532,10 @@ func (ns NullEnumOutgoingMessagesType) Value() (driver.Value, error) {
type EnumRotationType string
const (
- EnumRotationTypeDaily EnumRotationType = "daily"
- EnumRotationTypeHourly EnumRotationType = "hourly"
- EnumRotationTypeWeekly EnumRotationType = "weekly"
+ EnumRotationTypeDaily EnumRotationType = "daily"
+ EnumRotationTypeHourly EnumRotationType = "hourly"
+ EnumRotationTypeMonthly EnumRotationType = "monthly"
+ EnumRotationTypeWeekly EnumRotationType = "weekly"
)
func (e *EnumRotationType) Scan(src interface{}) error {
diff --git a/graphql2/generated.go b/graphql2/generated.go
index 8c3260353e..eba58d5e50 100644
--- a/graphql2/generated.go
+++ b/graphql2/generated.go
@@ -26600,7 +26600,7 @@ func (ec *executionContext) unmarshalInputCalcRotationHandoffTimesInput(ctx cont
asMap[k] = v
}
- fieldsInOrder := [...]string{"handoff", "from", "timeZone", "shiftLengthHours", "count"}
+ fieldsInOrder := [...]string{"handoff", "from", "timeZone", "shiftLengthHours", "shiftLength", "count"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
@@ -26638,11 +26638,20 @@ func (ec *executionContext) unmarshalInputCalcRotationHandoffTimesInput(ctx cont
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("shiftLengthHours"))
- data, err := ec.unmarshalNInt2int(ctx, v)
+ data, err := ec.unmarshalOInt2ᚖint(ctx, v)
if err != nil {
return it, err
}
it.ShiftLengthHours = data
+ case "shiftLength":
+ var err error
+
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("shiftLength"))
+ data, err := ec.unmarshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.ShiftLength = data
case "count":
var err error
@@ -41148,6 +41157,22 @@ func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.Se
return res
}
+func (ec *executionContext) unmarshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx context.Context, v interface{}) (*timeutil.ISODuration, error) {
+ if v == nil {
+ return nil, nil
+ }
+ var res = new(timeutil.ISODuration)
+ err := res.UnmarshalGQL(v)
+ return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx context.Context, sel ast.SelectionSet, v *timeutil.ISODuration) graphql.Marshaler {
+ if v == nil {
+ return graphql.Null
+ }
+ return v
+}
+
func (ec *executionContext) unmarshalOISOTimestamp2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) {
res, err := UnmarshalISOTimestamp(v)
return res, graphql.ErrorOnPath(ctx, err)
diff --git a/graphql2/graphqlapp/rotation.go b/graphql2/graphqlapp/rotation.go
index d74194764d..5cd60cdf79 100644
--- a/graphql2/graphqlapp/rotation.go
+++ b/graphql2/graphqlapp/rotation.go
@@ -12,6 +12,7 @@ import (
"github.com/target/goalert/search"
"github.com/target/goalert/user"
"github.com/target/goalert/util"
+ "github.com/target/goalert/util/timeutil"
"github.com/target/goalert/validation"
"github.com/target/goalert/validation/validate"
@@ -378,27 +379,39 @@ func (m *Mutation) UpdateRotation(ctx context.Context, input graphql2.UpdateRota
}
func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.CalcRotationHandoffTimesInput) ([]time.Time, error) {
- var result []time.Time
- var err error
- err = validate.Many(
- err,
- validate.Range("count", input.Count, 0, 20),
- validate.Range("hours", input.ShiftLengthHours, 0, 99999),
- )
+ err := validate.Range("count", input.Count, 0, 20)
if err != nil {
- return result, err
+ return nil, err
}
loc, err := util.LoadLocation(input.TimeZone)
if err != nil {
- return result, validation.NewFieldError("timeZone", err.Error())
+ return nil, validation.NewFieldError("timeZone", err.Error())
+ }
+
+ if input.ShiftLength != nil && input.ShiftLengthHours != nil {
+ return nil, validation.NewFieldError("shiftLength", "only one of (shiftLength, shiftLengthHours) is allowed")
}
- rot := &rotation.Rotation{
- Start: input.Handoff.In(loc),
- ShiftLength: input.ShiftLengthHours,
- Type: rotation.TypeHourly,
+ rot := rotation.Rotation{
+ Start: input.Handoff.In(loc),
+ }
+ switch {
+ case input.ShiftLength != nil:
+ err = setRotationShiftFromISO(&rot, input.ShiftLength)
+ if err != nil {
+ return nil, err
+ }
+ case input.ShiftLengthHours != nil:
+ err = validate.Range("hours", *input.ShiftLengthHours, 0, 99999)
+ if err != nil {
+ return nil, err
+ }
+ rot.Type = rotation.TypeHourly
+ rot.ShiftLength = *input.ShiftLengthHours
+ default:
+ return nil, validation.NewFieldError("shiftLength", "must be specified")
}
t := time.Now()
@@ -406,6 +419,7 @@ func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.Ca
t = input.From.In(loc)
}
+ var result []time.Time
for len(result) < input.Count {
t = rot.EndTime(t)
result = append(result, t)
@@ -413,3 +427,47 @@ func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.Ca
return result, nil
}
+
+// getRotationFromISO determines the rotation type based on the given ISODuration. An error is given if the unsupported year field or multiple non-zero fields are given.
+func setRotationShiftFromISO(rot *rotation.Rotation, dur *timeutil.ISODuration) error {
+
+ // validate only one time field (year, month, days, timepart) is non-zero
+ nonZeroFields := 0
+
+ if dur.YearPart > 0 {
+ // These validation errors are only possible from direct api calls,
+ // thus using ISO standard terminology "designator" to match the spec.
+ return validation.NewFieldError("shiftLength", "year designator not allowed")
+ }
+
+ if dur.MonthPart > 0 {
+ rot.Type = rotation.TypeMonthly
+ rot.ShiftLength = dur.MonthPart
+ nonZeroFields++
+ }
+ if dur.WeekPart > 0 {
+ rot.Type = rotation.TypeWeekly
+ rot.ShiftLength = dur.WeekPart
+ nonZeroFields++
+ }
+ if dur.DayPart > 0 {
+ rot.Type = rotation.TypeDaily
+ rot.ShiftLength = dur.DayPart
+ nonZeroFields++
+ }
+ if dur.HourPart > 0 {
+ rot.Type = rotation.TypeHourly
+ rot.ShiftLength = dur.HourPart
+ nonZeroFields++
+ }
+
+ if nonZeroFields == 0 {
+ return validation.NewFieldError("shiftLength", "must not be zero")
+ }
+ if nonZeroFields > 1 {
+ // Same as above, this error is only possible from direct api calls.
+ return validation.NewFieldError("shiftLength", "only one of (M, W, D, H) is allowed")
+ }
+
+ return nil
+}
diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go
index 7af777028d..20de99bdb3 100644
--- a/graphql2/models_gen.go
+++ b/graphql2/models_gen.go
@@ -78,11 +78,12 @@ type AuthSubjectConnection struct {
}
type CalcRotationHandoffTimesInput struct {
- Handoff time.Time `json:"handoff"`
- From *time.Time `json:"from,omitempty"`
- TimeZone string `json:"timeZone"`
- ShiftLengthHours int `json:"shiftLengthHours"`
- Count int `json:"count"`
+ Handoff time.Time `json:"handoff"`
+ From *time.Time `json:"from,omitempty"`
+ TimeZone string `json:"timeZone"`
+ ShiftLengthHours *int `json:"shiftLengthHours,omitempty"`
+ ShiftLength *timeutil.ISODuration `json:"shiftLength,omitempty"`
+ Count int `json:"count"`
}
type ClearTemporarySchedulesInput struct {
diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql
index fd198f7a4b..35ea0b922e 100644
--- a/graphql2/schema.graphql
+++ b/graphql2/schema.graphql
@@ -926,6 +926,7 @@ type Rotation {
}
enum RotationType {
+ monthly
weekly
daily
hourly
@@ -972,7 +973,12 @@ input CalcRotationHandoffTimesInput {
handoff: ISOTimestamp!
from: ISOTimestamp
timeZone: String!
- shiftLengthHours: Int!
+ shiftLengthHours: Int
+ @deprecated(
+ reason: "Only accurate for hourly-type rotations. Use shiftLength instead."
+ )
+
+ shiftLength: ISODuration
count: Int!
}
diff --git a/migrate/migrations/20230911153243-add-monthly-rotation.sql b/migrate/migrations/20230911153243-add-monthly-rotation.sql
new file mode 100644
index 0000000000..7268d14a5c
--- /dev/null
+++ b/migrate/migrations/20230911153243-add-monthly-rotation.sql
@@ -0,0 +1,5 @@
+-- +migrate Up notransaction
+ALTER TYPE enum_rotation_type ADD VALUE IF NOT EXISTS 'monthly';
+
+-- +migrate Down
+
diff --git a/migrate/schema.sql b/migrate/schema.sql
index d5602ff0e2..5f3253ed63 100644
--- a/migrate/schema.sql
+++ b/migrate/schema.sql
@@ -1,7 +1,7 @@
-- This file is auto-generated by "make db-schema"; DO NOT EDIT
--- DATA=35c108711cef0a38d78d5872c062fe576844c7f16e309378a051faa813578de0 -
--- DISK=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b -
--- PSQL=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b -
+-- DATA=6b404a10cbecec48dd680c5a6c3312950cef04a97ebf2b8bbff62df0c419d76d -
+-- DISK=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b -
+-- PSQL=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b -
--
-- pgdump-lite database dump
--
@@ -122,6 +122,7 @@ CREATE TYPE enum_outgoing_messages_type AS ENUM (
CREATE TYPE enum_rotation_type AS ENUM (
'daily',
'hourly',
+ 'monthly',
'weekly'
);
diff --git a/schedule/rotation/rotation.go b/schedule/rotation/rotation.go
index 73998a9673..c4f9550df7 100644
--- a/schedule/rotation/rotation.go
+++ b/schedule/rotation/rotation.go
@@ -32,12 +32,66 @@ func (r Rotation) shiftClock() timeutil.Clock {
case TypeWeekly:
return timeutil.NewClock(r.ShiftLength*24*7, 0)
default:
+ // monthly is handled separately
panic("unexpected rotation type")
}
}
+// monthStartTime recursively calculates the previous handoff time of a rotation active at t.
+func (r Rotation) monthStartTime(t time.Time, n int) time.Time {
+ if n > 10000 {
+ panic("too many iterations")
+ }
+
+ if !t.Before(r.Start) { // t is at or after start of rotation
+ next := r.Start.AddDate(0, r.ShiftLength*n, 0)
+ if next.After(t) {
+ return r.Start.AddDate(0, r.ShiftLength*(n-1), 0)
+ }
+
+ // recursively finds the end of shift time of the rotation which came immediately before t
+ return r.monthStartTime(t, n+1)
+ }
+
+ // t is before start of rotation
+ prev := r.Start.AddDate(0, -r.ShiftLength*n, 0)
+ if prev.Before(t) {
+ return prev
+ }
+
+ // recursively finds the end of shift time of the rotation which came immediately before t when t is before the rotation start time
+ return r.monthStartTime(t, n+1)
+}
+
+// monthEndTime recursively calculates the end of the rotation (handoff time) that was active at time t.
+func (r Rotation) monthEndTime(t time.Time, n int) time.Time {
+ if n > 10000 {
+ panic("too many iterations")
+ }
+
+ if !t.Before(r.Start) { // t is at or after start of rotation
+ next := r.Start.AddDate(0, r.ShiftLength*n, 0)
+ if next.After(t) {
+ return next
+ }
+
+ // recursively finds the immediate end of shift time after t
+ return r.monthEndTime(t, n+1)
+ }
+
+ // t is before start of rotation
+ prev := r.Start.AddDate(0, -r.ShiftLength*n, 0)
+ if prev.Before(t) || prev.Equal(t) {
+ return r.Start.AddDate(0, -r.ShiftLength*(n-1), 0)
+ }
+
+ // recursively finds the immediate end of shift time after t for cases when t is before the rotation start time
+ return r.monthEndTime(t, n+1)
+}
+
// StartTime calculates the start of the "shift" that started at (or was active) at t.
-// For daily and weekly rotations, start time will be the previous handoff time (from start).
+// For daily, weekly, and monthly rotations, start time will be the previous handoff time (from start).
+// For monthly rotations, the monthStartTime function is used to recursively handle calculations as the length of months vary.
func (r Rotation) StartTime(t time.Time) time.Time {
if r.ShiftLength <= 0 {
r.ShiftLength = 1
@@ -45,6 +99,10 @@ func (r Rotation) StartTime(t time.Time) time.Time {
t = t.In(r.Start.Location()).Truncate(time.Minute)
r.Start = r.Start.Truncate(time.Minute)
+ if r.Type == TypeMonthly {
+ return r.monthStartTime(t, 1)
+ }
+
shiftClockLen := r.shiftClock()
rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen
@@ -56,6 +114,7 @@ func (r Rotation) StartTime(t time.Time) time.Time {
}
// EndTime calculates the end of the "shift" that started at (or was active) at t.
+// For monthly rotations, the monthEndTime function is used to recursively handle calculations as the length of months vary.
//
// It is guaranteed to occur after t.
func (r Rotation) EndTime(t time.Time) time.Time {
@@ -65,6 +124,10 @@ func (r Rotation) EndTime(t time.Time) time.Time {
t = t.In(r.Start.Location()).Truncate(time.Minute)
r.Start = r.Start.Truncate(time.Minute)
+ if r.Type == TypeMonthly {
+ return r.monthEndTime(t, 1)
+ }
+
shiftClockLen := r.shiftClock()
rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen
@@ -88,7 +151,7 @@ func (r Rotation) Normalize() (*Rotation, error) {
err := validate.Many(
validate.IDName("Name", r.Name),
validate.Range("ShiftLength", r.ShiftLength, 1, 9000),
- validate.OneOf("Type", r.Type, TypeWeekly, TypeDaily, TypeHourly),
+ validate.OneOf("Type", r.Type, TypeMonthly, TypeWeekly, TypeDaily, TypeHourly),
validate.Text("Description", r.Description, 1, 255),
)
if err != nil {
diff --git a/schedule/rotation/rotation_test.go b/schedule/rotation/rotation_test.go
index cc352ae9a9..de76070f7f 100644
--- a/schedule/rotation/rotation_test.go
+++ b/schedule/rotation/rotation_test.go
@@ -165,6 +165,36 @@ func TestRotation_StartEnd_BruteForce(t *testing.T) {
"2020-11-01 05:00:00 -0600 CST",
)
+ // monthly rotation sanity check
+ check(&Rotation{
+ Type: TypeMonthly,
+ ShiftLength: 1,
+ Start: time.Date(2020, time.January, 2, 1, 30, 0, 0, loc),
+ },
+ time.Date(2020, time.April, 5, 1, 0, 0, 0, loc),
+ time.Date(2020, time.June, 12, 1, 0, 0, 0, loc),
+
+ "2020-04-02 01:30:00 -0500 CDT",
+ "2020-05-02 01:30:00 -0500 CDT",
+ "2020-06-02 01:30:00 -0500 CDT",
+ "2020-07-02 01:30:00 -0500 CDT",
+ )
+
+ // check monthly rotations with shift from daylight savings to standard time
+ check(&Rotation{
+ Type: TypeMonthly,
+ ShiftLength: 1,
+ Start: time.Date(2020, time.October, 1, 1, 30, 0, 0, loc),
+ },
+ time.Date(2020, time.October, 2, 1, 0, 0, 0, loc),
+ time.Date(2020, time.December, 2, 1, 0, 0, 0, loc),
+
+ "2020-10-01 01:30:00 -0500 CDT",
+ "2020-11-01 01:30:00 -0500 CDT",
+ "2020-12-01 01:30:00 -0600 CST",
+ "2021-01-01 01:30:00 -0600 CST",
+ )
+
loc, err = time.LoadLocation("Australia/Lord_Howe")
require.NoError(t, err)
@@ -288,7 +318,6 @@ func TestRotation_StartEnd_BruteForce(t *testing.T) {
"2020-10-05 02:15:00 +1100 +11",
"2020-10-06 02:15:00 +1100 +11",
)
-
}
func TestRotation_EndTime_DST(t *testing.T) {
@@ -396,8 +425,21 @@ func TestRotation_EndTime(t *testing.T) {
dur time.Duration
}
- // weekly
+ // monthly
data := []dat{
+ {s: "Jun 1 2017 12:00 am", l: 1, exp: "Jul 1 2017 12:00 am", dur: time.Hour * 24 * 30},
+ {s: "Jul 1 2017 12:00 am", l: 2, exp: "Sep 1 2017 12:00 am", dur: time.Hour * 24 * 31 * 2},
+
+ // DST tests
+ {s: "Mar 1 2017 12:00 am", l: 1, exp: "Apr 1 2017 12:00 am", dur: time.Hour*24*31 - time.Hour},
+ {s: "Nov 1 2017 12:00 am", l: 2, exp: "Jan 1 2018 12:00 am", dur: time.Hour*(24*(30+31)) + time.Hour},
+ }
+ for _, d := range data {
+ test(d.s, d.exp, d.l, d.dur, TypeMonthly)
+ }
+
+ // weekly
+ data = []dat{
{s: "Jun 10 2017 8:00 am", l: 1, exp: "Jun 17 2017 8:00 am", dur: time.Hour * 24 * 7},
{s: "Jun 10 2017 8:00 am", l: 2, exp: "Jun 24 2017 8:00 am", dur: time.Hour * 24 * 7 * 2},
@@ -468,6 +510,23 @@ func TestRotation_EndTime(t *testing.T) {
ts = r.EndTime(ts)
assert.Equal(t, orig.AddDate(0, 0, 1).String(), ts.String())
})
+
+ t.Run("subsequent calls (monthly)", func(t *testing.T) {
+ orig := time.Date(2020, time.January, 10, 0, 0, 0, 0, time.UTC)
+ r := &Rotation{
+ Type: TypeMonthly,
+ ShiftLength: 1,
+ Start: orig,
+ }
+ ts := r.EndTime(orig.AddDate(0, -2, 0))
+ assert.Equal(t, orig.AddDate(0, -1, 0).String(), ts.String())
+
+ ts = r.EndTime(ts)
+ assert.Equal(t, orig.String(), ts.String())
+
+ ts = r.EndTime(ts)
+ assert.Equal(t, orig.AddDate(0, 1, 0).String(), ts.String())
+ })
}
func TestRotation_Normalize(t *testing.T) {
@@ -564,8 +623,21 @@ func TestRotation_StartTime(t *testing.T) {
dur time.Duration
}
- // weekly
+ // monthly
data := []dat{
+ {s: "Jun 1 2017 12:00 am", l: 1, exp: "Jul 1 2017 12:00 am", dur: time.Hour * 24 * 30},
+ {s: "Jul 1 2017 12:00 am", l: 2, exp: "Sep 1 2017 12:00 am", dur: time.Hour * 24 * 31 * 2},
+
+ // DST tests
+ {s: "Mar 1 2017 12:00 am", l: 1, exp: "Apr 1 2017 12:00 am", dur: time.Hour*24*31 - time.Hour},
+ {s: "Nov 1 2017 12:00 am", l: 2, exp: "Jan 1 2018 12:00 am", dur: time.Hour*(24*(31+30)) + time.Hour},
+ }
+ for _, d := range data {
+ test(d.s, d.exp, d.l, d.dur, TypeMonthly)
+ }
+
+ // weekly
+ data = []dat{
{s: "Jun 10 2017 8:00 am", l: 1, exp: "Jun 17 2017 8:00 am", dur: time.Hour * 24 * 7},
{s: "Jun 10 2017 8:00 am", l: 2, exp: "Jun 24 2017 8:00 am", dur: time.Hour * 24 * 7 * 2},
diff --git a/schedule/rotation/type.go b/schedule/rotation/type.go
index 0e0e7d9710..fd90efc63c 100644
--- a/schedule/rotation/type.go
+++ b/schedule/rotation/type.go
@@ -3,18 +3,20 @@ package rotation
import (
"database/sql/driver"
"fmt"
- "github.com/target/goalert/validation"
"io"
+ "github.com/target/goalert/validation"
+
"github.com/99designs/gqlgen/graphql"
)
type Type string
const (
- TypeWeekly Type = "weekly"
- TypeDaily Type = "daily"
- TypeHourly Type = "hourly"
+ TypeMonthly Type = "monthly"
+ TypeWeekly Type = "weekly"
+ TypeDaily Type = "daily"
+ TypeHourly Type = "hourly"
)
// Scan handles reading a Role from the DB format
@@ -34,7 +36,7 @@ func (r *Type) Scan(value interface{}) error {
// Value converts the Role to the DB representation
func (r Type) Value() (driver.Value, error) {
switch r {
- case TypeWeekly, TypeDaily, TypeHourly:
+ case TypeMonthly, TypeWeekly, TypeDaily, TypeHourly:
return string(r), nil
default:
return nil, fmt.Errorf("unknown rotation type specified '%s'", r)
@@ -48,6 +50,8 @@ func (t *Type) UnmarshalGQL(v interface{}) error {
return err
}
switch str {
+ case "monthly":
+ *t = TypeMonthly
case "weekly":
*t = TypeWeekly
case "daily":
@@ -64,6 +68,8 @@ func (t *Type) UnmarshalGQL(v interface{}) error {
// MarshalGQL implements the graphql.Marshaler interface
func (t Type) MarshalGQL(w io.Writer) {
switch t {
+ case TypeMonthly:
+ graphql.MarshalString("monthly").MarshalGQL(w)
case TypeWeekly:
graphql.MarshalString("weekly").MarshalGQL(w)
case TypeHourly:
diff --git a/test/smoke/harness/graphql.go b/test/smoke/harness/graphql.go
index c7522d24f6..418049ab58 100644
--- a/test/smoke/harness/graphql.go
+++ b/test/smoke/harness/graphql.go
@@ -22,22 +22,23 @@ import (
// DefaultGraphQLAdminUserID is the UserID created & used for GraphQL calls by default.
const DefaultGraphQLAdminUserID = "00000000-0000-0000-0000-000000000002"
-func (h *Harness) insertGraphQLUser(userID string) string {
+func (h *Harness) createGraphQLUser(userID string) {
h.t.Helper()
- var err error
- if userID == DefaultGraphQLAdminUserID {
- permission.SudoContext(context.Background(), func(ctx context.Context) {
- _, err = h.backend.UserStore.Insert(ctx, &user.User{
- Name: "GraphQL User",
- ID: userID,
- Role: permission.RoleAdmin,
- })
+ permission.SudoContext(context.Background(), func(ctx context.Context) {
+ h.t.Helper()
+ _, err := h.backend.UserStore.Insert(ctx, &user.User{
+ Name: "GraphQL User",
+ ID: userID,
+ Role: permission.RoleAdmin,
})
if err != nil {
h.t.Fatal(errors.Wrap(err, "create GraphQL user"))
}
- }
+ })
+}
+func (h *Harness) createGraphQLSession(userID string) string {
+ h.t.Helper()
tok, err := h.backend.AuthHandler.CreateSession(context.Background(), "goalert-smoketest", userID)
if err != nil {
h.t.Fatal(errors.Wrap(err, "create auth session"))
@@ -91,43 +92,61 @@ func (h *Harness) GraphQLQueryT(t *testing.T, query string) *QLResponse {
// handling authentication. Queries are performed with the provided UserID.
func (h *Harness) GraphQLQueryUserT(t *testing.T, userID, query string) *QLResponse {
t.Helper()
+ retry := 1
+ var err error
+ var resp *http.Response
+ var tok string
h.mx.Lock()
- tok := h.gqlSessions[userID]
+ tok = h.gqlSessions[userID]
if tok == "" {
- tok = h.insertGraphQLUser(userID)
+ if userID == DefaultGraphQLAdminUserID {
+ h.createGraphQLUser(userID)
+ }
+ tok = h.createGraphQLSession(userID)
}
h.mx.Unlock()
- query = strings.Replace(query, "\t", "", -1)
- q := struct{ Query string }{Query: query}
+ for {
+ query = strings.Replace(query, "\t", "", -1)
+ q := struct{ Query string }{Query: query}
- data, err := json.Marshal(q)
- if err != nil {
- h.t.Fatal("failed to marshal graphql query")
- }
- t.Log("Query:", query)
+ data, err := json.Marshal(q)
+ if err != nil {
+ h.t.Fatal("failed to marshal graphql query")
+ }
+ t.Log("Query:", query)
- url := h.URL() + "/api/graphql"
- req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
- if err != nil {
- t.Fatal("failed to make request:", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{
- Name: auth.CookieName,
- Value: tok,
- })
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatal("failed to make http request:", err)
+ url := h.URL() + "/api/graphql"
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
+ if err != nil {
+ t.Fatal("failed to make request:", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{
+ Name: auth.CookieName,
+ Value: tok,
+ })
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal("failed to make http request:", err)
+ }
+ if resp.StatusCode == 401 && retry > 0 {
+ h.t.Logf("GraphQL request failed with 401, retrying with new session (%d left)", retry)
+ h.mx.Lock()
+ tok = h.createGraphQLSession(userID)
+ h.mx.Unlock()
+ resp.Body.Close()
+ retry--
+ continue
+ }
+ if resp.StatusCode != 200 {
+ data, _ := io.ReadAll(resp.Body)
+ t.Fatal("failed to make graphql request:", resp.Status, string(data))
+ }
+ break
}
defer resp.Body.Close()
- if resp.StatusCode != 200 {
- data, _ := io.ReadAll(resp.Body)
- t.Fatal("failed to make graphql request:", resp.Status, string(data))
- }
-
var r QLResponse
err = json.NewDecoder(resp.Body).Decode(&r)
if err != nil {
diff --git a/test/smoke/rotationmonthly_test.go b/test/smoke/rotationmonthly_test.go
new file mode 100644
index 0000000000..4c19413e2a
--- /dev/null
+++ b/test/smoke/rotationmonthly_test.go
@@ -0,0 +1,68 @@
+package smoke
+
+import (
+ "testing"
+ "time"
+
+ "github.com/target/goalert/test/smoke/harness"
+)
+
+func TestRotation_Monthly(t *testing.T) {
+ t.Parallel()
+
+ const sql = `
+ set timezone = 'America/Chicago';
+
+ insert into users (id, name, email)
+ values
+ ({{uuid "uid1"}}, 'bob', 'joe'),
+ ({{uuid "uid2"}}, 'ben', 'frank');
+
+ insert into user_contact_methods (id, user_id, name, type, value)
+ values
+ ({{uuid "cm1"}}, {{uuid "uid1"}}, 'personal', 'SMS', {{phone "1"}}),
+ ({{uuid "cm2"}}, {{uuid "uid2"}}, 'personal', 'SMS', {{phone "2"}});
+
+ insert into user_notification_rules (user_id, contact_method_id, delay_minutes)
+ values
+ ({{uuid "uid1"}}, {{uuid "cm1"}}, 0),
+ ({{uuid "uid2"}}, {{uuid "cm2"}}, 0);
+
+ insert into escalation_policies (id, name)
+ values
+ ({{uuid "eid"}}, 'esc policy');
+ insert into escalation_policy_steps (id, escalation_policy_id)
+ values
+ ({{uuid "esid"}}, {{uuid "eid"}});
+
+ insert into rotations (id, name, type, start_time, shift_length, time_zone)
+ values
+ ({{uuid "rot1"}}, 'default rotation', 'monthly', now(), 1, 'America/Chicago');
+
+ insert into rotation_participants (rotation_id, user_id, position)
+ values
+ ({{uuid "rot1"}}, {{uuid "uid1"}}, 0),
+ ({{uuid "rot1"}}, {{uuid "uid2"}}, 1);
+
+ insert into escalation_policy_actions (escalation_policy_step_id, rotation_id)
+ values
+ ({{uuid "esid"}}, {{uuid "rot1"}});
+
+ insert into services (id, escalation_policy_id, name) values
+ ({{uuid "sid"}}, {{uuid "eid"}}, 'service');
+
+ `
+
+ h := harness.NewHarness(t, sql, "add-monthly-rotation")
+ defer h.Close()
+
+ sid := h.UUID("sid")
+ uid1 := h.UUID("uid1")
+ uid2 := h.UUID("uid2")
+
+ h.WaitAndAssertOnCallUsers(sid, uid1)
+
+ h.FastForward(32 * 24 * time.Hour)
+
+ h.WaitAndAssertOnCallUsers(sid, uid2)
+}
diff --git a/web/src/app/rotations/HandoffSummary.tsx b/web/src/app/rotations/HandoffSummary.tsx
index b24ddf2c02..4234a6b04b 100644
--- a/web/src/app/rotations/HandoffSummary.tsx
+++ b/web/src/app/rotations/HandoffSummary.tsx
@@ -9,14 +9,15 @@ export interface HandoffSummaryProps {
timeZone: string
}
-function dur(p: HandoffSummaryProps): JSX.Element {
+function dur(p: HandoffSummaryProps): JSX.Element | string {
if (p.type === 'hourly') return
if (p.type === 'daily') return
if (p.type === 'weekly') return
+ if (p.type === 'monthly') return `${p.shiftLength} month(s)` // TODO: update Time to support months
throw new Error('unknown rotation type: ' + p.type)
}
-function ts(p: HandoffSummaryProps): JSX.Element {
+function ts(p: HandoffSummaryProps): JSX.Element | string {
if (p.type === 'hourly')
return
if (p.type === 'daily')
@@ -30,6 +31,8 @@ function ts(p: HandoffSummaryProps): JSX.Element {
format='weekday-clock'
/>
)
+ if (p.type === 'monthly')
+ return 'NOTE: Monthly rotations are not fully supported yet'
throw new Error('unknown rotation type: ' + p.type)
}
diff --git a/web/src/app/rotations/RotationForm.tsx b/web/src/app/rotations/RotationForm.tsx
index 665a4d753d..a34c3d3a34 100644
--- a/web/src/app/rotations/RotationForm.tsx
+++ b/web/src/app/rotations/RotationForm.tsx
@@ -55,6 +55,7 @@ function getHours(count: number, unit: RotationType): number {
hourly: 1,
daily: 24,
weekly: 24 * 7,
+ monthly: 24 * DateTime.local().daysInMonth,
}
return lookup[unit] * count
}
diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts
index aa212234cb..0b4a605457 100644
--- a/web/src/schema.d.ts
+++ b/web/src/schema.d.ts
@@ -726,7 +726,7 @@ export interface Rotation {
nextHandoffTimes: ISOTimestamp[]
}
-export type RotationType = 'weekly' | 'daily' | 'hourly'
+export type RotationType = 'monthly' | 'weekly' | 'daily' | 'hourly'
export interface UpdateAlertsInput {
alertIDs: number[]
@@ -758,7 +758,8 @@ export interface CalcRotationHandoffTimesInput {
handoff: ISOTimestamp
from?: null | ISOTimestamp
timeZone: string
- shiftLengthHours: number
+ shiftLengthHours?: null | number
+ shiftLength?: null | ISODuration
count: number
}