From ce5f4e76142c5c52f574612f12842c870d593b05 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 2 Jun 2020 11:28:51 -0500 Subject: [PATCH 01/55] Add endpoint to change a flow language --- go.mod | 2 +- go.sum | 4 +- web/flow/flow.go | 33 ++ web/flow/flow_test.go | 1 + web/flow/testdata/change_language.json | 686 +++++++++++++++++++++++++ 5 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 web/flow/testdata/change_language.json diff --git a/go.mod b/go.mod index acabb8454..533faf683 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.86.2 + github.com/nyaruka/goflow v0.86.3-0.20200601235620-753fc1b3d541 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 516c42d06..a93ffe0b9 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.86.2 h1:VU2ZJLIZv9IXWz75BPz55SZfak+fUh69FR6tsRa6Rbg= -github.com/nyaruka/goflow v0.86.2/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.86.3-0.20200601235620-753fc1b3d541 h1:SNLJPZBmAC2uPzuz8jYmAS6CsVp00wbl4YLssOoQeZo= +github.com/nyaruka/goflow v0.86.3-0.20200601235620-753fc1b3d541/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/web/flow/flow.go b/web/flow/flow.go index 22ee154ac..36025f7ae 100644 --- a/web/flow/flow.go +++ b/web/flow/flow.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" + "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/goflow/utils/uuids" @@ -20,6 +21,7 @@ func init() { web.RegisterJSONRoute(http.MethodPost, "/mr/flow/migrate", web.RequireAuthToken(handleMigrate)) web.RegisterJSONRoute(http.MethodPost, "/mr/flow/inspect", web.RequireAuthToken(handleInspect)) web.RegisterJSONRoute(http.MethodPost, "/mr/flow/clone", web.RequireAuthToken(handleClone)) + web.RegisterJSONRoute(http.MethodPost, "/mr/flow/change_language", web.RequireAuthToken(handleChangeLanguage)) } // Migrates a flow to the latest flow specification @@ -128,3 +130,34 @@ func handleClone(ctx context.Context, s *web.Server, r *http.Request) (interface return cloneJSON, http.StatusOK, nil } + +// Changes the language of a flow by replacing the text with a translation. +// +// { +// "language": "spa", +// "flow": { "uuid": "468621a8-32e6-4cd2-afc1-04416f7151f0", "nodes": [...]} +// } +// +type changeLanguageRequest struct { + Language envs.Language `json:"language" validate:"required"` + Flow json.RawMessage `json:"flow" validate:"required"` +} + +func handleChangeLanguage(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { + request := &changeLanguageRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + flow, err := goflow.ReadFlow(request.Flow) + if err != nil { + return errors.Wrapf(err, "unable to read flow"), http.StatusUnprocessableEntity, nil + } + + copy, err := flow.ChangeLanguage(request.Language) + if err != nil { + return errors.Wrapf(err, "unable to change flow language"), http.StatusUnprocessableEntity, nil + } + + return copy, http.StatusOK, nil +} diff --git a/web/flow/flow_test.go b/web/flow/flow_test.go index e6d068b23..84c20faec 100644 --- a/web/flow/flow_test.go +++ b/web/flow/flow_test.go @@ -7,6 +7,7 @@ import ( ) func TestServer(t *testing.T) { + web.RunWebTests(t, "testdata/change_language.json") web.RunWebTests(t, "testdata/clone.json") web.RunWebTests(t, "testdata/inspect.json") web.RunWebTests(t, "testdata/migrate.json") diff --git a/web/flow/testdata/change_language.json b/web/flow/testdata/change_language.json new file mode 100644 index 000000000..c33e95684 --- /dev/null +++ b/web/flow/testdata/change_language.json @@ -0,0 +1,686 @@ +[ + { + "label": "illegal method", + "method": "GET", + "path": "/mr/flow/change_language", + "status": 405, + "response": { + "error": "illegal method: GET" + } + }, + { + "label": "error response if language doesn't exist as translation in flow", + "method": "POST", + "path": "/mr/flow/change_language", + "body": { + "language": "kin", + "flow": { + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "eng", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "spa": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "Cual pastilla?" + ], + "quick_replies": [ + "Roja", + "Azul" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + } + }, + "ara": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "\u0627\u064a \u062d\u0628\u0648\u0628" + ], + "quick_replies": [ + "\u0623\u062d\u0645\u0631", + "\u0623\u0632\u0631\u0642" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "\u0623\u062d\u0645\u0631" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "\u0623\u0632\u0631\u0642" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "attachments": [], + "text": "Which pill?", + "type": "send_msg", + "quick_replies": [ + "Red", + "Blue" + ], + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75" + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "actions": [], + "router": { + "type": "switch", + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "cases": [ + { + "arguments": [ + "red" + ], + "type": "has_any_word", + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "arguments": [ + "blue" + ], + "type": "has_any_word", + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "Red", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "Blue", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Other", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "wait": { + "type": "msg" + }, + "result_name": "Pill" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c", + "destination_uuid": null + } + ] + } + ] + } + }, + "status": 422, + "response": { + "error": "unable to change flow language: no translation exists for kin" + } + }, + { + "label": "error response if language translation is incomplete", + "method": "POST", + "path": "/mr/flow/change_language", + "body": { + "language": "ara", + "flow": { + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "eng", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "spa": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "Cual pastilla?" + ], + "quick_replies": [ + "Roja", + "Azul" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + } + }, + "ara": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "\u0627\u064a \u062d\u0628\u0648\u0628" + ], + "quick_replies": [ + "\u0623\u062d\u0645\u0631", + "\u0623\u0632\u0631\u0642" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "\u0623\u062d\u0645\u0631" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "\u0623\u0632\u0631\u0642" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "attachments": [], + "text": "Which pill?", + "type": "send_msg", + "quick_replies": [ + "Red", + "Blue" + ], + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75" + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "actions": [], + "router": { + "type": "switch", + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "cases": [ + { + "arguments": [ + "red" + ], + "type": "has_any_word", + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "arguments": [ + "blue" + ], + "type": "has_any_word", + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "Red", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "Blue", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Other", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "wait": { + "type": "msg" + }, + "result_name": "Pill" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c", + "destination_uuid": null + } + ] + } + ] + } + }, + "status": 422, + "response": { + "error": "unable to change flow language: missing ara translation for text at 61bc5ed3-e216-4457-8ce5-ad658e697f29/arguments, 5f5fa09f-bf88-4719-ba64-cab9cf2f67b5/arguments, 3a044264-81d1-4ba7-882a-e98740c8e724/name" + } + }, + { + "label": "flow response with language changed if translation complete", + "method": "POST", + "path": "/mr/flow/change_language", + "body": { + "language": "spa", + "flow": { + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "eng", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "spa": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "Cual pastilla?" + ], + "quick_replies": [ + "Roja", + "Azul" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + } + }, + "ara": { + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "text": [ + "\u0627\u064a \u062d\u0628\u0648\u0628" + ], + "quick_replies": [ + "\u0623\u062d\u0645\u0631", + "\u0623\u0632\u0631\u0642" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "\u0623\u062d\u0645\u0631" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "\u0623\u0632\u0631\u0642" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "attachments": [], + "text": "Which pill?", + "type": "send_msg", + "quick_replies": [ + "Red", + "Blue" + ], + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75" + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "actions": [], + "router": { + "type": "switch", + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "cases": [ + { + "arguments": [ + "red" + ], + "type": "has_any_word", + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "arguments": [ + "blue" + ], + "type": "has_any_word", + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "Red", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "Blue", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Other", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "wait": { + "type": "msg" + }, + "result_name": "Pill" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c", + "destination_uuid": null + } + ] + } + ] + } + }, + "status": 200, + "response": { + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "spa", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "ara": { + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "أزرق" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "أحمر" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "أحمر", + "أزرق" + ], + "text": [ + "اي حبوب" + ] + } + }, + "eng": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Other" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Blue" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "blue" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "red" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Red" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Red", + "Blue" + ], + "text": [ + "Which pill?" + ] + } + }, + "spa": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Roja", + "Azul" + ], + "text": [ + "Cual pastilla?" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "type": "send_msg", + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75", + "text": "Cual pastilla?", + "quick_replies": [ + "Roja", + "Azul" + ] + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "router": { + "type": "switch", + "wait": { + "type": "msg" + }, + "result_name": "Pill", + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "Roja", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "Azul", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Otro", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "cases": [ + { + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "type": "has_any_word", + "arguments": [ + "rojo", + "roja" + ], + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "type": "has_any_word", + "arguments": [ + "azul" + ], + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ] + } + ] + } + } +] \ No newline at end of file From a782870daf27e5140f7565b8f8cdbed8dd813081 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 5 Jun 2020 09:24:02 -0500 Subject: [PATCH 02/55] Update to goflow v0.88.0 --- .github/workflows/ci.yml | 8 +++++--- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578d0c63e..c030223d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + # fail_ci_if_error: true # ideally this should be enabled but codecov uploads fail a lot release: name: Release @@ -57,9 +57,11 @@ jobs: uses: actions/checkout@v1 - name: Fetch GoFlow docs + # for now just grab en_US/ docs and bundle as docs/ run: | - export GOFLOW_VERSION=$(grep goflow go.mod | cut -d" " -f2) - curl https://codeload.github.com/nyaruka/goflow/tar.gz/$GOFLOW_VERSION | tar --wildcards --strip=1 -zx "*/docs/*" + GOFLOW_VERSION=$(grep goflow go.mod | cut -d" " -f2) + curl https://codeload.github.com/nyaruka/goflow/tar.gz/$GOFLOW_VERSION | tar --wildcards --strip=2 -zx "*/docs/en_US/*" + mv en_US docs - name: Install Go uses: actions/setup-go@v1 diff --git a/go.mod b/go.mod index 633f1e29e..f42f51fa7 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.87.0 + github.com/nyaruka/goflow v0.88.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 21ca6be19..10898b564 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.87.0 h1:JwClty7iwg4iitzitDQulqjkbRMANa0Z//yd2wfqfBQ= -github.com/nyaruka/goflow v0.87.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.88.0 h1:zRgApSSCjX2XitmZKoj2XHsUqazAvpZeMPDVxysy3gc= +github.com/nyaruka/goflow v0.88.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= From d36e07fbe463135d3da0f99808fded1f63974c51 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 5 Jun 2020 09:59:51 -0500 Subject: [PATCH 03/55] Update CHANGELOG.md for v5.5.25 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5546fa945..49b06de0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.25 +---------- + * Add endpoint to change a flow language + v5.5.24 ---------- * Tickets fixes and improvements From a9bba3cc5aec7d08ce00fd99b3774ef8831b0890 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 8 Jun 2020 12:16:33 -0500 Subject: [PATCH 04/55] Update to goflow v0.89.0 --- go.mod | 2 +- go.sum | 4 +- ivr/twiml/twiml.go | 7 +- models/orgs.go | 3 + models/templates.go | 5 +- web/flow/testdata/change_language.json | 394 ++++++++++++++++++++++--- web/testing.go | 20 +- 7 files changed, 379 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index f42f51fa7..550b9fd60 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.88.0 + github.com/nyaruka/goflow v0.89.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 10898b564..5d06dc898 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.88.0 h1:zRgApSSCjX2XitmZKoj2XHsUqazAvpZeMPDVxysy3gc= -github.com/nyaruka/goflow v0.88.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.89.0 h1:ZMDPG46WkUOR0+snOB8moIcHC2Po/sKDIYBD+SnkcxY= +github.com/nyaruka/goflow v0.89.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/ivr/twiml/twiml.go b/ivr/twiml/twiml.go index 0f4a83443..3cea2d86f 100644 --- a/ivr/twiml/twiml.go +++ b/ivr/twiml/twiml.go @@ -9,7 +9,6 @@ import ( "encoding/json" "encoding/xml" "fmt" - "github.com/nyaruka/goflow/envs" "io" "io/ioutil" "net/http" @@ -18,6 +17,8 @@ import ( "strconv" "strings" + "github.com/nyaruka/goflow/envs" + "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" "github.com/nyaruka/gocommon/urns" @@ -66,6 +67,7 @@ const ( ` ) + var validLanguageCodes = map[string]bool{ "da-DK": true, "de-DE": true, @@ -466,7 +468,8 @@ func responseForSprint(number urns.URN, resumeURL string, w flows.ActivatedWait, case *events.IVRCreatedEvent: if len(event.Msg.Attachments()) == 0 { country := envs.DeriveCountryFromTel(number.Path()) - languageCode := event.Msg.TextLanguage.ToISO639_2(country) + locale := envs.NewLocale(event.Msg.TextLanguage, country) + languageCode := locale.ToISO639_2() if _, valid := validLanguageCodes[languageCode]; !valid { languageCode = "" diff --git a/models/orgs.go b/models/orgs.go index 511aba3bb..68fdb6ab7 100644 --- a/models/orgs.go +++ b/models/orgs.go @@ -98,6 +98,9 @@ func (o *Org) Now() time.Time { return o.env.Now() } // MaxValueLength returns our max value length for contact fields and run results func (o *Org) MaxValueLength() int { return o.env.MaxValueLength() } +// DefaultLocale combines the default languages and countries into a locale +func (o *Org) DefaultLocale() envs.Locale { return o.env.DefaultLocale() } + // Equal return whether we are equal to the passed in environment func (o *Org) Equal(env envs.Environment) bool { return o.env.Equal(env) } diff --git a/models/templates.go b/models/templates.go index 6a88fd9a2..821aed306 100644 --- a/models/templates.go +++ b/models/templates.go @@ -41,6 +41,7 @@ type TemplateTranslation struct { t struct { Channel assets.ChannelReference `json:"channel" validate:"required"` Language envs.Language `json:"language" validate:"required"` + Country envs.Country `json:"country"` Content string `json:"content" validate:"required"` VariableCount int `json:"variable_count"` } @@ -54,6 +55,7 @@ func (t *TemplateTranslation) MarshalJSON() ([]byte, error) { return json.Marsha func (t *TemplateTranslation) Channel() assets.ChannelReference { return t.t.Channel } func (t *TemplateTranslation) Language() envs.Language { return t.t.Language } +func (t *TemplateTranslation) Country() envs.Country { return t.t.Country } func (t *TemplateTranslation) Content() string { return t.t.Content } func (t *TemplateTranslation) VariableCount() int { return t.t.VariableCount } @@ -86,10 +88,11 @@ func loadTemplates(ctx context.Context, db sqlx.Queryer, orgID OrgID) ([]assets. const selectTemplatesSQL = ` SELECT ROW_TO_JSON(r) FROM (SELECT t.name as name, - t.uuid as uuid, + t.uuid as uuid, (SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(tr))) FROM ( SELECT tr.language as language, + '' as country, tr.content as content, tr.variable_count as variable_count, JSON_BUILD_OBJECT('uuid', c.uuid, 'name', c.name) as channel diff --git a/web/flow/testdata/change_language.json b/web/flow/testdata/change_language.json index c33e95684..3d881fb3d 100644 --- a/web/flow/testdata/change_language.json +++ b/web/flow/testdata/change_language.json @@ -9,7 +9,7 @@ } }, { - "label": "error response if language doesn't exist as translation in flow", + "label": "uses all base text values if translation doesn't exist in flow", "method": "POST", "path": "/mr/flow/change_language", "body": { @@ -167,13 +167,195 @@ ] } }, - "status": 422, + "status": 200, "response": { - "error": "unable to change flow language: no translation exists for kin" + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "kin", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "ara": { + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "أزرق" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "أحمر" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "أحمر", + "أزرق" + ], + "text": [ + "اي حبوب" + ] + } + }, + "eng": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Other" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Blue" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "blue" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "red" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Red" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Red", + "Blue" + ], + "text": [ + "Which pill?" + ] + } + }, + "spa": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Roja", + "Azul" + ], + "text": [ + "Cual pastilla?" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "type": "send_msg", + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75", + "text": "Which pill?", + "quick_replies": [ + "Red", + "Blue" + ] + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "router": { + "type": "switch", + "wait": { + "type": "msg" + }, + "result_name": "Pill", + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "Red", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "Blue", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Other", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "cases": [ + { + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "type": "has_any_word", + "arguments": [ + "red" + ], + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "type": "has_any_word", + "arguments": [ + "blue" + ], + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ] + } + ] } }, { - "label": "error response if language translation is incomplete", + "label": "mixes base text and translation values if translation is incomplete", "method": "POST", "path": "/mr/flow/change_language", "body": { @@ -331,13 +513,174 @@ ] } }, - "status": 422, + "status": 200, "response": { - "error": "unable to change flow language: missing ara translation for text at 61bc5ed3-e216-4457-8ce5-ad658e697f29/arguments, 5f5fa09f-bf88-4719-ba64-cab9cf2f67b5/arguments, 3a044264-81d1-4ba7-882a-e98740c8e724/name" + "uuid": "19cad1f2-9110-4271-98d4-1b968bf19410", + "name": "Change Language", + "spec_version": "13.1.0", + "language": "ara", + "type": "messaging", + "revision": 16, + "expire_after_minutes": 10080, + "localization": { + "eng": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Other" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Blue" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "blue" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "red" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Red" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Red", + "Blue" + ], + "text": [ + "Which pill?" + ] + } + }, + "spa": { + "3a044264-81d1-4ba7-882a-e98740c8e724": { + "name": [ + "Otro" + ] + }, + "43f7e69e-727d-4cfe-81b8-564e7833052b": { + "name": [ + "Azul" + ] + }, + "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { + "arguments": [ + "azul" + ] + }, + "61bc5ed3-e216-4457-8ce5-ad658e697f29": { + "arguments": [ + "rojo", + "roja" + ] + }, + "d1ce3c92-7025-4607-a910-444361a6b9b3": { + "name": [ + "Roja" + ] + }, + "e42deebf-90fa-4636-81cb-d247a3d3ba75": { + "quick_replies": [ + "Roja", + "Azul" + ], + "text": [ + "Cual pastilla?" + ] + } + } + }, + "nodes": [ + { + "uuid": "3236913b-9b55-4f01-8b4d-549848c27fe8", + "actions": [ + { + "type": "send_msg", + "uuid": "e42deebf-90fa-4636-81cb-d247a3d3ba75", + "text": "اي حبوب", + "quick_replies": [ + "أحمر", + "أزرق" + ] + } + ], + "exits": [ + { + "uuid": "500d5c80-0af7-45ce-a95e-e9ece647aa53", + "destination_uuid": "51ad5add-269f-439a-a251-a8e14c6099e2" + } + ] + }, + { + "uuid": "51ad5add-269f-439a-a251-a8e14c6099e2", + "router": { + "type": "switch", + "wait": { + "type": "msg" + }, + "result_name": "Pill", + "categories": [ + { + "uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3", + "name": "أحمر", + "exit_uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b", + "name": "أزرق", + "exit_uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "3a044264-81d1-4ba7-882a-e98740c8e724", + "name": "Other", + "exit_uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ], + "operand": "@input.text", + "cases": [ + { + "uuid": "61bc5ed3-e216-4457-8ce5-ad658e697f29", + "type": "has_any_word", + "arguments": [ + "red" + ], + "category_uuid": "d1ce3c92-7025-4607-a910-444361a6b9b3" + }, + { + "uuid": "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5", + "type": "has_any_word", + "arguments": [ + "blue" + ], + "category_uuid": "43f7e69e-727d-4cfe-81b8-564e7833052b" + } + ], + "default_category_uuid": "3a044264-81d1-4ba7-882a-e98740c8e724" + }, + "exits": [ + { + "uuid": "18ce1dab-9875-48ab-9e16-695bad91ecef" + }, + { + "uuid": "73b255e2-59ae-454f-902a-abd2cd1e4eab" + }, + { + "uuid": "bda03d7b-6ff8-46f2-8308-470ba2c1613c" + } + ] + } + ] } }, { - "label": "flow response with language changed if translation complete", + "label": "uses only translation values if translation complete", "method": "POST", "path": "/mr/flow/change_language", "body": { @@ -561,43 +904,6 @@ "Which pill?" ] } - }, - "spa": { - "3a044264-81d1-4ba7-882a-e98740c8e724": { - "name": [ - "Otro" - ] - }, - "43f7e69e-727d-4cfe-81b8-564e7833052b": { - "name": [ - "Azul" - ] - }, - "5f5fa09f-bf88-4719-ba64-cab9cf2f67b5": { - "arguments": [ - "azul" - ] - }, - "61bc5ed3-e216-4457-8ce5-ad658e697f29": { - "arguments": [ - "rojo", - "roja" - ] - }, - "d1ce3c92-7025-4607-a910-444361a6b9b3": { - "name": [ - "Roja" - ] - }, - "e42deebf-90fa-4636-81cb-d247a3d3ba75": { - "quick_replies": [ - "Roja", - "Azul" - ], - "text": [ - "Cual pastilla?" - ] - } } }, "nodes": [ diff --git a/web/testing.go b/web/testing.go index 5e5b6b647..b0ca44d7a 100644 --- a/web/testing.go +++ b/web/testing.go @@ -66,7 +66,7 @@ func RunWebTests(t *testing.T, truthFile string) { err = json.Unmarshal(tcJSON, &tcs) require.NoError(t, err) - for _, tc := range tcs { + for i, tc := range tcs { uuids.SetGenerator(uuids.NewSeededGenerator(123456)) dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC))) @@ -105,18 +105,23 @@ func RunWebTests(t *testing.T, truthFile string) { resp, err := http.DefaultClient.Do(req) assert.NoError(t, err, "%s: error making request", tc.Label) - assert.Equal(t, tc.Status, resp.StatusCode, "%s: unexpected status", tc.Label) - // check all http mocks were used if tc.HTTPMocks != nil { assert.False(t, tc.HTTPMocks.HasUnused(), "%s: unused HTTP mocks in %s", tc.Label) } + // clone test case and populate with actual values + actual := tc + actual.Status = resp.StatusCode + actual.HTTPMocks = clonedMocks + tc.HTTPMocks = clonedMocks tc.actualResponse, err = ioutil.ReadAll(resp.Body) assert.NoError(t, err, "%s: error reading body", tc.Label) if !test.UpdateSnapshots { + assert.Equal(t, tc.Status, actual.Status, "%s: unexpected status", tc.Label) + var expectedResponse []byte expectedIsJSON := false @@ -135,10 +140,13 @@ func RunWebTests(t *testing.T, truthFile string) { } else { assert.Equal(t, string(expectedResponse), string(tc.actualResponse), "%s: unexpected response", tc.Label) } - } - for _, dba := range tc.DBAssertions { - testsuite.AssertQueryCount(t, db, dba.Query, nil, dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query) + for _, dba := range tc.DBAssertions { + testsuite.AssertQueryCount(t, db, dba.Query, nil, dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query) + } + + } else { + tcs[i] = actual } } From 8ae39fe63520e1a1099ce023566d79a32663f5b0 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 8 Jun 2020 13:11:43 -0500 Subject: [PATCH 05/55] Coverage --- models/env_test.go | 2 ++ web/server_test.go | 61 +--------------------------------------- web/testdata/server.json | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 60 deletions(-) create mode 100644 web/testdata/server.json diff --git a/models/env_test.go b/models/env_test.go index 89b8fb250..a05c9e604 100644 --- a/models/env_test.go +++ b/models/env_test.go @@ -40,11 +40,13 @@ func TestOrgs(t *testing.T) { assert.Equal(t, tz, org.Timezone()) assert.Equal(t, 0, len(org.AllowedLanguages())) assert.Equal(t, envs.Language(""), org.DefaultLanguage()) + assert.Equal(t, "", org.DefaultLocale().ToISO639_2()) org, err = loadOrg(ctx, tx, 2) assert.NoError(t, err) assert.Equal(t, []envs.Language{"eng", "fra"}, org.AllowedLanguages()) assert.Equal(t, envs.Language("eng"), org.DefaultLanguage()) + assert.Equal(t, "en", org.DefaultLocale().ToISO639_2()) _, err = loadOrg(ctx, tx, 99) assert.Error(t, err) diff --git a/web/server_test.go b/web/server_test.go index c75df80e3..b5df99b60 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -1,68 +1,9 @@ package web import ( - "bytes" - "io" - "io/ioutil" - "net/http" - "strings" - "sync" "testing" - "time" - - "github.com/nyaruka/mailroom/config" - "github.com/nyaruka/mailroom/testsuite" - - "github.com/stretchr/testify/assert" ) func TestServer(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() - wg := &sync.WaitGroup{} - - server := NewServer(ctx, config.Mailroom, db, rp, nil, nil, wg) - server.Start() - - // give our server time to start - time.Sleep(time.Second) - - defer server.Stop() - - tcs := []struct { - URL string - Method string - Body string - Status int - Response string - }{ - {"/arst", "GET", "", 404, "not found"}, - {"/", "POST", "", 405, "illegal"}, - {"/", "GET", "", 200, "mailroom"}, - {"/mr/", "POST", "", 405, "illegal"}, - {"/mr/", "GET", "", 200, "mailroom"}, - } - - for i, tc := range tcs { - var body io.Reader - - if tc.Body != "" { - body = bytes.NewReader([]byte(tc.Body)) - } - - req, err := http.NewRequest(tc.Method, "http://localhost:8090"+tc.URL, body) - assert.NoError(t, err, "%d: error creating request", i) - - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err, "%d: error making request", i) - - assert.Equal(t, tc.Status, resp.StatusCode, "%d: unexpected status", i) - - content, err := ioutil.ReadAll(resp.Body) - assert.NoError(t, err, "%d: error reading body", i) - - assert.True(t, strings.Contains(string(content), tc.Response), "%d: did not find string: %s in body: %s", i, tc.Response, string(content)) - } + RunWebTests(t, "testdata/server.json") } diff --git a/web/testdata/server.json b/web/testdata/server.json new file mode 100644 index 000000000..ffc4fcfb5 --- /dev/null +++ b/web/testdata/server.json @@ -0,0 +1,51 @@ +[ + { + "label": "404 if not a valid path", + "method": "GET", + "path": "/arst", + "status": 404, + "response": { + "error": "not found: /arst" + } + }, + { + "label": "illegal method if POST to root", + "method": "POST", + "path": "/", + "status": 405, + "response": { + "error": "illegal method: POST" + } + }, + { + "label": "status page if GET root", + "method": "GET", + "path": "/", + "status": 200, + "response": { + "component": "mailroom", + "url": "/", + "version": "Dev" + } + }, + { + "label": "illegal method if POST to /mr/", + "method": "POST", + "path": "/mr/", + "status": 405, + "response": { + "error": "illegal method: POST" + } + }, + { + "label": "status page if GET /mr/", + "method": "GET", + "path": "/mr/", + "status": 200, + "response": { + "component": "mailroom", + "url": "/mr/", + "version": "Dev" + } + } +] \ No newline at end of file From 87c02c9217042ca1619545e7e94b7e9b72f3a9fa Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 8 Jun 2020 13:32:08 -0500 Subject: [PATCH 06/55] Update CHANGELOG.md for v5.5.26 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b06de0a..18430a9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.26 +---------- + * Update to goflow v0.89.0 + v5.5.25 ---------- * Add endpoint to change a flow language From 4ff1b65acea384fe94c933fe71e153278e71ec25 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 8 Jun 2020 16:19:33 -0500 Subject: [PATCH 07/55] Add tests for loading ticketers --- models/tickets_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/models/tickets_test.go b/models/tickets_test.go index 6de143dd0..39624cac2 100644 --- a/models/tickets_test.go +++ b/models/tickets_test.go @@ -15,6 +15,44 @@ import ( "github.com/stretchr/testify/require" ) +func TestTicketers(t *testing.T) { + ctx := testsuite.CTX() + db := testsuite.DB() + + // can load directly by UUID + ticketer, err := models.LookupTicketerByUUID(ctx, db, models.ZendeskUUID) + assert.NoError(t, err) + assert.Equal(t, models.ZendeskID, ticketer.ID()) + assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name()) + assert.Equal(t, "1234-abcd", ticketer.Config("push_id")) + assert.Equal(t, "523562", ticketer.Config("push_token")) + + // org through org assets + org1, err := models.GetOrgAssets(ctx, db, models.Org1) + assert.NoError(t, err) + + ticketer = org1.TicketerByID(models.ZendeskID) + assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name()) + assert.Equal(t, "1234-abcd", ticketer.Config("push_id")) + + ticketer = org1.TicketerByUUID(models.ZendeskUUID) + assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name()) + assert.Equal(t, "1234-abcd", ticketer.Config("push_id")) + + ticketer.UpdateConfig(ctx, db, map[string]string{"new-key": "foo"}, map[string]bool{"push_id": true}) + models.FlushCache() + + org1, _ = models.GetOrgAssets(ctx, db, models.Org1) + ticketer = org1.TicketerByID(models.ZendeskID) + + assert.Equal(t, "foo", ticketer.Config("new-key")) // new config value added + assert.Equal(t, "", ticketer.Config("push_id")) // existing config value removed + assert.Equal(t, "523562", ticketer.Config("push_token")) // other value unchanged +} + func TestTickets(t *testing.T) { ctx := testsuite.CTX() db := testsuite.DB() From d4a1fb1b788c555936565f0aae42e6e3942834a2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 8 Jun 2020 14:58:26 -0500 Subject: [PATCH 08/55] Maybe fix intermittently failing test --- .../zendesk/testdata/event_callback.json | 90 ++++++++++--------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/services/tickets/zendesk/testdata/event_callback.json b/services/tickets/zendesk/testdata/event_callback.json index f9f674942..e7c88bd73 100644 --- a/services/tickets/zendesk/testdata/event_callback.json +++ b/services/tickets/zendesk/testdata/event_callback.json @@ -144,49 +144,6 @@ } ] }, - { - "label": "target and trigger deleted for destroy_integration_instance event", - "http_mocks": { - "https://nyaruka.zendesk.com/api/v2/targets/15.json": [ - { - "status": 200, - "body": "" - } - ], - "https://nyaruka.zendesk.com/api/v2/triggers/23.json": [ - { - "status": 200, - "body": "" - } - ] - }, - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "destroy_integration_instance", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "metadata": "{\"ticketer\":\"4ee6d4f3-f92b-439b-9718-8da90c05490b\",\"secret\":\"sesame\"}" - } - } - ] - }, - "status": 200, - "response": { - "status": "OK" - }, - "db_assertions": [ - { - "query": "select count(*) from tickets_ticketer where config @> '{\"target_id\": \"15\", \"trigger_id\": \"23\"}'", - "count": 0 - } - ] - }, { "label": "error for resources_created_from_external_ids event with invalid request ID", "method": "POST", @@ -247,5 +204,52 @@ "count": 1 } ] + }, + { + "label": "target and trigger deleted for destroy_integration_instance event", + "http_mocks": { + "https://nyaruka.zendesk.com/api/v2/targets/15.json": [ + { + "status": 200, + "body": "" + } + ], + "https://nyaruka.zendesk.com/api/v2/triggers/23.json": [ + { + "status": 200, + "body": "" + } + ] + }, + "method": "POST", + "path": "/mr/tickets/types/zendesk/event_callback", + "body": { + "events": [ + { + "type_id": "destroy_integration_instance", + "timestamp": "2015-09-08T22:48:09Z", + "subdomain": "nyaruka", + "integration_name": "Temba", + "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", + "data": { + "metadata": "{\"ticketer\":\"4ee6d4f3-f92b-439b-9718-8da90c05490b\",\"secret\":\"sesame\"}" + } + } + ] + }, + "status": 200, + "response": { + "status": "OK" + }, + "db_assertions": [ + { + "query": "select count(*) from tickets_ticketer where config @> '{\"target_id\": \"15\", \"trigger_id\": \"23\"}'", + "count": 0 + }, + { + "query": "select count(*) from tickets_ticketer where config @> '{\"subdomain\": \"nyaruka\", \"oauth_token\": \"754845822\", \"secret\": \"sesame\"}'", + "count": 1 + } + ] } ] \ No newline at end of file From b3db8999a035f6ec6ec69f6939535febd846e33b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 9 Jun 2020 16:39:54 -0500 Subject: [PATCH 09/55] Use AssertSnapshot from goflow test package --- services/tickets/mailgun/service_test.go | 9 ++++----- ...ump => TestCloseAndReopen_close_tickets.snap} | 0 ...mp => TestCloseAndReopen_reopen_tickets.snap} | 0 ...p => TestOpenAndForward_forward_message.snap} | 0 ....dump => TestOpenAndForward_open_ticket.snap} | 0 services/tickets/zendesk/service_test.go | 9 ++++----- ...ump => TestCloseAndReopen_close_tickets.snap} | 0 ...mp => TestCloseAndReopen_reopen_tickets.snap} | 0 ...p => TestOpenAndForward_forward_message.snap} | 0 ....dump => TestOpenAndForward_open_ticket.snap} | 0 testsuite/testsuite.go | 16 ---------------- 11 files changed, 8 insertions(+), 26 deletions(-) rename services/tickets/mailgun/testdata/{close_tickets.dump => TestCloseAndReopen_close_tickets.snap} (100%) rename services/tickets/mailgun/testdata/{reopen_tickets.dump => TestCloseAndReopen_reopen_tickets.snap} (100%) rename services/tickets/mailgun/testdata/{forward_message.dump => TestOpenAndForward_forward_message.snap} (100%) rename services/tickets/mailgun/testdata/{open_ticket.dump => TestOpenAndForward_open_ticket.snap} (100%) rename services/tickets/zendesk/testdata/{close_tickets.dump => TestCloseAndReopen_close_tickets.snap} (100%) rename services/tickets/zendesk/testdata/{reopen_tickets.dump => TestCloseAndReopen_reopen_tickets.snap} (100%) rename services/tickets/zendesk/testdata/{forward_message.dump => TestOpenAndForward_forward_message.snap} (100%) rename services/tickets/zendesk/testdata/{open_ticket.dump => TestOpenAndForward_open_ticket.snap} (100%) diff --git a/services/tickets/mailgun/service_test.go b/services/tickets/mailgun/service_test.go index c2db68fde..ad72b2f0f 100644 --- a/services/tickets/mailgun/service_test.go +++ b/services/tickets/mailgun/service_test.go @@ -15,7 +15,6 @@ import ( "github.com/nyaruka/goflow/utils/uuids" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/services/tickets/mailgun" - "github.com/nyaruka/mailroom/testsuite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -87,7 +86,7 @@ func TestOpenAndForward(t *testing.T) { }, ticket) assert.Equal(t, 1, len(logger.Logs)) - testsuite.AssertSnapshot(t, "open_ticket.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.MailgunID, "", "Need help", "Where are my cookies?", map[string]interface{}{ "contact-uuid": string(models.CathyUUID), @@ -99,7 +98,7 @@ func TestOpenAndForward(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, len(logger.Logs)) - testsuite.AssertSnapshot(t, "forward_message.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) } func TestCloseAndReopen(t *testing.T) { @@ -146,10 +145,10 @@ func TestCloseAndReopen(t *testing.T) { err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) assert.NoError(t, err) - testsuite.AssertSnapshot(t, "close_tickets.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "close_tickets", logger.Logs[0].Request) err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) assert.NoError(t, err) - testsuite.AssertSnapshot(t, "reopen_tickets.dump", logger.Logs[1].Request) + test.AssertSnapshot(t, "reopen_tickets", logger.Logs[1].Request) } diff --git a/services/tickets/mailgun/testdata/close_tickets.dump b/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap similarity index 100% rename from services/tickets/mailgun/testdata/close_tickets.dump rename to services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap diff --git a/services/tickets/mailgun/testdata/reopen_tickets.dump b/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap similarity index 100% rename from services/tickets/mailgun/testdata/reopen_tickets.dump rename to services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap diff --git a/services/tickets/mailgun/testdata/forward_message.dump b/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap similarity index 100% rename from services/tickets/mailgun/testdata/forward_message.dump rename to services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap diff --git a/services/tickets/mailgun/testdata/open_ticket.dump b/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap similarity index 100% rename from services/tickets/mailgun/testdata/open_ticket.dump rename to services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap diff --git a/services/tickets/zendesk/service_test.go b/services/tickets/zendesk/service_test.go index 4b122fe2c..a7da7a581 100644 --- a/services/tickets/zendesk/service_test.go +++ b/services/tickets/zendesk/service_test.go @@ -15,7 +15,6 @@ import ( "github.com/nyaruka/goflow/utils/uuids" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/services/tickets/zendesk" - "github.com/nyaruka/mailroom/testsuite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -97,7 +96,7 @@ func TestOpenAndForward(t *testing.T) { }, ticket) assert.Equal(t, 1, len(logger.Logs)) - testsuite.AssertSnapshot(t, "open_ticket.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.ZendeskID, "", "Need help", "Where are my cookies?", map[string]interface{}{ "contact-uuid": string(models.CathyUUID), @@ -109,7 +108,7 @@ func TestOpenAndForward(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, len(logger.Logs)) - testsuite.AssertSnapshot(t, "forward_message.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) } func TestCloseAndReopen(t *testing.T) { @@ -157,10 +156,10 @@ func TestCloseAndReopen(t *testing.T) { err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) assert.NoError(t, err) - testsuite.AssertSnapshot(t, "close_tickets.dump", logger.Logs[0].Request) + test.AssertSnapshot(t, "close_tickets", logger.Logs[0].Request) err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) assert.NoError(t, err) - testsuite.AssertSnapshot(t, "reopen_tickets.dump", logger.Logs[1].Request) + test.AssertSnapshot(t, "reopen_tickets", logger.Logs[1].Request) } diff --git a/services/tickets/zendesk/testdata/close_tickets.dump b/services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap similarity index 100% rename from services/tickets/zendesk/testdata/close_tickets.dump rename to services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap diff --git a/services/tickets/zendesk/testdata/reopen_tickets.dump b/services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap similarity index 100% rename from services/tickets/zendesk/testdata/reopen_tickets.dump rename to services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap diff --git a/services/tickets/zendesk/testdata/forward_message.dump b/services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap similarity index 100% rename from services/tickets/zendesk/testdata/forward_message.dump rename to services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap diff --git a/services/tickets/zendesk/testdata/open_ticket.dump b/services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap similarity index 100% rename from services/tickets/zendesk/testdata/open_ticket.dump rename to services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap diff --git a/testsuite/testsuite.go b/testsuite/testsuite.go index 5296ea24d..63f54671d 100644 --- a/testsuite/testsuite.go +++ b/testsuite/testsuite.go @@ -3,20 +3,16 @@ package testsuite import ( "context" "fmt" - "io/ioutil" "os" "os/exec" "path" "strings" "testing" - "github.com/nyaruka/goflow/test" - "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // Reset clears out both our database and redis DB @@ -119,15 +115,3 @@ func AssertQueryCount(t *testing.T, db *sqlx.DB, sql string, args []interface{}, } assert.Equal(t, count, c, errMsg...) } - -// AssertSnapshot checks that the file contains the expected text, or updates the file if -update was set -func AssertSnapshot(t *testing.T, name, expected string) { - if test.UpdateSnapshots { - err := ioutil.WriteFile("testdata/"+name, []byte(expected), 0666) - require.NoError(t, err) - } else { - data, err := ioutil.ReadFile("testdata/" + name) - require.NoError(t, err) - assert.Equal(t, string(data), expected) - } -} From 96a8bc42e041ebe0e05908956bdc3a50f3f5070a Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 10 Jun 2020 17:13:54 -0500 Subject: [PATCH 10/55] Update to latest goflow v0.91.0 --- go.mod | 2 +- go.sum | 4 ++-- web/po/po.go | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 550b9fd60..f5683c630 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.89.0 + github.com/nyaruka/goflow v0.91.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 5d06dc898..62125566d 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.89.0 h1:ZMDPG46WkUOR0+snOB8moIcHC2Po/sKDIYBD+SnkcxY= -github.com/nyaruka/goflow v0.89.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.91.0 h1:qaIPPGNnrk3SudWJEkLtdpt5rzek1KVZVvrD+UHybYg= +github.com/nyaruka/goflow v0.91.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/web/po/po.go b/web/po/po.go index a69f71503..26f128fec 100644 --- a/web/po/po.go +++ b/web/po/po.go @@ -6,8 +6,9 @@ import ( "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/i18n" + "github.com/nyaruka/goflow/flows/translation" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/goflow/utils/i18n" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/web" @@ -53,7 +54,7 @@ func handleExport(ctx context.Context, s *web.Server, r *http.Request, rawW http excludeProperties = []string{"arguments"} } - po, err := i18n.ExtractFromFlows("Generated by mailroom", request.Language, excludeProperties, flows...) + po, err := translation.ExtractFromFlows("Generated by mailroom", request.Language, excludeProperties, flows...) if err != nil { return errors.Wrapf(err, "unable to extract PO from flows") } @@ -100,7 +101,7 @@ func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interfac return err, http.StatusBadRequest, nil } - err = i18n.ImportIntoFlows(po, form.Language, flows...) + err = translation.ImportIntoFlows(po, form.Language, flows...) if err != nil { return err, http.StatusBadRequest, nil } From 2c8cc8f6aa89655138cdbdcd97ab7493f5744bce Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 10 Jun 2020 17:39:13 -0500 Subject: [PATCH 11/55] Allow searching by UUID, as well != matches on ID and UUID --- search/search.go | 9 ++++++++ search/testdata/elastic_test.json | 36 +++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/search/search.go b/search/search.go index c84b586f0..929049822 100644 --- a/search/search.go +++ b/search/search.go @@ -309,9 +309,18 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, } else { return nil, NewError("unsupported name query comparator: %s", c.Comparator()) } + } else if key == contactql.AttributeUUID { + if c.Comparator() == contactql.ComparatorEqual { + return elastic.NewTermQuery("uuid", value), nil + } else if c.Comparator() == contactql.ComparatorNotEqual { + return elastic.NewBoolQuery().MustNot(elastic.NewTermQuery("uuid", value)), nil + } + return nil, NewError("unsupported comparator for uuid: %s", c.Comparator()) } else if key == contactql.AttributeID { if c.Comparator() == contactql.ComparatorEqual { return elastic.NewIdsQuery().Ids(value), nil + } else if c.Comparator() == contactql.ComparatorNotEqual { + return elastic.NewBoolQuery().MustNot(elastic.NewIdsQuery().Ids(value)), nil } return nil, NewError("unsupported comparator for id: %s", c.Comparator()) } else if key == contactql.AttributeLanguage { diff --git a/search/testdata/elastic_test.json b/search/testdata/elastic_test.json index 698ebff57..5eaf5a166 100644 --- a/search/testdata/elastic_test.json +++ b/search/testdata/elastic_test.json @@ -1108,6 +1108,28 @@ "search": "name>chef", "error": "comparisons with > can only be used with date and number fields" }, + { + "label": "valid uuid =", + "search": "uuid=bbe6dba0-818b-4c5a-be51-10432095e27a", + "query": { + "term": { + "uuid": "bbe6dba0-818b-4c5a-be51-10432095e27a" + } + } + }, + { + "label": "valid uuid !=", + "search": "uuid!=bbe6dba0-818b-4c5a-be51-10432095e27a", + "query": { + "bool": { + "must_not": { + "term": { + "uuid": "bbe6dba0-818b-4c5a-be51-10432095e27a" + } + } + } + } + }, { "label": "valid id =", "search": "id=123", @@ -1120,9 +1142,19 @@ } }, { - "label": "invalid id !=", + "label": "valid id !=", "search": "id!=123", - "error": "unsupported comparator for id: !=" + "query": { + "bool": { + "must_not": { + "ids": { + "values": [ + "123" + ] + } + } + } + } }, { "label": "valid language is unset", From 7f2229c998f35524e565243845f13156ce8d8424 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 10 Jun 2020 17:39:44 -0500 Subject: [PATCH 12/55] Simplify elastic query generation code --- search/search.go | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/search/search.go b/search/search.go index 929049822..9635651b0 100644 --- a/search/search.go +++ b/search/search.go @@ -172,7 +172,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, // if we are looking for unset, inverse our query if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewBoolQuery().MustNot(query) + query = not(query) } return query, nil } @@ -187,7 +187,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, elastic.NewTermQuery("fields.text", value), elastic.NewExistsQuery("fields.text"), ) - return elastic.NewBoolQuery().MustNot(elastic.NewNestedQuery("fields", query)), nil + return not(elastic.NewNestedQuery("fields", query)), nil } else { return nil, NewError("unsupported text comparator: %s", c.Comparator()) } @@ -203,7 +203,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { query = elastic.NewMatchQuery("fields.number", value) } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot( + return not( elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must( fieldQuery, @@ -235,7 +235,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { query = elastic.NewRangeQuery("fields.datetime").Gte(start).Lt(end) } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot( + return not( elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must( fieldQuery, @@ -264,7 +264,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { query = elastic.NewTermQuery(name, value) } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot( + return not( elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must( elastic.NewTermQuery(name, value), @@ -289,11 +289,11 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, query = elastic.NewBoolQuery().Must( elastic.NewExistsQuery(key), - elastic.NewBoolQuery().MustNot(elastic.NewTermQuery(fmt.Sprintf("%s.keyword", key), "")), + not(elastic.NewTermQuery(fmt.Sprintf("%s.keyword", key), "")), ) if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewBoolQuery().MustNot(query) + query = not(query) } return query, nil @@ -305,7 +305,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, } else if c.Comparator() == contactql.ComparatorContains { return elastic.NewMatchQuery("name", value), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewTermQuery("name.keyword", c.Value())), nil + return not(elastic.NewTermQuery("name.keyword", c.Value())), nil } else { return nil, NewError("unsupported name query comparator: %s", c.Comparator()) } @@ -313,21 +313,21 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { return elastic.NewTermQuery("uuid", value), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewTermQuery("uuid", value)), nil + return not(elastic.NewTermQuery("uuid", value)), nil } return nil, NewError("unsupported comparator for uuid: %s", c.Comparator()) } else if key == contactql.AttributeID { if c.Comparator() == contactql.ComparatorEqual { return elastic.NewIdsQuery().Ids(value), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewIdsQuery().Ids(value)), nil + return not(elastic.NewIdsQuery().Ids(value)), nil } return nil, NewError("unsupported comparator for id: %s", c.Comparator()) } else if key == contactql.AttributeLanguage { if c.Comparator() == contactql.ComparatorEqual { return elastic.NewTermQuery("language", value), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewTermQuery("language", value)), nil + return not(elastic.NewTermQuery("language", value)), nil } else { return nil, NewError("unsupported language comparator: %s", c.Comparator()) } @@ -341,7 +341,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { return elastic.NewRangeQuery("created_on").Gte(start).Lt(end), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewRangeQuery("created_on").Gte(start).Lt(end)), nil + return not(elastic.NewRangeQuery("created_on").Gte(start).Lt(end)), nil } else if c.Comparator() == contactql.ComparatorGreaterThan { return elastic.NewRangeQuery("created_on").Gte(end), nil } else if c.Comparator() == contactql.ComparatorGreaterThanOrEqual { @@ -360,7 +360,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if (c.Comparator() == contactql.ComparatorEqual || c.Comparator() == contactql.ComparatorNotEqual) && value == "" { query = elastic.NewNestedQuery("urns", elastic.NewExistsQuery("urns.path")) if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewBoolQuery().MustNot(query) + query = not(query) } return query, nil } @@ -386,7 +386,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, if c.Comparator() == contactql.ComparatorEqual { return elastic.NewTermQuery("groups", group.UUID()), nil } else if c.Comparator() == contactql.ComparatorNotEqual { - return elastic.NewBoolQuery().MustNot(elastic.NewTermQuery("groups", group.UUID())), nil + return not(elastic.NewTermQuery("groups", group.UUID())), nil } else { return nil, NewError("unsupported group comparator: %s", c.Comparator()) } @@ -404,7 +404,7 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, elastic.NewExistsQuery("urns.path"), )) if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewBoolQuery().MustNot(query) + query = not(query) } return query, nil } @@ -427,6 +427,11 @@ func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, return nil, NewError("unsupported property type: %s", c.PropertyType()) } +// convenience utility to create a not boolean query +func not(queries ...elastic.Query) *elastic.BoolQuery { + return elastic.NewBoolQuery().MustNot(queries...) +} + // Error is used when an error is in the parsing of a field or query format type Error struct { error string From 5c51e3a2be495df1892f2f96a0c400066a443e16 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 11 Jun 2020 10:51:17 -0500 Subject: [PATCH 13/55] Update to goflow v0.91.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f5683c630..a6ed5045c 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.91.0 + github.com/nyaruka/goflow v0.91.1 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 62125566d..4704efe88 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.91.0 h1:qaIPPGNnrk3SudWJEkLtdpt5rzek1KVZVvrD+UHybYg= -github.com/nyaruka/goflow v0.91.0/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.91.1 h1:TJ3SkbkCh5Wk5U2myGgg0uhUbgacdefBvlb6hDOuABc= +github.com/nyaruka/goflow v0.91.1/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= From 456e798d034e588139cedb9e44e0540212a04b8e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 11 Jun 2020 10:53:03 -0500 Subject: [PATCH 14/55] Update CHANGELOG.md for v5.5.27 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18430a9ad..28ced7800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v5.5.27 +---------- + * Allow searching by UUID, as well != matches on ID and UUID + * Update to latest goflow v0.91.1 to fix clearing fields + * Maybe fix intermittently failing test + v5.5.26 ---------- * Update to goflow v0.89.0 From 1e187ad1e4fe052cffac0d40653cbdbf1829121d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 12 Jun 2020 12:07:27 -0500 Subject: [PATCH 15/55] Don't do any decoration of email ticket subjects --- services/tickets/mailgun/service.go | 18 ++++++------------ .../TestCloseAndReopen_close_tickets.snap | 4 ++-- .../TestCloseAndReopen_reopen_tickets.snap | 4 ++-- .../TestOpenAndForward_forward_message.snap | 4 ++-- .../TestOpenAndForward_open_ticket.snap | 4 ++-- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/services/tickets/mailgun/service.go b/services/tickets/mailgun/service.go index 4df0c8b05..301ad7c88 100644 --- a/services/tickets/mailgun/service.go +++ b/services/tickets/mailgun/service.go @@ -31,8 +31,6 @@ const ( ticketConfigLastMessageID = "last-message-id" ) -var subjectTemplate = newTemplate("subject", `[{{.brand}}] {{.contact}}: {{.subject}}`) - // body template for new ticket being opened var openBodyTemplate = newTemplate("open_body", `New ticket opened ------------------------------------------------ @@ -115,10 +113,9 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow from := s.ticketAddress(contactDisplay, ticketUUID) context := s.templateContext(subject, body, "", string(session.Contact().UUID()), contactDisplay) - fullSubject := evaluateTemplate(subjectTemplate, context) fullBody := evaluateTemplate(openBodyTemplate, context) - msgID, trace, err := s.client.SendMessage(from, s.toAddress, fullSubject, fullBody, nil) + msgID, trace, err := s.client.SendMessage(from, s.toAddress, subject, fullBody, nil) if trace != nil { logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) } @@ -131,20 +128,18 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, logHTTP flows.HTTPLogCallback) error { context := s.templateContext(ticket.Subject(), ticket.Body(), text, ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - subject := evaluateTemplate(subjectTemplate, context) body := evaluateTemplate(forwardBodyTemplate, context) - _, err := s.sendInTicket(ticket, subject, body, logHTTP) + _, err := s.sendInTicket(ticket, body, logHTTP) return err } func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { for _, ticket := range tickets { context := s.templateContext(ticket.Subject(), ticket.Body(), "", ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - subject := evaluateTemplate(subjectTemplate, context) body := evaluateTemplate(closedBodyTemplate, context) - _, err := s.sendInTicket(ticket, subject, body, logHTTP) + _, err := s.sendInTicket(ticket, body, logHTTP) if err != nil { return err } @@ -155,10 +150,9 @@ func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { for _, ticket := range tickets { context := s.templateContext(ticket.Subject(), ticket.Body(), "", ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - subject := evaluateTemplate(subjectTemplate, context) body := evaluateTemplate(reopenedBodyTemplate, context) - _, err := s.sendInTicket(ticket, subject, body, logHTTP) + _, err := s.sendInTicket(ticket, body, logHTTP) if err != nil { return err } @@ -167,7 +161,7 @@ func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback } // sends an email as part of the thread for the given ticket -func (s *service) sendInTicket(ticket *models.Ticket, subject, text string, logHTTP flows.HTTPLogCallback) (string, error) { +func (s *service) sendInTicket(ticket *models.Ticket, text string, logHTTP flows.HTTPLogCallback) (string, error) { contactDisplay := ticket.Config(ticketConfigContactDisplay) lastMessageID := ticket.Config(ticketConfigLastMessageID) if lastMessageID == "" { @@ -179,7 +173,7 @@ func (s *service) sendInTicket(ticket *models.Ticket, subject, text string, logH } from := s.ticketAddress(contactDisplay, ticket.UUID()) - return s.send(from, s.toAddress, subject, text, headers, logHTTP) + return s.send(from, s.toAddress, ticket.Subject(), text, headers, logHTTP) } func (s *service) send(from, to, subject, text string, headers map[string]string, logHTTP flows.HTTPLogCallback) (string, error) { diff --git a/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap b/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap index 89a5366ff..2e3eae820 100644 --- a/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap +++ b/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap @@ -1,7 +1,7 @@ POST /v3/tickets.rapidpro.io/messages HTTP/1.1 Host: api.mailgun.net User-Agent: Go-http-client/1.1 -Content-Length: 841 +Content-Length: 832 Authorization: Basic **************** Content-Type: multipart/form-data; boundary=e7187099-7d38-4f60-955c-325957214c42 Accept-Encoding: gzip @@ -17,7 +17,7 @@ bob@acme.com --e7187099-7d38-4f60-955c-325957214c42 Content-Disposition: form-data; name="subject" -[ACME] : New ticket +New ticket --e7187099-7d38-4f60-955c-325957214c42 Content-Disposition: form-data; name="text" diff --git a/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap b/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap index 207503582..a5ffa3196 100644 --- a/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap +++ b/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap @@ -1,7 +1,7 @@ POST /v3/tickets.rapidpro.io/messages HTTP/1.1 Host: api.mailgun.net User-Agent: Go-http-client/1.1 -Content-Length: 844 +Content-Length: 835 Authorization: Basic **************** Content-Type: multipart/form-data; boundary=59d74b86-3e2f-4a93-aece-b05d2fdcde0c Accept-Encoding: gzip @@ -17,7 +17,7 @@ bob@acme.com --59d74b86-3e2f-4a93-aece-b05d2fdcde0c Content-Disposition: form-data; name="subject" -[ACME] : Second ticket +Second ticket --59d74b86-3e2f-4a93-aece-b05d2fdcde0c Content-Disposition: form-data; name="text" diff --git a/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap b/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap index cd9b3ed4e..d66c5a80b 100644 --- a/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap +++ b/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap @@ -1,7 +1,7 @@ POST /v3/tickets.rapidpro.io/messages HTTP/1.1 Host: api.mailgun.net User-Agent: Go-http-client/1.1 -Content-Length: 1025 +Content-Length: 1011 Authorization: Basic **************** Content-Type: multipart/form-data; boundary=13e96d5a-4e65-4f07-9189-9d6270c6f3c0 Accept-Encoding: gzip @@ -17,7 +17,7 @@ bob@acme.com --13e96d5a-4e65-4f07-9189-9d6270c6f3c0 Content-Disposition: form-data; name="subject" -[ACME] Cathy: Need help +Need help --13e96d5a-4e65-4f07-9189-9d6270c6f3c0 Content-Disposition: form-data; name="text" diff --git a/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap b/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap index 1e2d3aa0d..4a0e2f732 100644 --- a/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap +++ b/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap @@ -1,7 +1,7 @@ POST /v3/tickets.rapidpro.io/messages HTTP/1.1 Host: api.mailgun.net User-Agent: Go-http-client/1.1 -Content-Length: 853 +Content-Length: 834 Authorization: Basic **************** Content-Type: multipart/form-data; boundary=297611a6-b583-45c3-8587-d4e530c948f0 Accept-Encoding: gzip @@ -17,7 +17,7 @@ bob@acme.com --297611a6-b583-45c3-8587-d4e530c948f0 Content-Disposition: form-data; name="subject" -[ACME] Ryan Lewis: Need help +Need help --297611a6-b583-45c3-8587-d4e530c948f0 Content-Disposition: form-data; name="text" From 9f700d75f6176c16b81040b312693a900bd64801 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 12 Jun 2020 12:22:59 -0500 Subject: [PATCH 16/55] Update CHANGELOG.md for v5.5.28 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ced7800..ecdac9aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.28 +---------- + * Don't do any decoration of email ticket subjects + v5.5.27 ---------- * Allow searching by UUID, as well != matches on ID and UUID From 32a53f060cc3943959864e70ed0c8d07b8d6fb90 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 15 Jun 2020 15:21:40 -0500 Subject: [PATCH 17/55] Update to latest goflow v0.92.0 --- go.mod | 5 +- go.sum | 12 +- models/contacts.go | 12 +- models/contacts_test.go | 7 +- models/groups_test.go | 6 +- search/search.go | 446 ------ search/search_test.go | 166 --- search/testdata/elastic_test.json | 1732 ------------------------ tasks/starts/worker_test.go | 4 +- search/mock.go => testsuite/elastic.go | 2 +- web/contact/contact.go | 39 +- web/contact/contact_test.go | 4 +- 12 files changed, 54 insertions(+), 2381 deletions(-) delete mode 100644 search/search.go delete mode 100644 search/search_test.go delete mode 100644 search/testdata/elastic_test.json rename search/mock.go => testsuite/elastic.go (98%) diff --git a/go.mod b/go.mod index a6ed5045c..5f777da19 100644 --- a/go.mod +++ b/go.mod @@ -17,15 +17,14 @@ require ( github.com/jmoiron/sqlx v1.2.0 github.com/kylelemons/godebug v1.1.0 // indirect github.com/lib/pq v1.4.0 - github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e // indirect github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.91.1 + github.com/nyaruka/goflow v0.92.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 - github.com/olivere/elastic v6.2.30+incompatible + github.com/olivere/elastic v6.2.33+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/prometheus/client_model v0.2.0 diff --git a/go.sum b/go.sum index 4704efe88..4e451a592 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc= github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.1 h1:mdxE1MF9o53iCb2Ghj1VfWvh7ZOwHpnVG/xwXrV90U8= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.91.1 h1:TJ3SkbkCh5Wk5U2myGgg0uhUbgacdefBvlb6hDOuABc= -github.com/nyaruka/goflow v0.91.1/go.mod h1:HBoTXbhrjhZENbCUlDvh8ZCR16ZvlgY4aTE/ABMS8uE= +github.com/nyaruka/goflow v0.92.0 h1:6eS5RN0kgEhafzJsfhKOAkk+tecQAo6yImaxmy56Sog= +github.com/nyaruka/goflow v0.92.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= @@ -139,8 +139,8 @@ github.com/nyaruka/null v1.2.0/go.mod h1:HSAFbLNOaEhHnoU0VCveCPz0GDtJ3GEtFWhvnBN github.com/nyaruka/phonenumbers v1.0.34/go.mod h1:GQ0cTHlrxPrhoLwyQ1blyN1hO794ygt6FTHWrFB5SSc= github.com/nyaruka/phonenumbers v1.0.55 h1:bj0nTO88Y68KeUQ/n3Lo2KgK7lM1hF7L9NFuwcCl3yg= github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= -github.com/olivere/elastic v6.2.30+incompatible h1:9JdhoNFfUF809qM1S5WLz3CZaxazd/mDty9XXwDRz4Q= -github.com/olivere/elastic v6.2.30+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= +github.com/olivere/elastic v6.2.33+incompatible h1:SRPB2w2OhJ7iULftDEHsNPRoL2GLREqPMRalVmbZaEw= +github.com/olivere/elastic v6.2.33+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= diff --git a/models/contacts.go b/models/contacts.go index f6c520b8b..775a7e34b 100644 --- a/models/contacts.go +++ b/models/contacts.go @@ -13,12 +13,12 @@ import ( "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/contactql" + "github.com/nyaruka/goflow/contactql/es" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/excellent/types" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/goflow/utils/uuids" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/null" "github.com/olivere/elastic" @@ -198,7 +198,7 @@ func BuildElasticQuery(org *OrgAssets, group assets.GroupUUID, query *contactql. // and by our query if present if query != nil { - q, err := search.ToElasticQuery(org.Env(), org.SessionAssets(), query) + q, err := es.ToElasticQuery(org.Env(), org.SessionAssets(), query) if err != nil { return nil, errors.Wrapf(err, "error converting contactql to elastic query: %s", query) } @@ -211,6 +211,7 @@ func BuildElasticQuery(org *OrgAssets, group assets.GroupUUID, query *contactql. // ContactIDsForQueryPage returns the ids of the contacts for the passed in query page func ContactIDsForQueryPage(ctx context.Context, client *elastic.Client, org *OrgAssets, group assets.GroupUUID, query string, sort string, offset int, pageSize int) (*contactql.ContactQuery, []ContactID, int64, error) { + env := org.Env() start := time.Now() var parsed *contactql.ContactQuery var err error @@ -220,7 +221,7 @@ func ContactIDsForQueryPage(ctx context.Context, client *elastic.Client, org *Or } if query != "" { - parsed, err = search.ParseQuery(org.Env(), org.SessionAssets(), query) + parsed, err = contactql.ParseQuery(query, env.RedactionPolicy(), env.DefaultCountry(), org.SessionAssets()) if err != nil { return nil, nil, 0, errors.Wrapf(err, "error parsing query: %s", query) } @@ -231,7 +232,7 @@ func ContactIDsForQueryPage(ctx context.Context, client *elastic.Client, org *Or return nil, nil, 0, errors.Wrapf(err, "error parsing query: %s", query) } - fieldSort, err := search.ToElasticFieldSort(org.SessionAssets(), sort) + fieldSort, err := es.ToElasticFieldSort(org.SessionAssets(), sort) if err != nil { return nil, nil, 0, errors.Wrapf(err, "error parsing sort") } @@ -274,6 +275,7 @@ func ContactIDsForQueryPage(ctx context.Context, client *elastic.Client, org *Or // ContactIDsForQuery returns the ids of all the contacts that match the passed in query func ContactIDsForQuery(ctx context.Context, client *elastic.Client, org *OrgAssets, query string) ([]ContactID, error) { + env := org.Env() start := time.Now() if client == nil { @@ -281,7 +283,7 @@ func ContactIDsForQuery(ctx context.Context, client *elastic.Client, org *OrgAss } // turn into elastic query - parsed, err := search.ParseQuery(org.Env(), org.SessionAssets(), query) + parsed, err := contactql.ParseQuery(query, env.RedactionPolicy(), env.DefaultCountry(), org.SessionAssets()) if err != nil { return nil, errors.Wrapf(err, "error parsing query: %s", query) } diff --git a/models/contacts_test.go b/models/contacts_test.go index d5c604bd9..a000a4d58 100644 --- a/models/contacts_test.go +++ b/models/contacts_test.go @@ -1,17 +1,16 @@ package models import ( + "fmt" "testing" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/mailroom/testsuite" + "github.com/olivere/elastic" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" - - "fmt" ) func TestElasticContacts(t *testing.T) { @@ -19,7 +18,7 @@ func TestElasticContacts(t *testing.T) { ctx := testsuite.CTX() db := testsuite.DB() - es := search.NewMockElasticServer() + es := testsuite.NewMockElasticServer() defer es.Close() client, err := elastic.NewClient( diff --git a/models/groups_test.go b/models/groups_test.go index c18552765..91049450c 100644 --- a/models/groups_test.go +++ b/models/groups_test.go @@ -4,11 +4,11 @@ import ( "fmt" "testing" - "github.com/lib/pq" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/utils/uuids" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/mailroom/testsuite" + + "github.com/lib/pq" "github.com/olivere/elastic" "github.com/stretchr/testify/assert" ) @@ -68,7 +68,7 @@ func TestDynamicGroups(t *testing.T) { org, err := GetOrgAssets(ctx, db, Org1) assert.NoError(t, err) - esServer := search.NewMockElasticServer() + esServer := testsuite.NewMockElasticServer() defer esServer.Close() es, err := elastic.NewClient( diff --git a/search/search.go b/search/search.go deleted file mode 100644 index 9635651b0..000000000 --- a/search/search.go +++ /dev/null @@ -1,446 +0,0 @@ -package search - -import ( - "fmt" - "sort" - "strings" - - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/contactql" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/goflow/utils/dates" - "github.com/olivere/elastic" - "github.com/pkg/errors" - "github.com/shopspring/decimal" -) - -// ParseQuery parses the passed in query returning the result -func ParseQuery(env envs.Environment, resolver contactql.Resolver, query string) (*contactql.ContactQuery, error) { - parsed, err := contactql.ParseQuery(query, env.RedactionPolicy(), env.DefaultCountry(), resolver) - if err != nil { - return nil, NewError(err.Error()) - } - return parsed, nil -} - -// ToElasticQuery converts a contactql query to an Elastic query returning the normalized view as well as the elastic query -func ToElasticQuery(env envs.Environment, resolver contactql.Resolver, query *contactql.ContactQuery) (elastic.Query, error) { - eq, err := nodeToElasticQuery(env, resolver, query.Root()) - if err != nil { - return nil, NewError(err.Error()) - } - - return eq, nil -} - -// FieldDependencies returns all the field this query is dependent on. This includes attributes such as "id" and "name" -func FieldDependencies(query *contactql.ContactQuery) []string { - if query == nil { - return []string{} - } - - seen := make(map[string]bool) - var appendFields func(node contactql.QueryNode, seen map[string]bool) - appendFields = func(node contactql.QueryNode, seen map[string]bool) { - switch n := node.(type) { - case *contactql.BoolCombination: - for _, c := range n.Children() { - appendFields(c, seen) - } - - case *contactql.Condition: - seen[n.PropertyKey()] = true - - default: - panic(fmt.Sprintf("unknown type in contactql query: %v", n)) - } - } - - appendFields(query.Root(), seen) - fields := make([]string, 0, len(seen)) - for k := range seen { - fields = append(fields, k) - } - - // order to make deterministic - sort.Strings(fields) - - return fields -} - -// ToElasticFieldSort returns the FieldSort for the passed in field -func ToElasticFieldSort(resolver contactql.Resolver, fieldName string) (*elastic.FieldSort, error) { - // no field name? default to most recent first by id - if fieldName == "" { - return elastic.NewFieldSort("id").Desc(), nil - } - - // figure out if we are ascending or descending (default is ascending, can be changed with leading -) - ascending := true - if strings.HasPrefix(fieldName, "-") { - ascending = false - fieldName = fieldName[1:] - } - - fieldName = strings.ToLower(fieldName) - - // name needs to be sorted by keyword field - if fieldName == contactql.AttributeName { - return elastic.NewFieldSort("name.keyword").Order(ascending), nil - } - - // other attributes are straight sorts - if fieldName == contactql.AttributeID || fieldName == contactql.AttributeCreatedOn || fieldName == contactql.AttributeLanguage { - return elastic.NewFieldSort(fieldName).Order(ascending), nil - } - - // we are sorting by a custom field - field := resolver.ResolveField(fieldName) - if field == nil { - return nil, NewError("unable to find field with name: %s", fieldName) - } - - var key string - switch field.Type() { - case assets.FieldTypeState, - assets.FieldTypeDistrict, - assets.FieldTypeWard: - key = fmt.Sprintf("fields.%s_keyword", field.Type()) - default: - key = fmt.Sprintf("fields.%s", field.Type()) - } - - sort := elastic.NewFieldSort(key) - sort = sort.Nested(elastic.NewNestedSort("fields").Filter(elastic.NewTermQuery("fields.field", field.UUID()))) - sort = sort.Order(ascending) - return sort, nil -} - -// AllowAsGroup returns whether a query can be used as a dynamic group -func AllowAsGroup(fields []string) bool { - return !(utils.StringSliceContains(fields, "id", false) || utils.StringSliceContains(fields, "group", false)) -} - -func nodeToElasticQuery(env envs.Environment, resolver contactql.Resolver, node contactql.QueryNode) (elastic.Query, error) { - switch n := node.(type) { - case *contactql.BoolCombination: - return boolCombinationToElasticQuery(env, resolver, n) - case *contactql.Condition: - return conditionToElasticQuery(env, resolver, n) - default: - return nil, errors.Errorf("unknown type converting to elastic query: %v", n) - } -} - -func boolCombinationToElasticQuery(env envs.Environment, resolver contactql.Resolver, combination *contactql.BoolCombination) (elastic.Query, error) { - queries := make([]elastic.Query, len(combination.Children())) - for i, child := range combination.Children() { - childQuery, err := nodeToElasticQuery(env, resolver, child) - if err != nil { - return nil, errors.Wrapf(err, "error evaluating child query") - } - queries[i] = childQuery - } - - if combination.Operator() == contactql.BoolOperatorAnd { - return elastic.NewBoolQuery().Must(queries...), nil - } - - return elastic.NewBoolQuery().Should(queries...), nil -} - -func conditionToElasticQuery(env envs.Environment, resolver contactql.Resolver, c *contactql.Condition) (elastic.Query, error) { - var query elastic.Query - key := c.PropertyKey() - - if c.PropertyType() == contactql.PropertyTypeField { - field := resolver.ResolveField(key) - if field == nil { - return nil, NewError("unable to find field: %s", key) - } - - fieldQuery := elastic.NewTermQuery("fields.field", field.UUID()) - fieldType := field.Type() - - // special cases for set/unset - if (c.Comparator() == contactql.ComparatorEqual || c.Comparator() == contactql.ComparatorNotEqual) && c.Value() == "" { - query = elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must( - fieldQuery, - elastic.NewExistsQuery("fields."+string(field.Type())), - )) - - // if we are looking for unset, inverse our query - if c.Comparator() == contactql.ComparatorEqual { - query = not(query) - } - return query, nil - } - - if fieldType == assets.FieldTypeText { - value := strings.ToLower(c.Value()) - if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewTermQuery("fields.text", value) - } else if c.Comparator() == contactql.ComparatorNotEqual { - query = elastic.NewBoolQuery().Must( - fieldQuery, - elastic.NewTermQuery("fields.text", value), - elastic.NewExistsQuery("fields.text"), - ) - return not(elastic.NewNestedQuery("fields", query)), nil - } else { - return nil, NewError("unsupported text comparator: %s", c.Comparator()) - } - - return elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must(fieldQuery, query)), nil - - } else if fieldType == assets.FieldTypeNumber { - value, err := decimal.NewFromString(c.Value()) - if err != nil { - return nil, NewError("can't convert '%s' to a number", c.Value()) - } - - if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewMatchQuery("fields.number", value) - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not( - elastic.NewNestedQuery("fields", - elastic.NewBoolQuery().Must( - fieldQuery, - elastic.NewMatchQuery("fields.number", value), - ), - ), - ), nil - } else if c.Comparator() == contactql.ComparatorGreaterThan { - query = elastic.NewRangeQuery("fields.number").Gt(value) - } else if c.Comparator() == contactql.ComparatorGreaterThanOrEqual { - query = elastic.NewRangeQuery("fields.number").Gte(value) - } else if c.Comparator() == contactql.ComparatorLessThan { - query = elastic.NewRangeQuery("fields.number").Lt(value) - } else if c.Comparator() == contactql.ComparatorLessThanOrEqual { - query = elastic.NewRangeQuery("fields.number").Lte(value) - } else { - return nil, NewError("unsupported number comparator: %s", c.Comparator()) - } - - return elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must(fieldQuery, query)), nil - - } else if fieldType == assets.FieldTypeDatetime { - value, err := envs.DateTimeFromString(env, c.Value(), false) - if err != nil { - return nil, NewError("string '%s' couldn't be parsed as a date", c.Value()) - } - start, end := dates.DayToUTCRange(value, value.Location()) - - if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewRangeQuery("fields.datetime").Gte(start).Lt(end) - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not( - elastic.NewNestedQuery("fields", - elastic.NewBoolQuery().Must( - fieldQuery, - elastic.NewRangeQuery("fields.datetime").Gte(start).Lt(end), - ), - ), - ), nil - } else if c.Comparator() == contactql.ComparatorGreaterThan { - query = elastic.NewRangeQuery("fields.datetime").Gte(end) - } else if c.Comparator() == contactql.ComparatorGreaterThanOrEqual { - query = elastic.NewRangeQuery("fields.datetime").Gte(start) - } else if c.Comparator() == contactql.ComparatorLessThan { - query = elastic.NewRangeQuery("fields.datetime").Lt(start) - } else if c.Comparator() == contactql.ComparatorLessThanOrEqual { - query = elastic.NewRangeQuery("fields.datetime").Lt(end) - } else { - return nil, NewError("unsupported datetime comparator: %s", c.Comparator()) - } - - return elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must(fieldQuery, query)), nil - - } else if fieldType == assets.FieldTypeState || fieldType == assets.FieldTypeDistrict || fieldType == assets.FieldTypeWard { - value := strings.ToLower(c.Value()) - var name = fmt.Sprintf("fields.%s_keyword", string(fieldType)) - - if c.Comparator() == contactql.ComparatorEqual { - query = elastic.NewTermQuery(name, value) - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not( - elastic.NewNestedQuery("fields", - elastic.NewBoolQuery().Must( - elastic.NewTermQuery(name, value), - elastic.NewExistsQuery(name), - ), - ), - ), nil - } else { - return nil, NewError("unsupported location comparator: %s", c.Comparator()) - } - - return elastic.NewNestedQuery("fields", elastic.NewBoolQuery().Must(fieldQuery, query)), nil - } else { - return nil, NewError("unsupported contact field type: %s", field.Type()) - } - } else if c.PropertyType() == contactql.PropertyTypeAttribute { - value := strings.ToLower(c.Value()) - - // special case for set/unset for name and language - if (c.Comparator() == contactql.ComparatorEqual || c.Comparator() == contactql.ComparatorNotEqual) && value == "" && - (key == contactql.AttributeName || key == contactql.AttributeLanguage) { - - query = elastic.NewBoolQuery().Must( - elastic.NewExistsQuery(key), - not(elastic.NewTermQuery(fmt.Sprintf("%s.keyword", key), "")), - ) - - if c.Comparator() == contactql.ComparatorEqual { - query = not(query) - } - - return query, nil - } - - if key == contactql.AttributeName { - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewTermQuery("name.keyword", c.Value()), nil - } else if c.Comparator() == contactql.ComparatorContains { - return elastic.NewMatchQuery("name", value), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewTermQuery("name.keyword", c.Value())), nil - } else { - return nil, NewError("unsupported name query comparator: %s", c.Comparator()) - } - } else if key == contactql.AttributeUUID { - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewTermQuery("uuid", value), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewTermQuery("uuid", value)), nil - } - return nil, NewError("unsupported comparator for uuid: %s", c.Comparator()) - } else if key == contactql.AttributeID { - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewIdsQuery().Ids(value), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewIdsQuery().Ids(value)), nil - } - return nil, NewError("unsupported comparator for id: %s", c.Comparator()) - } else if key == contactql.AttributeLanguage { - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewTermQuery("language", value), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewTermQuery("language", value)), nil - } else { - return nil, NewError("unsupported language comparator: %s", c.Comparator()) - } - } else if key == contactql.AttributeCreatedOn { - value, err := envs.DateTimeFromString(env, c.Value(), false) - if err != nil { - return nil, NewError("string '%s' couldn't be parsed as a date", c.Value()) - } - start, end := dates.DayToUTCRange(value, value.Location()) - - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewRangeQuery("created_on").Gte(start).Lt(end), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewRangeQuery("created_on").Gte(start).Lt(end)), nil - } else if c.Comparator() == contactql.ComparatorGreaterThan { - return elastic.NewRangeQuery("created_on").Gte(end), nil - } else if c.Comparator() == contactql.ComparatorGreaterThanOrEqual { - return elastic.NewRangeQuery("created_on").Gte(start), nil - } else if c.Comparator() == contactql.ComparatorLessThan { - return elastic.NewRangeQuery("created_on").Lt(start), nil - } else if c.Comparator() == contactql.ComparatorLessThanOrEqual { - return elastic.NewRangeQuery("created_on").Lt(end), nil - } else { - return nil, NewError("unsupported created_on comparator: %s", c.Comparator()) - } - } else if key == contactql.AttributeURN { - value := strings.ToLower(c.Value()) - - // special case for set/unset - if (c.Comparator() == contactql.ComparatorEqual || c.Comparator() == contactql.ComparatorNotEqual) && value == "" { - query = elastic.NewNestedQuery("urns", elastic.NewExistsQuery("urns.path")) - if c.Comparator() == contactql.ComparatorEqual { - query = not(query) - } - return query, nil - } - - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewNestedQuery("urns", elastic.NewTermQuery("urns.path.keyword", value)), nil - } else if c.Comparator() == contactql.ComparatorContains { - return elastic.NewNestedQuery("urns", elastic.NewMatchPhraseQuery("urns.path", value)), nil - } else { - return nil, NewError("unsupported urn comparator: %s", c.Comparator()) - } - - } else if key == contactql.AttributeGroup { - if c.Value() == "" { - return nil, NewError("empty values not supported for group conditions") - } - - group := resolver.ResolveGroup(c.Value()) - if group == nil { - return nil, NewError("no such group with name '%s", c.Value()) - } - - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewTermQuery("groups", group.UUID()), nil - } else if c.Comparator() == contactql.ComparatorNotEqual { - return not(elastic.NewTermQuery("groups", group.UUID())), nil - } else { - return nil, NewError("unsupported group comparator: %s", c.Comparator()) - } - - } else { - return nil, NewError("unsupported contact attribute: %s", key) - } - } else if c.PropertyType() == contactql.PropertyTypeScheme { - value := strings.ToLower(c.Value()) - - // special case for set/unset - if (c.Comparator() == contactql.ComparatorEqual || c.Comparator() == contactql.ComparatorNotEqual) && value == "" { - query = elastic.NewNestedQuery("urns", elastic.NewBoolQuery().Must( - elastic.NewTermQuery("urns.scheme", key), - elastic.NewExistsQuery("urns.path"), - )) - if c.Comparator() == contactql.ComparatorEqual { - query = not(query) - } - return query, nil - } - - if c.Comparator() == contactql.ComparatorEqual { - return elastic.NewNestedQuery("urns", elastic.NewBoolQuery().Must( - elastic.NewTermQuery("urns.path.keyword", value), - elastic.NewTermQuery("urns.scheme", key)), - ), nil - } else if c.Comparator() == contactql.ComparatorContains { - return elastic.NewNestedQuery("urns", elastic.NewBoolQuery().Must( - elastic.NewMatchPhraseQuery("urns.path", value), - elastic.NewTermQuery("urns.scheme", key)), - ), nil - } else { - return nil, NewError("unsupported scheme comparator: %s", c.Comparator()) - } - } - - return nil, NewError("unsupported property type: %s", c.PropertyType()) -} - -// convenience utility to create a not boolean query -func not(queries ...elastic.Query) *elastic.BoolQuery { - return elastic.NewBoolQuery().MustNot(queries...) -} - -// Error is used when an error is in the parsing of a field or query format -type Error struct { - error string -} - -func (e *Error) Error() string { - return e.error -} - -func NewError(err string, args ...interface{}) *Error { - return &Error{fmt.Sprintf(err, args...)} -} diff --git a/search/search_test.go b/search/search_test.go deleted file mode 100644 index 931b7b6dc..000000000 --- a/search/search_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package search - -import ( - "bytes" - "encoding/json" - "fmt" - "io/ioutil" - "testing" - "time" - - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/assets/static/types" - "github.com/nyaruka/goflow/contactql" - "github.com/nyaruka/goflow/envs" - - "github.com/olivere/elastic" - "github.com/stretchr/testify/assert" -) - -func newMockResolver() contactql.Resolver { - return contactql.NewMockResolver( - map[string]assets.Field{ - "age": types.NewField("6b6a43fa-a26d-4017-bede-328bcdd5c93b", "age", "Age", assets.FieldTypeNumber), - "color": types.NewField("ecc7b13b-c698-4f46-8a90-24a8fab6fe34", "color", "Color", assets.FieldTypeText), - "dob": types.NewField("cbd3fc0e-9b74-4207-a8c7-248082bb4572", "dob", "DOB", assets.FieldTypeDatetime), - "state": types.NewField("67663ad1-3abc-42dd-a162-09df2dea66ec", "state", "State", assets.FieldTypeState), - "district": types.NewField("54c72635-d747-4e45-883c-099d57dd998e", "district", "District", assets.FieldTypeDistrict), - "ward": types.NewField("fde8f740-c337-421b-8abb-83b954897c80", "ward", "Ward", assets.FieldTypeWard), - }, - map[string]assets.Group{ - "u-reporters": types.NewGroup("8de30b78-d9ef-4db2-b2e8-4f7b6aef64cf", "U-Reporters", ""), - "testers": types.NewGroup("cf51cf8d-94da-447a-b27e-a42a900c37a6", "Testers", ""), - }, - ) -} - -func TestElasticSort(t *testing.T) { - resolver := newMockResolver() - - tcs := []struct { - Label string - Sort string - Elastic string - Error error - }{ - {"empty", "", `{"id":{"order":"desc"}}`, nil}, - {"descending created_on", "-created_on", `{"created_on":{"order":"desc"}}`, nil}, - {"ascending name", "name", `{"name.keyword":{"order":"asc"}}`, nil}, - {"descending language", "-language", `{"language":{"order":"desc"}}`, nil}, - {"descending numeric", "-AGE", `{"fields.number":{"nested":{"filter":{"term":{"fields.field":"6b6a43fa-a26d-4017-bede-328bcdd5c93b"}},"path":"fields"},"order":"desc"}}`, nil}, - {"ascending text", "color", `{"fields.text":{"nested":{"filter":{"term":{"fields.field":"ecc7b13b-c698-4f46-8a90-24a8fab6fe34"}},"path":"fields"},"order":"asc"}}`, nil}, - {"descending date", "-dob", `{"fields.datetime":{"nested":{"filter":{"term":{"fields.field":"cbd3fc0e-9b74-4207-a8c7-248082bb4572"}},"path":"fields"},"order":"desc"}}`, nil}, - {"descending state", "-state", `{"fields.state_keyword":{"nested":{"filter":{"term":{"fields.field":"67663ad1-3abc-42dd-a162-09df2dea66ec"}},"path":"fields"},"order":"desc"}}`, nil}, - {"ascending district", "district", `{"fields.district_keyword":{"nested":{"filter":{"term":{"fields.field":"54c72635-d747-4e45-883c-099d57dd998e"}},"path":"fields"},"order":"asc"}}`, nil}, - {"ascending ward", "ward", `{"fields.ward_keyword":{"nested":{"filter":{"term":{"fields.field":"fde8f740-c337-421b-8abb-83b954897c80"}},"path":"fields"},"order":"asc"}}`, nil}, - - {"unknown field", "foo", "", fmt.Errorf("unable to find field with name: foo")}, - } - - for _, tc := range tcs { - sort, err := ToElasticFieldSort(resolver, tc.Sort) - - if err != nil { - assert.Equal(t, tc.Error.Error(), err.Error()) - continue - } - - src, _ := sort.Source() - encoded, _ := json.Marshal(src) - assert.Equal(t, tc.Elastic, string(encoded)) - } -} - -func TestQueryTerms(t *testing.T) { - resolver := newMockResolver() - - tcs := []struct { - Query string - Fields []string - }{ - {"joe", []string{"name"}}, - {"id = 10", []string{"id"}}, - {"name = joe or AGE > 10", []string{"age", "name"}}, - } - - env := envs.NewBuilder().Build() - - for _, tc := range tcs { - parsed, err := ParseQuery(env, resolver, tc.Query) - assert.NoError(t, err) - - fields := FieldDependencies(parsed) - assert.Equal(t, fields, tc.Fields) - } - -} - -func TestElasticQuery(t *testing.T) { - resolver := newMockResolver() - - type TestCase struct { - Label string `json:"label"` - Search string `json:"search"` - Query json.RawMessage `json:"query"` - Error string `json:"error"` - IsAnon bool `json:"is_anon"` - } - tcs := make([]TestCase, 0, 20) - tcJSON, err := ioutil.ReadFile("testdata/elastic_test.json") - assert.NoError(t, err) - - err = json.Unmarshal(tcJSON, &tcs) - assert.NoError(t, err) - - ny, _ := time.LoadLocation("America/New_York") - - for _, tc := range tcs { - redactionPolicy := envs.RedactionPolicyNone - if tc.IsAnon { - redactionPolicy = envs.RedactionPolicyURNs - } - env := envs.NewBuilder().WithTimezone(ny).WithRedactionPolicy(redactionPolicy).Build() - - qlQuery, err := ParseQuery(env, resolver, tc.Search) - - var query elastic.Query - if err == nil { - query, err = ToElasticQuery(env, resolver, qlQuery) - } - - if tc.Error != "" { - assert.Error(t, err, "%s: error not received converting to elastic: %s", tc.Label, tc.Search) - if err != nil { - assert.Contains(t, err.Error(), tc.Error) - } - continue - } - - assert.NoError(t, err, "%s: error received converting to elastic: %s", tc.Label, tc.Search) - if err != nil { - continue - } - - assert.NotNil(t, query, tc.Label) - if query == nil { - continue - } - - source, err := query.Source() - assert.NoError(t, err, tc.Label) - if err != nil { - continue - } - - asJSON, err := json.Marshal(source) - assert.NoError(t, err, tc.Label) - if err != nil { - continue - } - - compacted := &bytes.Buffer{} - json.Compact(compacted, tc.Query) - - assert.Equal(t, compacted.String(), string(asJSON), "%s: generated query does not match for: %s", tc.Label, tc.Search) - } -} diff --git a/search/testdata/elastic_test.json b/search/testdata/elastic_test.json deleted file mode 100644 index 5eaf5a166..000000000 --- a/search/testdata/elastic_test.json +++ /dev/null @@ -1,1732 +0,0 @@ -[ - { - "label": "valid text is set", - "search": "color!=\"\"", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "exists": { - "field": "fields.text" - } - } - ] - } - } - } - } - }, - { - "label": "valid text is unset", - "search": "color=\"\"", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "exists": { - "field": "fields.text" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid text =", - "search": "color=red", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "term": { - "fields.text": "red" - } - } - ] - } - } - } - } - }, - { - "label": "valid text !=", - "search": "color != red", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "term": { - "fields.text": "red" - } - }, - { - "exists": { - "field": "fields.text" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "invalid text >", - "search": "color > red", - "error": "comparisons with > can only be used with date and number fields" - }, - { - "label": "invalid text <", - "search": "color < red", - "error": "comparisons with < can only be used with date and number fields" - }, - { - "label": "invalid text < \"\"", - "search": "color < \"\"", - "error": "comparisons with < can only be used with date and number fields" - }, - { - "label": "invalid text > \"\"", - "search": "color > \"\"", - "error": "comparisons with > can only be used with date and number fields" - }, - { - "label": "valid number is set", - "search": "age!=\"\"", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "exists": { - "field": "fields.number" - } - } - ] - } - } - } - } - }, - { - "label": "valid number is unset", - "search": "age=\"\"", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "exists": { - "field": "fields.number" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid number =", - "search": "age=10", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "match": { - "fields.number": { - "query": "10" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid number !=", - "search": "age!=10", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "match": { - "fields.number": { - "query": "10" - } - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid number <=", - "search": "age<=10", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": null, - "include_lower": true, - "include_upper": true, - "to": "10" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid number >=", - "search": "age>=10", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": "10", - "include_lower": true, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - }, - { - "label": "valid number <", - "search": "age<10", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": null, - "include_lower": true, - "include_upper": false, - "to": "10" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid number >", - "search": "age>10", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": "10", - "include_lower": false, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - }, - { - "label": "invalid number operand", - "search": "age=fred", - "error": "can't convert 'fred' to a number" - }, - { - "label": "invalid datetime operand", - "search": "dob=10", - "error": "string '10' couldn't be parsed as a date" - }, - { - "label": "invalid number >", - "search": "age > \"\"", - "error": "can't convert '' to a number" - }, - { - "label": "valid date is set", - "search": "dob!=\"\"", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "exists": { - "field": "fields.datetime" - } - } - ] - } - } - } - } - }, - { - "label": "valid date is unset", - "search": "dob=\"\"", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "exists": { - "field": "fields.datetime" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid date =", - "search": "dob=2018-06-23", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid date !=", - "search": "dob!=2018-06-23", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid date >", - "search": "dob>2018-06-23", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": "2018-06-24T00:00:00-04:00", - "include_lower": true, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - }, - { - "label": "valid date >=", - "search": "dob>=2018-06-23", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - }, - { - "label": "valid date <", - "search": "dob<2018-06-23", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": null, - "include_lower": true, - "include_upper": false, - "to": "2018-06-23T00:00:00-04:00" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid date <=", - "search": "dob<=2018-06-23", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "cbd3fc0e-9b74-4207-a8c7-248082bb4572" - } - }, - { - "range": { - "fields.datetime": { - "from": null, - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - ] - } - } - } - } - }, - { - "label": "valid implicit name", - "search": "will", - "query": { - "match": { - "name": { - "query": "will" - } - } - } - }, - { - "label": "valid implicit name", - "search": "will", - "is_anon": true, - "query": { - "match": { - "name": { - "query": "will" - } - } - } - }, - { - "label": "valid implicit id (anon)", - "search": "7979", - "is_anon": true, - "query": { - "ids": { - "values": [ - "7979" - ] - } - } - }, - { - "label": "valid implicit tel (normal)", - "search": "7979", - "query": { - "nested": { - "path": "urns", - "query": { - "bool": { - "must": [ - { - "match_phrase": { - "urns.path": { - "query": "7979" - } - } - }, - { - "term": { - "urns.scheme": "tel" - } - } - ] - } - } - } - } - }, - { - "label": "valid state is set", - "search": "state!=\"\"", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "67663ad1-3abc-42dd-a162-09df2dea66ec" - } - }, - { - "exists": { - "field": "fields.state" - } - } - ] - } - } - } - } - }, - { - "label": "valid state is unset", - "search": "state=\"\"", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "67663ad1-3abc-42dd-a162-09df2dea66ec" - } - }, - { - "exists": { - "field": "fields.state" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "valid state =", - "search": "state=washington", - "query": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "67663ad1-3abc-42dd-a162-09df2dea66ec" - } - }, - { - "term": { - "fields.state_keyword": "washington" - } - } - ] - } - } - } - } - }, - { - "label": "valid state !=", - "search": "state!=washington", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.state_keyword": "washington" - } - }, - { - "exists": { - "field": "fields.state_keyword" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "invalid state <", - "search": "state", - "search": "name>chef", - "error": "comparisons with > can only be used with date and number fields" - }, - { - "label": "valid uuid =", - "search": "uuid=bbe6dba0-818b-4c5a-be51-10432095e27a", - "query": { - "term": { - "uuid": "bbe6dba0-818b-4c5a-be51-10432095e27a" - } - } - }, - { - "label": "valid uuid !=", - "search": "uuid!=bbe6dba0-818b-4c5a-be51-10432095e27a", - "query": { - "bool": { - "must_not": { - "term": { - "uuid": "bbe6dba0-818b-4c5a-be51-10432095e27a" - } - } - } - } - }, - { - "label": "valid id =", - "search": "id=123", - "query": { - "ids": { - "values": [ - "123" - ] - } - } - }, - { - "label": "valid id !=", - "search": "id!=123", - "query": { - "bool": { - "must_not": { - "ids": { - "values": [ - "123" - ] - } - } - } - } - }, - { - "label": "valid language is unset", - "search": "language=\"\"", - "query": { - "bool": { - "must_not": { - "bool": { - "must": [ - { - "exists": { - "field": "language" - } - }, - { - "bool": { - "must_not": { - "term": { - "language.keyword": "" - } - } - } - } - ] - } - } - } - } - }, - { - "label": "valid language is set", - "search": "language!=\"\"", - "query": { - "bool": { - "must": [ - { - "exists": { - "field": "language" - } - }, - { - "bool": { - "must_not": { - "term": { - "language.keyword": "" - } - } - } - } - ] - } - } - }, - { - "label": "valid language =", - "search": "language=spa", - "query": { - "term": { - "language": "spa" - } - } - }, - { - "label": "valid language !=", - "search": "language!=fra", - "query": { - "bool": { - "must_not": { - "term": { - "language": "fra" - } - } - } - } - }, - { - "label": "invalid language =", - "search": "language=dog", - "error": "'dog' is not a valid language code" - }, - { - "label": "invalid language !=", - "search": "language!=dog", - "error": "'dog' is not a valid language code" - }, - { - "label": "invalid language >", - "search": "language>dog", - "error": "comparisons with > can only be used with date and number fields" - }, - { - "label": "valid created_on >", - "search": "created_on>2018-06-23", - "query": { - "range": { - "created_on": { - "from": "2018-06-24T00:00:00-04:00", - "include_lower": true, - "include_upper": true, - "to": null - } - } - } - }, - { - "label": "valid created_on >=", - "search": "created_on>=2018-06-23", - "query": { - "range": { - "created_on": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": true, - "to": null - } - } - } - }, - { - "label": "valid created_on <", - "search": "created_on<2018-06-23", - "query": { - "range": { - "created_on": { - "from": null, - "include_lower": true, - "include_upper": false, - "to": "2018-06-23T00:00:00-04:00" - } - } - } - }, - { - "label": "valid created_on <=", - "search": "created_on<=2018-06-23", - "query": { - "range": { - "created_on": { - "from": null, - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - }, - { - "label": "valid created_on =", - "search": "created_on=2018-06-23", - "query": { - "range": { - "created_on": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - }, - { - "label": "valid created_on !=", - "search": "created_on!=2018-06-23", - "query": { - "bool": { - "must_not": { - "range": { - "created_on": { - "from": "2018-06-23T00:00:00-04:00", - "include_lower": true, - "include_upper": false, - "to": "2018-06-24T00:00:00-04:00" - } - } - } - } - } - }, - { - "label": "invalid created_on operand", - "search": "created_on", - "search": "tel>12345", - "error": "comparisons with > can only be used with date and number fields" - }, - { - "label": "valid tel is set on anon org", - "search": "tel!=\"\"", - "is_anon": true, - "query": { - "nested": { - "path": "urns", - "query": { - "bool": { - "must": [ - { - "term": { - "urns.scheme": "tel" - } - }, - { - "exists": { - "field": "urns.path" - } - } - ] - } - } - } - } - }, - { - "label": "valid tel is unset on anon org", - "search": "tel=\"\"", - "is_anon": true, - "query": { - "bool": { - "must_not": { - "nested": { - "path": "urns", - "query": { - "bool": { - "must": [ - { - "term": { - "urns.scheme": "tel" - } - }, - { - "exists": { - "field": "urns.path" - } - } - ] - } - } - } - } - } - } - }, - { - "label": "tel on anon org", - "search": "tel=12345", - "is_anon": true, - "error": "cannot query on redacted URNs" - }, - { - "label": "unsupported field", - "search": "unsupported=12345", - "error": "can't resolve 'unsupported' to attribute, scheme or field" - }, - { - "label": "bool and", - "search": "color=red and age>10", - "query": { - "bool": { - "must": [ - { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "term": { - "fields.text": "red" - } - } - ] - } - } - } - }, - { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": "10", - "include_lower": false, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - ] - } - } - }, - { - "label": "bool or", - "search": "color=red or age>10", - "query": { - "bool": { - "should": [ - { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "ecc7b13b-c698-4f46-8a90-24a8fab6fe34" - } - }, - { - "term": { - "fields.text": "red" - } - } - ] - } - } - } - }, - { - "nested": { - "path": "fields", - "query": { - "bool": { - "must": [ - { - "term": { - "fields.field": "6b6a43fa-a26d-4017-bede-328bcdd5c93b" - } - }, - { - "range": { - "fields.number": { - "from": "10", - "include_lower": false, - "include_upper": true, - "to": null - } - } - } - ] - } - } - } - } - ] - } - } - }, - { - "label": "urn is unset", - "search": "urn=\"\"", - "query": { - "bool": { - "must_not": { - "nested": { - "path": "urns", - "query": { - "exists": { - "field": "urns.path" - } - } - } - } - } - } - }, - { - "label": "urn is set", - "search": "urn !=\"\"", - "query": { - "nested": { - "path": "urns", - "query": { - "exists": { - "field": "urns.path" - } - } - } - } - }, - { - "label": "urn is number", - "search": "urn=\"+12067799192\"", - "query": { - "nested": { - "path": "urns", - "query": { - "term": { - "urns.path.keyword": "+12067799192" - } - } - } - } - }, - { - "label": "urn contains 12345", - "search": "urn~12345", - "query": { - "nested": { - "path": "urns", - "query": { - "match_phrase": { - "urns.path": { - "query": "12345" - } - } - } - } - } - }, - { - "label": "group = valid group name", - "search": "group = \"U-Reporters\"", - "query": { - "term": { - "groups": "8de30b78-d9ef-4db2-b2e8-4f7b6aef64cf" - } - } - }, - { - "label": "group = invalid group name", - "search": "group = \"Spammers\"", - "error": "'Spammers' is not a valid group name" - }, - { - "label": "group not equals a valid name", - "search": "group != \"U-Reporters\"", - "query": { - "bool": { - "must_not": { - "term": { - "groups": "8de30b78-d9ef-4db2-b2e8-4f7b6aef64cf" - } - } - } - } - }, - { - "label": "group != invalid group name", - "search": "group != \"Spammers\"", - "error": "'Spammers' is not a valid group name" - } -] \ No newline at end of file diff --git a/tasks/starts/worker_test.go b/tasks/starts/worker_test.go index 106f9329a..e0c074de6 100644 --- a/tasks/starts/worker_test.go +++ b/tasks/starts/worker_test.go @@ -10,8 +10,8 @@ import ( "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/queue" "github.com/nyaruka/mailroom/runner" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/mailroom/testsuite" + "github.com/olivere/elastic" "github.com/stretchr/testify/assert" ) @@ -24,7 +24,7 @@ func TestStarts(t *testing.T) { rc := testsuite.RC() defer rc.Close() - mes := search.NewMockElasticServer() + mes := testsuite.NewMockElasticServer() defer mes.Close() es, err := elastic.NewClient( diff --git a/search/mock.go b/testsuite/elastic.go similarity index 98% rename from search/mock.go rename to testsuite/elastic.go index fd824ced5..5f026ef4b 100644 --- a/search/mock.go +++ b/testsuite/elastic.go @@ -1,4 +1,4 @@ -package search +package testsuite import ( "io/ioutil" diff --git a/web/contact/contact.go b/web/contact/contact.go index b68d8560b..fd3e3eac4 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -6,12 +6,13 @@ import ( "net/http" "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/contactql" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions/modifiers" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/models" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" ) @@ -82,27 +83,34 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac if err != nil { switch cause := errors.Cause(err).(type) { - case *search.Error: + case *contactql.QueryError: return cause, http.StatusBadRequest, nil default: return nil, http.StatusInternalServerError, err } } - // create our normalized query + // normalize and inspect the query normalized := "" + allowAsGroup := false + fields := make([]string, 0) + if parsed != nil { normalized = parsed.String() + inspection := contactql.Inspect(parsed) + fields = append(fields, inspection.Attributes...) + for _, f := range inspection.Fields { + fields = append(fields, f.Key) + } + allowAsGroup = inspection.AllowAsGroup } - fields := search.FieldDependencies(parsed) - // build our response response := &searchResponse{ Query: normalized, ContactIDs: hits, Fields: fields, - AllowAsGroup: search.AllowAsGroup(fields), + AllowAsGroup: allowAsGroup, Total: total, Offset: request.Offset, Sort: request.Sort, @@ -153,20 +161,31 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } - parsed, err := search.ParseQuery(org.Env(), org.SessionAssets(), request.Query) + env := org.Env() + parsed, err := contactql.ParseQuery(request.Query, env.RedactionPolicy(), env.DefaultCountry(), org.SessionAssets()) if err != nil { switch cause := errors.Cause(err).(type) { - case *search.Error: + case *contactql.QueryError: return cause, http.StatusBadRequest, nil default: return nil, http.StatusInternalServerError, err } } + // normalize and inspect the query normalized := "" + allowAsGroup := false + fields := make([]string, 0) + if parsed != nil { normalized = parsed.String() + inspection := contactql.Inspect(parsed) + fields = append(fields, inspection.Attributes...) + for _, f := range inspection.Fields { + fields = append(fields, f.Key) + } + allowAsGroup = inspection.AllowAsGroup } eq, err := models.BuildElasticQuery(org, request.GroupUUID, parsed) @@ -178,14 +197,12 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte return nil, http.StatusInternalServerError, err } - fields := search.FieldDependencies(parsed) - // build our response response := &parseResponse{ Query: normalized, Fields: fields, ElasticQuery: eqj, - AllowAsGroup: search.AllowAsGroup(fields), + AllowAsGroup: allowAsGroup, } return response, http.StatusOK, nil diff --git a/web/contact/contact_test.go b/web/contact/contact_test.go index 916ec1d6a..1f2181379 100644 --- a/web/contact/contact_test.go +++ b/web/contact/contact_test.go @@ -15,9 +15,9 @@ import ( "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/hooks" "github.com/nyaruka/mailroom/models" - "github.com/nyaruka/mailroom/search" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/web" + "github.com/olivere/elastic" "github.com/stretchr/testify/assert" ) @@ -29,7 +29,7 @@ func TestSearch(t *testing.T) { rp := testsuite.RP() wg := &sync.WaitGroup{} - es := search.NewMockElasticServer() + es := testsuite.NewMockElasticServer() defer es.Close() client, err := elastic.NewClient( From 4fbb015a612eb5b2964ecc7eb6ee265c11550602 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 15 Jun 2020 15:45:04 -0500 Subject: [PATCH 18/55] Return query inspection results as new metadata field in responses --- web/contact/contact.go | 72 +++++++++++++++++---------- web/contact/testdata/parse_query.json | 56 +++++++++++++++++---- 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/web/contact/contact.go b/web/contact/contact.go index fd3e3eac4..da83519f4 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -45,19 +45,26 @@ type searchRequest struct { // { // "query": "age > 10", // "contact_ids": [5,10,15], -// "fields": ["age"], -// "allow_as_group": true, // "total": 3, -// "offset": 0 +// "offset": 0, +// "metadata": { +// "fields": [ +// {"key": "age", "name": "Age"} +// ], +// "allow_as_group": true +// } // } type searchResponse struct { - Query string `json:"query"` - ContactIDs []models.ContactID `json:"contact_ids"` - Fields []string `json:"fields"` - AllowAsGroup bool `json:"allow_as_group"` - Total int64 `json:"total"` - Offset int `json:"offset"` - Sort string `json:"sort"` + Query string `json:"query"` + ContactIDs []models.ContactID `json:"contact_ids"` + Total int64 `json:"total"` + Offset int `json:"offset"` + Sort string `json:"sort"` + Metadata *contactql.Inspection `json:"metadata,omitempty"` + + // deprecated + Fields []string `json:"fields"` + AllowAsGroup bool `json:"allow_as_group"` } // handles a contact search request @@ -92,28 +99,30 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac // normalize and inspect the query normalized := "" + var metadata *contactql.Inspection allowAsGroup := false fields := make([]string, 0) if parsed != nil { normalized = parsed.String() - inspection := contactql.Inspect(parsed) - fields = append(fields, inspection.Attributes...) - for _, f := range inspection.Fields { + metadata = contactql.Inspect(parsed) + fields = append(fields, metadata.Attributes...) + for _, f := range metadata.Fields { fields = append(fields, f.Key) } - allowAsGroup = inspection.AllowAsGroup + allowAsGroup = metadata.AllowAsGroup } // build our response response := &searchResponse{ Query: normalized, ContactIDs: hits, - Fields: fields, - AllowAsGroup: allowAsGroup, Total: total, Offset: request.Offset, Sort: request.Sort, + Metadata: metadata, + Fields: fields, + AllowAsGroup: allowAsGroup, } return response, http.StatusOK, nil @@ -137,15 +146,22 @@ type parseRequest struct { // // { // "query": "age > 10", -// "fields": ["age"], // "elastic_query": { .. }, -// "allow_as_group": true +// "metadata": { +// "fields": [ +// {"key": "age", "name": "Age"} +// ], +// "allow_as_group": true +// } // } type parseResponse struct { - Query string `json:"query"` - Fields []string `json:"fields"` - ElasticQuery interface{} `json:"elastic_query"` - AllowAsGroup bool `json:"allow_as_group"` + Query string `json:"query"` + ElasticQuery interface{} `json:"elastic_query"` + Metadata *contactql.Inspection `json:"metadata,omitempty"` + + // deprecated + Fields []string `json:"fields"` + AllowAsGroup bool `json:"allow_as_group"` } // handles a query parsing request @@ -175,17 +191,18 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte // normalize and inspect the query normalized := "" + var metadata *contactql.Inspection allowAsGroup := false fields := make([]string, 0) if parsed != nil { normalized = parsed.String() - inspection := contactql.Inspect(parsed) - fields = append(fields, inspection.Attributes...) - for _, f := range inspection.Fields { + metadata = contactql.Inspect(parsed) + fields = append(fields, metadata.Attributes...) + for _, f := range metadata.Fields { fields = append(fields, f.Key) } - allowAsGroup = inspection.AllowAsGroup + allowAsGroup = metadata.AllowAsGroup } eq, err := models.BuildElasticQuery(org, request.GroupUUID, parsed) @@ -200,8 +217,9 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte // build our response response := &parseResponse{ Query: normalized, - Fields: fields, ElasticQuery: eqj, + Metadata: metadata, + Fields: fields, AllowAsGroup: allowAsGroup, } diff --git a/web/contact/testdata/parse_query.json b/web/contact/testdata/parse_query.json index 55cdb57a0..a6d1223a6 100644 --- a/web/contact/testdata/parse_query.json +++ b/web/contact/testdata/parse_query.json @@ -33,9 +33,6 @@ "status": 200, "response": { "query": "age \u003e 10", - "fields": [ - "age" - ], "elastic_query": { "bool": { "must": [ @@ -78,6 +75,21 @@ ] } }, + "metadata": { + "attributes": [], + "schemes": [], + "fields": [ + { + "key": "age", + "name": "Age" + } + ], + "groups": [], + "allow_as_group": true + }, + "fields": [ + "age" + ], "allow_as_group": true } }, @@ -93,9 +105,6 @@ "status": 200, "response": { "query": "age \u003e 10", - "fields": [ - "age" - ], "elastic_query": { "bool": { "must": [ @@ -143,6 +152,21 @@ ] } }, + "metadata": { + "attributes": [], + "schemes": [], + "fields": [ + { + "key": "age", + "name": "Age" + } + ], + "groups": [], + "allow_as_group": true + }, + "fields": [ + "age" + ], "allow_as_group": true } }, @@ -157,9 +181,6 @@ "status": 200, "response": { "query": "group = \"Testers\"", - "fields": [ - "group" - ], "elastic_query": { "bool": { "must": [ @@ -181,6 +202,23 @@ ] } }, + "metadata": { + "attributes": [ + "group" + ], + "schemes": [], + "fields": [], + "groups": [ + { + "uuid": "5e9d8fab-5e7e-4f51-b533-261af5dea70d", + "name": "Testers" + } + ], + "allow_as_group": false + }, + "fields": [ + "group" + ], "allow_as_group": false } } From 12d221e594765fdc8219aca68f65d0c056731be8 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 16 Jun 2020 09:51:10 -0500 Subject: [PATCH 19/55] Update CHANGELOG.md for v5.5.29 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdac9aad..722b025a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v5.5.29 +---------- + * Return query inspection results as new metadata field in responses + * Update to latest goflow v0.92.0 + v5.5.28 ---------- * Don't do any decoration of email ticket subjects From 39b0289491fcb610fdd473c162540e578e4a9b96 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 17 Jun 2020 11:47:33 -0500 Subject: [PATCH 20/55] Rework loading of orgs to make it easier to add more fields --- models/orgs.go | 111 ++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/models/orgs.go b/models/orgs.go index 68fdb6ab7..d51060cec 100644 --- a/models/orgs.go +++ b/models/orgs.go @@ -11,6 +11,7 @@ import ( "github.com/nyaruka/goflow/services/airtime/dtone" "github.com/nyaruka/goflow/services/email/smtp" "github.com/nyaruka/goflow/utils/httpx" + "github.com/nyaruka/goflow/utils/jsonx" "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/goflow" "github.com/nyaruka/null" @@ -60,13 +61,15 @@ const ( // Org is mailroom's type for RapidPro orgs. It also implements the envs.Environment interface for GoFlow type Org struct { - id OrgID - env envs.Environment - config map[string]interface{} + o struct { + ID OrgID `json:"id"` + Config null.Map `json:"config"` + } + env envs.Environment } // ID returns the id of the org -func (o *Org) ID() OrgID { return o.id } +func (o *Org) ID() OrgID { return o.o.ID } // DateFormat returns the date format for this org func (o *Org) DateFormat() envs.DateFormat { return o.env.DateFormat() } @@ -109,23 +112,23 @@ func (o *Org) MarshalJSON() ([]byte, error) { return json.Marshal(o.env) } -// ConfigValue returns the string value for the passed in config (or default if not found) -func (o *Org) ConfigValue(key string, def string) string { - if o.config == nil { - return def - } - - val, found := o.config[key] - if !found { - return def +// UnmarshalJSON is our custom unmarshaller +func (o *Org) UnmarshalJSON(b []byte) error { + err := jsonx.Unmarshal(b, &o.o) + if err != nil { + return err } - strVal, isStr := val.(string) - if !isStr { - return def + o.env, err = envs.ReadEnvironment(b) + if err != nil { + return err } + return nil +} - return strVal +// ConfigValue returns the string value for the passed in config (or default if not found) +func (o *Org) ConfigValue(key string, def string) string { + return o.o.Config.GetString(key, def) } // EmailService returns the email service for this org @@ -160,8 +163,7 @@ func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) { start := time.Now() org := &Org{} - var orgJSON, orgConfig json.RawMessage - rows, err := db.Query(selectOrgEnvironment, orgID, config.Mailroom.MaxValueLength) + rows, err := db.Queryx(selectOrgByID, orgID, config.Mailroom.MaxValueLength) if err != nil { return nil, errors.Wrapf(err, "error loading org: %d", orgID) } @@ -170,20 +172,9 @@ func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) { return nil, errors.Errorf("no org with id: %d", orgID) } - err = rows.Scan(&org.id, &orgConfig, &orgJSON) - if err != nil { - return nil, errors.Wrapf(err, "error scanning org: %d", orgID) - } - - org.env, err = envs.ReadEnvironment(orgJSON) + err = readJSONRow(rows, org) if err != nil { - return nil, errors.Wrapf(err, "error unmarshalling org json: %s", orgJSON) - } - - org.config = make(map[string]interface{}) - err = json.Unmarshal(orgConfig, &org.config) - if err != nil { - return nil, errors.Wrapf(err, "error unmarshalling org config: %s", orgConfig) + return nil, errors.Wrapf(err, "error unmarshalling org") } logrus.WithField("elapsed", time.Since(start)).WithField("org_id", orgID).Debug("loaded org environment") @@ -191,38 +182,34 @@ func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) { return org, nil } -const selectOrgEnvironment = ` -SELECT id, config, ROW_TO_JSON(o) FROM (SELECT +const selectOrgByID = ` +SELECT ROW_TO_JSON(o) FROM (SELECT id, - COALESCE(o.config::json,'{}'::json) as config, - (SELECT CASE date_format - WHEN 'D' THEN 'DD-MM-YYYY' - WHEN 'M' THEN 'MM-DD-YYYY' - END) date_format, - 'tt:mm' as time_format, + COALESCE(o.config::json,'{}'::json) AS config, + (SELECT CASE date_format WHEN 'D' THEN 'DD-MM-YYYY' WHEN 'M' THEN 'MM-DD-YYYY' END) AS date_format, + 'tt:mm' AS time_format, timezone, - (SELECT CASE is_anon - WHEN TRUE THEN 'urns' - WHEN FALSE THEN 'none' - END) redaction_policy, - $2::int as max_value_length, - (SELECT iso_code FROM orgs_language WHERE id = o.primary_language_id) as default_language, - (SELECT ARRAY_AGG(iso_code) FROM orgs_language WHERE org_id = o.id) allowed_languages, - COALESCE((SELECT - country - FROM - channels_channel c - WHERE - c.org_id = o.id AND - c.is_active = TRUE AND - c.country IS NOT NULL - GROUP BY - c.country - ORDER BY - count(c.country) desc, - country - LIMIT 1 - ), '') default_country + (SELECT CASE is_anon WHEN TRUE THEN 'urns' WHEN FALSE THEN 'none' END) AS redaction_policy, + $2::int AS max_value_length, + (SELECT iso_code FROM orgs_language WHERE id = o.primary_language_id) AS default_language, + (SELECT ARRAY_AGG(iso_code) FROM orgs_language WHERE org_id = o.id) AS allowed_languages, + COALESCE( + ( + SELECT + country + FROM + channels_channel c + WHERE + c.org_id = o.id AND + c.is_active = TRUE AND + c.country IS NOT NULL + GROUP BY + c.country + ORDER BY + count(c.country) desc, + country + LIMIT 1 + ), '') AS default_country FROM orgs_org o WHERE From e3e25e5f88f1b1c0bcceeda81413d7fd8c65e5d9 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 17 Jun 2020 11:59:48 -0500 Subject: [PATCH 21/55] Add new fields to Org model --- mailroom_test.dump | Bin 1840270 -> 1841016 bytes models/orgs.go | 14 ++++++++++++-- models/{env_test.go => orgs_test.go} | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) rename models/{env_test.go => orgs_test.go} (96%) diff --git a/mailroom_test.dump b/mailroom_test.dump index 546815a558fa429a8b66fdc601bb5db69994cf45..66d06d9e180355ee4fe8bfd974a4edf3eecb8062 100644 GIT binary patch delta 83208 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%0D;_8S8YmmrG;=wbj#AP85xJCs5KzSI@GN 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 diff --git a/models/orgs.go b/models/orgs.go index d51060cec..4f5735a15 100644 --- a/models/orgs.go +++ b/models/orgs.go @@ -62,8 +62,10 @@ const ( // Org is mailroom's type for RapidPro orgs. It also implements the envs.Environment interface for GoFlow type Org struct { o struct { - ID OrgID `json:"id"` - Config null.Map `json:"config"` + ID OrgID `json:"id"` + Suspended bool `json:"is_suspended"` + UsesTopups bool `json:"uses_topups"` + Config null.Map `json:"config"` } env envs.Environment } @@ -71,6 +73,12 @@ type Org struct { // ID returns the id of the org func (o *Org) ID() OrgID { return o.o.ID } +// Suspended returns whether the org has been suspended +func (o *Org) Suspended() bool { return o.o.Suspended } + +// UsesTopups returns whether the org uses topups +func (o *Org) UsesTopups() bool { return o.o.UsesTopups } + // DateFormat returns the date format for this org func (o *Org) DateFormat() envs.DateFormat { return o.env.DateFormat() } @@ -185,6 +193,8 @@ func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) { const selectOrgByID = ` SELECT ROW_TO_JSON(o) FROM (SELECT id, + is_suspended, + uses_topups, COALESCE(o.config::json,'{}'::json) AS config, (SELECT CASE date_format WHEN 'D' THEN 'DD-MM-YYYY' WHEN 'M' THEN 'MM-DD-YYYY' END) AS date_format, 'tt:mm' AS time_format, diff --git a/models/env_test.go b/models/orgs_test.go similarity index 96% rename from models/env_test.go rename to models/orgs_test.go index a05c9e604..363d04692 100644 --- a/models/env_test.go +++ b/models/orgs_test.go @@ -31,6 +31,8 @@ func TestOrgs(t *testing.T) { assert.NoError(t, err) assert.Equal(t, OrgID(1), org.ID()) + assert.False(t, org.Suspended()) + assert.True(t, org.UsesTopups()) assert.Equal(t, envs.DateFormatDayMonthYear, org.DateFormat()) assert.Equal(t, envs.TimeFormatHourMinute, org.TimeFormat()) assert.Equal(t, envs.RedactionPolicyNone, org.RedactionPolicy()) From 6b47ed4aa6044d78ccc0aad9cde0ae55441fe2c6 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 17 Jun 2020 13:28:10 -0500 Subject: [PATCH 22/55] Make decrementing org credit optional --- hooks/ivr_created.go | 4 ++-- hooks/msg_created.go | 4 ++-- ivr/ivr.go | 16 +++++++-------- models/msgs.go | 30 ++++++++++++++-------------- models/topups.go | 21 ++++++++++++-------- models/topups_test.go | 5 ++++- tasks/handler/worker.go | 44 ++++++++++++++++++++--------------------- 7 files changed, 66 insertions(+), 58 deletions(-) diff --git a/hooks/ivr_created.go b/hooks/ivr_created.go index 7734a0d7e..1308559f6 100644 --- a/hooks/ivr_created.go +++ b/hooks/ivr_created.go @@ -22,7 +22,7 @@ type CommitIVRHook struct{} var commitIVRHook = &CommitIVRHook{} // Apply takes care of inserting all the messages in the passed in scene assigning topups to them as needed. -func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { msgs := make([]*models.Msg, 0, len(scenes)) for _, s := range scenes { for _, m := range s { @@ -32,7 +32,7 @@ func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, // find the topup we will assign rc := rp.Get() - topup, err := models.DecrementOrgCredits(ctx, tx, rc, org.OrgID(), len(msgs)) + topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { return errors.Wrapf(err, "error finding active topup") diff --git a/hooks/msg_created.go b/hooks/msg_created.go index e2ae97cd2..3b2d0b1ec 100644 --- a/hooks/msg_created.go +++ b/hooks/msg_created.go @@ -141,7 +141,7 @@ type CommitMessagesHook struct{} var commitMessagesHook = &CommitMessagesHook{} // Apply takes care of inserting all the messages in the passed in scene assigning topups to them as needed. -func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { msgs := make([]*models.Msg, 0, len(scenes)) for _, s := range scenes { for _, m := range s { @@ -151,7 +151,7 @@ func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.P // find the topup we will assign rc := rp.Get() - topup, err := models.DecrementOrgCredits(ctx, tx, rc, org.OrgID(), len(msgs)) + topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { return errors.Wrapf(err, "error finding active topup") diff --git a/ivr/ivr.go b/ivr/ivr.go index 904037ef9..7dfd9b031 100644 --- a/ivr/ivr.go +++ b/ivr/ivr.go @@ -404,15 +404,15 @@ func StartIVRFlow( func ResumeIVRFlow( ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, s3Client s3iface.S3API, resumeURL string, client Client, - org *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, + oa *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, r *http.Request, w http.ResponseWriter) error { - contact, err := c.FlowContact(org) + contact, err := c.FlowContact(oa) if err != nil { return errors.Wrapf(err, "error creating flow contact") } - session, err := models.ActiveSessionForContact(ctx, db, org, models.IVRFlow, contact) + session, err := models.ActiveSessionForContact(ctx, db, oa, models.IVRFlow, contact) if err != nil { return errors.Wrapf(err, "error loading session for contact") } @@ -494,7 +494,7 @@ func ResumeIVRFlow( // filename is based on our org id and msg UUID filename := string(msgUUID) + path.Ext(attachment.URL()) - path := filepath.Join(config.S3MediaPrefix, fmt.Sprintf("%d", org.OrgID()), filename[:4], filename[4:8], filename) + path := filepath.Join(config.S3MediaPrefix, fmt.Sprintf("%d", oa.OrgID()), filename[:4], filename[4:8], filename) if !strings.HasPrefix(path, "/") { path = fmt.Sprintf("/%s", path) } @@ -518,11 +518,11 @@ func ResumeIVRFlow( msgIn := flows.NewMsgIn(msgUUID, urn, channel.ChannelReference(), input, attachments) // create an incoming message - msg := models.NewIncomingIVR(org.OrgID(), conn, msgIn, time.Now()) + msg := models.NewIncomingIVR(oa.OrgID(), conn, msgIn, time.Now()) // find a topup rc := rp.Get() - topupID, err := models.DecrementOrgCredits(ctx, db, rc, org.OrgID(), 1) + topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) rc.Close() // error or no topup, that's an end of call @@ -541,7 +541,7 @@ func ResumeIVRFlow( } // create our msg resume event - resume := resumes.NewMsg(org.Env(), contact, msgIn) + resume := resumes.NewMsg(oa.Env(), contact, msgIn) // hook to set our connection on our session before our event hooks run hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { @@ -560,7 +560,7 @@ func ResumeIVRFlow( } } - session, err = runner.ResumeFlow(ctx, db, rp, org, session, resume, hook) + session, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook) if err != nil { return errors.Wrapf(err, "error resuming ivr flow") } diff --git a/models/msgs.go b/models/msgs.go index 86d9d86b1..3f823722f 100644 --- a/models/msgs.go +++ b/models/msgs.go @@ -730,7 +730,7 @@ func (b *BroadcastBatch) SetIsLast(last bool) { b.b.IsLast = last } func (b *BroadcastBatch) MarshalJSON() ([]byte, error) { return json.Marshal(b.b) } func (b *BroadcastBatch) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &b.b) } -func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, org *OrgAssets, bcast *BroadcastBatch) ([]*Msg, error) { +func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa *OrgAssets, bcast *BroadcastBatch) ([]*Msg, error) { repeatedContacts := make(map[ContactID]bool) broadcastURNs := bcast.URNs() @@ -757,12 +757,12 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or } // load all our contacts - contacts, err := LoadContacts(ctx, db, org, contactIDs) + contacts, err := LoadContacts(ctx, db, oa, contactIDs) if err != nil { return nil, errors.Wrapf(err, "error loading contacts for broadcast") } - channels := org.SessionAssets().Channels() + channels := oa.SessionAssets().Channels() // for each contact, build our message msgs := make([]*Msg, 0, len(contacts)) @@ -773,7 +773,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or return nil, nil } - contact, err := c.FlowContact(org) + contact, err := c.FlowContact(oa) if err != nil { return nil, errors.Wrapf(err, "error creating flow contact") } @@ -790,7 +790,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or return nil, nil } urn = u.URN() - channel = org.ChannelByUUID(c.UUID()) + channel = oa.ChannelByUUID(c.UUID()) break } } @@ -800,7 +800,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or c := channels.GetForURN(u, assets.ChannelRoleSend) if c != nil { urn = u.URN() - channel = org.ChannelByUUID(c.UUID()) + channel = oa.ChannelByUUID(c.UUID()) break } } @@ -818,7 +818,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or lang := contact.Language() if lang != envs.NilLanguage { found := false - for _, l := range org.Env().AllowedLanguages() { + for _, l := range oa.Env().AllowedLanguages() { if l == lang { found = true break @@ -835,7 +835,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or // not found? try org default language if t == nil { - t = trans[org.Env().DefaultLanguage()] + t = trans[oa.Env().DefaultLanguage()] } // not found? use broadcast base language @@ -863,12 +863,12 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or if template != "" { // build up the minimum viable context for templates templateCtx := types.NewXObject(map[string]types.XValue{ - "contact": flows.Context(org.Env(), contact), - "fields": flows.Context(org.Env(), contact.Fields()), - "globals": flows.Context(org.Env(), org.SessionAssets().Globals()), - "urns": flows.ContextFunc(org.Env(), contact.URNs().MapContext), + "contact": flows.Context(oa.Env(), contact), + "fields": flows.Context(oa.Env(), contact.Fields()), + "globals": flows.Context(oa.Env(), oa.SessionAssets().Globals()), + "urns": flows.ContextFunc(oa.Env(), contact.URNs().MapContext), }) - text, _ = excellent.EvaluateTemplate(org.Env(), templateCtx, template, nil) + text, _ = excellent.EvaluateTemplate(oa.Env(), templateCtx, template, nil) } // don't do anything if we have no text or attachments @@ -878,7 +878,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or // create our outgoing message out := flows.NewMsgOut(urn, channel.ChannelReference(), text, t.Attachments, t.QuickReplies, nil, flows.NilMsgTopic) - msg, err := NewOutgoingMsg(org.OrgID(), channel, c.ID(), out, time.Now()) + msg, err := NewOutgoingMsg(oa.OrgID(), channel, c.ID(), out, time.Now()) msg.SetBroadcastID(bcast.BroadcastID()) if err != nil { return nil, errors.Wrapf(err, "error creating outgoing message") @@ -915,7 +915,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, or // get a topup to assign to our messages rc := rp.Get() - topup, err := DecrementOrgCredits(ctx, db, rc, org.OrgID(), len(msgs)) + topup, err := AllocateTopups(ctx, db, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { return nil, errors.Wrapf(err, "error finding active topup") diff --git a/models/topups.go b/models/topups.go index deb3d103f..75fe8d37a 100644 --- a/models/topups.go +++ b/models/topups.go @@ -25,11 +25,16 @@ const ( redisCreditsRemainingKey = `org:%d:cache:credits_remaining:%d` ) -// DecrementOrgCredits decrements the credits by the passed amount for the passed in org, passing back the id -// of the topup that should be assigned to the messages using those credits. -func DecrementOrgCredits(ctx context.Context, db Queryer, rc redis.Conn, orgID OrgID, amount int) (TopupID, error) { +// AllocateTopups allocates topups for the given number of messages if topups are used by the org. +// If topups are allocated it will return the ID of the topup to assign to those messages. +func AllocateTopups(ctx context.Context, db Queryer, rc redis.Conn, org *Org, amount int) (TopupID, error) { + // if org doesn't use topups, do nothing + if !org.UsesTopups() { + return NilTopupID, nil + } + // no matter what we decrement our org credit - topups, err := redis.Ints(decrementCreditLua.Do(rc, orgID, amount)) + topups, err := redis.Ints(decrementCreditLua.Do(rc, org.ID(), amount)) if err != nil { return NilTopupID, err } @@ -40,7 +45,7 @@ func DecrementOrgCredits(ctx context.Context, db Queryer, rc redis.Conn, orgID O } // no active topup found, lets calculate it - topup, err := calculateActiveTopup(ctx, db, orgID) + topup, err := calculateActiveTopup(ctx, db, org.ID()) if err != nil { return NilTopupID, err } @@ -53,11 +58,11 @@ func DecrementOrgCredits(ctx context.Context, db Queryer, rc redis.Conn, orgID O // got one? then cache it expireSeconds := -int(time.Since(topup.Expiration) / time.Second) if expireSeconds > 0 && topup.Remaining-amount > 0 { - rc.Send("SETEX", fmt.Sprintf(redisActiveTopupKey, orgID), expireSeconds, topup.ID) - _, err := rc.Do("SETEX", fmt.Sprintf(redisCreditsRemainingKey, orgID, topup.ID), expireSeconds, topup.Remaining-amount) + rc.Send("SETEX", fmt.Sprintf(redisActiveTopupKey, org.ID()), expireSeconds, topup.ID) + _, err := rc.Do("SETEX", fmt.Sprintf(redisCreditsRemainingKey, org.ID(), topup.ID), expireSeconds, topup.Remaining-amount) if err != nil { // an error here isn't the end of the world, log it and move on - logrus.WithError(err).Errorf("error setting active topup in redis for org: %d", orgID) + logrus.WithError(err).Errorf("error setting active topup in redis for org: %d", org.ID()) } } diff --git a/models/topups_test.go b/models/topups_test.go index b1f5c99c5..69c852014 100644 --- a/models/topups_test.go +++ b/models/topups_test.go @@ -53,7 +53,10 @@ func TestTopups(t *testing.T) { } for _, tc := range tc2s { - topup, err := DecrementOrgCredits(ctx, tx, rc, tc.OrgID, 1) + org, err := loadOrg(ctx, tx, tc.OrgID) + assert.NoError(t, err) + + topup, err := AllocateTopups(ctx, tx, rc, org, 1) assert.NoError(t, err) assert.Equal(t, tc.TopupID, topup) tx.MustExec(`INSERT INTO orgs_topupcredits(is_squashed, used, topup_id) VALUES(TRUE, 1, $1)`, tc.OrgID) diff --git a/tasks/handler/worker.go b/tasks/handler/worker.go index c981ae28c..559b85d12 100644 --- a/tasks/handler/worker.go +++ b/tasks/handler/worker.go @@ -458,28 +458,28 @@ func handleStopEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *St // handleMsgEvent is called when a new message arrives from a contact func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *MsgEvent) error { - org, err := models.GetOrgAssets(ctx, db, event.OrgID) + oa, err := models.GetOrgAssets(ctx, db, event.OrgID) if err != nil { return errors.Wrapf(err, "error loading org") } // find the topup for this message rc := rp.Get() - topup, err := models.DecrementOrgCredits(ctx, db, rc, event.OrgID, 1) + topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) rc.Close() if err != nil { return errors.Wrapf(err, "error calculating topup for msg") } // load our contact - contacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{event.ContactID}) + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID}) if err != nil { return errors.Wrapf(err, "error loading contact") } // contact has been deleted, ignore this message but mark it as handled if len(contacts) == 0 { - err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topup) + err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID) if err != nil { return errors.Wrapf(err, "error updating message for deleted contact") } @@ -489,25 +489,25 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg modelContact := contacts[0] // load the channel for this message - channel := org.ChannelByID(event.ChannelID) + channel := oa.ChannelByID(event.ChannelID) // if we have URNs make sure the message URN is our highest priority (this is usually a noop) if len(modelContact.URNs()) > 0 { - err = modelContact.UpdatePreferredURN(ctx, db, org, event.URNID, channel) + err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID, channel) if err != nil { return errors.Wrapf(err, "error changing primary URN") } } // build our flow contact - contact, err := modelContact.FlowContact(org) + contact, err := modelContact.FlowContact(oa) if err != nil { return errors.Wrapf(err, "error creating flow contact") } // if this channel is no longer active or this contact is blocked, ignore this message (mark it as handled) if channel == nil || modelContact.IsBlocked() { - err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topup) + err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID) if err != nil { return errors.Wrapf(err, "error marking blocked or nil channel message as handled") } @@ -527,7 +527,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if this is a new contact, we need to calculate dynamic groups and campaigns if newContact { - err = models.CalculateDynamicGroups(ctx, db, org, contact) + err = models.CalculateDynamicGroups(ctx, db, oa, contact) if err != nil { return errors.Wrapf(err, "unable to initialize new contact") } @@ -539,14 +539,14 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg return errors.Wrapf(err, "unable to look up open tickets for contact") } for _, ticket := range tickets { - ticket.ForwardIncoming(ctx, db, org, event.MsgUUID, event.Text, event.Attachments) + ticket.ForwardIncoming(ctx, db, oa, event.MsgUUID, event.Text, event.Attachments) } // find any matching triggers - trigger := models.FindMatchingMsgTrigger(org, contact, event.Text) + trigger := models.FindMatchingMsgTrigger(oa, contact, event.Text) // get any active session for this contact - session, err := models.ActiveSessionForContact(ctx, db, org, models.MessagingFlow, contact) + session, err := models.ActiveSessionForContact(ctx, db, oa, models.MessagingFlow, contact) if err != nil { return errors.Wrapf(err, "error loading active session for contact") } @@ -554,7 +554,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // we have a session and it has an active flow, check whether we should honor triggers var flow *models.Flow if session != nil && session.CurrentFlowID() != models.NilFlowID { - flow, err = org.FlowByID(session.CurrentFlowID()) + flow, err = oa.FlowByID(session.CurrentFlowID()) // flow this session is in is gone, interrupt our session and reset it if err == models.ErrNotFound { @@ -579,7 +579,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg } sessions[0].SetIncomingMsg(event.MsgID, event.MsgExternalID) - err = models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topup) + err = models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID) if err != nil { return errors.Wrapf(err, "error marking message as handled") } @@ -590,7 +590,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg if (trigger != nil && trigger.TriggerType() != models.CatchallTriggerType && (flow == nil || !flow.IgnoreTriggers())) || (trigger != nil && trigger.TriggerType() == models.CatchallTriggerType && (flow == nil)) { // load our flow - flow, err := org.FlowByID(trigger.FlowID()) + flow, err := oa.FlowByID(trigger.FlowID()) if err != nil && err != models.ErrNotFound { return errors.Wrapf(err, "error loading flow for trigger") } @@ -599,8 +599,8 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg if flow != nil { // if this is an IVR flow, we need to trigger that start (which happens in a different queue) if flow.FlowType() == models.IVRFlow { - err = runner.TriggerIVRFlow(ctx, db, rp, org.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, func(ctx context.Context, tx *sqlx.Tx) error { - return models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topup) + err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, func(ctx context.Context, tx *sqlx.Tx) error { + return models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID) }) if err != nil { return errors.Wrapf(err, "error while triggering ivr flow") @@ -609,8 +609,8 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg } // otherwise build the trigger and start the flow directly - trigger := triggers.NewMsg(org.Env(), flow.FlowReference(), contact, msgIn, trigger.Match()) - _, err = runner.StartFlowForContacts(ctx, db, rp, org, flow, []flows.Trigger{trigger}, hook, true) + trigger := triggers.NewMsg(oa.Env(), flow.FlowReference(), contact, msgIn, trigger.Match()) + _, err = runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true) if err != nil { return errors.Wrapf(err, "error starting flow for contact") } @@ -620,15 +620,15 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if there is a session, resume it if session != nil && flow != nil { - resume := resumes.NewMsg(org.Env(), contact, msgIn) - _, err = runner.ResumeFlow(ctx, db, rp, org, session, resume, hook) + resume := resumes.NewMsg(oa.Env(), contact, msgIn) + _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook) if err != nil { return errors.Wrapf(err, "error resuming flow for contact") } return nil } - err = models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, topup) + err = models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, topupID) if err != nil { return errors.Wrapf(err, "error marking message as handled") } From c743fa1b42410f093e5d0011d16b6b37b4b09283 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 17 Jun 2020 14:19:31 -0500 Subject: [PATCH 23/55] Add test --- models/topups_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/models/topups_test.go b/models/topups_test.go index 69c852014..f8a21e71d 100644 --- a/models/topups_test.go +++ b/models/topups_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/nyaruka/mailroom/testsuite" + "github.com/stretchr/testify/assert" ) @@ -61,4 +62,11 @@ func TestTopups(t *testing.T) { assert.Equal(t, tc.TopupID, topup) tx.MustExec(`INSERT INTO orgs_topupcredits(is_squashed, used, topup_id) VALUES(TRUE, 1, $1)`, tc.OrgID) } + + // topups can be disabled for orgs + tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, Org1) + org, err := loadOrg(ctx, tx, Org1) + topup, err := AllocateTopups(ctx, tx, rc, org, 1) + assert.NoError(t, err) + assert.Equal(t, NilTopupID, topup) } From ab2a484e21349e6aa01bb9abc6d8f3185f419920 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 17 Jun 2020 16:28:59 -0500 Subject: [PATCH 24/55] Org being suspended should stop message handling --- tasks/handler/handler_test.go | 17 +++++++++++++++++ tasks/handler/worker.go | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/tasks/handler/handler_test.go b/tasks/handler/handler_test.go index f7fe77620..83f06d11e 100644 --- a/tasks/handler/handler_test.go +++ b/tasks/handler/handler_test.go @@ -217,6 +217,23 @@ func TestMsgEvents(t *testing.T) { db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2 ORDER BY id DESC LIMIT 1`, models.Org2FredID, previous) assert.Equal(t, "Hey, how are you?", text) + + // restore flow + db.MustExec(`UPDATE flows_flow SET is_active = TRUE where id = $1`, models.Org2FavoritesFlowID) + models.FlushCache() + + // suspend our org + db.MustExec(`UPDATE orgs_org SET is_suspended = TRUE WHERE id = $1`, models.Org2) + + // message should be handled as an inbox message.. no new session + task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start") + AddHandleTask(rc, models.Org2FredID, task) + task, _ = queue.PopNextTask(rc, queue.HandlerQueue) + err = handleContactEvent(ctx, db, rp, task) + assert.NoError(t, err) + + testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_msg WHERE status = 'H' AND msg_type = 'I'`, nil, 1) + testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1`, []interface{}{models.Org2FredID}, 7) } func TestChannelEvents(t *testing.T) { diff --git a/tasks/handler/worker.go b/tasks/handler/worker.go index 559b85d12..5d2e3ec6c 100644 --- a/tasks/handler/worker.go +++ b/tasks/handler/worker.go @@ -463,6 +463,15 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg return errors.Wrapf(err, "error loading org") } + // if org is suspended, just send message to inbox + if oa.Org().Suspended() { + err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, models.NilTopupID) + if err != nil { + return errors.Wrapf(err, "error updating message for suspended org") + } + return nil + } + // find the topup for this message rc := rp.Get() topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) From e4d65a1bd0fa86631fe8097fc4fb99d0179988e2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 18 Jun 2020 10:01:49 -0500 Subject: [PATCH 25/55] Update CHANGELOG.md for v5.5.30 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722b025a7..0dea7992e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v5.5.30 +---------- + * Org being suspended should stop message handling + * Make decrementing org credit optional + v5.5.29 ---------- * Return query inspection results as new metadata field in responses From 01a5191a1481a568e429100385abd4d74d1b3beb Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 18 Jun 2020 11:37:20 -0500 Subject: [PATCH 26/55] Continue handling as normal for suspended orgs --- hooks/ivr_created.go | 2 +- hooks/msg_created.go | 4 ++-- ivr/ivr.go | 10 +++------- models/msgs.go | 4 ++-- tasks/handler/handler_test.go | 17 ----------------- tasks/handler/worker.go | 13 ++----------- 6 files changed, 10 insertions(+), 40 deletions(-) diff --git a/hooks/ivr_created.go b/hooks/ivr_created.go index 1308559f6..cf4bc591c 100644 --- a/hooks/ivr_created.go +++ b/hooks/ivr_created.go @@ -35,7 +35,7 @@ func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { - return errors.Wrapf(err, "error finding active topup") + return errors.Wrapf(err, "error allocating topup for outgoing IVR message") } // if we have an active topup, assign it to our messages diff --git a/hooks/msg_created.go b/hooks/msg_created.go index 3b2d0b1ec..f7647673c 100644 --- a/hooks/msg_created.go +++ b/hooks/msg_created.go @@ -149,12 +149,12 @@ func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.P } } - // find the topup we will assign + // allocate a topup for this message if org uses topups rc := rp.Get() topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { - return errors.Wrapf(err, "error finding active topup") + return errors.Wrapf(err, "error allocating topup for outgoing message") } // if we have an active topup, assign it to our messages diff --git a/ivr/ivr.go b/ivr/ivr.go index 7dfd9b031..6364a8775 100644 --- a/ivr/ivr.go +++ b/ivr/ivr.go @@ -520,18 +520,14 @@ func ResumeIVRFlow( // create an incoming message msg := models.NewIncomingIVR(oa.OrgID(), conn, msgIn, time.Now()) - // find a topup + // allocate a topup for this message if org uses topups rc := rp.Get() topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) rc.Close() - - // error or no topup, that's an end of call if err != nil { - return errors.Wrapf(err, "unable to look up topup") - } - if topupID == models.NilTopupID { - return client.WriteEmptyResponse(w, "no topups for org, exiting call") + return errors.Wrapf(err, "error allocating topup for incoming IVR message") } + msg.SetTopup(topupID) // commit it diff --git a/models/msgs.go b/models/msgs.go index 3f823722f..e3f2bb446 100644 --- a/models/msgs.go +++ b/models/msgs.go @@ -913,12 +913,12 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa } } - // get a topup to assign to our messages + // allocate a topup for these message if org uses topups rc := rp.Get() topup, err := AllocateTopups(ctx, db, rc, oa.Org(), len(msgs)) rc.Close() if err != nil { - return nil, errors.Wrapf(err, "error finding active topup") + return nil, errors.Wrapf(err, "error allocating topup for broadcast messages") } // if we have an active topup, assign it to our messages diff --git a/tasks/handler/handler_test.go b/tasks/handler/handler_test.go index 83f06d11e..f7fe77620 100644 --- a/tasks/handler/handler_test.go +++ b/tasks/handler/handler_test.go @@ -217,23 +217,6 @@ func TestMsgEvents(t *testing.T) { db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2 ORDER BY id DESC LIMIT 1`, models.Org2FredID, previous) assert.Equal(t, "Hey, how are you?", text) - - // restore flow - db.MustExec(`UPDATE flows_flow SET is_active = TRUE where id = $1`, models.Org2FavoritesFlowID) - models.FlushCache() - - // suspend our org - db.MustExec(`UPDATE orgs_org SET is_suspended = TRUE WHERE id = $1`, models.Org2) - - // message should be handled as an inbox message.. no new session - task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start") - AddHandleTask(rc, models.Org2FredID, task) - task, _ = queue.PopNextTask(rc, queue.HandlerQueue) - err = handleContactEvent(ctx, db, rp, task) - assert.NoError(t, err) - - testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_msg WHERE status = 'H' AND msg_type = 'I'`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1`, []interface{}{models.Org2FredID}, 7) } func TestChannelEvents(t *testing.T) { diff --git a/tasks/handler/worker.go b/tasks/handler/worker.go index 5d2e3ec6c..40e4ab0fc 100644 --- a/tasks/handler/worker.go +++ b/tasks/handler/worker.go @@ -463,21 +463,12 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg return errors.Wrapf(err, "error loading org") } - // if org is suspended, just send message to inbox - if oa.Org().Suspended() { - err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, models.NilTopupID) - if err != nil { - return errors.Wrapf(err, "error updating message for suspended org") - } - return nil - } - - // find the topup for this message + // allocate a topup for this message if org uses topups rc := rp.Get() topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) rc.Close() if err != nil { - return errors.Wrapf(err, "error calculating topup for msg") + return errors.Wrapf(err, "error allocating topup for incoming message") } // load our contact From da55555ed21e0723b02cd347a55c8cc63ce5496c Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 18 Jun 2020 11:52:01 -0500 Subject: [PATCH 27/55] Simplify how AllocateTopups is called --- hooks/ivr_created.go | 4 +--- hooks/msg_created.go | 4 +--- ivr/ivr.go | 6 ++---- models/msgs.go | 4 +--- models/topups.go | 5 ++++- models/topups_test.go | 7 +++---- tasks/handler/worker.go | 4 +--- 7 files changed, 13 insertions(+), 21 deletions(-) diff --git a/hooks/ivr_created.go b/hooks/ivr_created.go index cf4bc591c..ff4b73841 100644 --- a/hooks/ivr_created.go +++ b/hooks/ivr_created.go @@ -31,9 +31,7 @@ func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, } // find the topup we will assign - rc := rp.Get() - topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) - rc.Close() + topup, err := models.AllocateTopups(ctx, tx, rp, oa.Org(), len(msgs)) if err != nil { return errors.Wrapf(err, "error allocating topup for outgoing IVR message") } diff --git a/hooks/msg_created.go b/hooks/msg_created.go index f7647673c..afe86568d 100644 --- a/hooks/msg_created.go +++ b/hooks/msg_created.go @@ -150,9 +150,7 @@ func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.P } // allocate a topup for this message if org uses topups - rc := rp.Get() - topup, err := models.AllocateTopups(ctx, tx, rc, oa.Org(), len(msgs)) - rc.Close() + topup, err := models.AllocateTopups(ctx, tx, rp, oa.Org(), len(msgs)) if err != nil { return errors.Wrapf(err, "error allocating topup for outgoing message") } diff --git a/ivr/ivr.go b/ivr/ivr.go index 6364a8775..23f276c41 100644 --- a/ivr/ivr.go +++ b/ivr/ivr.go @@ -520,10 +520,8 @@ func ResumeIVRFlow( // create an incoming message msg := models.NewIncomingIVR(oa.OrgID(), conn, msgIn, time.Now()) - // allocate a topup for this message if org uses topups - rc := rp.Get() - topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) - rc.Close() + // allocate a topup for this message if org uses topups) + topupID, err := models.AllocateTopups(ctx, db, rp, oa.Org(), 1) if err != nil { return errors.Wrapf(err, "error allocating topup for incoming IVR message") } diff --git a/models/msgs.go b/models/msgs.go index e3f2bb446..7d1f3e149 100644 --- a/models/msgs.go +++ b/models/msgs.go @@ -914,9 +914,7 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa } // allocate a topup for these message if org uses topups - rc := rp.Get() - topup, err := AllocateTopups(ctx, db, rc, oa.Org(), len(msgs)) - rc.Close() + topup, err := AllocateTopups(ctx, db, rp, oa.Org(), len(msgs)) if err != nil { return nil, errors.Wrapf(err, "error allocating topup for broadcast messages") } diff --git a/models/topups.go b/models/topups.go index 75fe8d37a..04725698f 100644 --- a/models/topups.go +++ b/models/topups.go @@ -27,7 +27,10 @@ const ( // AllocateTopups allocates topups for the given number of messages if topups are used by the org. // If topups are allocated it will return the ID of the topup to assign to those messages. -func AllocateTopups(ctx context.Context, db Queryer, rc redis.Conn, org *Org, amount int) (TopupID, error) { +func AllocateTopups(ctx context.Context, db Queryer, rp *redis.Pool, org *Org, amount int) (TopupID, error) { + rc := rp.Get() + defer rc.Close() + // if org doesn't use topups, do nothing if !org.UsesTopups() { return NilTopupID, nil diff --git a/models/topups_test.go b/models/topups_test.go index f8a21e71d..54e8faaf5 100644 --- a/models/topups_test.go +++ b/models/topups_test.go @@ -11,8 +11,7 @@ import ( func TestTopups(t *testing.T) { ctx := testsuite.CTX() db := testsuite.DB() - rc := testsuite.RC() - defer rc.Close() + rp := testsuite.RP() tx, err := db.BeginTxx(ctx, nil) assert.NoError(t, err) @@ -57,7 +56,7 @@ func TestTopups(t *testing.T) { org, err := loadOrg(ctx, tx, tc.OrgID) assert.NoError(t, err) - topup, err := AllocateTopups(ctx, tx, rc, org, 1) + topup, err := AllocateTopups(ctx, tx, rp, org, 1) assert.NoError(t, err) assert.Equal(t, tc.TopupID, topup) tx.MustExec(`INSERT INTO orgs_topupcredits(is_squashed, used, topup_id) VALUES(TRUE, 1, $1)`, tc.OrgID) @@ -66,7 +65,7 @@ func TestTopups(t *testing.T) { // topups can be disabled for orgs tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, Org1) org, err := loadOrg(ctx, tx, Org1) - topup, err := AllocateTopups(ctx, tx, rc, org, 1) + topup, err := AllocateTopups(ctx, tx, rp, org, 1) assert.NoError(t, err) assert.Equal(t, NilTopupID, topup) } diff --git a/tasks/handler/worker.go b/tasks/handler/worker.go index 40e4ab0fc..57e970f9e 100644 --- a/tasks/handler/worker.go +++ b/tasks/handler/worker.go @@ -464,9 +464,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg } // allocate a topup for this message if org uses topups - rc := rp.Get() - topupID, err := models.AllocateTopups(ctx, db, rc, oa.Org(), 1) - rc.Close() + topupID, err := models.AllocateTopups(ctx, db, rp, oa.Org(), 1) if err != nil { return errors.Wrapf(err, "error allocating topup for incoming message") } From f16d0c4ada56acd9d32de91e12c9d4c62a81acec Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 18 Jun 2020 13:53:27 -0500 Subject: [PATCH 28/55] Messages without topups should be queued --- hooks/msg_created.go | 4 ++-- hooks/msg_created_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/msg_created.go b/hooks/msg_created.go index afe86568d..c5e1e16ad 100644 --- a/hooks/msg_created.go +++ b/hooks/msg_created.go @@ -42,13 +42,13 @@ func (h *SendMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Poo // for each scene gather all our messages for s, args := range scenes { - // walk through our messages, separate by whether they have a topup + // walk through our messages, separate by whether they're android or not courierMsgs := make([]*models.Msg, 0, len(args)) for _, m := range args { msg := m.(*models.Msg) channel := msg.Channel() - if msg.TopupID() != models.NilTopupID && channel != nil { + if channel != nil { if channel.Type() == models.ChannelTypeAndroid { androidChannels[channel] = true } else { diff --git a/hooks/msg_created_test.go b/hooks/msg_created_test.go index 1401d26bd..08c242f49 100644 --- a/hooks/msg_created_test.go +++ b/hooks/msg_created_test.go @@ -126,7 +126,7 @@ func TestNoTopup(t *testing.T) { }, SQLAssertions: []SQLAssertion{ SQLAssertion{ - SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='No Topup' AND contact_id = $1 AND status = 'P'", + SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='No Topup' AND contact_id = $1 AND status = 'Q'", Args: []interface{}{models.CathyID}, Count: 1, }, From fbce675e0c3453ce8469d11c6673f4fc9fb71b19 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 18 Jun 2020 16:45:46 -0500 Subject: [PATCH 29/55] Update CHANGELOG.md for v5.5.31 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dea7992e..ebfdcb787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +v5.5.31 +---------- + * Messages without topups should be queued + * Continue handling as normal for suspended orgs + v5.5.30 ---------- * Org being suspended should stop message handling From aa024b59b7b19c2c98c96373c7b9b08ff034fad4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 19 Jun 2020 13:33:33 -0500 Subject: [PATCH 30/55] When blocking contacts archive any triggers which only apply to them --- models/contacts.go | 17 +++------ models/triggers.go | 69 ++++++++++++++++++++++++++++++++++- models/triggers_test.go | 80 ++++++++++++++++++++++++++++++----------- 3 files changed, 133 insertions(+), 33 deletions(-) diff --git a/models/contacts.go b/models/contacts.go index 775a7e34b..0808de1dc 100644 --- a/models/contacts.go +++ b/models/contacts.go @@ -1299,7 +1299,7 @@ type contactStatusUpdate struct { // UpdateContactStatus updates the contacts status as the passed changes func UpdateContactStatus(ctx context.Context, tx Queryer, changes []*ContactStatusChange) error { - contactTriggersIDs := make([]interface{}, 0, len(changes)) + archiveTriggersForContactIDs := make([]ContactID, 0, len(changes)) statusUpdates := make([]interface{}, 0, len(changes)) for _, ch := range changes { @@ -1307,7 +1307,7 @@ func UpdateContactStatus(ctx context.Context, tx Queryer, changes []*ContactStat stopped := ch.Status == flows.ContactStatusStopped if blocked || stopped { - contactTriggersIDs = append(contactTriggersIDs, ch.ContactID) + archiveTriggersForContactIDs = append(archiveTriggersForContactIDs, ch.ContactID) } statusUpdates = append( @@ -1321,10 +1321,9 @@ func UpdateContactStatus(ctx context.Context, tx Queryer, changes []*ContactStat } - // remove triggers for contact we'll stop/block - _, err := tx.ExecContext(ctx, deleteAllContactTriggersForIDsSQL, pq.Array(contactTriggersIDs)) + err := ArchiveContactTriggers(ctx, tx, archiveTriggersForContactIDs) if err != nil { - return errors.Wrapf(err, "error removing contact from triggers") + return errors.Wrapf(err, "error archiving triggers for blocked or stopped contacts") } // do our status update @@ -1332,6 +1331,7 @@ func UpdateContactStatus(ctx context.Context, tx Queryer, changes []*ContactStat if err != nil { return errors.Wrapf(err, "error updating contact statuses") } + return err } @@ -1349,10 +1349,3 @@ const updateContactStatusSQL = ` WHERE c.id = r.id::int ` - -const deleteAllContactTriggersForIDsSQL = ` -DELETE FROM - triggers_trigger_contacts -WHERE - contact_id = ANY($1) -` diff --git a/models/triggers.go b/models/triggers.go index bc86df271..6d7843943 100644 --- a/models/triggers.go +++ b/models/triggers.go @@ -5,10 +5,12 @@ import ( "strings" "time" - "github.com/jmoiron/sqlx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/triggers" "github.com/nyaruka/goflow/utils" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -295,3 +297,68 @@ GROUP BY t.id ) r; ` + +const selectTriggersByContactIDsSQL = ` +SELECT + t.id AS id +FROM + triggers_trigger t +INNER JOIN + triggers_trigger_contacts tc ON tc.trigger_id = t.id +WHERE + tc.contact_id = ANY($1) AND + is_archived = FALSE +` + +const deleteContactTriggersForIDsSQL = ` +DELETE FROM + triggers_trigger_contacts +WHERE + contact_id = ANY($1) +` + +const archiveEmptyTriggersSQL = ` +UPDATE + triggers_trigger +SET + is_archived = TRUE +WHERE + id = ANY($1) AND + NOT EXISTS (SELECT * FROM triggers_trigger_contacts WHERE trigger_id = triggers_trigger.id) AND + NOT EXISTS (SELECT * FROM triggers_trigger_groups WHERE trigger_id = triggers_trigger.id) +` + +// ArchiveContactTriggers removes the given contacts from any triggers and archives any triggers +// which reference only those contacts +func ArchiveContactTriggers(ctx context.Context, tx Queryer, contactIDs []ContactID) error { + // start by getting all the active triggers that reference these contacts + rows, err := tx.QueryxContext(ctx, selectTriggersByContactIDsSQL, pq.Array(contactIDs)) + if err != nil { + return errors.Wrapf(err, "error finding triggers for contacts") + } + defer rows.Close() + + triggerIDs := make([]TriggerID, 0) + for rows.Next() { + var triggerID TriggerID + err := rows.Scan(&triggerID) + if err != nil { + return errors.Wrapf(err, "error reading trigger ID") + } + triggerIDs = append(triggerIDs, triggerID) + } + + // remove any references to these contacts in triggers + _, err = tx.ExecContext(ctx, deleteContactTriggersForIDsSQL, pq.Array(contactIDs)) + if err != nil { + return errors.Wrapf(err, "error removing contacts from triggers") + } + + // archive any of the original triggers which are now not referencing any contact or group + _, err = tx.ExecContext(ctx, archiveEmptyTriggersSQL, pq.Array(triggerIDs)) + if err != nil { + return errors.Wrapf(err, "error archiving empty triggers") + } + + return nil +} diff --git a/models/triggers_test.go b/models/triggers_test.go index b95960f01..55f2aa9c7 100644 --- a/models/triggers_test.go +++ b/models/triggers_test.go @@ -1,15 +1,18 @@ package models import ( + "fmt" "testing" - "github.com/jmoiron/sqlx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/testsuite" + + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func insertTrigger(t *testing.T, db *sqlx.DB, active bool, flowID FlowID, triggerType TriggerType, keyword string, matchType MatchType, groupIDs []GroupID, referrerID string, channelID ChannelID) TriggerID { +func insertTrigger(t *testing.T, db *sqlx.DB, active bool, flowID FlowID, triggerType TriggerType, keyword string, matchType MatchType, groupIDs []GroupID, contactIDs []ContactID, referrerID string, channelID ChannelID) TriggerID { var triggerID TriggerID err := db.Get(&triggerID, `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, referrer_id, is_archived, @@ -23,6 +26,11 @@ func insertTrigger(t *testing.T, db *sqlx.DB, active bool, flowID FlowID, trigge db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, triggerID, g) } + // insert any contact associations + for _, c := range contactIDs { + db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2)`, triggerID, c) + } + return triggerID } @@ -31,9 +39,9 @@ func TestChannelTriggers(t *testing.T) { db := testsuite.DB() ctx := testsuite.CTX() - fooID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, "foo", TwitterChannelID) - barID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, "bar", NilChannelID) - bazID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, "", TwitterChannelID) + fooID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "foo", TwitterChannelID) + barID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "bar", NilChannelID) + bazID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "", TwitterChannelID) FlushCache() @@ -71,11 +79,11 @@ func TestTriggers(t *testing.T) { db := testsuite.DB() ctx := testsuite.CTX() - joinID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, "", NilChannelID) - resistID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, nil, "", NilChannelID) - farmersID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, []GroupID{DoctorsGroupID}, "", NilChannelID) - farmersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, []GroupID{DoctorsGroupID}, "", NilChannelID) - othersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, nil, "", NilChannelID) + joinID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID) + resistID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, nil, nil, "", NilChannelID) + farmersID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID) + farmersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID) + othersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, nil, nil, "", NilChannelID) FlushCache() @@ -89,7 +97,7 @@ func TestTriggers(t *testing.T) { cathy, err := contacts[0].FlowContact(org) assert.NoError(t, err) - greg, err := contacts[1].FlowContact(org) + george, err := contacts[1].FlowContact(org) assert.NoError(t, err) tcs := []struct { @@ -99,20 +107,52 @@ func TestTriggers(t *testing.T) { }{ {"join", cathy, joinID}, {"join this", cathy, joinID}, - {"resist", greg, resistID}, + {"resist", george, resistID}, {"resist", cathy, farmersID}, {"resist this", cathy, farmersAllID}, {"other", cathy, farmersAllID}, - {"other", greg, othersAllID}, - {"", greg, othersAllID}, + {"other", george, othersAllID}, + {"", george, othersAllID}, } - for i, tc := range tcs { - trigger := FindMatchingMsgTrigger(org, tc.Contact, tc.Text) - if trigger == nil { - assert.Equal(t, tc.TriggerID, TriggerID(0), "%d: did not get back expected trigger", i) - } else { - assert.Equal(t, tc.TriggerID, trigger.ID(), "%d: did not get back expected trigger", i) + for _, tc := range tcs { + testID := fmt.Sprintf("'%s' sent by %s", tc.Text, tc.Contact.Name()) + + actualTriggerID := NilTriggerID + actualTrigger := FindMatchingMsgTrigger(org, tc.Contact, tc.Text) + if actualTrigger != nil { + actualTriggerID = actualTrigger.ID() } + + assert.Equal(t, tc.TriggerID, actualTriggerID, "did not get back expected trigger for %s", testID) } } + +func TestArchiveContactTriggers(t *testing.T) { + testsuite.Reset() + db := testsuite.DB() + ctx := testsuite.CTX() + + everybodyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID) + cathyOnly1ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID}, "", NilChannelID) + cathyOnly2ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "this", MatchOnly, nil, []ContactID{CathyID}, "", NilChannelID) + cathyAndGeorgeID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID, GeorgeID}, "", NilChannelID) + cathyAndGroupID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, []GroupID{DoctorsGroupID}, []ContactID{CathyID}, "", NilChannelID) + georgeOnlyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{GeorgeID}, "", NilChannelID) + + err := ArchiveContactTriggers(ctx, db, []ContactID{CathyID, BobID}) + require.NoError(t, err) + + assertTriggerArchived := func(id TriggerID, archived bool) { + var isArchived bool + db.Get(&isArchived, `SELECT is_archived FROM triggers_trigger WHERE id = $1`, id) + assert.Equal(t, archived, isArchived, `is_archived mismatch for trigger %d`, id) + } + + assertTriggerArchived(everybodyID, false) + assertTriggerArchived(cathyOnly1ID, true) + assertTriggerArchived(cathyOnly2ID, true) + assertTriggerArchived(cathyAndGeorgeID, false) + assertTriggerArchived(cathyAndGroupID, false) + assertTriggerArchived(georgeOnlyID, false) +} From 4b5298235f4f4673e3aa517d33cff3f68ff62571 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 19 Jun 2020 14:18:12 -0500 Subject: [PATCH 31/55] Formatting and comments --- models/airtime.go | 4 +- models/assets.go | 165 ++++++++++++++++++----------------- models/channel_connection.go | 36 ++++++-- models/channels.go | 20 +++-- models/classifiers.go | 15 ++-- models/flows.go | 27 +++--- models/runs.go | 2 +- models/triggers.go | 16 +++- runner/runner.go | 64 +++++++------- 9 files changed, 196 insertions(+), 153 deletions(-) diff --git a/models/airtime.go b/models/airtime.go index c52c674f2..47ca1629e 100644 --- a/models/airtime.go +++ b/models/airtime.go @@ -11,10 +11,10 @@ import ( "github.com/shopspring/decimal" ) -// AirtimeTransferID is our type for airtime transfer ids +// AirtimeTransferID is the type for airtime transfer IDs type AirtimeTransferID null.Int -// NilAirtimeTransferID is our nil value for airtime transfer ids +// NilAirtimeTransferID is the nil value for airtime transfer IDs var NilAirtimeTransferID = AirtimeTransferID(0) // AirtimeTransferStatus is the type for the status of a transfer diff --git a/models/assets.go b/models/assets.go index 92a3b4124..cb999e291 100644 --- a/models/assets.go +++ b/models/assets.go @@ -88,7 +88,7 @@ func FlushCache() { // org assets passed in to prevent refetching locations func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets, refresh Refresh) (*OrgAssets, error) { // build our new assets - o := &OrgAssets{ + oa := &OrgAssets{ db: db, builtAt: time.Now(), orgID: orgID, @@ -96,209 +96,210 @@ func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets // inherit our built at if we reusing anything if prev != nil && refresh&RefreshAll > 0 { - o.builtAt = prev.builtAt + oa.builtAt = prev.builtAt } // we load everything at once except for flows which are lazily loaded var err error if prev == nil || refresh&RefreshOrg > 0 { - o.org, err = loadOrg(ctx, db, orgID) + oa.org, err = loadOrg(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading environment for org %d", orgID) } } else { - o.org = prev.org + oa.org = prev.org } if prev == nil || refresh&RefreshChannels > 0 { - o.channels, err = loadChannels(ctx, db, orgID) + oa.channels, err = loadChannels(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading channel assets for org %d", orgID) } - o.channelsByID = make(map[ChannelID]*Channel) - o.channelsByUUID = make(map[assets.ChannelUUID]*Channel) - for _, c := range o.channels { + oa.channelsByID = make(map[ChannelID]*Channel) + oa.channelsByUUID = make(map[assets.ChannelUUID]*Channel) + for _, c := range oa.channels { channel := c.(*Channel) - o.channelsByID[channel.ID()] = channel - o.channelsByUUID[channel.UUID()] = channel + oa.channelsByID[channel.ID()] = channel + oa.channelsByUUID[channel.UUID()] = channel } } else { - o.channels = prev.channels - o.channelsByID = prev.channelsByID - o.channelsByUUID = prev.channelsByUUID + oa.channels = prev.channels + oa.channelsByID = prev.channelsByID + oa.channelsByUUID = prev.channelsByUUID } if prev == nil || refresh&RefreshFields > 0 { - o.fields, err = loadFields(ctx, db, orgID) + oa.fields, err = loadFields(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading field assets for org %d", orgID) } - o.fieldsByUUID = make(map[assets.FieldUUID]*Field) - o.fieldsByKey = make(map[string]*Field) - for _, f := range o.fields { + oa.fieldsByUUID = make(map[assets.FieldUUID]*Field) + oa.fieldsByKey = make(map[string]*Field) + for _, f := range oa.fields { field := f.(*Field) - o.fieldsByUUID[field.UUID()] = field - o.fieldsByKey[field.Key()] = field + oa.fieldsByUUID[field.UUID()] = field + oa.fieldsByKey[field.Key()] = field } } else { - o.fields = prev.fields - o.fieldsByUUID = prev.fieldsByUUID - o.fieldsByKey = prev.fieldsByKey + oa.fields = prev.fields + oa.fieldsByUUID = prev.fieldsByUUID + oa.fieldsByKey = prev.fieldsByKey } if prev == nil || refresh&RefreshGroups > 0 { - o.groups, err = loadGroups(ctx, db, orgID) + oa.groups, err = loadGroups(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading group assets for org %d", orgID) } - o.groupsByID = make(map[GroupID]*Group) - o.groupsByUUID = make(map[assets.GroupUUID]*Group) - for _, g := range o.groups { + oa.groupsByID = make(map[GroupID]*Group) + oa.groupsByUUID = make(map[assets.GroupUUID]*Group) + for _, g := range oa.groups { group := g.(*Group) - o.groupsByID[group.ID()] = group - o.groupsByUUID[group.UUID()] = group + oa.groupsByID[group.ID()] = group + oa.groupsByUUID[group.UUID()] = group } } else { - o.groups = prev.groups - o.groupsByID = prev.groupsByID - o.groupsByUUID = prev.groupsByUUID + oa.groups = prev.groups + oa.groupsByID = prev.groupsByID + oa.groupsByUUID = prev.groupsByUUID } if prev == nil || refresh&RefreshClassifiers > 0 { - o.classifiers, err = loadClassifiers(ctx, db, orgID) + oa.classifiers, err = loadClassifiers(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading classifier assets for org %d", orgID) } - o.classifiersByUUID = make(map[assets.ClassifierUUID]*Classifier) - for _, c := range o.classifiers { - o.classifiersByUUID[c.UUID()] = c.(*Classifier) + oa.classifiersByUUID = make(map[assets.ClassifierUUID]*Classifier) + for _, c := range oa.classifiers { + oa.classifiersByUUID[c.UUID()] = c.(*Classifier) } } else { - o.classifiers = prev.classifiers - o.classifiersByUUID = prev.classifiersByUUID + oa.classifiers = prev.classifiers + oa.classifiersByUUID = prev.classifiersByUUID } if prev == nil || refresh&RefreshLabels > 0 { - o.labels, err = loadLabels(ctx, db, orgID) + oa.labels, err = loadLabels(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading group labels for org %d", orgID) } - o.labelsByUUID = make(map[assets.LabelUUID]*Label) - for _, l := range o.labels { - o.labelsByUUID[l.UUID()] = l.(*Label) + oa.labelsByUUID = make(map[assets.LabelUUID]*Label) + for _, l := range oa.labels { + oa.labelsByUUID[l.UUID()] = l.(*Label) } } else { - o.labels = prev.labels - o.labelsByUUID = prev.labelsByUUID + oa.labels = prev.labels + oa.labelsByUUID = prev.labelsByUUID } if prev == nil || refresh&RefreshResthooks > 0 { - o.resthooks, err = loadResthooks(ctx, db, orgID) + oa.resthooks, err = loadResthooks(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading resthooks for org %d", orgID) } } else { - o.resthooks = prev.resthooks + oa.resthooks = prev.resthooks } if prev == nil || refresh&RefreshCampaigns > 0 { - o.campaigns, err = loadCampaigns(ctx, db, orgID) + oa.campaigns, err = loadCampaigns(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading campaigns for org %d", orgID) } - o.campaignEventsByField = make(map[FieldID][]*CampaignEvent) - o.campaignEventsByID = make(map[CampaignEventID]*CampaignEvent) - o.campaignsByGroup = make(map[GroupID][]*Campaign) - for _, c := range o.campaigns { - o.campaignsByGroup[c.GroupID()] = append(o.campaignsByGroup[c.GroupID()], c) + oa.campaignEventsByField = make(map[FieldID][]*CampaignEvent) + oa.campaignEventsByID = make(map[CampaignEventID]*CampaignEvent) + oa.campaignsByGroup = make(map[GroupID][]*Campaign) + for _, c := range oa.campaigns { + oa.campaignsByGroup[c.GroupID()] = append(oa.campaignsByGroup[c.GroupID()], c) for _, e := range c.Events() { - o.campaignEventsByField[e.RelativeToID()] = append(o.campaignEventsByField[e.RelativeToID()], e) - o.campaignEventsByID[e.ID()] = e + oa.campaignEventsByField[e.RelativeToID()] = append(oa.campaignEventsByField[e.RelativeToID()], e) + oa.campaignEventsByID[e.ID()] = e } } } else { - o.campaigns = prev.campaigns - o.campaignEventsByField = prev.campaignEventsByField - o.campaignEventsByID = prev.campaignEventsByID - o.campaignsByGroup = prev.campaignsByGroup + oa.campaigns = prev.campaigns + oa.campaignEventsByField = prev.campaignEventsByField + oa.campaignEventsByID = prev.campaignEventsByID + oa.campaignsByGroup = prev.campaignsByGroup } if prev == nil || refresh&RefreshTriggers > 0 { - o.triggers, err = loadTriggers(ctx, db, orgID) + oa.triggers, err = loadTriggers(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading triggers for org %d", orgID) } } else { - o.triggers = prev.triggers + oa.triggers = prev.triggers } if prev == nil || refresh&RefreshTemplates > 0 { - o.templates, err = loadTemplates(ctx, db, orgID) + oa.templates, err = loadTemplates(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading templates for org %d", orgID) } } else { - o.templates = prev.templates + oa.templates = prev.templates } if prev == nil || refresh&RefreshGlobals > 0 { - o.globals, err = loadGlobals(ctx, db, orgID) + oa.globals, err = loadGlobals(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading globals for org %d", orgID) } } else { - o.globals = prev.globals + oa.globals = prev.globals } if prev == nil || refresh&RefreshLocations > 0 { - o.locations, err = loadLocations(ctx, db, orgID) - o.locationsBuiltAt = time.Now() + oa.locations, err = loadLocations(ctx, db, orgID) + oa.locationsBuiltAt = time.Now() if err != nil { return nil, errors.Wrapf(err, "error loading group locations for org %d", orgID) } } else { - o.locations = prev.locations - o.locationsBuiltAt = prev.locationsBuiltAt + oa.locations = prev.locations + oa.locationsBuiltAt = prev.locationsBuiltAt } if prev == nil || refresh&RefreshFlows > 0 { - o.flowByUUID = make(map[assets.FlowUUID]assets.Flow) - o.flowByID = make(map[FlowID]assets.Flow) + oa.flowByUUID = make(map[assets.FlowUUID]assets.Flow) + oa.flowByID = make(map[FlowID]assets.Flow) } else { - o.flowByUUID = prev.flowByUUID - o.flowByID = prev.flowByID + oa.flowByUUID = prev.flowByUUID + oa.flowByID = prev.flowByID } if prev == nil || refresh&RefreshTicketers > 0 { - o.ticketers, err = loadTicketers(ctx, db, orgID) + oa.ticketers, err = loadTicketers(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading ticketer assets for org %d", orgID) } - o.ticketersByID = make(map[TicketerID]*Ticketer) - o.ticketersByUUID = make(map[assets.TicketerUUID]*Ticketer) - for _, t := range o.ticketers { - o.ticketersByID[t.(*Ticketer).ID()] = t.(*Ticketer) - o.ticketersByUUID[t.UUID()] = t.(*Ticketer) + oa.ticketersByID = make(map[TicketerID]*Ticketer) + oa.ticketersByUUID = make(map[assets.TicketerUUID]*Ticketer) + for _, t := range oa.ticketers { + oa.ticketersByID[t.(*Ticketer).ID()] = t.(*Ticketer) + oa.ticketersByUUID[t.UUID()] = t.(*Ticketer) } } else { - o.ticketers = prev.ticketers - o.ticketersByID = prev.ticketersByID - o.ticketersByUUID = prev.ticketersByUUID + oa.ticketers = prev.ticketers + oa.ticketersByID = prev.ticketersByID + oa.ticketersByUUID = prev.ticketersByUUID } // intialize our session assets - o.sessionAssets, err = engine.NewSessionAssets(o.Env(), o, goflow.MigrationConfig()) + oa.sessionAssets, err = engine.NewSessionAssets(oa.Env(), oa, goflow.MigrationConfig()) if err != nil { return nil, errors.Wrapf(err, "error build session assets for org: %d", orgID) } - return o, nil + return oa, nil } // Refresh is our type for the pieces of org assets we want fresh (not cached) type Refresh int +// refresh bit masks const ( RefreshNone = Refresh(0) RefreshAll = Refresh(^0) diff --git a/models/channel_connection.go b/models/channel_connection.go index 5548c484f..35f59181b 100644 --- a/models/channel_connection.go +++ b/models/channel_connection.go @@ -5,28 +5,41 @@ import ( "database/sql/driver" "time" + "github.com/nyaruka/null" + "github.com/jmoiron/sqlx" "github.com/lib/pq" - "github.com/nyaruka/null" "github.com/pkg/errors" ) +// ConnectionID is the type for connection IDs type ConnectionID null.Int +// NilConnectionID is the nil value for connection IDs const NilConnectionID = ConnectionID(0) +// ConnectionStatus is the type for the status of a connection type ConnectionStatus string +// ConnectionDirection is the type for the direction of a connection type ConnectionDirection string +// ConnectionType is the type for the type of a connection type ConnectionType string +// connection direction constants const ( ConnectionDirectionIn = ConnectionDirection("I") ConnectionDirectionOut = ConnectionDirection("O") +) +// connection type constants +const ( ConnectionTypeIVR = ConnectionType("V") +) +// connection status constants +const ( ConnectionStatusPending = ConnectionStatus("P") ConnectionStatusQueued = ConnectionStatus("Q") ConnectionStatusWired = ConnectionStatus("W") @@ -48,6 +61,7 @@ const ( ConnectionThrottleWait = time.Minute * 2 ) +// ChannelConnection models a session or connection with a particular channel type ChannelConnection struct { c struct { ID ConnectionID `json:"id" db:"id"` @@ -71,15 +85,19 @@ type ChannelConnection struct { } } -func (c *ChannelConnection) ID() ConnectionID { return c.c.ID } +// ID returns the id of this connection +func (c *ChannelConnection) ID() ConnectionID { return c.c.ID } + +// Status returns the status of this connection func (c *ChannelConnection) Status() ConnectionStatus { return c.c.Status } -func (c *ChannelConnection) NextAttempt() *time.Time { return c.c.NextAttempt } -func (c *ChannelConnection) ExternalID() string { return c.c.ExternalID } -func (c *ChannelConnection) OrgID() OrgID { return c.c.OrgID } -func (c *ChannelConnection) ContactID() ContactID { return c.c.ContactID } -func (c *ChannelConnection) ContactURNID() URNID { return c.c.ContactURNID } -func (c *ChannelConnection) ChannelID() ChannelID { return c.c.ChannelID } -func (c *ChannelConnection) StartID() StartID { return c.c.StartID } + +func (c *ChannelConnection) NextAttempt() *time.Time { return c.c.NextAttempt } +func (c *ChannelConnection) ExternalID() string { return c.c.ExternalID } +func (c *ChannelConnection) OrgID() OrgID { return c.c.OrgID } +func (c *ChannelConnection) ContactID() ContactID { return c.c.ContactID } +func (c *ChannelConnection) ContactURNID() URNID { return c.c.ContactURNID } +func (c *ChannelConnection) ChannelID() ChannelID { return c.c.ChannelID } +func (c *ChannelConnection) StartID() StartID { return c.c.StartID } const insertConnectionSQL = ` INSERT INTO diff --git a/models/channels.go b/models/channels.go index 8f1eb48e7..70bd86108 100644 --- a/models/channels.go +++ b/models/channels.go @@ -7,28 +7,34 @@ import ( "math" "time" - "github.com/jmoiron/sqlx" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/null" + + "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) +// ChannelID is the type for channel IDs type ChannelID null.Int +// NilChannelID is the nil value for channel IDs +const NilChannelID = ChannelID(0) + +// ChannelType is the type for the type of a channel type ChannelType string +// channel type constants const ( - NilChannelID = ChannelID(0) - ChannelTypeAndroid = ChannelType("A") +) - ChannelConfigCallbackDomain = "callback_domain" - +// config key constants +const ( + ChannelConfigCallbackDomain = "callback_domain" ChannelConfigMaxConcurrentEvents = "max_concurrent_events" - - ChannelConfigFCMID = "FCM_ID" + ChannelConfigFCMID = "FCM_ID" ) // Channel is the mailroom struct that represents channels diff --git a/models/classifiers.go b/models/classifiers.go index 16d414bde..1c98d7882 100644 --- a/models/classifiers.go +++ b/models/classifiers.go @@ -17,18 +17,21 @@ import ( "github.com/sirupsen/logrus" ) -// ClassifierID is our type for classifier ids +// ClassifierID is our type for classifier IDs type ClassifierID null.Int -const ( - // NilClassifierID is our const for a nil classifier ID - NilClassifierID = ClassifierID(0) +// NilClassifierID is nil value for classifier IDs +const NilClassifierID = ClassifierID(0) - // Our classifier types +// classifier type constants +const ( ClassifierTypeWit = "wit" ClassifierTypeLuis = "luis" ClassifierTypeBothub = "bothub" +) +// classifier config key constants +const ( // Wit.ai config options WitConfigAccessToken = "access_token" @@ -51,7 +54,7 @@ func init() { ) } -// Classifier is our type for a Classifier +// Classifier is our type for a classifier type Classifier struct { c struct { ID ClassifierID `json:"id"` diff --git a/models/flows.go b/models/flows.go index f226657cd..d2639d89c 100644 --- a/models/flows.go +++ b/models/flows.go @@ -6,31 +6,36 @@ import ( "encoding/json" "time" - "github.com/jmoiron/sqlx" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/null" + + "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) +// FlowID is the type for flow IDs type FlowID null.Int +// NilFlowID is nil value for flow IDs +const NilFlowID = FlowID(0) + +// FlowType is the type for the type of a flow type FlowType string +// flow type constants const ( - GoFlowMajorVersion = 12 - IVRFlow = FlowType("V") MessagingFlow = FlowType("M") SurveyorFlow = FlowType("S") +) - FlowConfigIVRRetryMinutes = "ivr_retry" - - NilFlowID = FlowID(0) +const ( + flowConfigIVRRetryMinutes = "ivr_retry" ) -var FlowTypeMapping = map[flows.FlowType]FlowType{ +var flowTypeMapping = map[flows.FlowType]FlowType{ flows.FlowTypeMessaging: MessagingFlow, flows.FlowTypeVoice: IVRFlow, flows.FlowTypeMessagingOffline: SurveyorFlow, @@ -70,7 +75,7 @@ func (f *Flow) Version() string { return f.f.Version } // IVRRetryWait returns the wait before retrying a failed IVR call func (f *Flow) IVRRetryWait() time.Duration { - value := f.f.Config.Get(FlowConfigIVRRetryMinutes, nil) + value := f.f.Config.Get(flowConfigIVRRetryMinutes, nil) fv, isFloat := value.(float64) if isFloat { return time.Minute * time.Duration(int(fv)) @@ -86,16 +91,16 @@ func (f *Flow) FlowReference() *assets.FlowReference { return assets.NewFlowReference(f.UUID(), f.Name()) } -func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) { +func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) { // first try to look up in our assets - flow, _ := org.Flow(flowUUID) + flow, _ := oa.Flow(flowUUID) if flow != nil { return flow.(*Flow).ID(), nil } // flow may be inactive, try to look up the ID only var flowID FlowID - err := tx.GetContext(ctx, &flowID, `SELECT id FROM flows_flow WHERE org_id = $1 AND uuid = $2;`, org.OrgID(), flowUUID) + err := tx.GetContext(ctx, &flowID, `SELECT id FROM flows_flow WHERE org_id = $1 AND uuid = $2;`, oa.OrgID(), flowUUID) return flowID, err } diff --git a/models/runs.go b/models/runs.go index 2959dcda9..c119878d7 100644 --- a/models/runs.go +++ b/models/runs.go @@ -301,7 +301,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi } // figure out our type - sessionType, found := FlowTypeMapping[fs.Type()] + sessionType, found := flowTypeMapping[fs.Type()] if !found { return nil, errors.Errorf("unknown flow type: %s", fs.Type()) } diff --git a/models/triggers.go b/models/triggers.go index bc86df271..40e79dc1c 100644 --- a/models/triggers.go +++ b/models/triggers.go @@ -13,12 +13,16 @@ import ( "github.com/sirupsen/logrus" ) +// TriggerType is the type of a trigger type TriggerType string +// MatchType is used for keyword triggers to specify how they should match type MatchType string +// TriggerID is the type for trigger database IDs type TriggerID int +// trigger type constants const ( CatchallTriggerType = TriggerType("C") KeywordTriggerType = TriggerType("K") @@ -27,13 +31,17 @@ const ( ReferralTriggerType = TriggerType("R") CallTriggerType = TriggerType("V") ScheduleTriggerType = TriggerType("S") +) +// match type constants +const ( MatchFirst = "F" MatchOnly = "O" - - NilTriggerID = TriggerID(0) ) +// NilTriggerID is the nil value for trigger IDs +const NilTriggerID = TriggerID(0) + // Trigger represents a trigger in an organization type Trigger struct { t struct { @@ -49,7 +57,9 @@ type Trigger struct { } } -func (t *Trigger) ID() TriggerID { return t.t.ID } +// ID returns the id of this trigger +func (t *Trigger) ID() TriggerID { return t.t.ID } + func (t *Trigger) FlowID() FlowID { return t.t.FlowID } func (t *Trigger) TriggerType() TriggerType { return t.t.TriggerType } func (t *Trigger) Keyword() string { return t.t.Keyword } diff --git a/runner/runner.go b/runner/runner.go index cca763a4a..374e66435 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -56,12 +56,12 @@ type StartOptions struct { type TriggerBuilder func(contact *flows.Contact) (flows.Trigger, error) // ResumeFlow resumes the passed in session using the passed in session -func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) { +func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) { start := time.Now() - sa := org.SessionAssets() + sa := oa.SessionAssets() // does the flow this session is part of still exist? - _, err := org.FlowByID(session.CurrentFlowID()) + _, err := oa.FlowByID(session.CurrentFlowID()) if err != nil { // if this flow just isn't available anymore, log this error if err == models.ErrNotFound { @@ -72,7 +72,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.Or } // build our flow session - fs, err := session.FlowSession(sa, org.Env()) + fs, err := session.FlowSession(sa, oa.Env()) if err != nil { return nil, errors.Wrapf(err, "unable to create session from output") } @@ -97,7 +97,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.Or } // write our updated session and runs - err = session.WriteUpdatedSession(txCTX, tx, rp, org, fs, sprint, hook) + err = session.WriteUpdatedSession(txCTX, tx, rp, oa, fs, sprint, hook) if err != nil { tx.Rollback() return nil, errors.Wrapf(err, "error updating session for resume") @@ -119,7 +119,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.Or return nil, errors.Wrapf(err, "error starting transaction for post commit hooks") } - err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, org, []*models.Scene{session.Scene()}) + err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, []*models.Scene{session.Scene()}) if err == nil { err = tx.Commit() } @@ -151,13 +151,13 @@ func StartFlowBatch( } // create our org assets - org, err := models.GetOrgAssets(ctx, db, batch.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, batch.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error creating assets for org: %d", batch.OrgID()) } // try to load our flow - flow, err := org.FlowByID(batch.FlowID()) + flow, err := oa.FlowByID(batch.FlowID()) if err == models.ErrNotFound { logrus.WithField("flow_id", batch.FlowID()).Info("skipping flow start, flow no longer active or archived") return nil, nil @@ -180,20 +180,20 @@ func StartFlowBatch( // this will build our trigger for each contact started triggerBuilder := func(contact *flows.Contact) (flows.Trigger, error) { if batch.ParentSummary() != nil { - trigger, err := triggers.NewFlowAction(org.Env(), flow.FlowReference(), contact, batch.ParentSummary(), batchStart) + trigger, err := triggers.NewFlowAction(oa.Env(), flow.FlowReference(), contact, batch.ParentSummary(), batchStart) if err != nil { return nil, errors.Wrap(err, "unable to create flow action trigger") } return trigger, nil } if batch.Extra() != nil { - return triggers.NewManual(org.Env(), flow.FlowReference(), contact, batchStart, params), nil + return triggers.NewManual(oa.Env(), flow.FlowReference(), contact, batchStart, params), nil } - return triggers.NewManual(org.Env(), flow.FlowReference(), contact, batchStart, nil), nil + return triggers.NewManual(oa.Env(), flow.FlowReference(), contact, batchStart, nil), nil } // before committing our runs we want to set the start they are associated with - updateStartID := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + updateStartID := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { // for each run in our sessions, set the start id for _, s := range sessions { for _, r := range s.Runs() { @@ -211,7 +211,7 @@ func StartFlowBatch( options.TriggerBuilder = triggerBuilder options.CommitHook = updateStartID - sessions, err := StartFlow(ctx, db, rp, org, flow, batch.ContactIDs(), options) + sessions, err := StartFlow(ctx, db, rp, oa, flow, batch.ContactIDs(), options) if err != nil { return nil, errors.Wrapf(err, "error starting flow batch") } @@ -245,13 +245,13 @@ func FireCampaignEvents( } // create our org assets - org, err := models.GetOrgAssets(ctx, db, orgID) + oa, err := models.GetOrgAssets(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error creating assets for org: %d", orgID) } // find our actual event - dbEvent := org.CampaignEventByID(fires[0].EventID) + dbEvent := oa.CampaignEventByID(fires[0].EventID) // no longer active? delete these event fires and return if dbEvent == nil { @@ -263,7 +263,7 @@ func FireCampaignEvents( } // try to load our flow - flow, err := org.Flow(flowUUID) + flow, err := oa.Flow(flowUUID) if err == models.ErrNotFound { err := models.DeleteEventFires(ctx, db, fires) if err != nil { @@ -298,7 +298,7 @@ func FireCampaignEvents( // if this is an ivr flow, we need to create a task to perform the start there if dbFlow.FlowType() == models.IVRFlow { // Trigger our IVR flow start - err := TriggerIVRFlow(ctx, db, rp, org.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error { + err := TriggerIVRFlow(ctx, db, rp, oa.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error { return models.MarkEventsFired(ctx, tx, fires, time.Now(), models.FireResultFired) }) if err != nil { @@ -311,7 +311,7 @@ func FireCampaignEvents( flowRef := assets.NewFlowReference(flow.UUID(), flow.Name()) options.TriggerBuilder = func(contact *flows.Contact) (flows.Trigger, error) { delete(skippedContacts, models.ContactID(contact.ID())) - return triggers.NewCampaign(org.Env(), flowRef, contact, event), nil + return triggers.NewCampaign(oa.Env(), flowRef, contact, event), nil } // this is our pre commit callback for our sessions, we'll mark the event fires associated @@ -351,7 +351,7 @@ func FireCampaignEvents( return nil } - sessions, err := StartFlow(ctx, db, rp, org, dbFlow, contactIDs, options) + sessions, err := StartFlow(ctx, db, rp, oa, dbFlow, contactIDs, options) if err != nil { logrus.WithField("contact_ids", contactIDs).WithError(err).Errorf("error starting flow for campaign event: %v", event) } else { @@ -380,7 +380,7 @@ func FireCampaignEvents( // StartFlow runs the passed in flow for the passed in contact func StartFlow( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.OrgAssets, + ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, flow *models.Flow, contactIDs []models.ContactID, options *StartOptions) ([]*models.Session, error) { if len(contactIDs) == 0 { @@ -444,7 +444,7 @@ func StartFlow( // try up to a second to get a lock for a contact for _, contactID := range remaining { - lockID := models.ContactLock(org.OrgID(), contactID) + lockID := models.ContactLock(oa.OrgID(), contactID) lock, err := locker.GrabLock(rp, lockID, time.Minute*5, time.Second) if err != nil { return nil, errors.Wrapf(err, "error attempting to grab lock") @@ -465,7 +465,7 @@ func StartFlow( } // load our locked contacts - contacts, err := models.LoadContacts(ctx, db, org, locked) + contacts, err := models.LoadContacts(ctx, db, oa, locked) if err != nil { return nil, errors.Wrapf(err, "error loading contacts to start") } @@ -473,7 +473,7 @@ func StartFlow( // ok, we've filtered our contacts, build our triggers triggers := make([]flows.Trigger, 0, len(locked)) for _, c := range contacts { - contact, err := c.FlowContact(org) + contact, err := c.FlowContact(oa) if err != nil { return nil, errors.Wrapf(err, "error creating flow contact") } @@ -484,7 +484,7 @@ func StartFlow( triggers = append(triggers, trigger) } - ss, err := StartFlowForContacts(ctx, db, rp, org, flow, triggers, options.CommitHook, options.Interrupt) + ss, err := StartFlowForContacts(ctx, db, rp, oa, flow, triggers, options.CommitHook, options.Interrupt) if err != nil { return nil, errors.Wrapf(err, "error starting flow for contacts") } @@ -496,7 +496,7 @@ func StartFlow( // release all our locks for i := range locked { - lockID := models.ContactLock(org.OrgID(), locked[i]) + lockID := models.ContactLock(oa.OrgID(), locked[i]) locker.ReleaseLock(rp, lockID, locks[i]) released[lockID] = true } @@ -510,9 +510,9 @@ func StartFlow( // StartFlowForContacts runs the passed in flow for the passed in contact func StartFlowForContacts( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.OrgAssets, + ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, flow *models.Flow, triggers []flows.Trigger, hook models.SessionCommitHook, interrupt bool) ([]*models.Session, error) { - assets := org.SessionAssets() + sa := oa.SessionAssets() // no triggers? nothing to do if len(triggers) == 0 { @@ -531,7 +531,7 @@ func StartFlowForContacts( log := log.WithField("contact_uuid", trigger.Contact().UUID()) start := time.Now() - session, sprint, err := goflow.Engine().NewSession(assets, trigger) + session, sprint, err := goflow.Engine().NewSession(sa, trigger) if err != nil { log.WithError(err).Errorf("error starting flow") continue @@ -572,7 +572,7 @@ func StartFlowForContacts( } // write our session to the db - dbSessions, err := models.WriteSessions(txCTX, tx, rp, org, sessions, sprints, hook) + dbSessions, err := models.WriteSessions(txCTX, tx, rp, oa, sessions, sprints, hook) if err == nil { // commit it at once commitStart := time.Now() @@ -612,7 +612,7 @@ func StartFlowForContacts( } } - dbSession, err := models.WriteSessions(txCTX, tx, rp, org, []flows.Session{session}, []flows.Sprint{sprint}, hook) + dbSession, err := models.WriteSessions(txCTX, tx, rp, oa, []flows.Session{session}, []flows.Sprint{sprint}, hook) if err != nil { tx.Rollback() log.WithField("contact_uuid", session.Contact().UUID()).WithError(err).Errorf("error writing session to db") @@ -644,7 +644,7 @@ func StartFlowForContacts( scenes = append(scenes, s.Scene()) } - err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, org, scenes) + err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, scenes) if err == nil { err = tx.Commit() } @@ -666,7 +666,7 @@ func StartFlowForContacts( continue } - err = models.ApplyEventPostCommitHooks(ctx, tx, rp, org, []*models.Scene{session.Scene()}) + err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, []*models.Scene{session.Scene()}) if err != nil { tx.Rollback() log.WithError(err).Errorf("error applying post commit hook") From 1deeb9cd251d90460431614badeb2d2a46d37260 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 23 Jun 2020 09:15:17 -0500 Subject: [PATCH 32/55] Update CHANGELOG.md for v5.5.32 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfdcb787..7de959775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.32 +---------- + * When blocking contacts archive any triggers which only apply to them + v5.5.31 ---------- * Messages without topups should be queued From eb8ea799ddfc2a30d934164e7de5a6777afa461f Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 23 Jun 2020 15:25:30 -0500 Subject: [PATCH 33/55] Remove dedundant updates to modified_on --- hooks/contact_groups_changed.go | 13 ------------- web/contact/contact.go | 6 ------ 2 files changed, 19 deletions(-) diff --git a/hooks/contact_groups_changed.go b/hooks/contact_groups_changed.go index 6c392e440..b0cce66d4 100644 --- a/hooks/contact_groups_changed.go +++ b/hooks/contact_groups_changed.go @@ -5,7 +5,6 @@ import ( "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/events" "github.com/nyaruka/mailroom/models" @@ -68,18 +67,6 @@ func (h *CommitGroupChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *red return errors.Wrapf(err, "error removing contacts from groups") } - // build the list of all contact ids changed, we'll update modified_on for them - changedIDs := make([]models.ContactID, 0, len(changed)) - for c := range changed { - changedIDs = append(changedIDs, c) - } - if len(changedIDs) > 0 { - _, err = tx.ExecContext(ctx, `UPDATE contacts_contact SET modified_on = NOW() WHERE id = ANY($1)`, pq.Array(changedIDs)) - if err != nil { - return errors.Wrapf(err, "error updating contacts modified_on") - } - } - return nil } diff --git a/web/contact/contact.go b/web/contact/contact.go index da83519f4..2df5e29d9 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -356,12 +356,6 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying pre commit hooks") } - // apply modified_by - err = models.UpdateContactModifiedBy(ctx, tx, modifiedContactIDs, request.UserID) - if err != nil { - return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying modified_by") - } - // commit our transaction err = tx.Commit() if err != nil { From e7e4731532f1248d70746ecbe250fe7616a737f2 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 24 Jun 2020 12:46:29 -0500 Subject: [PATCH 34/55] Update to latest goflow v0.93.0 --- go.mod | 2 +- go.sum | 4 +- .../{modify_contacts.json => modify.json} | 51 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) rename web/contact/testdata/{modify_contacts.json => modify.json} (92%) diff --git a/go.mod b/go.mod index 5f777da19..20278d6e6 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.92.0 + github.com/nyaruka/goflow v0.93.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 4e451a592..0b73f0c29 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.92.0 h1:6eS5RN0kgEhafzJsfhKOAkk+tecQAo6yImaxmy56Sog= -github.com/nyaruka/goflow v0.92.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.93.0 h1:r1tpD9R6vjNpmXkL4WZb65sU6Y0O+lYRivpHmPvkjvU= +github.com/nyaruka/goflow v0.93.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/web/contact/testdata/modify_contacts.json b/web/contact/testdata/modify.json similarity index 92% rename from web/contact/testdata/modify_contacts.json rename to web/contact/testdata/modify.json index 8840ec106..a6c1a2af2 100644 --- a/web/contact/testdata/modify_contacts.json +++ b/web/contact/testdata/modify.json @@ -563,6 +563,57 @@ "label": "add URN", "method": "POST", "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "urns", + "modification": "append", + "urns": [ + "tel:+255788555111" + ] + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "language": "fra", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+255788555111" + ] + }, + "events": [ + { + "type": "contact_urns_changed", + "created_on": "2018-07-06T12:30:00.123456789Z", + "urns": [ + "tel:+255788555111" + ] + } + ] + } + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555111'", + "count": 1 + } + ] + }, + { + "label": "add URN (deprecated urn modifier)", + "method": "POST", + "path": "/mr/contact/modify", "body": { "org_id": 1, "contact_ids": [ From 83bcde69228c4603d1f07d4455c6d8f81d906921 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 24 Jun 2020 13:40:08 -0500 Subject: [PATCH 35/55] Fix tests --- web/contact/contact_test.go | 2 +- web/contact/testdata/modify.json | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/web/contact/contact_test.go b/web/contact/contact_test.go index 1f2181379..7ec0ec8f4 100644 --- a/web/contact/contact_test.go +++ b/web/contact/contact_test.go @@ -194,5 +194,5 @@ func TestModifyContacts(t *testing.T) { db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.CathyID) db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID) - web.RunWebTests(t, "testdata/modify_contacts.json") + web.RunWebTests(t, "testdata/modify.json") } diff --git a/web/contact/testdata/modify.json b/web/contact/testdata/modify.json index a6c1a2af2..2e015d872 100644 --- a/web/contact/testdata/modify.json +++ b/web/contact/testdata/modify.json @@ -623,7 +623,7 @@ { "type": "urn", "modification": "append", - "urn": "tel:+255788555111" + "urn": "tel:+255788555222" } ] }, @@ -638,7 +638,8 @@ "timezone": "America/Los_Angeles", "created_on": "2018-07-06T12:30:00.123457Z", "urns": [ - "tel:+255788555111" + "tel:+255788555111?id=20121&priority=1000", + "tel:+255788555222" ] }, "events": [ @@ -646,7 +647,8 @@ "type": "contact_urns_changed", "created_on": "2018-07-06T12:30:00.123456789Z", "urns": [ - "tel:+255788555111" + "tel:+255788555111?id=20121&priority=1000", + "tel:+255788555222" ] } ] @@ -654,7 +656,7 @@ }, "db_assertions": [ { - "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555111'", + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555222'", "count": 1 } ] @@ -687,7 +689,8 @@ "timezone": "America/Los_Angeles", "created_on": "2018-07-06T12:30:00.123457Z", "urns": [ - "tel:+255788555111?id=20121\u0026priority=1000" + "tel:+255788555111?id=20121&priority=1000", + "tel:+255788555222?id=20122&priority=999" ] }, "events": [] From 200abd3e6c84efd3dbea136bab478c2b1bda68f4 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 24 Jun 2020 15:55:05 -0500 Subject: [PATCH 36/55] Update CHANGELOG.md for v5.5.33 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de959775..e9bcac595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.33 +---------- + * Update to latest goflow v0.93.0 + v5.5.32 ---------- * When blocking contacts archive any triggers which only apply to them From 8eef2a6cebb8357deae4ab900a9d3d0be17a053b Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 25 Jun 2020 12:02:26 -0500 Subject: [PATCH 37/55] Fix detaching URNs --- go.mod | 2 +- go.sum | 6 +- models/contacts.go | 24 +++- web/contact/testdata/modify.json | 183 ++++++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 20278d6e6..8035b84ed 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.93.0 + github.com/nyaruka/goflow v0.93.1 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index 0b73f0c29..34bb3d0dc 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,10 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.93.0 h1:r1tpD9R6vjNpmXkL4WZb65sU6Y0O+lYRivpHmPvkjvU= -github.com/nyaruka/goflow v0.93.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.93.1-0.20200625163928-0821a2360d55 h1:wU9ErkZmKHrGGNago7kQoQ8fdNQlxbPkYEDq8n9GlUE= +github.com/nyaruka/goflow v0.93.1-0.20200625163928-0821a2360d55/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.93.1 h1:7sh4B6iLEdKS4MMwVqRqI2q7Q8kKd5MKbR0FfXUywjY= +github.com/nyaruka/goflow v0.93.1/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/models/contacts.go b/models/contacts.go index 0808de1dc..08988a3b8 100644 --- a/models/contacts.go +++ b/models/contacts.go @@ -1083,14 +1083,19 @@ func UpdateContactURNs(ctx context.Context, tx Queryer, org *OrgAssets, changes // keep track of all our inserts inserts := make([]interface{}, 0, len(changes)) - // and updates + // and updates to URNs updates := make([]interface{}, 0, len(changes)) + contactIDs := make([]ContactID, 0) + updatedURNIDs := make([]URNID, 0) + // identities we are inserting identities := make([]string, 0, 1) // for each of our changes (one per contact) for _, change := range changes { + contactIDs = append(contactIDs, change.ContactID) + // priority for each contact starts at 1000 priority := topURNPriority @@ -1100,15 +1105,17 @@ func UpdateContactURNs(ctx context.Context, tx Queryer, org *OrgAssets, changes channelID := GetURNChannelID(org, urn) // do we have an id? - urnID := GetURNInt(urn, "id") + urnID := URNID(GetURNInt(urn, "id")) if urnID > 0 { // if so, this is a URN update updates = append(updates, &urnUpdate{ - URNID: URNID(urnID), + URNID: urnID, ChannelID: channelID, Priority: priority, }) + + updatedURNIDs = append(updatedURNIDs, urnID) } else { // new URN, add it instead inserts = append(inserts, &urnInsert{ @@ -1136,6 +1143,17 @@ func UpdateContactURNs(ctx context.Context, tx Queryer, org *OrgAssets, changes return errors.Wrapf(err, "error updating urns") } + // then detach any URNs that weren't updated (the ones we're not keeping) + _, err = tx.ExecContext( + ctx, + `UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = ANY($1) AND id != ALL($2)`, + pq.Array(contactIDs), + pq.Array(updatedURNIDs), + ) + if err != nil { + return errors.Wrapf(err, "error detaching urns") + } + if len(inserts) > 0 { // find the unique ids of the contacts that may be affected by our URN inserts rows, err := tx.QueryxContext(ctx, diff --git a/web/contact/testdata/modify.json b/web/contact/testdata/modify.json index 2e015d872..9398f838d 100644 --- a/web/contact/testdata/modify.json +++ b/web/contact/testdata/modify.json @@ -672,9 +672,11 @@ ], "modifiers": [ { - "type": "urn", + "type": "urns", "modification": "append", - "urn": "tel:+255788555111" + "urns": [ + "tel:+255788555111" + ] } ] }, @@ -702,5 +704,182 @@ "count": 1 } ] + }, + { + "label": "remove a URN", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "urns", + "modification": "remove", + "urns": [ + "tel:+255788555222" + ] + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "language": "fra", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+255788555111?id=20121&priority=1000" + ] + }, + "events": [ + { + "type": "contact_urns_changed", + "created_on": "2018-07-06T12:30:00.123456789Z", + "urns": [ + "tel:+255788555111?id=20121&priority=1000" + ] + } + ] + } + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555111'", + "count": 1 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555222'", + "count": 0 + } + ] + }, + { + "label": "set URNs", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "urns", + "modification": "set", + "urns": [ + "tel:+255788555111", + "tel:+255788555333" + ] + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "language": "fra", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+255788555111", + "tel:+255788555333" + ] + }, + "events": [ + { + "type": "contact_urns_changed", + "created_on": "2018-07-06T12:30:00.123456789Z", + "urns": [ + "tel:+255788555111", + "tel:+255788555333" + ] + } + ] + } + }, + "db_assertions": [ + { + "query": "SELECT count(DISTINCT path) FROM contacts_contacturn WHERE path IN ('+255788555111', '+255788555222', '+255788555333')", + "count": 3 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555111'", + "count": 1 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555222'", + "count": 0 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555333'", + "count": 1 + } + ] + }, + { + "label": "clear URNs", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "urns", + "modification": "set", + "urns": [] + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "language": "fra", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z" + }, + "events": [ + { + "type": "contact_urns_changed", + "created_on": "2018-07-06T12:30:00.123456789Z", + "urns": [] + } + ] + } + }, + "db_assertions": [ + { + "query": "SELECT count(DISTINCT path) FROM contacts_contacturn WHERE path IN ('+255788555111', '+255788555222', '+255788555333')", + "count": 3 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555111'", + "count": 0 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555222'", + "count": 0 + }, + { + "query": "SELECT count(*) FROM contacts_contacturn WHERE contact_id = 10000 AND path = '+255788555333'", + "count": 0 + } + ] } ] \ No newline at end of file From 4f3c3645d43b7b40862f0bfe97f5c80aa3f090bf Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 25 Jun 2020 14:11:49 -0500 Subject: [PATCH 38/55] Add test --- go.sum | 2 -- models/contacts_test.go | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index 34bb3d0dc..ef05a4cd1 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,6 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.93.1-0.20200625163928-0821a2360d55 h1:wU9ErkZmKHrGGNago7kQoQ8fdNQlxbPkYEDq8n9GlUE= -github.com/nyaruka/goflow v0.93.1-0.20200625163928-0821a2360d55/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/goflow v0.93.1 h1:7sh4B6iLEdKS4MMwVqRqI2q7Q8kKd5MKbR0FfXUywjY= github.com/nyaruka/goflow v0.93.1/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= diff --git a/models/contacts_test.go b/models/contacts_test.go index a000a4d58..dbde3ce89 100644 --- a/models/contacts_test.go +++ b/models/contacts_test.go @@ -375,3 +375,72 @@ func TestUpdateContactStatus(t *testing.T) { testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND is_stopped = TRUE`, []interface{}{CathyID}, 1) } + +func TestUpdateContactURNs(t *testing.T) { + testsuite.Reset() + ctx := testsuite.CTX() + db := testsuite.DB() + testsuite.Reset() + + oa, err := GetOrgAssets(ctx, db, Org1) + assert.NoError(t, err) + + numInitialURNs := 0 + db.Get(&numInitialURNs, `SELECT count(*) FROM contacts_contacturn`) + + assertContactURNs := func(contactID ContactID, expected []string) { + var actual []string + err = db.Select(&actual, `SELECT identity FROM contacts_contacturn WHERE contact_id = $1 ORDER BY priority DESC`, contactID) + assert.NoError(t, err) + assert.Equal(t, expected, actual, "URNs mismatch for contact %d", contactID) + } + + assertContactURNs(CathyID, []string{"tel:+16055741111"}) + assertContactURNs(BobID, []string{"tel:+16055742222"}) + assertContactURNs(GeorgeID, []string{"tel:+16055743333"}) + + cathyURN := urns.URN(fmt.Sprintf("tel:+16055741111?id=%d", CathyURNID)) + bobURN := urns.URN(fmt.Sprintf("tel:+16055742222?id=%d", BobURNID)) + + // give Cathy a new higher priority URN + err = UpdateContactURNs(ctx, db, oa, []*ContactURNsChanged{{CathyID, Org1, []urns.URN{"tel:+16055700001", cathyURN}}}) + assert.NoError(t, err) + + assertContactURNs(CathyID, []string{"tel:+16055700001", "tel:+16055741111"}) + + // give Bob a new lower priority URN + err = UpdateContactURNs(ctx, db, oa, []*ContactURNsChanged{{BobID, Org1, []urns.URN{bobURN, "tel:+16055700002"}}}) + assert.NoError(t, err) + + assertContactURNs(BobID, []string{"tel:+16055742222", "tel:+16055700002"}) + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 0) // shouldn't be any orphan URNs + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+2) // but 2 new URNs + + // remove a URN from Cathy + err = UpdateContactURNs(ctx, db, oa, []*ContactURNsChanged{{CathyID, Org1, []urns.URN{"tel:+16055700001"}}}) + assert.NoError(t, err) + + assertContactURNs(CathyID, []string{"tel:+16055700001"}) + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 1) // now orphaned + + // steal a URN from Bob + err = UpdateContactURNs(ctx, db, oa, []*ContactURNsChanged{{CathyID, Org1, []urns.URN{"tel:+16055700001", "tel:+16055700002"}}}) + assert.NoError(t, err) + + assertContactURNs(CathyID, []string{"tel:+16055700001", "tel:+16055700002"}) + assertContactURNs(BobID, []string{"tel:+16055742222"}) + + // steal the URN back from Cathy whilst simulataneously adding new URN to Cathy and not-changing anything for George + err = UpdateContactURNs(ctx, db, oa, []*ContactURNsChanged{ + {BobID, Org1, []urns.URN{"tel:+16055742222", "tel:+16055700002"}}, + {CathyID, Org1, []urns.URN{"tel:+16055700001", "tel:+16055700003"}}, + {GeorgeID, Org1, []urns.URN{"tel:+16055743333"}}, + }) + assert.NoError(t, err) + + assertContactURNs(CathyID, []string{"tel:+16055700001", "tel:+16055700003"}) + assertContactURNs(BobID, []string{"tel:+16055742222", "tel:+16055700002"}) + assertContactURNs(GeorgeID, []string{"tel:+16055743333"}) + + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+3) +} From cffeda283f12ae69c49e226f774f74ea1eef9cea Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 25 Jun 2020 15:27:34 -0500 Subject: [PATCH 39/55] Update CHANGELOG.md for v5.5.34 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bcac595..4d403506a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.34 +---------- + * Fix detaching URNs + v5.5.33 ---------- * Update to latest goflow v0.93.0 From 401e6565e805b55581fa2da3c05f4eb9bc9517f1 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 26 Jun 2020 10:57:28 -0500 Subject: [PATCH 40/55] Update to latest goflow and add tests for field modifiers --- go.mod | 2 +- go.sum | 4 +- models/contacts.go | 13 +- models/locations.go | 7 +- models/locations_test.go | 5 +- models/orgs.go | 3 + web/contact/contact.go | 5 +- web/contact/testdata/modify.json | 266 ++++++++++++++++++++++++-- web/contact/testdata/parse_query.json | 4 +- web/server.go | 5 +- 10 files changed, 283 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index 8035b84ed..737c7cf9d 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.93.1 + github.com/nyaruka/goflow v0.94.0 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index ef05a4cd1..ae87d00d0 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.93.1 h1:7sh4B6iLEdKS4MMwVqRqI2q7Q8kKd5MKbR0FfXUywjY= -github.com/nyaruka/goflow v0.93.1/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.94.0 h1:fmUdADrFsJjClsbxJMd0R0uMyYWtQNr4aiURBI31ZKo= +github.com/nyaruka/goflow v0.94.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/models/contacts.go b/models/contacts.go index 08988a3b8..f7b565b33 100644 --- a/models/contacts.go +++ b/models/contacts.go @@ -17,7 +17,6 @@ import ( "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/excellent/types" "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" "github.com/nyaruka/goflow/utils/uuids" "github.com/nyaruka/null" "github.com/olivere/elastic" @@ -421,12 +420,12 @@ func (c *Contact) Status() flows.ContactStatus { // fieldValueEnvelope is our utility struct for the value of a field type fieldValueEnvelope struct { - Text types.XText `json:"text"` - Datetime *types.XDateTime `json:"datetime,omitempty"` - Number *types.XNumber `json:"number,omitempty"` - State utils.LocationPath `json:"state,omitempty"` - District utils.LocationPath `json:"district,omitempty"` - Ward utils.LocationPath `json:"ward,omitempty"` + Text types.XText `json:"text"` + Datetime *types.XDateTime `json:"datetime,omitempty"` + Number *types.XNumber `json:"number,omitempty"` + State envs.LocationPath `json:"state,omitempty"` + District envs.LocationPath `json:"district,omitempty"` + Ward envs.LocationPath `json:"ward,omitempty"` } type ContactURN struct { diff --git a/models/locations.go b/models/locations.go index 4c81b80bb..f90b93fca 100644 --- a/models/locations.go +++ b/models/locations.go @@ -5,10 +5,11 @@ import ( "encoding/json" "time" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/envs" + "github.com/jmoiron/sqlx" "github.com/lib/pq" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/utils" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -102,7 +103,7 @@ func loadLocations(ctx context.Context, db sqlx.Queryer, orgID OrgID) ([]assets. } // then read it in - hierarchy, err := utils.ReadLocationHierarchy(locationJSON) + hierarchy, err := envs.ReadLocationHierarchy(locationJSON) if err != nil { return nil, errors.Wrapf(err, "error unmarshalling hierarchy: %s", string(locationJSON)) } diff --git a/models/locations_test.go b/models/locations_test.go index 1e1dc498b..ab29379d3 100644 --- a/models/locations_test.go +++ b/models/locations_test.go @@ -3,8 +3,9 @@ package models import ( "testing" - "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/goflow/envs" "github.com/nyaruka/mailroom/testsuite" + "github.com/stretchr/testify/assert" ) @@ -30,7 +31,7 @@ func TestLocations(t *testing.T) { tcs := []struct { Name string - Level utils.LocationLevel + Level envs.LocationLevel Aliases []string NumChildren int }{ diff --git a/models/orgs.go b/models/orgs.go index 4f5735a15..f609f1e3c 100644 --- a/models/orgs.go +++ b/models/orgs.go @@ -112,6 +112,9 @@ func (o *Org) MaxValueLength() int { return o.env.MaxValueLength() } // DefaultLocale combines the default languages and countries into a locale func (o *Org) DefaultLocale() envs.Locale { return o.env.DefaultLocale() } +// LocationResolver returns a resolver for locations +func (o *Org) LocationResolver() envs.LocationResolver { return o.env.LocationResolver() } + // Equal return whether we are equal to the passed in environment func (o *Org) Equal(env envs.Environment) bool { return o.env.Equal(env) } diff --git a/web/contact/contact.go b/web/contact/contact.go index da83519f4..6a50f6ab2 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -307,6 +307,9 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac results := make(map[models.ContactID]modifyResult) + // create an environment instance with location support + env := flows.NewEnvironment(org.Env(), org.SessionAssets().Locations()) + // create scenes for our contacts scenes := make([]*models.Scene, 0, len(contacts)) for _, contact := range contacts { @@ -324,7 +327,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac // apply our modifiers for _, mod := range mods { - mod.Apply(org.Env(), org.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) + mod.Apply(env, org.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) } results[contact.ID()] = result diff --git a/web/contact/testdata/modify.json b/web/contact/testdata/modify.json index 9398f838d..916273895 100644 --- a/web/contact/testdata/modify.json +++ b/web/contact/testdata/modify.json @@ -161,7 +161,64 @@ ] }, { - "label": "set valid numeric field", + "label": "set text field with valid value", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "field", + "field": { + "key": "gender", + "name": "Gender" + }, + "value": "M" + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "fields": { + "gender": { + "text": "M" + } + } + }, + "events": [ + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:01.123456789Z", + "field": { + "key": "gender", + "name": "Gender" + }, + "value": { + "text": "M" + } + } + ] + } + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM contacts_contact WHERE id = 10000 AND fields = '{\"3a5891e4-756e-4dc9-8e12-b7a766168824\": {\"text\": \"M\"}}'", + "count": 1 + } + ] + }, + { + "label": "set numeric field with valid value", "method": "POST", "path": "/mr/contact/modify", "body": { @@ -176,10 +233,7 @@ "key": "age", "name": "Age" }, - "value": { - "text": "24", - "number": 24 - } + "value": "24" } ] }, @@ -202,13 +256,16 @@ "age": { "text": "24", "number": 24 + }, + "gender": { + "text": "M" } } }, "events": [ { "type": "contact_field_changed", - "created_on": "2018-07-06T12:30:00.123456789Z", + "created_on": "2018-07-06T12:30:01.123456789Z", "field": { "key": "age", "name": "Age" @@ -220,7 +277,7 @@ }, { "type": "contact_groups_changed", - "created_on": "2018-07-06T12:30:01.123456789Z", + "created_on": "2018-07-06T12:30:02.123456789Z", "groups_added": [ { "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638", @@ -233,7 +290,7 @@ }, "db_assertions": [ { - "query": "SELECT count(*) FROM contacts_contact WHERE id = 10000 AND fields = '{\"903f51da-2717-47c7-a0d3-f2f32877013d\": {\"text\": \"24\", \"number\": 24}}'", + "query": "SELECT count(*) FROM contacts_contact WHERE id = 10000 AND fields = '{\"3a5891e4-756e-4dc9-8e12-b7a766168824\": {\"text\": \"M\"}, \"903f51da-2717-47c7-a0d3-f2f32877013d\": {\"text\": \"24\", \"number\": 24}}'", "count": 1 }, { @@ -247,7 +304,7 @@ ] }, { - "label": "clear field", + "label": "set date field with valid value", "method": "POST", "path": "/mr/contact/modify", "body": { @@ -256,13 +313,173 @@ 10000 ], "modifiers": [ + { + "type": "field", + "field": { + "key": "joined", + "name": "Joined" + }, + "value": "26/06/2020" + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "groups": [ + { + "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638", + "name": "Doctors" + } + ], + "fields": { + "age": { + "text": "24", + "number": 24 + }, + "gender": { + "text": "M" + }, + "joined": { + "text": "26/06/2020", + "datetime": "2020-06-26T05:30:01.123456-07:00" + } + } + }, + "events": [ + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:02.123456789Z", + "field": { + "key": "joined", + "name": "Joined" + }, + "value": { + "text": "26/06/2020", + "datetime": "2020-06-26T05:30:01.123456-07:00" + } + } + ] + } + } + }, + { + "label": "set state field with valid value", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "field", + "field": { + "key": "state", + "name": "State" + }, + "value": "BORNO" + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "groups": [ + { + "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638", + "name": "Doctors" + } + ], + "fields": { + "age": { + "text": "24", + "number": 24 + }, + "gender": { + "text": "M" + }, + "joined": { + "text": "26/06/2020", + "datetime": "2020-06-26T05:30:01.123456-07:00" + }, + "state": { + "text": "BORNO", + "state": "Nigeria > Borno" + } + } + }, + "events": [ + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:01.123456789Z", + "field": { + "key": "state", + "name": "State" + }, + "value": { + "text": "BORNO", + "state": "Nigeria > Borno" + } + } + ] + } + } + }, + { + "label": "clear fields", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "field", + "field": { + "key": "gender", + "name": "Gender" + }, + "value": "" + }, { "type": "field", "field": { "key": "age", "name": "Age" }, - "value": null + "value": "" + }, + { + "type": "field", + "field": { + "key": "joined", + "name": "Joined" + }, + "value": "" + }, + { + "type": "field", + "field": { + "key": "state", + "name": "State" + }, + "value": "" } ] }, @@ -280,6 +497,15 @@ { "type": "contact_field_changed", "created_on": "2018-07-06T12:30:00.123456789Z", + "field": { + "key": "gender", + "name": "Gender" + }, + "value": null + }, + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:01.123456789Z", "field": { "key": "age", "name": "Age" @@ -288,13 +514,31 @@ }, { "type": "contact_groups_changed", - "created_on": "2018-07-06T12:30:01.123456789Z", + "created_on": "2018-07-06T12:30:02.123456789Z", "groups_removed": [ { "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638", "name": "Doctors" } ] + }, + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:03.123456789Z", + "field": { + "key": "joined", + "name": "Joined" + }, + "value": null + }, + { + "type": "contact_field_changed", + "created_on": "2018-07-06T12:30:04.123456789Z", + "field": { + "key": "state", + "name": "State" + }, + "value": null } ] } diff --git a/web/contact/testdata/parse_query.json b/web/contact/testdata/parse_query.json index a6d1223a6..48cd71ee7 100644 --- a/web/contact/testdata/parse_query.json +++ b/web/contact/testdata/parse_query.json @@ -32,7 +32,7 @@ }, "status": 200, "response": { - "query": "age \u003e 10", + "query": "age > 10", "elastic_query": { "bool": { "must": [ @@ -104,7 +104,7 @@ }, "status": 200, "response": { - "query": "age \u003e 10", + "query": "age > 10", "elastic_query": { "bool": { "must": [ diff --git a/web/server.go b/web/server.go index 2f4f2bbf5..aba325c1c 100644 --- a/web/server.go +++ b/web/server.go @@ -8,14 +8,15 @@ import ( "sync" "time" + "github.com/nyaruka/goflow/utils/jsonx" "github.com/nyaruka/mailroom/config" - "github.com/olivere/elastic" "github.com/aws/aws-sdk-go/service/s3/s3iface" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" + "github.com/olivere/elastic" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -126,7 +127,7 @@ func (s *Server) WrapJSONHandler(handler JSONHandler) http.HandlerFunc { } } - serialized, serr := json.MarshalIndent(value, "", " ") + serialized, serr := jsonx.MarshalPretty(value) if serr != nil { logrus.WithError(err).WithField("http_request", r).Error("error serializing handler response") w.WriteHeader(http.StatusInternalServerError) From 4a2c4288748a46b6babcf65b57e00096e6944e5d Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Mon, 29 Jun 2020 08:50:13 -0500 Subject: [PATCH 41/55] Update CHANGELOG.md for v5.5.35 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d403506a..44faa0df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.35 +---------- + * Update to latest goflow and add tests for field modifiers + v5.5.34 ---------- * Fix detaching URNs From 3bc49506c05f268ded3ff9636a9a84f39d893b91 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 1 Jul 2020 10:03:30 -0500 Subject: [PATCH 42/55] Fail flow starts which can't be started --- go.mod | 2 +- go.sum | 2 ++ models/starts.go | 21 +++++++++++++++ tasks/starts/worker.go | 14 +++++++++- tasks/starts/worker_test.go | 53 +++++++++++++++++++++++++++++++------ web/contact/contact.go | 18 ++++++------- 6 files changed, 90 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 737c7cf9d..bb9465eac 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.94.0 + github.com/nyaruka/goflow v0.94.1 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index ae87d00d0..d8d541667 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbz github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= github.com/nyaruka/goflow v0.94.0 h1:fmUdADrFsJjClsbxJMd0R0uMyYWtQNr4aiURBI31ZKo= github.com/nyaruka/goflow v0.94.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.94.1 h1:FzbA4Age1i5GuQ9su/E4z7HRa7f5ghv+GeNGUn5nPfA= +github.com/nyaruka/goflow v0.94.1/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/models/starts.go b/models/starts.go index 485c5a5bd..9bc56dd86 100644 --- a/models/starts.go +++ b/models/starts.go @@ -18,8 +18,10 @@ type StartID null.Int // NilStartID is our constant for a nil start id var NilStartID = StartID(0) +// StartType is the type for the type of a start type StartType string +// start type constants const ( StartTypeManual = StartType("M") StartTypeAPI = StartType("A") @@ -27,6 +29,17 @@ const ( StartTypeTrigger = StartType("T") ) +// StartStatus is the type for the status of a start +type StartStatus string + +// start status constants +const ( + StartStatusPending = StartStatus("P") + StartStatusStarting = StartStatus("S") + StartStatusComplete = StartStatus("C") + StartStatusFailed = StartStatus("F") +) + // RestartParticipants is our type for the bool of restarting participatants type RestartParticipants bool @@ -55,7 +68,15 @@ func MarkStartStarted(ctx context.Context, db *sqlx.DB, startID StartID, contact return errors.Wrapf(err, "error setting start as started") } return nil +} +// MarkStartFailed sets the status for the passed in flow start to F +func MarkStartFailed(ctx context.Context, db *sqlx.DB, startID StartID) error { + _, err := db.Exec("UPDATE flows_flowstart SET status = 'F', modified_on = NOW() WHERE id = $1", startID) + if err != nil { + return errors.Wrapf(err, "error setting start as failed") + } + return nil } // FlowStartBatch represents a single flow batch that needs to be started diff --git a/tasks/starts/worker.go b/tasks/starts/worker.go index 11d530c30..3eee0b5e1 100644 --- a/tasks/starts/worker.go +++ b/tasks/starts/worker.go @@ -6,6 +6,7 @@ import ( "time" "github.com/nyaruka/gocommon/urns" + "github.com/nyaruka/goflow/contactql" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" @@ -43,7 +44,18 @@ func handleFlowStart(ctx context.Context, mr *mailroom.Mailroom, task *queue.Tas return errors.Wrapf(err, "error unmarshalling flow start task: %s", string(task.Task)) } - return CreateFlowBatches(ctx, mr.DB, mr.RP, mr.ElasticClient, startTask) + err = CreateFlowBatches(ctx, mr.DB, mr.RP, mr.ElasticClient, startTask) + if err != nil { + models.MarkStartFailed(ctx, mr.DB, startTask.ID()) + + // if error is user created query error.. don't escalate error to sentry + isQueryError, _ := contactql.IsQueryError(err) + if !isQueryError { + return err + } + } + + return nil } // CreateFlowBatches takes our master flow start and creates batches of flow starts for all the unique contacts diff --git a/tasks/starts/worker_test.go b/tasks/starts/worker_test.go index e0c074de6..552de3f38 100644 --- a/tasks/starts/worker_test.go +++ b/tasks/starts/worker_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/nyaruka/goflow/utils/uuids" + "github.com/nyaruka/mailroom" + "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/hooks" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/queue" @@ -14,6 +16,7 @@ import ( "github.com/olivere/elastic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStarts(t *testing.T) { @@ -32,7 +35,9 @@ func TestStarts(t *testing.T) { elastic.SetHealthcheck(false), elastic.SetSniff(false), ) - assert.NoError(t, err) + require.NoError(t, err) + + mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: rp, ElasticClient: es} // insert a flow run for one of our contacts // TODO: can be replaced with a normal flow start of another flow once we support flows with waits @@ -54,6 +59,7 @@ func TestStarts(t *testing.T) { ContactCount int BatchCount int TotalCount int + Status models.StartStatus }{ { Label: "empty flow start", @@ -62,6 +68,7 @@ func TestStarts(t *testing.T) { ContactCount: 0, BatchCount: 0, TotalCount: 0, + Status: models.StartStatusComplete, }, { Label: "Single group", @@ -71,6 +78,7 @@ func TestStarts(t *testing.T) { ContactCount: 121, BatchCount: 2, TotalCount: 121, + Status: models.StartStatusComplete, }, { Label: "Group and Contact (but all already active)", @@ -81,6 +89,7 @@ func TestStarts(t *testing.T) { ContactCount: 121, BatchCount: 2, TotalCount: 0, + Status: models.StartStatusComplete, }, { Label: "Contact restart", @@ -92,6 +101,7 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 1, + Status: models.StartStatusComplete, }, { Label: "Previous group and one new contact", @@ -102,6 +112,7 @@ func TestStarts(t *testing.T) { ContactCount: 122, BatchCount: 2, TotalCount: 1, + Status: models.StartStatusComplete, }, { Label: "Single contact, no restart", @@ -111,6 +122,7 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 0, + Status: models.StartStatusComplete, }, { Label: "Single contact, include active, but no restart", @@ -121,6 +133,7 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 0, + Status: models.StartStatusComplete, }, { Label: "Single contact, include active and restart", @@ -132,6 +145,7 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 1, + Status: models.StartStatusComplete, }, { Label: "Query start", @@ -170,6 +184,19 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 1, + Status: models.StartStatusComplete, + }, + { + Label: "Query start with invalid query", + FlowID: models.SingleMessageFlowID, + Query: "xyz = 45", + RestartParticipants: true, + IncludeActive: true, + Queue: queue.HandlerQueue, + ContactCount: 0, + BatchCount: 0, + TotalCount: 0, + Status: models.StartStatusFailed, }, { Label: "New Contact", @@ -179,10 +206,11 @@ func TestStarts(t *testing.T) { ContactCount: 1, BatchCount: 1, TotalCount: 1, + Status: models.StartStatusComplete, }, } - for i, tc := range tcs { + for _, tc := range tcs { mes.NextResponse = tc.QueryResponse // handle our start task @@ -195,7 +223,10 @@ func TestStarts(t *testing.T) { err := models.InsertFlowStarts(ctx, db, []*models.FlowStart{start}) assert.NoError(t, err) - err = CreateFlowBatches(ctx, db, rp, es, start) + startJSON, err := json.Marshal(start) + require.NoError(t, err) + + err = handleFlowStart(ctx, mr, &queue.Task{Type: queue.StartFlow, Task: startJSON}) assert.NoError(t, err) // pop all our tasks and execute them @@ -219,14 +250,20 @@ func TestStarts(t *testing.T) { } // assert our count of batches - assert.Equal(t, tc.BatchCount, count, "%d: unexpected batch count", i) + assert.Equal(t, tc.BatchCount, count, "unexpected batch count in '%s'", tc.Label) // assert our count of total flow runs created testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun where flow_id = $1 AND start_id = $2 AND is_active = FALSE`, - []interface{}{tc.FlowID, start.ID()}, tc.TotalCount, "%d: unexpected total run count", i) + []interface{}{tc.FlowID, start.ID()}, tc.TotalCount, "unexpected total run count in '%s'", tc.Label) - // flow start should be complete - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where status = 'C' AND id = $1 AND contact_count = $2`, - []interface{}{start.ID(), tc.ContactCount}, 1, "%d: start status not set to complete", i) + // assert final status + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where status = $2 AND id = $1`, + []interface{}{start.ID(), tc.Status}, 1, "status mismatch in '%s'", tc.Label) + + // assert final contact count + if tc.Status != models.StartStatusFailed { + testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where contact_count = $2 AND id = $1`, + []interface{}{start.ID(), tc.ContactCount}, 1, "contact count mismatch in '%s'", tc.Label) + } } } diff --git a/web/contact/contact.go b/web/contact/contact.go index 6a50f6ab2..53f4085af 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -89,12 +89,11 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac request.GroupUUID, request.Query, request.Sort, request.Offset, request.PageSize) if err != nil { - switch cause := errors.Cause(err).(type) { - case *contactql.QueryError: - return cause, http.StatusBadRequest, nil - default: - return nil, http.StatusInternalServerError, err + isQueryError, qerr := contactql.IsQueryError(err) + if isQueryError { + return qerr, http.StatusBadRequest, nil } + return nil, http.StatusInternalServerError, err } // normalize and inspect the query @@ -181,12 +180,11 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte parsed, err := contactql.ParseQuery(request.Query, env.RedactionPolicy(), env.DefaultCountry(), org.SessionAssets()) if err != nil { - switch cause := errors.Cause(err).(type) { - case *contactql.QueryError: - return cause, http.StatusBadRequest, nil - default: - return nil, http.StatusInternalServerError, err + isQueryError, qerr := contactql.IsQueryError(err) + if isQueryError { + return qerr, http.StatusBadRequest, nil } + return nil, http.StatusInternalServerError, err } // normalize and inspect the query From d45d6a45b176e8c354ef31cea363ae6de1738bfa Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 1 Jul 2020 11:12:51 -0500 Subject: [PATCH 43/55] Ignore missing assets when reading modifiers --- web/contact/contact.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/contact/contact.go b/web/contact/contact.go index 53f4085af..4f77fcb37 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -290,7 +290,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac // build up our modifiers mods := make([]flows.Modifier, len(request.Modifiers)) for i, m := range request.Modifiers { - mod, err := modifiers.ReadModifier(org.SessionAssets(), m, nil) + mod, err := modifiers.ReadModifier(org.SessionAssets(), m, assets.IgnoreMissing) if err != nil { return errors.Wrapf(err, "error in modifier: %s", string(m)), http.StatusBadRequest, nil } From 3092377161f9166fecc8076de87d2455ddd39646 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 1 Jul 2020 11:59:21 -0500 Subject: [PATCH 44/55] Remove unused code --- web/contact/contact.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/contact/contact.go b/web/contact/contact.go index 430e328f5..52f88aba9 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -338,17 +338,12 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction") } - modifiedContactIDs := make([]models.ContactID, 0, len(contacts)) - // apply our events for _, scene := range scenes { err := models.HandleEvents(ctx, tx, s.RP, org, scene, results[scene.ContactID()].Events) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying events") } - if len(results[scene.ContactID()].Events) > 0 { - modifiedContactIDs = append(modifiedContactIDs, scene.ContactID()) - } } // gather all our pre commit events, group them by hook and apply them From 9643d8f38dae8c35cbd3a20f9ccad60ef373245e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 1 Jul 2020 12:14:53 -0500 Subject: [PATCH 45/55] Add new test for multiple modifiers in the same scene --- web/contact/testdata/modify.json | 90 ++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/web/contact/testdata/modify.json b/web/contact/testdata/modify.json index 916273895..31b23cad6 100644 --- a/web/contact/testdata/modify.json +++ b/web/contact/testdata/modify.json @@ -1125,5 +1125,95 @@ "count": 0 } ] + }, + { + "label": "multiple modifiers of different types", + "method": "POST", + "path": "/mr/contact/modify", + "body": { + "org_id": 1, + "contact_ids": [ + 10000 + ], + "modifiers": [ + { + "type": "name", + "name": "Juan" + }, + { + "type": "language", + "language": "spa" + }, + { + "type": "groups", + "modification": "add", + "groups": [ + { + "name": "Testers", + "uuid": "5e9d8fab-5e7e-4f51-b533-261af5dea70d" + } + ] + }, + { + "type": "urns", + "modification": "set", + "urns": [ + "tel:+255788555111" + ] + } + ] + }, + "status": 200, + "response": { + "10000": { + "contact": { + "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", + "id": 10000, + "name": "Juan", + "language": "spa", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+255788555111" + ], + "groups": [ + { + "uuid": "5e9d8fab-5e7e-4f51-b533-261af5dea70d", + "name": "Testers" + } + ] + }, + "events": [ + { + "type": "contact_name_changed", + "created_on": "2018-07-06T12:30:00.123456789Z", + "name": "Juan" + }, + { + "type": "contact_language_changed", + "created_on": "2018-07-06T12:30:01.123456789Z", + "language": "spa" + }, + { + "type": "contact_groups_changed", + "created_on": "2018-07-06T12:30:02.123456789Z", + "groups_added": [ + { + "uuid": "5e9d8fab-5e7e-4f51-b533-261af5dea70d", + "name": "Testers" + } + ] + }, + { + "type": "contact_urns_changed", + "created_on": "2018-07-06T12:30:03.123456789Z", + "urns": [ + "tel:+255788555111" + ] + } + ] + } + } } ] \ No newline at end of file From bf23c034ece364b0a7b79c0e12d3c56588bc3718 Mon Sep 17 00:00:00 2001 From: Nic Pottier Date: Wed, 1 Jul 2020 10:25:39 -0700 Subject: [PATCH 46/55] read country from templates --- mailroom_test.dump | Bin 1841016 -> 1841272 bytes models/templates.go | 7 ++++--- models/templates_test.go | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/mailroom_test.dump b/mailroom_test.dump index 66d06d9e180355ee4fe8bfd974a4edf3eecb8062..6035262fb00ce4050e9c2cfe7d4cb702f2dc549f 100644 GIT binary patch delta 69267 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

mc9V?Z$L1R97 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 X&`AC>{LlExskbCBzfvJEe>whtDUqXp delta 69623 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 Date: Wed, 1 Jul 2020 11:34:31 -0700 Subject: [PATCH 47/55] Update CHANGELOG.md for v5.5.36 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44faa0df7..fea437846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +v5.5.36 +---------- + * Sead country from templates + * Ignore missing assets when reading modifiers + * Fail flow starts which can't be started + v5.5.35 ---------- * Update to latest goflow and add tests for field modifiers From 76f12e81421e7c5e528bd2d8600bb7ca1c095e87 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Wed, 1 Jul 2020 16:28:51 -0500 Subject: [PATCH 48/55] Tweak tests so UUIDs aren't reset between web tests --- go.mod | 2 +- go.sum | 6 ++---- services/tickets/mailgun/testdata/receive.json | 4 ++-- web/flow/testdata/clone.json | 10 +++++----- web/testing.go | 3 ++- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index bb9465eac..b2cb6c341 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.2.0 - github.com/nyaruka/goflow v0.94.1 + github.com/nyaruka/goflow v0.94.2 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 diff --git a/go.sum b/go.sum index d8d541667..96c2f514f 100644 --- a/go.sum +++ b/go.sum @@ -128,10 +128,8 @@ github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= github.com/nyaruka/gocommon v1.2.0 h1:gCmVCXYZFwKDMqQj8R1jNlK+7a06khKFq3zX8fBBbzw= github.com/nyaruka/gocommon v1.2.0/go.mod h1:9Y21Fd6iZXDLHWTRiZAc6b4LQSCi6HEEQK4SB45Yav4= -github.com/nyaruka/goflow v0.94.0 h1:fmUdADrFsJjClsbxJMd0R0uMyYWtQNr4aiURBI31ZKo= -github.com/nyaruka/goflow v0.94.0/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= -github.com/nyaruka/goflow v0.94.1 h1:FzbA4Age1i5GuQ9su/E4z7HRa7f5ghv+GeNGUn5nPfA= -github.com/nyaruka/goflow v0.94.1/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= +github.com/nyaruka/goflow v0.94.2 h1:NduUutwkEQIMNBV1XLTIVPVAbRFyCFtjD45lri04xRo= +github.com/nyaruka/goflow v0.94.2/go.mod h1:PDah2hr5WzODnUFK4VWWQkg7SqnYclf7P9Ik5u/VOG0= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/services/tickets/mailgun/testdata/receive.json b/services/tickets/mailgun/testdata/receive.json index 915b2a34c..8e0f2aa54 100644 --- a/services/tickets/mailgun/testdata/receive.json +++ b/services/tickets/mailgun/testdata/receive.json @@ -59,7 +59,7 @@ "response": { "action": "forwarded", "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef", - "msg_uuid": "d2f852ec-7b4e-457f-ae7f-f8b243c49ff5" + "msg_uuid": "692926ea-09d6-4942-bd38-d266ec8d3716" }, "db_assertions": [ { @@ -78,7 +78,7 @@ "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": [ { "status": 200, - "body": "{\"id\": \"\u003c20200426161758.1.590432020254B2BF@tickets.rapidpro.io\u003e\", \"message\": \"Queued. Thank you.\"}" + "body": "{\"id\": \"<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>\", \"message\": \"Queued. Thank you.\"}" } ] }, diff --git a/web/flow/testdata/clone.json b/web/flow/testdata/clone.json index e9cbcf318..2c392e895 100644 --- a/web/flow/testdata/clone.json +++ b/web/flow/testdata/clone.json @@ -212,24 +212,24 @@ "groups": [ { "name": "Testers", - "uuid": "8720f157-ca1c-432f-9c0b-2014ddc77094" + "uuid": "312d3af0-a565-4c96-ba00-bd7f0d08e671" } ], "type": "add_contact_groups", - "uuid": "692926ea-09d6-4942-bd38-d266ec8d3716" + "uuid": "5ecda5fc-951c-437b-a17e-f85e49829fb9" }, { "text": "Your birthdate is soon", "type": "send_msg", - "uuid": "c34b6c7d-fa06-4563-92a3-d648ab64bccb" + "uuid": "a4d15ed4-5b24-407f-b86e-4b881f09a186" } ], "exits": [ { - "uuid": "5802813d-6c58-4292-8228-9728778b6c98" + "uuid": "b88ce93d-4360-4455-a691-235cbe720980" } ], - "uuid": "d2f852ec-7b4e-457f-ae7f-f8b243c49ff5" + "uuid": "970b8069-50f5-4f6f-8f41-6b2d9f33d623" } ], "revision": 106, diff --git a/web/testing.go b/web/testing.go index b0ca44d7a..804cb2628 100644 --- a/web/testing.go +++ b/web/testing.go @@ -32,6 +32,8 @@ func RunWebTests(t *testing.T, truthFile string) { wg := &sync.WaitGroup{} defer uuids.SetGenerator(uuids.DefaultGenerator) + uuids.SetGenerator(uuids.NewSeededGenerator(123456)) + defer dates.SetNowSource(dates.DefaultNowSource) server := NewServer(context.Background(), config.Mailroom, db, rp, nil, nil, wg) @@ -67,7 +69,6 @@ func RunWebTests(t *testing.T, truthFile string) { require.NoError(t, err) for i, tc := range tcs { - uuids.SetGenerator(uuids.NewSeededGenerator(123456)) dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC))) var clonedMocks *httpx.MockRequestor From 60a0f41020267e5f6017ebcf268b3ff9d034076e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 2 Jul 2020 10:10:17 -0500 Subject: [PATCH 49/55] Fix reading of modifiers so always ignore modifier that becomes noop --- goflow/modifiers.go | 29 +++++++++++++++++ goflow/modifiers_test.go | 63 +++++++++++++++++++++++++++++++++++++ web/contact/contact.go | 14 +++------ web/contact/contact_test.go | 5 +++ web/surveyor/surveyor.go | 27 +++++----------- 5 files changed, 110 insertions(+), 28 deletions(-) create mode 100644 goflow/modifiers.go create mode 100644 goflow/modifiers_test.go diff --git a/goflow/modifiers.go b/goflow/modifiers.go new file mode 100644 index 000000000..66be2f9a1 --- /dev/null +++ b/goflow/modifiers.go @@ -0,0 +1,29 @@ +package goflow + +import ( + "encoding/json" + + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/flows/actions/modifiers" + + "github.com/pkg/errors" +) + +// ReadModifiers reads modifiers from the given JSON +func ReadModifiers(sa flows.SessionAssets, data []json.RawMessage, allowMissing bool) ([]flows.Modifier, error) { + mods := make([]flows.Modifier, 0, len(data)) + for _, m := range data { + mod, err := modifiers.ReadModifier(sa, m, assets.IgnoreMissing) + + // if this modifier turned into a no-op, ignore + if err == modifiers.ErrNoModifier && allowMissing { + continue + } + if err != nil { + return nil, errors.Wrapf(err, "error reading modifier: %s", string(m)) + } + mods = append(mods, mod) + } + return mods, nil +} diff --git a/goflow/modifiers_test.go b/goflow/modifiers_test.go new file mode 100644 index 000000000..89b7e15ae --- /dev/null +++ b/goflow/modifiers_test.go @@ -0,0 +1,63 @@ +package goflow_test + +import ( + "encoding/json" + "testing" + + "github.com/nyaruka/mailroom/goflow" + "github.com/nyaruka/mailroom/models" + "github.com/nyaruka/mailroom/testsuite" + + "github.com/stretchr/testify/assert" +) + +func TestReadModifiers(t *testing.T) { + ctx := testsuite.CTX() + db := testsuite.DB() + + oa, err := models.GetOrgAssets(ctx, db, models.Org1) + assert.NoError(t, err) + + // can read empty list + mods, err := goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{}, true) + assert.NoError(t, err) + assert.Equal(t, 0, len(mods)) + + // can read non-empty list + mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + []byte(`{"type": "name", "name": "Bob"}`), + []byte(`{"type": "field", "field": {"key": "gender", "name": "Gender"}, "value": "M"}`), + []byte(`{"type": "language", "language": "spa"}`), + }, true) + assert.NoError(t, err) + assert.Equal(t, 3, len(mods)) + assert.Equal(t, "name", mods[0].Type()) + assert.Equal(t, "field", mods[1].Type()) + assert.Equal(t, "language", mods[2].Type()) + + // modifier with missing asset can be ignored + mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + []byte(`{"type": "name", "name": "Bob"}`), + []byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`), + []byte(`{"type": "language", "language": "spa"}`), + }, true) + assert.NoError(t, err) + assert.Equal(t, 2, len(mods)) + assert.Equal(t, "name", mods[0].Type()) + assert.Equal(t, "language", mods[1].Type()) + + // modifier with missing asset or an error if allowMissing is false + mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + []byte(`{"type": "name", "name": "Bob"}`), + []byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`), + []byte(`{"type": "language", "language": "spa"}`), + }, false) + assert.EqualError(t, err, `error reading modifier: {"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}: no modifier to return because of missing assets`) + + // error if any modifier structurally invalid + mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + []byte(`{"type": "field", "value": "O"}`), + []byte(`{"type": "language", "language": "spa"}`), + }, false) + assert.EqualError(t, err, `error reading modifier: {"type": "field", "value": "O"}: field 'field' is required`) +} diff --git a/web/contact/contact.go b/web/contact/contact.go index 52f88aba9..6b2cf7c46 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -8,8 +8,8 @@ import ( "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/contactql" "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/flows/actions/modifiers" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/goflow" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/web" @@ -287,14 +287,10 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to clone orgs") } - // build up our modifiers - mods := make([]flows.Modifier, len(request.Modifiers)) - for i, m := range request.Modifiers { - mod, err := modifiers.ReadModifier(org.SessionAssets(), m, assets.IgnoreMissing) - if err != nil { - return errors.Wrapf(err, "error in modifier: %s", string(m)), http.StatusBadRequest, nil - } - mods[i] = mod + // read the modifiers from the request + mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, false) + if err != nil { + return nil, http.StatusBadRequest, err } // load our contacts diff --git a/web/contact/contact_test.go b/web/contact/contact_test.go index 7ec0ec8f4..5665a1027 100644 --- a/web/contact/contact_test.go +++ b/web/contact/contact_test.go @@ -194,5 +194,10 @@ func TestModifyContacts(t *testing.T) { db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.CathyID) db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID) + // because we made changes to a group above, need to make sure we don't use stale org assets + models.FlushCache() + web.RunWebTests(t, "testdata/modify.json") + + models.FlushCache() } diff --git a/web/surveyor/surveyor.go b/web/surveyor/surveyor.go index 69eef2a42..1a4d36b1f 100644 --- a/web/surveyor/surveyor.go +++ b/web/surveyor/surveyor.go @@ -5,11 +5,8 @@ import ( "encoding/json" "net/http" - "github.com/nyaruka/goflow/assets" - - "github.com/nyaruka/goflow/flows/actions/modifiers" - "github.com/nyaruka/gocommon/urns" + "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/engine" "github.com/nyaruka/goflow/flows/events" @@ -17,6 +14,7 @@ import ( "github.com/nyaruka/mailroom/goflow" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" ) @@ -85,18 +83,9 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // and our modifiers - contactModifiers := make([]flows.Modifier, 0, len(request.Modifiers)) - for _, m := range request.Modifiers { - modifier, err := modifiers.ReadModifier(org.SessionAssets(), m, assets.IgnoreMissing) - - // if this modifier turned into a no-op, ignore - if err == modifiers.ErrNoModifier { - continue - } - if err != nil { - return nil, http.StatusBadRequest, errors.Wrapf(err, "error unmarshalling modifier: %s", string(m)) - } - contactModifiers = append(contactModifiers, modifier) + mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, true) + if err != nil { + return nil, http.StatusBadRequest, err } // create / assign our contact @@ -126,13 +115,13 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Wrapf(err, "error loading flow contact") } - modifierEvents := make([]flows.Event, 0, len(contactModifiers)) + modifierEvents := make([]flows.Event, 0, len(mods)) appender := func(e flows.Event) { modifierEvents = append(modifierEvents, e) } // run through each contact modifier, applying it to our contact - for _, m := range contactModifiers { + for _, m := range mods { m.Apply(org.Env(), org.SessionAssets(), flowContact, appender) } @@ -145,7 +134,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // create our sprint - sprint := engine.NewSprint(contactModifiers, modifierEvents) + sprint := engine.NewSprint(mods, modifierEvents) // write our session out tx, err := s.DB.BeginTxx(ctx, nil) From 5a54fefd8e8b414b657361ed4c61e157bfcafc26 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 2 Jul 2020 13:20:11 -0500 Subject: [PATCH 50/55] Use typed constants instead of boolean --- goflow/modifiers.go | 13 +++++++++++-- goflow/modifiers_test.go | 10 +++++----- web/contact/contact.go | 2 +- web/surveyor/surveyor.go | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/goflow/modifiers.go b/goflow/modifiers.go index 66be2f9a1..69cac2275 100644 --- a/goflow/modifiers.go +++ b/goflow/modifiers.go @@ -10,14 +10,23 @@ import ( "github.com/pkg/errors" ) +// MissingAssets is the type for defining missing assets behavior +type MissingAssets int + +// missing assets constants +const ( + IgnoreMissing MissingAssets = 0 + ErrorOnMissing MissingAssets = 1 +) + // ReadModifiers reads modifiers from the given JSON -func ReadModifiers(sa flows.SessionAssets, data []json.RawMessage, allowMissing bool) ([]flows.Modifier, error) { +func ReadModifiers(sa flows.SessionAssets, data []json.RawMessage, missing MissingAssets) ([]flows.Modifier, error) { mods := make([]flows.Modifier, 0, len(data)) for _, m := range data { mod, err := modifiers.ReadModifier(sa, m, assets.IgnoreMissing) // if this modifier turned into a no-op, ignore - if err == modifiers.ErrNoModifier && allowMissing { + if err == modifiers.ErrNoModifier && missing == IgnoreMissing { continue } if err != nil { diff --git a/goflow/modifiers_test.go b/goflow/modifiers_test.go index 89b7e15ae..a0ebd0372 100644 --- a/goflow/modifiers_test.go +++ b/goflow/modifiers_test.go @@ -19,7 +19,7 @@ func TestReadModifiers(t *testing.T) { assert.NoError(t, err) // can read empty list - mods, err := goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{}, true) + mods, err := goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{}, goflow.IgnoreMissing) assert.NoError(t, err) assert.Equal(t, 0, len(mods)) @@ -28,7 +28,7 @@ func TestReadModifiers(t *testing.T) { []byte(`{"type": "name", "name": "Bob"}`), []byte(`{"type": "field", "field": {"key": "gender", "name": "Gender"}, "value": "M"}`), []byte(`{"type": "language", "language": "spa"}`), - }, true) + }, goflow.IgnoreMissing) assert.NoError(t, err) assert.Equal(t, 3, len(mods)) assert.Equal(t, "name", mods[0].Type()) @@ -40,7 +40,7 @@ func TestReadModifiers(t *testing.T) { []byte(`{"type": "name", "name": "Bob"}`), []byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`), []byte(`{"type": "language", "language": "spa"}`), - }, true) + }, goflow.IgnoreMissing) assert.NoError(t, err) assert.Equal(t, 2, len(mods)) assert.Equal(t, "name", mods[0].Type()) @@ -51,13 +51,13 @@ func TestReadModifiers(t *testing.T) { []byte(`{"type": "name", "name": "Bob"}`), []byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`), []byte(`{"type": "language", "language": "spa"}`), - }, false) + }, goflow.ErrorOnMissing) assert.EqualError(t, err, `error reading modifier: {"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}: no modifier to return because of missing assets`) // error if any modifier structurally invalid mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ []byte(`{"type": "field", "value": "O"}`), []byte(`{"type": "language", "language": "spa"}`), - }, false) + }, goflow.ErrorOnMissing) assert.EqualError(t, err, `error reading modifier: {"type": "field", "value": "O"}: field 'field' is required`) } diff --git a/web/contact/contact.go b/web/contact/contact.go index 6b2cf7c46..adeb4839d 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -288,7 +288,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac } // read the modifiers from the request - mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, false) + mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, goflow.ErrorOnMissing) if err != nil { return nil, http.StatusBadRequest, err } diff --git a/web/surveyor/surveyor.go b/web/surveyor/surveyor.go index 1a4d36b1f..af27db1d9 100644 --- a/web/surveyor/surveyor.go +++ b/web/surveyor/surveyor.go @@ -83,7 +83,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // and our modifiers - mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, true) + mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, goflow.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, err } From 8bf084321045e84fb883a97c52653aee9094beb7 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 2 Jul 2020 14:02:23 -0500 Subject: [PATCH 51/55] Update CHANGELOG.md for v5.5.37 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fea437846..4138f37b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.37 +---------- + * Fix reading of modifiers so always ignore modifier that becomes noop + v5.5.36 ---------- * Sead country from templates From eae11be9260b36dcc2f696e3db1f7d4b9552ce82 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 3 Jul 2020 10:15:30 -0500 Subject: [PATCH 52/55] Use oa for OrgAssets vars consistently --- hooks/airtime_transferred.go | 8 ++--- hooks/broadcast_created.go | 8 ++--- hooks/campaigns.go | 12 +++---- hooks/contact_field_changed.go | 6 ++-- hooks/contact_groups_changed.go | 8 ++--- hooks/contact_language_changed.go | 4 +-- hooks/contact_modified.go | 2 +- hooks/contact_name_changed.go | 4 +-- hooks/contact_status_changed.go | 4 +-- hooks/contact_urns_changed.go | 8 ++--- hooks/email_sent.go | 2 +- hooks/hooks_test.go | 26 +++++++------- hooks/input_labels_added.go | 6 ++-- hooks/ivr_created.go | 4 +-- hooks/msg_created.go | 14 ++++---- hooks/msg_received.go | 4 +-- hooks/noop.go | 2 +- hooks/resthook_called.go | 10 +++--- hooks/service_called.go | 12 +++---- hooks/session_triggered.go | 16 ++++----- hooks/session_triggered_test.go | 14 ++++---- hooks/ticket_opened.go | 10 +++--- hooks/webhook_called.go | 10 +++--- ivr/ivr.go | 32 ++++++++--------- ivr/nexmo/nexmo_test.go | 4 +-- runner/runner.go | 2 +- runner/runner_test.go | 16 ++++----- services/tickets/utils_test.go | 10 +++--- tasks/broadcasts/worker.go | 8 ++--- tasks/broadcasts/worker_test.go | 16 ++++----- tasks/groups/worker.go | 4 +-- tasks/handler/worker.go | 48 ++++++++++++------------- tasks/ivr/cron.go | 6 ++-- tasks/ivr/worker.go | 6 ++-- tasks/starts/worker.go | 8 ++--- web/contact/contact.go | 38 ++++++++++---------- web/flow/flow.go | 4 +-- web/ivr/ivr.go | 43 +++++++++++------------ web/po/po.go | 8 ++--- web/simulation/simulation.go | 58 +++++++++++++++---------------- web/surveyor/surveyor.go | 20 +++++------ web/ticket/ticket.go | 12 +++---- 42 files changed, 267 insertions(+), 270 deletions(-) diff --git a/hooks/airtime_transferred.go b/hooks/airtime_transferred.go index f5a5c2d66..df0ab7a7c 100644 --- a/hooks/airtime_transferred.go +++ b/hooks/airtime_transferred.go @@ -25,7 +25,7 @@ type InsertAirtimeTransfersHook struct{} var insertAirtimeTransfersHook = &InsertAirtimeTransfersHook{} // Apply inserts all the airtime transfers that were created -func (h *InsertAirtimeTransfersHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertAirtimeTransfersHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // gather all our transfers transfers := make([]*models.AirtimeTransfer, 0, len(scenes)) @@ -62,7 +62,7 @@ func (h *InsertAirtimeTransfersHook) Apply(ctx context.Context, tx *sqlx.Tx, rp } // handleAirtimeTransferred is called for each airtime transferred event -func handleAirtimeTransferred(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleAirtimeTransferred(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.AirtimeTransferredEvent) status := models.AirtimeTransferStatusSuccess @@ -71,7 +71,7 @@ func handleAirtimeTransferred(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, } transfer := models.NewAirtimeTransfer( - org.OrgID(), + oa.OrgID(), status, scene.ContactID(), event.Sender, @@ -95,7 +95,7 @@ func handleAirtimeTransferred(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, // add a log for each HTTP call for _, httpLog := range event.HTTPLogs { transfer.AddLog(models.NewAirtimeTransferredLog( - org.OrgID(), + oa.OrgID(), httpLog.URL, httpLog.Request, httpLog.Response, diff --git a/hooks/broadcast_created.go b/hooks/broadcast_created.go index 77f978308..4b9d948d3 100644 --- a/hooks/broadcast_created.go +++ b/hooks/broadcast_created.go @@ -23,7 +23,7 @@ type StartBroadcastsHook struct{} var startBroadcastsHook = &StartBroadcastsHook{} // Apply queues up our broadcasts for sending -func (h *StartBroadcastsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *StartBroadcastsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { rc := rp.Get() defer rc.Close() @@ -32,7 +32,7 @@ func (h *StartBroadcastsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis. for _, e := range es { event := e.(*events.BroadcastCreatedEvent) - bcast, err := models.NewBroadcastFromEvent(ctx, tx, org, event) + bcast, err := models.NewBroadcastFromEvent(ctx, tx, oa, event) if err != nil { return errors.Wrapf(err, "error creating broadcast") } @@ -46,7 +46,7 @@ func (h *StartBroadcastsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis. priority = queue.HighPriority } - err = queue.AddTask(rc, taskQ, queue.SendBroadcast, int(org.OrgID()), bcast, priority) + err = queue.AddTask(rc, taskQ, queue.SendBroadcast, int(oa.OrgID()), bcast, priority) if err != nil { return errors.Wrapf(err, "error queuing broadcast") } @@ -57,7 +57,7 @@ func (h *StartBroadcastsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis. } // handleBroadcastCreated is called for each broadcast created event across our scene -func handleBroadcastCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleBroadcastCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.BroadcastCreatedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/campaigns.go b/hooks/campaigns.go index 0eb00c3d0..35cb0fc79 100644 --- a/hooks/campaigns.go +++ b/hooks/campaigns.go @@ -18,7 +18,7 @@ type UpdateCampaignEventsHook struct{} var updateCampaignEventsHook = &UpdateCampaignEventsHook{} // Apply will update all the campaigns for the passed in scene, minimizing the number of queries to do so -func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // these are all the events we need to delete unfired fires for deletes := make([]*models.FireDelete, 0, 5) @@ -42,7 +42,7 @@ func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *r delete(groupAdds, event.GroupID) case *events.ContactFieldChangedEvent: - field := org.FieldByKey(event.Field.Key) + field := oa.FieldByKey(event.Field.Key) if field == nil { logrus.WithFields(logrus.Fields{ "field_key": event.Field.Key, @@ -63,7 +63,7 @@ func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *r // for every group that was removed, we need to remove all event fires for them for g := range groupRemoves { - for _, c := range org.CampaignByGroupID(g) { + for _, c := range oa.CampaignByGroupID(g) { for _, e := range c.Events() { // only delete events that we qualify for or that were changed if e.QualifiesByField(s.Contact()) || fieldChanges[e.RelativeToID()] { @@ -75,7 +75,7 @@ func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *r // for every field that was changed, we need to also remove event fires and recalculate for f := range fieldChanges { - fieldEvents := org.CampaignEventsByFieldID(f) + fieldEvents := oa.CampaignEventsByFieldID(f) for _, e := range fieldEvents { // only recalculate the events if this contact qualifies for this event or this group was removed if e.QualifiesByGroup(s.Contact()) || groupRemoves[e.Campaign().GroupID()] { @@ -95,7 +95,7 @@ func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *r // add in all the events we qualify for in campaigns we are now part of for g := range groupAdds { - for _, c := range org.CampaignByGroupID(g) { + for _, c := range oa.CampaignByGroupID(g) { for _, e := range c.Events() { addEvents[e] = true } @@ -103,7 +103,7 @@ func (h *UpdateCampaignEventsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *r } // ok, for all the unique events we now calculate our fire date - tz := org.Env().Timezone() + tz := oa.Env().Timezone() now := time.Now() for ce := range addEvents { scheduled, err := ce.ScheduleForContact(tz, now, s.Contact()) diff --git a/hooks/contact_field_changed.go b/hooks/contact_field_changed.go index 962276185..239468364 100644 --- a/hooks/contact_field_changed.go +++ b/hooks/contact_field_changed.go @@ -24,7 +24,7 @@ type CommitFieldChangesHook struct{} var commitFieldChangesHook = &CommitFieldChangesHook{} // Apply squashes and writes all the field updates for the contacts -func (h *CommitFieldChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitFieldChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // our list of updates fieldUpdates := make([]interface{}, 0, len(scenes)) fieldDeletes := make(map[assets.FieldUUID][]interface{}) @@ -32,7 +32,7 @@ func (h *CommitFieldChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *red updates := make(map[assets.FieldUUID]*flows.Value, len(es)) for _, e := range es { event := e.(*events.ContactFieldChangedEvent) - field := org.FieldByKey(event.Field.Key) + field := oa.FieldByKey(event.Field.Key) if field == nil { logrus.WithFields(logrus.Fields{ "field_key": event.Field.Key, @@ -90,7 +90,7 @@ func (h *CommitFieldChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *red } // handleContactFieldChanged is called when a contact field changes -func handleContactFieldChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactFieldChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactFieldChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/contact_groups_changed.go b/hooks/contact_groups_changed.go index b0cce66d4..77784fb05 100644 --- a/hooks/contact_groups_changed.go +++ b/hooks/contact_groups_changed.go @@ -22,7 +22,7 @@ type CommitGroupChangesHook struct{} var commitGroupChangesHook = &CommitGroupChangesHook{} // Apply squashes and adds or removes all our contact groups -func (h *CommitGroupChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitGroupChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // build up our list of all adds and removes adds := make([]*models.GroupAdd, 0, len(scenes)) removes := make([]*models.GroupRemove, 0, len(scenes)) @@ -71,7 +71,7 @@ func (h *CommitGroupChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *red } // handleContactGroupsChanged is called when a group is added or removed from our contact -func handleContactGroupsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactGroupsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactGroupsChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -83,7 +83,7 @@ func handleContactGroupsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool // remove each of our groups for _, g := range event.GroupsRemoved { // look up our group id - group := org.GroupByUUID(g.UUID) + group := oa.GroupByUUID(g.UUID) if group == nil { logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -106,7 +106,7 @@ func handleContactGroupsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool // add each of our groups for _, g := range event.GroupsAdded { // look up our group id - group := org.GroupByUUID(g.UUID) + group := oa.GroupByUUID(g.UUID) if group == nil { logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/contact_language_changed.go b/hooks/contact_language_changed.go index c8722e906..344ebf6a1 100644 --- a/hooks/contact_language_changed.go +++ b/hooks/contact_language_changed.go @@ -21,7 +21,7 @@ type CommitLanguageChangesHook struct{} var commitLanguageChangesHook = &CommitLanguageChangesHook{} // Apply applies our contact language change before our commit -func (h *CommitLanguageChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitLanguageChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // build up our list of pairs of contact id and language name updates := make([]interface{}, 0, len(scenes)) for s, e := range scenes { @@ -35,7 +35,7 @@ func (h *CommitLanguageChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp * } // handleContactLanguageChanged is called when we process a contact language change -func handleContactLanguageChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactLanguageChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactLanguageChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/contact_modified.go b/hooks/contact_modified.go index fd7fec713..9f1b651de 100644 --- a/hooks/contact_modified.go +++ b/hooks/contact_modified.go @@ -15,7 +15,7 @@ type ContactModifiedHook struct{} var contactModifiedHook = &ContactModifiedHook{} // Apply squashes and updates modified_on on all the contacts passed in -func (h *ContactModifiedHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *ContactModifiedHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // our list of contact ids contactIDs := make([]models.ContactID, 0, len(scenes)) for scene := range scenes { diff --git a/hooks/contact_name_changed.go b/hooks/contact_name_changed.go index e5e50a06d..b51c75be8 100644 --- a/hooks/contact_name_changed.go +++ b/hooks/contact_name_changed.go @@ -22,7 +22,7 @@ type CommitNameChangesHook struct{} var commitNameChangesHook = &CommitNameChangesHook{} // Apply commits our contact name changes as a bulk update for the passed in map of scene -func (h *CommitNameChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitNameChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // build up our list of pairs of contact id and contact name updates := make([]interface{}, 0, len(scenes)) for s, e := range scenes { @@ -36,7 +36,7 @@ func (h *CommitNameChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redi } // handleContactNameChanged changes the name of the contact -func handleContactNameChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactNameChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactNameChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/contact_status_changed.go b/hooks/contact_status_changed.go index 31fc2c308..4124c72c1 100644 --- a/hooks/contact_status_changed.go +++ b/hooks/contact_status_changed.go @@ -22,7 +22,7 @@ type CommitStatusChangesHook struct{} var commitStatusChangesHook = &CommitStatusChangesHook{} // Apply commits our contact status change -func (h *CommitStatusChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitStatusChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { statusChanges := make([]*models.ContactStatusChange, 0, len(scenes)) for scene, es := range scenes { @@ -39,7 +39,7 @@ func (h *CommitStatusChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *re } // handleContactStatusChanged updates contact status -func handleContactStatusChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactStatusChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactStatusChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/contact_urns_changed.go b/hooks/contact_urns_changed.go index be00f60e6..59ddf4bbc 100644 --- a/hooks/contact_urns_changed.go +++ b/hooks/contact_urns_changed.go @@ -22,14 +22,14 @@ type CommitURNChangesHook struct{} var commitURNChangesHook = &CommitURNChangesHook{} // Apply adds all our URNS in a batch -func (h *CommitURNChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitURNChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // gather all our urn changes, we only care about the last change for each scene changes := make([]*models.ContactURNsChanged, 0, len(scenes)) for _, sessionChanges := range scenes { changes = append(changes, sessionChanges[len(sessionChanges)-1].(*models.ContactURNsChanged)) } - err := models.UpdateContactURNs(ctx, tx, org, changes) + err := models.UpdateContactURNs(ctx, tx, oa, changes) if err != nil { return errors.Wrapf(err, "error updating contact urns") } @@ -38,7 +38,7 @@ func (h *CommitURNChangesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis } // handleContactURNsChanged is called for each contact urn changed event that is encountered -func handleContactURNsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleContactURNsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ContactURNsChangedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -49,7 +49,7 @@ func handleContactURNsChanged(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, // create our URN changed event change := &models.ContactURNsChanged{ ContactID: scene.ContactID(), - OrgID: org.OrgID(), + OrgID: oa.OrgID(), URNs: event.URNs, } diff --git a/hooks/email_sent.go b/hooks/email_sent.go index b294ec3cc..b9bb397c8 100644 --- a/hooks/email_sent.go +++ b/hooks/email_sent.go @@ -17,7 +17,7 @@ func init() { } // goflow now sends email so this just logs the event -func handleEmailSent(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleEmailSent(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.EmailSentEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), diff --git a/hooks/hooks_test.go b/hooks/hooks_test.go index 5dd5ff57e..2fa9fd20b 100644 --- a/hooks/hooks_test.go +++ b/hooks/hooks_test.go @@ -175,10 +175,10 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { ctx := testsuite.CTX() rp := testsuite.RP() - org, err := models.GetOrgAssets(ctx, db, models.OrgID(1)) + oa, err := models.GetOrgAssets(ctx, db, models.OrgID(1)) assert.NoError(t, err) - org, err = org.Clone(ctx, db) + oa, err = oa.Clone(ctx, db) assert.NoError(t, err) // reuse id from one of our real flows @@ -194,11 +194,11 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { assert.NoError(t, err) // add it to our org - flow := org.SetFlow(flowID, flowUUID, testFlow.Name(), flowDef) + flow := oa.SetFlow(flowID, flowUUID, testFlow.Name(), flowDef) assert.NoError(t, err) options := runner.NewStartOptions() - options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, session []*models.Session) error { + options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, session []*models.Session) error { for _, s := range session { msg := tc.Msgs[s.ContactID()] if msg != nil { @@ -210,12 +210,12 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { options.TriggerBuilder = func(contact *flows.Contact) (flows.Trigger, error) { msg := tc.Msgs[models.ContactID(contact.ID())] if msg == nil { - return triggers.NewManual(org.Env(), flow.FlowReference(), contact, false, nil), nil + return triggers.NewManual(oa.Env(), flow.FlowReference(), contact, false, nil), nil } - return triggers.NewMsg(org.Env(), flow.FlowReference(), contact, msg, nil), nil + return triggers.NewMsg(oa.Env(), flow.FlowReference(), contact, msg, nil), nil } - _, err = runner.StartFlow(ctx, db, rp, org, flow, []models.ContactID{models.CathyID, models.BobID, models.GeorgeID, models.AlexandriaID}, options) + _, err = runner.StartFlow(ctx, db, rp, oa, flow, []models.ContactID{models.CathyID, models.BobID, models.GeorgeID, models.AlexandriaID}, options) assert.NoError(t, err) results := make(map[models.ContactID]modifyResult) @@ -224,11 +224,11 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { scenes := make([]*models.Scene, 0, len(tc.Modifiers)) for contactID, mods := range tc.Modifiers { - contacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{contactID}) + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{contactID}) assert.NoError(t, err) contact := contacts[0] - flowContact, err := contact.FlowContact(org) + flowContact, err := contact.FlowContact(oa) assert.NoError(t, err) result := modifyResult{ @@ -240,7 +240,7 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { // apply our modifiers for _, mod := range mods { - mod.Apply(org.Env(), org.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) + mod.Apply(oa.Env(), oa.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) } results[contact.ID()] = result @@ -252,11 +252,11 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { assert.NoError(t, err) for _, scene := range scenes { - err := models.HandleEvents(ctx, tx, rp, org, scene, results[scene.ContactID()].Events) + err := models.HandleEvents(ctx, tx, rp, oa, scene, results[scene.ContactID()].Events) assert.NoError(t, err) } - err = models.ApplyEventPreCommitHooks(ctx, tx, rp, org, scenes) + err = models.ApplyEventPreCommitHooks(ctx, tx, rp, oa, scenes) assert.NoError(t, err) err = tx.Commit() @@ -265,7 +265,7 @@ func RunHookTestCases(t *testing.T, tcs []HookTestCase) { tx, err = db.BeginTxx(ctx, nil) assert.NoError(t, err) - err = models.ApplyEventPostCommitHooks(ctx, tx, rp, org, scenes) + err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, scenes) assert.NoError(t, err) err = tx.Commit() diff --git a/hooks/input_labels_added.go b/hooks/input_labels_added.go index e093a6fa7..34903b536 100644 --- a/hooks/input_labels_added.go +++ b/hooks/input_labels_added.go @@ -23,7 +23,7 @@ type CommitAddedLabelsHook struct{} var commitAddedLabelsHook = &CommitAddedLabelsHook{} // Apply applies our input labels added, committing them in a single batch -func (h *CommitAddedLabelsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *CommitAddedLabelsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // build our list of msg label adds, we dedupe these so we never double add in the same transaction seen := make(map[string]bool) adds := make([]*models.MsgLabelAdd, 0, len(scenes)) @@ -44,7 +44,7 @@ func (h *CommitAddedLabelsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redi } // handleInputLabelsAdded is called for each input labels added event in a scene -func handleInputLabelsAdded(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleInputLabelsAdded(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.InputLabelsAddedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -54,7 +54,7 @@ func handleInputLabelsAdded(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, or // for each label add an insertion for _, l := range event.Labels { - label := org.LabelByUUID(l.UUID) + label := oa.LabelByUUID(l.UUID) if label == nil { return errors.Errorf("unable to find label with UUID: %s", l.UUID) } diff --git a/hooks/ivr_created.go b/hooks/ivr_created.go index ff4b73841..9d9004ceb 100644 --- a/hooks/ivr_created.go +++ b/hooks/ivr_created.go @@ -53,7 +53,7 @@ func (h *CommitIVRHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, } // handleIVRCreated creates the db msg for the passed in event -func handleIVRCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleIVRCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.IVRCreatedEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -72,7 +72,7 @@ func handleIVRCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *mod return nil } - msg, err := models.NewOutgoingIVR(org.OrgID(), conn, event.Msg, event.CreatedOn()) + msg, err := models.NewOutgoingIVR(oa.OrgID(), conn, event.Msg, event.CreatedOn()) if err != nil { return errors.Wrapf(err, "error creating outgoing ivr say: %s", event.Msg.Text()) } diff --git a/hooks/msg_created.go b/hooks/msg_created.go index c5e1e16ad..79fffc653 100644 --- a/hooks/msg_created.go +++ b/hooks/msg_created.go @@ -30,7 +30,7 @@ type SendMessagesHook struct{} var sendMessagesHook = &SendMessagesHook{} // Apply sends all non-android messages to courier -func (h *SendMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *SendMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { rc := rp.Get() defer rc.Close() @@ -172,7 +172,7 @@ func (h *CommitMessagesHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.P } // handleMsgCreated creates the db msg for the passed in event -func handleMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.MsgCreatedEvent) // must be in a session @@ -197,7 +197,7 @@ func handleMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *mod if scene.Session().SessionType() == models.MessagingFlow { urn := event.Msg.URN() if models.GetURNInt(urn, "id") == 0 { - urn, err := models.GetOrCreateURN(ctx, tx, org, scene.ContactID(), event.Msg.URN()) + urn, err := models.GetOrCreateURN(ctx, tx, oa, scene.ContactID(), event.Msg.URN()) if err != nil { return errors.Wrapf(err, "unable to get or create URN: %s", event.Msg.URN()) } @@ -209,13 +209,13 @@ func handleMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *mod // get our channel var channel *models.Channel if event.Msg.Channel() != nil { - channel = org.ChannelByUUID(event.Msg.Channel().UUID) + channel = oa.ChannelByUUID(event.Msg.Channel().UUID) if channel == nil { return errors.Errorf("unable to load channel with uuid: %s", event.Msg.Channel().UUID) } } - msg, err := models.NewOutgoingMsg(org.OrgID(), channel, scene.ContactID(), event.Msg, event.CreatedOn()) + msg, err := models.NewOutgoingMsg(oa.OrgID(), channel, scene.ContactID(), event.Msg, event.CreatedOn()) if err != nil { return errors.Wrapf(err, "error creating outgoing message to %s", event.Msg.URN()) } @@ -235,7 +235,7 @@ func handleMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *mod } // handlePreMsgCreated clears our timeout on our session so that courier can send it when the message is sent, that will be set by courier when sent -func handlePreMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handlePreMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.MsgCreatedEvent) // we only clear timeouts on messaging flows @@ -247,7 +247,7 @@ func handlePreMsgCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org * var channel *models.Channel if event.Msg.Channel() != nil { - channel = org.ChannelByUUID(event.Msg.Channel().UUID) + channel = oa.ChannelByUUID(event.Msg.Channel().UUID) if channel == nil { return errors.Errorf("unable to load channel with uuid: %s", event.Msg.Channel().UUID) } diff --git a/hooks/msg_received.go b/hooks/msg_received.go index e89063aa5..1b91e4453 100644 --- a/hooks/msg_received.go +++ b/hooks/msg_received.go @@ -16,7 +16,7 @@ func init() { } // handleMsgReceived takes care of creating the incoming message for surveyor flows, it is a noop for all other flows -func handleMsgReceived(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleMsgReceived(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.MsgReceivedEvent) // we only care about msg received events when dealing with surveyor flows @@ -31,7 +31,7 @@ func handleMsgReceived(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *mo "urn": event.Msg.URN(), }).Debug("msg received event") - msg := models.NewIncomingMsg(org.OrgID(), nil, scene.ContactID(), &event.Msg, event.CreatedOn()) + msg := models.NewIncomingMsg(oa.OrgID(), nil, scene.ContactID(), &event.Msg, event.CreatedOn()) // we'll commit this message with all the others scene.AppendToEventPreCommitHook(commitMessagesHook, msg) diff --git a/hooks/noop.go b/hooks/noop.go index 909d6b8fa..36b8b5818 100644 --- a/hooks/noop.go +++ b/hooks/noop.go @@ -23,6 +23,6 @@ func init() { } // NoopHandler is our hook for events we ignore in a run -func NoopHandler(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, event flows.Event) error { +func NoopHandler(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, event flows.Event) error { return nil } diff --git a/hooks/resthook_called.go b/hooks/resthook_called.go index 19bd6f50c..753d86176 100644 --- a/hooks/resthook_called.go +++ b/hooks/resthook_called.go @@ -22,7 +22,7 @@ type InsertWebhookEventHook struct{} var insertWebhookEventHook = &InsertWebhookEventHook{} // Apply inserts all the webook events that were created -func (h *InsertWebhookEventHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertWebhookEventHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { events := make([]*models.WebhookEvent, 0, len(scenes)) for _, rs := range scenes { for _, r := range rs { @@ -39,7 +39,7 @@ func (h *InsertWebhookEventHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *red } // handleResthookCalled is called for each resthook call in a scene -func handleResthookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleResthookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ResthookCalledEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -48,15 +48,15 @@ func handleResthookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org }).Debug("resthook called") // look up our resthook id - resthook := org.ResthookBySlug(event.Resthook) + resthook := oa.ResthookBySlug(event.Resthook) if resthook == nil { - logrus.WithField("org_id", org.OrgID()).WithField("resthook", event.Resthook).Errorf("unable to find resthook with slug, ignoring event") + logrus.WithField("org_id", oa.OrgID()).WithField("resthook", event.Resthook).Errorf("unable to find resthook with slug, ignoring event") return nil } // create an event for this call re := models.NewWebhookEvent( - org.OrgID(), + oa.OrgID(), resthook.ID(), string(event.Payload), event.CreatedOn(), diff --git a/hooks/service_called.go b/hooks/service_called.go index 4d33481be..a7c9aea71 100644 --- a/hooks/service_called.go +++ b/hooks/service_called.go @@ -24,7 +24,7 @@ type InsertHTTPLogsHook struct{} var insertHTTPLogsHook = &InsertHTTPLogsHook{} // Apply inserts all the classifier logs that were created -func (h *InsertHTTPLogsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertHTTPLogsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // gather all our logs logs := make([]*models.HTTPLog, 0, len(scenes)) for _, ls := range scenes { @@ -42,18 +42,18 @@ func (h *InsertHTTPLogsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.P } // handleServiceCalled is called for each service called event -func handleServiceCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleServiceCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.ServiceCalledEvent) var classifier *models.Classifier var ticketer *models.Ticketer if event.Service == "classifier" { - classifier = org.ClassifierByUUID(event.Classifier.UUID) + classifier = oa.ClassifierByUUID(event.Classifier.UUID) if classifier == nil { return errors.Errorf("unable to find classifier with UUID: %s", event.Classifier.UUID) } } else if event.Service == "ticketer" { - ticketer = org.TicketerByUUID(event.Ticketer.UUID) + ticketer = oa.TicketerByUUID(event.Ticketer.UUID) if ticketer == nil { return errors.Errorf("unable to find ticketer with UUID: %s", event.Ticketer.UUID) } @@ -73,7 +73,7 @@ func handleServiceCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org * if event.Service == "classifier" { log = models.NewClassifierCalledLog( - org.OrgID(), + oa.OrgID(), classifier.ID(), httpLog.URL, httpLog.Request, @@ -84,7 +84,7 @@ func handleServiceCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org * ) } else if event.Service == "ticketer" { log = models.NewTicketerCalledLog( - org.OrgID(), + oa.OrgID(), ticketer.ID(), httpLog.URL, httpLog.Request, diff --git a/hooks/session_triggered.go b/hooks/session_triggered.go index 6e07dbba2..8ad351709 100644 --- a/hooks/session_triggered.go +++ b/hooks/session_triggered.go @@ -28,7 +28,7 @@ type InsertStartHook struct{} var insertStartHook = &InsertStartHook{} // Apply queues up our flow starts -func (h *StartStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *StartStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { rc := rp.Get() defer rc.Close() @@ -46,7 +46,7 @@ func (h *StartStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, priority = queue.HighPriority } - err := queue.AddTask(rc, taskQ, queue.StartFlow, int(org.OrgID()), start, priority) + err := queue.AddTask(rc, taskQ, queue.StartFlow, int(oa.OrgID()), start, priority) if err != nil { return errors.Wrapf(err, "error queuing flow start") } @@ -57,7 +57,7 @@ func (h *StartStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, } // Apply inserts our starts -func (h *InsertStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { rc := rp.Get() defer rc.Close() @@ -69,7 +69,7 @@ func (h *InsertStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool event := e.(*events.SessionTriggeredEvent) // look up our flow - f, err := org.Flow(event.Flow.UUID) + f, err := oa.Flow(event.Flow.UUID) if err != nil { return errors.Wrapf(err, "unable to load flow with UUID: %s", event.Flow.UUID) } @@ -78,20 +78,20 @@ func (h *InsertStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool // load our groups by uuid groupIDs := make([]models.GroupID, 0, len(event.Groups)) for i := range event.Groups { - group := org.GroupByUUID(event.Groups[i].UUID) + group := oa.GroupByUUID(event.Groups[i].UUID) if group != nil { groupIDs = append(groupIDs, group.ID()) } } // load our contacts by uuid - contactIDs, err := models.ContactIDsFromReferences(ctx, tx, org, event.Contacts) + contactIDs, err := models.ContactIDsFromReferences(ctx, tx, oa, event.Contacts) if err != nil { return errors.Wrapf(err, "error loading contacts by reference") } // create our start - start := models.NewFlowStart(org.OrgID(), models.StartTypeFlowAction, flow.FlowType(), flow.ID(), models.DoRestartParticipants, models.DoIncludeActive). + start := models.NewFlowStart(oa.OrgID(), models.StartTypeFlowAction, flow.FlowType(), flow.ID(), models.DoRestartParticipants, models.DoIncludeActive). WithGroupIDs(groupIDs). WithContactIDs(contactIDs). WithURNs(event.URNs). @@ -116,7 +116,7 @@ func (h *InsertStartHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool } // handleSessionTriggered queues this event for being started after our scene are committed -func handleSessionTriggered(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleSessionTriggered(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.SessionTriggeredEvent) logrus.WithFields(logrus.Fields{ diff --git a/hooks/session_triggered_test.go b/hooks/session_triggered_test.go index b1c2fe069..1e6deddf7 100644 --- a/hooks/session_triggered_test.go +++ b/hooks/session_triggered_test.go @@ -22,10 +22,10 @@ func TestSessionTriggered(t *testing.T) { db := testsuite.DB() ctx := testsuite.CTX() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) - simpleFlow, err := org.FlowByID(models.SingleMessageFlowID) + simpleFlow, err := oa.FlowByID(models.SingleMessageFlowID) assert.NoError(t, err) contactRef := &flows.ContactReference{ @@ -37,7 +37,7 @@ func TestSessionTriggered(t *testing.T) { } tcs := []HookTestCase{ - HookTestCase{ + { Actions: ContactActionMap{ models.CathyID: []flows.Action{ actions.NewStartSession(newActionUUID(), simpleFlow.FlowReference(), nil, []*flows.ContactReference{contactRef}, []*assets.GroupReference{groupRef}, nil, true), @@ -93,22 +93,22 @@ func TestQuerySessionTriggered(t *testing.T) { db := testsuite.DB() ctx := testsuite.CTX() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) - favoriteFlow, err := org.FlowByID(models.FavoritesFlowID) + favoriteFlow, err := oa.FlowByID(models.FavoritesFlowID) assert.NoError(t, err) sessionAction := actions.NewStartSession(newActionUUID(), favoriteFlow.FlowReference(), nil, nil, nil, nil, true) sessionAction.ContactQuery = "name ~ @contact.name" tcs := []HookTestCase{ - HookTestCase{ + { Actions: ContactActionMap{ models.CathyID: []flows.Action{sessionAction}, }, SQLAssertions: []SQLAssertion{ - SQLAssertion{ + { SQL: `select count(*) from flows_flowstart where flow_id = $1 AND start_type = 'F' AND status = 'P' AND query = 'name ~ "Cathy"' AND parent_summary IS NOT NULL;`, Args: []interface{}{models.FavoritesFlowID}, Count: 1, diff --git a/hooks/ticket_opened.go b/hooks/ticket_opened.go index 4f0d07862..7f019101d 100644 --- a/hooks/ticket_opened.go +++ b/hooks/ticket_opened.go @@ -24,7 +24,7 @@ type InsertTicketsHook struct{} var insertTicketsHook = &InsertTicketsHook{} // Apply inserts all the airtime transfers that were created -func (h *InsertTicketsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertTicketsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // gather all our tickets tickets := make([]*models.Ticket, 0, len(scenes)) @@ -44,17 +44,17 @@ func (h *InsertTicketsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Po } // handleTicketOpened is called for each ticket opened event -func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.TicketOpenedEvent) - ticketer := org.TicketerByUUID(event.Ticket.Ticketer.UUID) + ticketer := oa.TicketerByUUID(event.Ticket.Ticketer.UUID) if ticketer == nil { return errors.Errorf("unable to find ticketer with UUID: %s", event.Ticket.Ticketer.UUID) } ticket := models.NewTicket( event.Ticket.UUID, - org.OrgID(), + oa.OrgID(), scene.ContactID(), ticketer.ID(), event.Ticket.ExternalID, @@ -62,7 +62,7 @@ func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *m event.Ticket.Body, map[string]interface{}{ "contact-uuid": scene.Contact().UUID(), - "contact-display": tickets.GetContactDisplay(org.Env(), scene.Contact()), + "contact-display": tickets.GetContactDisplay(oa.Env(), scene.Contact()), }, ) diff --git a/hooks/webhook_called.go b/hooks/webhook_called.go index 4c8615de4..1d158af49 100644 --- a/hooks/webhook_called.go +++ b/hooks/webhook_called.go @@ -23,7 +23,7 @@ type UnsubscribeResthookHook struct{} var unsubscribeResthookHook = &UnsubscribeResthookHook{} // Apply squashes and applies all our resthook unsubscriptions -func (h *UnsubscribeResthookHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene map[*models.Scene][]interface{}) error { +func (h *UnsubscribeResthookHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene map[*models.Scene][]interface{}) error { // gather all our unsubscribes unsubs := make([]*models.ResthookUnsubscribe, 0, len(scene)) for _, us := range scene { @@ -46,7 +46,7 @@ type InsertWebhookResultHook struct{} var insertWebhookResultHook = &InsertWebhookResultHook{} // Apply inserts all the webook results that were created -func (h *InsertWebhookResultHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { +func (h *InsertWebhookResultHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scenes map[*models.Scene][]interface{}) error { // gather all our results results := make([]*models.WebhookResult, 0, len(scenes)) for _, rs := range scenes { @@ -64,7 +64,7 @@ func (h *InsertWebhookResultHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *re } // handleWebhookCalled is called for each webhook call in a scene -func handleWebhookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, scene *models.Scene, e flows.Event) error { +func handleWebhookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, scene *models.Scene, e flows.Event) error { event := e.(*events.WebhookCalledEvent) logrus.WithFields(logrus.Fields{ "contact_uuid": scene.ContactUUID(), @@ -78,7 +78,7 @@ func handleWebhookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org * // if this was a resthook and the status was 410, that means we should remove it if event.Status == flows.CallStatusSubscriberGone { unsub := &models.ResthookUnsubscribe{ - OrgID: org.OrgID(), + OrgID: oa.OrgID(), Slug: event.Resthook, URL: event.URL, } @@ -94,7 +94,7 @@ func handleWebhookCalled(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org * // create a result for this call result := models.NewWebhookResult( - org.OrgID(), scene.ContactID(), + oa.OrgID(), scene.ContactID(), event.URL, event.Request, event.StatusCode, response, time.Millisecond*time.Duration(event.ElapsedMS), event.CreatedOn(), diff --git a/ivr/ivr.go b/ivr/ivr.go index 23f276c41..a00f4a286 100644 --- a/ivr/ivr.go +++ b/ivr/ivr.go @@ -105,14 +105,14 @@ func HangupCall(ctx context.Context, config *config.Config, db *sqlx.DB, conn *m // no matter what mark our call as failed defer conn.MarkFailed(ctx, db, time.Now()) - // load our org - org, err := models.GetOrgAssets(ctx, db, conn.OrgID()) + // load our org assets + oa, err := models.GetOrgAssets(ctx, db, conn.OrgID()) if err != nil { return errors.Wrapf(err, "unable to load org") } // and our channel - channel := org.ChannelByID(conn.ChannelID()) + channel := oa.ChannelByID(conn.ChannelID()) if channel == nil { return errors.Wrapf(err, "unable to load channel") } @@ -159,7 +159,7 @@ func HangupCall(ctx context.Context, config *config.Config, db *sqlx.DB, conn *m } // RequestCallStart creates a new ChannelSession for the passed in flow start and contact, returning the created session -func RequestCallStart(ctx context.Context, config *config.Config, db *sqlx.DB, org *models.OrgAssets, start *models.FlowStartBatch, contact *models.Contact) (*models.ChannelConnection, error) { +func RequestCallStart(ctx context.Context, config *config.Config, db *sqlx.DB, oa *models.OrgAssets, start *models.FlowStartBatch, contact *models.Contact) (*models.ChannelConnection, error) { // find a tel URL for the contact telURN := urns.NilURN for _, u := range contact.URNs() { @@ -179,7 +179,7 @@ func RequestCallStart(ctx context.Context, config *config.Config, db *sqlx.DB, o } // build our channel assets, we need these to calculate the preferred channel for a call - channels, err := org.Channels() + channels, err := oa.Channels() if err != nil { return nil, errors.Wrapf(err, "unable to load channels for org") } @@ -207,7 +207,7 @@ func RequestCallStart(ctx context.Context, config *config.Config, db *sqlx.DB, o // create our channel connection conn, err := models.InsertIVRConnection( - ctx, db, org.OrgID(), channel.ID(), start.StartID(), contact.ID(), models.URNID(urnID), + ctx, db, oa.OrgID(), channel.ID(), start.StartID(), contact.ID(), models.URNID(urnID), models.ConnectionDirectionOut, models.ConnectionStatusPending, "", ) if err != nil { @@ -319,7 +319,7 @@ func WriteErrorResponse(ctx context.Context, db *sqlx.DB, client Client, conn *m // StartIVRFlow takes care of starting the flow in the passed in start for the passed in contact and URN func StartIVRFlow( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, client Client, resumeURL string, org *models.OrgAssets, + ctx context.Context, db *sqlx.DB, rp *redis.Pool, client Client, resumeURL string, oa *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, startID models.StartID, r *http.Request, w http.ResponseWriter) error { @@ -334,13 +334,13 @@ func StartIVRFlow( return errors.Wrapf(err, "unable to load start: %d", startID) } - flow, err := org.FlowByID(start.FlowID()) + flow, err := oa.FlowByID(start.FlowID()) if err != nil { return errors.Wrapf(err, "unable to load flow: %d", startID) } // our flow contact - contact, err := c.FlowContact(org) + contact, err := c.FlowContact(oa) if err != nil { return errors.Wrapf(err, "error loading flow contact") } @@ -359,12 +359,12 @@ func StartIVRFlow( var trigger flows.Trigger if len(start.ParentSummary()) > 0 { - trigger, err = triggers.NewFlowActionVoice(org.Env(), flowRef, contact, connRef, start.ParentSummary(), false) + trigger, err = triggers.NewFlowActionVoice(oa.Env(), flowRef, contact, connRef, start.ParentSummary(), false) if err != nil { return errors.Wrap(err, "unable to create flow action trigger") } } else { - trigger = triggers.NewManualVoice(org.Env(), flowRef, contact, connRef, false, params) + trigger = triggers.NewManualVoice(oa.Env(), flowRef, contact, connRef, false, params) } // mark our connection as started @@ -374,7 +374,7 @@ func StartIVRFlow( } // we set the connection on the session before our event hooks fire so that IVR messages can be created with the right connection reference - hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { for _, session := range sessions { session.SetChannelConnection(conn) } @@ -382,7 +382,7 @@ func StartIVRFlow( } // start our flow - sessions, err := runner.StartFlowForContacts(ctx, db, rp, org, flow, []flows.Trigger{trigger}, hook, true) + sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true) if err != nil { return errors.Wrapf(err, "error starting flow") } @@ -538,7 +538,7 @@ func ResumeIVRFlow( resume := resumes.NewMsg(oa.Env(), contact, msgIn) // hook to set our connection on our session before our event hooks run - hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { for _, session := range sessions { session.SetChannelConnection(conn) } @@ -579,7 +579,7 @@ func ResumeIVRFlow( // HandleIVRStatus is called on status callbacks for an IVR call. We let the client decide whether the call has // ended for some reason and update the state of the call and session if so -func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error { +func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error { // read our status and duration from our client status, duration := client.StatusForRequest(r) @@ -597,7 +597,7 @@ func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, org *mode return errors.Wrapf(err, "unable to load start: %d", conn.StartID()) } - flow, err := org.FlowByID(start.FlowID()) + flow, err := oa.FlowByID(start.FlowID()) if err != nil { return errors.Wrapf(err, "unable to load flow: %d", start.FlowID()) } diff --git a/ivr/nexmo/nexmo_test.go b/ivr/nexmo/nexmo_test.go index 760518e15..20d6dade4 100644 --- a/ivr/nexmo/nexmo_test.go +++ b/ivr/nexmo/nexmo_test.go @@ -42,10 +42,10 @@ func TestResponseForSprint(t *testing.T) { config.Mailroom.AttachmentDomain = "mailroom.io" defer func() { config.Mailroom.AttachmentDomain = "" }() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) - channel := org.ChannelByUUID(models.NexmoChannelUUID) + channel := oa.ChannelByUUID(models.NexmoChannelUUID) assert.NotNil(t, channel) c, err := NewClientFromChannel(channel) diff --git a/runner/runner.go b/runner/runner.go index 374e66435..e054df23b 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -317,7 +317,7 @@ func FireCampaignEvents( // this is our pre commit callback for our sessions, we'll mark the event fires associated // with the passed in sessions as complete in the same transaction fired := time.Now() - options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { // build up our list of event fire ids based on the session contact ids fires := make([]*models.EventFire, 0, len(sessions)) for _, s := range sessions { diff --git a/runner/runner_test.go b/runner/runner_test.go index 23d99a45e..07e7ccdc3 100644 --- a/runner/runner_test.go +++ b/runner/runner_test.go @@ -183,21 +183,21 @@ func TestContactRuns(t *testing.T) { ctx := testsuite.CTX() rp := testsuite.RP() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) - flow, err := org.FlowByID(models.FavoritesFlowID) + flow, err := oa.FlowByID(models.FavoritesFlowID) assert.NoError(t, err) // load our contact - contacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{models.CathyID}) + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{models.CathyID}) assert.NoError(t, err) - contact, err := contacts[0].FlowContact(org) + contact, err := contacts[0].FlowContact(oa) assert.NoError(t, err) - trigger := triggers.NewManual(org.Env(), flow.FlowReference(), contact, false, nil) - sessions, err := StartFlowForContacts(ctx, db, rp, org, flow, []flows.Trigger{trigger}, nil, true) + trigger := triggers.NewManual(oa.Env(), flow.FlowReference(), contact, false, nil) + sessions, err := StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, nil, true) assert.NoError(t, err) assert.NotNil(t, sessions) @@ -236,9 +236,9 @@ func TestContactRuns(t *testing.T) { // answer our first question msg := flows.NewMsgIn(flows.MsgUUID(uuids.New()), models.CathyURN, nil, tc.Message, nil) msg.SetID(10) - resume := resumes.NewMsg(org.Env(), contact, msg) + resume := resumes.NewMsg(oa.Env(), contact, msg) - session, err = ResumeFlow(ctx, db, rp, org, session, resume, nil) + session, err = ResumeFlow(ctx, db, rp, oa, session, resume, nil) assert.NoError(t, err) assert.NotNil(t, session) diff --git a/services/tickets/utils_test.go b/services/tickets/utils_test.go index ab37b7649..922aa92df 100644 --- a/services/tickets/utils_test.go +++ b/services/tickets/utils_test.go @@ -19,22 +19,22 @@ func TestGetContactDisplay(t *testing.T) { ctx := testsuite.CTX() db := testsuite.DB() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) require.NoError(t, err) - contact, err := models.LoadContact(ctx, db, org, models.CathyID) + contact, err := models.LoadContact(ctx, db, oa, models.CathyID) require.NoError(t, err) - flowContact, err := contact.FlowContact(org) + flowContact, err := contact.FlowContact(oa) require.NoError(t, err) // name if they have one - assert.Equal(t, "Cathy", tickets.GetContactDisplay(org.Env(), flowContact)) + assert.Equal(t, "Cathy", tickets.GetContactDisplay(oa.Env(), flowContact)) flowContact.SetName("") // or primary URN - assert.Equal(t, "(605) 574-1111", tickets.GetContactDisplay(org.Env(), flowContact)) + assert.Equal(t, "(605) 574-1111", tickets.GetContactDisplay(oa.Env(), flowContact)) // but not if org is anon anonEnv := envs.NewBuilder().WithRedactionPolicy(envs.RedactionPolicyURNs).Build() diff --git a/tasks/broadcasts/worker.go b/tasks/broadcasts/worker.go index 39ea998a9..19fc73e30 100644 --- a/tasks/broadcasts/worker.go +++ b/tasks/broadcasts/worker.go @@ -56,13 +56,13 @@ func CreateBroadcastBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, bc contactIDs[id] = true } - org, err := models.GetOrgAssets(ctx, db, bcast.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, bcast.OrgID()) if err != nil { return errors.Wrapf(err, "error getting org assets") } // get the contact ids for our URNs - urnMap, err := models.ContactIDsFromURNs(ctx, db, org, bcast.URNs()) + urnMap, err := models.ContactIDsFromURNs(ctx, db, oa, bcast.URNs()) if err != nil { return errors.Wrapf(err, "error getting contact ids for urns") } @@ -160,13 +160,13 @@ func SendBroadcastBatch(ctx context.Context, db *sqlx.DB, rp *redis.Pool, bcast } }() - org, err := models.GetOrgAssets(ctx, db, bcast.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, bcast.OrgID()) if err != nil { return errors.Wrapf(err, "error getting org assets") } // create this batch of messages - msgs, err := models.CreateBroadcastMessages(ctx, db, rp, org, bcast) + msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, bcast) if err != nil { return errors.Wrapf(err, "error creating broadcast messages") } diff --git a/tasks/broadcasts/worker_test.go b/tasks/broadcasts/worker_test.go index e7e8c6988..901888296 100644 --- a/tasks/broadcasts/worker_test.go +++ b/tasks/broadcasts/worker_test.go @@ -26,12 +26,12 @@ func TestBroadcastEvents(t *testing.T) { rc := testsuite.RC() defer rc.Close() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) eng := envs.Language("eng") basic := map[envs.Language]*events.BroadcastTranslation{ - eng: &events.BroadcastTranslation{ + eng: { Text: "hello world", Attachments: nil, QuickReplies: nil, @@ -83,7 +83,7 @@ func TestBroadcastEvents(t *testing.T) { for i, tc := range tcs { // handle our start task event := events.NewBroadcastCreated(tc.Translations, tc.BaseLanguage, tc.Groups, tc.Contacts, tc.URNs) - bcast, err := models.NewBroadcastFromEvent(ctx, db, org, event) + bcast, err := models.NewBroadcastFromEvent(ctx, db, oa, event) assert.NoError(t, err) err = CreateBroadcastBatches(ctx, db, rp, bcast) @@ -130,7 +130,7 @@ func TestBroadcastTask(t *testing.T) { rc := testsuite.RC() defer rc.Close() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, models.Org1) assert.NoError(t, err) eng := envs.Language("eng") @@ -142,7 +142,7 @@ func TestBroadcastTask(t *testing.T) { assert.NoError(t, err) evaluated := map[envs.Language]*models.BroadcastTranslation{ - eng: &models.BroadcastTranslation{ + eng: { Text: "hello world", Attachments: nil, QuickReplies: nil, @@ -150,7 +150,7 @@ func TestBroadcastTask(t *testing.T) { } legacy := map[envs.Language]*models.BroadcastTranslation{ - eng: &models.BroadcastTranslation{ + eng: { Text: "hi @(PROPER(contact.name)) legacy URN: @contact.tel_e164 Gender: @contact.gender", Attachments: nil, QuickReplies: nil, @@ -158,7 +158,7 @@ func TestBroadcastTask(t *testing.T) { } template := map[envs.Language]*models.BroadcastTranslation{ - eng: &models.BroadcastTranslation{ + eng: { Text: "hi @(title(contact.name)) from @globals.org_name goflow URN: @urns.tel Gender: @fields.gender", Attachments: nil, QuickReplies: nil, @@ -196,7 +196,7 @@ func TestBroadcastTask(t *testing.T) { for i, tc := range tcs { // handle our start task - bcast := models.NewBroadcast(org.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs) + bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs) err = CreateBroadcastBatches(ctx, db, rp, bcast) assert.NoError(t, err) diff --git a/tasks/groups/worker.go b/tasks/groups/worker.go index e30d08a29..3f94b0332 100644 --- a/tasks/groups/worker.go +++ b/tasks/groups/worker.go @@ -58,12 +58,12 @@ func handlePopulateDynamicGroup(ctx context.Context, mr *mailroom.Mailroom, task log.Info("starting population of dynamic group") - org, err := models.GetOrgAssets(ctx, mr.DB, t.OrgID) + oa, err := models.GetOrgAssets(ctx, mr.DB, t.OrgID) if err != nil { return errors.Wrapf(err, "unable to load org when populating group: %d", t.GroupID) } - count, err := models.PopulateDynamicGroup(ctx, mr.DB, mr.ElasticClient, org, t.GroupID, t.Query) + count, err := models.PopulateDynamicGroup(ctx, mr.DB, mr.ElasticClient, oa, t.GroupID, t.Query) if err != nil { return errors.Wrapf(err, "error populating dynamic group: %d", t.GroupID) } diff --git a/tasks/handler/worker.go b/tasks/handler/worker.go index 57e970f9e..31550ad72 100644 --- a/tasks/handler/worker.go +++ b/tasks/handler/worker.go @@ -206,13 +206,13 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType string, event *TimedEvent) error { start := time.Now() log := logrus.WithField("event_type", eventType).WithField("contact_id", event.OrgID).WithField("session_id", event.SessionID) - org, err := models.GetOrgAssets(ctx, db, event.OrgID) + oa, err := models.GetOrgAssets(ctx, db, event.OrgID) if err != nil { return errors.Wrapf(err, "error loading org") } // load our contact - contacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{event.ContactID}) + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID}) if err != nil { return errors.Wrapf(err, "error loading contact") } @@ -225,13 +225,13 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp modelContact := contacts[0] // build our flow contact - contact, err := modelContact.FlowContact(org) + contact, err := modelContact.FlowContact(oa) if err != nil { return errors.Wrapf(err, "error creating flow contact") } // get the active session for this contact - session, err := models.ActiveSessionForContact(ctx, db, org, models.MessagingFlow, contact) + session, err := models.ActiveSessionForContact(ctx, db, oa, models.MessagingFlow, contact) if err != nil { return errors.Wrapf(err, "error loading active session for contact") } @@ -263,7 +263,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp return nil } - resume = resumes.NewRunExpiration(org.Env(), contact) + resume = resumes.NewRunExpiration(oa.Env(), contact) case TimeoutEventType: if session.TimeoutOn() == nil { @@ -278,13 +278,13 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp return nil } - resume = resumes.NewWaitTimeout(org.Env(), contact) + resume = resumes.NewWaitTimeout(oa.Env(), contact) default: return errors.Errorf("unknown event type: %s", eventType) } - _, err = runner.ResumeFlow(ctx, db, rp, org, session, resume, nil) + _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, nil) if err != nil { return errors.Wrapf(err, "error resuming flow for timeout") } @@ -295,20 +295,20 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp // HandleChannelEvent is called for channel events func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType models.ChannelEventType, event *models.ChannelEvent, conn *models.ChannelConnection) (*models.Session, error) { - org, err := models.GetOrgAssets(ctx, db, event.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, event.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error loading org") } // load the channel for this event - channel := org.ChannelByID(event.ChannelID()) + channel := oa.ChannelByID(event.ChannelID()) if channel == nil { logrus.WithField("channel_id", event.ChannelID).Info("ignoring event, couldn't find channel") return nil, nil } // load our contact - contacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{event.ContactID()}) + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID()}) if err != nil { return nil, errors.Wrapf(err, "error loading contact") } @@ -325,16 +325,16 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT switch eventType { case models.NewConversationEventType: - trigger = models.FindMatchingNewConversationTrigger(org, channel) + trigger = models.FindMatchingNewConversationTrigger(oa, channel) case models.ReferralEventType: - trigger = models.FindMatchingReferralTrigger(org, channel, event.ExtraValue("referrer_id")) + trigger = models.FindMatchingReferralTrigger(oa, channel, event.ExtraValue("referrer_id")) case models.MOMissEventType: - trigger = models.FindMatchingMissedCallTrigger(org) + trigger = models.FindMatchingMissedCallTrigger(oa) case models.MOCallEventType: - trigger = models.FindMatchingMOCallTrigger(org, modelContact) + trigger = models.FindMatchingMOCallTrigger(oa, modelContact) case models.WelcomeMessateEventType: trigger = nil @@ -344,19 +344,19 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT } // make sure this URN is our highest priority (this is usually a noop) - err = modelContact.UpdatePreferredURN(ctx, db, org, event.URNID(), channel) + err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID(), channel) if err != nil { return nil, errors.Wrapf(err, "error changing primary URN") } // build our flow contact - contact, err := modelContact.FlowContact(org) + contact, err := modelContact.FlowContact(oa) if err != nil { return nil, errors.Wrapf(err, "error creating flow contact") } if event.IsNewContact() { - err = models.CalculateDynamicGroups(ctx, db, org, contact) + err = models.CalculateDynamicGroups(ctx, db, oa, contact) if err != nil { return nil, errors.Wrapf(err, "unable to initialize new contact") } @@ -369,7 +369,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT } // load our flow - flow, err := org.FlowByID(trigger.FlowID()) + flow, err := oa.FlowByID(trigger.FlowID()) if err == models.ErrNotFound { return nil, nil } @@ -380,7 +380,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT // if this is an IVR flow, we need to trigger that start (which happens in a different queue) if flow.FlowType() == models.IVRFlow && conn == nil { - err = runner.TriggerIVRFlow(ctx, db, rp, org.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil) + err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil) if err != nil { return nil, errors.Wrapf(err, "error while triggering ivr flow") } @@ -406,11 +406,11 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT case models.NewConversationEventType, models.ReferralEventType, models.MOMissEventType: channelEvent := triggers.NewChannelEvent(triggers.ChannelEventType(eventType), channel.ChannelReference()) - flowTrigger = triggers.NewChannel(org.Env(), flow.FlowReference(), contact, channelEvent, params) + flowTrigger = triggers.NewChannel(oa.Env(), flow.FlowReference(), contact, channelEvent, params) case models.MOCallEventType: urn := contacts[0].URNForID(event.URNID()) - flowTrigger = triggers.NewIncomingCall(org.Env(), flow.FlowReference(), contact, urn, channel.ChannelReference()) + flowTrigger = triggers.NewIncomingCall(oa.Env(), flow.FlowReference(), contact, urn, channel.ChannelReference()) default: return nil, errors.Errorf("unknown channel event type: %s", eventType) @@ -420,7 +420,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT // so that IVR messages can be created with the right connection reference var hook models.SessionCommitHook if conn != nil { - hook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + hook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { for _, session := range sessions { session.SetChannelConnection(conn) } @@ -428,7 +428,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT } } - sessions, err := runner.StartFlowForContacts(ctx, db, rp, org, flow, []flows.Trigger{flowTrigger}, hook, true) + sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{flowTrigger}, hook, true) if err != nil { return nil, errors.Wrapf(err, "error starting flow for contact") } @@ -570,7 +570,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg msgIn.SetID(event.MsgID) // build our hook to mark our message as handled - hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *models.OrgAssets, sessions []*models.Session) error { + hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { // set our incoming message event on our session if len(sessions) != 1 { return errors.Errorf("handle hook called with more than one session") diff --git a/tasks/ivr/cron.go b/tasks/ivr/cron.go index e66e2344b..71d2e4089 100644 --- a/tasks/ivr/cron.go +++ b/tasks/ivr/cron.go @@ -75,14 +75,14 @@ func retryCalls(ctx context.Context, config *config.Config, db *sqlx.DB, rp *red } // load the org for this connection - org, err := models.GetOrgAssets(ctx, db, conn.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, conn.OrgID()) if err != nil { log.WithError(err).WithField("org_id", conn.OrgID()).Error("error loading org") continue } // and the associated channel - channel := org.ChannelByID(conn.ChannelID()) + channel := oa.ChannelByID(conn.ChannelID()) if channel == nil { // fail this call, channel is no longer active err = models.UpdateChannelConnectionStatuses(ctx, db, []models.ConnectionID{conn.ID()}, models.ConnectionStatusFailed) @@ -93,7 +93,7 @@ func retryCalls(ctx context.Context, config *config.Config, db *sqlx.DB, rp *red } // finally load the full URN - urn, err := models.URNForID(ctx, db, org, conn.ContactURNID()) + urn, err := models.URNForID(ctx, db, oa, conn.ContactURNID()) if err != nil { log.WithError(err).WithField("urn_id", conn.ContactURNID()).Error("unable to load contact urn") continue diff --git a/tasks/ivr/worker.go b/tasks/ivr/worker.go index 7958cfae8..2614f96e8 100644 --- a/tasks/ivr/worker.go +++ b/tasks/ivr/worker.go @@ -75,13 +75,13 @@ func HandleFlowStartBatch(bg context.Context, config *config.Config, db *sqlx.DB } // load our org assets - org, err := models.GetOrgAssets(ctx, db, batch.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, batch.OrgID()) if err != nil { return errors.Wrapf(err, "error loading org assets for org: %d", batch.OrgID()) } // ok, we can initiate calls for the remaining contacts - contacts, err := models.LoadContacts(ctx, db, org, contactIDs) + contacts, err := models.LoadContacts(ctx, db, oa, contactIDs) if err != nil { return errors.Wrapf(err, "error loading contacts") } @@ -91,7 +91,7 @@ func HandleFlowStartBatch(bg context.Context, config *config.Config, db *sqlx.DB start := time.Now() ctx, cancel := context.WithTimeout(bg, time.Minute) - session, err := ivr.RequestCallStart(ctx, config, db, org, batch, contact) + session, err := ivr.RequestCallStart(ctx, config, db, oa, batch, contact) cancel() if err != nil { logrus.WithError(err).Errorf("error starting ivr flow for contact: %d and flow: %d", contact.ID(), batch.FlowID()) diff --git a/tasks/starts/worker.go b/tasks/starts/worker.go index 3eee0b5e1..58be58d36 100644 --- a/tasks/starts/worker.go +++ b/tasks/starts/worker.go @@ -66,14 +66,14 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela contactIDs[id] = true } - org, err := models.GetOrgAssets(ctx, db, start.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, start.OrgID()) if err != nil { return errors.Wrapf(err, "error loading org assets") } // look up any contacts by URN if len(start.URNs()) > 0 { - urnContactIDs, err := models.ContactIDsFromURNs(ctx, db, org, start.URNs()) + urnContactIDs, err := models.ContactIDsFromURNs(ctx, db, oa, start.URNs()) if err != nil { return errors.Wrapf(err, "error getting contact ids from urns") } @@ -84,7 +84,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela // if we are meant to create a new contact, do so if start.CreateContact() { - newID, err := models.CreateContact(ctx, db, org, urns.NilURN) + newID, err := models.CreateContact(ctx, db, oa, urns.NilURN) if err != nil { return errors.Wrapf(err, "error creating new contact") } @@ -111,7 +111,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela // finally, if we have a query, add the contacts that match that as well if start.Query() != "" { - matches, err := models.ContactIDsForQuery(ctx, ec, org, start.Query()) + matches, err := models.ContactIDsForQuery(ctx, ec, oa, start.Query()) if err != nil { return errors.Wrapf(err, "error performing search for start: %d", start.ID()) } diff --git a/web/contact/contact.go b/web/contact/contact.go index adeb4839d..ac707ba0a 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -78,14 +78,14 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - // grab our org - org, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields) + // grab our org assets + oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } // Perform our search - parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, s.ElasticClient, org, + parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, s.ElasticClient, oa, request.GroupUUID, request.Query, request.Sort, request.Offset, request.PageSize) if err != nil { @@ -170,14 +170,14 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - // grab our org - org, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields) + // grab our org assets + oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } - env := org.Env() - parsed, err := contactql.ParseQuery(request.Query, env.RedactionPolicy(), env.DefaultCountry(), org.SessionAssets()) + env := oa.Env() + parsed, err := contactql.ParseQuery(request.Query, env.RedactionPolicy(), env.DefaultCountry(), oa.SessionAssets()) if err != nil { isQueryError, qerr := contactql.IsQueryError(err) @@ -203,7 +203,7 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte allowAsGroup = metadata.AllowAsGroup } - eq, err := models.BuildElasticQuery(org, request.GroupUUID, parsed) + eq, err := models.BuildElasticQuery(oa, request.GroupUUID, parsed) if err != nil { return nil, http.StatusInternalServerError, err } @@ -275,26 +275,26 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + // grab our org assets + oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } // clone it as we will modify flows - org, err = org.Clone(s.CTX, s.DB) + oa, err = oa.Clone(s.CTX, s.DB) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to clone orgs") } // read the modifiers from the request - mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, goflow.ErrorOnMissing) + mods, err := goflow.ReadModifiers(oa.SessionAssets(), request.Modifiers, goflow.ErrorOnMissing) if err != nil { return nil, http.StatusBadRequest, err } // load our contacts - contacts, err := models.LoadContacts(ctx, s.DB, org, request.ContactIDs) + contacts, err := models.LoadContacts(ctx, s.DB, oa, request.ContactIDs) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load contact") } @@ -302,12 +302,12 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac results := make(map[models.ContactID]modifyResult) // create an environment instance with location support - env := flows.NewEnvironment(org.Env(), org.SessionAssets().Locations()) + env := flows.NewEnvironment(oa.Env(), oa.SessionAssets().Locations()) // create scenes for our contacts scenes := make([]*models.Scene, 0, len(contacts)) for _, contact := range contacts { - flowContact, err := contact.FlowContact(org) + flowContact, err := contact.FlowContact(oa) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error creating flow contact for contact: %d", contact.ID()) } @@ -321,7 +321,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac // apply our modifiers for _, mod := range mods { - mod.Apply(env, org.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) + mod.Apply(env, oa.SessionAssets(), flowContact, func(e flows.Event) { result.Events = append(result.Events, e) }) } results[contact.ID()] = result @@ -336,14 +336,14 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac // apply our events for _, scene := range scenes { - err := models.HandleEvents(ctx, tx, s.RP, org, scene, results[scene.ContactID()].Events) + err := models.HandleEvents(ctx, tx, s.RP, oa, scene, results[scene.ContactID()].Events) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying events") } } // gather all our pre commit events, group them by hook and apply them - err = models.ApplyEventPreCommitHooks(ctx, tx, s.RP, org, scenes) + err = models.ApplyEventPreCommitHooks(ctx, tx, s.RP, oa, scenes) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying pre commit hooks") } @@ -360,7 +360,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac } // then apply our post commit hooks - err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, org, scenes) + err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, oa, scenes) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying pre commit hooks") } diff --git a/web/flow/flow.go b/web/flow/flow.go index 36025f7ae..0693cf0b6 100644 --- a/web/flow/flow.go +++ b/web/flow/flow.go @@ -85,11 +85,11 @@ func handleInspect(ctx context.Context, s *web.Server, r *http.Request) (interfa var sa flows.SessionAssets // if we have an org ID, create session assets to look for missing dependencies if request.OrgID != models.NilOrgID { - org, err := models.GetOrgAssetsWithRefresh(ctx, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows) + oa, err := models.GetOrgAssetsWithRefresh(ctx, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows) if err != nil { return nil, 0, err } - sa = org.SessionAssets() + sa = oa.SessionAssets() } return flow.Inspect(sa), http.StatusOK, nil diff --git a/web/ivr/ivr.go b/web/ivr/ivr.go index d853a2909..c67df53b1 100644 --- a/web/ivr/ivr.go +++ b/web/ivr/ivr.go @@ -54,14 +54,14 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw return writeClientError(w, err) } - // load our org - org, err := models.GetOrgAssets(ctx, s.DB, orgID) + // load our org assets + oa, err := models.GetOrgAssets(ctx, s.DB, orgID) if err != nil { return writeClientError(w, errors.Wrapf(err, "error loading org assets")) } // and our channel - channel := org.ChannelByUUID(channelUUID) + channel := oa.ChannelByUUID(channelUUID) if channel == nil { return writeClientError(w, errors.Wrapf(err, "no active channel with uuid: %s", channelUUID)) } @@ -114,7 +114,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw } // get the contact id for this URN - ids, err := models.ContactIDsFromURNs(ctx, s.DB, org, []urns.URN{urn}) + ids, err := models.ContactIDsFromURNs(ctx, s.DB, oa, []urns.URN{urn}) if err != nil { return client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load contact by urn")) } @@ -123,7 +123,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw return client.WriteErrorResponse(w, errors.Errorf("no contact for urn: %s", urn)) } - urn, err = models.URNForURN(ctx, s.DB, org, urn) + urn, err = models.URNForURN(ctx, s.DB, oa, urn) if err != nil { return client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load urn")) } @@ -135,7 +135,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw } // we first create an incoming call channel event and see if that matches - event := models.NewChannelEvent(models.MOCallEventType, org.OrgID(), channel.ID(), contactID, urnID, nil, false) + event := models.NewChannelEvent(models.MOCallEventType, oa.OrgID(), channel.ID(), contactID, urnID, nil, false) externalID, err := client.CallIDForRequest(r) if err != nil { @@ -144,7 +144,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw // create our connection conn, err = models.InsertIVRConnection( - ctx, s.DB, org.OrgID(), channel.ID(), models.NilStartID, contactID, urnID, + ctx, s.DB, oa.OrgID(), channel.ID(), models.NilStartID, contactID, urnID, models.ConnectionDirectionIn, models.ConnectionStatusInProgress, externalID, ) if err != nil { @@ -174,7 +174,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw // no session means no trigger, create a missed call event instead // we first create an incoming call channel event and see if that matches - event = models.NewChannelEvent(models.MOMissEventType, org.OrgID(), channel.ID(), contactID, urnID, nil, false) + event = models.NewChannelEvent(models.MOMissEventType, oa.OrgID(), channel.ID(), contactID, urnID, nil, false) err = event.Insert(ctx, s.DB) if err != nil { return client.WriteErrorResponse(w, errors.Wrapf(err, "error inserting channel event")) @@ -259,14 +259,14 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, rawW http.R return errors.Wrapf(err, "unable to load channel connection with id: %d", request.ConnectionID) } - // load our org - org, err := models.GetOrgAssets(ctx, s.DB, conn.OrgID()) + // load our org assets + oa, err := models.GetOrgAssets(ctx, s.DB, conn.OrgID()) if err != nil { return writeClientError(w, errors.Wrapf(err, "error loading org assets")) } // and our channel - channel := org.ChannelByID(conn.ChannelID()) + channel := oa.ChannelByID(conn.ChannelID()) if channel == nil { return writeClientError(w, errors.Errorf("no active channel with id: %d", conn.ChannelID())) } @@ -311,7 +311,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, rawW http.R } // load our contact - contacts, err := models.LoadContacts(ctx, s.DB, org, []models.ContactID{conn.ContactID()}) + contacts, err := models.LoadContacts(ctx, s.DB, oa, []models.ContactID{conn.ContactID()}) if err != nil { return client.WriteErrorResponse(w, errors.Wrapf(err, "no such contact")) } @@ -323,7 +323,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, rawW http.R } // load the URN for this connection - urn, err := models.URNForID(ctx, s.DB, org, conn.ContactURNID()) + urn, err := models.URNForID(ctx, s.DB, oa, conn.ContactURNID()) if err != nil { return client.WriteErrorResponse(w, errors.Errorf("unable to find connection urn: %d", conn.ContactURNID())) } @@ -346,20 +346,20 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, rawW http.R case actionStart: err = ivr.StartIVRFlow( ctx, s.DB, s.RP, client, resumeURL, - org, channel, conn, contacts[0], urn, conn.StartID(), + oa, channel, conn, contacts[0], urn, conn.StartID(), r, w, ) case actionResume: err = ivr.ResumeIVRFlow( ctx, s.Config, s.DB, s.RP, s.S3Client, resumeURL, client, - org, channel, conn, contacts[0], urn, + oa, channel, conn, contacts[0], urn, r, w, ) case actionStatus: err = ivr.HandleIVRStatus( - ctx, s.DB, s.RP, org, client, conn, + ctx, s.DB, s.RP, oa, client, conn, r, w, ) @@ -402,14 +402,14 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, rawW http return writeClientError(w, err) } - // load our org - org, err := models.GetOrgAssets(ctx, s.DB, orgID) + // load our org assets + oa, err := models.GetOrgAssets(ctx, s.DB, orgID) if err != nil { return writeClientError(w, errors.Wrapf(err, "error loading org assets")) } // and our channel - channel := org.ChannelByUUID(channelUUID) + channel := oa.ChannelByUUID(channelUUID) if channel == nil { return writeClientError(w, errors.Wrapf(err, "no active channel with uuid: %s", channelUUID)) } @@ -468,10 +468,7 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, rawW http } }() - err = ivr.HandleIVRStatus( - ctx, s.DB, s.RP, org, client, conn, - r, w, - ) + err = ivr.HandleIVRStatus(ctx, s.DB, s.RP, oa, client, conn, r, w) // had an error? mark our connection as errored and log it if err != nil { diff --git a/web/po/po.go b/web/po/po.go index 26f128fec..b7d257540 100644 --- a/web/po/po.go +++ b/web/po/po.go @@ -110,20 +110,20 @@ func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interfac } func loadFlows(ctx context.Context, db *sqlx.DB, orgID models.OrgID, flowIDs []models.FlowID) ([]flows.Flow, error) { - // grab our org - org, err := models.GetOrgAssets(ctx, db, orgID) + // grab our org assets + oa, err := models.GetOrgAssets(ctx, db, orgID) if err != nil { return nil, errors.Wrapf(err, "unable to load org assets") } flows := make([]flows.Flow, len(flowIDs)) for i, flowID := range flowIDs { - dbFlow, err := org.FlowByID(flowID) + dbFlow, err := oa.FlowByID(flowID) if err != nil { return nil, errors.Wrapf(err, "unable to load flow with ID %d", flowID) } - flow, err := org.SessionAssets().Flows().Get(dbFlow.UUID()) + flow, err := oa.SessionAssets().Flows().Get(dbFlow.UUID()) if err != nil { return nil, errors.Wrapf(err, "unable to read flow with UUID %s", string(dbFlow.UUID())) } diff --git a/web/simulation/simulation.go b/web/simulation/simulation.go index 54da5aedc..72cf49c95 100644 --- a/web/simulation/simulation.go +++ b/web/simulation/simulation.go @@ -78,16 +78,16 @@ type startRequest struct { } // handleSimulationEvents takes care of updating our db with any events needed during simulation -func handleSimulationEvents(ctx context.Context, db models.Queryer, org *models.OrgAssets, es []flows.Event) error { +func handleSimulationEvents(ctx context.Context, db models.Queryer, oa *models.OrgAssets, es []flows.Event) error { // nicpottier: this could be refactored into something more similar to how we handle normal events (ie hooks) if // we see ourselves taking actions for more than just webhook events wes := make([]*models.WebhookEvent, 0) for _, e := range es { if e.Type() == events.TypeResthookCalled { rec := e.(*events.ResthookCalledEvent) - resthook := org.ResthookBySlug(rec.Resthook) + resthook := oa.ResthookBySlug(rec.Resthook) if resthook != nil { - we := models.NewWebhookEvent(org.OrgID(), resthook.ID(), string(rec.Payload), rec.CreatedOn()) + we := models.NewWebhookEvent(oa.OrgID(), resthook.ID(), string(rec.Payload), rec.CreatedOn()) wes = append(wes, we) } } @@ -104,13 +104,13 @@ func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation") } - // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + // grab our org assets + oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets") } // clone it since we will be modifying it - org, err = org.Clone(s.CTX, s.DB) + oa, err = oa.Clone(s.CTX, s.DB) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to clone org") } @@ -118,7 +118,7 @@ func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface // for each of our passed in definitions for _, flow := range request.Flows { // populate our flow in our org from our request - err = populateFlow(org, flow.UUID, flow.Definition) + err = populateFlow(oa, flow.UUID, flow.Definition) if err != nil { return nil, http.StatusBadRequest, err } @@ -126,27 +126,27 @@ func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface // populate any test channels for _, channel := range request.Assets.Channels { - org.AddTestChannel(channel) + oa.AddTestChannel(channel) } // read our trigger - trigger, err := triggers.ReadTrigger(org.SessionAssets(), request.Trigger, assets.IgnoreMissing) + trigger, err := triggers.ReadTrigger(oa.SessionAssets(), request.Trigger, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to read trigger") } - return triggerFlow(ctx, s.DB, org, trigger) + return triggerFlow(ctx, s.DB, oa, trigger) } // triggerFlow creates a new session with the passed in trigger, returning our standard response -func triggerFlow(ctx context.Context, db *sqlx.DB, org *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) { +func triggerFlow(ctx context.Context, db *sqlx.DB, oa *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) { // start our flow session - session, sprint, err := goflow.Simulator().NewSession(org.SessionAssets(), trigger) + session, sprint, err := goflow.Simulator().NewSession(oa.SessionAssets(), trigger) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting session") } - err = handleSimulationEvents(ctx, db, org, sprint.Events()) + err = handleSimulationEvents(ctx, db, oa, sprint.Events()) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events") } @@ -180,14 +180,14 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusBadRequest, err } - // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + // grab our org assets + oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) if err != nil { return nil, http.StatusBadRequest, err } // clone it as we will modify it - org, err = org.Clone(s.CTX, s.DB) + oa, err = oa.Clone(s.CTX, s.DB) if err != nil { return nil, http.StatusBadRequest, err } @@ -195,7 +195,7 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac // for each of our passed in definitions for _, flow := range request.Flows { // populate our flow in our org from our request - err = populateFlow(org, flow.UUID, flow.Definition) + err = populateFlow(oa, flow.UUID, flow.Definition) if err != nil { return nil, http.StatusBadRequest, err } @@ -203,16 +203,16 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac // populate any test channels for _, channel := range request.Assets.Channels { - org.AddTestChannel(channel) + oa.AddTestChannel(channel) } - session, err := goflow.Simulator().ReadSession(org.SessionAssets(), request.Session, assets.IgnoreMissing) + session, err := goflow.Simulator().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, err } // read our resume - resume, err := resumes.ReadResume(org.SessionAssets(), request.Resume, assets.IgnoreMissing) + resume, err := resumes.ReadResume(oa.SessionAssets(), request.Resume, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, err } @@ -220,12 +220,12 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac // if this is a msg resume we want to check whether it might be caught by a trigger if resume.Type() == resumes.TypeMsg { msgResume := resume.(*resumes.MsgResume) - trigger := models.FindMatchingMsgTrigger(org, msgResume.Contact(), msgResume.Msg().Text()) + trigger := models.FindMatchingMsgTrigger(oa, msgResume.Contact(), msgResume.Msg().Text()) if trigger != nil { var flow *models.Flow for _, r := range session.Runs() { if r.Status() == flows.RunStatusWaiting { - f, _ := org.Flow(r.FlowReference().UUID) + f, _ := oa.Flow(r.FlowReference().UUID) if f != nil { flow = f.(*models.Flow) } @@ -235,14 +235,14 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac // we don't have a current flow or the current flow doesn't ignore triggers if flow == nil || (!flow.IgnoreTriggers() && trigger.TriggerType() == models.KeywordTriggerType) { - triggeredFlow, err := org.FlowByID(trigger.FlowID()) + triggeredFlow, err := oa.FlowByID(trigger.FlowID()) if err != nil && err != models.ErrNotFound { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load triggered flow") } if triggeredFlow != nil { - trigger := triggers.NewMsg(org.Env(), triggeredFlow.FlowReference(), resume.Contact(), msgResume.Msg(), trigger.Match()) - return triggerFlow(ctx, s.DB, org, trigger) + trigger := triggers.NewMsg(oa.Env(), triggeredFlow.FlowReference(), resume.Contact(), msgResume.Msg(), trigger.Match()) + return triggerFlow(ctx, s.DB, oa, trigger) } } } @@ -259,7 +259,7 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, err } - err = handleSimulationEvents(ctx, s.DB, org, sprint.Events()) + err = handleSimulationEvents(ctx, s.DB, oa, sprint.Events()) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events") } @@ -268,13 +268,13 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac } // populateFlow takes care of setting the definition for the flow with the passed in UUID according to the passed in definitions -func populateFlow(org *models.OrgAssets, uuid assets.FlowUUID, flowDef json.RawMessage) error { - f, err := org.Flow(uuid) +func populateFlow(oa *models.OrgAssets, uuid assets.FlowUUID, flowDef json.RawMessage) error { + f, err := oa.Flow(uuid) if err != nil { return errors.Wrapf(err, "unable to find flow with uuid: %s", uuid) } flow := f.(*models.Flow) - org.SetFlow(flow.ID(), flow.UUID(), flow.Name(), flowDef) + oa.SetFlow(flow.ID(), flow.UUID(), flow.Name(), flowDef) return nil } diff --git a/web/surveyor/surveyor.go b/web/surveyor/surveyor.go index af27db1d9..5758376e7 100644 --- a/web/surveyor/surveyor.go +++ b/web/surveyor/surveyor.go @@ -54,9 +54,9 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation") } - // grab our org + // grab our org assets orgID := ctx.Value(web.OrgIDKey).(models.OrgID) - org, err := models.GetOrgAssets(s.CTX, s.DB, orgID) + oa, err := models.GetOrgAssets(s.CTX, s.DB, orgID) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets") } @@ -67,7 +67,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Errorf("missing request user") } - fs, err := goflow.Engine().ReadSession(org.SessionAssets(), request.Session, assets.IgnoreMissing) + fs, err := goflow.Engine().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "error reading session") } @@ -83,7 +83,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // and our modifiers - mods, err := goflow.ReadModifiers(org.SessionAssets(), request.Modifiers, goflow.IgnoreMissing) + mods, err := goflow.ReadModifiers(oa.SessionAssets(), request.Modifiers, goflow.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, err } @@ -95,13 +95,13 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // create / fetch our contact based on the highest priority URN - contactID, err := models.CreateContact(ctx, s.DB, org, urn) + contactID, err := models.CreateContact(ctx, s.DB, oa, urn) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to look up contact") } // load that contact to get the current groups and UUID - contacts, err := models.LoadContacts(ctx, s.DB, org, []models.ContactID{contactID}) + contacts, err := models.LoadContacts(ctx, s.DB, oa, []models.ContactID{contactID}) if err == nil && len(contacts) == 0 { err = errors.Errorf("no contacts loaded") } @@ -110,7 +110,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // load our flow contact - flowContact, err := contacts[0].FlowContact(org) + flowContact, err := contacts[0].FlowContact(oa) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error loading flow contact") } @@ -122,7 +122,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac // run through each contact modifier, applying it to our contact for _, m := range mods { - m.Apply(org.Env(), org.SessionAssets(), flowContact, appender) + m.Apply(oa.Env(), oa.SessionAssets(), flowContact, appender) } // set this updated contact on our session @@ -141,7 +141,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction for session write") } - sessions, err := models.WriteSessions(ctx, tx, s.RP, org, []flows.Session{fs}, []flows.Sprint{sprint}, nil) + sessions, err := models.WriteSessions(ctx, tx, s.RP, oa, []flows.Session{fs}, []flows.Sprint{sprint}, nil) if err == nil && len(sessions) == 0 { err = errors.Errorf("no sessions written") } @@ -160,7 +160,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac } // write our post commit hooks - err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, org, []*models.Scene{sessions[0].Scene()}) + err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, oa, []*models.Scene{sessions[0].Scene()}) if err != nil { tx.Rollback() return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying post commit hooks") diff --git a/web/ticket/ticket.go b/web/ticket/ticket.go index 582b45dd8..5050bf151 100644 --- a/web/ticket/ticket.go +++ b/web/ticket/ticket.go @@ -46,8 +46,8 @@ func handleClose(ctx context.Context, s *web.Server, r *http.Request, l *models. return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + // grab our org assets + oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } @@ -57,7 +57,7 @@ func handleClose(ctx context.Context, s *web.Server, r *http.Request, l *models. return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) } - err = models.CloseTickets(ctx, s.DB, org, tickets, true, l) + err = models.CloseTickets(ctx, s.DB, oa, tickets, true, l) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "error closing tickets for org: %d", request.OrgID) } @@ -78,8 +78,8 @@ func handleReopen(ctx context.Context, s *web.Server, r *http.Request, l *models return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + // grab our org assets + oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } @@ -89,7 +89,7 @@ func handleReopen(ctx context.Context, s *web.Server, r *http.Request, l *models return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) } - err = models.ReopenTickets(ctx, s.DB, org, tickets, true, l) + err = models.ReopenTickets(ctx, s.DB, oa, tickets, true, l) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "error reopening tickets for org: %d", request.OrgID) } From 28fd13277d5bdad298183458c29c90a5d97888e5 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Fri, 3 Jul 2020 11:56:27 -0500 Subject: [PATCH 53/55] Update CHANGELOG.md for v5.5.38 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4138f37b1..65b6bd4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.5.38 +---------- + * Varible naming consistency + v5.5.37 ---------- * Fix reading of modifiers so always ignore modifier that becomes noop From bbc150f942929b7684474dddd1ebffc64a77c527 Mon Sep 17 00:00:00 2001 From: Nic Pottier Date: Mon, 6 Jul 2020 10:30:55 -0700 Subject: [PATCH 54/55] touch for 5.6.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b6bd4b7..047fe526e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v5.5.38 +v5.5.38 ---------- * Varible naming consistency From ecd2a102793a43e7d11f92afd9e7d4e1a93d3a29 Mon Sep 17 00:00:00 2001 From: Nic Pottier Date: Mon, 6 Jul 2020 10:31:21 -0700 Subject: [PATCH 55/55] Update CHANGELOG.md for v5.6.0 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 047fe526e..defc2ef8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v5.6.0 +---------- + * 5.6.0 Release Candidate + v5.5.38 ---------- * Varible naming consistency