diff --git a/web/contact/contact.go b/web/contact/contact.go index 124fb64d4..48b3fa6fb 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -5,10 +5,8 @@ import ( "encoding/json" "net/http" - "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/contactql" - "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/goflow" @@ -229,53 +227,24 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte return response, http.StatusOK, nil } -// Request that a set of contacts is created. +// Request to create a new contact. // // { // "org_id": 1, // "user_id": 1, -// "contacts": [{ -// "name": "Joe Blow", -// "language": "eng", -// "urns": ["tel:+250788123123"], -// "fields": {"age": "39"}, -// "groups": ["b0b778db-6657-430b-9272-989ad43a10db"] -// }, { -// "name": "Frank", -// "language": "spa", -// "urns": ["tel:+250788676767", "twitter:franky"], -// "fields": {} -// }] +// "contact": { +// "name": "Joe Blow", +// "language": "eng", +// "urns": ["tel:+250788123123"], +// "fields": {"age": "39"}, +// "groups": ["b0b778db-6657-430b-9272-989ad43a10db"] +// } // } // type createRequest struct { - OrgID models.OrgID `json:"org_id" validate:"required"` - UserID models.UserID `json:"user_id"` - Contacts []struct { - Name string `json:"name"` - Languge envs.Language `json:"language"` - URNs []urns.URN `json:"urns"` - Fields map[string]string `json:"fields"` - Groups []assets.GroupUUID `json:"groups"` - } `json:"contacts" validate:"required"` -} - -// Response for contact creation. Will return an array of contacts/errors the same size as that in the request. -// -// [{ -// "contact": { -// "id": 123, -// "uuid": "559d4cf7-8ed3-43db-9bbb-2be85345f87e", -// "name": "Joe", -// "language": "eng" -// } -// },{ -// "error": "URNs owned by other contact" -// }] -// -type createResult struct { - Contact *flows.Contact `json:"contact,omitempty"` - Error string `json:"error,omitempty"` + OrgID models.OrgID `json:"org_id" validate:"required"` + UserID models.UserID `json:"user_id"` + Contact *contactSpec `json:"contact" validate:"required"` } // handles a request to create the given contacts @@ -286,24 +255,28 @@ func handleCreate(ctx context.Context, s *web.Server, r *http.Request) (interfac } // grab our org - org, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + 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") } - results := make([]createResult, len(request.Contacts)) + c, err := request.Contact.validate(oa.Env(), oa.SessionAssets()) + if err != nil { + return nil, http.StatusBadRequest, err + } - for i, c := range request.Contacts { - _, flowContact, err := models.CreateContact(ctx, s.DB, org, request.UserID, c.Name, c.Languge, c.URNs) - if err != nil { - results[i].Error = err.Error() - continue - } + _, contact, err := models.CreateContact(ctx, s.DB, oa, request.UserID, c.name, c.language, c.urns) + if err != nil { + return nil, http.StatusBadRequest, err + } - results[i].Contact = flowContact + modifiersByContact := map[*flows.Contact][]flows.Modifier{contact: c.mods} + _, err = ModifyContacts(ctx, s.DB, s.RP, oa, modifiersByContact) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error modifying new contact") } - return results, http.StatusOK, nil + return map[string]interface{}{"contact": contact}, http.StatusOK, nil } // Request that a set of contacts is modified. @@ -382,16 +355,16 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac } // build a map of each contact to all mods (all mods are applied to all contacts) - contactsAndMods := make(map[*flows.Contact][]flows.Modifier, len(contacts)) + modifiersByContact := make(map[*flows.Contact][]flows.Modifier, len(contacts)) for _, contact := range contacts { flowContact, err := contact.FlowContact(oa) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error creating flow contact for contact: %d", contact.ID()) } - contactsAndMods[flowContact] = mods + modifiersByContact[flowContact] = mods } - eventsByContact, err := ModifyContacts(ctx, s.DB, s.RP, oa, contactsAndMods) + eventsByContact, err := ModifyContacts(ctx, s.DB, s.RP, oa, modifiersByContact) if err != nil { return nil, http.StatusInternalServerError, errors.Wrap(err, "error modifying contacts") } @@ -406,14 +379,14 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac } // ModifyContacts modifies contacts by applying modifiers and handling the resultant events -func ModifyContacts(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, contactsAndMods map[*flows.Contact][]flows.Modifier) (map[*flows.Contact][]flows.Event, error) { +func ModifyContacts(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, modifiersByContact map[*flows.Contact][]flows.Modifier) (map[*flows.Contact][]flows.Event, error) { // create an environment instance with location support env := flows.NewEnvironment(oa.Env(), oa.SessionAssets().Locations()) eventsByContact := make(map[*flows.Contact][]flows.Event) // apply the modifiers to get the events for each contact - for contact, mods := range contactsAndMods { + for contact, mods := range modifiersByContact { events := make([]flows.Event, 0) for _, mod := range mods { mod.Apply(env, oa.SessionAssets(), contact, func(e flows.Event) { events = append(events, e) }) @@ -426,9 +399,9 @@ func ModifyContacts(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models return nil, errors.Wrapf(err, "error starting transaction") } - scenes := make([]*models.Scene, 0, len(contactsAndMods)) + scenes := make([]*models.Scene, 0, len(modifiersByContact)) - for contact := range contactsAndMods { + for contact := range modifiersByContact { scene := models.NewSceneForContact(contact) scenes = append(scenes, scene) diff --git a/web/contact/testdata/create.json b/web/contact/testdata/create.json index da22dc7dd..1dc13636f 100644 --- a/web/contact/testdata/create.json +++ b/web/contact/testdata/create.json @@ -1,15 +1,16 @@ [ { - "label": "noop", + "label": "error if contact not provided", "method": "POST", "path": "/mr/contact/create", "body": { "org_id": 1, - "user_id": 1, - "contacts": [] + "user_id": 1 + }, + "status": 400, + "response": { + "error": "request failed validation: field 'contact' is required" }, - "status": 200, - "response": [], "db_assertions": [ { "query": "SELECT count(*) FROM contacts_contact WHERE created_by_id != 2", @@ -18,104 +19,87 @@ ] }, { - "label": "create empty contacts", + "label": "create empty contact", "method": "POST", "path": "/mr/contact/create", "body": { "org_id": 1, "user_id": 1, - "contacts": [ - {}, - {} - ] + "contact": {} }, "status": 200, - "response": [ - { - "contact": { - "uuid": "d2f852ec-7b4e-457f-ae7f-f8b243c49ff5", - "id": 30000, - "status": "active", - "timezone": "America/Los_Angeles", - "created_on": "2018-07-06T12:30:00.123457Z" - } - }, - { - "contact": { - "uuid": "692926ea-09d6-4942-bd38-d266ec8d3716", - "id": 30001, - "status": "active", - "timezone": "America/Los_Angeles", - "created_on": "2018-07-06T12:30:01.123457Z" - } + "response": { + "contact": { + "uuid": "d2f852ec-7b4e-457f-ae7f-f8b243c49ff5", + "id": 30000, + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z" } - ], + }, "db_assertions": [ { "query": "SELECT count(*) FROM contacts_contact WHERE created_by_id != 2", - "count": 2 + "count": 1 }, { "query": "SELECT count(*) FROM contacts_contact WHERE name = '' AND language = ''", - "count": 2 + "count": 1 } ] }, { - "label": "create contacts with all properties", + "label": "create contact with all properties", "method": "POST", "path": "/mr/contact/create", "body": { "org_id": 1, "user_id": 1, - "contacts": [ - { - "name": "José", - "language": "spa", - "urns": [ - "tel:+16055700001" - ] + "contact": { + "name": "José", + "language": "spa", + "urns": [ + "tel:+16055700001" + ], + "fields": { + "gender": "M", + "age": "39" }, - { - "name": "Jean", - "language": "fra", - "urns": [ - "tel:+16055700002" - ] - } - ] + "groups": [ + "c153e265-f7c9-4539-9dbc-9b358714b638" + ] + } }, "status": 200, - "response": [ - { - "contact": { - "uuid": "8720f157-ca1c-432f-9c0b-2014ddc77094", - "id": 30002, - "name": "José", - "language": "spa", - "status": "active", - "timezone": "America/Los_Angeles", - "created_on": "2018-07-06T12:30:00.123457Z", - "urns": [ - "tel:+16055700001?id=20121&priority=1000" - ] - } - }, - { - "contact": { - "uuid": "c34b6c7d-fa06-4563-92a3-d648ab64bccb", - "id": 30003, - "name": "Jean", - "language": "fra", - "status": "active", - "timezone": "America/Los_Angeles", - "created_on": "2018-07-06T12:30:01.123457Z", - "urns": [ - "tel:+16055700002?id=20122&priority=1000" - ] + "response": { + "contact": { + "uuid": "692926ea-09d6-4942-bd38-d266ec8d3716", + "id": 30001, + "name": "José", + "language": "spa", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+16055700001?id=20121&priority=1000" + ], + "groups": [ + { + "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638", + "name": "Doctors" + } + ], + "fields": { + "age": { + "text": "39", + "number": 39 + }, + "gender": { + "text": "M" + } } } - ] + } }, { "label": "error if try to create contact with taken URN", @@ -124,21 +108,17 @@ "body": { "org_id": 1, "user_id": 1, - "contacts": [ - { - "name": "María", - "urns": [ - "tel:+16055700001" - ] - } - ] - }, - "status": 200, - "response": [ - { - "error": "URNs in use by other contacts" + "contact": { + "name": "María", + "urns": [ + "tel:+16055700001" + ] } - ] + }, + "status": 500, + "response": { + "error": "URNs in use by other contacts" + } }, { "label": "though ok to take an orphaned URN", @@ -147,30 +127,26 @@ "body": { "org_id": 1, "user_id": 1, - "contacts": [ - { - "name": "María", - "urns": [ - "tel:+16055741111" - ] - } - ] + "contact": { + "name": "María", + "urns": [ + "tel:+16055741111" + ] + } }, "status": 200, - "response": [ - { - "contact": { - "uuid": "970b8069-50f5-4f6f-8f41-6b2d9f33d623", - "id": 30005, - "name": "María", - "status": "active", - "timezone": "America/Los_Angeles", - "created_on": "2018-07-06T12:30:00.123457Z", - "urns": [ - "tel:+16055741111?id=10000&priority=50" - ] - } + "response": { + "contact": { + "uuid": "c34b6c7d-fa06-4563-92a3-d648ab64bccb", + "id": 30003, + "name": "María", + "status": "active", + "timezone": "America/Los_Angeles", + "created_on": "2018-07-06T12:30:00.123457Z", + "urns": [ + "tel:+16055741111?id=10000&priority=50" + ] } - ] + } } ] \ No newline at end of file diff --git a/web/contact/utils.go b/web/contact/utils.go new file mode 100644 index 000000000..224907689 --- /dev/null +++ b/web/contact/utils.go @@ -0,0 +1,76 @@ +package contact + +import ( + "github.com/nyaruka/gocommon/urns" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/envs" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/flows/actions/modifiers" + + "github.com/pkg/errors" +) + +// a validated contact creation task +type creation struct { + name string + language envs.Language + urns []urns.URN + mods []flows.Modifier +} + +// describes a contact to be created +type contactSpec struct { + Name string `json:"name"` + Language string `json:"language"` + URNs []urns.URN `json:"urns"` + Fields map[string]string `json:"fields"` + Groups []assets.GroupUUID `json:"groups"` +} + +func (c *contactSpec) validate(env envs.Environment, sa flows.SessionAssets) (*creation, error) { + country := string(env.DefaultCountry()) + var err error + validated := &creation{name: c.Name} + + if c.Language != "" { + validated.language, err = envs.ParseLanguage(c.Language) + if err != nil { + return nil, errors.Wrap(err, "invalid language") + } + } + + validated.urns = make([]urns.URN, len(c.URNs)) + for i, urn := range c.URNs { + validated.urns[i] = urn.Normalize(country) + } + + validated.mods = make([]flows.Modifier, 0, len(c.Fields)) + + for key, value := range c.Fields { + field := sa.Fields().Get(key) + if field == nil { + return nil, errors.Errorf("unknown contact field '%s'", key) + } + if value != "" { + validated.mods = append(validated.mods, modifiers.NewField(field, value)) + } + } + + if len(c.Groups) > 0 { + groups := make([]*flows.Group, len(c.Groups)) + for i, uuid := range c.Groups { + group := sa.Groups().Get(uuid) + if group == nil { + return nil, errors.Errorf("unknown contact group '%s'", uuid) + } + if group.IsDynamic() { + return nil, errors.Errorf("can't add contact to dynamic group '%s'", uuid) + } + groups[i] = group + } + + validated.mods = append(validated.mods, modifiers.NewGroups(groups, modifiers.GroupsAdd)) + } + + return validated, nil +} diff --git a/web/ivr/ivr.go b/web/ivr/ivr.go index 08d821919..762991574 100644 --- a/web/ivr/ivr.go +++ b/web/ivr/ivr.go @@ -113,14 +113,10 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, raw return client.WriteErrorResponse(w, errors.Wrapf(err, "unable to find URN in request")) } - // get the contact id for this URN - ids, err := models.ContactIDsFromURNs(ctx, s.DB, org, []urns.URN{urn}, true) + // get the contact for this URN + contact, _, err := models.GetOrCreateContact(ctx, s.DB, org, urn) if err != nil { - return client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load contact by urn")) - } - contactID, found := ids[urn] - if !found { - return client.WriteErrorResponse(w, errors.Errorf("no contact for urn: %s", urn)) + return client.WriteErrorResponse(w, errors.Wrapf(err, "unable to get contact by urn")) } urn, err = models.URNForURN(ctx, s.DB, org, urn) @@ -135,7 +131,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, org.OrgID(), channel.ID(), contact.ID(), urnID, nil, false) externalID, err := client.CallIDForRequest(r) if err != nil { @@ -144,7 +140,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, org.OrgID(), channel.ID(), models.NilStartID, contact.ID(), urnID, models.ConnectionDirectionIn, models.ConnectionStatusInProgress, externalID, ) if err != nil { @@ -174,7 +170,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, org.OrgID(), channel.ID(), contact.ID(), urnID, nil, false) err = event.Insert(ctx, s.DB) if err != nil { return client.WriteErrorResponse(w, errors.Wrapf(err, "error inserting channel event"))