diff --git a/flows/actions/base_test.go b/flows/actions/base_test.go index 92a55867a..bae19b5dc 100644 --- a/flows/actions/base_test.go +++ b/flows/actions/base_test.go @@ -697,7 +697,6 @@ func TestConstructors(t *testing.T) { actions.NewStartSession( actionUUID, assets.NewFlowReference(assets.FlowUUID("fece6eac-9127-4343-9269-56e88f391562"), "Parent"), - []*assets.GroupReference{ assets.NewGroupReference(assets.GroupUUID("b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"), "Testers"), }, @@ -734,6 +733,28 @@ func TestConstructors(t *testing.T) { "create_contact": true }`, }, + { + actions.NewTriggerSession( + actionUUID, + assets.NewFlowReference(assets.FlowUUID("fece6eac-9127-4343-9269-56e88f391562"), "Parent"), + flows.NewContactReference(flows.ContactUUID("cbe87f5c-cda2-4f90-b5dd-0ac93a884950"), "Bob Smith"), + "", + true, + ), + `{ + "type": "trigger_session", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "flow": { + "uuid": "fece6eac-9127-4343-9269-56e88f391562", + "name": "Parent" + }, + "contact": { + "uuid": "cbe87f5c-cda2-4f90-b5dd-0ac93a884950", + "name": "Bob Smith" + }, + "interrupt": true + }`, + }, } for _, tc := range tests { diff --git a/flows/actions/start_session.go b/flows/actions/start_session.go index 6a5c4d456..f948f5542 100644 --- a/flows/actions/start_session.go +++ b/flows/actions/start_session.go @@ -8,9 +8,6 @@ import ( "github.com/nyaruka/goflow/flows/events" ) -// max number of times a session can trigger another session without there being input from the contact -const maxAncestorsSinceInput = 5 - func init() { registerType(TypeStartSession, func() flows.Action { return &StartSessionAction{} }) } @@ -97,6 +94,6 @@ func (a *StartSessionAction) Execute(run flows.Run, step flows.Step, logModifier history := flows.NewChildHistory(run.Session()) - logEvent(events.NewSessionTriggered(flow.Reference(false), groupRefs, contactRefs, contactQuery, a.Exclusions, a.CreateContact, urnList, runSnapshot, history)) + logEvent(events.NewLegacySessionTriggered(flow.Reference(false), groupRefs, contactRefs, contactQuery, a.Exclusions, a.CreateContact, urnList, runSnapshot, history)) return nil } diff --git a/flows/actions/testdata/trigger_session.json b/flows/actions/testdata/trigger_session.json new file mode 100644 index 000000000..620e2661c --- /dev/null +++ b/flows/actions/testdata/trigger_session.json @@ -0,0 +1,203 @@ +[ + { + "description": "Error event and NOOP if flow missing", + "action": { + "type": "trigger_session", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "flow": { + "uuid": "dede1e50-db55-4b50-8929-2116bfc56148", + "name": "Missing" + }, + "contact": { + "uuid": "945493e3-933f-4668-9761-ce990fae5e5c", + "name": "Stavros" + }, + "interrupt": true + }, + "events": [ + { + "type": "error", + "created_on": "2018-10-18T14:20:30.000123456Z", + "step_uuid": "59d74b86-3e2f-4a93-aece-b05d2fdcde0c", + "text": "missing dependency: flow[uuid=dede1e50-db55-4b50-8929-2116bfc56148,name=Missing]" + } + ], + "inspection": { + "dependencies": [ + { + "uuid": "dede1e50-db55-4b50-8929-2116bfc56148", + "name": "Missing", + "type": "flow", + "missing": true + }, + { + "uuid": "945493e3-933f-4668-9761-ce990fae5e5c", + "name": "Stavros", + "type": "contact" + } + ], + "issues": [ + { + "type": "missing_dependency", + "node_uuid": "72a1f5df-49f9-45df-94c9-d86f7ea064e5", + "action_uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "description": "missing flow dependency 'dede1e50-db55-4b50-8929-2116bfc56148'", + "dependency": { + "uuid": "dede1e50-db55-4b50-8929-2116bfc56148", + "name": "Missing", + "type": "flow" + } + } + ], + "results": [], + "waiting_exits": [], + "parent_refs": [] + } + }, + { + "description": "Session triggered event with concrete contact reference", + "action": { + "type": "trigger_session", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "flow": { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Collect Age" + }, + "contact": { + "uuid": "945493e3-933f-4668-9761-ce990fae5e5c", + "name": "Stavros" + }, + "interrupt": true + }, + "events": [ + { + "type": "session_triggered", + "created_on": "2018-10-18T14:20:30.000123456Z", + "step_uuid": "59d74b86-3e2f-4a93-aece-b05d2fdcde0c", + "flow": { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Collect Age" + }, + "contact": { + "uuid": "945493e3-933f-4668-9761-ce990fae5e5c", + "name": "Stavros" + }, + "interrupt": true, + "exclusions": {}, + "run_summary": { + "uuid": "e7187099-7d38-4f60-955c-325957214c42", + "flow": { + "uuid": "bead76f5-dac4-4c9d-996c-c62b326e8c0a", + "name": "Action Tester", + "revision": 123 + }, + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "last_seen_on": "2018-10-18T14:20:30.000123456Z", + "status": "active", + "timezone": "America/Guayaquil", + "created_on": "2018-06-20T11:40:30.123456789Z", + "urns": [ + "tel:+12065551212?channel=57f1078f-88aa-46f4-a59a-948a5739c03d&id=123", + "twitterid:54784326227#nyaruka" + ], + "groups": [ + { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Testers" + }, + { + "uuid": "0ec97956-c451-48a0-a180-1ce766623e31", + "name": "Males" + } + ], + "fields": { + "gender": { + "text": "Male" + } + } + }, + "status": "active", + "results": {} + }, + "history": { + "parent_uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", + "ancestors": 1, + "ancestors_since_input": 0 + } + } + ] + }, + { + "description": "Session triggered event with URN", + "action": { + "type": "trigger_session", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "flow": { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Collect Age" + }, + "urn": "@(\"tel:+593979123456\")", + "interrupt": true + }, + "events": [ + { + "type": "session_triggered", + "created_on": "2018-10-18T14:20:30.000123456Z", + "step_uuid": "59d74b86-3e2f-4a93-aece-b05d2fdcde0c", + "flow": { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Collect Age" + }, + "urn": "tel:+593979123456", + "interrupt": true, + "exclusions": {}, + "run_summary": { + "uuid": "e7187099-7d38-4f60-955c-325957214c42", + "flow": { + "uuid": "bead76f5-dac4-4c9d-996c-c62b326e8c0a", + "name": "Action Tester", + "revision": 123 + }, + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "last_seen_on": "2018-10-18T14:20:30.000123456Z", + "status": "active", + "timezone": "America/Guayaquil", + "created_on": "2018-06-20T11:40:30.123456789Z", + "urns": [ + "tel:+12065551212?channel=57f1078f-88aa-46f4-a59a-948a5739c03d&id=123", + "twitterid:54784326227#nyaruka" + ], + "groups": [ + { + "uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", + "name": "Testers" + }, + { + "uuid": "0ec97956-c451-48a0-a180-1ce766623e31", + "name": "Males" + } + ], + "fields": { + "gender": { + "text": "Male" + } + } + }, + "status": "active", + "results": {} + }, + "history": { + "parent_uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", + "ancestors": 1, + "ancestors_since_input": 0 + } + } + ] + } +] \ No newline at end of file diff --git a/flows/actions/trigger_session.go b/flows/actions/trigger_session.go new file mode 100644 index 000000000..4f8e969bb --- /dev/null +++ b/flows/actions/trigger_session.go @@ -0,0 +1,125 @@ +package actions + +import ( + "fmt" + + "github.com/nyaruka/gocommon/jsonx" + "github.com/nyaruka/gocommon/urns" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/flows/events" + "github.com/nyaruka/goflow/utils" +) + +// max number of times a session can trigger another session without there being input from the contact +const maxAncestorsSinceInput = 5 + +func init() { + registerType(TypeTriggerSession, func() flows.Action { return &TriggerSessionAction{} }) +} + +// TypeTriggerSession is the type for the trigger session action +const TypeTriggerSession string = "trigger_session" + +// TriggerSessionAction can be used to trigger sessions for another contact. A [event:session_triggered] event will be +// created and it's the responsibility of the caller to act on that by initiating a new session with the flow engine. +// The contact can be specified via a reference or as a URN. In the latter case the contact will be created if they +// don't exist. +// +// { +// "uuid": "8eebd020-1af5-431c-b943-aa670fc74da9", +// "type": "trigger_session", +// "flow": {"uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", "name": "Registration"}, +// "contact": {"uuid": "1e1ce1e1-9288-4504-869e-022d1003c72a", "name": "Bob"}, +// "interrupt": true +// } +// +// @action start_session +type TriggerSessionAction struct { + baseAction + onlineAction + + Flow *assets.FlowReference `json:"flow" validate:"required"` + Contact *flows.ContactReference `json:"contact,omitempty"` + URN string `json:"urn,omitempty" engine:"evaluated"` + Interrupt bool `json:"interrupt"` +} + +// NewTriggerSession creates a new trigger session action +func NewTriggerSession(uuid flows.ActionUUID, flow *assets.FlowReference, contact *flows.ContactReference, urn string, interrupt bool) *TriggerSessionAction { + return &TriggerSessionAction{ + baseAction: newBaseAction(TypeTriggerSession, uuid), + Flow: flow, + Contact: contact, + URN: urn, + Interrupt: interrupt, + } +} + +// Validate validates our action is valid +func (a *TriggerSessionAction) Validate() error { + if (a.Contact != nil && a.URN != "") || (a.Contact == nil && a.URN == "") { + return fmt.Errorf("must specify either contact or urn") + } + return nil +} + +// Execute runs our action +func (a *TriggerSessionAction) Execute(run flows.Run, step flows.Step, logModifier flows.ModifierCallback, logEvent flows.EventCallback) error { + urn := a.resolveURN(run, logEvent) + + if urn == urns.NilURN && a.Contact == nil { + return nil + } + + // check that flow exists - error event if not + flow, err := run.Session().Assets().Flows().Get(a.Flow.UUID) + if err != nil { + logEvent(events.NewDependencyError(a.Flow)) + return nil + } + + // loop footgun prevention + ref := run.Session().History() + if ref.AncestorsSinceInput >= maxAncestorsSinceInput { + logEvent(events.NewErrorf("too many sessions have been spawned since the last time input was received")) + return nil + } + + runSnapshot, err := jsonx.Marshal(run.Snapshot()) + if err != nil { + return err + } + + history := flows.NewChildHistory(run.Session()) + + logEvent(events.NewSessionTriggered(flow.Reference(false), a.Contact, urn, a.Interrupt, runSnapshot, history)) + return nil +} + +func (a *TriggerSessionAction) resolveURN(run flows.Run, logEvent flows.EventCallback) urns.URN { + if a.URN == "" { + return urns.NilURN + } + + // otherwise this is a variable reference so evaluate it + evaluatedURN, ok := run.EvaluateTemplate(a.URN, logEvent) + if !ok { + return urns.NilURN + } + + // if we have a valid URN now, return it + urn := urns.URN(evaluatedURN) + if urn.Validate() == nil { + return urn.Normalize() + } + + // otherwise try to parse as phone number + parsedTel := utils.ParsePhoneNumber(evaluatedURN, run.Session().MergedEnvironment().DefaultCountry()) + if parsedTel != "" { + urn, _ := urns.New(urns.Phone, parsedTel) + return urn.Normalize() + } + + return urns.NilURN +} diff --git a/flows/definition/migrations/specdata/templates.json b/flows/definition/migrations/specdata/templates.json index 0a1ba083b..0d2a9f7e1 100644 --- a/flows/definition/migrations/specdata/templates.json +++ b/flows/definition/migrations/specdata/templates.json @@ -75,7 +75,10 @@ ".groups[*].name_match", ".legacy_vars[*]" ], - "transfer_airtime": [] + "transfer_airtime": [], + "trigger_session": [ + ".urn" + ] }, "routers": { "random": [ diff --git a/flows/events/base_test.go b/flows/events/base_test.go index 5b62920d7..6a68b98e3 100644 --- a/flows/events/base_test.go +++ b/flows/events/base_test.go @@ -561,7 +561,7 @@ func TestEventMarshaling(t *testing.T) { }`, }, { - events.NewSessionTriggered( + events.NewLegacySessionTriggered( assets.NewFlowReference(assets.FlowUUID("e4d441f0-24e3-4627-85fb-1e99e733baf0"), "Collect Age"), []*assets.GroupReference{ assets.NewGroupReference(assets.GroupUUID("5f9fd4f7-4b0f-462a-a598-18bfc7810412"), "Supervisors"), diff --git a/flows/events/session_triggered.go b/flows/events/session_triggered.go index 228806ae7..f91104ef7 100644 --- a/flows/events/session_triggered.go +++ b/flows/events/session_triggered.go @@ -56,19 +56,37 @@ type Exclusions struct { type SessionTriggeredEvent struct { BaseEvent - Flow *assets.FlowReference `json:"flow" validate:"required"` + Flow *assets.FlowReference `json:"flow" validate:"required"` + Contact *flows.ContactReference `json:"contact,omitempty"` + URN urns.URN `json:"urn,omitempty" validate:"omitempty,urn"` + Interrupt bool `json:"interrupt,omitempty"` + RunSummary json.RawMessage `json:"run_summary"` + History *flows.SessionHistory `json:"history"` + + // deprecated (used by StartSessionAction) Groups []*assets.GroupReference `json:"groups,omitempty" validate:"dive"` Contacts []*flows.ContactReference `json:"contacts,omitempty" validate:"dive"` ContactQuery string `json:"contact_query,omitempty"` Exclusions Exclusions `json:"exclusions"` CreateContact bool `json:"create_contact,omitempty"` URNs []urns.URN `json:"urns,omitempty" validate:"dive,urn"` - RunSummary json.RawMessage `json:"run_summary"` - History *flows.SessionHistory `json:"history"` } // NewSessionTriggered returns a new session triggered event -func NewSessionTriggered(flow *assets.FlowReference, groups []*assets.GroupReference, contacts []*flows.ContactReference, contactQuery string, exclusions Exclusions, createContact bool, urns []urns.URN, runSummary json.RawMessage, history *flows.SessionHistory) *SessionTriggeredEvent { +func NewSessionTriggered(flow *assets.FlowReference, contact *flows.ContactReference, urn urns.URN, interrupt bool, runSummary json.RawMessage, history *flows.SessionHistory) *SessionTriggeredEvent { + return &SessionTriggeredEvent{ + BaseEvent: NewBaseEvent(TypeSessionTriggered), + Flow: flow, + Contact: contact, + URN: urn, + Interrupt: interrupt, + RunSummary: runSummary, + History: history, + } +} + +// NewLegacySessionTriggered returns a new session triggered event +func NewLegacySessionTriggered(flow *assets.FlowReference, groups []*assets.GroupReference, contacts []*flows.ContactReference, contactQuery string, exclusions Exclusions, createContact bool, urns []urns.URN, runSummary json.RawMessage, history *flows.SessionHistory) *SessionTriggeredEvent { return &SessionTriggeredEvent{ BaseEvent: NewBaseEvent(TypeSessionTriggered), Flow: flow,