From 728f68104c4d042ba4529b93240bae5d7a4f747e Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Tue, 17 Oct 2023 10:48:30 -0500 Subject: [PATCH] Remove ticketers --- cmd/mailroom/main.go | 3 - core/goflow/engine.go | 22 -- core/goflow/engine_test.go | 20 - core/handlers/service_called.go | 20 - core/handlers/ticket_opened.go | 12 - core/handlers/ticket_opened_test.go | 32 +- core/models/assets.go | 40 +- core/models/contacts.go | 11 +- core/models/contacts_test.go | 9 +- core/models/http_logs.go | 39 -- core/models/http_logs_test.go | 42 -- core/models/notifications_test.go | 2 +- core/models/ticket_events_test.go | 2 +- core/models/tickets.go | 341 +--------------- core/models/tickets_test.go | 171 ++------ core/tasks/handler/contact_tasks.go | 8 +- .../handler/handle_contact_event_test.go | 8 +- go.mod | 2 +- go.sum | 2 + services/tickets/intern/service.go | 49 --- services/tickets/intern/service_test.go | 96 ----- services/tickets/mailgun/client.go | 129 ------ services/tickets/mailgun/client_test.go | 60 --- services/tickets/mailgun/service.go | 236 ----------- services/tickets/mailgun/service_test.go | 173 -------- .../TestCloseAndReopen_close_tickets.snap | 37 -- .../TestCloseAndReopen_reopen_tickets.snap | 37 -- .../TestOpenAndForward_forward_message.snap | 47 --- .../TestOpenAndForward_open_ticket.snap | 34 -- .../TestSendMessage_mailgun_request.snap | 39 -- .../tickets/mailgun/testdata/receive.json | 372 ------------------ services/tickets/mailgun/web.go | 142 ------- services/tickets/mailgun/web_test.go | 20 - services/tickets/utils.go | 179 --------- services/tickets/utils_test.go | 192 --------- services/tickets/zendesk/client.go | 300 -------------- services/tickets/zendesk/client_test.go | 243 ------------ services/tickets/zendesk/service.go | 272 ------------- services/tickets/zendesk/service_test.go | 182 --------- .../TestCloseAndReopen_close_tickets.snap | 9 - .../TestCloseAndReopen_reopen_tickets.snap | 9 - .../TestOpenAndForward_forward_message.snap | 9 - .../TestOpenAndForward_open_ticket.snap | 9 - .../tickets/zendesk/testdata/channelback.json | 74 ---- .../zendesk/testdata/event_callback.json | 255 ------------ services/tickets/zendesk/testdata/target.json | 132 ------- services/tickets/zendesk/utils.go | 66 ---- services/tickets/zendesk/utils_test.go | 31 -- services/tickets/zendesk/web.go | 301 -------------- services/tickets/zendesk/web_test.go | 42 -- testsuite/testdata/constants.go | 5 - testsuite/testdata/tickets.go | 19 +- web/contact/base_test.go | 1 - web/msg/base_test.go | 2 +- web/ticket/base_test.go | 41 +- web/ticket/close.go | 6 +- web/ticket/reopen.go | 10 +- web/wrappers.go | 18 - web/wrappers_test.go | 65 --- 59 files changed, 93 insertions(+), 4636 deletions(-) delete mode 100644 services/tickets/intern/service.go delete mode 100644 services/tickets/intern/service_test.go delete mode 100644 services/tickets/mailgun/client.go delete mode 100644 services/tickets/mailgun/client_test.go delete mode 100644 services/tickets/mailgun/service.go delete mode 100644 services/tickets/mailgun/service_test.go delete mode 100644 services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap delete mode 100644 services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap delete mode 100644 services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap delete mode 100644 services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap delete mode 100644 services/tickets/mailgun/testdata/TestSendMessage_mailgun_request.snap delete mode 100644 services/tickets/mailgun/testdata/receive.json delete mode 100644 services/tickets/mailgun/web.go delete mode 100644 services/tickets/mailgun/web_test.go delete mode 100644 services/tickets/utils.go delete mode 100644 services/tickets/utils_test.go delete mode 100644 services/tickets/zendesk/client.go delete mode 100644 services/tickets/zendesk/client_test.go delete mode 100644 services/tickets/zendesk/service.go delete mode 100644 services/tickets/zendesk/service_test.go delete mode 100644 services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap delete mode 100644 services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap delete mode 100644 services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap delete mode 100644 services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap delete mode 100644 services/tickets/zendesk/testdata/channelback.json delete mode 100644 services/tickets/zendesk/testdata/event_callback.json delete mode 100644 services/tickets/zendesk/testdata/target.json delete mode 100644 services/tickets/zendesk/utils.go delete mode 100644 services/tickets/zendesk/utils_test.go delete mode 100644 services/tickets/zendesk/web.go delete mode 100644 services/tickets/zendesk/web_test.go delete mode 100644 web/wrappers_test.go diff --git a/cmd/mailroom/main.go b/cmd/mailroom/main.go index 7866850cb..f37671d71 100644 --- a/cmd/mailroom/main.go +++ b/cmd/mailroom/main.go @@ -33,9 +33,6 @@ import ( _ "github.com/nyaruka/mailroom/core/tasks/timeouts" _ "github.com/nyaruka/mailroom/services/ivr/twiml" _ "github.com/nyaruka/mailroom/services/ivr/vonage" - _ "github.com/nyaruka/mailroom/services/tickets/intern" - _ "github.com/nyaruka/mailroom/services/tickets/mailgun" - _ "github.com/nyaruka/mailroom/services/tickets/zendesk" _ "github.com/nyaruka/mailroom/web/contact" _ "github.com/nyaruka/mailroom/web/docs" _ "github.com/nyaruka/mailroom/web/flow" diff --git a/core/goflow/engine.go b/core/goflow/engine.go index be6f6553f..853500332 100644 --- a/core/goflow/engine.go +++ b/core/goflow/engine.go @@ -4,7 +4,6 @@ import ( "sync" "github.com/nyaruka/gocommon/urns" - "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/engine" "github.com/nyaruka/goflow/services/webhooks" @@ -17,7 +16,6 @@ var engInit, simulatorInit sync.Once var emailFactory func(*runtime.Config) engine.EmailServiceFactory var classificationFactory func(*runtime.Config) engine.ClassificationServiceFactory -var ticketFactory func(*runtime.Config) engine.TicketServiceFactory var airtimeFactory func(*runtime.Config) engine.AirtimeServiceFactory // RegisterEmailServiceFactory can be used by outside callers to register a email factory @@ -32,12 +30,6 @@ func RegisterClassificationServiceFactory(f func(*runtime.Config) engine.Classif classificationFactory = f } -// RegisterTicketServiceFactory can be used by outside callers to register a ticket service factory -// for use by the engine -func RegisterTicketServiceFactory(f func(*runtime.Config) engine.TicketServiceFactory) { - ticketFactory = f -} - // RegisterAirtimeServiceFactory can be used by outside callers to register a airtime factory // for use by the engine func RegisterAirtimeServiceFactory(f func(*runtime.Config) engine.AirtimeServiceFactory) { @@ -58,7 +50,6 @@ func Engine(c *runtime.Config) flows.Engine { WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, httpRetries, httpAccess, webhookHeaders, c.WebhooksMaxBodyBytes)). WithClassificationServiceFactory(classificationFactory(c)). WithEmailServiceFactory(emailFactory(c)). - WithTicketServiceFactory(ticketFactory(c)). WithAirtimeServiceFactory(airtimeFactory(c)). WithMaxStepsPerSprint(c.MaxStepsPerSprint). WithMaxResumesPerSession(c.MaxResumesPerSession). @@ -84,7 +75,6 @@ func Simulator(c *runtime.Config) flows.Engine { WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, nil, httpAccess, webhookHeaders, c.WebhooksMaxBodyBytes)). WithClassificationServiceFactory(classificationFactory(c)). // simulated sessions do real classification WithEmailServiceFactory(simulatorEmailServiceFactory). // but faked emails - WithTicketServiceFactory(simulatorTicketServiceFactory). // and faked tickets WithAirtimeServiceFactory(simulatorAirtimeServiceFactory). // and faked airtime transfers WithMaxStepsPerSprint(c.MaxStepsPerSprint). WithMaxResumesPerSession(c.MaxResumesPerSession). @@ -106,18 +96,6 @@ func (s *simulatorEmailService) Send(addresses []string, subject, body string) e return nil } -func simulatorTicketServiceFactory(ticketer *flows.Ticketer) (flows.TicketService, error) { - return &simulatorTicketService{ticketer: ticketer}, nil -} - -type simulatorTicketService struct { - ticketer *flows.Ticketer -} - -func (s *simulatorTicketService) Open(env envs.Environment, contact *flows.Contact, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - return flows.OpenTicket(s.ticketer, topic, body, assignee), nil -} - func simulatorAirtimeServiceFactory(flows.SessionAssets) (flows.AirtimeService, error) { return &simulatorAirtimeService{}, nil } diff --git a/core/goflow/engine_test.go b/core/goflow/engine_test.go index cea62c814..7132e3d31 100644 --- a/core/goflow/engine_test.go +++ b/core/goflow/engine_test.go @@ -8,9 +8,7 @@ import ( "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/goflow" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -58,24 +56,6 @@ func TestSimulatorAirtime(t *testing.T) { }, transfer) } -func TestSimulatorTicket(t *testing.T) { - ctx, rt := testsuite.Runtime() - - ticketer, err := models.LookupTicketerByUUID(ctx, rt.DB.DB, testdata.Mailgun.UUID) - require.NoError(t, err) - - svc, err := goflow.Simulator(rt.Config).Services().Ticket(flows.NewTicketer(ticketer)) - assert.NoError(t, err) - - oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) - require.NoError(t, err) - - ticket, err := svc.Open(nil, nil, oa.SessionAssets().Topics().FindByName("General"), "Where are my cookies?", nil, nil) - assert.NoError(t, err) - assert.Equal(t, testdata.Mailgun.UUID, ticket.Ticketer().UUID()) - assert.Equal(t, "Where are my cookies?", ticket.Body()) -} - func TestSimulatorWebhook(t *testing.T) { _, rt := testsuite.Runtime() diff --git a/core/handlers/service_called.go b/core/handlers/service_called.go index 3f7e97710..061e5b1f9 100644 --- a/core/handlers/service_called.go +++ b/core/handlers/service_called.go @@ -25,18 +25,12 @@ func handleServiceCalled(ctx context.Context, rt *runtime.Runtime, tx *sqlx.Tx, slog.Debug("service called", "contact", scene.ContactUUID(), "session", scene.SessionID(), "service", event.Service) var classifier *models.Classifier - var ticketer *models.Ticketer if event.Service == "classifier" { 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 = oa.TicketerByUUID(event.Ticketer.UUID) - if ticketer == nil { - return errors.Errorf("unable to find ticketer with UUID: %s", event.Ticketer.UUID) - } } // create a log for each HTTP call @@ -58,21 +52,7 @@ func handleServiceCalled(ctx context.Context, rt *runtime.Runtime, tx *sqlx.Tx, httpLog.Retries, httpLog.CreatedOn, ) - } else if event.Service == "ticketer" { - log = models.NewTicketerCalledLog( - oa.OrgID(), - ticketer.ID(), - httpLog.URL, - httpLog.StatusCode, - httpLog.Request, - httpLog.Response, - httpLog.Status != flows.CallStatusSuccess, - time.Duration(httpLog.ElapsedMS)*time.Millisecond, - httpLog.Retries, - httpLog.CreatedOn, - ) } - scene.AppendToEventPreCommitHook(hooks.InsertHTTPLogsHook, log) } diff --git a/core/handlers/ticket_opened.go b/core/handlers/ticket_opened.go index 13e22482f..6168d44d2 100644 --- a/core/handlers/ticket_opened.go +++ b/core/handlers/ticket_opened.go @@ -10,7 +10,6 @@ import ( "github.com/nyaruka/mailroom/core/hooks" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/runtime" - "github.com/nyaruka/mailroom/services/tickets" "github.com/pkg/errors" ) @@ -24,11 +23,6 @@ func handleTicketOpened(ctx context.Context, rt *runtime.Runtime, tx *sqlx.Tx, o slog.Debug("ticket opened", "contact", scene.ContactUUID(), "session", scene.SessionID(), "ticket", event.Ticket.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) - } - var topicID models.TopicID if event.Ticket.Topic != nil { topic := oa.TopicByUUID(event.Ticket.Topic.UUID) @@ -62,15 +56,9 @@ func handleTicketOpened(ctx context.Context, rt *runtime.Runtime, tx *sqlx.Tx, o scene.UserID(), openedInID, scene.ContactID(), - ticketer.ID(), - event.Ticket.ExternalID, topicID, event.Ticket.Body, assigneeID, - map[string]any{ - "contact-uuid": scene.Contact().UUID(), - "contact-display": tickets.GetContactDisplay(oa.Env(), scene.Contact()), - }, ) scene.AppendToEventPreCommitHook(hooks.InsertTicketsHook, ticket) diff --git a/core/handlers/ticket_opened_test.go b/core/handlers/ticket_opened_test.go index 3ad843e24..ca42d3cb6 100644 --- a/core/handlers/ticket_opened_test.go +++ b/core/handlers/ticket_opened_test.go @@ -8,8 +8,6 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - _ "github.com/nyaruka/mailroom/services/tickets/mailgun" - _ "github.com/nyaruka/mailroom/services/tickets/zendesk" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" ) @@ -45,7 +43,6 @@ func TestTicketOpened(t *testing.T) { testdata.Cathy: []flows.Action{ actions.NewOpenTicket( handlers.NewActionUUID(), - assets.NewTicketerReference(testdata.Mailgun.UUID, "Mailgun (IT Support)"), assets.NewTopicReference(testdata.SupportTopic.UUID, "Support"), "Where are my cookies?", assets.NewUserReference(testdata.Admin.Email, "Admin"), @@ -55,7 +52,6 @@ func TestTicketOpened(t *testing.T) { testdata.Bob: []flows.Action{ actions.NewOpenTicket( handlers.NewActionUUID(), - assets.NewTicketerReference(testdata.Zendesk.UUID, "Zendesk (Nyaruka)"), nil, "I've found some cookies", nil, @@ -65,35 +61,15 @@ func TestTicketOpened(t *testing.T) { }, SQLAssertions: []handlers.SQLAssertion{ { // cathy's old ticket will still be open and cathy's new ticket will have been created - SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O' AND ticketer_id = $2", - Args: []any{testdata.Cathy.ID, testdata.Mailgun.ID}, + SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O'", + Args: []any{testdata.Cathy.ID}, Count: 1, }, - { // and there's an HTTP log for that - SQL: "select count(*) from request_logs_httplog where ticketer_id = $1", - Args: []any{testdata.Mailgun.ID}, - Count: 1, - }, - { // which doesn't include our API token - SQL: "select count(*) from request_logs_httplog where ticketer_id = $1 AND request like '%sesame%'", - Args: []any{testdata.Mailgun.ID}, - Count: 0, - }, { // bob's ticket will have been created too - SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O' AND ticketer_id = $2", - Args: []any{testdata.Bob.ID, testdata.Zendesk.ID}, + SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O'", + Args: []any{testdata.Bob.ID}, Count: 1, }, - { // and there's an HTTP log for that - SQL: "select count(*) from request_logs_httplog where ticketer_id = $1", - Args: []any{testdata.Zendesk.ID}, - Count: 1, - }, - { // which doesn't include our API token - SQL: "select count(*) from request_logs_httplog where ticketer_id = $1 AND request like '%523562%'", - Args: []any{testdata.Zendesk.ID}, - Count: 0, - }, { // and we have 2 ticket opened events for the 2 tickets opened SQL: "select count(*) from tickets_ticketevent where event_type = 'O'", Count: 2, diff --git a/core/models/assets.go b/core/models/assets.go index ef3e524ec..e0596dd28 100644 --- a/core/models/assets.go +++ b/core/models/assets.go @@ -40,10 +40,9 @@ const ( RefreshOptIns = Refresh(1 << 11) RefreshResthooks = Refresh(1 << 12) RefreshTemplates = Refresh(1 << 13) - RefreshTicketers = Refresh(1 << 14) - RefreshTopics = Refresh(1 << 15) - RefreshTriggers = Refresh(1 << 16) - RefreshUsers = Refresh(1 << 17) + RefreshTopics = Refresh(1 << 14) + RefreshTriggers = Refresh(1 << 15) + RefreshUsers = Refresh(1 << 16) ) // OrgAssets is our top level cache of all things contained in an org. It is used to build @@ -89,10 +88,6 @@ type OrgAssets struct { optInsByID map[OptInID]*OptIn optInsByUUID map[assets.OptInUUID]*OptIn - ticketers []assets.Ticketer - ticketersByID map[TicketerID]*Ticketer - ticketersByUUID map[assets.TicketerUUID]*Ticketer - topics []assets.Topic topicsByID map[TopicID]*Topic topicsByUUID map[assets.TopicUUID]*Topic @@ -358,23 +353,6 @@ func NewOrgAssets(ctx context.Context, rt *runtime.Runtime, orgID OrgID, prev *O oa.flowByID = prev.flowByID } - if prev == nil || refresh&RefreshTicketers > 0 { - oa.ticketers, err = loadAssetType(ctx, db, orgID, "ticketers", loadTicketers) - if err != nil { - return nil, errors.Wrapf(err, "error loading ticketer assets for org %d", orgID) - } - 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 { - oa.ticketers = prev.ticketers - oa.ticketersByID = prev.ticketersByID - oa.ticketersByUUID = prev.ticketersByUUID - } - if prev == nil || refresh&RefreshTopics > 0 { oa.topics, err = loadAssetType(ctx, db, orgID, "topics", loadTopics) if err != nil { @@ -687,18 +665,6 @@ func (a *OrgAssets) Globals() ([]assets.Global, error) { return a.globals, nil } -func (a *OrgAssets) Ticketers() ([]assets.Ticketer, error) { - return a.ticketers, nil -} - -func (a *OrgAssets) TicketerByID(id TicketerID) *Ticketer { - return a.ticketersByID[id] -} - -func (a *OrgAssets) TicketerByUUID(uuid assets.TicketerUUID) *Ticketer { - return a.ticketersByUUID[uuid] -} - func (a *OrgAssets) Topics() ([]assets.Topic, error) { return a.topics, nil } diff --git a/core/models/contacts.go b/core/models/contacts.go index e4acd0c3a..be41f48ef 100644 --- a/core/models/contacts.go +++ b/core/models/contacts.go @@ -207,12 +207,8 @@ func (c *Contact) FlowContact(oa *OrgAssets) (*flows.Contact, error) { // convert our ticket to a flow ticket var ticket *flows.Ticket - var err error if c.ticket != nil { - ticket, err = c.ticket.FlowTicket(oa) - if err != nil { - return nil, errors.Wrapf(err, "error creating flow ticket") - } + ticket = c.ticket.FlowTicket(oa) } // create our flow contact @@ -331,10 +327,7 @@ func LoadContacts(ctx context.Context, db Queryer, oa *OrgAssets, ids []ContactI // grab the last opened open ticket if len(e.Tickets) > 0 { t := e.Tickets[0] - ticketer := oa.TicketerByID(t.TicketerID) - if ticketer != nil { - contact.ticket = NewTicket(t.UUID, oa.OrgID(), NilUserID, NilFlowID, contact.ID(), ticketer.ID(), t.ExternalID, t.TopicID, t.Body, t.AssigneeID, nil) - } + contact.ticket = NewTicket(t.UUID, oa.OrgID(), NilUserID, NilFlowID, contact.ID(), t.TopicID, t.Body, t.AssigneeID) } contacts = append(contacts, contact) diff --git a/core/models/contacts_test.go b/core/models/contacts_test.go index 32b3f7b3e..7c130974c 100644 --- a/core/models/contacts_test.go +++ b/core/models/contacts_test.go @@ -29,14 +29,11 @@ func TestContacts(t *testing.T) { defer testsuite.Reset(testsuite.ResetAll) // for now it's still possible to have more than one open ticket in the database - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.SupportTopic, "Where are my shoes?", "1234", time.Now(), testdata.Agent) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.SalesTopic, "Where are my pants?", "2345", time.Now(), nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.SupportTopic, "Where are my shoes?", time.Now(), testdata.Agent) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.SalesTopic, "Where are my pants?", time.Now(), nil) testdata.InsertContactURN(rt, testdata.Org1, testdata.Bob, "whatsapp:250788373373", 999, nil) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Bob, testdata.Mailgun, testdata.DefaultTopic, "His name is Bob", "", time.Now(), testdata.Editor) - - // delete mailgun ticketer - rt.DB.MustExec(`UPDATE tickets_ticketer SET is_active = false WHERE id = $1`, testdata.Mailgun.ID) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Bob, testdata.DefaultTopic, "His name is Bob", time.Now(), testdata.Editor) org, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshAll) assert.NoError(t, err) diff --git a/core/models/http_logs.go b/core/models/http_logs.go index b7b57f321..069b4b712 100644 --- a/core/models/http_logs.go +++ b/core/models/http_logs.go @@ -5,7 +5,6 @@ import ( "database/sql/driver" "time" - "github.com/nyaruka/goflow/flows" "github.com/nyaruka/null/v3" ) @@ -80,13 +79,6 @@ func NewClassifierCalledLog(orgID OrgID, cid ClassifierID, url string, statusCod return h } -// NewTicketerCalledLog creates a new HTTP log for a ticketer call -func NewTicketerCalledLog(orgID OrgID, tid TicketerID, url string, statusCode int, request, response string, isError bool, elapsed time.Duration, retries int, createdOn time.Time) *HTTPLog { - h := newHTTPLog(orgID, LogTypeTicketerCalled, url, statusCode, request, response, isError, elapsed, retries, createdOn) - h.TicketerID = tid - return h -} - // NewAirtimeTransferredLog creates a new HTTP log for an airtime transfer func NewAirtimeTransferredLog(orgID OrgID, url string, statusCode int, request, response string, isError bool, elapsed time.Duration, retries int, createdOn time.Time) *HTTPLog { return newHTTPLog(orgID, LogTypeAirtimeTransferred, url, statusCode, request, response, isError, elapsed, retries, createdOn) @@ -112,34 +104,3 @@ func (i *HTTPLogID) Scan(value any) error { return null.ScanInt(value, i func (i HTTPLogID) Value() (driver.Value, error) { return null.IntValue(i) } func (i *HTTPLogID) UnmarshalJSON(b []byte) error { return null.UnmarshalInt(b, i) } func (i HTTPLogID) MarshalJSON() ([]byte, error) { return null.MarshalInt(i) } - -// HTTPLogger is a logger for HTTPLogs -type HTTPLogger struct { - logs []*HTTPLog -} - -// Ticketer creates a callback for engine HTTP logs which are associated with the given ticketer -func (h *HTTPLogger) Ticketer(t *Ticketer) flows.HTTPLogCallback { - return func(l *flows.HTTPLog) { - h.logs = append(h.logs, NewTicketerCalledLog( - t.OrgID(), - t.ID(), - l.URL, - l.StatusCode, - l.Request, - l.Response, - l.Status != flows.CallStatusSuccess, - time.Duration(l.ElapsedMS)*time.Millisecond, - l.Retries, - l.CreatedOn, - )) - } -} - -// Insert this logger's logs into the database -func (h *HTTPLogger) Insert(ctx context.Context, db DBorTx) error { - if len(h.logs) > 0 { - return InsertHTTPLogs(ctx, db, h.logs) - } - return nil -} diff --git a/core/models/http_logs_test.go b/core/models/http_logs_test.go index a6eb108ed..c2e90981b 100644 --- a/core/models/http_logs_test.go +++ b/core/models/http_logs_test.go @@ -1,19 +1,15 @@ package models_test import ( - "net/http" "testing" "time" "github.com/nyaruka/gocommon/dbutil/assertdb" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestHTTPLogs(t *testing.T) { @@ -42,41 +38,3 @@ func TestHTTPLogs(t *testing.T) { assertdb.Query(t, rt.DB, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND status_code = 400 AND flow_id = $2 AND num_retries = 2`, testdata.Org1.ID, testdata.Favorites.ID).Returns(1) } - -func TestHTTPLogger(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer func() { rt.DB.MustExec(`DELETE FROM request_logs_httplog`) }() - - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://temba.io": { - httpx.NewMockResponse(200, nil, []byte(`hello`)), - httpx.NewMockResponse(400, nil, []byte(`world`)), - }, - })) - - mailgun, err := models.LookupTicketerByUUID(ctx, rt.DB.DB, testdata.Mailgun.UUID) - require.NoError(t, err) - - logger := &models.HTTPLogger{} - log := logger.Ticketer(mailgun) - - // make and log a few HTTP requests - req1, err := http.NewRequest("GET", "https://temba.io", nil) - require.NoError(t, err) - trace1, err := httpx.DoTrace(http.DefaultClient, req1, nil, nil, -1) - require.NoError(t, err) - log(flows.NewHTTPLog(trace1, flows.HTTPStatusFromCode, nil)) - - req2, err := http.NewRequest("GET", "https://temba.io", nil) - require.NoError(t, err) - trace2, err := httpx.DoTrace(http.DefaultClient, req2, nil, nil, -1) - require.NoError(t, err) - log(flows.NewHTTPLog(trace2, flows.HTTPStatusFromCode, nil)) - - err = logger.Insert(ctx, rt.DB) - assert.NoError(t, err) - - assertdb.Query(t, rt.DB, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND ticketer_id = $2`, testdata.Org1.ID, testdata.Mailgun.ID).Returns(2) -} diff --git a/core/models/notifications_test.go b/core/models/notifications_test.go index 4a034eaa8..616f776c4 100644 --- a/core/models/notifications_test.go +++ b/core/models/notifications_test.go @@ -166,7 +166,7 @@ func assertNotifications(t *testing.T, ctx context.Context, db *sqlx.DB, after t } func openTicket(t *testing.T, ctx context.Context, rt *runtime.Runtime, openedBy *testdata.User, assignee *testdata.User) (*models.Ticket, *models.TicketEvent) { - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.SupportTopic, "Where my pants", "", time.Now(), assignee) + ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.SupportTopic, "Where my pants", time.Now(), assignee) modelTicket := ticket.Load(rt) openedEvent := models.NewTicketOpenedEvent(modelTicket, openedBy.SafeID(), assignee.SafeID()) diff --git a/core/models/ticket_events_test.go b/core/models/ticket_events_test.go index c4ba87577..8e2f2a4fd 100644 --- a/core/models/ticket_events_test.go +++ b/core/models/ticket_events_test.go @@ -18,7 +18,7 @@ func TestTicketEvents(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "17", time.Now(), nil) + ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), nil) modelTicket := ticket.Load(rt) e1 := models.NewTicketOpenedEvent(modelTicket, testdata.Admin.ID, testdata.Agent.ID) diff --git a/core/models/tickets.go b/core/models/tickets.go index 0cc8d0752..83f1f7ec7 100644 --- a/core/models/tickets.go +++ b/core/models/tickets.go @@ -4,19 +4,12 @@ import ( "context" "database/sql" "database/sql/driver" - "net/http" "time" "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/dbutil" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/flows/engine" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/null/v3" "github.com/pkg/errors" @@ -49,30 +42,16 @@ const ( TicketDailyTimingLastClose = TicketDailyTimingType("C") ) -// Register a ticket service factory with the engine -func init() { - goflow.RegisterTicketServiceFactory(ticketServiceFactory) -} - -func ticketServiceFactory(c *runtime.Config) engine.TicketServiceFactory { - return func(ticketer *flows.Ticketer) (flows.TicketService, error) { - return ticketer.Asset().(*Ticketer).AsService(c, ticketer) - } -} - type Ticket struct { t struct { ID TicketID `db:"id"` UUID flows.TicketUUID `db:"uuid"` OrgID OrgID `db:"org_id"` ContactID ContactID `db:"contact_id"` - TicketerID TicketerID `db:"ticketer_id"` - ExternalID null.String `db:"external_id"` Status TicketStatus `db:"status"` TopicID TopicID `db:"topic_id"` Body string `db:"body"` AssigneeID UserID `db:"assignee_id"` - Config null.Map[any] `db:"config"` OpenedOn time.Time `db:"opened_on"` OpenedByID UserID `db:"opened_by_id"` OpenedInID FlowID `db:"opened_in_id"` @@ -84,20 +63,17 @@ type Ticket struct { } // NewTicket creates a new open ticket -func NewTicket(uuid flows.TicketUUID, orgID OrgID, userID UserID, flowID FlowID, contactID ContactID, ticketerID TicketerID, externalID string, topicID TopicID, body string, assigneeID UserID, config map[string]any) *Ticket { +func NewTicket(uuid flows.TicketUUID, orgID OrgID, userID UserID, flowID FlowID, contactID ContactID, topicID TopicID, body string, assigneeID UserID) *Ticket { t := &Ticket{} t.t.UUID = uuid t.t.OrgID = orgID t.t.OpenedByID = userID t.t.OpenedInID = flowID t.t.ContactID = contactID - t.t.TicketerID = ticketerID - t.t.ExternalID = null.String(externalID) t.t.Status = TicketStatusOpen t.t.TopicID = topicID t.t.Body = body t.t.AssigneeID = assigneeID - t.t.Config = null.Map[any](config) return t } @@ -105,26 +81,15 @@ func (t *Ticket) ID() TicketID { return t.t.ID } func (t *Ticket) UUID() flows.TicketUUID { return t.t.UUID } func (t *Ticket) OrgID() OrgID { return t.t.OrgID } func (t *Ticket) ContactID() ContactID { return t.t.ContactID } -func (t *Ticket) TicketerID() TicketerID { return t.t.TicketerID } -func (t *Ticket) ExternalID() null.String { return t.t.ExternalID } func (t *Ticket) Status() TicketStatus { return t.t.Status } func (t *Ticket) TopicID() TopicID { return t.t.TopicID } func (t *Ticket) Body() string { return t.t.Body } func (t *Ticket) AssigneeID() UserID { return t.t.AssigneeID } func (t *Ticket) RepliedOn() *time.Time { return t.t.RepliedOn } func (t *Ticket) LastActivityOn() time.Time { return t.t.LastActivityOn } -func (t *Ticket) Config(key string) string { - v, _ := t.t.Config[key].(string) - return v -} -func (t *Ticket) OpenedByID() UserID { return t.t.OpenedByID } - -func (t *Ticket) FlowTicket(oa *OrgAssets) (*flows.Ticket, error) { - modelTicketer := oa.TicketerByID(t.TicketerID()) - if modelTicketer == nil { - return nil, errors.New("unable to load ticketer with id %d") - } +func (t *Ticket) OpenedByID() UserID { return t.t.OpenedByID } +func (t *Ticket) FlowTicket(oa *OrgAssets) *flows.Ticket { var topic *flows.Topic if t.TopicID() != NilTopicID { dbTopic := oa.TopicByID(t.TopicID()) @@ -141,34 +106,7 @@ func (t *Ticket) FlowTicket(oa *OrgAssets) (*flows.Ticket, error) { } } - return flows.NewTicket( - t.UUID(), - oa.SessionAssets().Ticketers().Get(modelTicketer.UUID()), - topic, - t.Body(), - string(t.ExternalID()), - assignee, - ), nil -} - -// ForwardIncoming forwards an incoming message from a contact to this ticket -func (t *Ticket) ForwardIncoming(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment) error { - ticketer := oa.TicketerByID(t.t.TicketerID) - if ticketer == nil { - return errors.Errorf("can't find ticketer with id %d", t.t.TicketerID) - } - - service, err := ticketer.AsService(rt.Config, flows.NewTicketer(ticketer)) - if err != nil { - return err - } - - logger := &HTTPLogger{} - err = service.Forward(t, msgUUID, text, attachments, logger.Ticketer(ticketer)) - - logger.Insert(ctx, rt.DB) - - return err + return flows.NewTicket(t.UUID(), topic, t.Body(), assignee) } const sqlSelectLastOpenTicket = ` @@ -177,13 +115,10 @@ SELECT uuid, org_id, contact_id, - ticketer_id, - external_id, status, topic_id, body, assignee_id, - config, opened_on, opened_by_id, opened_in_id, @@ -214,13 +149,10 @@ SELECT uuid, org_id, contact_id, - ticketer_id, - external_id, status, topic_id, body, assignee_id, - config, opened_on, opened_by_id, opened_in_id, @@ -263,13 +195,10 @@ SELECT t.uuid, t.org_id, t.contact_id, - t.ticketer_id, - t.external_id, t.status, t.topic_id, t.body, t.assignee_id, - t.config, t.opened_on, t.opened_by_id, t.opened_in_id, @@ -287,36 +216,6 @@ func LookupTicketByUUID(ctx context.Context, db *sqlx.DB, uuid flows.TicketUUID) return lookupTicket(ctx, db, sqlSelectTicketByUUID, uuid) } -const sqlSelectTicketByExternalID = ` -SELECT - t.id, - t.uuid, - t.org_id, - t.contact_id, - t.ticketer_id, - t.external_id, - t.status, - t.topic_id, - t.body, - t.assignee_id, - t.config, - t.opened_on, - t.opened_by_id, - t.opened_in_id, - t.replied_on, - t.modified_on, - t.closed_on, - t.last_activity_on -FROM - tickets_ticket t -WHERE - t.ticketer_id = $1 AND t.external_id = $2` - -// LookupTicketByExternalID looks up the ticket with the passed in ticketer and external ID -func LookupTicketByExternalID(ctx context.Context, db *sqlx.DB, ticketerID TicketerID, externalID string) (*Ticket, error) { - return lookupTicket(ctx, db, sqlSelectTicketByExternalID, ticketerID, externalID) -} - func lookupTicket(ctx context.Context, db *sqlx.DB, query string, params ...any) (*Ticket, error) { rows, err := db.QueryxContext(ctx, query, params...) if err != nil && err != sql.ErrNoRows { @@ -339,8 +238,8 @@ func lookupTicket(ctx context.Context, db *sqlx.DB, query string, params ...any) const sqlInsertTicket = ` INSERT INTO - tickets_ticket(uuid, org_id, contact_id, ticketer_id, external_id, status, topic_id, body, assignee_id, config, opened_on, opened_by_id, opened_in_id, modified_on, last_activity_on) - VALUES( :uuid, :org_id, :contact_id, :ticketer_id, :external_id, :status, :topic_id, :body, :assignee_id, :config, NOW(), :opened_by_id, :opened_in_id, NOW() , NOW()) + tickets_ticket(uuid, org_id, contact_id, status, topic_id, body, assignee_id, opened_on, opened_by_id, opened_in_id, modified_on, last_activity_on) + VALUES( :uuid, :org_id, :contact_id, :status, :topic_id, :body, :assignee_id, NOW(), :opened_by_id, :opened_in_id, NOW() , NOW()) RETURNING id ` @@ -380,26 +279,6 @@ func InsertTickets(ctx context.Context, tx DBorTx, oa *OrgAssets, tickets []*Tic return nil } -// UpdateTicketExternalID updates the external ID of the given ticket -func UpdateTicketExternalID(ctx context.Context, db DBorTx, ticket *Ticket, externalID string) error { - t := &ticket.t - t.ExternalID = null.String(externalID) - - _, err := db.ExecContext(ctx, `UPDATE tickets_ticket SET external_id = $2 WHERE id = $1`, t.ID, t.ExternalID) - return errors.Wrap(err, "error updating ticket external id") -} - -// UpdateTicketConfig updates the passed in ticket's config with any passed in values -func UpdateTicketConfig(ctx context.Context, db DBorTx, ticket *Ticket, config map[string]string) error { - t := &ticket.t - for key, value := range config { - t.Config[key] = value - } - - _, err := db.ExecContext(ctx, `UPDATE tickets_ticket SET config = $2 WHERE id = $1`, t.ID, t.Config) - return errors.Wrap(err, "error updating ticket config") -} - // UpdateTicketLastActivity updates the last_activity_on of the given tickets to be now func UpdateTicketLastActivity(ctx context.Context, db DBorTx, tickets []*Ticket) error { now := dates.Now() @@ -552,8 +431,7 @@ UPDATE tickets_ticket WHERE id = ANY($1)` // CloseTickets closes the passed in tickets -func CloseTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userID UserID, tickets []*Ticket, externally, force bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) { - byTicketer := make(map[TicketerID][]*Ticket) +func CloseTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userID UserID, tickets []*Ticket) (map[*Ticket]*TicketEvent, error) { ids := make([]TicketID, 0, len(tickets)) events := make([]*TicketEvent, 0, len(tickets)) eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) @@ -562,7 +440,6 @@ func CloseTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userI for _, ticket := range tickets { if ticket.Status() != TicketStatusClosed { - byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket) ids = append(ids, ticket.ID()) t := &ticket.t t.Status = TicketStatusClosed @@ -577,23 +454,6 @@ func CloseTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userI } } - if externally { - for ticketerID, ticketerTickets := range byTicketer { - ticketer := oa.TicketerByID(ticketerID) - if ticketer != nil { - service, err := ticketer.AsService(rt.Config, flows.NewTicketer(ticketer)) - if err != nil { - return nil, err - } - - err = service.Close(ticketerTickets, logger.Ticketer(ticketer)) - if err != nil && !force { - return nil, err - } - } - } - } - // mark the tickets as closed in the db _, err := rt.DB.ExecContext(ctx, sqlCloseTickets, pq.Array(ids), now) if err != nil { @@ -617,8 +477,7 @@ UPDATE tickets_ticket WHERE id = ANY($1)` // ReopenTickets reopens the passed in tickets -func ReopenTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userID UserID, tickets []*Ticket, externally bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) { - byTicketer := make(map[TicketerID][]*Ticket) +func ReopenTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, userID UserID, tickets []*Ticket) (map[*Ticket]*TicketEvent, error) { ids := make([]TicketID, 0, len(tickets)) events := make([]*TicketEvent, 0, len(tickets)) eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) @@ -627,7 +486,6 @@ func ReopenTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, user for _, ticket := range tickets { if ticket.Status() != TicketStatusOpen { - byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket) ids = append(ids, ticket.ID()) t := &ticket.t t.Status = TicketStatusOpen @@ -642,23 +500,6 @@ func ReopenTickets(ctx context.Context, rt *runtime.Runtime, oa *OrgAssets, user } } - if externally { - for ticketerID, ticketerTickets := range byTicketer { - ticketer := oa.TicketerByID(ticketerID) - if ticketer != nil { - service, err := ticketer.AsService(rt.Config, flows.NewTicketer(ticketer)) - if err != nil { - return nil, err - } - - err = service.Reopen(ticketerTickets, logger.Ticketer(ticketer)) - if err != nil { - return nil, err - } - } - } - } - // mark the tickets as opened in the db _, err := rt.DB.ExecContext(ctx, sqlReopenTickets, pq.Array(ids), now) if err != nil { @@ -735,172 +576,6 @@ func TicketRecordReplied(ctx context.Context, db DBorTx, ticketID TicketID, when return time.Duration(-1), nil } -// Ticketer is our type for a ticketer asset -type Ticketer struct { - t struct { - ID TicketerID `json:"id"` - UUID assets.TicketerUUID `json:"uuid"` - OrgID OrgID `json:"org_id"` - Type string `json:"ticketer_type"` - Name string `json:"name"` - Config map[string]string `json:"config"` - } -} - -// ID returns the ID -func (t *Ticketer) ID() TicketerID { return t.t.ID } - -// UUID returns the UUID -func (t *Ticketer) UUID() assets.TicketerUUID { return t.t.UUID } - -// OrgID returns the org ID -func (t *Ticketer) OrgID() OrgID { return t.t.OrgID } - -// Name returns the name -func (t *Ticketer) Name() string { return t.t.Name } - -// Type returns the type -func (t *Ticketer) Type() string { return t.t.Type } - -// Config returns the named config value -func (t *Ticketer) Config(key string) string { return t.t.Config[key] } - -// Reference returns an asset reference to this ticketer -func (t *Ticketer) Reference() *assets.TicketerReference { - return assets.NewTicketerReference(t.t.UUID, t.t.Name) -} - -// AsService builds the corresponding engine service for the passed in Ticketer -func (t *Ticketer) AsService(cfg *runtime.Config, ticketer *flows.Ticketer) (TicketService, error) { - httpClient, httpRetries, _ := goflow.HTTP(cfg) - - initFunc := ticketServices[t.Type()] - if initFunc != nil { - return initFunc(cfg, httpClient, httpRetries, ticketer, t.t.Config) - } - - return nil, errors.Errorf("unrecognized ticket service type '%s'", t.Type()) -} - -// UpdateConfig updates the configuration of this ticketer with the given values -func (t *Ticketer) UpdateConfig(ctx context.Context, db DBorTx, add map[string]string, remove map[string]bool) error { - for key, value := range add { - t.t.Config[key] = value - } - for key := range remove { - delete(t.t.Config, key) - } - - // convert to null.Map to save - dbMap := make(map[string]any, len(t.t.Config)) - for key, value := range t.t.Config { - dbMap[key] = value - } - - _, err := db.ExecContext(ctx, `UPDATE tickets_ticketer SET config = $2 WHERE id = $1`, t.t.ID, null.Map[any](dbMap)) - return errors.Wrap(err, "error updating ticketer config") -} - -// TicketService extends the engine's ticket service and adds support for forwarding new incoming messages -type TicketService interface { - flows.TicketService - - Forward(*Ticket, flows.MsgUUID, string, []utils.Attachment, flows.HTTPLogCallback) error - Close([]*Ticket, flows.HTTPLogCallback) error - Reopen([]*Ticket, flows.HTTPLogCallback) error -} - -// TicketServiceFunc is a func which creates a ticket service -type TicketServiceFunc func(*runtime.Config, *http.Client, *httpx.RetryConfig, *flows.Ticketer, map[string]string) (TicketService, error) - -var ticketServices = map[string]TicketServiceFunc{} - -// RegisterTicketService registers a new ticket service -func RegisterTicketService(name string, initFunc TicketServiceFunc) { - ticketServices[name] = initFunc -} - -const sqlSelectTicketerByUUID = ` -SELECT ROW_TO_JSON(r) FROM (SELECT - t.id as id, - t.uuid as uuid, - t.org_id as org_id, - t.name as name, - t.ticketer_type as ticketer_type, - t.config as config -FROM - tickets_ticketer t -WHERE - t.uuid = $1 AND - t.is_active = TRUE -) r; -` - -// LookupTicketerByUUID looks up the ticketer with the passed in UUID -func LookupTicketerByUUID(ctx context.Context, db *sql.DB, uuid assets.TicketerUUID) (*Ticketer, error) { - rows, err := db.QueryContext(ctx, sqlSelectTicketerByUUID, string(uuid)) - if err != nil && err != sql.ErrNoRows { - return nil, errors.Wrapf(err, "error querying for ticketer for uuid: %s", string(uuid)) - } - defer rows.Close() - - if err == sql.ErrNoRows || !rows.Next() { - return nil, nil - } - - ticketer := &Ticketer{} - err = dbutil.ScanJSON(rows, &ticketer.t) - if err != nil { - return nil, errors.Wrapf(err, "error unmarshalling ticketer") - } - - return ticketer, nil -} - -const sqlSelectOrgTicketers = ` -SELECT ROW_TO_JSON(r) FROM (SELECT - t.id as id, - t.uuid as uuid, - t.org_id as org_id, - t.name as name, - t.ticketer_type as ticketer_type, - t.config as config -FROM - tickets_ticketer t -WHERE - t.org_id = $1 AND - t.is_active = TRUE -ORDER BY - t.created_on ASC -) r; -` - -// loadTicketers loads all the ticketers for the passed in org -func loadTicketers(ctx context.Context, db *sql.DB, orgID OrgID) ([]assets.Ticketer, error) { - rows, err := db.QueryContext(ctx, sqlSelectOrgTicketers, orgID) - if err != nil && err != sql.ErrNoRows { - return nil, errors.Wrapf(err, "error querying ticketers for org: %d", orgID) - } - defer rows.Close() - - ticketers := make([]assets.Ticketer, 0, 2) - for rows.Next() { - ticketer := &Ticketer{} - err := dbutil.ScanJSON(rows, &ticketer.t) - if err != nil { - return nil, errors.Wrapf(err, "error unmarshalling ticketer") - } - ticketers = append(ticketers, ticketer) - } - - return ticketers, nil -} - -func (i *TicketerID) Scan(value any) error { return null.ScanInt(value, i) } -func (i TicketerID) Value() (driver.Value, error) { return null.IntValue(i) } -func (i *TicketerID) UnmarshalJSON(b []byte) error { return null.UnmarshalInt(b, i) } -func (i TicketerID) MarshalJSON() ([]byte, error) { return null.MarshalInt(i) } - func insertTicketDailyCounts(ctx context.Context, tx DBorTx, countType TicketDailyCountType, tz *time.Location, scopeCounts map[string]int) error { return insertDailyCounts(ctx, tx, "tickets_ticketdailycount", countType, tz, scopeCounts) } diff --git a/core/models/tickets_test.go b/core/models/tickets_test.go index 4da9e48ab..93f750a79 100644 --- a/core/models/tickets_test.go +++ b/core/models/tickets_test.go @@ -7,57 +7,15 @@ import ( "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/dbutil/assertdb" - "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/runtime" - _ "github.com/nyaruka/mailroom/services/tickets/mailgun" - _ "github.com/nyaruka/mailroom/services/tickets/zendesk" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/nyaruka/null/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestTicketers(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) - - // can load directly by UUID - ticketer, err := models.LookupTicketerByUUID(ctx, rt.DB.DB, testdata.Zendesk.UUID) - assert.NoError(t, err) - assert.Equal(t, testdata.Zendesk.ID, ticketer.ID()) - assert.Equal(t, testdata.Zendesk.UUID, 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, rt, testdata.Org1.ID) - assert.NoError(t, err) - - ticketer = org1.TicketerByID(testdata.Zendesk.ID) - assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID()) - assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name()) - assert.Equal(t, "1234-abcd", ticketer.Config("push_id")) - - ticketer = org1.TicketerByUUID(testdata.Zendesk.UUID) - assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID()) - assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name()) - assert.Equal(t, "1234-abcd", ticketer.Config("push_id")) - - ticketer.UpdateConfig(ctx, rt.DB, map[string]string{"new-key": "foo"}, map[string]bool{"push_id": true}) - - org1, _ = models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers) - ticketer = org1.TicketerByID(testdata.Zendesk.ID) - - 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, rt := testsuite.Runtime() @@ -71,14 +29,9 @@ func TestTickets(t *testing.T) { testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, - testdata.Mailgun.ID, - "EX12345", testdata.DefaultTopic.ID, "Where are my cookies?", testdata.Admin.ID, - map[string]any{ - "contact-display": "Cathy", - }, ) ticket2 := models.NewTicket( "64f81be1-00ff-48ef-9e51-97d6f924c1a4", @@ -86,12 +39,9 @@ func TestTickets(t *testing.T) { testdata.Admin.ID, models.NilFlowID, testdata.Bob.ID, - testdata.Zendesk.ID, - "EX7869", testdata.SalesTopic.ID, "Where are my trousers?", models.NilUserID, - nil, ) ticket3 := models.NewTicket( "28ef8ddc-b221-42f3-aeae-ee406fc9d716", @@ -99,23 +49,16 @@ func TestTickets(t *testing.T) { models.NilUserID, testdata.Favorites.ID, testdata.Alexandria.ID, - testdata.Zendesk.ID, - "EX6677", testdata.SupportTopic.ID, "Where are my pants?", testdata.Admin.ID, - nil, ) assert.Equal(t, flows.TicketUUID("2ef57efc-d85f-4291-b330-e4afe68af5fe"), ticket1.UUID()) assert.Equal(t, testdata.Org1.ID, ticket1.OrgID()) assert.Equal(t, testdata.Cathy.ID, ticket1.ContactID()) - assert.Equal(t, testdata.Mailgun.ID, ticket1.TicketerID()) - assert.Equal(t, null.String("EX12345"), ticket1.ExternalID()) assert.Equal(t, testdata.DefaultTopic.ID, ticket1.TopicID()) - assert.Equal(t, "Cathy", ticket1.Config("contact-display")) assert.Equal(t, testdata.Admin.ID, ticket1.AssigneeID()) - assert.Equal(t, "", ticket1.Config("xyz")) err := models.InsertTickets(ctx, rt.DB, oa, []*models.Ticket{ticket1, ticket2, ticket3}) assert.NoError(t, err) @@ -134,11 +77,6 @@ func TestTickets(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "Where are my cookies?", tk1.Body()) - // can lookup a ticket by external ID and ticketer - tk2, err := models.LookupTicketByExternalID(ctx, rt.DB, testdata.Zendesk.ID, "EX7869") - assert.NoError(t, err) - assert.Equal(t, "Where are my trousers?", tk2.Body()) - // can lookup open tickets by contact org1, _ := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) cathy, err := models.LoadContact(ctx, rt.DB, org1, testdata.Cathy.ID) @@ -150,27 +88,6 @@ func TestTickets(t *testing.T) { assert.Equal(t, "Where are my cookies?", tk.Body()) } -func TestUpdateTicketConfig(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetData) - - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", time.Now(), nil) - modelTicket := ticket.Load(rt) - - // empty configs are null - assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticket WHERE config IS NULL AND id = $1`, ticket.ID).Returns(1) - - models.UpdateTicketConfig(ctx, rt.DB, modelTicket, map[string]string{"foo": "2352", "bar": "abc"}) - - assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticket WHERE config='{"foo": "2352", "bar": "abc"}'::jsonb AND id = $1`, ticket.ID).Returns(1) - - // updates are additive - models.UpdateTicketConfig(ctx, rt.DB, modelTicket, map[string]string{"foo": "6547", "zed": "xyz"}) - - assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticket WHERE config='{"foo": "6547", "bar": "abc", "zed": "xyz"}'::jsonb AND id = $1`, ticket.ID).Returns(1) -} - func TestUpdateTicketLastActivity(t *testing.T) { ctx, rt := testsuite.Runtime() @@ -181,7 +98,7 @@ func TestUpdateTicketLastActivity(t *testing.T) { defer dates.SetNowSource(dates.DefaultNowSource) dates.SetNowSource(dates.NewFixedNowSource(now)) - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", time.Now(), nil) + ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", time.Now(), nil) modelTicket := ticket.Load(rt) models.UpdateTicketLastActivity(ctx, rt.DB, []*models.Ticket{modelTicket}) @@ -197,20 +114,20 @@ func TestTicketsAssign(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers) + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", nil) + ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", nil) modelTicket1 := ticket1.Load(rt) - ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my pants", "234", time.Now(), nil) + ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my pants", time.Now(), nil) modelTicket2 := ticket2.Load(rt) // create ticket already assigned to a user - ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my glasses", "", time.Now(), testdata.Admin) + ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my glasses", time.Now(), testdata.Admin) modelTicket3 := ticket3.Load(rt) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "", "", time.Now(), nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "", time.Now(), nil) evts, err := models.TicketsAssign(ctx, rt.DB, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2, modelTicket3}, testdata.Agent.ID) require.NoError(t, err) @@ -238,16 +155,16 @@ func TestTicketsAddNote(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers) + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", nil) + ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", nil) modelTicket1 := ticket1.Load(rt) - ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my pants", "234", time.Now(), testdata.Agent) + ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my pants", time.Now(), testdata.Agent) modelTicket2 := ticket2.Load(rt) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "", "", time.Now(), nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "", time.Now(), nil) evts, err := models.TicketsAddNote(ctx, rt.DB, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, "spam") require.NoError(t, err) @@ -266,19 +183,19 @@ func TestTicketsChangeTopic(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers) + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.SalesTopic, "Where my shoes", "123", nil) + ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.SalesTopic, "Where my shoes", nil) modelTicket1 := ticket1.Load(rt) - ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.SupportTopic, "Where my pants", "234", time.Now(), nil) + ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.SupportTopic, "Where my pants", time.Now(), nil) modelTicket2 := ticket2.Load(rt) - ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my pants", "345", time.Now(), nil) + ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my pants", time.Now(), nil) modelTicket3 := ticket3.Load(rt) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "", "", time.Now(), nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "", time.Now(), nil) evts, err := models.TicketsChangeTopic(ctx, rt.DB, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2, modelTicket3}, testdata.SupportTopic.ID) require.NoError(t, err) @@ -295,24 +212,14 @@ func TestCloseTickets(t *testing.T) { ctx, rt := testsuite.Runtime() defer testsuite.Reset(testsuite.ResetData) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - })) - - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers|models.RefreshGroups) + + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - ticket1 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", time.Now(), nil) + ticket1 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", time.Now(), nil) modelTicket1 := ticket1.Load(rt) - ticket2 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my pants", "234", nil) + ticket2 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my pants", nil) modelTicket2 := ticket2.Load(rt) _, cathy, _ := testdata.Cathy.Load(rt, oa) @@ -323,8 +230,7 @@ func TestCloseTickets(t *testing.T) { assert.Equal(t, "Doctors", cathy.Groups().All()[0].Name()) assert.Equal(t, "Open Tickets", cathy.Groups().All()[1].Name()) - logger := &models.HTTPLogger{} - evts, err := models.CloseTickets(ctx, rt, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, false, logger) + evts, err := models.CloseTickets(ctx, rt, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}) require.NoError(t, err) assert.Equal(t, 1, len(evts)) assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket1].EventType()) @@ -336,11 +242,6 @@ func TestCloseTickets(t *testing.T) { assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticketevent WHERE org_id = $1 AND ticket_id = $2 AND event_type = 'C'`, []any{testdata.Org1.ID, ticket1.ID}, 1) - // and the logger has an http log it can insert for that ticketer - require.NoError(t, logger.Insert(ctx, rt.DB)) - - assertdb.Query(t, rt.DB, `SELECT count(*) FROM request_logs_httplog WHERE ticketer_id = $1`, testdata.Mailgun.ID).Returns(1) - // reload Cathy and check they're no longer in the tickets group _, cathy, _ = testdata.Cathy.Load(rt, oa) assert.Equal(t, 1, len(cathy.Groups().All())) @@ -350,10 +251,10 @@ func TestCloseTickets(t *testing.T) { assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'C'`, ticket2.ID).Returns(0) // can close tickets without a user - ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", time.Now(), nil) + ticket3 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", time.Now(), nil) modelTicket3 := ticket3.Load(rt) - evts, err = models.CloseTickets(ctx, rt, oa, models.NilUserID, []*models.Ticket{modelTicket3}, false, false, logger) + evts, err = models.CloseTickets(ctx, rt, oa, models.NilUserID, []*models.Ticket{modelTicket3}) require.NoError(t, err) assert.Equal(t, 1, len(evts)) assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket3].EventType()) @@ -365,28 +266,17 @@ func TestReopenTickets(t *testing.T) { ctx, rt := testsuite.Runtime() defer testsuite.Reset(testsuite.ResetData) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - })) - - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTicketers|models.RefreshGroups) + + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", nil) + ticket1 := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", nil) modelTicket1 := ticket1.Load(rt) - ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Where my pants", "234", time.Now(), nil) + ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my pants", time.Now(), nil) modelTicket2 := ticket2.Load(rt) - logger := &models.HTTPLogger{} - evts, err := models.ReopenTickets(ctx, rt, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, logger) + evts, err := models.ReopenTickets(ctx, rt, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}) require.NoError(t, err) assert.Equal(t, 1, len(evts)) assert.Equal(t, models.TicketEventTypeReopened, evts[modelTicket1].EventType()) @@ -397,11 +287,6 @@ func TestReopenTickets(t *testing.T) { // and there's reopened event for it assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticketevent WHERE org_id = $1 AND ticket_id = $2 AND event_type = 'R'`, testdata.Org1.ID, ticket1.ID).Returns(1) - // and the logger has an http log it can insert for that ticketer - require.NoError(t, logger.Insert(ctx, rt.DB)) - - assertdb.Query(t, rt.DB, `SELECT count(*) FROM request_logs_httplog WHERE ticketer_id = $1`, testdata.Mailgun.ID).Returns(1) - // but no events for ticket #2 which waas already open assertdb.Query(t, rt.DB, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'R'`, ticket2.ID).Returns(0) @@ -423,7 +308,7 @@ func TestTicketRecordReply(t *testing.T) { openedOn := time.Date(2022, 5, 18, 14, 21, 0, 0, time.UTC) repliedOn := time.Date(2022, 5, 18, 15, 0, 0, 0, time.UTC) - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where my shoes", "123", openedOn, nil) + ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where my shoes", openedOn, nil) timing, err := models.TicketRecordReplied(ctx, rt.DB, ticket.ID, repliedOn) assert.NoError(t, err) diff --git a/core/tasks/handler/contact_tasks.go b/core/tasks/handler/contact_tasks.go index d3bbfac65..de91a2c7a 100644 --- a/core/tasks/handler/contact_tasks.go +++ b/core/tasks/handler/contact_tasks.go @@ -405,9 +405,6 @@ func handleMsgEvent(ctx context.Context, rt *runtime.Runtime, event *MsgEvent) e if err != nil { return errors.Wrapf(err, "unable to look up open tickets for contact") } - if ticket != nil { - ticket.ForwardIncoming(ctx, rt, oa, event.MsgUUID, event.Text, attachments) - } // find any matching triggers trigger, keyword := models.FindMatchingMsgTrigger(oa, channel, contact, event.Text) @@ -581,10 +578,7 @@ func handleTicketEvent(ctx context.Context, rt *runtime.Runtime, event *models.T } // build our flow ticket - ticket, err := tickets[0].FlowTicket(oa) - if err != nil { - return errors.Wrapf(err, "error creating flow contact") - } + ticket := tickets[0].FlowTicket(oa) // build our flow trigger var flowTrigger flows.Trigger diff --git a/core/tasks/handler/handle_contact_event_test.go b/core/tasks/handler/handle_contact_event_test.go index 22ef0e33c..38c7d3a92 100644 --- a/core/tasks/handler/handle_contact_event_test.go +++ b/core/tasks/handler/handle_contact_event_test.go @@ -39,15 +39,15 @@ func TestMsgEvents(t *testing.T) { // give Cathy and Bob some tickets... openTickets := map[*testdata.Contact][]*testdata.Ticket{ testdata.Cathy: { - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Ok", "", time.Now(), nil), + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Ok", time.Now(), nil), }, } closedTickets := map[*testdata.Contact][]*testdata.Ticket{ testdata.Cathy: { - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "", "", nil), + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "", nil), }, testdata.Bob: { - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.Mailgun, testdata.DefaultTopic, "Ok", "", nil), + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.DefaultTopic, "Ok", nil), }, } @@ -573,7 +573,7 @@ func TestTicketEvents(t *testing.T) { // add a ticket closed trigger testdata.InsertTicketClosedTrigger(rt, testdata.Org1, testdata.Favorites) - ticket := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Where are my shoes?", "", nil) + ticket := testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Where are my shoes?", nil) modelTicket := ticket.Load(rt) event := models.NewTicketClosedEvent(modelTicket, testdata.Admin.ID) diff --git a/go.mod b/go.mod index 5ce812532..b2b00ab28 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/lib/pq v1.10.9 github.com/nyaruka/ezconf v0.2.1 github.com/nyaruka/gocommon v1.42.0 - github.com/nyaruka/goflow v0.197.1 + github.com/nyaruka/goflow v0.197.2-0.20231016213748-79b726331261 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null/v3 v3.0.0 github.com/nyaruka/redisx v0.5.0 diff --git a/go.sum b/go.sum index d2dc651e4..a2d38339f 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/nyaruka/gocommon v1.42.0 h1:lJtIJ+1fehx8DWrxFegR0OtH1BjKIZs8/y/zaLrCm github.com/nyaruka/gocommon v1.42.0/go.mod h1:JuphjZr/q+GYycaXSQ1WmXzJdbqkbm0iMBlqxxVcF8M= github.com/nyaruka/goflow v0.197.1 h1:cUbSCquY8bZzpgD4wVo30SYFNeb4+qkGXNhCOskCms8= github.com/nyaruka/goflow v0.197.1/go.mod h1:85aGw8FytONUE8W22xmkgYWIVwgZszeYtaeLowYqaq0= +github.com/nyaruka/goflow v0.197.2-0.20231016213748-79b726331261 h1:Na80mnJdTUXMHTRxNWAryNUb2lbdPbYEmCzYgPpMdP4= +github.com/nyaruka/goflow v0.197.2-0.20231016213748-79b726331261/go.mod h1:85aGw8FytONUE8W22xmkgYWIVwgZszeYtaeLowYqaq0= github.com/nyaruka/librato v1.1.1 h1:0nTYtJLl3Sn7lX3CuHsLf+nXy1k/tGV0OjVxLy3Et4s= github.com/nyaruka/librato v1.1.1/go.mod h1:fme1Fu1PT2qvkaBZyw8WW+SrnFe2qeeCWpvqmAaKAKE= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/services/tickets/intern/service.go b/services/tickets/intern/service.go deleted file mode 100644 index 7743bb967..000000000 --- a/services/tickets/intern/service.go +++ /dev/null @@ -1,49 +0,0 @@ -package intern - -import ( - "net/http" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" -) - -const ( - typeInternal = "internal" -) - -func init() { - models.RegisterTicketService(typeInternal, NewService) -} - -type service struct { - ticketer *flows.Ticketer -} - -// NewService creates a new internal ticket service -func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { - return &service{ticketer: ticketer}, nil -} - -// Open just returns a new ticket - no external service to notify -func (s *service) Open(env envs.Environment, contact *flows.Contact, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - return flows.OpenTicket(s.ticketer, topic, body, assignee), nil -} - -// Forward is a noop -func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { - return nil -} - -// Close is a noop -func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - return nil -} - -// Reopen is a noop -func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - return nil -} diff --git a/services/tickets/intern/service_test.go b/services/tickets/intern/service_test.go deleted file mode 100644 index 58700d851..000000000 --- a/services/tickets/intern/service_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package intern_test - -import ( - "net/http" - "testing" - - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/assets/static" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - intern "github.com/nyaruka/mailroom/services/tickets/intern" - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestOpenAndForward(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer uuids.SetGenerator(uuids.DefaultGenerator) - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal")) - - svc, err := intern.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - nil, - ) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - - oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) - require.NoError(t, err) - defaultTopic := oa.SessionAssets().Topics().FindByName("General") - - env := envs.NewBuilder().Build() - _, contact, _ := testdata.Cathy.Load(rt, oa) - - ticket, err := svc.Open(env, contact, defaultTopic, "Where are my cookies?", nil, logger.Log) - assert.NoError(t, err) - assert.Equal(t, flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), ticket.UUID()) - assert.Equal(t, "General", ticket.Topic().Name()) - assert.Equal(t, "Where are my cookies?", ticket.Body()) - assert.Equal(t, "", ticket.ExternalID()) - assert.Equal(t, 0, len(logger.Logs)) - - dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Internal.ID, "", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, nil) - - logger = &flows.HTTPLogger{} - err = svc.Forward( - dbTicket, - flows.MsgUUID("ca5607f0-cba8-4c94-9cd5-c4fbc24aa767"), - "It's urgent", - []utils.Attachment{utils.Attachment("image/jpg:http://myfiles.com/media/0123/attachment1.jpg")}, - logger.Log, - ) - - // forwarding is a NOOP for internal ticketers - assert.NoError(t, err) - assert.Equal(t, 0, len(logger.Logs)) -} - -func TestCloseAndReopen(t *testing.T) { - _, rt := testsuite.Runtime() - - defer uuids.SetGenerator(uuids.DefaultGenerator) - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal")) - svc, err := intern.NewService(rt.Config, http.DefaultClient, nil, ticketer, nil) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Internal.ID, "12", testdata.DefaultTopic.ID, "Where my cookies?", models.NilUserID, nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Bob.ID, testdata.Internal.ID, "14", testdata.DefaultTopic.ID, "Where my shoes?", models.NilUserID, nil) - - err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) - - // NOOP - assert.NoError(t, err) - assert.Equal(t, 0, len(logger.Logs)) - - err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) - - // NOOP - assert.NoError(t, err) - assert.Equal(t, 0, len(logger.Logs)) -} diff --git a/services/tickets/mailgun/client.go b/services/tickets/mailgun/client.go deleted file mode 100644 index 7df5b8c3c..000000000 --- a/services/tickets/mailgun/client.go +++ /dev/null @@ -1,129 +0,0 @@ -package mailgun - -import ( - "bytes" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/textproto" - "sort" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/jsonx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/utils" - "github.com/pkg/errors" -) - -const apiBaseURL = "https://api.mailgun.net/v3" - -type baseResponse struct { - Message string `json:"message"` -} - -// Client is a basic mailgun client -type Client struct { - httpClient *http.Client - httpRetries *httpx.RetryConfig - domain string - apiKey string -} - -// NewClient creates a new mailgun client -func NewClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, domain, apiKey string) *Client { - return &Client{ - httpClient: httpClient, - httpRetries: httpRetries, - domain: domain, - apiKey: apiKey, - } -} - -type messageResponse struct { - baseResponse - ID string `json:"id"` -} - -// EmailAttachment is an email attachment -type EmailAttachment struct { - Filename string - ContentType string - Body io.Reader -} - -// SendMessage sends a new email message and returns the ID -// see https://documentation.mailgun.com/en/latest/api-sending.html -func (c *Client) SendMessage(from, to, subject, text string, attachments []*EmailAttachment, headers map[string]string) (string, *httpx.Trace, error) { - writeBody := func(w *multipart.Writer) error { - w.WriteField("from", from) - w.WriteField("to", to) - w.WriteField("subject", subject) - w.WriteField("text", text) - - for _, attachment := range attachments { - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="attachment"; filename="%s"`, attachment.Filename)) - h.Set("Content-Type", attachment.ContentType) - fw, err := w.CreatePart(h) - if err != nil { - return err - } - _, err = io.Copy(fw, attachment.Body) - if err != nil { - return err - } - } - - // for the sake of tests, we want to output headers in consistent order - headerKeys := make([]string, 0, len(headers)) - for k := range headers { - headerKeys = append(headerKeys, k) - } - sort.Strings(headerKeys) - - for _, k := range headerKeys { - w.WriteField("h:"+k, headers[k]) - } - return nil - } - - trace, err := c.post("messages", writeBody) - if err != nil { - return "", trace, err - } - - if trace.Response.StatusCode >= 400 { - response := &baseResponse{} - jsonx.Unmarshal(trace.ResponseBody, response) - return "", trace, errors.New(response.Message) - } - - response := &messageResponse{} - if err := utils.UnmarshalAndValidate(trace.ResponseBody, response); err != nil { - return "", trace, err - } - - return response.ID, trace, nil -} - -func (c *Client) post(endpoint string, payload func(w *multipart.Writer) error) (*httpx.Trace, error) { - b := &bytes.Buffer{} - w := multipart.NewWriter(b) - w.SetBoundary(string(uuids.New())) - - if err := payload(w); err != nil { - return nil, err - } - - w.Close() - - req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s/%s", apiBaseURL, c.domain, endpoint), bytes.NewReader(b.Bytes())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.SetBasicAuth("api", c.apiKey) - - return httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1) -} diff --git a/services/tickets/mailgun/client_test.go b/services/tickets/mailgun/client_test.go deleted file mode 100644 index 6c875df61..000000000 --- a/services/tickets/mailgun/client_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package mailgun_test - -import ( - "bytes" - "net/http" - "testing" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/test" - "github.com/nyaruka/mailroom/services/tickets/mailgun" - - "github.com/stretchr/testify/assert" -) - -func TestSendMessage(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - defer uuids.SetGenerator(uuids.DefaultGenerator) - - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.MockConnectionError, - httpx.NewMockResponse(400, nil, []byte(`{"message": "Something went wrong"}`)), // non-200 response - httpx.NewMockResponse(200, nil, []byte(`xx`)), // non-JSON response - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - })) - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - - client := mailgun.NewClient(http.DefaultClient, nil, "tickets.rapidpro.io", "123456789") - - _, _, err := client.SendMessage("Bob ", "support@acme.com", "Need help", "Where are my cookies?", nil, nil) - assert.EqualError(t, err, "unable to connect to server") - - _, _, err = client.SendMessage("Bob ", "support@acme.com", "Need help", "Where are my cookies?", nil, nil) - assert.EqualError(t, err, "Something went wrong") - - _, _, err = client.SendMessage("Bob ", "support@acme.com", "Need help", "Where are my cookies?", nil, nil) - assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") - - msgID, trace, err := client.SendMessage( - "Bob ", - "support@acme.com", - "Need help", - "Where are my cookies?", - []*mailgun.EmailAttachment{ - {"test.jpg", "image/jpeg", bytes.NewReader([]byte(`IMANIMAGE`))}, - {"test.mp4", "audio/mp4", bytes.NewReader([]byte(`IMAVIDEO`))}, - }, - map[string]string{"In-Reply-To": "12415"}, - ) - assert.NoError(t, err) - assert.Equal(t, "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", msgID) - assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 111\r\n\r\n", string(trace.ResponseTrace)) - - test.AssertSnapshot(t, "mailgun_request", string(trace.RequestTrace)) -} diff --git a/services/tickets/mailgun/service.go b/services/tickets/mailgun/service.go deleted file mode 100644 index b2beffb6e..000000000 --- a/services/tickets/mailgun/service.go +++ /dev/null @@ -1,236 +0,0 @@ -package mailgun - -import ( - "encoding/base64" - "fmt" - "net/http" - "strings" - "text/template" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/stringsx" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" - "github.com/nyaruka/mailroom/services/tickets" - - "github.com/pkg/errors" -) - -const ( - typeMailgun = "mailgun" - - configDomain = "domain" - configAPIKey = "api_key" - configToAddress = "to_address" - configBrandName = "brand_name" - configURLBase = "url_base" - - ticketConfigContactUUID = "contact-uuid" - ticketConfigContactDisplay = "contact-display" - ticketConfigLastMessageID = "last-message-id" -) - -// body template for new ticket being opened -var openBodyTemplate = newTemplate("open_body", `New ticket opened ------------------------------------------------- - -{{.body}} - ------------------------------------------------- -* Reply to the contact by replying to this email -* Close this ticket by replying with CLOSE -* View this contact at {{.contact_url}} -`) - -// body template for message being forwarded from contact -var forwardBodyTemplate = newTemplate("forward_body", `{{.contact}} replied: ------------------------------------------------- - -{{.message}} - ------------------------------------------------- -* Reply to the contact by replying to this email -* Close this ticket by replying with CLOSE -* View this contact at {{.contact_url}} -`) - -// body template for ticket being closed -var closedBodyTemplate = newTemplate("closed_body", `{{.message}} -* Ticket has been closed -* Replying to the contact will reopen this ticket -* View this contact at {{.contact_url}} -`) - -// body template for ticket being reopened -var reopenedBodyTemplate = newTemplate("reopened_body", `{{.message}} -* Ticket has been reopened -* Close this ticket by replying with CLOSE -* View this contact at {{.contact_url}} -`) - -func init() { - models.RegisterTicketService(typeMailgun, NewService) -} - -type service struct { - client *Client - ticketer *flows.Ticketer - toAddress string - brandName string - urlBase string - redactor stringsx.Redactor -} - -// NewService creates a new mailgun email-based ticket service -func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { - domain := config[configDomain] - apiKey := config[configAPIKey] - toAddress := config[configToAddress] - brandName := config[configBrandName] - urlBase := config[configURLBase] - - if domain != "" && apiKey != "" && toAddress != "" && urlBase != "" { - // need to redact the string used for basic auth - basicAuth := base64.StdEncoding.EncodeToString([]byte("api:" + apiKey)) - - return &service{ - client: NewClient(httpClient, httpRetries, domain, apiKey), - ticketer: ticketer, - toAddress: toAddress, - brandName: brandName, - urlBase: urlBase, - redactor: stringsx.NewRedactor(flows.RedactionMask, apiKey, basicAuth), - }, nil - } - return nil, errors.New("missing domain or api_key or to_address or url_base in mailgun config") -} - -// Open opens a ticket which for mailgun means just sending an initial email -func (s *service) Open(env envs.Environment, contact *flows.Contact, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - ticket := flows.OpenTicket(s.ticketer, topic, body, assignee) - contactDisplay := tickets.GetContactDisplay(env, contact) - - from := s.ticketAddress(contactDisplay, ticket.UUID()) - context := s.templateContext(body, "", string(contact.UUID()), contactDisplay) - fullBody := evaluateTemplate(openBodyTemplate, context) - - msgID, trace, err := s.client.SendMessage(from, s.toAddress, subjectFromBody(body), fullBody, nil, nil) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return nil, errors.Wrap(err, "error calling mailgun API") - } - - ticket.SetExternalID(msgID) - return ticket, nil -} - -func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { - context := s.templateContext(ticket.Body(), text, ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - body := evaluateTemplate(forwardBodyTemplate, context) - - _, err := s.sendInTicket(ticket, body, attachments, logHTTP) - return err -} - -func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - for _, ticket := range tickets { - context := s.templateContext(ticket.Body(), "", ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - body := evaluateTemplate(closedBodyTemplate, context) - - _, err := s.sendInTicket(ticket, body, nil, logHTTP) - if err != nil { - return err - } - } - return nil -} - -func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - for _, ticket := range tickets { - context := s.templateContext(ticket.Body(), "", ticket.Config(ticketConfigContactUUID), ticket.Config(ticketConfigContactDisplay)) - body := evaluateTemplate(reopenedBodyTemplate, context) - - _, err := s.sendInTicket(ticket, body, nil, logHTTP) - if err != nil { - return err - } - } - return nil -} - -// sends an email as part of the thread for the given ticket -func (s *service) sendInTicket(ticket *models.Ticket, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) (string, error) { - contactDisplay := ticket.Config(ticketConfigContactDisplay) - lastMessageID := ticket.Config(ticketConfigLastMessageID) - if lastMessageID == "" { - lastMessageID = string(ticket.ExternalID()) // id of first message sent becomes external ID - } - headers := map[string]string{ - "In-Reply-To": lastMessageID, - "References": lastMessageID, - } - from := s.ticketAddress(contactDisplay, ticket.UUID()) - - return s.send(from, s.toAddress, subjectFromBody(ticket.Body()), text, attachments, headers, logHTTP) -} - -func (s *service) send(from, to, subject, text string, attachments []utils.Attachment, headers map[string]string, logHTTP flows.HTTPLogCallback) (string, error) { - // fetch our attachments and convert to email attachments - emailAttachments := make([]*EmailAttachment, len(attachments)) - for i, attachment := range attachments { - file, err := tickets.FetchFile(attachment.URL(), nil) - if err != nil { - return "", errors.Wrapf(err, "error fetching attachment file") - } - emailAttachments[i] = &EmailAttachment{Filename: "untitled", ContentType: file.ContentType, Body: file.Body} - } - - msgID, trace, err := s.client.SendMessage(from, to, subject, text, emailAttachments, headers) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return "", errors.Wrap(err, "error calling mailgun API") - } - - return msgID, nil -} - -func (s *service) ticketAddress(contactDisplay string, ticketUUID flows.TicketUUID) string { - address := fmt.Sprintf("ticket+%s@%s", ticketUUID, s.client.domain) - return fmt.Sprintf("%s via %s <%s>", contactDisplay, s.brandName, address) -} - -func (s *service) noReplyAddress() string { - return fmt.Sprintf("no-reply@%s", s.client.domain) -} - -func (s *service) templateContext(body, message, contactUUID, contactDisplay string) map[string]string { - return map[string]string{ - "brand": s.brandName, // rapidpro brand - "subject": subjectFromBody(body), // portion of body used as subject - "body": body, // original ticket body - "message": message, // new message if this is a forward - "contact": contactDisplay, // display name contact - "contact_url": fmt.Sprintf("%s/contact/read/%s/", s.urlBase, contactUUID), // link to contact - } -} - -func newTemplate(name, value string) *template.Template { - return template.Must(template.New(name).Parse(value)) -} - -func evaluateTemplate(t *template.Template, c map[string]string) string { - b := &strings.Builder{} - t.Execute(b, c) - return b.String() -} - -func subjectFromBody(body string) string { - return stringsx.Truncate(strings.ReplaceAll(body, "\n", ""), 64) -} diff --git a/services/tickets/mailgun/service_test.go b/services/tickets/mailgun/service_test.go deleted file mode 100644 index df58b0e9c..000000000 --- a/services/tickets/mailgun/service_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package mailgun_test - -import ( - "net/http" - "testing" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/assets/static" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/test" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/services/tickets/mailgun" - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestOpenAndForward(t *testing.T) { - ctx, rt := testsuite.Runtime() - - session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) - require.NoError(t, err) - - defer uuids.SetGenerator(uuids.DefaultGenerator) - defer dates.SetNowSource(dates.DefaultNowSource) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 123456789, time.UTC))) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.MockConnectionError, - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - "http://myfiles.com/media/0123/attachment1.jpg": { - httpx.NewMockResponse(200, map[string]string{"Content-Type": "image/jpg"}, []byte(`MYIMAGE`)), - }, - })) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun")) - - _, err = mailgun.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{}, - ) - assert.EqualError(t, err, "missing domain or api_key or to_address or url_base in mailgun config") - - svc, err := mailgun.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{ - "domain": "tickets.rapidpro.io", - "api_key": "123456789", - "to_address": "bob@acme.com", - "brand_name": "ACME", - "url_base": "http://app.rapidpro.io", - }, - ) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - - oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) - require.NoError(t, err) - defaultTopic := oa.SessionAssets().Topics().FindByName("General") - - _, err = svc.Open(session.Environment(), session.Contact(), defaultTopic, "Where are my cookies?", nil, logger.Log) - assert.EqualError(t, err, "error calling mailgun API: unable to connect to server") - - logger = &flows.HTTPLogger{} - - ticket, err := svc.Open(session.Environment(), session.Contact(), defaultTopic, "Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies?", nil, logger.Log) - assert.NoError(t, err) - assert.Equal(t, flows.TicketUUID("9688d21d-95aa-4bed-afc7-f31b35731a3d"), ticket.UUID()) - assert.Equal(t, "General", ticket.Topic().Name()) - assert.Equal(t, "Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies?", ticket.Body()) - assert.Equal(t, "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", ticket.ExternalID()) - assert.Equal(t, 1, len(logger.Logs)) - test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) - - dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Mailgun.ID, "", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, map[string]any{ - "contact-uuid": string(testdata.Cathy.UUID), - "contact-display": "Cathy", - }) - - logger = &flows.HTTPLogger{} - err = svc.Forward( - dbTicket, - flows.MsgUUID("ca5607f0-cba8-4c94-9cd5-c4fbc24aa767"), - "It's urgent", - []utils.Attachment{utils.Attachment("image/jpg:http://myfiles.com/media/0123/attachment1.jpg")}, - logger.Log, - ) - - assert.NoError(t, err) - assert.Equal(t, 1, len(logger.Logs)) - test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) -} - -func TestCloseAndReopen(t *testing.T) { - _, rt := testsuite.Runtime() - - defer uuids.SetGenerator(uuids.DefaultGenerator) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - })) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun")) - svc, err := mailgun.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{ - "domain": "tickets.rapidpro.io", - "api_key": "123456789", - "to_address": "bob@acme.com", - "brand_name": "ACME", - "url_base": "http://app.rapidpro.io", - }, - ) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", testdata.DefaultTopic.ID, "Where my cookies?", models.NilUserID, nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Bob.ID, testdata.Zendesk.ID, "14", testdata.DefaultTopic.ID, "Where my shoes?", models.NilUserID, nil) - - err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) - - assert.NoError(t, err) - test.AssertSnapshot(t, "close_tickets", logger.Logs[0].Request) - - err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) - - assert.NoError(t, err) - test.AssertSnapshot(t, "reopen_tickets", logger.Logs[1].Request) -} diff --git a/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap b/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap deleted file mode 100644 index a2468656e..000000000 --- a/services/tickets/mailgun/testdata/TestCloseAndReopen_close_tickets.snap +++ /dev/null @@ -1,37 +0,0 @@ -POST /v3/tickets.rapidpro.io/messages HTTP/1.1 -Host: api.mailgun.net -User-Agent: Go-http-client/1.1 -Content-Length: 839 -Authorization: Basic **************** -Content-Type: multipart/form-data; boundary=e7187099-7d38-4f60-955c-325957214c42 -Accept-Encoding: gzip - ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="from" - - via ACME ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="to" - -bob@acme.com ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="subject" - -Where my cookies? ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="text" - - -* Ticket has been closed -* Replying to the contact will reopen this ticket -* View this contact at http://app.rapidpro.io/contact/read// - ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="h:In-Reply-To" - -12 ---e7187099-7d38-4f60-955c-325957214c42 -Content-Disposition: form-data; name="h:References" - -12 ---e7187099-7d38-4f60-955c-325957214c42-- diff --git a/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap b/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap deleted file mode 100644 index bdc39eb3c..000000000 --- a/services/tickets/mailgun/testdata/TestCloseAndReopen_reopen_tickets.snap +++ /dev/null @@ -1,37 +0,0 @@ -POST /v3/tickets.rapidpro.io/messages HTTP/1.1 -Host: api.mailgun.net -User-Agent: Go-http-client/1.1 -Content-Length: 837 -Authorization: Basic **************** -Content-Type: multipart/form-data; boundary=59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Accept-Encoding: gzip - ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="from" - - via ACME ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="to" - -bob@acme.com ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="subject" - -Where my shoes? ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="text" - - -* Ticket has been closed -* Replying to the contact will reopen this ticket -* View this contact at http://app.rapidpro.io/contact/read// - ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="h:In-Reply-To" - -14 ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c -Content-Disposition: form-data; name="h:References" - -14 ---59d74b86-3e2f-4a93-aece-b05d2fdcde0c-- diff --git a/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap b/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap deleted file mode 100644 index e65f2b108..000000000 --- a/services/tickets/mailgun/testdata/TestOpenAndForward_forward_message.snap +++ /dev/null @@ -1,47 +0,0 @@ -POST /v3/tickets.rapidpro.io/messages HTTP/1.1 -Host: api.mailgun.net -User-Agent: Go-http-client/1.1 -Content-Length: 1171 -Authorization: Basic **************** -Content-Type: multipart/form-data; boundary=13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Accept-Encoding: gzip - ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="from" - -Cathy via ACME ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="to" - -bob@acme.com ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="subject" - -Where are my cookies? ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="text" - -Cathy replied: ------------------------------------------------- - -It's urgent - ------------------------------------------------- -* Reply to the contact by replying to this email -* Close this ticket by replying with CLOSE -* View this contact at http://app.rapidpro.io/contact/read/6393abc0-283d-4c9b-a1b3-641a035c34bf/ - ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="attachment"; filename="untitled" -Content-Type: image/jpg - -MYIMAGE ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="h:In-Reply-To" - - ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0 -Content-Disposition: form-data; name="h:References" - - ---13e96d5a-4e65-4f07-9189-9d6270c6f3c0-- diff --git a/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap b/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap deleted file mode 100644 index a28149086..000000000 --- a/services/tickets/mailgun/testdata/TestOpenAndForward_open_ticket.snap +++ /dev/null @@ -1,34 +0,0 @@ -POST /v3/tickets.rapidpro.io/messages HTTP/1.1 -Host: api.mailgun.net -User-Agent: Go-http-client/1.1 -Content-Length: 977 -Authorization: Basic **************** -Content-Type: multipart/form-data; boundary=297611a6-b583-45c3-8587-d4e530c948f0 -Accept-Encoding: gzip - ---297611a6-b583-45c3-8587-d4e530c948f0 -Content-Disposition: form-data; name="from" - -Ryan Lewis via ACME ---297611a6-b583-45c3-8587-d4e530c948f0 -Content-Disposition: form-data; name="to" - -bob@acme.com ---297611a6-b583-45c3-8587-d4e530c948f0 -Content-Disposition: form-data; name="subject" - -Where are my cookies? Where are my cookies? Where are my cookies ---297611a6-b583-45c3-8587-d4e530c948f0 -Content-Disposition: form-data; name="text" - -New ticket opened ------------------------------------------------- - -Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies? Where are my cookies? - ------------------------------------------------- -* Reply to the contact by replying to this email -* Close this ticket by replying with CLOSE -* View this contact at http://app.rapidpro.io/contact/read/5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f/ - ---297611a6-b583-45c3-8587-d4e530c948f0-- diff --git a/services/tickets/mailgun/testdata/TestSendMessage_mailgun_request.snap b/services/tickets/mailgun/testdata/TestSendMessage_mailgun_request.snap deleted file mode 100644 index bcd0d0a41..000000000 --- a/services/tickets/mailgun/testdata/TestSendMessage_mailgun_request.snap +++ /dev/null @@ -1,39 +0,0 @@ -POST /v3/tickets.rapidpro.io/messages HTTP/1.1 -Host: api.mailgun.net -User-Agent: Go-http-client/1.1 -Content-Length: 886 -Authorization: Basic YXBpOjEyMzQ1Njc4OQ== -Content-Type: multipart/form-data; boundary=9688d21d-95aa-4bed-afc7-f31b35731a3d -Accept-Encoding: gzip - ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="from" - -Bob ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="to" - -support@acme.com ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="subject" - -Need help ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="text" - -Where are my cookies? ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="attachment"; filename="test.jpg" -Content-Type: image/jpeg - -IMANIMAGE ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="attachment"; filename="test.mp4" -Content-Type: audio/mp4 - -IMAVIDEO ---9688d21d-95aa-4bed-afc7-f31b35731a3d -Content-Disposition: form-data; name="h:In-Reply-To" - -12415 ---9688d21d-95aa-4bed-afc7-f31b35731a3d-- diff --git a/services/tickets/mailgun/testdata/receive.json b/services/tickets/mailgun/testdata/receive.json deleted file mode 100644 index 7b20094a5..000000000 --- a/services/tickets/mailgun/testdata/receive.json +++ /dev/null @@ -1,372 +0,0 @@ -[ - { - "label": "error response if missing required field", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "929fa5cb" - }, - { - "name": "signature", - "data": "123456" - } - ], - "body_encode": "multipart", - "status": 400, - "response": { - "error": "error decoding form: Key: 'receiveRequest.Recipient' Error:Field validation for 'Recipient' failed on the 'required' tag" - } - }, - { - "label": "error response if signature validation fails", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "123456" - } - ], - "body_encode": "multipart", - "status": 403, - "response": { - "error": "request signature validation failed" - } - }, - { - "label": "error response if recipient address isn't a valid ticket address", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "foo@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e" - } - ], - "body_encode": "multipart", - "status": 400, - "response": { - "error": "invalid recipient: foo@mr.nyaruka.com" - } - }, - { - "label": "error response if ticket doesn't exist", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "ticket+f73e94ab-0b4a-4f47-ac3c-1746b80ace5a@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e" - } - ], - "body_encode": "multipart", - "status": 400, - "response": { - "error": "error looking up ticket f73e94ab-0b4a-4f47-ac3c-1746b80ace5a" - } - }, - { - "label": "rejected response if sender address isn't configured on ticketer", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "jim@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e" - } - ], - "body_encode": "multipart", - "status": 200, - "response": { - "action": "rejected", - "ticket_uuid": "$cathy_ticket_uuid$" - } - }, - { - "label": "forwarded response if message was created (no attachments, request sent as urlencoded form)", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": "recipient=ticket%2B$cathy_ticket_uuid$%40mr.nyaruka.com&sender=bob%40acme.com&subject=Re%3A%20%5BRapidPro-Tickets%5D%20New%20ticket&Message-Id=%3C12345%40mail.gmail.com%3E&stripped-text=Hello×tamp=1590088411&token=987654321&signature=3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e", - "status": 200, - "response": { - "action": "forwarded", - "ticket_uuid": "$cathy_ticket_uuid$", - "msg_uuid": "692926ea-09d6-4942-bd38-d266ec8d3716" - }, - "db_assertions": [ - { - "query": "select count(*) from msgs_msg where direction = 'O'", - "count": 1 - }, - { - "query": "select count(*) from tickets_ticket where status = 'O'", - "count": 1 - } - ] - }, - { - "label": "forwarded response if message was created (attachments, request sent as multipart encoded form)", - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<23456@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Hello again" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e" - }, - { - "name": "attachment-count", - "data": "2" - }, - { - "name": "attachment-1", - "filename": "test.txt", - "content-type": "text/plain", - "data": "hi there" - }, - { - "name": "attachment-2", - "filename": "text.jpg", - "content-type": "image/jpeg", - "data": "IMAGE" - } - ], - "body_encode": "multipart", - "status": 200, - "response": { - "action": "forwarded", - "ticket_uuid": "$cathy_ticket_uuid$", - "msg_uuid": "5802813d-6c58-4292-8228-9728778b6c98" - }, - "db_assertions": [ - { - "query": "select count(*) from msgs_msg where direction = 'O' AND uuid = '5802813d-6c58-4292-8228-9728778b6c98' AND attachments = '{text/plain:https:///_test_attachments_storage/attachments/1/8720/f157/8720f157-ca1c-432f-9c0b-2014ddc77094.txt,image/jpeg:https:///_test_attachments_storage/attachments/1/c34b/6c7d/c34b6c7d-fa06-4563-92a3-d648ab64bccb.jpg}'", - "count": 1 - }, - { - "query": "select count(*) from tickets_ticket where status = 'O'", - "count": 1 - } - ] - }, - { - "label": "ticket closed and closed response if incoming message was CLOSE", - "http_mocks": { - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": [ - { - "status": 200, - "body": "{\"id\": \"<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>\", \"message\": \"Queued. Thank you.\"}" - } - ] - }, - "method": "POST", - "path": "/mr/tickets/types/mailgun/receive", - "body": [ - { - "name": "recipient", - "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" - }, - { - "name": "sender", - "data": "bob@acme.com" - }, - { - "name": "subject", - "data": "Re: [RapidPro-Tickets] New ticket" - }, - { - "name": "Message-Id", - "data": "<12345@mail.gmail.com>" - }, - { - "name": "stripped-text", - "data": "Close" - }, - { - "name": "timestamp", - "data": "1590088411" - }, - { - "name": "token", - "data": "987654321" - }, - { - "name": "signature", - "data": "3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e" - } - ], - "body_encode": "multipart", - "status": 200, - "response": { - "action": "closed", - "ticket_uuid": "$cathy_ticket_uuid$" - }, - "db_assertions": [ - { - "query": "select count(*) from tickets_ticket where status = 'C'", - "count": 1 - } - ] - } -] \ No newline at end of file diff --git a/services/tickets/mailgun/web.go b/services/tickets/mailgun/web.go deleted file mode 100644 index c614d49dd..000000000 --- a/services/tickets/mailgun/web.go +++ /dev/null @@ -1,142 +0,0 @@ -package mailgun - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - "regexp" - "strings" - - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" - "github.com/nyaruka/mailroom/services/tickets" - "github.com/nyaruka/mailroom/web" - - "github.com/pkg/errors" -) - -func init() { - base := "/mr/tickets/types/mailgun" - - web.RegisterRoute(http.MethodPost, base+"/receive", web.MarshaledResponse(web.WithHTTPLogs(handleReceive))) -} - -type receiveRequest struct { - Recipient string `form:"recipient" validate:"required,email"` - Sender string `form:"sender" validate:"required,email"` - From string `form:"From"` - ReplyTo string `form:"Reply-To"` - MessageID string `form:"Message-Id" validate:"required"` - Subject string `form:"subject" validate:"required"` - PlainBody string `form:"body-plain"` - StrippedText string `form:"stripped-text" validate:"required"` - HTMLBody string `form:"body-html"` - Timestamp string `form:"timestamp" validate:"required"` - Token string `form:"token" validate:"required"` - Signature string `form:"signature" validate:"required"` - AttachmentCount int `form:"attachment-count"` -} - -// see https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks -func (r *receiveRequest) verify(signingKey string) bool { - v := r.Timestamp + r.Token - - mac := hmac.New(sha256.New, []byte(signingKey)) - mac.Write([]byte(v)) - expectedMAC := hex.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(r.Signature), []byte(expectedMAC)) -} - -// what we send back to mailgun.. this is mostly for our own since logging since they don't parse this -type receiveResponse struct { - Action string `json:"action"` - TicketUUID flows.TicketUUID `json:"ticket_uuid"` - MsgUUID flows.MsgUUID `json:"msg_uuid,omitempty"` -} - -var addressRegex = regexp.MustCompile(`^ticket\+([0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})@.*$`) - -func handleReceive(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { - request := &receiveRequest{} - if err := web.DecodeAndValidateForm(request, r); err != nil { - return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil - } - - if !request.verify(rt.Config.MailgunSigningKey) { - return errors.New("request signature validation failed"), http.StatusForbidden, nil - } - - // decode any attachments - files := make([]*tickets.File, request.AttachmentCount) - for i := range files { - file, header, err := r.FormFile(fmt.Sprintf("attachment-%d", i+1)) - if err != nil { - return errors.Wrapf(err, "error decoding attachment #%d", i+1), http.StatusBadRequest, nil - } - files[i] = &tickets.File{URL: header.Filename, ContentType: header.Header.Get("Content-Type"), Body: file} - } - - // recipient is in the format ticket+@... parse it out - match := addressRegex.FindAllStringSubmatch(request.Recipient, -1) - if len(match) != 1 || len(match[0]) != 2 { - return errors.Errorf("invalid recipient: %s", request.Recipient), http.StatusBadRequest, nil - } - - // look up the ticket and ticketer - ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, rt, flows.TicketUUID(match[0][1]), typeMailgun) - if err != nil { - return err, http.StatusBadRequest, nil - } - mailgun := svc.(*service) - - // check that this sender is allowed to send to this ticket - configuredAddress := ticketer.Config(configToAddress) - if request.Sender != configuredAddress { - body := fmt.Sprintf("The address %s is not allowed to reply to this ticket\n", request.Sender) - - mailgun.send(mailgun.noReplyAddress(), request.From, "Ticket reply rejected", body, nil, nil, l.Ticketer(ticketer)) - - return &receiveResponse{Action: "rejected", TicketUUID: ticket.UUID()}, http.StatusOK, nil - } - - oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - if err != nil { - return err, http.StatusBadRequest, nil - } - - // check if reply is actually a command - if strings.ToLower(strings.TrimSpace(request.StrippedText)) == "close" { - err = tickets.Close(ctx, rt, oa, ticket, true, l) - if err != nil { - return errors.Wrapf(err, "error closing ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil - } - - return &receiveResponse{Action: "closed", TicketUUID: ticket.UUID()}, http.StatusOK, nil - } - - // update our ticket config - err = models.UpdateTicketConfig(ctx, rt.DB, ticket, map[string]string{ticketConfigLastMessageID: request.MessageID}) - if err != nil { - return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil - } - - // reopen ticket if necessary - if ticket.Status() != models.TicketStatusOpen { - err = tickets.Reopen(ctx, rt, oa, ticket, false, nil) - if err != nil { - return errors.Wrapf(err, "error reopening ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil - } - } - - msg, err := tickets.SendReply(ctx, rt, ticket, request.StrippedText, files) - if err != nil { - return err, http.StatusInternalServerError, nil - } - - return &receiveResponse{Action: "forwarded", TicketUUID: ticket.UUID(), MsgUUID: msg.UUID()}, http.StatusOK, nil -} diff --git a/services/tickets/mailgun/web_test.go b/services/tickets/mailgun/web_test.go deleted file mode 100644 index ceb278c99..000000000 --- a/services/tickets/mailgun/web_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package mailgun - -import ( - "testing" - "time" - - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" -) - -func TestReceive(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetData | testsuite.ResetStorage) - - // create a mailgun ticket for Cathy - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "", time.Now(), nil) - - testsuite.RunWebTests(t, ctx, rt, "testdata/receive.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) -} diff --git a/services/tickets/utils.go b/services/tickets/utils.go deleted file mode 100644 index d5ab08194..000000000 --- a/services/tickets/utils.go +++ /dev/null @@ -1,179 +0,0 @@ -package tickets - -import ( - "bytes" - "context" - "io" - "mime" - "net/http" - "path/filepath" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/core/msgio" - "github.com/nyaruka/mailroom/core/tasks/handler" - "github.com/nyaruka/mailroom/runtime" - "github.com/pkg/errors" -) - -// GetContactDisplay gets a non-empty display value for a contact for use on a ticket -func GetContactDisplay(env envs.Environment, contact *flows.Contact) string { - display := contact.Format(env) - if display == "" { - return "Anonymous" - } - return display -} - -// FromTicketUUID takes a ticket UUID and looks up the ticket and ticketer, and creates the service -func FromTicketUUID(ctx context.Context, rt *runtime.Runtime, uuid flows.TicketUUID, ticketerType string) (*models.Ticket, *models.Ticketer, models.TicketService, error) { - // look up our ticket - ticket, err := models.LookupTicketByUUID(ctx, rt.DB, uuid) - if err != nil || ticket == nil { - return nil, nil, nil, errors.Errorf("error looking up ticket %s", uuid) - } - - // look up our assets - assets, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - if err != nil { - return nil, nil, nil, errors.Wrapf(err, "error looking up org #%d", ticket.OrgID()) - } - - // and get the ticketer for this ticket - ticketer := assets.TicketerByID(ticket.TicketerID()) - if ticketer == nil || ticketer.Type() != ticketerType { - return nil, nil, nil, errors.Errorf("error looking up ticketer #%d", ticket.TicketerID()) - } - - // and load it as a service - svc, err := ticketer.AsService(rt.Config, flows.NewTicketer(ticketer)) - if err != nil { - return nil, nil, nil, errors.Wrap(err, "error loading ticketer service") - } - - return ticket, ticketer, svc, nil -} - -// FromTicketerUUID takes a ticketer UUID and looks up the ticketer and creates the service -func FromTicketerUUID(ctx context.Context, rt *runtime.Runtime, uuid assets.TicketerUUID, ticketerType string) (*models.Ticketer, models.TicketService, error) { - ticketer, err := models.LookupTicketerByUUID(ctx, rt.DB.DB, uuid) - if err != nil || ticketer == nil || ticketer.Type() != ticketerType { - return nil, nil, errors.Errorf("error looking up ticketer %s", uuid) - } - - // and load it as a service - svc, err := ticketer.AsService(rt.Config, flows.NewTicketer(ticketer)) - if err != nil { - return nil, nil, errors.Wrap(err, "error loading ticketer service") - } - - return ticketer, svc, nil -} - -// SendReply sends a message reply from the ticket system user to the contact -func SendReply(ctx context.Context, rt *runtime.Runtime, ticket *models.Ticket, text string, files []*File) (*models.Msg, error) { - // look up our assets - oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - if err != nil { - return nil, errors.Wrapf(err, "error looking up org #%d", ticket.OrgID()) - } - - // load the contact and generate as a flow contact - c, err := models.LoadContact(ctx, rt.DB, oa, ticket.ContactID()) - if err != nil { - return nil, errors.Wrap(err, "error loading contact") - } - - contact, err := c.FlowContact(oa) - if err != nil { - return nil, errors.Wrap(err, "error creating flow contact") - } - - // upload files to create message attachments - attachments := make([]utils.Attachment, len(files)) - for i, file := range files { - filename := string(uuids.New()) + filepath.Ext(file.URL) - - attachments[i], err = oa.Org().StoreAttachment(ctx, rt, filename, file.ContentType, file.Body) - if err != nil { - return nil, errors.Wrapf(err, "error storing attachment %s for ticket reply", file.URL) - } - } - - out, ch := models.NewMsgOut(oa, contact, text, attachments, nil, contact.Locale(oa.Env())) - msg, err := models.NewOutgoingTicketMsg(rt, oa.Org(), ch, contact, out, dates.Now(), ticket.ID(), models.NilUserID) - if err != nil { - return nil, errors.Wrap(err, "error creating outgoing message") - } - - err = models.InsertMessages(ctx, rt.DB, []*models.Msg{msg}) - if err != nil { - return nil, errors.Wrap(err, "error inserting outgoing message") - } - - if err := models.RecordTicketReply(ctx, rt.DB, oa, ticket.ID(), models.NilUserID); err != nil { - return nil, errors.Wrap(err, "error recording ticket reply") - } - - msgio.QueueMessages(ctx, rt, rt.DB, nil, []*models.Msg{msg}) - return msg, nil -} - -var retries = httpx.NewFixedRetries(time.Second*5, time.Second*10) - -// File represents a file sent to us from a ticketing service -type File struct { - URL string - ContentType string - Body io.ReadCloser -} - -// FetchFile fetches a file from the given URL -func FetchFile(url string, headers map[string]string) (*File, error) { - req, _ := httpx.NewRequest("GET", url, nil, headers) - - trace, err := httpx.DoTrace(http.DefaultClient, req, retries, nil, 10*1024*1024) - if err != nil { - return nil, err - } - if trace.Response.StatusCode/100 != 2 { - return nil, errors.New("fetch returned non-200 response") - } - - contentType, _, _ := mime.ParseMediaType(trace.Response.Header.Get("Content-Type")) - - return &File{URL: url, ContentType: contentType, Body: io.NopCloser(bytes.NewReader(trace.ResponseBody))}, nil -} - -// Close closes the given ticket, and creates and queues a closed event -func Close(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error { - events, err := models.CloseTickets(ctx, rt, oa, models.NilUserID, []*models.Ticket{ticket}, externally, false, l) - if err != nil { - return errors.Wrap(err, "error closing ticket") - } - - if len(events) == 1 { - rc := rt.RP.Get() - defer rc.Close() - - err = handler.QueueTicketEvent(rc, ticket.ContactID(), events[ticket]) - if err != nil { - return errors.Wrapf(err, "error queueing ticket closed event") - } - } - - return nil -} - -// Reopen reopens the given ticket -func Reopen(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error { - _, err := models.ReopenTickets(ctx, rt, oa, models.NilUserID, []*models.Ticket{ticket}, externally, l) - return err -} diff --git a/services/tickets/utils_test.go b/services/tickets/utils_test.go deleted file mode 100644 index 9f0b92f89..000000000 --- a/services/tickets/utils_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package tickets_test - -import ( - "os" - "testing" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/services/tickets" - _ "github.com/nyaruka/mailroom/services/tickets/mailgun" - _ "github.com/nyaruka/mailroom/services/tickets/zendesk" - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetContactDisplay(t *testing.T) { - ctx, rt := testsuite.Runtime() - - oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) - require.NoError(t, err) - - contact, err := models.LoadContact(ctx, rt.DB, oa, testdata.Cathy.ID) - require.NoError(t, err) - - flowContact, err := contact.FlowContact(oa) - require.NoError(t, err) - - // name if they have one - assert.Equal(t, "Cathy", tickets.GetContactDisplay(oa.Env(), flowContact)) - - flowContact.SetName("") - - // or primary URN - assert.Equal(t, "(605) 574-1111", tickets.GetContactDisplay(oa.Env(), flowContact)) - - // but not if org is anon - anonEnv := envs.NewBuilder().WithRedactionPolicy(envs.RedactionPolicyURNs).Build() - assert.Equal(t, "10000", tickets.GetContactDisplay(anonEnv, flowContact)) -} - -func TestFromTicketUUID(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) - - // create some tickets - ticket1 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "", time.Now(), nil) - ticket2 := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my shoes?", "", time.Now(), nil) - - // break mailgun configuration - rt.DB.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, testdata.Mailgun.ID) - - models.FlushCache() - - // err if no ticket with UUID - _, _, _, err := tickets.FromTicketUUID(ctx, rt, "33c54d0c-bd49-4edf-87a9-c391a75a630c", "mailgun") - assert.EqualError(t, err, "error looking up ticket 33c54d0c-bd49-4edf-87a9-c391a75a630c") - - // err if no ticketer type doesn't match - _, _, _, err = tickets.FromTicketUUID(ctx, rt, ticket1.UUID, "zendesk") - assert.EqualError(t, err, "error looking up ticketer #2") - - // err if ticketer isn't configured correctly and can't be loaded as a service - _, _, _, err = tickets.FromTicketUUID(ctx, rt, ticket1.UUID, "mailgun") - assert.EqualError(t, err, "error loading ticketer service: missing domain or api_key or to_address or url_base in mailgun config") - - // if all is correct, returns the ticket, ticketer asset, and ticket service - ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, rt, ticket2.UUID, "zendesk") - - assert.NoError(t, err) - assert.Equal(t, ticket2.UUID, ticket.UUID()) - assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID()) - assert.Implements(t, (*models.TicketService)(nil), svc) -} - -func TestFromTicketerUUID(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) - - // break mailgun configuration - rt.DB.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, testdata.Mailgun.ID) - - // err if no ticketer with UUID - _, _, err := tickets.FromTicketerUUID(ctx, rt, "33c54d0c-bd49-4edf-87a9-c391a75a630c", "mailgun") - assert.EqualError(t, err, "error looking up ticketer 33c54d0c-bd49-4edf-87a9-c391a75a630c") - - // err if no ticketer type doesn't match - _, _, err = tickets.FromTicketerUUID(ctx, rt, testdata.Mailgun.UUID, "zendesk") - assert.EqualError(t, err, "error looking up ticketer f9c9447f-a291-4f3c-8c79-c089bbd4e713") - - // err if ticketer isn't configured correctly and can't be loaded as a service - _, _, err = tickets.FromTicketerUUID(ctx, rt, testdata.Mailgun.UUID, "mailgun") - assert.EqualError(t, err, "error loading ticketer service: missing domain or api_key or to_address or url_base in mailgun config") - - // if all is correct, returns the ticketer asset and ticket service - ticketer, svc, err := tickets.FromTicketerUUID(ctx, rt, testdata.Zendesk.UUID, "zendesk") - - assert.NoError(t, err) - assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID()) - assert.Implements(t, (*models.TicketService)(nil), svc) -} - -func TestSendReply(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) - - defer uuids.SetGenerator(uuids.DefaultGenerator) - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - - imageBody, err := os.Open("../../core/models/testdata/test.jpg") - require.NoError(t, err) - - image := &tickets.File{URL: "http://coolfiles.com/a.jpg", ContentType: "image/jpeg", Body: imageBody} - - // create a ticket - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "", time.Now(), nil) - modelTicket := ticket.Load(rt) - - msg, err := tickets.SendReply(ctx, rt, modelTicket, "I'll get back to you", []*tickets.File{image}) - require.NoError(t, err) - - assert.Equal(t, "I'll get back to you", msg.Text()) - assert.Equal(t, testdata.Cathy.ID, msg.ContactID()) - assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_attachments_storage/attachments/1/e718/7099/e7187099-7d38-4f60-955c-325957214c42.jpg"}, msg.Attachments()) - assert.FileExists(t, "_test_attachments_storage/attachments/1/e718/7099/e7187099-7d38-4f60-955c-325957214c42.jpg") - - // try with file that can't be read (i.e. same file again which is already closed) - _, err = tickets.SendReply(ctx, rt, modelTicket, "I'll get back to you", []*tickets.File{image}) - assert.EqualError(t, err, "error storing attachment http://coolfiles.com/a.jpg for ticket reply: unable to read attachment content: read ../../core/models/testdata/test.jpg: file already closed") -} - -func TestCloseTicket(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) - - defer dates.SetNowSource(dates.DefaultNowSource) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2021, 6, 8, 16, 40, 30, 0, time.UTC))) - - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.NewMockResponse(200, nil, []byte(`{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`)), - }, - })) - - oa := testdata.Org1.Load(rt) - - // create an open ticket - ticket1 := models.NewTicket( - "2ef57efc-d85f-4291-b330-e4afe68af5fe", - testdata.Org1.ID, - testdata.Admin.ID, - models.NilFlowID, - testdata.Cathy.ID, - testdata.Mailgun.ID, - "EX12345", - testdata.DefaultTopic.ID, - "Where are my cookies?", - models.NilUserID, - map[string]any{ - "contact-display": "Cathy", - }, - ) - err := models.InsertTickets(ctx, rt.DB, oa, []*models.Ticket{ticket1}) - require.NoError(t, err) - - // create a close ticket trigger - testdata.InsertTicketClosedTrigger(rt, testdata.Org1, testdata.Favorites) - - logger := &models.HTTPLogger{} - - err = tickets.Close(ctx, rt, oa, ticket1, true, logger) - require.NoError(t, err) - - testsuite.AssertContactTasks(t, 1, testdata.Cathy.ID, - []string{`{"type":"ticket_closed","org_id":1,"task":{"id":1,"org_id":1,"contact_id":10000,"ticket_id":1,"event_type":"C","created_on":"2021-06-08T16:40:33Z"},"queued_on":"2021-06-08T16:40:36Z"}`}) -} diff --git a/services/tickets/zendesk/client.go b/services/tickets/zendesk/client.go deleted file mode 100644 index 9edeeb0eb..000000000 --- a/services/tickets/zendesk/client.go +++ /dev/null @@ -1,300 +0,0 @@ -package zendesk - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/jsonx" -) - -type baseClient struct { - httpClient *http.Client - httpRetries *httpx.RetryConfig - subdomain string - token string -} - -func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, subdomain, token string) baseClient { - return baseClient{ - httpClient: httpClient, - httpRetries: httpRetries, - subdomain: subdomain, - token: token, - } -} - -type errorResponse struct { - Error string `json:"error"` - Description string `json:"description"` -} - -func (c *baseClient) post(endpoint string, payload any, response any) (*httpx.Trace, error) { - return c.request("POST", endpoint, payload, response) -} - -func (c *baseClient) put(endpoint string, payload any, response any) (*httpx.Trace, error) { - return c.request("PUT", endpoint, payload, response) -} - -func (c *baseClient) delete(endpoint string) (*httpx.Trace, error) { - return c.request("DELETE", endpoint, nil, nil) -} - -func (c *baseClient) request(method, endpoint string, payload any, response any) (*httpx.Trace, error) { - url := fmt.Sprintf("https://%s.zendesk.com/api/v2/%s", c.subdomain, endpoint) - headers := map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", c.token), - } - var body io.Reader - - if payload != nil { - data, err := jsonx.Marshal(payload) - if err != nil { - return nil, err - } - body = bytes.NewReader(data) - headers["Content-Type"] = "application/json" - } - - req, err := httpx.NewRequest(method, url, body, headers) - if err != nil { - return nil, err - } - - trace, err := httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1) - if err != nil { - return trace, err - } - - if trace.Response.StatusCode >= 400 { - response := &errorResponse{} - jsonx.Unmarshal(trace.ResponseBody, response) - return trace, errors.New(response.Description) - } - - if response != nil { - return trace, jsonx.Unmarshal(trace.ResponseBody, response) - } - return trace, nil -} - -// RESTClient is a client for the Zendesk REST API -type RESTClient struct { - baseClient -} - -// NewRESTClient creates a new REST client -func NewRESTClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, subdomain, token string) *RESTClient { - return &RESTClient{baseClient: newBaseClient(httpClient, httpRetries, subdomain, token)} -} - -// Target see https://developer.zendesk.com/rest_api/docs/support/targets -type Target struct { - ID int64 `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - TargetURL string `json:"target_url"` - Method string `json:"method"` - Username string `json:"username"` - Password string `json:"password"` - ContentType string `json:"content_type"` -} - -// CreateTarget see https://developer.zendesk.com/rest_api/docs/support/targets#create-target -func (c *RESTClient) CreateTarget(target *Target) (*Target, *httpx.Trace, error) { - payload := struct { - Target *Target `json:"target"` - }{Target: target} - - response := &struct { - Target *Target `json:"target"` - }{} - - trace, err := c.post("targets.json", payload, response) - if err != nil { - return nil, trace, err - } - - return response.Target, trace, nil -} - -// DeleteTarget see https://developer.zendesk.com/rest_api/docs/support/targets#delete-target -func (c *RESTClient) DeleteTarget(id int64) (*httpx.Trace, error) { - return c.delete(fmt.Sprintf("targets/%d.json", id)) -} - -// Condition see https://developer.zendesk.com/rest_api/docs/support/triggers#conditions -type Condition struct { - Field string `json:"field"` - Operator string `json:"operator"` - Value string `json:"value"` -} - -// Conditions see https://developer.zendesk.com/rest_api/docs/support/triggers#conditions -type Conditions struct { - All []Condition `json:"all"` - Any []Condition `json:"any"` -} - -// Action see https://developer.zendesk.com/rest_api/docs/support/triggers#actions -type Action struct { - Field string `json:"field"` - Value []string `json:"value"` -} - -// Trigger see https://developer.zendesk.com/rest_api/docs/support/triggers -type Trigger struct { - ID int64 `json:"id"` - Title string `json:"title"` - Conditions Conditions `json:"conditions"` - Actions []Action `json:"actions"` -} - -// CreateTrigger see https://developer.zendesk.com/rest_api/docs/support/triggers#create-trigger -func (c *RESTClient) CreateTrigger(trigger *Trigger) (*Trigger, *httpx.Trace, error) { - payload := struct { - Trigger *Trigger `json:"trigger"` - }{Trigger: trigger} - - response := &struct { - Trigger *Trigger `json:"trigger"` - }{} - - trace, err := c.post("triggers.json", payload, response) - if err != nil { - return nil, trace, err - } - - return response.Trigger, trace, nil -} - -// DeleteTrigger see https://developer.zendesk.com/rest_api/docs/support/triggers#delete-trigger -func (c *RESTClient) DeleteTrigger(id int64) (*httpx.Trace, error) { - return c.delete(fmt.Sprintf("triggers/%d.json", id)) -} - -// Ticket see https://developer.zendesk.com/rest_api/docs/support/tickets#json-format -type Ticket struct { - ID int64 `json:"id,omitempty"` - ExternalID string `json:"external_id,omitempty"` - Status string `json:"status,omitempty"` -} - -// JobStatus see https://developer.zendesk.com/rest_api/docs/support/job_statuses#job-statuses -type JobStatus struct { - ID string `json:"id"` - URL string `json:"url"` - Status string `json:"status"` -} - -// UpdateManyTickets see https://developer.zendesk.com/rest_api/docs/support/tickets#update-many-tickets -func (c *RESTClient) UpdateManyTickets(ids []int64, status string) (*JobStatus, *httpx.Trace, error) { - payload := struct { - Ticket *Ticket `json:"ticket"` - }{ - Ticket: &Ticket{Status: status}, - } - - response := &struct { - JobStatus *JobStatus `json:"job_status"` - }{} - - trace, err := c.put("tickets/update_many.json?ids="+encodeIds(ids), payload, response) - if err != nil { - return nil, trace, err - } - - return response.JobStatus, trace, nil -} - -// PushClient is a client for the Zendesk channel push API and requires a special push token -type PushClient struct { - baseClient -} - -// NewPushClient creates a new push client -func NewPushClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, subdomain, token string) *PushClient { - return &PushClient{baseClient: newBaseClient(httpClient, httpRetries, subdomain, token)} -} - -// FieldValue is a value for the named field -type FieldValue struct { - ID string `json:"id"` - Value string `json:"value"` -} - -// Author see https://developer.zendesk.com/rest_api/docs/support/channel_framework#author-object -type Author struct { - ExternalID string `json:"external_id"` - Name string `json:"name,omitempty"` - ImageURL string `json:"image_url,omitempty"` - Locale string `json:"locale,omitempty"` - Fields []FieldValue `json:"fields,omitempty"` -} - -// DisplayInfo see https://developer.zendesk.com/rest_api/docs/support/channel_framework#display_info-object -type DisplayInfo struct { - Type string `json:"type"` - Data map[string]string `json:"data"` -} - -// ExternalResource see https://developer.zendesk.com/rest_api/docs/support/channel_framework#external_resource-object -type ExternalResource struct { - ExternalID string `json:"external_id"` - Message string `json:"message"` - HTMLMessage string `json:"html_message,omitempty"` - ParentID string `json:"parent_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - Author Author `json:"author"` - DisplayInfo []DisplayInfo `json:"display_info,omitempty"` - AllowChannelback bool `json:"allow_channelback"` - Fields []FieldValue `json:"fields,omitempty"` - FileURLs []string `json:"file_urls,omitempty"` -} - -// Status see https://developer.zendesk.com/rest_api/docs/support/channel_framework#status-object -type Status struct { - Code string `json:"code"` - Description string `json:"description"` -} - -// Result see https://developer.zendesk.com/rest_api/docs/support/channel_framework#result-object -type Result struct { - ExternalResourceID string `json:"external_resource_id"` - Status Status `json:"status"` -} - -// Push pushes the given external resources -func (c *PushClient) Push(instanceID, requestID string, externalResources []*ExternalResource) ([]*Result, *httpx.Trace, error) { - payload := struct { - InstancePushID string `json:"instance_push_id"` - RequestID string `json:"request_id,omitempty"` - ExternalResources []*ExternalResource `json:"external_resources"` - }{InstancePushID: instanceID, RequestID: requestID, ExternalResources: externalResources} - - response := &struct { - Results []*Result `json:"results"` - }{} - - trace, err := c.post("any_channel/push.json", payload, response) - if err != nil { - return nil, trace, err - } - - return response.Results, trace, nil -} - -func encodeIds(ids []int64) string { - idStrs := make([]string, len(ids)) - for i := range ids { - idStrs[i] = fmt.Sprintf("%d", ids[i]) - } - return strings.Join(idStrs, ",") -} diff --git a/services/tickets/zendesk/client_test.go b/services/tickets/zendesk/client_test.go deleted file mode 100644 index 936810864..000000000 --- a/services/tickets/zendesk/client_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package zendesk_test - -import ( - "net/http" - "testing" - "time" - - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/mailroom/services/tickets/zendesk" - - "github.com/stretchr/testify/assert" -) - -func TestCreateTarget(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/targets.json": { - httpx.MockConnectionError, - httpx.NewMockResponse(400, nil, []byte(`{"description": "Something went wrong", "error": "Unknown"}`)), // non-200 response - httpx.NewMockResponse(200, nil, []byte(`xx`)), // non-JSON response - httpx.NewMockResponse(201, nil, []byte(`{ - "target": { - "id": 1234567, - "title": "Temba", - "target_url": "http://temba.io/updates", - "method": "POST", - "content_type": "application/json" - } - }`)), - }, - })) - - client := zendesk.NewRESTClient(http.DefaultClient, nil, "nyaruka", "123456789") - target := &zendesk.Target{ - Title: "Temba", - TargetURL: "http://temba.io/updates", - Method: "POST", - ContentType: "application/json", - } - - _, _, err := client.CreateTarget(target) - assert.EqualError(t, err, "unable to connect to server") - - _, _, err = client.CreateTarget(target) - assert.EqualError(t, err, "Something went wrong") - - _, _, err = client.CreateTarget(target) - assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") - - target, trace, err := client.CreateTarget(target) - assert.NoError(t, err) - assert.Equal(t, int64(1234567), target.ID) - assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 180\r\n\r\n", string(trace.ResponseTrace)) -} - -func TestDeleteTarget(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/targets/123.json": { - httpx.NewMockResponse(200, nil, nil), - }, - })) - - client := zendesk.NewRESTClient(http.DefaultClient, nil, "nyaruka", "123456789") - - trace, err := client.DeleteTarget(123) - - assert.NoError(t, err) - assert.Equal(t, "DELETE /api/v2/targets/123.json HTTP/1.1\r\nHost: nyaruka.zendesk.com\r\nUser-Agent: Go-http-client/1.1\r\nAuthorization: Bearer 123456789\r\nAccept-Encoding: gzip\r\n\r\n", string(trace.RequestTrace)) -} - -func TestCreateTrigger(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/triggers.json": { - httpx.MockConnectionError, - httpx.NewMockResponse(400, nil, []byte(`{"description": "Something went wrong", "error": "Unknown"}`)), // non-200 response - httpx.NewMockResponse(200, nil, []byte(`xx`)), // non-JSON response - httpx.NewMockResponse(201, nil, []byte(`{ - "trigger": { - "id": 1234567, - "title": "Notify Temba", - "conditions": { - "all": [ - { - "field": "status", - "operator": "changed" - } - ] - }, - "actions": [ - { - "field": "notification_target", - "value": ["123", "{}"] - } - ] - } - }`)), - }, - })) - - client := zendesk.NewRESTClient(http.DefaultClient, nil, "nyaruka", "123456789") - trigger := &zendesk.Trigger{ - Title: "Temba", - Conditions: zendesk.Conditions{ - All: []zendesk.Condition{ - {"status", "changed", ""}, - }, - }, - Actions: []zendesk.Action{ - {"notification_target", []string{"123", "{}"}}, - }, - } - - _, _, err := client.CreateTrigger(trigger) - assert.EqualError(t, err, "unable to connect to server") - - _, _, err = client.CreateTrigger(trigger) - assert.EqualError(t, err, "Something went wrong") - - _, _, err = client.CreateTrigger(trigger) - assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") - - trigger, trace, err := client.CreateTrigger(trigger) - assert.NoError(t, err) - assert.Equal(t, int64(1234567), trigger.ID) - assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 317\r\n\r\n", string(trace.ResponseTrace)) -} - -func TestDeleteTrigger(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/triggers/123.json": { - httpx.NewMockResponse(200, nil, nil), - }, - })) - - client := zendesk.NewRESTClient(http.DefaultClient, nil, "nyaruka", "123456789") - - trace, err := client.DeleteTrigger(123) - - assert.NoError(t, err) - assert.Equal(t, "DELETE /api/v2/triggers/123.json HTTP/1.1\r\nHost: nyaruka.zendesk.com\r\nUser-Agent: Go-http-client/1.1\r\nAuthorization: Bearer 123456789\r\nAccept-Encoding: gzip\r\n\r\n", string(trace.RequestTrace)) -} - -func TestUpdateManyTickets(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/tickets/update_many.json?ids=123,234": { - httpx.NewMockResponse(201, nil, []byte(`{ - "job_status": { - "id": "1234-abcd", - "url": "http://zendesk.com", - "status": "queued" - } - }`)), - }, - })) - - client := zendesk.NewRESTClient(http.DefaultClient, nil, "nyaruka", "123456789") - - jobStatus, trace, err := client.UpdateManyTickets([]int64{123, 234}, "solved") - - assert.NoError(t, err) - assert.Equal(t, "queued", jobStatus.Status) - assert.Equal(t, "PUT /api/v2/tickets/update_many.json?ids=123,234 HTTP/1.1\r\nHost: nyaruka.zendesk.com\r\nUser-Agent: Go-http-client/1.1\r\nContent-Length: 30\r\nAuthorization: Bearer 123456789\r\nContent-Type: application/json\r\nAccept-Encoding: gzip\r\n\r\n{\"ticket\":{\"status\":\"solved\"}}", string(trace.RequestTrace)) - assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 114\r\n\r\n", string(trace.ResponseTrace)) -} - -func TestPush(t *testing.T) { - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/any_channel/push.json": { - httpx.MockConnectionError, - httpx.NewMockResponse(400, nil, []byte(`{"description": "Something went wrong", "error": "Unknown"}`)), // non-200 response - httpx.NewMockResponse(200, nil, []byte(`xx`)), // non-JSON response - httpx.NewMockResponse(201, nil, []byte(`{ - "results": [ - { - "external_resource_id": "123", - "status": {"code": "success"} - }, - { - "external_resource_id": "234", - "status": {"code":"processing_error", "description":"Boom"} - } - ] - }`)), - }, - })) - - client := zendesk.NewPushClient(http.DefaultClient, nil, "nyaruka", "123456789") - - _, _, err := client.Push("1234-abcd", "5678-edfg", []*zendesk.ExternalResource{}) - assert.EqualError(t, err, "unable to connect to server") - - _, _, err = client.Push("1234-abcd", "5678-edfg", []*zendesk.ExternalResource{}) - assert.EqualError(t, err, "Something went wrong") - - _, _, err = client.Push("1234-abcd", "5678-edfg", []*zendesk.ExternalResource{}) - assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") - - results, trace, err := client.Push("1234-abcd", "5678-edfg", []*zendesk.ExternalResource{ - { - ExternalID: "234", - Message: "A useful comment", - HTMLMessage: "A very useful comment", - ParentID: "123", - CreatedAt: time.Date(2015, 1, 13, 8, 59, 26, 0, time.UTC), - Author: zendesk.Author{ - ExternalID: "456", - Name: "Fred", - Locale: "de", - }, - DisplayInfo: []zendesk.DisplayInfo{ - { - Type: "9ef45ff7-4aaa-4a58-8e77-a7c74dfa51c4", - Data: map[string]string{"whatever": "I want"}, - }, - }, - AllowChannelback: true, - }, - { - ExternalID: "636", - Message: "Hi there", - ThreadID: "347", - CreatedAt: time.Date(2020, 1, 13, 8, 59, 26, 0, time.UTC), - Author: zendesk.Author{ - ExternalID: "123", - Name: "Jim", - Locale: "en", - }, - AllowChannelback: true, - }, - }) - assert.NoError(t, err) - assert.Equal(t, 2, len(results)) - assert.Equal(t, "success", results[0].Status.Code) - assert.Equal(t, "processing_error", results[1].Status.Code) - assert.Equal(t, "Boom", results[1].Status.Description) - assert.Equal(t, "POST /api/v2/any_channel/push.json HTTP/1.1\r\nHost: nyaruka.zendesk.com\r\nUser-Agent: Go-http-client/1.1\r\nContent-Length: 589\r\nAuthorization: Bearer 123456789\r\nContent-Type: application/json\r\nAccept-Encoding: gzip\r\n\r\n{\"instance_push_id\":\"1234-abcd\",\"request_id\":\"5678-edfg\",\"external_resources\":[{\"external_id\":\"234\",\"message\":\"A useful comment\",\"html_message\":\"A very useful comment\",\"parent_id\":\"123\",\"created_at\":\"2015-01-13T08:59:26Z\",\"author\":{\"external_id\":\"456\",\"name\":\"Fred\",\"locale\":\"de\"},\"display_info\":[{\"type\":\"9ef45ff7-4aaa-4a58-8e77-a7c74dfa51c4\",\"data\":{\"whatever\":\"I want\"}}],\"allow_channelback\":true},{\"external_id\":\"636\",\"message\":\"Hi there\",\"thread_id\":\"347\",\"created_at\":\"2020-01-13T08:59:26Z\",\"author\":{\"external_id\":\"123\",\"name\":\"Jim\",\"locale\":\"en\"},\"allow_channelback\":true}]}", string(trace.RequestTrace)) - assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 234\r\n\r\n", string(trace.ResponseTrace)) -} diff --git a/services/tickets/zendesk/service.go b/services/tickets/zendesk/service.go deleted file mode 100644 index ce4d2630c..000000000 --- a/services/tickets/zendesk/service.go +++ /dev/null @@ -1,272 +0,0 @@ -package zendesk - -import ( - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/stringsx" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" - - "github.com/pkg/errors" -) - -const ( - typeZendesk = "zendesk" - - configSubdomain = "subdomain" - configSecret = "secret" - configOAuthToken = "oauth_token" - configPushID = "push_id" - configPushToken = "push_token" - configTargetID = "target_id" - configTriggerID = "trigger_id" - - statusOpen = "open" - statusSolved = "solved" - statusClosed = "closed" -) - -func init() { - models.RegisterTicketService(typeZendesk, NewService) -} - -type service struct { - rtConfig *runtime.Config - restClient *RESTClient - pushClient *PushClient - ticketer *flows.Ticketer - redactor stringsx.Redactor - secret string - instancePushID string - targetID string - triggerID string -} - -// NewService creates a new zendesk ticket service -func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { - subdomain := config[configSubdomain] - secret := config[configSecret] - oAuthToken := config[configOAuthToken] - instancePushID := config[configPushID] - pushToken := config[configPushToken] - targetID := config[configTargetID] - triggerID := config[configTriggerID] - - if subdomain != "" && secret != "" && oAuthToken != "" && instancePushID != "" && pushToken != "" { - return &service{ - rtConfig: rtCfg, - restClient: NewRESTClient(httpClient, httpRetries, subdomain, oAuthToken), - pushClient: NewPushClient(httpClient, httpRetries, subdomain, pushToken), - ticketer: ticketer, - redactor: stringsx.NewRedactor(flows.RedactionMask, oAuthToken, pushToken), - secret: secret, - instancePushID: instancePushID, - targetID: targetID, - triggerID: triggerID, - }, nil - } - return nil, errors.New("missing subdomain or secret or oauth_token or push_id or push_token in zendesk config") -} - -// Open opens a ticket which for mailgun means just sending an initial email -func (s *service) Open(env envs.Environment, contact *flows.Contact, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - ticket := flows.OpenTicket(s.ticketer, topic, body, assignee) - contactDisplay := contact.Format(env) - - msg := &ExternalResource{ - ExternalID: string(ticket.UUID()), // there's no local msg so use ticket UUID instead - Message: body, - ThreadID: string(ticket.UUID()), - CreatedAt: dates.Now(), - Author: Author{ - ExternalID: string(contact.UUID()), - Name: contactDisplay, - }, - AllowChannelback: true, - } - - if err := s.push(msg, logHTTP); err != nil { - return nil, err - } - - return ticket, nil -} - -func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { - contactUUID := ticket.Config("contact-uuid") - contactDisplay := ticket.Config("contact-display") - - fileURLs, err := s.convertAttachments(attachments) - if err != nil { - return errors.Wrap(err, "error converting attachments") - } - - msg := &ExternalResource{ - ExternalID: string(msgUUID), - Message: text, - ThreadID: string(ticket.UUID()), - CreatedAt: dates.Now(), - Author: Author{ - ExternalID: contactUUID, - Name: contactDisplay, - }, - FileURLs: fileURLs, - AllowChannelback: true, - } - - return s.push(msg, logHTTP) -} - -func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - ids, err := ticketsToZendeskIDs(tickets) - if err != nil { - return nil - } - - _, trace, err := s.restClient.UpdateManyTickets(ids, statusClosed) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - return err -} - -func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - ids, err := ticketsToZendeskIDs(tickets) - if err != nil { - return nil - } - - _, trace, err := s.restClient.UpdateManyTickets(ids, statusOpen) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - return err -} - -// AddStatusCallback adds a target and trigger to callback to us when ticket status is changed -func (s *service) AddStatusCallback(name, domain string, logHTTP flows.HTTPLogCallback) (map[string]string, error) { - targetURL := fmt.Sprintf("https://%s/mr/tickets/types/zendesk/target/%s", domain, s.ticketer.UUID()) - - target := &Target{ - Type: "http_target", - Title: fmt.Sprintf("%s Tickets", name), - TargetURL: targetURL, - Method: "POST", - Username: "zendesk", - Password: s.secret, - ContentType: "application/json", - } - - target, trace, err := s.restClient.CreateTarget(target) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return nil, err - } - - payload := `{ - "event": "status_changed", - "id": {{ticket.id}}, - "status": "{{ticket.status}}" -}` - - trigger := &Trigger{ - Title: fmt.Sprintf("Notify %s on ticket status change", name), - Conditions: Conditions{ - All: []Condition{ - {Field: "status", Operator: "changed"}, - {Field: "via_id", Operator: "is", Value: "55"}, // see https://developer.zendesk.com/rest_api/docs/support/triggers#via-types - }, - }, - Actions: []Action{ - {Field: "notification_target", Value: []string{fmt.Sprintf("%d", target.ID), string(payload)}}, - }, - } - - trigger, trace, err = s.restClient.CreateTrigger(trigger) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return nil, err - } - - return map[string]string{ - configTargetID: NumericIDToString(target.ID), - configTriggerID: NumericIDToString(trigger.ID), - }, nil -} - -func (s *service) RemoveStatusCallback(logHTTP flows.HTTPLogCallback) error { - if s.triggerID != "" { - id, _ := ParseNumericID(s.triggerID) - trace, err := s.restClient.DeleteTrigger(id) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return err - } - } - if s.targetID != "" { - id, _ := ParseNumericID(s.targetID) - trace, err := s.restClient.DeleteTarget(id) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return err - } - } - return nil -} - -func (s *service) push(msg *ExternalResource, logHTTP flows.HTTPLogCallback) error { - rid := NewRequestID(s.secret) - - results, trace, err := s.pushClient.Push(s.instancePushID, rid.String(), []*ExternalResource{msg}) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil || results[0].Status.Code != "success" { - if err == nil { - err = errors.New(results[0].Status.Description) - } - return errors.Wrap(err, "error pushing message to zendesk") - } - return nil -} - -// convert attachments to URLs which Zendesk can POST to. -// -// For example https://mybucket.s3.amazonaws.com/attachments/1/01c1/1aa4/01c11aa4-770a-4783.jpg -// is sent to Zendesk as file/1/01c1/1aa4/01c11aa4-770a-4783.jpg -// which it will request as POST https://textit.com/tickets/types/zendesk/file/1/01c1/1aa4/01c11aa4-770a-4783.jpg -func (s *service) convertAttachments(attachments []utils.Attachment) ([]string, error) { - prefix := s.rtConfig.S3AttachmentsPrefix - if !strings.HasPrefix(prefix, "/") { - prefix = "/" + prefix - } - - fileURLs := make([]string, len(attachments)) - for i, a := range attachments { - u, err := url.Parse(a.URL()) - if err != nil { - return nil, err - } - path := strings.TrimPrefix(u.Path, prefix) - path = strings.TrimPrefix(path, "/") - - fileURLs[i] = "file/" + path - } - return fileURLs, nil -} diff --git a/services/tickets/zendesk/service_test.go b/services/tickets/zendesk/service_test.go deleted file mode 100644 index a671ba914..000000000 --- a/services/tickets/zendesk/service_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package zendesk_test - -import ( - "net/http" - "testing" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/assets/static" - "github.com/nyaruka/goflow/envs" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/test" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/services/tickets/zendesk" - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestOpenAndForward(t *testing.T) { - ctx, rt := testsuite.Runtime() - - session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) - require.NoError(t, err) - - defer uuids.SetGenerator(uuids.DefaultGenerator) - defer dates.SetNowSource(dates.DefaultNowSource) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - uuids.SetGenerator(uuids.NewSeededGenerator(12345)) - dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 0, time.UTC))) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/any_channel/push.json": { - httpx.MockConnectionError, - httpx.NewMockResponse(201, nil, []byte(`{ - "results": [ - { - "external_resource_id": "123", - "status": {"code": "success"} - } - ] - }`)), - httpx.NewMockResponse(201, nil, []byte(`{ - "results": [ - { - "external_resource_id": "124", - "status": {"code": "success"} - } - ] - }`)), - }, - })) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk")) - - _, err = zendesk.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{}, - ) - assert.EqualError(t, err, "missing subdomain or secret or oauth_token or push_id or push_token in zendesk config") - - svc, err := zendesk.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{ - "subdomain": "nyaruka", - "secret": "sesame", - "oauth_token": "987654321", - "push_id": "1234-abcd", - "push_token": "123456789", - }, - ) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - - oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) - require.NoError(t, err) - defaultTopic := oa.SessionAssets().Topics().FindByName("General") - - // try with connection failure - _, err = svc.Open(session.Environment(), session.Contact(), defaultTopic, "Where are my cookies?", nil, logger.Log) - assert.EqualError(t, err, "error pushing message to zendesk: unable to connect to server") - - logger = &flows.HTTPLogger{} - - ticket, err := svc.Open(session.Environment(), session.Contact(), defaultTopic, "Where are my cookies?", nil, logger.Log) - assert.NoError(t, err) - assert.Equal(t, flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), ticket.UUID()) - assert.Equal(t, "General", ticket.Topic().Name()) - assert.Equal(t, "Where are my cookies?", ticket.Body()) - assert.Equal(t, "", ticket.ExternalID()) - assert.Equal(t, 1, len(logger.Logs)) - test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) - - dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Zendesk.ID, "", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, map[string]any{ - "contact-uuid": string(testdata.Cathy.UUID), - "contact-display": "Cathy", - }) - - logger = &flows.HTTPLogger{} - err = svc.Forward( - dbTicket, - flows.MsgUUID("ca5607f0-cba8-4c94-9cd5-c4fbc24aa767"), - "It's urgent", - []utils.Attachment{utils.Attachment("image/jpg:http://myfiles.com/attachments/0123/attachment1.jpg")}, - logger.Log, - ) - - assert.NoError(t, err) - assert.Equal(t, 1, len(logger.Logs)) - test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) -} - -func TestCloseAndReopen(t *testing.T) { - _, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetData) - - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://nyaruka.zendesk.com/api/v2/tickets/update_many.json?ids=12,14": { - httpx.NewMockResponse(201, nil, []byte(`{ - "job_status": { - "id": "1234-abcd", - "url": "http://zendesk.com", - "status": "queued" - } - }`)), - }, - "https://nyaruka.zendesk.com/api/v2/tickets/update_many.json?ids=14": { - httpx.NewMockResponse(201, nil, []byte(`{ - "job_status": { - "id": "1234-abcd", - "url": "http://zendesk.com", - "status": "queued" - } - }`)), - }, - })) - - ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk")) - svc, err := zendesk.NewService( - rt.Config, - http.DefaultClient, - nil, - ticketer, - map[string]string{ - "subdomain": "nyaruka", - "secret": "sesame", - "oauth_token": "987654321", - "push_id": "1234-abcd", - "push_token": "123456789", - }, - ) - require.NoError(t, err) - - logger := &flows.HTTPLogger{} - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", testdata.DefaultTopic.ID, "Where my cookies?", models.NilUserID, nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Admin.ID, models.NilFlowID, testdata.Bob.ID, testdata.Zendesk.ID, "14", testdata.DefaultTopic.ID, "Where my shoes?", models.NilUserID, nil) - - err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) - - assert.NoError(t, err) - test.AssertSnapshot(t, "close_tickets", logger.Logs[0].Request) - - err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) - - assert.NoError(t, err) - test.AssertSnapshot(t, "reopen_tickets", logger.Logs[1].Request) -} diff --git a/services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap b/services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap deleted file mode 100644 index f24d2c1bd..000000000 --- a/services/tickets/zendesk/testdata/TestCloseAndReopen_close_tickets.snap +++ /dev/null @@ -1,9 +0,0 @@ -PUT /api/v2/tickets/update_many.json?ids=12,14 HTTP/1.1 -Host: nyaruka.zendesk.com -User-Agent: Go-http-client/1.1 -Content-Length: 30 -Authorization: Bearer **************** -Content-Type: application/json -Accept-Encoding: gzip - -{"ticket":{"status":"closed"}} \ No newline at end of file diff --git a/services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap b/services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap deleted file mode 100644 index 89354cd6d..000000000 --- a/services/tickets/zendesk/testdata/TestCloseAndReopen_reopen_tickets.snap +++ /dev/null @@ -1,9 +0,0 @@ -PUT /api/v2/tickets/update_many.json?ids=14 HTTP/1.1 -Host: nyaruka.zendesk.com -User-Agent: Go-http-client/1.1 -Content-Length: 28 -Authorization: Bearer **************** -Content-Type: application/json -Accept-Encoding: gzip - -{"ticket":{"status":"open"}} \ No newline at end of file diff --git a/services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap b/services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap deleted file mode 100644 index e8759ae66..000000000 --- a/services/tickets/zendesk/testdata/TestOpenAndForward_forward_message.snap +++ /dev/null @@ -1,9 +0,0 @@ -POST /api/v2/any_channel/push.json HTTP/1.1 -Host: nyaruka.zendesk.com -User-Agent: Go-http-client/1.1 -Content-Length: 409 -Authorization: Bearer **************** -Content-Type: application/json -Accept-Encoding: gzip - -{"instance_push_id":"1234-abcd","request_id":"sesame:1570461699000000000","external_resources":[{"external_id":"ca5607f0-cba8-4c94-9cd5-c4fbc24aa767","message":"It's urgent","thread_id":"59d74b86-3e2f-4a93-aece-b05d2fdcde0c","created_at":"2019-10-07T15:21:38Z","author":{"external_id":"6393abc0-283d-4c9b-a1b3-641a035c34bf","name":"Cathy"},"allow_channelback":true,"file_urls":["file/0123/attachment1.jpg"]}]} \ No newline at end of file diff --git a/services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap b/services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap deleted file mode 100644 index 537dce39e..000000000 --- a/services/tickets/zendesk/testdata/TestOpenAndForward_open_ticket.snap +++ /dev/null @@ -1,9 +0,0 @@ -POST /api/v2/any_channel/push.json HTTP/1.1 -Host: nyaruka.zendesk.com -User-Agent: Go-http-client/1.1 -Content-Length: 382 -Authorization: Bearer **************** -Content-Type: application/json -Accept-Encoding: gzip - -{"instance_push_id":"1234-abcd","request_id":"sesame:1570461695000000000","external_resources":[{"external_id":"59d74b86-3e2f-4a93-aece-b05d2fdcde0c","message":"Where are my cookies?","thread_id":"59d74b86-3e2f-4a93-aece-b05d2fdcde0c","created_at":"2019-10-07T15:21:34Z","author":{"external_id":"5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f","name":"Ryan Lewis"},"allow_channelback":true}]} \ No newline at end of file diff --git a/services/tickets/zendesk/testdata/channelback.json b/services/tickets/zendesk/testdata/channelback.json deleted file mode 100644 index e310e9942..000000000 --- a/services/tickets/zendesk/testdata/channelback.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "label": "error response if missing required field", - "method": "POST", - "path": "/mr/tickets/types/zendesk/channelback", - "body": "message=We%20can%20help&recipient_id=1234&thread_id=7452108c-a52a-461b-825e-dd1e9688fcad", - "status": 400, - "response": { - "error": "error decoding form: Key: 'channelbackRequest.Metadata' Error:Field validation for 'Metadata' failed on the 'required' tag" - } - }, - { - "label": "error response if can't find ticket with thread ID", - "method": "POST", - "path": "/mr/tickets/types/zendesk/channelback", - "body": "message=We%20can%20help&recipient_id=1234&thread_id=7452108c-a52a-461b-825e-dd1e9688fcad&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D", - "status": 400, - "response": { - "error": "error looking up ticket 7452108c-a52a-461b-825e-dd1e9688fcad" - } - }, - { - "label": "error response if passed secret is incorrect", - "method": "POST", - "path": "/mr/tickets/types/zendesk/channelback", - "body": "message=We%20can%20help&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesxyz%22%7D", - "status": 401, - "response": { - "error": "ticketer secret mismatch" - } - }, - { - "label": "create message and send to contact if everything correct", - "method": "POST", - "path": "/mr/tickets/types/zendesk/channelback", - "body": "message=We%20can%20help&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D", - "status": 200, - "response": { - "external_id": "1", - "allow_channelback": true - }, - "db_assertions": [ - { - "query": "select count(*) from msgs_msg where direction = 'O' and text = 'We can help'", - "count": 1 - } - ] - }, - { - "label": "create message with attachments", - "method": "POST", - "path": "/mr/tickets/types/zendesk/channelback", - "body": "file_urls%5B%5D=https%3A%2F%2Fd3v-nyaruka.zendesk.com%2Fattachments%2Ftoken%2FEWTWEGWE%2F%3Fname%3DIhCY7aKs_400x400.jpg&message=Like%20this&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D", - "http_mocks": { - "https://d3v-nyaruka.zendesk.com/attachments/token/EWTWEGWE/?name=IhCY7aKs_400x400.jpg": [ - { - "status": 200, - "body": "IMAGE" - } - ] - }, - "status": 200, - "response": { - "external_id": "2", - "allow_channelback": true - }, - "db_assertions": [ - { - "query": "select count(*) from msgs_msg where direction = 'O' and text = 'Like this' and attachments = '{text/plain:https:///_test_attachments_storage/attachments/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'", - "count": 1 - } - ] - } -] \ No newline at end of file diff --git a/services/tickets/zendesk/testdata/event_callback.json b/services/tickets/zendesk/testdata/event_callback.json deleted file mode 100644 index f35d8fb67..000000000 --- a/services/tickets/zendesk/testdata/event_callback.json +++ /dev/null @@ -1,255 +0,0 @@ -[ - { - "label": "error response if missing required field", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": {}, - "status": 400, - "response": { - "error": "field 'events' is required" - } - }, - { - "label": "NOOP for create_integration event", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "create_integration", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "manifest_url": "https://temba.io/mainfest.json" - } - } - ] - }, - "status": 200, - "response": { - "status": "OK" - } - }, - { - "label": "NOOP for destroy_integration event", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "destroy_integration", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "manifest_url": "https://temba.io/mainfest.json" - } - } - ] - }, - "status": 200, - "response": { - "status": "OK" - } - }, - { - "label": "error if can't parse metadata from create_integration_instance event", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "create_integration_instance", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "metadata": "xxx" - } - } - ] - }, - "status": 400, - "response": { - "error": "error unmarshaling metadata: invalid character 'x' looking for beginning of value" - } - }, - { - "label": "error if can't load ticketer specified in create_integration_instance event", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "create_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-8da90c05490c\",\"secret\":\"sesame\"}" - } - } - ] - }, - "status": 400, - "response": { - "error": "error looking up ticketer 4ee6d4f3-f92b-439b-9718-8da90c05490c" - } - }, - { - "label": "target and trigger created for create_integration_instance event", - "http_mocks": { - "https://nyaruka.zendesk.com/api/v2/targets.json": [ - { - "status": 200, - "body": "{\"target\":{\"id\":15}}" - } - ], - "https://nyaruka.zendesk.com/api/v2/triggers.json": [ - { - "status": 200, - "body": "{\"trigger\":{\"id\":23}}" - } - ] - }, - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "create_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": 1 - } - ] - }, - { - "label": "error for resources_created_from_external_ids event with invalid request ID", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "resources_created_from_external_ids", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "request_id": "xxxx", - "resource_events": [] - } - } - ] - }, - "status": 400, - "response": { - "error": "invalid request ID format" - } - }, - { - "label": "set local external id to zendesk ID for resources_created_from_external_ids event for comment_on_new_ticket", - "method": "POST", - "path": "/mr/tickets/types/zendesk/event_callback", - "body": { - "events": [ - { - "type_id": "resources_created_from_external_ids", - "timestamp": "2015-09-08T22:48:09Z", - "subdomain": "nyaruka", - "integration_name": "Temba", - "integration_id": "25e2b1b2-e7f9-4485-8331-9f890aa9e2b8", - "data": { - "request_id": "sesame:1242663456373", - "resource_events": [ - { - "type_id": "comment_on_new_ticket", - "external_id": "$cathy_ticket_uuid$", - "comment_id": 111, - "ticket_id": 222 - } - ] - } - } - ] - }, - "status": 200, - "response": { - "status": "OK" - }, - "db_assertions": [ - { - "query": "select count(*) from tickets_ticket where external_id = '222'", - "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 diff --git a/services/tickets/zendesk/testdata/target.json b/services/tickets/zendesk/testdata/target.json deleted file mode 100644 index 3e237dc17..000000000 --- a/services/tickets/zendesk/testdata/target.json +++ /dev/null @@ -1,132 +0,0 @@ -[ - { - "label": "404 response if URL malformed", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/XYZ", - "body": {}, - "status": 404, - "response": { - "error": "not found: /mr/tickets/types/zendesk/target/XYZ" - } - }, - { - "label": "404 response if no such ticketer", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/122a91d5-cfc0-4777-88ef-d5b1e013e031", - "body": {}, - "status": 404, - "response": { - "error": "no such ticketer 122a91d5-cfc0-4777-88ef-d5b1e013e031" - } - }, - { - "label": "unauthorized response if basic auth missing", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "body": { - "event": "status_changed", - "id": 1234, - "status": "New" - }, - "status": 401, - "response": { - "status": "unauthorized" - } - }, - { - "label": "unauthorized response if basic auth fails", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "headers": { - "Authorization": "Basic emVuZGVzazoyMzUy" - }, - "body": { - "event": "status_changed", - "id": 1234, - "status": "New" - }, - "status": 401, - "response": { - "status": "unauthorized" - } - }, - { - "label": "error response if missing required field", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "headers": { - "Authorization": "Basic emVuZGVzazpzZXNhbWU=" - }, - "body": { - "event": "status_changed", - "status": "New" - }, - "status": 400, - "response": { - "error": "field 'id' is required" - } - }, - { - "label": "ignored response if can't find ticket", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "headers": { - "Authorization": "Basic emVuZGVzazpzZXNhbWU=" - }, - "body": { - "event": "status_changed", - "id": 34567845, - "status": "New" - }, - "status": 200, - "response": { - "status": "ignored" - } - }, - { - "label": "ticket updated if credentials correct", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "headers": { - "Authorization": "Basic emVuZGVzazpzZXNhbWU=" - }, - "body": { - "event": "status_changed", - "id": 1234, - "status": "Solved" - }, - "status": 200, - "response": { - "status": "handled" - }, - "db_assertions": [ - { - "query": "select count(*) from tickets_ticket where status = 'C'", - "count": 1 - } - ] - }, - { - "label": "ticket updated also when status is non-English", - "method": "POST", - "path": "/mr/tickets/types/zendesk/target/4ee6d4f3-f92b-439b-9718-8da90c05490b", - "headers": { - "Authorization": "Basic emVuZGVzazpzZXNhbWU=" - }, - "body": { - "event": "status_changed", - "id": 1234, - "status": "Abierto" - }, - "status": 200, - "response": { - "status": "handled" - }, - "db_assertions": [ - { - "query": "select count(*) from tickets_ticket where status = 'O'", - "count": 1 - } - ] - } -] \ No newline at end of file diff --git a/services/tickets/zendesk/utils.go b/services/tickets/zendesk/utils.go deleted file mode 100644 index db838f91b..000000000 --- a/services/tickets/zendesk/utils.go +++ /dev/null @@ -1,66 +0,0 @@ -package zendesk - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/mailroom/core/models" - - "github.com/pkg/errors" -) - -func ParseNumericID(s string) (int64, error) { - n, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return 0, errors.Errorf("%s is not a valid zendesk numeric id", s) - } - return n, nil -} - -func NumericIDToString(n int64) string { - return fmt.Sprintf("%d", n) -} - -// RequestID is a helper class to construct and later parse requests IDs for push requests -type RequestID struct { - Secret string - Timestamp time.Time -} - -// NewRequestID creates a new unique request ID -func NewRequestID(secret string) RequestID { - return RequestID{Secret: secret, Timestamp: dates.Now()} -} - -func (i RequestID) String() string { - return fmt.Sprintf("%s:%d", i.Secret, i.Timestamp.UnixNano()) -} - -// ParseRequestID parses a request ID -func ParseRequestID(s string) (RequestID, error) { - parts := strings.Split(s, ":") - if len(parts) == 2 { - secret := parts[0] - nanos, err := strconv.ParseInt(parts[1], 10, 64) - if err == nil { - return RequestID{Secret: secret, Timestamp: time.Unix(0, nanos)}, nil - } - } - return RequestID{}, errors.New("invalid request ID format") -} - -// parses out the zendesk ticket IDs from our local external ID field -func ticketsToZendeskIDs(tickets []*models.Ticket) ([]int64, error) { - var err error - ids := make([]int64, len(tickets)) - for i := range tickets { - ids[i], err = ParseNumericID(string(tickets[i].ExternalID())) - if err != nil { - return nil, err - } - } - return ids, nil -} diff --git a/services/tickets/zendesk/utils_test.go b/services/tickets/zendesk/utils_test.go deleted file mode 100644 index aeaf8938e..000000000 --- a/services/tickets/zendesk/utils_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package zendesk_test - -import ( - "testing" - "time" - - "github.com/nyaruka/gocommon/dates" - "github.com/nyaruka/mailroom/services/tickets/zendesk" - - "github.com/stretchr/testify/assert" -) - -func TestRequestID(t *testing.T) { - defer dates.SetNowSource(dates.DefaultNowSource) - dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 123456789, time.UTC))) - - id1 := zendesk.NewRequestID("sesame") - - assert.Equal(t, "sesame:1570461690123456789", id1.String()) - - id2, err := zendesk.ParseRequestID("sesame:1570461690123456789") - assert.NoError(t, err) - assert.Equal(t, "sesame", id2.Secret) - assert.True(t, id2.Timestamp.Equal(time.Date(2019, 10, 7, 15, 21, 30, 123456789, time.UTC))) - - _, err = zendesk.ParseRequestID("sesame") - assert.EqualError(t, err, "invalid request ID format") - - _, err = zendesk.ParseRequestID("sesame:abc") - assert.EqualError(t, err, "invalid request ID format") -} diff --git a/services/tickets/zendesk/web.go b/services/tickets/zendesk/web.go deleted file mode 100644 index c32be0d4d..000000000 --- a/services/tickets/zendesk/web.go +++ /dev/null @@ -1,301 +0,0 @@ -package zendesk - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/go-chi/chi" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" - "github.com/nyaruka/mailroom/services/tickets" - "github.com/nyaruka/mailroom/web" - "github.com/pkg/errors" -) - -func init() { - base := "/mr/tickets/types/zendesk" - - web.RegisterRoute(http.MethodPost, base+"/channelback", web.MarshaledResponse(handleChannelback)) - web.RegisterRoute(http.MethodPost, base+"/event_callback", web.MarshaledResponse(web.WithHTTPLogs(handleEventCallback))) - web.RegisterRoute(http.MethodPost, base+`/target/{ticketer:[a-f0-9\-]+}`, web.MarshaledResponse(web.WithHTTPLogs(handleTicketerTarget))) -} - -type integrationMetadata struct { - TicketerUUID assets.TicketerUUID `json:"ticketer" validate:"required"` - Secret string `json:"secret" validate:"required"` -} - -type channelbackRequest struct { - Message string `form:"message" validate:"required"` - FileURLs []string `form:"file_urls[]"` - ParentID string `form:"parent_id"` - ThreadID string `form:"thread_id" validate:"required"` - RecipientID string `form:"recipient_id" validate:"required"` - Metadata string `form:"metadata" validate:"required"` -} - -type channelbackResponse struct { - ExternalID string `json:"external_id"` - AllowChannelback bool `json:"allow_channelback"` -} - -func handleChannelback(ctx context.Context, rt *runtime.Runtime, r *http.Request) (any, int, error) { - request := &channelbackRequest{} - if err := web.DecodeAndValidateForm(request, r); err != nil { - return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil - } - - // decode our metadata - metadata := &integrationMetadata{} - if err := utils.UnmarshalAndValidate([]byte(request.Metadata), metadata); err != nil { - return errors.Wrapf(err, "error unmarshaling metadata"), http.StatusBadRequest, nil - } - - // lookup the ticket and ticketer - ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, rt, flows.TicketUUID(request.ThreadID), typeZendesk) - if err != nil { - return err, http.StatusBadRequest, nil - } - - // check ticketer secret - if ticketer.Config(configSecret) != metadata.Secret { - return errors.New("ticketer secret mismatch"), http.StatusUnauthorized, nil - } - - // reopen ticket if necessary - if ticket.Status() != models.TicketStatusOpen { - oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - if err != nil { - return err, http.StatusBadRequest, nil - } - - err = tickets.Reopen(ctx, rt, oa, ticket, false, nil) - if err != nil { - return errors.Wrapf(err, "error reopening ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil - } - } - - // fetch files - files := make([]*tickets.File, len(request.FileURLs)) - for i, fileURL := range request.FileURLs { - files[i], err = tickets.FetchFile(fileURL, nil) - if err != nil { - return errors.Wrapf(err, "error fetching ticket file '%s'", fileURL), http.StatusBadRequest, nil - } - } - - msg, err := tickets.SendReply(ctx, rt, ticket, request.Message, files) - if err != nil { - return err, http.StatusBadRequest, nil - } - - return &channelbackResponse{ExternalID: fmt.Sprintf("%d", msg.ID()), AllowChannelback: true}, http.StatusOK, nil -} - -type channelEvent struct { - TypeID string `json:"type_id"` - Timestamp time.Time `json:"timestamp"` - Subdomain string `json:"subdomain"` - IntegrationName string `json:"integration_name"` - IntegrationID string `json:"integration_id"` - Error string `json:"error"` - Data json.RawMessage `json:"data"` -} - -type integrationInstanceData struct { - Metadata string `json:"metadata"` -} - -type resourceEvent struct { - TypeID string `json:"type_id"` - TicketID int64 `json:"ticket_id"` - CommentID int64 `json:"comment_id"` - ExternalID string `json:"external_id"` -} - -type resourcesCreatedData struct { - RequestID string `json:"request_id"` - ResourceEvents []resourceEvent `json:"resource_events"` -} - -type eventCallbackRequest struct { - Events []*channelEvent `json:"events" validate:"required"` -} - -func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { - request := &eventCallbackRequest{} - if err := web.ReadAndValidateJSON(r, request); err != nil { - return err, http.StatusBadRequest, nil - } - - for _, e := range request.Events { - if err := processChannelEvent(ctx, rt, e, l); err != nil { - return err, http.StatusBadRequest, nil - } - } - - return map[string]string{"status": "OK"}, http.StatusOK, nil -} - -func processChannelEvent(ctx context.Context, rt *runtime.Runtime, event *channelEvent, l *models.HTTPLogger) error { - lr := slog.With("integration_id", event.IntegrationID, "subdomain", event.Subdomain) - - switch event.TypeID { - - case "create_integration": - lr.Info("zendesk app installed") - case "destroy_integration": - lr.Info("zendesk app uninstalled") - - case "create_integration_instance", "destroy_integration_instance": - data := &integrationInstanceData{} - if err := utils.UnmarshalAndValidate(event.Data, data); err != nil { - return err - } - - metadata := &integrationMetadata{} - if err := utils.UnmarshalAndValidate([]byte(data.Metadata), metadata); err != nil { - return errors.Wrapf(err, "error unmarshaling metadata") - } - - // look up our ticketer - ticketer, svc, err := tickets.FromTicketerUUID(ctx, rt, metadata.TicketerUUID, typeZendesk) - if err != nil { - return err - } - zendesk := svc.(*service) - - // check secret - if ticketer.Config(configSecret) != metadata.Secret { - return errors.New("ticketer secret mismatch") - } - - if event.TypeID == "create_integration_instance" { - // user has added an account through the admin UI - newConfig, err := zendesk.AddStatusCallback(event.IntegrationName, event.IntegrationID, l.Ticketer(ticketer)) - if err != nil { - return err - } - - // save away the target and trigger zendesk ids - if err := ticketer.UpdateConfig(ctx, rt.DB, newConfig, nil); err != nil { - return errors.Wrapf(err, "error updating config for ticketer %s", ticketer.UUID()) - } - - lr.Info("zendesk channel account added") - } else { - // user has removed a channel account - if err := zendesk.RemoveStatusCallback(l.Ticketer(ticketer)); err != nil { - return err - } - - // delete config values that came from adding this account - remConfig := utils.Set([]string{configPushID, configPushToken, configTargetID, configTriggerID}) - if err := ticketer.UpdateConfig(ctx, rt.DB, nil, remConfig); err != nil { - return errors.Wrapf(err, "error updating config for ticketer %s", ticketer.UUID()) - } - - lr.Info("zendesk channel account removed") - } - - case "resources_created_from_external_ids": - data := &resourcesCreatedData{} - if err := utils.UnmarshalAndValidate(event.Data, data); err != nil { - return err - } - - // parse the request ID we passed to zendesk when we pushed these external resources - reqID, err := ParseRequestID(data.RequestID) - if err != nil { - return err - } - - for _, re := range data.ResourceEvents { - if re.TypeID == "comment_on_new_ticket" { - if err := processCommentOnNewTicket(ctx, rt, reqID, re, l); err != nil { - return err - } - } - } - } - return nil -} - -func processCommentOnNewTicket(ctx context.Context, rt *runtime.Runtime, reqID RequestID, re resourceEvent, l *models.HTTPLogger) error { - // look up our ticket and ticketer - ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, rt, flows.TicketUUID(re.ExternalID), typeZendesk) - if err != nil { - return err - } - - // check ticketer secret - if ticketer.Config(configSecret) != reqID.Secret { - return errors.New("ticketer secret mismatch") - } - - // update our local ticket with the ID from Zendesk - return models.UpdateTicketExternalID(ctx, rt.DB, ticket, fmt.Sprintf("%d", re.TicketID)) -} - -type targetRequest struct { - Event string `json:"event" validate:"required"` - ID int64 `json:"id" validate:"required"` - Status string `json:"status"` -} - -func handleTicketerTarget(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { - ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer")) - - // look up our ticketer - ticketer, _, err := tickets.FromTicketerUUID(ctx, rt, ticketerUUID, typeZendesk) - if err != nil || ticketer == nil { - return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil - } - - // check authentication - username, password, _ := r.BasicAuth() - if username != "zendesk" || password != ticketer.Config(configSecret) { - return map[string]string{"status": "unauthorized"}, http.StatusUnauthorized, nil - } - - // parse request payload - request := &targetRequest{} - if err := web.ReadAndValidateJSON(r, request); err != nil { - return err, http.StatusBadRequest, nil - } - - // lookup ticket - ticket, err := models.LookupTicketByExternalID(ctx, rt.DB, ticketer.ID(), fmt.Sprintf("%d", request.ID)) - if err != nil || ticket == nil { - // we don't return an error here, because ticket might just belong to a different ticketer - return map[string]string{"status": "ignored"}, http.StatusOK, nil - } - - oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - if err != nil { - return err, http.StatusBadRequest, nil - } - - if request.Event == "status_changed" { - switch strings.ToLower(request.Status) { - case statusSolved, statusClosed, "resuelto", "cerrado": - err = tickets.Close(ctx, rt, oa, ticket, false, l) - case statusOpen, "abierto": - err = tickets.Reopen(ctx, rt, oa, ticket, false, l) - } - - if err != nil { - return err, http.StatusBadRequest, nil - } - } - - return map[string]string{"status": "handled"}, http.StatusOK, nil -} diff --git a/services/tickets/zendesk/web_test.go b/services/tickets/zendesk/web_test.go deleted file mode 100644 index 0ec384c4f..000000000 --- a/services/tickets/zendesk/web_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package zendesk - -import ( - "testing" - "time" - - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" -) - -func TestChannelback(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetData) - - // create a zendesk ticket for Cathy - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "1234", time.Now(), nil) - - testsuite.RunWebTests(t, ctx, rt, "testdata/channelback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) -} - -func TestEventCallback(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetAll) // tests include destroying ticketer - - // create a zendesk ticket for Cathy - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "1234", time.Now(), nil) - - testsuite.RunWebTests(t, ctx, rt, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) -} - -func TestTarget(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer testsuite.Reset(testsuite.ResetData) - - // create a zendesk ticket for Cathy - ticket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "1234", time.Now(), nil) - - testsuite.RunWebTests(t, ctx, rt, "testdata/target.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) -} diff --git a/testsuite/testdata/constants.go b/testsuite/testdata/constants.go index aaef9a3dd..497e9f052 100644 --- a/testsuite/testdata/constants.go +++ b/testsuite/testdata/constants.go @@ -63,11 +63,6 @@ var DefaultTopic = &Topic{1, "5cc1848a-357c-4de9-9720-45770ec18d11"} var SalesTopic = &Topic{2, "9ef2ff21-064a-41f1-8560-ccc990b4f937"} var SupportTopic = &Topic{3, "0a8f2e00-fef6-402c-bd79-d789446ec0e0"} -var Internal = &Ticketer{1, "ffc903f7-8cbb-443f-9627-87106842d1aa"} -var Mailgun = &Ticketer{2, "f9c9447f-a291-4f3c-8c79-c089bbd4e713"} -var Zendesk = &Ticketer{3, "4ee6d4f3-f92b-439b-9718-8da90c05490b"} -var RocketChat = &Ticketer{4, "6c50665f-b4ff-4e37-9625-bc464fe6a999"} - var Partners = &Team{1, "4321c30b-b596-46fa-adb4-4a46d37923f6"} var Office = &Team{2, "f14c1762-d38b-4072-ae63-2705332a3719"} diff --git a/testsuite/testdata/tickets.go b/testsuite/testdata/tickets.go index b1c4f6f3b..6827b71c5 100644 --- a/testsuite/testdata/tickets.go +++ b/testsuite/testdata/tickets.go @@ -33,22 +33,17 @@ func (k *Ticket) Load(rt *runtime.Runtime) *models.Ticket { return tickets[0] } -type Ticketer struct { - ID models.TicketerID - UUID assets.TicketerUUID -} - // InsertOpenTicket inserts an open ticket -func InsertOpenTicket(rt *runtime.Runtime, org *Org, contact *Contact, ticketer *Ticketer, topic *Topic, body, externalID string, openedOn time.Time, assignee *User) *Ticket { - return insertTicket(rt, org, contact, ticketer, models.TicketStatusOpen, topic, body, externalID, openedOn, assignee) +func InsertOpenTicket(rt *runtime.Runtime, org *Org, contact *Contact, topic *Topic, body string, openedOn time.Time, assignee *User) *Ticket { + return insertTicket(rt, org, contact, models.TicketStatusOpen, topic, body, openedOn, assignee) } // InsertClosedTicket inserts a closed ticket -func InsertClosedTicket(rt *runtime.Runtime, org *Org, contact *Contact, ticketer *Ticketer, topic *Topic, body, externalID string, assignee *User) *Ticket { - return insertTicket(rt, org, contact, ticketer, models.TicketStatusClosed, topic, body, externalID, dates.Now(), assignee) +func InsertClosedTicket(rt *runtime.Runtime, org *Org, contact *Contact, topic *Topic, body string, assignee *User) *Ticket { + return insertTicket(rt, org, contact, models.TicketStatusClosed, topic, body, dates.Now(), assignee) } -func insertTicket(rt *runtime.Runtime, org *Org, contact *Contact, ticketer *Ticketer, status models.TicketStatus, topic *Topic, body, externalID string, openedOn time.Time, assignee *User) *Ticket { +func insertTicket(rt *runtime.Runtime, org *Org, contact *Contact, status models.TicketStatus, topic *Topic, body string, openedOn time.Time, assignee *User) *Ticket { uuid := flows.TicketUUID(uuids.New()) lastActivityOn := openedOn @@ -61,8 +56,8 @@ func insertTicket(rt *runtime.Runtime, org *Org, contact *Contact, ticketer *Tic var id models.TicketID must(rt.DB.Get(&id, - `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, topic_id, body, external_id, opened_on, modified_on, closed_on, last_activity_on, assignee_id) - VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12) RETURNING id`, uuid, org.ID, contact.ID, ticketer.ID, status, topic.ID, body, externalID, openedOn, closedOn, lastActivityOn, assignee.SafeID(), + `INSERT INTO tickets_ticket(uuid, org_id, contact_id, status, topic_id, body, opened_on, modified_on, closed_on, last_activity_on, assignee_id) + VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10) RETURNING id`, uuid, org.ID, contact.ID, status, topic.ID, body, openedOn, closedOn, lastActivityOn, assignee.SafeID(), )) return &Ticket{id, uuid} } diff --git a/web/contact/base_test.go b/web/contact/base_test.go index ca3d25e60..86a690ec1 100644 --- a/web/contact/base_test.go +++ b/web/contact/base_test.go @@ -16,7 +16,6 @@ import ( "github.com/nyaruka/goflow/envs" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" - _ "github.com/nyaruka/mailroom/services/tickets/intern" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" diff --git a/web/msg/base_test.go b/web/msg/base_test.go index 06e52f9e9..2c1c8e708 100644 --- a/web/msg/base_test.go +++ b/web/msg/base_test.go @@ -15,7 +15,7 @@ func TestSend(t *testing.T) { defer testsuite.Reset(testsuite.ResetData | testsuite.ResetRedis) - cathyTicket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "help", "", time.Date(2015, 1, 1, 12, 30, 45, 0, time.UTC), nil) + cathyTicket := testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "help", time.Date(2015, 1, 1, 12, 30, 45, 0, time.UTC), nil) testsuite.RunWebTests(t, ctx, rt, "testdata/send.json", map[string]string{ "cathy_ticket_id": fmt.Sprintf("%d", cathyTicket.ID), diff --git a/web/ticket/base_test.go b/web/ticket/base_test.go index 2e2f9d0f6..2ea1a45fd 100644 --- a/web/ticket/base_test.go +++ b/web/ticket/base_test.go @@ -4,9 +4,6 @@ import ( "testing" "time" - _ "github.com/nyaruka/mailroom/services/tickets/intern" - _ "github.com/nyaruka/mailroom/services/tickets/mailgun" - _ "github.com/nyaruka/mailroom/services/tickets/zendesk" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" ) @@ -16,10 +13,10 @@ func TestTicketAssign(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "17", time.Now(), testdata.Admin) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "21", time.Now(), testdata.Agent) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "34", nil) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.Internal, testdata.DefaultTopic, "", "", nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Admin) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Agent) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", nil) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.DefaultTopic, "", nil) testsuite.RunWebTests(t, ctx, rt, "testdata/assign.json", nil) } @@ -29,9 +26,9 @@ func TestTicketAddNote(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "17", time.Now(), testdata.Admin) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "21", time.Now(), testdata.Agent) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "34", nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Admin) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Agent) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", nil) testsuite.RunWebTests(t, ctx, rt, "testdata/add_note.json", nil) } @@ -41,9 +38,9 @@ func TestTicketChangeTopic(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "17", time.Now(), testdata.Admin) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.SupportTopic, "Have you seen my cookies?", "21", time.Now(), testdata.Agent) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Internal, testdata.SalesTopic, "Have you seen my cookies?", "34", nil) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Admin) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.SupportTopic, "Have you seen my cookies?", time.Now(), testdata.Agent) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.SalesTopic, "Have you seen my cookies?", nil) testsuite.RunWebTests(t, ctx, rt, "testdata/change_topic.json", nil) } @@ -53,11 +50,11 @@ func TestTicketClose(t *testing.T) { defer testsuite.Reset(testsuite.ResetData) - // create 2 open tickets and 1 closed one for Cathy across two different ticketers - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "17", time.Now(), testdata.Admin) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "21", time.Now(), nil) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "34", testdata.Editor) - testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "21", time.Now(), nil) + // create 2 open tickets and 1 closed one for Cathy + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), testdata.Admin) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), nil) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", testdata.Editor) + testdata.InsertOpenTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", time.Now(), nil) testsuite.RunWebTests(t, ctx, rt, "testdata/close.json", nil) } @@ -68,13 +65,13 @@ func TestTicketReopen(t *testing.T) { defer testsuite.Reset(testsuite.ResetData | testsuite.ResetRedis) // we should be able to reopen ticket #1 because Cathy has no other tickets open - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Mailgun, testdata.DefaultTopic, "Have you seen my cookies?", "17", testdata.Admin) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", testdata.Admin) // but then we won't be able to open ticket #2 - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "21", nil) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Cathy, testdata.DefaultTopic, "Have you seen my cookies?", nil) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.Zendesk, testdata.DefaultTopic, "Have you seen my cookies?", "27", testdata.Editor) - testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Alexandria, testdata.Internal, testdata.DefaultTopic, "Have you seen my cookies?", "", testdata.Editor) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Bob, testdata.DefaultTopic, "Have you seen my cookies?", testdata.Editor) + testdata.InsertClosedTicket(rt, testdata.Org1, testdata.Alexandria, testdata.DefaultTopic, "Have you seen my cookies?", testdata.Editor) testsuite.RunWebTests(t, ctx, rt, "testdata/reopen.json", nil) } diff --git a/web/ticket/close.go b/web/ticket/close.go index a6792da58..3dfefc40d 100644 --- a/web/ticket/close.go +++ b/web/ticket/close.go @@ -12,7 +12,7 @@ import ( ) func init() { - web.RegisterRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.MarshaledResponse(web.WithHTTPLogs(handleClose)))) + web.RegisterRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.MarshaledResponse(handleClose))) } // Closes any open tickets with the given ids. If force=true then even if tickets can't be closed on external service, @@ -24,7 +24,7 @@ func init() { // "ticket_ids": [1234, 2345], // "force": false // } -func handleClose(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { +func handleClose(ctx context.Context, rt *runtime.Runtime, r *http.Request) (any, int, error) { request := &bulkTicketRequest{} if err := web.ReadAndValidateJSON(r, request); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil @@ -41,7 +41,7 @@ func handleClose(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *m return nil, 0, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) } - evts, err := models.CloseTickets(ctx, rt, oa, request.UserID, tickets, true, request.Force, l) + evts, err := models.CloseTickets(ctx, rt, oa, request.UserID, tickets) if err != nil { return nil, 0, errors.Wrap(err, "error closing tickets") } diff --git a/web/ticket/reopen.go b/web/ticket/reopen.go index 812c0a3b0..ffa127f25 100644 --- a/web/ticket/reopen.go +++ b/web/ticket/reopen.go @@ -13,7 +13,7 @@ import ( ) func init() { - web.RegisterRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.MarshaledResponse(web.WithHTTPLogs(handleReopen)))) + web.RegisterRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.MarshaledResponse(handleReopen))) } // Reopens any closed tickets with the given ids @@ -23,7 +23,7 @@ func init() { // "user_id": 234, // "ticket_ids": [1234, 2345] // } -func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { +func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request) (any, int, error) { request := &bulkTicketRequest{} if err := web.ReadAndValidateJSON(r, request); err != nil { return errors.Wrap(err, "request failed validation"), http.StatusBadRequest, nil @@ -53,7 +53,7 @@ func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request, l * start := time.Now() for len(remaining) > 0 && time.Since(start) < time.Second*10 { - evts, skipped, err := tryToLockAndReopen(ctx, rt, oa, remaining, request.UserID, l) + evts, skipped, err := tryToLockAndReopen(ctx, rt, oa, remaining, request.UserID) if err != nil { return nil, 0, err } @@ -66,7 +66,7 @@ func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request, l * return newBulkResponse(results), http.StatusOK, nil } -func tryToLockAndReopen(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, tickets map[models.ContactID]*models.Ticket, userID models.UserID, l *models.HTTPLogger) (map[*models.Ticket]*models.TicketEvent, map[models.ContactID]*models.Ticket, error) { +func tryToLockAndReopen(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, tickets map[models.ContactID]*models.Ticket, userID models.UserID) (map[*models.Ticket]*models.TicketEvent, map[models.ContactID]*models.Ticket, error) { locks, skipped, err := models.LockContacts(ctx, rt, oa.OrgID(), maps.Keys(tickets), time.Second) if err != nil { return nil, nil, err @@ -90,7 +90,7 @@ func tryToLockAndReopen(ctx context.Context, rt *runtime.Runtime, oa *models.Org } } - evts, err := models.ReopenTickets(ctx, rt, oa, userID, reopenable, true, l) + evts, err := models.ReopenTickets(ctx, rt, oa, userID, reopenable) if err != nil { return nil, nil, errors.Wrap(err, "error reopening tickets") } diff --git a/web/wrappers.go b/web/wrappers.go index 12376d707..e574f8dc7 100644 --- a/web/wrappers.go +++ b/web/wrappers.go @@ -104,21 +104,3 @@ func RequireAuthToken(handler Handler) Handler { return handler(ctx, rt, r, w) } } - -// LoggingJSONHandler is a JSON web handler which logs HTTP logs -type LoggingJSONHandler func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) - -// WithHTTPLogs wraps a handler to create a handler which can record and save HTTP logs -func WithHTTPLogs(handler LoggingJSONHandler) MarshaledHandler { - return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (any, int, error) { - logger := &models.HTTPLogger{} - - response, status, err := handler(ctx, rt, r, logger) - - if err := logger.Insert(ctx, rt.DB); err != nil { - return nil, 0, errors.Wrap(err, "error writing HTTP logs") - } - - return response, status, err - } -} diff --git a/web/wrappers_test.go b/web/wrappers_test.go deleted file mode 100644 index f6c3672d1..000000000 --- a/web/wrappers_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package web_test - -import ( - "context" - "net/http" - "testing" - - "github.com/nyaruka/gocommon/dbutil/assertdb" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/runtime" - "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/nyaruka/mailroom/web" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWithHTTPLogs(t *testing.T) { - ctx, rt := testsuite.Runtime() - - defer rt.DB.MustExec(`DELETE FROM request_logs_httplog`) - - defer httpx.SetRequestor(httpx.DefaultRequestor) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://temba.io": { - httpx.NewMockResponse(200, nil, []byte(`hello`)), - httpx.NewMockResponse(400, nil, []byte(`world`)), - }, - })) - - handler := func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (any, int, error) { - ticketer, _ := models.LookupTicketerByUUID(ctx, rt.DB.DB, testdata.Mailgun.UUID) - - logger := l.Ticketer(ticketer) - - // make and log a few HTTP requests - req1, err := http.NewRequest("GET", "https://temba.io", nil) - require.NoError(t, err) - trace1, err := httpx.DoTrace(http.DefaultClient, req1, nil, nil, -1) - require.NoError(t, err) - logger(flows.NewHTTPLog(trace1, flows.HTTPStatusFromCode, nil)) - - req2, err := http.NewRequest("GET", "https://temba.io", nil) - require.NoError(t, err) - trace2, err := httpx.DoTrace(http.DefaultClient, req2, nil, nil, -1) - require.NoError(t, err) - logger(flows.NewHTTPLog(trace2, flows.HTTPStatusFromCode, nil)) - - return map[string]string{"status": "OK"}, http.StatusOK, nil - } - - // simulate handler being invoked by server - wrapped := web.WithHTTPLogs(handler) - response, status, err := wrapped(ctx, rt, nil) - - // check response from handler - assert.Equal(t, map[string]string{"status": "OK"}, response) - assert.Equal(t, http.StatusOK, status) - assert.NoError(t, err) - - // check HTTP logs were created - assertdb.Query(t, rt.DB, `select count(*) from request_logs_httplog where ticketer_id = $1;`, testdata.Mailgun.ID).Returns(2) -}