diff --git a/graphql2/generated.go b/graphql2/generated.go index 7cda28299d..d291288e88 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -182,6 +182,8 @@ type ComplexityRoot struct { Destination func(childComplexity int) int ID func(childComplexity int) int ProviderID func(childComplexity int) int + RetryCount func(childComplexity int) int + SentAt func(childComplexity int) int ServiceID func(childComplexity int) int ServiceName func(childComplexity int) int Source func(childComplexity int) int @@ -1213,6 +1215,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.DebugMessage.ProviderID(childComplexity), true + case "DebugMessage.retryCount": + if e.complexity.DebugMessage.RetryCount == nil { + break + } + + return e.complexity.DebugMessage.RetryCount(childComplexity), true + + case "DebugMessage.sentAt": + if e.complexity.DebugMessage.SentAt == nil { + break + } + + return e.complexity.DebugMessage.SentAt(childComplexity), true + case "DebugMessage.serviceID": if e.complexity.DebugMessage.ServiceID == nil { break @@ -7874,6 +7890,91 @@ func (ec *executionContext) fieldContext_DebugMessage_providerID(ctx context.Con return fc, nil } +func (ec *executionContext) _DebugMessage_sentAt(ctx context.Context, field graphql.CollectedField, obj *DebugMessage) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DebugMessage_sentAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.SentAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*time.Time) + fc.Result = res + return ec.marshalOISOTimestamp2ᚖtimeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DebugMessage_sentAt(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DebugMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ISOTimestamp does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DebugMessage_retryCount(ctx context.Context, field graphql.CollectedField, obj *DebugMessage) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DebugMessage_retryCount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.RetryCount, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DebugMessage_retryCount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DebugMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _DebugMessageStatusInfo_state(ctx context.Context, field graphql.CollectedField, obj *DebugMessageStatusInfo) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DebugMessageStatusInfo_state(ctx, field) if err != nil { @@ -9803,6 +9904,10 @@ func (ec *executionContext) fieldContext_MessageLogConnection_nodes(ctx context. return ec.fieldContext_DebugMessage_alertID(ctx, field) case "providerID": return ec.fieldContext_DebugMessage_providerID(ctx, field) + case "sentAt": + return ec.fieldContext_DebugMessage_sentAt(ctx, field) + case "retryCount": + return ec.fieldContext_DebugMessage_retryCount(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DebugMessage", field.Name) }, @@ -13822,6 +13927,10 @@ func (ec *executionContext) fieldContext_Query_debugMessages(ctx context.Context return ec.fieldContext_DebugMessage_alertID(ctx, field) case "providerID": return ec.fieldContext_DebugMessage_providerID(ctx, field) + case "sentAt": + return ec.fieldContext_DebugMessage_sentAt(ctx, field) + case "retryCount": + return ec.fieldContext_DebugMessage_retryCount(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DebugMessage", field.Name) }, @@ -28902,6 +29011,17 @@ func (ec *executionContext) _DebugMessage(ctx context.Context, sel ast.Selection out.Values[i] = ec._DebugMessage_providerID(ctx, field, obj) + case "sentAt": + + out.Values[i] = ec._DebugMessage_sentAt(ctx, field, obj) + + case "retryCount": + + out.Values[i] = ec._DebugMessage_retryCount(ctx, field, obj) + + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/graphql2/graphqlapp/messagelog.go b/graphql2/graphqlapp/messagelog.go index f277421917..c7dd0c5bc5 100644 --- a/graphql2/graphqlapp/messagelog.go +++ b/graphql2/graphqlapp/messagelog.go @@ -173,12 +173,14 @@ func (q *Query) MessageLogs(ctx context.Context, opts *graphql2.MessageLogSearch } dm := graphql2.DebugMessage{ - ID: log.ID, - CreatedAt: log.CreatedAt, - UpdatedAt: log.LastStatusAt, - Type: strings.TrimPrefix(log.MessageType.String(), "MessageType"), - Status: msgStatus(notification.Status{State: log.LastStatus, Details: log.StatusDetails}), - AlertID: &log.AlertID, + ID: log.ID, + CreatedAt: log.CreatedAt, + UpdatedAt: log.LastStatusAt, + Type: strings.TrimPrefix(log.MessageType.String(), "MessageType"), + Status: msgStatus(notification.Status{State: log.LastStatus, Details: log.StatusDetails}), + AlertID: &log.AlertID, + RetryCount: log.RetryCount, + SentAt: log.SentAt, } if dest.ID != "" { dm.Destination, err = q.formatDest(ctx, dest) diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index b9a1fd7378..b5e0e02045 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -218,19 +218,21 @@ type DebugCarrierInfoInput struct { } type DebugMessage struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Type string `json:"type"` - Status string `json:"status"` - UserID *string `json:"userID"` - UserName *string `json:"userName"` - Source *string `json:"source"` - Destination string `json:"destination"` - ServiceID *string `json:"serviceID"` - ServiceName *string `json:"serviceName"` - AlertID *int `json:"alertID"` - ProviderID *string `json:"providerID"` + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Type string `json:"type"` + Status string `json:"status"` + UserID *string `json:"userID"` + UserName *string `json:"userName"` + Source *string `json:"source"` + Destination string `json:"destination"` + ServiceID *string `json:"serviceID"` + ServiceName *string `json:"serviceName"` + AlertID *int `json:"alertID"` + ProviderID *string `json:"providerID"` + SentAt *time.Time `json:"sentAt"` + RetryCount int `json:"retryCount"` } type DebugMessageStatusInfo struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 40baa71ec3..47ef57520e 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -200,6 +200,8 @@ type DebugMessage { serviceName: String alertID: Int providerID: ID + sentAt: ISOTimestamp + retryCount: Int! } input MessageLogSearchOptions { @@ -291,7 +293,6 @@ input UserOverrideSearchOptions { end: ISOTimestamp # end of the window to search for. } - type UserOverrideConnection { nodes: [UserOverride!]! pageInfo: PageInfo! diff --git a/notification/search.go b/notification/search.go index 2363bf5740..bd970bf53c 100644 --- a/notification/search.go +++ b/notification/search.go @@ -36,6 +36,9 @@ type MessageLog struct { ServiceID string ServiceName string + + SentAt *time.Time + RetryCount int } // SearchOptions allow filtering and paginating the list of messages. @@ -62,7 +65,8 @@ var searchTemplate = template.Must(template.New("search").Funcs(search.Helpers() SELECT om.id, om.created_at, om.last_status_at, om.message_type, om.last_status, om.status_details, om.src_value, om.alert_id, om.provider_msg_id, - om.user_id, u.name, om.contact_method_id, om.channel_id, om.service_id, s.name + om.user_id, u.name, om.contact_method_id, om.channel_id, om.service_id, s.name, + om.sent_at, om.retry_count FROM outgoing_messages om LEFT JOIN users u ON om.user_id = u.id LEFT JOIN services s ON om.service_id = s.id @@ -98,7 +102,7 @@ var searchTemplate = template.Must(template.New("search").Funcs(search.Helpers() OR (om.created_at = :cursorCreatedAt AND om.id > :afterID) {{end}} AND om.last_status != 'bundled' - ORDER BY om.created_at desc, om.id asc + ORDER BY om.last_status = 'pending' desc, coalesce(om.sent_at, om.last_status_at) desc, om.created_at desc, om.id asc LIMIT {{.Limit}} `)) @@ -166,13 +170,14 @@ func (s *Store) Search(ctx context.Context, opts *SearchOptions) ([]MessageLog, for rows.Next() { var l MessageLog var alertID sql.NullInt64 + var retryCount sql.NullInt32 var chanID sqlutil.NullUUID var serviceID, svcName sql.NullString var srcValue sql.NullString var userID, userName sql.NullString var cmID sql.NullString var providerID sql.NullString - var lastStatusAt sql.NullTime + var lastStatusAt, sentAt sql.NullTime err = rows.Scan( &l.ID, &l.CreatedAt, @@ -189,6 +194,8 @@ func (s *Store) Search(ctx context.Context, opts *SearchOptions) ([]MessageLog, &chanID, &serviceID, &svcName, + &sentAt, + &retryCount, ) if err != nil { return nil, err @@ -211,6 +218,10 @@ func (s *Store) Search(ctx context.Context, opts *SearchOptions) ([]MessageLog, l.UserName = userName.String l.ContactMethodID = cmID.String l.LastStatusAt = lastStatusAt.Time + if sentAt.Valid { + l.SentAt = &sentAt.Time + } + l.RetryCount = int(retryCount.Int32) result = append(result, l) } diff --git a/test/smoke/graphqladminmessagelogs_test.go b/test/smoke/graphqladminmessagelogs_test.go new file mode 100644 index 0000000000..f720fdc790 --- /dev/null +++ b/test/smoke/graphqladminmessagelogs_test.go @@ -0,0 +1,79 @@ +package smoke + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/target/goalert/test/smoke/harness" +) + +// TestGraphQLAdminMessageLogs tests that logs are properly generated for messages. +func TestGraphQLAdminMessageLogs(t *testing.T) { + t.Parallel() + + const sql = ` + insert into users (id, name, email) + values + ({{uuid "user"}}, 'bob', 'joe'); + insert into user_contact_methods (id, user_id, name, type, value, disabled) + values + ({{uuid "cm1"}}, {{uuid "user"}}, 'personal', 'SMS', {{phone "1"}}, true); + insert into outgoing_messages (id, message_type, created_at, sent_at, contact_method_id, last_status, user_id) + values + ({{uuid "om1"}}, 'test_notification', '2022-01-01 00:01:00', '2022-01-01 00:01:01', {{uuid "cm1"}}, 'delivered', {{uuid "user"}}), + ({{uuid "om2"}}, 'test_notification', '2022-01-01 00:02:00', '2022-01-01 00:01:02', {{uuid "cm1"}}, 'delivered', {{uuid "user"}}), + ({{uuid "om3"}}, 'test_notification', '2022-01-01 00:03:00', null, {{uuid "cm1"}}, 'failed', {{uuid "user"}}), + ({{uuid "om4"}}, 'test_notification', '2022-01-01 00:05:00', null, {{uuid "cm1"}}, 'pending', {{uuid "user"}}), + ({{uuid "om5"}}, 'test_notification', '2022-01-01 00:04:00', '2022-01-01 00:01:04', {{uuid "cm1"}}, 'delivered', {{uuid "user"}}); + ` + + h := harness.NewHarness(t, sql, "switchover-mk2") + defer h.Close() + + doQL := func(query string, res interface{}) { + g := h.GraphQLQuery2(query) + for _, err := range g.Errors { + t.Error("GraphQL Error:", err.Message) + } + if len(g.Errors) > 0 { + t.Fatal("errors returned from GraphQL") + } + t.Log("Response:", string(g.Data)) + if res == nil { + return + } + err := json.Unmarshal(g.Data, &res) + if err != nil { + t.Fatal("failed to parse response:", err) + } + } + + type messageLogs struct { + MessageLogs struct { + Nodes []struct { + ID string `json:"id"` + } `json:"nodes"` + } `json:"messageLogs"` + } + + var logs messageLogs + + doQL(`query { + messageLogs(input: {}) { + nodes { + id + } + } + }`, &logs) + + // tests that the message logs are returned in the correct order + // of not sent then most recent to least recent + assert.Len(t, logs.MessageLogs.Nodes, 5, "messageLogs query") + assert.Equal(t, h.UUID("om4"), logs.MessageLogs.Nodes[0].ID) + assert.Equal(t, h.UUID("om3"), logs.MessageLogs.Nodes[1].ID) + assert.Equal(t, h.UUID("om5"), logs.MessageLogs.Nodes[2].ID) + assert.Equal(t, h.UUID("om2"), logs.MessageLogs.Nodes[3].ID) + assert.Equal(t, h.UUID("om1"), logs.MessageLogs.Nodes[4].ID) + +} diff --git a/web/src/app/admin/admin-message-logs/AdminDebugMessagesLayout.tsx b/web/src/app/admin/admin-message-logs/AdminDebugMessagesLayout.tsx index 72d211e894..20ffe37d17 100644 --- a/web/src/app/admin/admin-message-logs/AdminDebugMessagesLayout.tsx +++ b/web/src/app/admin/admin-message-logs/AdminDebugMessagesLayout.tsx @@ -28,6 +28,8 @@ const query = gql` serviceName alertID providerID + retryCount + sentAt } pageInfo { hasNextPage diff --git a/web/src/app/admin/admin-message-logs/DebugMessageDetails.tsx b/web/src/app/admin/admin-message-logs/DebugMessageDetails.tsx index 6bb77ea18c..62f377a2a8 100644 --- a/web/src/app/admin/admin-message-logs/DebugMessageDetails.tsx +++ b/web/src/app/admin/admin-message-logs/DebugMessageDetails.tsx @@ -38,6 +38,12 @@ export default function DebugMessageDetails(props: Props): JSX.Element { const isOpen = Boolean(log) + const sentAtText = (): string => { + return log?.sentAt + ? DateTime.fromISO(log.sentAt).toFormat('fff') + : 'Not Sent' + } + return ( - {log?.id && ( + {!!log?.id && ( )} - {log?.createdAt && ( + {!!log?.createdAt && ( )} - {log?.updatedAt && ( + {!!log?.updatedAt && ( )} - {log?.type && ( + + + + {!!log?.retryCount && ( + + + + )} + + {!!log?.type && ( )} - {log?.status && ( + {!!log?.status && ( )} - {log?.userID && log?.userName && ( + {!!log?.userID && !!log?.userName && ( )} - {log?.serviceID && log?.serviceName && ( + {!!log?.serviceID && !!log?.serviceName && ( )} - {log?.alertID && ( + {!!log?.alertID && ( )} - {log?.source && ( + {!!log?.source && ( )} - {log?.destination && ( + {!!log?.destination && ( )} - {log?.providerID && ( + {!!log?.providerID && (