diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b32d9edd..12ba18f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: [push, pull_request] env: - go-version: '1.14.x' + go-version: '1.16.x' postgis-version: '2.5' jobs: test: @@ -51,7 +51,7 @@ jobs: if: success() uses: codecov/codecov-action@v1 with: - fail_ci_if_error: true + fail_ci_if_error: false release: name: Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dc437cc..e0faa9643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,168 @@ +v6.5.6 +---------- + * Update to latest goflow and add parse_only as param to parse_query to allow us to extract field dependencies even when they don't yet exist in the database + +v6.5.5 +---------- + * Fix tests broken by recent db changes to msgs and broadcasts + * Populate ticket_count when creating new contacts + +v6.5.4 +---------- + * Actually save IVR messages with sent_on set + +v6.5.3 +---------- + * Update contact modified_on after populate dynamic group task + * Update to latest goflow + +v6.5.2 +---------- + * Set sent_on for outgoing IVR messages + +v6.5.1 +---------- + * Support flow config ivr_retry values of -1 meaning no retry + * Log error if marking event fire as fired fails + +v6.5.0 +---------- + * Update to latest goflow and gocommon + +v6.4.3 +---------- + * Fix triggering new IVR flow from a simulation resume so that it includes connection to test channel + +v6.4.2 +---------- + * Latest goflow with latest localization + +v6.4.1 +---------- + * Update to latest goflow to get fixes for nulls in webhook responses + * Add new error type for failed SQL queries + +v6.4.0 +---------- + * move s3 session config error to a warning for the time being since not strictly required yet + +v6.3.31 +---------- + * Support ticket open events with assignees + * Add endpoints for ticket assignment and adding notes + +v6.3.30 +---------- + * Update to latest goflow + +v6.3.29 +---------- + * Include args in BulkQuery error output + +v6.3.28 +---------- + * Return more SQL when BulkQuery errors + * Update to latest goflow/gocommon + +v6.3.27 +---------- + * Fix handling of inbox messages to also update open tickets + +v6.3.26 +---------- + * Stop writing broadcast.is_active which is now nullable + +v6.3.25 +---------- + * Update to latest goflow + +v6.3.24 +---------- + * Update to latest goflow + * Load org users as assets and use for ticket assignees and manual trigger users + * Add ticket to broadcasts and update last_activity_on after creating messages for a broadcast with a ticket + +v6.3.23 +---------- + * Add support for exclusion groups on scheduled triggers + +v6.3.22 +---------- + * Update ticket last_activity_on when opening/closing and for incoming messages + * Set contact_id when creating new tickets events + +v6.3.21 +---------- + * Update to latest goflow and which no longer takes default_language + +v6.3.20 +---------- + * Have our session filename lead with timestamp so other objects can exist in contact dirs + +v6.3.19 +---------- + * Parse URL to get path out for sessions + +v6.3.18 +---------- + * Use s3 session prefix when building s3 paths, default to / + * Throw error upwards if we have no DB backdown + * Read session files from storage when org configured to do so + +v6.3.17 +---------- + * Ignore contact tickets on ticketers which have been deleted + +v6.3.16 +---------- + * Add ticket closed triggers and use to handle close ticket events + * Add ticket events and insert when opening/closing/reopening tickets + +v6.3.15 +---------- + * Fix test which modifies org + * Update to latest goflow as last release was broken + +v6.3.14 +---------- + * Update to latest goflow + * Write sessions to s3 on resumes (optionally) + * Add support for exclusion groups on triggers and generalize trigger matching + +v6.3.13 +---------- + * Introduce runtime.Runtime + * Simplify testdata functions + * Various fixes from linter + * Simplify use of test contacts in handler tests + * Move test constants out of models package + * Remove reduntant resend_msgs task + +v6.3.12 +---------- + * Update to latest goflow (legacy_extra is no longer an issue) + * Make Msg.next_attempt nullable + * Add web endpoint for msg resends so they can be a synchronous operation + +v6.3.11 +---------- + * Expose open tickets as @contact.tickets + +v6.3.9 +---------- + * Fix queueing of resent messages to courier and improve testing of queueing + * Update to latest goflow + * Add WA template translation namespace + +v6.3.8 +---------- + * Add task to resend messages + +v6.3.7 +---------- + * Update to latest goflow + * Update test database and rename Nexmo to Vonage + v6.3.6 ---------- * Update to latest goflow diff --git a/README.md b/README.md index f512c01fd..70be6174e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# mailroom +# mailroom -[![Build Status](https://github.com/nyaruka/mailroom/workflows/CI/badge.svg)](https://github.com/nyaruka/mailroom/actions?query=workflow%3ACI) -[![codecov](https://codecov.io/gh/nyaruka/mailroom/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/mailroom) +[![Build Status](https://github.com/nyaruka/mailroom/workflows/CI/badge.svg)](https://github.com/nyaruka/mailroom/actions?query=workflow%3ACI) +[![codecov](https://codecov.io/gh/nyaruka/mailroom/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/mailroom) -# About +# About Mailroom is the [RapidPro](https://github.com/rapidpro/rapidpro) component responsible for the execution of flows. It interacts directly with the RapidPro database and sends and receives messages with [Courier](https://github.com/nyaruka/courier) for handling via Redis. @@ -18,9 +18,10 @@ behind a reverse proxy such as nginx or Elastic Load Balancer that provides HTTP # Configuration Mailroom uses a tiered configuration system, each option takes precendence over the ones above it: - 1. The configuration file - 2. Environment variables starting with `MAILROOM_` - 3. Command line parameters + +1. The configuration file +2. Environment variables starting with `MAILROOM_` +3. Command line parameters We recommend running Mailroom with no changes to the configuration and no parameters, using only environment variables to configure it. You can use `% mailroom --help` to see a list of the @@ -30,29 +31,34 @@ environment variables and parameters and for more details on each option. For use with RapidPro, you will want to configure these settings: - * `MAILROOM_ADDRESS`: the address to bind our web server to (default "localhost") - * `MAILROOM_DOMAIN`: the domain that mailroom is listening on - * `MAILROOM_AUTH_TOKEN`: the token clients will need to authenticate web requests (should match setting in RapidPro) - * `MAILROOM_ATTACHMENT_DOMAIN`: the domain that will be used for relative attachments in flows - * `MAILROOM_DB`: URL describing how to connect to the RapidPro database (default "postgres://temba:temba@localhost/temba?sslmode=disable") - * `MAILROOM_REDIS`: URL describing how to connect to Redis (default "redis://localhost:6379/15") - * `MAILROOM_ELASTIC`: URL describing how to connect to ElasticSearch (default "http://localhost:9200") - * `MAILROOM_SMTP_SERVER`: the smtp configuration for sending emails ex: smtp://user%40password@server:port/?from=foo%40gmail.com - +- `MAILROOM_ADDRESS`: the address to bind our web server to (default "localhost") +- `MAILROOM_DOMAIN`: the domain that mailroom is listening on +- `MAILROOM_AUTH_TOKEN`: the token clients will need to authenticate web requests (should match setting in RapidPro) +- `MAILROOM_ATTACHMENT_DOMAIN`: the domain that will be used for relative attachments in flows +- `MAILROOM_DB`: URL describing how to connect to the RapidPro database (default "postgres://temba:temba@localhost/temba?sslmode=disable") +- `MAILROOM_REDIS`: URL describing how to connect to Redis (default "redis://localhost:6379/15") +- `MAILROOM_ELASTIC`: URL describing how to connect to ElasticSearch (default "http://localhost:9200") +- `MAILROOM_SMTP_SERVER`: the smtp configuration for sending emails ex: smtp://user%40password@server:port/?from=foo%40gmail.com + For writing of message attachments, Mailroom needs access to an S3 bucket, you can configure access to your bucket via: - * `MAILROOM_S3_REGION`: The region for your S3 bucket (ex: `eu-west-1`) - * `MAILROOM_S3_MEDIA_BUCKET`: The name of your S3 bucket (ex: `dl-mailroom`) - * `MAILROOM_S3_MEDIA_PREFIX`: The prefix to use for filenames of attachments added to your bucket (ex: `attachments`) - * `MAILROOM_AWS_ACCESS_KEY_ID`: The AWS access key id used to authenticate to AWS - * `MAILROOM_AWS_SECRET_ACCESS_KEY` The AWS secret access key used to authenticate to AWS +- `MAILROOM_S3_REGION`: The region for your S3 bucket (ex: `eu-west-1`) +- `MAILROOM_S3_MEDIA_BUCKET`: The name of your S3 bucket (ex: `dl-mailroom`) +- `MAILROOM_S3_MEDIA_PREFIX`: The prefix to use for filenames of attachments added to your bucket (ex: `attachments`) +- `MAILROOM_AWS_ACCESS_KEY_ID`: The AWS access key id used to authenticate to AWS +- `MAILROOM_AWS_SECRET_ACCESS_KEY` The AWS secret access key used to authenticate to AWS + +While still in beta, Mailroom will move to writing session data to S3 in 6.6, you can configure those buckets using: + +- `MAILROOM_S3_SESSION_BUCKET`: The name of your S3 bucket (ex: `rp-sessions`) +- `MAILROOM_S3_SESSION_PREFIX`: The prefix to use for filenames of sessions added to your bucket (ex: ``) Recommended settings for error and performance monitoring: - * `MAILROOM_LIBRATO_USERNAME`: The username to use for logging of events to Librato - * `MAILROOM_LIBRATO_TOKEN`: The token to use for logging of events to Librato - * `MAILROOM_SENTRY_DSN`: The DSN to use when logging errors to Sentry - * `MAILROOM_LOG_LEVEL`: the logging level mailroom should use (default "error", use "debug" for more) +- `MAILROOM_LIBRATO_USERNAME`: The username to use for logging of events to Librato +- `MAILROOM_LIBRATO_TOKEN`: The token to use for logging of events to Librato +- `MAILROOM_SENTRY_DSN`: The DSN to use when logging errors to Sentry +- `MAILROOM_LOG_LEVEL`: the logging level mailroom should use (default "error", use "debug" for more) # Development diff --git a/cmd/mailroom/main.go b/cmd/mailroom/main.go index a063f4cfa..f187e2138 100644 --- a/cmd/mailroom/main.go +++ b/cmd/mailroom/main.go @@ -20,7 +20,6 @@ import ( _ "github.com/nyaruka/mailroom/core/tasks/campaigns" _ "github.com/nyaruka/mailroom/core/tasks/contacts" _ "github.com/nyaruka/mailroom/core/tasks/expirations" - _ "github.com/nyaruka/mailroom/core/tasks/groups" _ "github.com/nyaruka/mailroom/core/tasks/interrupts" _ "github.com/nyaruka/mailroom/core/tasks/ivr" _ "github.com/nyaruka/mailroom/core/tasks/schedules" @@ -36,6 +35,7 @@ import ( _ "github.com/nyaruka/mailroom/web/expression" _ "github.com/nyaruka/mailroom/web/flow" _ "github.com/nyaruka/mailroom/web/ivr" + _ "github.com/nyaruka/mailroom/web/msg" _ "github.com/nyaruka/mailroom/web/org" _ "github.com/nyaruka/mailroom/web/po" _ "github.com/nyaruka/mailroom/web/simulation" diff --git a/config/config.go b/config/config.go index 45f74aa01..dd952ef01 100644 --- a/config/config.go +++ b/config/config.go @@ -47,12 +47,18 @@ type Config struct { Domain string `help:"the domain that mailroom is listening on"` AttachmentDomain string `help:"the domain that will be used for relative attachment"` - S3Endpoint string `help:"the S3 endpoint we will write attachments to"` - S3Region string `help:"the S3 region we will write attachments to"` - S3MediaBucket string `help:"the S3 bucket we will write attachments to"` - S3MediaPrefix string `help:"the prefix that will be added to attachment filenames"` - S3DisableSSL bool `help:"whether we disable SSL when accessing S3. Should always be set to False unless you're hosting an S3 compatible service within a secure internal network"` - S3ForcePathStyle bool `help:"whether we force S3 path style. Should generally need to default to False unless you're hosting an S3 compatible service"` + S3Endpoint string `help:"the S3 endpoint we will write attachments to"` + S3Region string `help:"the S3 region we will write attachments to"` + + S3MediaBucket string `help:"the S3 bucket we will write attachments to"` + S3MediaPrefix string `help:"the prefix that will be added to attachment filenames"` + + S3SessionBucket string `help:"the S3 bucket we will write attachments to"` + S3SessionPrefix string `help:"the prefix that will be added to attachment filenames"` + + S3DisableSSL bool `help:"whether we disable SSL when accessing S3. Should always be set to False unless you're hosting an S3 compatible service within a secure internal network"` + S3ForcePathStyle bool `help:"whether we force S3 path style. Should generally need to default to False unless you're hosting an S3 compatible service"` + AWSAccessKeyID string `help:"the access key id to use when authenticating S3"` AWSSecretAccessKey string `help:"the secret access key id to use when authenticating S3"` @@ -93,6 +99,8 @@ func NewMailroomConfig() *Config { S3Region: "us-east-1", S3MediaBucket: "mailroom-media", S3MediaPrefix: "/media/", + S3SessionBucket: "mailroom-sessions", + S3SessionPrefix: "/", S3DisableSSL: false, S3ForcePathStyle: false, AWSAccessKeyID: "", diff --git a/config/config_test.go b/config/config_test.go index 4bb476ba2..7ab175e34 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -5,11 +5,15 @@ import ( "testing" "github.com/nyaruka/mailroom/config" + "github.com/nyaruka/mailroom/testsuite" "github.com/stretchr/testify/assert" ) func TestParseDisallowedNetworks(t *testing.T) { + // this is only here because this is the first test run.. should find a better way to ensure DB is in correct state for first test that needs it + testsuite.Reset() + cfg := config.NewMailroomConfig() privateNetwork1 := &net.IPNet{IP: net.IPv4(10, 0, 0, 0).To4(), Mask: net.CIDRMask(8, 32)} diff --git a/core/goflow/engine.go b/core/goflow/engine.go index 1d750d015..816b83dba 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/gocommon/uuids" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/engine" "github.com/nyaruka/goflow/services/webhooks" @@ -46,22 +45,22 @@ func RegisterAirtimeServiceFactory(factory engine.AirtimeServiceFactory) { } // Engine returns the global engine instance for use with real sessions -func Engine() flows.Engine { +func Engine(cfg *config.Config) flows.Engine { engInit.Do(func() { webhookHeaders := map[string]string{ - "User-Agent": "RapidProMailroom/" + config.Mailroom.Version, + "User-Agent": "RapidProMailroom/" + cfg.Version, "X-Mailroom-Mode": "normal", } - httpClient, httpRetries, httpAccess := HTTP() + httpClient, httpRetries, httpAccess := HTTP(cfg) eng = engine.NewBuilder(). - WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, httpRetries, httpAccess, webhookHeaders, config.Mailroom.WebhooksMaxBodyBytes)). + WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, httpRetries, httpAccess, webhookHeaders, cfg.WebhooksMaxBodyBytes)). WithClassificationServiceFactory(classificationFactory). WithEmailServiceFactory(emailFactory). WithTicketServiceFactory(ticketFactory). WithAirtimeServiceFactory(airtimeFactory). - WithMaxStepsPerSprint(config.Mailroom.MaxStepsPerSprint). + WithMaxStepsPerSprint(cfg.MaxStepsPerSprint). Build() }) @@ -69,22 +68,22 @@ func Engine() flows.Engine { } // Simulator returns the global engine instance for use with simulated sessions -func Simulator() flows.Engine { +func Simulator(cfg *config.Config) flows.Engine { simulatorInit.Do(func() { webhookHeaders := map[string]string{ - "User-Agent": "RapidProMailroom/" + config.Mailroom.Version, + "User-Agent": "RapidProMailroom/" + cfg.Version, "X-Mailroom-Mode": "simulation", } - httpClient, _, httpAccess := HTTP() // don't do retries in simulator + httpClient, _, httpAccess := HTTP(cfg) // don't do retries in simulator simulator = engine.NewBuilder(). - WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, nil, httpAccess, webhookHeaders, config.Mailroom.WebhooksMaxBodyBytes)). + WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, nil, httpAccess, webhookHeaders, cfg.WebhooksMaxBodyBytes)). WithClassificationServiceFactory(classificationFactory). // simulated sessions do real classification WithEmailServiceFactory(simulatorEmailServiceFactory). // but faked emails WithTicketServiceFactory(simulatorTicketServiceFactory). // and faked tickets WithAirtimeServiceFactory(simulatorAirtimeServiceFactory). // and faked airtime transfers - WithMaxStepsPerSprint(config.Mailroom.MaxStepsPerSprint). + WithMaxStepsPerSprint(cfg.MaxStepsPerSprint). Build() }) @@ -110,7 +109,7 @@ type simulatorTicketService struct { } func (s *simulatorTicketService) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - return flows.NewTicket(flows.TicketUUID(uuids.New()), s.ticketer.Reference(), subject, body, ""), nil + return flows.OpenTicket(s.ticketer, subject, body), nil } func simulatorAirtimeServiceFactory(session flows.Session) (flows.AirtimeService, error) { diff --git a/core/goflow/engine_test.go b/core/goflow/engine_test.go index 31b650924..aa33562e3 100644 --- a/core/goflow/engine_test.go +++ b/core/goflow/engine_test.go @@ -10,6 +10,7 @@ import ( "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" @@ -17,7 +18,9 @@ import ( ) func TestEngineWebhook(t *testing.T) { - svc, err := goflow.Engine().Services().Webhook(nil) + _, rt, _, _ := testsuite.Get() + + svc, err := goflow.Engine(rt.Config).Services().Webhook(nil) assert.NoError(t, err) defer httpx.SetRequestor(httpx.DefaultRequestor) @@ -37,7 +40,9 @@ func TestEngineWebhook(t *testing.T) { } func TestSimulatorAirtime(t *testing.T) { - svc, err := goflow.Simulator().Services().Airtime(nil) + _, rt, _, _ := testsuite.Get() + + svc, err := goflow.Simulator(rt.Config).Services().Airtime(nil) assert.NoError(t, err) amounts := map[string]decimal.Decimal{"USD": decimal.RequireFromString(`1.50`)} @@ -55,25 +60,25 @@ func TestSimulatorAirtime(t *testing.T) { } func TestSimulatorTicket(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.ResetDB() + ctx, rt, db, _ := testsuite.Get() - ticketer, err := models.LookupTicketerByUUID(ctx, db, models.MailgunUUID) + ticketer, err := models.LookupTicketerByUUID(ctx, db, testdata.Mailgun.UUID) require.NoError(t, err) - svc, err := goflow.Simulator().Services().Ticket(nil, flows.NewTicketer(ticketer)) + svc, err := goflow.Simulator(rt.Config).Services().Ticket(nil, flows.NewTicketer(ticketer)) assert.NoError(t, err) ticket, err := svc.Open(nil, "New ticket", "Where are my cookies?", nil) assert.NoError(t, err) - assert.Equal(t, models.MailgunUUID, ticket.Ticketer.UUID) - assert.Equal(t, "New ticket", ticket.Subject) - assert.Equal(t, "Where are my cookies?", ticket.Body) + assert.Equal(t, testdata.Mailgun.UUID, ticket.Ticketer().UUID()) + assert.Equal(t, "New ticket", ticket.Subject()) + assert.Equal(t, "Where are my cookies?", ticket.Body()) } func TestSimulatorWebhook(t *testing.T) { - svc, err := goflow.Simulator().Services().Webhook(nil) + _, rt, _, _ := testsuite.Get() + + svc, err := goflow.Simulator(rt.Config).Services().Webhook(nil) assert.NoError(t, err) defer httpx.SetRequestor(httpx.DefaultRequestor) diff --git a/core/goflow/flows.go b/core/goflow/flows.go index 7d1b56619..91d217d98 100644 --- a/core/goflow/flows.go +++ b/core/goflow/flows.go @@ -22,8 +22,8 @@ func SpecVersion() *semver.Version { } // ReadFlow reads a flow from the given JSON definition, migrating it if necessary -func ReadFlow(data json.RawMessage) (flows.Flow, error) { - return definition.ReadFlow(data, MigrationConfig()) +func ReadFlow(cfg *config.Config, data json.RawMessage) (flows.Flow, error) { + return definition.ReadFlow(data, MigrationConfig(cfg)) } // CloneDefinition clones the given flow definition @@ -32,14 +32,14 @@ func CloneDefinition(data json.RawMessage, depMapping map[uuids.UUID]uuids.UUID) } // MigrateDefinition migrates the given flow definition to the specified version -func MigrateDefinition(data json.RawMessage, toVersion *semver.Version) (json.RawMessage, error) { - return migrations.MigrateToVersion(data, toVersion, MigrationConfig()) +func MigrateDefinition(cfg *config.Config, data json.RawMessage, toVersion *semver.Version) (json.RawMessage, error) { + return migrations.MigrateToVersion(data, toVersion, MigrationConfig(cfg)) } // MigrationConfig returns the migration configuration for flows -func MigrationConfig() *migrations.Config { +func MigrationConfig(cfg *config.Config) *migrations.Config { migConfInit.Do(func() { - migConf = &migrations.Config{BaseMediaURL: "https://" + config.Mailroom.AttachmentDomain} + migConf = &migrations.Config{BaseMediaURL: "https://" + cfg.AttachmentDomain} }) return migConf diff --git a/core/goflow/flows_test.go b/core/goflow/flows_test.go index 71fb5cd71..22f3772fe 100644 --- a/core/goflow/flows_test.go +++ b/core/goflow/flows_test.go @@ -9,6 +9,7 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/test" "github.com/nyaruka/mailroom/core/goflow" + "github.com/nyaruka/mailroom/testsuite" "github.com/Masterminds/semver" "github.com/stretchr/testify/assert" @@ -19,13 +20,15 @@ func TestSpecVersion(t *testing.T) { } func TestReadFlow(t *testing.T) { + _, rt, _, _ := testsuite.Get() + // try to read empty definition - flow, err := goflow.ReadFlow([]byte(`{}`)) + flow, err := goflow.ReadFlow(rt.Config, []byte(`{}`)) assert.Nil(t, flow) assert.EqualError(t, err, "unable to read flow header: field 'uuid' is required, field 'spec_version' is required") // read legacy definition - flow, err = goflow.ReadFlow([]byte(`{"flow_type": "M", "base_language": "eng", "action_sets": [], "metadata": {"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "Legacy"}}`)) + flow, err = goflow.ReadFlow(rt.Config, []byte(`{"flow_type": "M", "base_language": "eng", "action_sets": [], "metadata": {"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "Legacy"}}`)) assert.Nil(t, err) assert.Equal(t, assets.FlowUUID("502c3ee4-3249-4dee-8e71-c62070667d52"), flow.UUID()) assert.Equal(t, "Legacy", flow.Name()) @@ -33,7 +36,7 @@ func TestReadFlow(t *testing.T) { assert.Equal(t, flows.FlowTypeMessaging, flow.Type()) // read new definition - flow, err = goflow.ReadFlow([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`)) + flow, err = goflow.ReadFlow(rt.Config, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`)) assert.Nil(t, err) assert.Equal(t, assets.FlowUUID("502c3ee4-3249-4dee-8e71-c62070667d52"), flow.UUID()) assert.Equal(t, "New", flow.Name()) @@ -46,12 +49,14 @@ func TestCloneDefinition(t *testing.T) { cloned, err := goflow.CloneDefinition([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), nil) assert.NoError(t, err) - test.AssertEqualJSON(t, []byte(`{"uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), cloned, "cloned flow mismatch") + test.AssertEqualJSON(t, []byte(`{"uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), cloned) } func TestMigrateDefinition(t *testing.T) { + _, rt, _, _ := testsuite.Get() + // 13.0 > 13.1 - migrated, err := goflow.MigrateDefinition([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), semver.MustParse("13.1.0")) + migrated, err := goflow.MigrateDefinition(rt.Config, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), semver.MustParse("13.1.0")) assert.NoError(t, err) - test.AssertEqualJSON(t, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.1.0", "type": "messaging", "language": "eng", "nodes": []}`), migrated, "migrated flow mismatch") + test.AssertEqualJSON(t, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.1.0", "type": "messaging", "language": "eng", "nodes": []}`), migrated) } diff --git a/core/goflow/http.go b/core/goflow/http.go index 7e907a522..8f0fc7c35 100644 --- a/core/goflow/http.go +++ b/core/goflow/http.go @@ -17,7 +17,7 @@ var httpRetries *httpx.RetryConfig var httpAccess *httpx.AccessConfig // HTTP returns the configuration objects for HTTP calls from the engine and its services -func HTTP() (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) { +func HTTP(cfg *config.Config) (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) { httpInit.Do(func() { // customize the default golang transport t := http.DefaultTransport.(*http.Transport).Clone() @@ -30,16 +30,16 @@ func HTTP() (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) { httpClient = &http.Client{ Transport: t, - Timeout: time.Duration(config.Mailroom.WebhooksTimeout) * time.Millisecond, + Timeout: time.Duration(cfg.WebhooksTimeout) * time.Millisecond, } httpRetries = httpx.NewExponentialRetries( - time.Duration(config.Mailroom.WebhooksInitialBackoff)*time.Millisecond, - config.Mailroom.WebhooksMaxRetries, - config.Mailroom.WebhooksBackoffJitter, + time.Duration(cfg.WebhooksInitialBackoff)*time.Millisecond, + cfg.WebhooksMaxRetries, + cfg.WebhooksBackoffJitter, ) - disallowedIPs, disallowedNets, _ := config.Mailroom.ParseDisallowedNetworks() + disallowedIPs, disallowedNets, _ := cfg.ParseDisallowedNetworks() httpAccess = httpx.NewAccessConfig(10*time.Second, disallowedIPs, disallowedNets) }) return httpClient, httpRetries, httpAccess diff --git a/core/goflow/modifiers_test.go b/core/goflow/modifiers_test.go index 7830f99ad..1acbeb258 100644 --- a/core/goflow/modifiers_test.go +++ b/core/goflow/modifiers_test.go @@ -7,15 +7,15 @@ import ( "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/stretchr/testify/assert" ) func TestReadModifiers(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) // can read empty list @@ -47,7 +47,7 @@ func TestReadModifiers(t *testing.T) { assert.Equal(t, "language", mods[1].Type()) // modifier with missing asset or an error if allowMissing is false - mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + _, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ []byte(`{"type": "name", "name": "Bob"}`), []byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`), []byte(`{"type": "language", "language": "spa"}`), @@ -55,7 +55,7 @@ func TestReadModifiers(t *testing.T) { assert.EqualError(t, err, `error reading modifier: {"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}: no modifier to return because of missing assets`) // error if any modifier structurally invalid - mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ + _, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{ []byte(`{"type": "field", "value": "O"}`), []byte(`{"type": "language", "language": "spa"}`), }, goflow.ErrorOnMissing) diff --git a/core/handlers/airtime_transferred_test.go b/core/handlers/airtime_transferred_test.go index 4dd52b5a8..285a11e6a 100644 --- a/core/handlers/airtime_transferred_test.go +++ b/core/handlers/airtime_transferred_test.go @@ -7,8 +7,8 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/shopspring/decimal" ) @@ -193,79 +193,6 @@ var productsResponse = `[ } ]` -var transactionConfirmedResponse = `{ - "benefits": [ - { - "additional_information": null, - "amount": { - "base": 3, - "promotion_bonus": 0, - "total_excluding_tax": 3 - }, - "type": "CREDITS", - "unit": "USD", - "unit_type": "CURRENCY" - } - ], - "confirmation_date": "2021-03-24T20:05:06.111631000Z", - "confirmation_expiration_date": "2021-03-24T21:05:05.883561000Z", - "creation_date": "2021-03-24T20:05:05.883561000Z", - "credit_party_identifier": { - "mobile_number": "+593979123456" - }, - "external_id": "EX12345", - "id": 2237512891, - "prices": { - "retail": { - "amount": 4, - "fee": 0, - "unit": "USD", - "unit_type": "CURRENCY" - }, - "wholesale": { - "amount": 3.6, - "fee": 0, - "unit": "USD", - "unit_type": "CURRENCY" - } - }, - "product": { - "description": "", - "id": 6035, - "name": "3 USD", - "operator": { - "country": { - "iso_code": "ECU", - "name": "Ecuador", - "regions": null - }, - "id": 1596, - "name": "Claro Ecuador", - "regions": null - }, - "regions": null, - "service": { - "id": 1, - "name": "Mobile" - }, - "type": "FIXED_VALUE_RECHARGE" - }, - "promotions": null, - "rates": { - "base": 0.833333333333333, - "retail": 0.75, - "wholesale": 0.833333333333333 - }, - "status": { - "class": { - "id": 2, - "message": "CONFIRMED" - }, - "id": 20000, - "message": "CONFIRMED" - } -}` - var transactionRejectedResponse = `{ "benefits": [ { @@ -340,8 +267,9 @@ var transactionRejectedResponse = `{ }` func TestAirtimeTransferred(t *testing.T) { - testsuite.Reset() + _, _, db, _ := testsuite.Get() + defer testsuite.Reset() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -361,43 +289,43 @@ func TestAirtimeTransferred(t *testing.T) { }, })) - testsuite.DB().MustExec(`UPDATE orgs_org SET config = '{"dtone_key": "key123", "dtone_secret": "sesame"}'::jsonb WHERE id = $1`, models.Org1) + db.MustExec(`UPDATE orgs_org SET config = '{"dtone_key": "key123", "dtone_secret": "sesame"}'::jsonb WHERE id = $1`, testdata.Org1.ID) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewTransferAirtime(handlers.NewActionUUID(), map[string]decimal.Decimal{"USD": decimal.RequireFromString(`3.50`)}, "Transfer"), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from airtime_airtimetransfer where org_id = $1 AND contact_id = $2 AND status = 'S'`, - Args: []interface{}{models.Org1, models.CathyID}, + Args: []interface{}{testdata.Org1.ID, testdata.Cathy.ID}, Count: 1, }, { SQL: `select count(*) from request_logs_httplog where org_id = $1 AND airtime_transfer_id IS NOT NULL AND is_error = FALSE AND url LIKE 'https://dvs-api.dtone.com/v1/%'`, - Args: []interface{}{models.Org1}, + Args: []interface{}{testdata.Org1.ID}, Count: 3, }, }, }, { Actions: handlers.ContactActionMap{ - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewTransferAirtime(handlers.NewActionUUID(), map[string]decimal.Decimal{"USD": decimal.RequireFromString(`3.50`)}, "Transfer"), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from airtime_airtimetransfer where org_id = $1 AND contact_id = $2 AND status = 'F'`, - Args: []interface{}{models.Org1, models.GeorgeID}, + Args: []interface{}{testdata.Org1.ID, testdata.George.ID}, Count: 1, }, { SQL: `select count(*) from request_logs_httplog where org_id = $1 AND airtime_transfer_id IS NOT NULL AND is_error = TRUE AND url LIKE 'https://dvs-api.dtone.com/v1/%'`, - Args: []interface{}{models.Org1}, + Args: []interface{}{testdata.Org1.ID}, Count: 1, }, }, diff --git a/core/handlers/base_test.go b/core/handlers/base_test.go index 4dc1c5de3..e19104caf 100644 --- a/core/handlers/base_test.go +++ b/core/handlers/base_test.go @@ -17,7 +17,9 @@ import ( "github.com/nyaruka/goflow/flows/triggers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/runner" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" @@ -25,9 +27,9 @@ import ( "github.com/stretchr/testify/require" ) -type ContactActionMap map[models.ContactID][]flows.Action -type ContactMsgMap map[models.ContactID]*flows.MsgIn -type ContactModifierMap map[models.ContactID][]flows.Modifier +type ContactActionMap map[*testdata.Contact][]flows.Action +type ContactMsgMap map[*testdata.Contact]*flows.MsgIn +type ContactModifierMap map[*testdata.Contact][]flows.Modifier type modifyResult struct { Contact *flows.Contact `json:"contact"` @@ -43,7 +45,7 @@ type TestCase struct { SQLAssertions []SQLAssertion } -type Assertion func(t *testing.T, db *sqlx.DB, rc redis.Conn) error +type Assertion func(t *testing.T, rt *runtime.Runtime) error type SQLAssertion struct { SQL string @@ -82,11 +84,11 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow exits := make([]flows.Exit, len(tc.Actions)) exitNodes := make([]flows.Node, len(tc.Actions)) i = 0 - for cid, actions := range tc.Actions { + for contact, actions := range tc.Actions { cases[i] = routers.NewCase( uuids.New(), "has_any_word", - []string{fmt.Sprintf("%d", cid)}, + []string{fmt.Sprintf("%d", contact.ID)}, categoryUUIDs[i], ) @@ -99,7 +101,7 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow categories[i] = routers.NewCategory( categoryUUIDs[i], - fmt.Sprintf("Contact %d", cid), + fmt.Sprintf("Contact %d", contact.ID), exitUUIDs[i], ) @@ -157,19 +159,22 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow } func RunTestCases(t *testing.T, tcs []TestCase) { - models.FlushCache() + ctx, rt, db, _ := testsuite.Get() - db := testsuite.DB() - ctx := testsuite.CTX() - rp := testsuite.RP() + models.FlushCache() oa, err := models.GetOrgAssets(ctx, db, models.OrgID(1)) assert.NoError(t, err) // reuse id from one of our real flows - flowUUID := models.FavoritesFlowUUID + flowUUID := testdata.Favorites.UUID for i, tc := range tcs { + msgsByContactID := make(map[models.ContactID]*flows.MsgIn) + for contact, msg := range tc.Msgs { + msgsByContactID[contact.ID] = msg + } + // build our flow for this test case testFlow := createTestFlow(t, flowUUID, tc) flowDef, err := json.Marshal(testFlow) @@ -184,7 +189,7 @@ func RunTestCases(t *testing.T, tcs []TestCase) { options := runner.NewStartOptions() options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, session []*models.Session) error { for _, s := range session { - msg := tc.Msgs[s.ContactID()] + msg := msgsByContactID[s.ContactID()] if msg != nil { s.SetIncomingMsg(msg.ID(), "") } @@ -192,23 +197,24 @@ func RunTestCases(t *testing.T, tcs []TestCase) { return nil } options.TriggerBuilder = func(contact *flows.Contact) flows.Trigger { - msg := tc.Msgs[models.ContactID(contact.ID())] + msg := msgsByContactID[models.ContactID(contact.ID())] if msg == nil { return triggers.NewBuilder(oa.Env(), testFlow.Reference(), contact).Manual().Build() } return triggers.NewBuilder(oa.Env(), testFlow.Reference(), contact).Msg(msg).Build() } - _, err = runner.StartFlow(ctx, db, rp, oa, flow.(*models.Flow), []models.ContactID{models.CathyID, models.BobID, models.GeorgeID, models.AlexandriaID}, options) - assert.NoError(t, err) + for _, c := range []*testdata.Contact{testdata.Cathy, testdata.Bob, testdata.George, testdata.Alexandria} { + _, err := runner.StartFlow(ctx, rt, oa, flow.(*models.Flow), []models.ContactID{c.ID}, options) + require.NoError(t, err) + } results := make(map[models.ContactID]modifyResult) // create scenes for our contacts scenes := make([]*models.Scene, 0, len(tc.Modifiers)) - for contactID, mods := range tc.Modifiers { - - contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{contactID}) + for contact, mods := range tc.Modifiers { + contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{contact.ID}) assert.NoError(t, err) contact := contacts[0] @@ -236,11 +242,11 @@ func RunTestCases(t *testing.T, tcs []TestCase) { assert.NoError(t, err) for _, scene := range scenes { - err := models.HandleEvents(ctx, tx, rp, oa, scene, results[scene.ContactID()].Events) + err := models.HandleEvents(ctx, tx, rt.RP, oa, scene, results[scene.ContactID()].Events) assert.NoError(t, err) } - err = models.ApplyEventPreCommitHooks(ctx, tx, rp, oa, scenes) + err = models.ApplyEventPreCommitHooks(ctx, tx, rt.RP, oa, scenes) assert.NoError(t, err) err = tx.Commit() @@ -249,23 +255,22 @@ func RunTestCases(t *testing.T, tcs []TestCase) { tx, err = db.BeginTxx(ctx, nil) assert.NoError(t, err) - err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, scenes) + err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, scenes) assert.NoError(t, err) err = tx.Commit() assert.NoError(t, err) + time.Sleep(500 * time.Millisecond) + // now check our assertions - time.Sleep(1 * time.Second) - for ii, a := range tc.SQLAssertions { - testsuite.AssertQueryCount(t, db, a.SQL, a.Args, a.Count, "%d:%d: mismatch in expected count for query: %s", i, ii, a.SQL) + for j, a := range tc.SQLAssertions { + testsuite.AssertQuery(t, db, a.SQL, a.Args...).Returns(a.Count, "%d:%d: mismatch in expected count for query: %s", i, j, a.SQL) } - rc := rp.Get() - for ii, a := range tc.Assertions { - err := a(t, db, rc) - assert.NoError(t, err, "%d: %d error checking assertion", i, ii) + for j, a := range tc.Assertions { + err := a(t, rt) + assert.NoError(t, err, "%d:%d error checking assertion", i, j) } - rc.Close() } } diff --git a/core/handlers/broadcast_created_test.go b/core/handlers/broadcast_created_test.go index a3fa521e2..456b487d8 100644 --- a/core/handlers/broadcast_created_test.go +++ b/core/handlers/broadcast_created_test.go @@ -10,34 +10,37 @@ import ( "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/gomodule/redigo/redis" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" ) func TestBroadcastCreated(t *testing.T) { - testsuite.Reset() + defer testsuite.Reset() // TODO: test contacts, groups tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSendBroadcast(handlers.NewActionUUID(), "hello world", nil, nil, []urns.URN{urns.URN("tel:+12065551212")}, nil, nil, nil), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from flows_flowrun where contact_id = $1 AND is_active = FALSE", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, }, Assertions: []handlers.Assertion{ - func(t *testing.T, db *sqlx.DB, rc redis.Conn) error { + func(t *testing.T, rt *runtime.Runtime) error { + rc := rt.RP.Get() + defer rc.Close() + task, err := queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err) assert.NotNil(t, task) diff --git a/core/handlers/campaigns_test.go b/core/handlers/campaigns_test.go index 2c58a0426..ed28d533d 100644 --- a/core/handlers/campaigns_test.go +++ b/core/handlers/campaigns_test.go @@ -8,14 +8,14 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestCampaigns(t *testing.T) { testsuite.Reset() - doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors") + doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors") joined := assets.NewFieldReference("joined", "Joined") // insert an event on our campaign that is based on created_on @@ -23,43 +23,43 @@ func TestCampaigns(t *testing.T) { `INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour, campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode) VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I')`, - uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID) + uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID) // insert an event on our campaign that is based on last_seen_on testsuite.DB().MustExec( `INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour, campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode) VALUES(TRUE, NOW(), NOW(), $1, 2, 'D', 'F', -1, $2, 1, 1, $3, $4, 'I')`, - uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.LastSeenOnFieldID) + uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.LastSeenOnField.ID) // init their values testsuite.DB().MustExec( `update contacts_contact set fields = fields - '8c1c1256-78d6-4a5b-9f1c-1761d5728251' - WHERE id = $1`, models.CathyID) + WHERE id = $1`, testdata.Cathy.ID) testsuite.DB().MustExec( `update contacts_contact set fields = fields || '{"8c1c1256-78d6-4a5b-9f1c-1761d5728251": { "text": "2029-09-15T12:00:00+00:00", "datetime": "2029-09-15T12:00:00+00:00" }}'::jsonb - WHERE id = $1`, models.BobID) + WHERE id = $1`, testdata.Bob.ID) tcs := []handlers.TestCase{ { Msgs: handlers.ContactMsgMap{ - models.CathyID: flows.NewMsgIn(flows.MsgUUID(uuids.New()), models.CathyURN, nil, "Hi there", nil), + testdata.Cathy: flows.NewMsgIn(flows.MsgUUID(uuids.New()), testdata.Cathy.URN, nil, "Hi there", nil), }, Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false), actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}), actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"), actions.NewSetContactField(handlers.NewActionUUID(), joined, ""), }, - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}), actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"), actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}), actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"), actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false), @@ -68,17 +68,17 @@ func TestCampaigns(t *testing.T) { SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`, - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 2, }, { SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`, - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 3, }, { SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`, - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 0, }, }, diff --git a/core/handlers/contact_field_changed_test.go b/core/handlers/contact_field_changed_test.go index 5b208bf3a..000806e6f 100644 --- a/core/handlers/contact_field_changed_test.go +++ b/core/handlers/contact_field_changed_test.go @@ -7,38 +7,40 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactFieldChanged(t *testing.T) { + _, _, db, _ := testsuite.Get() + + defer testsuite.Reset() + gender := assets.NewFieldReference("gender", "Gender") age := assets.NewFieldReference("age", "Age") - db := testsuite.DB() - // populate some field values on alexandria - db.Exec(`UPDATE contacts_contact SET fields = '{"903f51da-2717-47c7-a0d3-f2f32877013d": {"text":"34"}, "3a5891e4-756e-4dc9-8e12-b7a766168824": {"text":"female"}}' WHERE id = $1`, models.AlexandriaID) + db.MustExec(`UPDATE contacts_contact SET fields = '{"903f51da-2717-47c7-a0d3-f2f32877013d": {"text":"34"}, "3a5891e4-756e-4dc9-8e12-b7a766168824": {"text":"female"}}' WHERE id = $1`, testdata.Alexandria.ID) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"), actions.NewSetContactField(handlers.NewActionUUID(), gender, "Female"), actions.NewSetContactField(handlers.NewActionUUID(), age, ""), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"), actions.NewSetContactField(handlers.NewActionUUID(), gender, ""), actions.NewSetContactField(handlers.NewActionUUID(), age, "40"), }, - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewSetContactField(handlers.NewActionUUID(), gender, ""), actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"), actions.NewSetContactField(handlers.NewActionUUID(), age, "Old"), }, - models.AlexandriaID: []flows.Action{ + testdata.Alexandria: []flows.Action{ actions.NewSetContactField(handlers.NewActionUUID(), age, ""), actions.NewSetContactField(handlers.NewActionUUID(), gender, ""), }, @@ -46,42 +48,42 @@ func TestContactFieldChanged(t *testing.T) { SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Female"}'::jsonb`, - Args: []interface{}{models.CathyID, models.GenderFieldUUID}, + Args: []interface{}{testdata.Cathy.ID, testdata.GenderField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`, - Args: []interface{}{models.CathyID, models.AgeFieldUUID}, + Args: []interface{}{testdata.Cathy.ID, testdata.AgeField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`, - Args: []interface{}{models.GeorgeID, models.GenderFieldUUID}, + Args: []interface{}{testdata.George.ID, testdata.GenderField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"40", "number": 40}'::jsonb`, - Args: []interface{}{models.GeorgeID, models.AgeFieldUUID}, + Args: []interface{}{testdata.George.ID, testdata.AgeField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Male"}'::jsonb`, - Args: []interface{}{models.BobID, models.GenderFieldUUID}, + Args: []interface{}{testdata.Bob.ID, testdata.GenderField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Old"}'::jsonb`, - Args: []interface{}{models.BobID, models.AgeFieldUUID}, + Args: []interface{}{testdata.Bob.ID, testdata.AgeField.UUID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`, - Args: []interface{}{models.BobID, "unknown"}, + Args: []interface{}{testdata.Bob.ID, "unknown"}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND fields = '{}'`, - Args: []interface{}{models.AlexandriaID}, + Args: []interface{}{testdata.Alexandria.ID}, Count: 1, }, }, diff --git a/core/handlers/contact_groups_changed_test.go b/core/handlers/contact_groups_changed_test.go index ed70bdea3..70e84cb39 100644 --- a/core/handlers/contact_groups_changed_test.go +++ b/core/handlers/contact_groups_changed_test.go @@ -7,23 +7,26 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactGroupsChanged(t *testing.T) { - doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors") - testers := assets.NewGroupReference(models.TestersGroupUUID, "Testers") + defer testsuite.Reset() + + doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors") + testers := assets.NewGroupReference(testdata.TestersGroup.UUID, "Testers") tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}), actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}), actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false), actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{testers}), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false), actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{testers}), }, @@ -31,22 +34,22 @@ func TestContactGroupsChanged(t *testing.T) { SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2", - Args: []interface{}{models.CathyID, models.DoctorsGroupID}, + Args: []interface{}{testdata.Cathy.ID, testdata.DoctorsGroup.ID}, Count: 0, }, { SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2", - Args: []interface{}{models.CathyID, models.TestersGroupID}, + Args: []interface{}{testdata.Cathy.ID, testdata.TestersGroup.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2", - Args: []interface{}{models.GeorgeID, models.TestersGroupID}, + Args: []interface{}{testdata.George.ID, testdata.TestersGroup.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2", - Args: []interface{}{models.BobID, models.TestersGroupID}, + Args: []interface{}{testdata.Bob.ID, testdata.TestersGroup.ID}, Count: 0, }, }, diff --git a/core/handlers/contact_language_changed_test.go b/core/handlers/contact_language_changed_test.go index ad1340f5d..f04703ba0 100644 --- a/core/handlers/contact_language_changed_test.go +++ b/core/handlers/contact_language_changed_test.go @@ -6,43 +6,46 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactLanguageChanged(t *testing.T) { + defer testsuite.Reset() + tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSetContactLanguage(handlers.NewActionUUID(), "fra"), actions.NewSetContactLanguage(handlers.NewActionUUID(), "eng"), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewSetContactLanguage(handlers.NewActionUUID(), "spa"), }, - models.AlexandriaID: []flows.Action{ + testdata.Alexandria: []flows.Action{ actions.NewSetContactLanguage(handlers.NewActionUUID(), ""), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from contacts_contact where id = $1 and language = 'eng'", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contact where id = $1 and language = 'spa'", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contact where id = $1 and language is NULL;", - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contact where id = $1 and language is NULL;", - Args: []interface{}{models.AlexandriaID}, + Args: []interface{}{testdata.Alexandria.ID}, Count: 1, }, }, diff --git a/core/handlers/contact_name_changed_test.go b/core/handlers/contact_name_changed_test.go index a298ca537..169e01dff 100644 --- a/core/handlers/contact_name_changed_test.go +++ b/core/handlers/contact_name_changed_test.go @@ -6,31 +6,34 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactNameChanged(t *testing.T) { + defer testsuite.Reset() + tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSetContactName(handlers.NewActionUUID(), "Fred"), actions.NewSetContactName(handlers.NewActionUUID(), "Tarzan"), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewSetContactName(handlers.NewActionUUID(), "Geoff Newman"), }, - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewSetContactName(handlers.NewActionUUID(), ""), }, - models.AlexandriaID: []flows.Action{ + testdata.Alexandria: []flows.Action{ actions.NewSetContactName(handlers.NewActionUUID(), "😃234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from contacts_contact where name = 'Tarzan' and id = $1", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { @@ -39,17 +42,17 @@ func TestContactNameChanged(t *testing.T) { }, { SQL: "select count(*) from contacts_contact where name IS NULL and id = $1", - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contact where name = 'Geoff Newman' and id = $1", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contact where name = '😃2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678' and id = $1", - Args: []interface{}{models.AlexandriaID}, + Args: []interface{}{testdata.Alexandria.ID}, Count: 1, }, }, diff --git a/core/handlers/contact_status_changed_test.go b/core/handlers/contact_status_changed_test.go index d5831f1a5..a2d3cafa5 100644 --- a/core/handlers/contact_status_changed_test.go +++ b/core/handlers/contact_status_changed_test.go @@ -6,55 +6,56 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/modifiers" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactStatusChanged(t *testing.T) { + _, _, db, _ := testsuite.Get() - db := testsuite.DB() + defer testsuite.Reset() // make sure cathyID contact is active - db.Exec(`UPDATE contacts_contact SET status = 'A' WHERE id = $1`, models.CathyID) + db.Exec(`UPDATE contacts_contact SET status = 'A' WHERE id = $1`, testdata.Cathy.ID) tcs := []handlers.TestCase{ { Modifiers: handlers.ContactModifierMap{ - models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusBlocked)}, + testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusBlocked)}, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from contacts_contact where id = $1 AND status = 'B'`, - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, }, }, { Modifiers: handlers.ContactModifierMap{ - models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusStopped)}, + testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusStopped)}, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from contacts_contact where id = $1 AND status = 'S'`, - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, }, }, { Modifiers: handlers.ContactModifierMap{ - models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusActive)}, + testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusActive)}, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from contacts_contact where id = $1 AND status = 'A'`, - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: `select count(*) from contacts_contact where id = $1 AND status = 'A'`, - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, }, diff --git a/core/handlers/contact_urns_changed_test.go b/core/handlers/contact_urns_changed_test.go index e2edae4e9..285e6e5f3 100644 --- a/core/handlers/contact_urns_changed_test.go +++ b/core/handlers/contact_urns_changed_test.go @@ -8,50 +8,51 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestContactURNsChanged(t *testing.T) { - db := testsuite.DB() + _, _, db, _ := testsuite.Get() + + defer testsuite.Reset() // add a URN to george that cathy will steal - testdata.InsertContactURN(t, db, models.Org1, models.GeorgeID, urns.URN("tel:+12065551212"), 100) + testdata.InsertContactURN(db, testdata.Org1, testdata.George, urns.URN("tel:+12065551212"), 100) now := time.Now() tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "12065551212"), actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "12065551212"), actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "11551"), actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "+16055741111"), }, - models.GeorgeID: []flows.Action{}, + testdata.George: []flows.Action{}, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'telegram' and path = '11551' and priority = 998", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'tel' and path = '+12065551212' and priority = 999 and identity = 'tel:+12065551212'", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'tel' and path = '+16055741111' and priority = 1000", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, // evan lost his 206 URN { SQL: "select count(*) from contacts_contacturn where contact_id = $1", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 1, }, // two contacts updated, both cathy and evan since their URNs changed diff --git a/core/handlers/input_labels_added_test.go b/core/handlers/input_labels_added_test.go index 94428d3f8..06e098c9a 100644 --- a/core/handlers/input_labels_added_test.go +++ b/core/handlers/input_labels_added_test.go @@ -14,31 +14,33 @@ import ( ) func TestInputLabelsAdded(t *testing.T) { - db := testsuite.DB() + _, _, db, _ := testsuite.Get() + + defer testsuite.Reset() reporting := assets.NewLabelReference(assets.LabelUUID("ebc4dedc-91c4-4ed4-9dd6-daa05ea82698"), "Reporting") testing := assets.NewLabelReference(assets.LabelUUID("a6338cdc-7938-4437-8b05-2d5d785e3a08"), "Testing") - msg1 := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start") - msg2 := testdata.InsertIncomingMsg(t, db, models.Org1, models.BobID, models.BobURN, models.BobURNID, "start") + msg1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled) + msg2 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Bob, "start", models.MsgStatusHandled) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}), actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{testing}), actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}), }, - models.BobID: []flows.Action{}, - models.GeorgeID: []flows.Action{ + testdata.Bob: []flows.Action{}, + testdata.George: []flows.Action{ actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{testing}), actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}), }, }, Msgs: handlers.ContactMsgMap{ - models.CathyID: msg1, - models.BobID: msg2, + testdata.Cathy: msg1, + testdata.Bob: msg2, }, SQLAssertions: []handlers.SQLAssertion{ { @@ -53,7 +55,7 @@ func TestInputLabelsAdded(t *testing.T) { }, { SQL: "select count(*) from msgs_msg_labels l JOIN msgs_msg m ON l.msg_id = m.id WHERE m.contact_id = $1", - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 0, }, }, diff --git a/core/handlers/ivr_created.go b/core/handlers/ivr_created.go index 93d434fa2..4c2f63fa0 100644 --- a/core/handlers/ivr_created.go +++ b/core/handlers/ivr_created.go @@ -38,10 +38,7 @@ func handleIVRCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mode return nil } - msg, err := models.NewOutgoingIVR(oa.OrgID(), conn, event.Msg, event.CreatedOn()) - if err != nil { - return errors.Wrapf(err, "error creating outgoing ivr say: %s", event.Msg.Text()) - } + msg := models.NewOutgoingIVR(oa.OrgID(), conn, event.Msg, event.CreatedOn()) // register to have this message committed scene.AppendToEventPreCommitHook(hooks.CommitIVRHook, msg) diff --git a/core/handlers/msg_created_test.go b/core/handlers/msg_created_test.go index 7a1b2ab5b..fcfb22ff0 100644 --- a/core/handlers/msg_created_test.go +++ b/core/handlers/msg_created_test.go @@ -20,23 +20,24 @@ import ( ) func TestMsgCreated(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Get() + + defer testsuite.Reset() config.Mailroom.AttachmentDomain = "foo.bar.com" defer func() { config.Mailroom.AttachmentDomain = "" }() // add a URN for cathy so we can test all urn sends - testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 10) + testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 10) // delete all URNs for bob - db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, models.BobID) + db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, testdata.Bob.ID) // change alexandrias URN to a twitter URN and set her language to eng so that a template gets used for her - db.MustExec(`UPDATE contacts_contacturn SET identity = 'twitter:12345', path='12345', scheme='twitter' WHERE contact_id = $1`, models.AlexandriaID) - db.MustExec(`UPDATE contacts_contact SET language='eng' WHERE id = $1`, models.AlexandriaID) + db.MustExec(`UPDATE contacts_contacturn SET identity = 'twitter:12345', path='12345', scheme='twitter' WHERE contact_id = $1`, testdata.Alexandria.ID) + db.MustExec(`UPDATE contacts_contact SET language='eng' WHERE id = $1`, testdata.Alexandria.ID) - msg1 := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start") + msg1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled) templateAction := actions.NewSendMsg(handlers.NewActionUUID(), "Template time", nil, nil, false) templateAction.Templating = &actions.Templating{ @@ -48,45 +49,45 @@ func TestMsgCreated(t *testing.T) { tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, []string{"yes", "no"}, true), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "Hello Attachments", []string{"image/png:/images/image1.png"}, nil, true), }, - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "No URNs", nil, nil, false), }, - models.AlexandriaID: []flows.Action{ + testdata.Alexandria: []flows.Action{ templateAction, }, }, Msgs: handlers.ContactMsgMap{ - models.CathyID: msg1, + testdata.Cathy: msg1, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='Hello World' AND contact_id = $1 AND metadata = $2 AND response_to_id = $3 AND high_priority = TRUE", - Args: []interface{}{models.CathyID, `{"quick_replies":["yes","no"]}`, msg1.ID()}, + Args: []interface{}{testdata.Cathy.ID, `{"quick_replies":["yes","no"]}`, msg1.ID()}, Count: 2, }, { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='Hello Attachments' AND contact_id = $1 AND attachments[1] = $2 AND status = 'Q' AND high_priority = FALSE", - Args: []interface{}{models.GeorgeID, "image/png:https://foo.bar.com/images/image1.png"}, + Args: []interface{}{testdata.George.ID, "image/png:https://foo.bar.com/images/image1.png"}, Count: 1, }, { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id=$1;", - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 0, }, { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id = $1 AND text = $2 AND metadata = $3 AND direction = 'O' AND status = 'Q' AND channel_id = $4", Args: []interface{}{ - models.AlexandriaID, + testdata.Alexandria.ID, `Hi Alexandia, are you still experiencing problems with tooth?`, - `{"templating":{"template":{"uuid":"9c22b594-fcab-4b29-9bcb-ce4404894a80","name":"revive_issue"},"language":"eng","country":"US","variables":["Alexandia","tooth"]}}`, - models.TwitterChannelID, + `{"templating":{"template":{"uuid":"9c22b594-fcab-4b29-9bcb-ce4404894a80","name":"revive_issue"},"language":"eng","country":"US","variables":["Alexandia","tooth"],"namespace":"2d40b45c_25cd_4965_9019_f05d0124c5fa"}}`, + testdata.TwitterChannel.ID, }, Count: 1, }, @@ -100,12 +101,12 @@ func TestMsgCreated(t *testing.T) { defer rc.Close() // Cathy should have 1 batch of queued messages at high priority - count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", models.TwilioChannelUUID))) + count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", testdata.TwilioChannel.UUID))) assert.NoError(t, err) assert.Equal(t, 1, count) // One bulk for George - count, err = redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/0", models.TwilioChannelUUID))) + count, err = redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/0", testdata.TwilioChannel.UUID))) assert.NoError(t, err) assert.Equal(t, 1, count) } @@ -115,19 +116,19 @@ func TestNoTopup(t *testing.T) { db := testsuite.DB() // no more credits - db.MustExec(`UPDATE orgs_topup SET credits = 0 WHERE org_id = $1`, models.Org1) + db.MustExec(`UPDATE orgs_topup SET credits = 0 WHERE org_id = $1`, testdata.Org1.ID) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "No Topup", nil, nil, false), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='No Topup' AND contact_id = $1 AND status = 'Q'", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, }, @@ -142,28 +143,28 @@ func TestNewURN(t *testing.T) { db := testsuite.DB() // switch our twitter channel to telegram - telegramUUID := models.TwitterChannelUUID - telegramID := models.TwitterChannelID + telegramUUID := testdata.TwitterChannel.UUID + telegramID := testdata.TwitterChannel.ID db.MustExec( `UPDATE channels_channel SET channel_type = 'TG', name = 'Telegram', schemes = ARRAY['telegram'] WHERE uuid = $1`, telegramUUID, ) // give George a URN that Bob will steal - testdata.InsertContactURN(t, db, models.Org1, models.GeorgeID, urns.URN("telegram:67890"), 1) + testdata.InsertContactURN(db, testdata.Org1, testdata.George, urns.URN("telegram:67890"), 1) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ // brand new URN on Cathy - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "12345"), actions.NewSetContactChannel(handlers.NewActionUUID(), assets.NewChannelReference(telegramUUID, "telegram")), actions.NewSendMsg(handlers.NewActionUUID(), "Cathy Message", nil, nil, false), }, // Bob is stealing a URN previously assigned to George - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "67890"), actions.NewSetContactChannel(handlers.NewActionUUID(), assets.NewChannelReference(telegramUUID, "telegram")), actions.NewSendMsg(handlers.NewActionUUID(), "Bob Message", nil, nil, false), @@ -184,7 +185,7 @@ func TestNewURN(t *testing.T) { u.identity = $2 AND m.channel_id = $3 AND u.channel_id IS NULL`, - Args: []interface{}{models.CathyID, "telegram:12345", telegramID}, + Args: []interface{}{testdata.Cathy.ID, "telegram:12345", telegramID}, Count: 1, }, { @@ -201,7 +202,7 @@ func TestNewURN(t *testing.T) { u.identity = $2 AND m.channel_id = $3 AND u.channel_id IS NULL`, - Args: []interface{}{models.BobID, "telegram:67890", telegramID}, + Args: []interface{}{testdata.Bob.ID, "telegram:67890", telegramID}, Count: 1, }, }, diff --git a/core/handlers/msg_received_test.go b/core/handlers/msg_received_test.go index 60a1736a2..d83f9e790 100644 --- a/core/handlers/msg_received_test.go +++ b/core/handlers/msg_received_test.go @@ -15,33 +15,34 @@ import ( ) func TestMsgReceived(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Get() + + defer testsuite.Reset() now := time.Now() tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, nil, false), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "Hello world", nil, nil, false), }, }, Msgs: handlers.ContactMsgMap{ - models.CathyID: testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start"), + testdata.Cathy: testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled), }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "SELECT COUNT(*) FROM contacts_contact WHERE id = $1 AND last_seen_on > $2", - Args: []interface{}{models.CathyID, now}, + Args: []interface{}{testdata.Cathy.ID, now}, Count: 1, }, { SQL: "SELECT COUNT(*) FROM contacts_contact WHERE id = $1 AND last_seen_on IS NULL", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 1, }, }, @@ -49,17 +50,17 @@ func TestMsgReceived(t *testing.T) { { FlowType: flows.FlowTypeMessagingOffline, Actions: handlers.ContactActionMap{ - models.BobID: []flows.Action{ + testdata.Bob: []flows.Action{ actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, nil, false), }, }, Msgs: handlers.ContactMsgMap{ - models.BobID: flows.NewMsgIn(flows.MsgUUID(uuids.New()), urns.NilURN, nil, "Hi offline", nil), + testdata.Bob: flows.NewMsgIn(flows.MsgUUID(uuids.New()), urns.NilURN, nil, "Hi offline", nil), }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'I'", - Args: []interface{}{models.BobID}, + Args: []interface{}{testdata.Bob.ID}, Count: 1, }, }, diff --git a/core/handlers/service_called_test.go b/core/handlers/service_called_test.go index b0f36c20e..03e8443ac 100644 --- a/core/handlers/service_called_test.go +++ b/core/handlers/service_called_test.go @@ -8,10 +8,12 @@ import ( "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/actions" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" ) func TestServiceCalled(t *testing.T) { + defer testsuite.Reset() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -29,19 +31,19 @@ func TestServiceCalled(t *testing.T) { }, })) - wit := assets.NewClassifierReference(models.WitUUID, "Wit Classifier") + wit := assets.NewClassifierReference(testdata.Wit.UUID, "Wit Classifier") tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewCallClassifier(handlers.NewActionUUID(), wit, "book me a flight", "flight"), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from request_logs_httplog where org_id = $1 AND is_error = FALSE AND classifier_id = $2 AND url = 'https://api.wit.ai/message?v=20200513&q=book+me+a+flight'`, - Args: []interface{}{models.Org1, models.WitID}, + Args: []interface{}{testdata.Org1.ID, testdata.Wit.ID}, Count: 1, }, }, diff --git a/core/handlers/session_triggered_test.go b/core/handlers/session_triggered_test.go index 795df4b67..19524c5b4 100644 --- a/core/handlers/session_triggered_test.go +++ b/core/handlers/session_triggered_test.go @@ -11,32 +11,30 @@ import ( "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/gomodule/redigo/redis" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" ) func TestSessionTriggered(t *testing.T) { - testsuite.Reset() - testsuite.ResetRP() - models.FlushCache() - db := testsuite.DB() - ctx := testsuite.CTX() + ctx, _, db, _ := testsuite.Get() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + defer testsuite.Reset() + + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) - simpleFlow, err := oa.FlowByID(models.SingleMessageFlowID) + simpleFlow, err := oa.FlowByID(testdata.SingleMessage.ID) assert.NoError(t, err) contactRef := &flows.ContactReference{ - UUID: models.GeorgeUUID, + UUID: testdata.George.UUID, } groupRef := &assets.GroupReference{ - UUID: models.TestersGroupUUID, + UUID: testdata.TestersGroup.UUID, } uuids.SetGenerator(uuids.NewSeededGenerator(1234567)) @@ -45,34 +43,37 @@ func TestSessionTriggered(t *testing.T) { tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewStartSession(handlers.NewActionUUID(), simpleFlow.FlowReference(), nil, []*flows.ContactReference{contactRef}, []*assets.GroupReference{groupRef}, nil, true), }, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: "select count(*) from flows_flowrun where contact_id = $1 AND is_active = FALSE", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from flows_flowstart where org_id = 1 AND start_type = 'F' AND flow_id = $1 AND status = 'P' AND parent_summary IS NOT NULL AND session_history IS NOT NULL;", - Args: []interface{}{models.SingleMessageFlowID}, + Args: []interface{}{testdata.SingleMessage.ID}, Count: 1, }, { SQL: "select count(*) from flows_flowstart_contacts where id = 1 AND contact_id = $1", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 1, }, { SQL: "select count(*) from flows_flowstart_groups where id = 1 AND contactgroup_id = $1", - Args: []interface{}{models.TestersGroupID}, + Args: []interface{}{testdata.TestersGroup.ID}, Count: 1, }, }, Assertions: []handlers.Assertion{ - func(t *testing.T, db *sqlx.DB, rc redis.Conn) error { + func(t *testing.T, rt *runtime.Runtime) error { + rc := rt.RP.Get() + defer rc.Close() + task, err := queue.PopNextTask(rc, queue.BatchQueue) assert.NoError(t, err) assert.NotNil(t, task) @@ -80,8 +81,8 @@ func TestSessionTriggered(t *testing.T) { err = json.Unmarshal(task.Task, &start) assert.NoError(t, err) assert.True(t, start.CreateContact()) - assert.Equal(t, []models.ContactID{models.GeorgeID}, start.ContactIDs()) - assert.Equal(t, []models.GroupID{models.TestersGroupID}, start.GroupIDs()) + assert.Equal(t, []models.ContactID{testdata.George.ID}, start.ContactIDs()) + assert.Equal(t, []models.GroupID{testdata.TestersGroup.ID}, start.GroupIDs()) assert.Equal(t, simpleFlow.ID(), start.FlowID()) assert.JSONEq(t, `{"parent_uuid":"39a9f95e-3641-4d19-95e0-ed866f27c829", "ancestors":1, "ancestors_since_input":1}`, string(start.SessionHistory())) return nil @@ -94,16 +95,12 @@ func TestSessionTriggered(t *testing.T) { } func TestQuerySessionTriggered(t *testing.T) { - testsuite.Reset() - testsuite.ResetRP() - models.FlushCache() - db := testsuite.DB() - ctx := testsuite.CTX() + ctx, _, db, rp := testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) - favoriteFlow, err := oa.FlowByID(models.FavoritesFlowID) + favoriteFlow, err := oa.FlowByID(testdata.Favorites.ID) assert.NoError(t, err) sessionAction := actions.NewStartSession(handlers.NewActionUUID(), favoriteFlow.FlowReference(), nil, nil, nil, nil, true) @@ -112,17 +109,20 @@ func TestQuerySessionTriggered(t *testing.T) { tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{sessionAction}, + testdata.Cathy: []flows.Action{sessionAction}, }, SQLAssertions: []handlers.SQLAssertion{ { SQL: `select count(*) from flows_flowstart where flow_id = $1 AND start_type = 'F' AND status = 'P' AND query = 'name ~ "Cathy"' AND parent_summary IS NOT NULL;`, - Args: []interface{}{models.FavoritesFlowID}, + Args: []interface{}{testdata.Favorites.ID}, Count: 1, }, }, Assertions: []handlers.Assertion{ - func(t *testing.T, db *sqlx.DB, rc redis.Conn) error { + func(t *testing.T, rt *runtime.Runtime) error { + rc := rp.Get() + defer rc.Close() + task, err := queue.PopNextTask(rc, queue.BatchQueue) assert.NoError(t, err) assert.NotNil(t, task) diff --git a/core/handlers/ticket_opened.go b/core/handlers/ticket_opened.go index 03f244ba2..6a62f5494 100644 --- a/core/handlers/ticket_opened.go +++ b/core/handlers/ticket_opened.go @@ -28,6 +28,15 @@ func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mo return errors.Errorf("unable to find ticketer with UUID: %s", event.Ticket.Ticketer.UUID) } + var assigneeID models.UserID + if event.Ticket.Assignee != nil { + assignee := oa.UserByEmail(event.Ticket.Assignee.Email) + if assignee == nil { + return errors.Errorf("unable to find user with email: %s", event.Ticket.Assignee.Email) + } + assigneeID = assignee.ID() + } + ticket := models.NewTicket( event.Ticket.UUID, oa.OrgID(), @@ -36,6 +45,7 @@ func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mo event.Ticket.ExternalID, event.Ticket.Subject, event.Ticket.Body, + assigneeID, map[string]interface{}{ "contact-uuid": scene.Contact().UUID(), "contact-display": tickets.GetContactDisplay(oa.Env(), scene.Contact()), diff --git a/core/handlers/ticket_opened_test.go b/core/handlers/ticket_opened_test.go index 8d1e48d4e..54585e745 100644 --- a/core/handlers/ticket_opened_test.go +++ b/core/handlers/ticket_opened_test.go @@ -11,6 +11,7 @@ import ( "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" _ "github.com/nyaruka/mailroom/services/tickets/mailgun" _ "github.com/nyaruka/mailroom/services/tickets/zendesk" @@ -19,10 +20,9 @@ import ( ) func TestTicketOpened(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() + ctx, _, db, _ := testsuite.Get() + defer testsuite.Reset() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -45,51 +45,55 @@ func TestTicketOpened(t *testing.T) { })) // an existing ticket - cathyTicket := models.NewTicket(flows.TicketUUID(uuids.New()), models.Org1, models.CathyID, models.MailgunID, "748363", "Old Question", "Who?", nil) + cathyTicket := models.NewTicket(flows.TicketUUID(uuids.New()), testdata.Org1.ID, testdata.Cathy.ID, testdata.Mailgun.ID, "748363", "Old Question", "Who?", models.NilUserID, nil) err := models.InsertTickets(ctx, db, []*models.Ticket{cathyTicket}) require.NoError(t, err) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ - actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(models.MailgunUUID, "Mailgun (IT Support)"), "Need help", "Where are my cookies?", "Email Ticket"), + testdata.Cathy: []flows.Action{ + actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(testdata.Mailgun.UUID, "Mailgun (IT Support)"), "Need help", "Where are my cookies?", "Email Ticket"), }, - models.BobID: []flows.Action{ - actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(models.ZendeskUUID, "Zendesk (Nyaruka)"), "Interesting", "I've found some cookies", "Zen Ticket"), + testdata.Bob: []flows.Action{ + actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(testdata.Zendesk.UUID, "Zendesk (Nyaruka)"), "Interesting", "I've found some cookies", "Zen Ticket"), }, }, 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: []interface{}{models.CathyID, models.MailgunID}, + Args: []interface{}{testdata.Cathy.ID, testdata.Mailgun.ID}, Count: 2, }, { // and there's an HTTP log for that SQL: "select count(*) from request_logs_httplog where ticketer_id = $1", - Args: []interface{}{models.MailgunID}, + Args: []interface{}{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: []interface{}{models.MailgunID}, + Args: []interface{}{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: []interface{}{models.BobID, models.ZendeskID}, + Args: []interface{}{testdata.Bob.ID, testdata.Zendesk.ID}, Count: 1, }, { // and there's an HTTP log for that SQL: "select count(*) from request_logs_httplog where ticketer_id = $1", - Args: []interface{}{models.ZendeskID}, + Args: []interface{}{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: []interface{}{models.ZendeskID}, + Args: []interface{}{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/handlers/webhook_called_test.go b/core/handlers/webhook_called_test.go index f9d97212d..9fda89ef6 100644 --- a/core/handlers/webhook_called_test.go +++ b/core/handlers/webhook_called_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/flows" @@ -13,8 +13,9 @@ import ( ) func TestWebhookCalled(t *testing.T) { - testsuite.Reset() + _, _, db, _ := testsuite.Get() + defer testsuite.Reset() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -30,21 +31,21 @@ func TestWebhookCalled(t *testing.T) { })) // add a few resthooks - testsuite.DB().MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1);`) - testsuite.DB().MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'bar', 1, NOW(), NOW(), 1, 1);`) + db.MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1);`) + db.MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'bar', 1, NOW(), NOW(), 1, 1);`) // and a few targets - testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/', 1, 1, 1);`) - testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 2);`) - testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 1);`) + db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/', 1, 1, 1);`) + db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 2);`) + db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 1);`) tcs := []handlers.TestCase{ { Actions: handlers.ContactActionMap{ - models.CathyID: []flows.Action{ + testdata.Cathy: []flows.Action{ actions.NewCallResthook(handlers.NewActionUUID(), "foo", "foo"), }, - models.GeorgeID: []flows.Action{ + testdata.George: []flows.Action{ actions.NewCallResthook(handlers.NewActionUUID(), "foo", "foo"), actions.NewCallWebhook(handlers.NewActionUUID(), "GET", "http://rapidpro.io/?unsub=1", nil, "", ""), }, @@ -67,22 +68,22 @@ func TestWebhookCalled(t *testing.T) { }, { SQL: "select count(*) from api_webhookresult where contact_id = $1 AND status_code = 200", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from api_webhookresult where contact_id = $1 AND status_code = 410", - Args: []interface{}{models.CathyID}, + Args: []interface{}{testdata.Cathy.ID}, Count: 1, }, { SQL: "select count(*) from api_webhookresult where contact_id = $1", - Args: []interface{}{models.GeorgeID}, + Args: []interface{}{testdata.George.ID}, Count: 3, }, { SQL: "select count(*) from api_webhookevent where org_id = $1", - Args: []interface{}{models.Org1}, + Args: []interface{}{testdata.Org1.ID}, Count: 2, }, }, diff --git a/core/hooks/insert_tickets.go b/core/hooks/insert_tickets.go index b6bf164bf..b0c009c64 100644 --- a/core/hooks/insert_tickets.go +++ b/core/hooks/insert_tickets.go @@ -32,5 +32,17 @@ func (h *insertTicketsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Po return errors.Wrapf(err, "error inserting tickets") } + // generate opened events for each ticket + openEvents := make([]*models.TicketEvent, len(tickets)) + for i, ticket := range tickets { + openEvents[i] = models.NewTicketOpenedEvent(ticket, models.NilUserID, ticket.AssigneeID()) + } + + // and insert those too + err = models.InsertTicketEvents(ctx, tx, openEvents) + if err != nil { + return errors.Wrapf(err, "error inserting ticket opened events") + } + return nil } diff --git a/core/ivr/ivr.go b/core/ivr/ivr.go index fcbc0fb09..3738497dd 100644 --- a/core/ivr/ivr.go +++ b/core/ivr/ivr.go @@ -22,6 +22,7 @@ import ( "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/runner" + "github.com/nyaruka/mailroom/runtime" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" @@ -35,9 +36,6 @@ const ( NilCallID = CallID("") NilAttachment = utils.Attachment("") - // Our user agent - userAgent = "Mailroom/" - // ErrorMessage that is spoken to an IVR user if an error occurs ErrorMessage = "An error has occurred, please try again later." ) @@ -298,17 +296,17 @@ func WriteErrorResponse(ctx context.Context, db *sqlx.DB, client Client, conn *m // StartIVRFlow takes care of starting the flow in the passed in start for the passed in contact and URN func StartIVRFlow( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, client Client, resumeURL string, oa *models.OrgAssets, + ctx context.Context, rt *runtime.Runtime, client Client, resumeURL string, oa *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, startID models.StartID, r *http.Request, w http.ResponseWriter) error { // connection isn't in a wired status, that's an error if conn.Status() != models.ConnectionStatusWired && conn.Status() != models.ConnectionStatusInProgress { - return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("connection in invalid state: %s", conn.Status())) + return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("connection in invalid state: %s", conn.Status())) } // get the flow for our start - start, err := models.GetFlowStartAttributes(ctx, db, startID) + start, err := models.GetFlowStartAttributes(ctx, rt.DB, startID) if err != nil { return errors.Wrapf(err, "unable to load start: %d", startID) } @@ -358,7 +356,7 @@ func StartIVRFlow( } // mark our connection as started - err = conn.MarkStarted(ctx, db, time.Now()) + err = conn.MarkStarted(ctx, rt.DB, time.Now()) if err != nil { return errors.Wrapf(err, "error updating call status") } @@ -372,7 +370,7 @@ func StartIVRFlow( } // start our flow - sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true) + sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, hook, true) if err != nil { return errors.Wrapf(err, "error starting flow") } @@ -382,7 +380,7 @@ func StartIVRFlow( } // have our client output our session status - err = client.WriteSessionResponse(ctx, rp, channel, conn, sessions[0], urn, resumeURL, r, w) + err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, sessions[0], urn, resumeURL, r, w) if err != nil { return errors.Wrapf(err, "error writing ivr response for start") } @@ -392,7 +390,7 @@ func StartIVRFlow( // ResumeIVRFlow takes care of resuming the flow in the passed in start for the passed in contact and URN func ResumeIVRFlow( - ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage, + ctx context.Context, rt *runtime.Runtime, resumeURL string, client Client, oa *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, r *http.Request, w http.ResponseWriter) error { @@ -402,25 +400,25 @@ func ResumeIVRFlow( return errors.Wrapf(err, "error creating flow contact") } - session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeVoice, contact) + session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeVoice, contact) if err != nil { return errors.Wrapf(err, "error loading session for contact") } if session == nil { - return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("no active IVR session for contact")) + return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("no active IVR session for contact")) } if session.ConnectionID() == nil { - return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("active session: %d has no connection", session.ID())) + return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("active session: %d has no connection", session.ID())) } if *session.ConnectionID() != conn.ID() { - return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("active session: %d does not match connection: %d", session.ID(), *session.ConnectionID())) + return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("active session: %d does not match connection: %d", session.ID(), *session.ConnectionID())) } // preprocess this request - body, err := client.PreprocessResume(ctx, db, rp, conn, r) + body, err := client.PreprocessResume(ctx, rt.DB, rt.RP, conn, r) if err != nil { return errors.Wrapf(err, "error preprocessing resume") } @@ -444,7 +442,7 @@ func ResumeIVRFlow( // make sure our call is still happening status, _ := client.StatusForRequest(r) if status != models.ConnectionStatusInProgress { - err := conn.UpdateStatus(ctx, db, status, 0, time.Now()) + err := conn.UpdateStatus(ctx, rt.DB, status, 0, time.Now()) if err != nil { return errors.Wrapf(err, "error updating status") } @@ -455,17 +453,17 @@ func ResumeIVRFlow( if err != nil { // call has ended, so will our session if err == CallEndedError { - WriteErrorResponse(ctx, db, client, conn, w, errors.Wrapf(err, "call already ended")) + WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Wrapf(err, "call already ended")) } - return WriteErrorResponse(ctx, db, client, conn, w, errors.Wrapf(err, "error finding input for request")) + return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Wrapf(err, "error finding input for request")) } var resume flows.Resume var clientErr error switch res := ivrResume.(type) { case InputResume: - resume, clientErr, err = buildMsgResume(ctx, config, db, rp, store, client, channel, contact, urn, conn, oa, r, res) + resume, clientErr, err = buildMsgResume(ctx, rt.Config, rt.DB, rt.RP, rt.MediaStorage, client, channel, contact, urn, conn, oa, r, res) case DialResume: resume, clientErr, err = buildDialResume(oa, contact, res) @@ -484,19 +482,19 @@ func ResumeIVRFlow( return client.WriteErrorResponse(w, fmt.Errorf("no resume found, ending call")) } - session, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook) + session, err = runner.ResumeFlow(ctx, rt, oa, session, resume, hook) if err != nil { return errors.Wrapf(err, "error resuming ivr flow") } // if still active, write out our response if status == models.ConnectionStatusInProgress { - err = client.WriteSessionResponse(ctx, rp, channel, conn, session, urn, resumeURL, r, w) + err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, session, urn, resumeURL, r, w) if err != nil { return errors.Wrapf(err, "error writing ivr response for resume") } } else { - err = models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitCompleted, time.Now()) + err = models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitCompleted, time.Now()) if err != nil { logrus.WithError(err).Error("error closing session") } @@ -547,7 +545,7 @@ func buildMsgResume( // filename is based on our org id and msg UUID filename := string(msgUUID) + path.Ext(resume.Attachment.URL()) - resume.Attachment, err = oa.Org().StoreAttachment(store, filename, resume.Attachment.ContentType(), resp.Body) + resume.Attachment, err = oa.Org().StoreAttachment(ctx, store, filename, resume.Attachment.ContentType(), resp.Body) if err != nil { return nil, errors.Wrapf(err, "unable to download and store attachment, ending call"), nil } @@ -583,7 +581,7 @@ func buildMsgResume( // HandleIVRStatus is called on status callbacks for an IVR call. We let the client decide whether the call has // ended for some reason and update the state of the call and session if so -func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error { +func HandleIVRStatus(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error { // read our status and duration from our client status, duration := client.StatusForRequest(r) @@ -591,12 +589,12 @@ func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *model if status == models.ConnectionStatusErrored { // no associated start? this is a permanent failure if conn.StartID() == models.NilStartID { - conn.MarkFailed(ctx, db, time.Now()) - return client.WriteEmptyResponse(w, fmt.Sprintf("status updated: F")) + conn.MarkFailed(ctx, rt.DB, time.Now()) + return client.WriteEmptyResponse(w, "status updated: F") } // on errors we need to look up the flow to know how long to wait before retrying - start, err := models.GetFlowStartAttributes(ctx, db, conn.StartID()) + start, err := models.GetFlowStartAttributes(ctx, rt.DB, conn.StartID()) if err != nil { return errors.Wrapf(err, "unable to load start: %d", conn.StartID()) } @@ -606,16 +604,16 @@ func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *model return errors.Wrapf(err, "unable to load flow: %d", start.FlowID()) } - conn.MarkErrored(ctx, db, time.Now(), flow.IVRRetryWait()) + conn.MarkErrored(ctx, rt.DB, time.Now(), flow.IVRRetryWait()) if conn.Status() == models.ConnectionStatusErrored { return client.WriteEmptyResponse(w, fmt.Sprintf("status updated: %s next_attempt: %s", conn.Status(), conn.NextAttempt())) } } else if status == models.ConnectionStatusFailed { - conn.MarkFailed(ctx, db, time.Now()) + conn.MarkFailed(ctx, rt.DB, time.Now()) } else { if status != conn.Status() || duration > 0 { - err := conn.UpdateStatus(ctx, db, status, duration, time.Now()) + err := conn.UpdateStatus(ctx, rt.DB, status, duration, time.Now()) if err != nil { return errors.Wrapf(err, "error updating call status") } diff --git a/core/ivr/twiml/twiml.go b/core/ivr/twiml/twiml.go index a3477ce9e..6cb0969d2 100644 --- a/core/ivr/twiml/twiml.go +++ b/core/ivr/twiml/twiml.go @@ -23,6 +23,7 @@ import ( "github.com/nyaruka/goflow/flows/routers/waits" "github.com/nyaruka/goflow/flows/routers/waits/hints" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/ivr" "github.com/nyaruka/mailroom/core/models" @@ -67,13 +68,6 @@ const ( sendURLConfig = "send_url" baseURLConfig = "base_url" - - errorBody = ` - - An error was encountered. Goodbye. - - - ` ) var validLanguageCodes = map[string]bool{ @@ -501,7 +495,7 @@ func responseForSprint(number urns.URN, resumeURL string, w flows.ActivatedWait, if len(event.Msg.Attachments()) == 0 { country := envs.DeriveCountryFromTel(number.Path()) locale := envs.NewLocale(event.Msg.TextLanguage, country) - languageCode := locale.ToISO639_2() + languageCode := locale.ToBCP47() if _, valid := validLanguageCodes[languageCode]; !valid { languageCode = "" @@ -509,7 +503,7 @@ func responseForSprint(number urns.URN, resumeURL string, w flows.ActivatedWait, commands = append(commands, Say{Text: event.Msg.Text(), Language: languageCode}) } else { for _, a := range event.Msg.Attachments() { - a = models.NormalizeAttachment(a) + a = models.NormalizeAttachment(config.Mailroom, a) commands = append(commands, Play{URL: a.URL()}) } } diff --git a/core/ivr/vonage/vonage.go b/core/ivr/vonage/vonage.go index 74d65aee3..4cd5e3826 100644 --- a/core/ivr/vonage/vonage.go +++ b/core/ivr/vonage/vonage.go @@ -62,13 +62,6 @@ const ( appIDConfig = "nexmo_app_id" privateKeyConfig = "nexmo_app_private_key" - errorBody = ` - - An error was encountered. Goodbye. - - - ` - statusFailed = "failed" ) @@ -192,7 +185,7 @@ func (c *client) PreprocessStatus(ctx context.Context, db *sqlx.DB, rp *redis.Po } if nxType == "transfer" { - return c.MakeEmptyResponseBody(fmt.Sprintf("ignoring conversation callback")), nil + return c.MakeEmptyResponseBody("ignoring conversation callback"), nil } // grab our uuid out @@ -284,7 +277,7 @@ func (c *client) PreprocessStatus(ctx context.Context, db *sqlx.DB, rp *redis.Po return c.MakeEmptyResponseBody(fmt.Sprintf("updated status for call: %s to: %s", callUUID, status)), nil } - return c.MakeEmptyResponseBody(fmt.Sprintf("ignoring non final status for tranfer leg")), nil + return c.MakeEmptyResponseBody("ignoring non final status for tranfer leg"), nil } func (c *client) PreprocessResume(ctx context.Context, db *sqlx.DB, rp *redis.Pool, conn *models.ChannelConnection, r *http.Request) ([]byte, error) { @@ -961,9 +954,7 @@ func (c *client) responseForSprint(ctx context.Context, rp *redis.Pool, channel } } - for _, w := range waitActions { - actions = append(actions, w) - } + actions = append(actions, waitActions...) var body []byte var err error diff --git a/core/ivr/vonage/vonage_test.go b/core/ivr/vonage/vonage_test.go index 7a4a520cf..06618dda5 100644 --- a/core/ivr/vonage/vonage_test.go +++ b/core/ivr/vonage/vonage_test.go @@ -15,26 +15,29 @@ import ( "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" ) func TestResponseForSprint(t *testing.T) { - ctx, db, rp := testsuite.Reset() + ctx, _, db, rp := testsuite.Get() + + defer testsuite.Reset() + rc := rp.Get() defer rc.Close() - models.FlushCache() urn := urns.URN("tel:+12067799294") - channelRef := assets.NewChannelReference(models.VonageChannelUUID, "Vonage Channel") + channelRef := assets.NewChannelReference(testdata.VonageChannel.UUID, "Vonage Channel") resumeURL := "http://temba.io/resume?session=1" // deactivate our twilio channel - db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID) // add auth tokens - db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, models.VonageChannelID) + db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, testdata.VonageChannel.ID) // set our UUID generator uuids.SetGenerator(uuids.NewSeededGenerator(0)) @@ -43,10 +46,10 @@ func TestResponseForSprint(t *testing.T) { config.Mailroom.AttachmentDomain = "mailroom.io" defer func() { config.Mailroom.AttachmentDomain = "" }() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) - channel := oa.ChannelByUUID(models.VonageChannelUUID) + channel := oa.ChannelByUUID(testdata.VonageChannel.UUID) assert.NotNil(t, channel) c, err := NewClientFromChannel(http.DefaultClient, channel) diff --git a/core/models/airtime_test.go b/core/models/airtime_test.go index 102fa5b8c..7794ac41d 100644 --- a/core/models/airtime_test.go +++ b/core/models/airtime_test.go @@ -1,25 +1,28 @@ -package models +package models_test import ( "testing" "time" "github.com/nyaruka/gocommon/urns" + "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" ) func TestAirtimeTransfers(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer db.MustExec(`DELETE FROM airtime_airtimetransfer`) // insert a transfer - transfer := NewAirtimeTransfer( - Org1, - AirtimeTransferStatusSuccess, - CathyID, + transfer := models.NewAirtimeTransfer( + testdata.Org1.ID, + models.AirtimeTransferStatusSuccess, + testdata.Cathy.ID, urns.URN("tel:+250700000001"), urns.URN("tel:+250700000002"), "RWF", @@ -27,18 +30,16 @@ func TestAirtimeTransfers(t *testing.T) { decimal.RequireFromString(`1000`), time.Now(), ) - err := InsertAirtimeTransfers(ctx, db, []*AirtimeTransfer{transfer}) + err := models.InsertAirtimeTransfers(ctx, db, []*models.AirtimeTransfer{transfer}) assert.Nil(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`, - []interface{}{Org1, AirtimeTransferStatusSuccess}, 1) + testsuite.AssertQuery(t, db, `SELECT org_id, status from airtime_airtimetransfer`).Columns(map[string]interface{}{"org_id": int64(1), "status": "S"}) // insert a failed transfer with nil sender, empty currency - transfer = NewAirtimeTransfer( - Org1, - AirtimeTransferStatusFailed, - CathyID, + transfer = models.NewAirtimeTransfer( + testdata.Org1.ID, + models.AirtimeTransferStatusFailed, + testdata.Cathy.ID, urns.NilURN, urns.URN("tel:+250700000002"), "", @@ -46,10 +47,8 @@ func TestAirtimeTransfers(t *testing.T) { decimal.Zero, time.Now(), ) - err = InsertAirtimeTransfers(ctx, db, []*AirtimeTransfer{transfer}) + err = models.InsertAirtimeTransfers(ctx, db, []*models.AirtimeTransfer{transfer}) assert.Nil(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`, - []interface{}{Org1, AirtimeTransferStatusFailed}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`, testdata.Org1.ID, models.AirtimeTransferStatusFailed).Returns(1) } diff --git a/core/models/assets.go b/core/models/assets.go index f1edea222..b5b4527b1 100644 --- a/core/models/assets.go +++ b/core/models/assets.go @@ -12,6 +12,7 @@ import ( "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/engine" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/goflow" cache "github.com/patrickmn/go-cache" "github.com/pkg/errors" @@ -67,6 +68,10 @@ type OrgAssets struct { locations []assets.LocationHierarchy locationsBuiltAt time.Time + + users []assets.User + usersByID map[UserID]*User + usersByEmail map[string]*User } var ErrNotFound = errors.New("not found") @@ -125,7 +130,7 @@ func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets var err error if prev == nil || refresh&RefreshOrg > 0 { - oa.org, err = loadOrg(ctx, db, orgID) + oa.org, err = LoadOrg(ctx, config.Mailroom, db, orgID) if err != nil { return nil, errors.Wrapf(err, "error loading environment for org %d", orgID) } @@ -315,8 +320,25 @@ func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets oa.ticketersByUUID = prev.ticketersByUUID } + if prev == nil || refresh&RefreshUsers > 0 { + oa.users, err = loadUsers(ctx, db, orgID) + if err != nil { + return nil, errors.Wrapf(err, "error loading user assets for org %d", orgID) + } + oa.usersByID = make(map[UserID]*User) + oa.usersByEmail = make(map[string]*User) + for _, u := range oa.users { + oa.usersByID[u.(*User).ID()] = u.(*User) + oa.usersByEmail[u.Email()] = u.(*User) + } + } else { + oa.users = prev.users + oa.usersByID = prev.usersByID + oa.usersByEmail = prev.usersByEmail + } + // intialize our session assets - oa.sessionAssets, err = engine.NewSessionAssets(oa.Env(), oa, goflow.MigrationConfig()) + oa.sessionAssets, err = engine.NewSessionAssets(oa.Env(), oa, goflow.MigrationConfig(config.Mailroom)) if err != nil { return nil, errors.Wrapf(err, "error build session assets for org: %d", orgID) } @@ -345,6 +367,7 @@ const ( RefreshLabels = Refresh(1 << 12) RefreshFlows = Refresh(1 << 13) RefreshTicketers = Refresh(1 << 14) + RefreshUsers = Refresh(1 << 15) ) // GetOrgAssets creates or gets org assets for the passed in org @@ -439,6 +462,9 @@ func (a *OrgAssets) FieldByKey(key string) *Field { func (a *OrgAssets) CloneForSimulation(ctx context.Context, db *sqlx.DB, newDefs map[assets.FlowUUID]json.RawMessage, testChannels []assets.Channel) (*OrgAssets, error) { // only channels and flows can be modified so only refresh those clone, err := NewOrgAssets(context.Background(), a.db, a.OrgID(), a, RefreshFlows) + if err != nil { + return nil, err + } for flowUUID, newDef := range newDefs { // get the original flow @@ -455,13 +481,10 @@ func (a *OrgAssets) CloneForSimulation(ctx context.Context, db *sqlx.DB, newDefs clone.flowByID[cf.ID()] = cf } - for _, channel := range testChannels { - // we don't populate our maps for uuid or id, shouldn't be used in any hook anyways - clone.channels = append(clone.channels, channel) - } + clone.channels = append(clone.channels, testChannels...) // rebuild our session assets with our new items - clone.sessionAssets, err = engine.NewSessionAssets(a.Env(), clone, goflow.MigrationConfig()) + clone.sessionAssets, err = engine.NewSessionAssets(a.Env(), clone, goflow.MigrationConfig(config.Mailroom)) if err != nil { return nil, errors.Wrapf(err, "error build session assets for org: %d", clone.OrgID()) } @@ -482,7 +505,7 @@ func (a *OrgAssets) Flow(flowUUID assets.FlowUUID) (assets.Flow, error) { return flow, nil } - dbFlow, err := loadFlowByUUID(ctx, a.db, a.orgID, flowUUID) + dbFlow, err := LoadFlowByUUID(ctx, a.db, a.orgID, flowUUID) if err != nil { return nil, errors.Wrapf(err, "error loading flow: %s", flowUUID) } @@ -512,7 +535,7 @@ func (a *OrgAssets) FlowByID(flowID FlowID) (*Flow, error) { return flow.(*Flow), nil } - dbFlow, err := loadFlowByID(ctx, a.db, a.orgID, flowID) + dbFlow, err := LoadFlowByID(ctx, a.db, a.orgID, flowID) if err != nil { return nil, errors.Wrapf(err, "error loading flow: %d", flowID) } @@ -605,3 +628,15 @@ func (a *OrgAssets) TicketerByID(id TicketerID) *Ticketer { func (a *OrgAssets) TicketerByUUID(uuid assets.TicketerUUID) *Ticketer { return a.ticketersByUUID[uuid] } + +func (a *OrgAssets) Users() ([]assets.User, error) { + return a.users, nil +} + +func (a *OrgAssets) UserByID(id UserID) *User { + return a.usersByID[id] +} + +func (a *OrgAssets) UserByEmail(email string) *User { + return a.usersByEmail[email] +} diff --git a/core/models/assets_test.go b/core/models/assets_test.go index 260ba527e..440449752 100644 --- a/core/models/assets_test.go +++ b/core/models/assets_test.go @@ -8,17 +8,18 @@ import ( "github.com/nyaruka/goflow/assets/static/types" "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 TestCloneForSimulation(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + models.FlushCache() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) newFavoritesDef := `{ @@ -28,7 +29,7 @@ func TestCloneForSimulation(t *testing.T) { }` newDefs := map[assets.FlowUUID]json.RawMessage{ - models.FavoritesFlowUUID: []byte(newFavoritesDef), + testdata.Favorites.UUID: []byte(newFavoritesDef), } testChannels := []assets.Channel{ @@ -40,7 +41,7 @@ func TestCloneForSimulation(t *testing.T) { require.NoError(t, err) // should get new definition - flow, err := clone.Flow(models.FavoritesFlowUUID) + flow, err := clone.Flow(testdata.Favorites.UUID) require.NoError(t, err) assert.Equal(t, newFavoritesDef, string(flow.Definition())) @@ -51,11 +52,11 @@ func TestCloneForSimulation(t *testing.T) { assert.Equal(t, "Test Channel 2", testChannel2.Name()) // as well as the regular channels - vonage := clone.SessionAssets().Channels().Get(models.VonageChannelUUID) + vonage := clone.SessionAssets().Channels().Get(testdata.VonageChannel.UUID) assert.Equal(t, "Vonage", vonage.Name()) // original assets still has original flow definition - flow, err = oa.Flow(models.FavoritesFlowUUID) + flow, err = oa.Flow(testdata.Favorites.UUID) require.NoError(t, err) assert.Equal(t, "{\"_ui\": {\"nodes\": {\"10c9c241-777f-4010-a841-6e87abed8520\": {\"typ", string(flow.Definition())[:64]) @@ -64,6 +65,6 @@ func TestCloneForSimulation(t *testing.T) { assert.Nil(t, testChannel1) // can't override definition for a non-existent flow - oa, err = oa.CloneForSimulation(ctx, db, map[assets.FlowUUID]json.RawMessage{"a121f1af-7dfa-47af-9d22-9726372e2daa": []byte(newFavoritesDef)}, nil) + _, err = oa.CloneForSimulation(ctx, db, map[assets.FlowUUID]json.RawMessage{"a121f1af-7dfa-47af-9d22-9726372e2daa": []byte(newFavoritesDef)}, nil) assert.EqualError(t, err, "unable to find flow with UUID 'a121f1af-7dfa-47af-9d22-9726372e2daa': not found") } diff --git a/core/models/campaigns_test.go b/core/models/campaigns_test.go index 087d8871b..3781f74b7 100644 --- a/core/models/campaigns_test.go +++ b/core/models/campaigns_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -81,35 +82,35 @@ func TestCampaignSchedule(t *testing.T) { } func TestAddEventFires(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer db.MustExec(`DELETE FROM campaigns_eventfire`) scheduled1 := time.Date(2020, 9, 8, 14, 38, 30, 123456789, time.UTC) err := models.AddEventFires(ctx, db, []*models.FireAdd{ - {ContactID: models.CathyID, EventID: models.RemindersEvent1ID, Scheduled: scheduled1}, - {ContactID: models.BobID, EventID: models.RemindersEvent1ID, Scheduled: scheduled1}, - {ContactID: models.BobID, EventID: models.RemindersEvent2ID, Scheduled: scheduled1}, + {ContactID: testdata.Cathy.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled1}, + {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled1}, + {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent2.ID, Scheduled: scheduled1}, }) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire`, nil, 3) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.CathyID, models.RemindersEvent1ID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.BobID, models.RemindersEvent1ID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.BobID, models.RemindersEvent2ID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire`).Returns(3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Cathy.ID, testdata.RemindersEvent1.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Bob.ID, testdata.RemindersEvent1.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Bob.ID, testdata.RemindersEvent2.ID).Returns(1) - db.MustExec(`UPDATE campaigns_eventfire SET fired = NOW() WHERE contact_id = $1`, models.CathyID) + db.MustExec(`UPDATE campaigns_eventfire SET fired = NOW() WHERE contact_id = $1`, testdata.Cathy.ID) scheduled2 := time.Date(2020, 9, 8, 14, 38, 30, 123456789, time.UTC) err = models.AddEventFires(ctx, db, []*models.FireAdd{ - {ContactID: models.CathyID, EventID: models.RemindersEvent1ID, Scheduled: scheduled2}, // fine because previous one now has non-null fired - {ContactID: models.BobID, EventID: models.RemindersEvent1ID, Scheduled: scheduled2}, // won't be added due to conflict + {ContactID: testdata.Cathy.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled2}, // fine because previous one now has non-null fired + {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled2}, // won't be added due to conflict }) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire`, nil, 4) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.CathyID, models.RemindersEvent1ID}, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.BobID}, 2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire`).Returns(4) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Cathy.ID, testdata.RemindersEvent1.ID).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.Bob.ID).Returns(2) } diff --git a/core/models/channel_connection.go b/core/models/channel_connection.go index e2ec1f31e..97b2dad15 100644 --- a/core/models/channel_connection.go +++ b/core/models/channel_connection.go @@ -353,13 +353,13 @@ func (c *ChannelConnection) MarkStarted(ctx context.Context, db Queryer, now tim } // MarkErrored updates the status for this connection to errored and schedules a retry if appropriate -func (c *ChannelConnection) MarkErrored(ctx context.Context, db Queryer, now time.Time, wait time.Duration) error { +func (c *ChannelConnection) MarkErrored(ctx context.Context, db Queryer, now time.Time, retryWait *time.Duration) error { c.c.Status = ConnectionStatusErrored c.c.EndedOn = &now - if c.c.RetryCount < ConnectionMaxRetries { + if c.c.RetryCount < ConnectionMaxRetries && retryWait != nil { c.c.RetryCount++ - next := now.Add(wait) + next := now.Add(*retryWait) c.c.NextAttempt = &next } else { c.c.Status = ConnectionStatusFailed diff --git a/core/models/channel_connection_test.go b/core/models/channel_connection_test.go index 0ca721f9f..60e28a2ce 100644 --- a/core/models/channel_connection_test.go +++ b/core/models/channel_connection_test.go @@ -1,27 +1,31 @@ -package models +package models_test import ( "testing" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestChannelConnections(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer db.MustExec(`DELETE FROM channels_channelconnection`) - conn, err := InsertIVRConnection(ctx, db, Org1, TwilioChannelID, NilStartID, CathyID, CathyURNID, ConnectionDirectionOut, ConnectionStatusPending, "") + conn, err := models.InsertIVRConnection(ctx, db, testdata.Org1.ID, testdata.TwilioChannel.ID, models.NilStartID, testdata.Cathy.ID, testdata.Cathy.URNID, models.ConnectionDirectionOut, models.ConnectionStatusPending, "") assert.NoError(t, err) - assert.NotEqual(t, ConnectionID(0), conn.ID()) + assert.NotEqual(t, models.ConnectionID(0), conn.ID()) err = conn.UpdateExternalID(ctx, db, "test1") assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from channels_channelconnection where external_id = 'test1' AND id = $1`, []interface{}{conn.ID()}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from channels_channelconnection where external_id = 'test1' AND id = $1`, conn.ID()).Returns(1) - conn2, err := SelectChannelConnection(ctx, db, conn.ID()) + conn2, err := models.SelectChannelConnection(ctx, db, conn.ID()) assert.NoError(t, err) assert.Equal(t, "test1", conn2.ExternalID()) } diff --git a/core/models/channel_event_test.go b/core/models/channel_event_test.go index d8962b2ca..c7bccaf47 100644 --- a/core/models/channel_event_test.go +++ b/core/models/channel_event_test.go @@ -1,22 +1,26 @@ -package models +package models_test import ( "encoding/json" "testing" "time" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestChannelEvents(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer db.MustExec(`DELETE FROM channels_channelevent`) start := time.Now() // no extra - e := NewChannelEvent(MOMissEventType, Org1, TwilioChannelID, CathyID, CathyURNID, nil, false) + e := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, nil, false) err := e.Insert(ctx, db) assert.NoError(t, err) assert.NotZero(t, e.ID()) @@ -24,7 +28,7 @@ func TestChannelEvents(t *testing.T) { assert.True(t, e.OccurredOn().After(start)) // with extra - e2 := NewChannelEvent(MOMissEventType, Org1, TwilioChannelID, CathyID, CathyURNID, map[string]interface{}{"referral_id": "foobar"}, false) + e2 := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, map[string]interface{}{"referral_id": "foobar"}, false) err = e2.Insert(ctx, db) assert.NoError(t, err) assert.NotZero(t, e2.ID()) @@ -33,7 +37,7 @@ func TestChannelEvents(t *testing.T) { asJSON, err := json.Marshal(e2) assert.NoError(t, err) - e3 := &ChannelEvent{} + e3 := &models.ChannelEvent{} err = json.Unmarshal(asJSON, e3) assert.NoError(t, err) assert.Equal(t, e2.Extra(), e3.Extra()) diff --git a/core/models/channel_logs.go b/core/models/channel_logs.go index 359ae67a4..a0ff14b9c 100644 --- a/core/models/channel_logs.go +++ b/core/models/channel_logs.go @@ -61,7 +61,7 @@ func NewChannelLog(trace *httpx.Trace, isError bool, desc string, channel *Chann l.URL = url l.Method = trace.Request.Method l.Request = string(trace.RequestTrace) - l.Response = trace.ResponseTraceUTF8("...") + l.Response = string(trace.SanitizedResponse("...")) l.Status = statusCode l.CreatedOn = trace.StartTime l.RequestTime = int((trace.EndTime.Sub(trace.StartTime)) / time.Millisecond) diff --git a/core/models/channel_logs_test.go b/core/models/channel_logs_test.go index f91e85bf9..09dacfa1d 100644 --- a/core/models/channel_logs_test.go +++ b/core/models/channel_logs_test.go @@ -7,15 +7,15 @@ import ( "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/require" ) func TestChannelLogs(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - db.MustExec(`DELETE FROM channels_channellog;`) + defer db.MustExec(`DELETE FROM channels_channellog`) defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -24,28 +24,32 @@ func TestChannelLogs(t *testing.T) { "http://rapidpro.io/new": {httpx.NewMockResponse(200, nil, "OK")}, })) - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) - channel := oa.ChannelByID(models.TwilioChannelID) + channel := oa.ChannelByID(testdata.TwilioChannel.ID) + require.NotNil(t, channel) req1, _ := httpx.NewRequest("GET", "http://rapidpro.io", nil, nil) trace1, err := httpx.DoTrace(http.DefaultClient, req1, nil, nil, -1) + require.NoError(t, err) log1 := models.NewChannelLog(trace1, false, "test request", channel, nil) req2, _ := httpx.NewRequest("GET", "http://rapidpro.io/bad", nil, nil) trace2, err := httpx.DoTrace(http.DefaultClient, req2, nil, nil, -1) + require.NoError(t, err) log2 := models.NewChannelLog(trace2, true, "test request", channel, nil) req3, _ := httpx.NewRequest("GET", "http://rapidpro.io/new", nil, map[string]string{"X-Forwarded-Path": "/old"}) trace3, err := httpx.DoTrace(http.DefaultClient, req3, nil, nil, -1) + require.NoError(t, err) log3 := models.NewChannelLog(trace3, false, "test request", channel, nil) err = models.InsertChannelLogs(ctx, db, []*models.ChannelLog{log1, log2, log3}) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog`, nil, 3) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io' AND is_error = FALSE AND channel_id = $1`, []interface{}{channel.ID()}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io/bad' AND is_error = TRUE AND channel_id = $1`, []interface{}{channel.ID()}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'https://rapidpro.io/old' AND is_error = FALSE AND channel_id = $1`, []interface{}{channel.ID()}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog`).Returns(3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io' AND is_error = FALSE AND channel_id = $1`, channel.ID()).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io/bad' AND is_error = TRUE AND channel_id = $1`, channel.ID()).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'https://rapidpro.io/old' AND is_error = FALSE AND channel_id = $1`, channel.ID()).Returns(1) } diff --git a/core/models/channels_test.go b/core/models/channels_test.go index 0f02df02a..9f86e6678 100644 --- a/core/models/channels_test.go +++ b/core/models/channels_test.go @@ -1,28 +1,36 @@ -package models +package models_test import ( "testing" "github.com/nyaruka/goflow/assets" + "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 TestChannels(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() // add some tel specific config to channel 2 - db.MustExec(`UPDATE channels_channel SET config = '{"matching_prefixes": ["250", "251"], "allow_international": true}' WHERE id = $1`, VonageChannelID) + db.MustExec(`UPDATE channels_channel SET config = '{"matching_prefixes": ["250", "251"], "allow_international": true}' WHERE id = $1`, testdata.VonageChannel.ID) // make twitter channel have a parent of twilio channel - db.MustExec(`UPDATE channels_channel SET parent_id = $1 WHERE id = $2`, TwilioChannelID, TwitterChannelID) + db.MustExec(`UPDATE channels_channel SET parent_id = $1 WHERE id = $2`, testdata.TwilioChannel.ID, testdata.TwitterChannel.ID) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, 1, models.RefreshChannels) + require.NoError(t, err) - channels, err := loadChannels(ctx, db, 1) - assert.NoError(t, err) + channels, err := oa.Channels() + require.NoError(t, err) tcs := []struct { - ID ChannelID + ID models.ChannelID UUID assets.ChannelUUID Name string Address string @@ -33,8 +41,8 @@ func TestChannels(t *testing.T) { Parent *assets.ChannelReference }{ { - TwilioChannelID, - TwilioChannelUUID, + testdata.TwilioChannel.ID, + testdata.TwilioChannel.UUID, "Twilio", "+13605551212", []string{"tel"}, @@ -44,8 +52,8 @@ func TestChannels(t *testing.T) { nil, }, { - VonageChannelID, - VonageChannelUUID, + testdata.VonageChannel.ID, + testdata.VonageChannel.UUID, "Vonage", "5789", []string{"tel"}, @@ -55,21 +63,21 @@ func TestChannels(t *testing.T) { nil, }, { - TwitterChannelID, - TwitterChannelUUID, + testdata.TwitterChannel.ID, + testdata.TwitterChannel.UUID, "Twitter", "ureport", []string{"twitter"}, []assets.ChannelRole{"send", "receive"}, nil, false, - assets.NewChannelReference(TwilioChannelUUID, "Twilio"), + assets.NewChannelReference(testdata.TwilioChannel.UUID, "Twilio"), }, } assert.Equal(t, len(tcs), len(channels)) for i, tc := range tcs { - channel := channels[i].(*Channel) + channel := channels[i].(*models.Channel) assert.Equal(t, tc.UUID, channel.UUID()) assert.Equal(t, tc.ID, channel.ID()) assert.Equal(t, tc.Name, channel.Name()) diff --git a/core/models/classifiers.go b/core/models/classifiers.go index e30d0fc7f..a6b860ca5 100644 --- a/core/models/classifiers.go +++ b/core/models/classifiers.go @@ -10,6 +10,7 @@ import ( "github.com/nyaruka/goflow/services/classification/bothub" "github.com/nyaruka/goflow/services/classification/luis" "github.com/nyaruka/goflow/services/classification/wit" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/utils/dbutil" "github.com/nyaruka/null" @@ -90,7 +91,7 @@ func (c *Classifier) Type() string { return c.c.Type } // AsService builds the corresponding ClassificationService for the passed in Classifier func (c *Classifier) AsService(classifier *flows.Classifier) (flows.ClassificationService, error) { - httpClient, httpRetries, httpAccess := goflow.HTTP() + httpClient, httpRetries, httpAccess := goflow.HTTP(config.Mailroom) switch c.Type() { case ClassifierTypeWit: diff --git a/core/models/classifiers_test.go b/core/models/classifiers_test.go index ab7fa71d9..3d1f447c4 100644 --- a/core/models/classifiers_test.go +++ b/core/models/classifiers_test.go @@ -1,34 +1,40 @@ -package models +package models_test import ( "testing" "github.com/nyaruka/goflow/assets" + "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 TestClassifiers(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshClassifiers) + require.NoError(t, err) - classifiers, err := loadClassifiers(ctx, db, 1) - assert.NoError(t, err) + classifiers, err := oa.Classifiers() + require.NoError(t, err) tcs := []struct { - ID ClassifierID + ID models.ClassifierID UUID assets.ClassifierUUID Name string Intents []string }{ - {LuisID, LuisUUID, "LUIS", []string{"book_flight", "book_car"}}, - {WitID, WitUUID, "Wit.ai", []string{"register"}}, - {BothubID, BothubUUID, "BotHub", []string{"intent"}}, + {testdata.Luis.ID, testdata.Luis.UUID, "LUIS", []string{"book_flight", "book_car"}}, + {testdata.Wit.ID, testdata.Wit.UUID, "Wit.ai", []string{"register"}}, + {testdata.Bothub.ID, testdata.Bothub.UUID, "BotHub", []string{"intent"}}, } assert.Equal(t, len(tcs), len(classifiers)) for i, tc := range tcs { - c := classifiers[i].(*Classifier) + c := classifiers[i].(*models.Classifier) assert.Equal(t, tc.UUID, c.UUID()) assert.Equal(t, tc.ID, c.ID()) assert.Equal(t, tc.Name, c.Name()) diff --git a/core/models/contacts.go b/core/models/contacts.go index 46f8d9108..fc73178a2 100644 --- a/core/models/contacts.go +++ b/core/models/contacts.go @@ -80,6 +80,7 @@ type Contact struct { fields map[string]*flows.Value groups []*Group urns []urns.URN + tickets []*Ticket createdOn time.Time modifiedOn time.Time lastSeenOn *time.Time @@ -191,27 +192,38 @@ func (c *Contact) UpdatePreferredURN(ctx context.Context, db Queryer, org *OrgAs } // FlowContact converts our mailroom contact into a flow contact for use in the engine -func (c *Contact) FlowContact(org *OrgAssets) (*flows.Contact, error) { +func (c *Contact) FlowContact(oa *OrgAssets) (*flows.Contact, error) { // convert our groups to a list of references groups := make([]*assets.GroupReference, len(c.groups)) for i, g := range c.groups { groups[i] = assets.NewGroupReference(g.UUID(), g.Name()) } + // convert our tickets to flow tickets + tickets := make([]*flows.Ticket, len(c.tickets)) + var err error + for i, t := range c.tickets { + tickets[i], err = t.FlowTicket(oa) + if err != nil { + return nil, errors.Wrapf(err, "error creating flow ticket") + } + } + // create our flow contact contact, err := flows.NewContact( - org.SessionAssets(), + oa.SessionAssets(), c.uuid, flows.ContactID(c.id), c.name, c.language, contactToFlowStatus[c.Status()], - org.Env().Timezone(), + oa.Env().Timezone(), c.createdOn, c.lastSeenOn, c.urns, groups, c.fields, + tickets, assets.IgnoreMissing, ) if err != nil { @@ -305,6 +317,16 @@ func LoadContacts(ctx context.Context, db Queryer, org *OrgAssets, ids []Contact } contact.urns = contactURNs + // initialize our tickets + tickets := make([]*Ticket, 0, len(e.Tickets)) + for _, t := range e.Tickets { + ticketer := org.TicketerByID(t.TicketerID) + if ticketer != nil { + tickets = append(tickets, NewTicket(t.UUID, org.OrgID(), contact.ID(), ticketer.ID(), t.ExternalID, t.Subject, t.Body, t.AssigneeID, nil)) + } + } + contact.tickets = tickets + contacts = append(contacts, contact) } @@ -384,16 +406,6 @@ func queryContactIDs(ctx context.Context, db Queryer, query string, args ...inte return ids, nil } -// fieldValueEnvelope is our utility struct for the value of a field -type fieldValueEnvelope struct { - Text types.XText `json:"text"` - Datetime *types.XDateTime `json:"datetime,omitempty"` - Number *types.XNumber `json:"number,omitempty"` - State envs.LocationPath `json:"state,omitempty"` - District envs.LocationPath `json:"district,omitempty"` - Ward envs.LocationPath `json:"ward,omitempty"` -} - type ContactURN struct { ID URNID `json:"id" db:"id"` Priority int `json:"priority" db:"priority"` @@ -435,17 +447,32 @@ func (u *ContactURN) AsURN(org *OrgAssets) (urns.URN, error) { // contactEnvelope is our JSON structure for a contact as read from the database type contactEnvelope struct { - ID ContactID `json:"id"` - UUID flows.ContactUUID `json:"uuid"` - Name string `json:"name"` - Language envs.Language `json:"language"` - Status ContactStatus `json:"status"` - Fields map[assets.FieldUUID]*fieldValueEnvelope `json:"fields"` - GroupIDs []GroupID `json:"group_ids"` - URNs []ContactURN `json:"urns"` - CreatedOn time.Time `json:"created_on"` - ModifiedOn time.Time `json:"modified_on"` - LastSeenOn *time.Time `json:"last_seen_on"` + ID ContactID `json:"id"` + UUID flows.ContactUUID `json:"uuid"` + Name string `json:"name"` + Language envs.Language `json:"language"` + Status ContactStatus `json:"status"` + Fields map[assets.FieldUUID]struct { + Text types.XText `json:"text"` + Datetime *types.XDateTime `json:"datetime,omitempty"` + Number *types.XNumber `json:"number,omitempty"` + State envs.LocationPath `json:"state,omitempty"` + District envs.LocationPath `json:"district,omitempty"` + Ward envs.LocationPath `json:"ward,omitempty"` + } `json:"fields"` + GroupIDs []GroupID `json:"group_ids"` + URNs []ContactURN `json:"urns"` + Tickets []struct { + UUID flows.TicketUUID `json:"uuid"` + TicketerID TicketerID `json:"ticketer_id"` + ExternalID string `json:"external_id"` + Subject string `json:"subject"` + Body string `json:"body"` + AssigneeID UserID `json:"assignee_id"` + } `json:"tickets"` + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` + LastSeenOn *time.Time `json:"last_seen_on"` } const selectContactSQL = ` @@ -462,7 +489,8 @@ SELECT ROW_TO_JSON(r) FROM (SELECT last_seen_on, fields, g.groups AS group_ids, - u.urns AS urns + u.urns AS urns, + t.tickets AS tickets FROM contacts_contact c LEFT JOIN ( @@ -497,6 +525,26 @@ LEFT JOIN ( GROUP BY contact_id ) u ON c.id = u.contact_id +LEFT JOIN ( + SELECT + contact_id, + array_agg( + json_build_object( + 'uuid', t.uuid, + 'subject', t.subject, + 'body', t.body, + 'external_id', t.external_id, + 'ticketer_id', t.ticketer_id, + 'assignee_id', t.assignee_id + ) ORDER BY t.opened_on ASC, t.id ASC + ) as tickets + FROM + tickets_ticket t + WHERE + t.status = 'O' AND t.contact_id = ANY($1) + GROUP BY + contact_id +) t ON c.id = t.contact_id WHERE c.id = ANY($1) AND is_active = TRUE AND @@ -740,8 +788,8 @@ func insertContactAndURNs(ctx context.Context, db Queryer, orgID OrgID, userID U // first insert our contact var contactID ContactID err := db.GetContext(ctx, &contactID, - `INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, created_on, modified_on, created_by_id, modified_by_id) - VALUES($1, TRUE, 'A', $2, $3, $4, $5, $5, $6, $6) + `INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, ticket_count, created_on, modified_on, created_by_id, modified_by_id) + VALUES($1, TRUE, 'A', $2, $3, $4, 0, $5, $5, $6, $6) RETURNING id`, orgID, uuids.New(), null.String(name), null.String(string(language)), dates.Now(), userID, ) @@ -869,10 +917,7 @@ func URNForID(ctx context.Context, db Queryer, org *OrgAssets, urnID URNID) (urn // CalculateDynamicGroups recalculates all the dynamic groups for the passed in contact, recalculating // campaigns as necessary based on those group changes. func CalculateDynamicGroups(ctx context.Context, db Queryer, org *OrgAssets, contact *flows.Contact) error { - added, removed, errs := contact.ReevaluateQueryBasedGroups(org.Env()) - if len(errs) > 0 { - return errors.Wrapf(errs[0], "error calculating dynamic groups") - } + added, removed := contact.ReevaluateQueryBasedGroups(org.Env()) campaigns := make(map[CampaignID]*Campaign) diff --git a/core/models/contacts_test.go b/core/models/contacts_test.go index a2c90e103..f915acbd2 100644 --- a/core/models/contacts_test.go +++ b/core/models/contacts_test.go @@ -2,6 +2,7 @@ package models_test import ( "fmt" + "sort" "testing" "time" @@ -20,22 +21,31 @@ import ( ) func TestContacts(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - org, err := models.GetOrgAssets(ctx, db, 1) - assert.NoError(t, err) + defer testsuite.Reset() - testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("whatsapp:250788373373"), 999) + testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, "whatsapp:250788373373", 999) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Problem!", "Where are my shoes?", "1234", testdata.Agent) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Another Problem!", "Where are my pants?", "2345", nil) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Urgent", "His name is Bob", "", testdata.Editor) - db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, models.GeorgeID) - db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.GeorgeID) - db.MustExec(`UPDATE contacts_contact SET is_active = FALSE WHERE id = $1`, models.AlexandriaID) + // delete mailgun ticketer + db.MustExec(`UPDATE tickets_ticketer SET is_active = false WHERE id = $1`, testdata.Mailgun.ID) - modelContacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{models.CathyID, models.GeorgeID, models.BobID, models.AlexandriaID}) + org, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshAll) assert.NoError(t, err) - assert.Equal(t, 3, len(modelContacts)) + + db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, testdata.George.ID) + db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.George.ID) + db.MustExec(`UPDATE contacts_contact SET is_active = FALSE WHERE id = $1`, testdata.Alexandria.ID) + + modelContacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID, testdata.George.ID, testdata.Alexandria.ID}) + require.NoError(t, err) + require.Equal(t, 3, len(modelContacts)) + + // LoadContacts doesn't guarantee returned order of contacts + sort.Slice(modelContacts, func(i, j int) bool { return modelContacts[i].ID() < modelContacts[j].ID() }) // convert to goflow contacts contacts := make([]*flows.Contact, len(modelContacts)) @@ -44,45 +54,54 @@ func TestContacts(t *testing.T) { assert.NoError(t, err) } - if len(contacts) == 3 { - assert.Equal(t, "Cathy", contacts[0].Name()) - assert.Equal(t, len(contacts[0].URNs()), 1) - assert.Equal(t, contacts[0].URNs()[0].String(), "tel:+16055741111?id=10000&priority=1000") - assert.Equal(t, 1, contacts[0].Groups().Count()) - - assert.Equal(t, "Yobe", contacts[0].Fields()["state"].QueryValue()) - assert.Equal(t, "Dokshi", contacts[0].Fields()["ward"].QueryValue()) - assert.Equal(t, "F", contacts[0].Fields()["gender"].QueryValue()) - assert.Equal(t, (*flows.FieldValue)(nil), contacts[0].Fields()["age"]) - - assert.Equal(t, "Bob", contacts[1].Name()) - assert.NotNil(t, contacts[1].Fields()["joined"].QueryValue()) - assert.Equal(t, 2, len(contacts[1].URNs())) - assert.Equal(t, contacts[1].URNs()[0].String(), "tel:+16055742222?id=10001&priority=1000") - assert.Equal(t, contacts[1].URNs()[1].String(), "whatsapp:250788373373?id=20121&priority=999") - assert.Equal(t, 0, contacts[1].Groups().Count()) - - assert.Equal(t, "George", contacts[2].Name()) - assert.Equal(t, decimal.RequireFromString("30"), contacts[2].Fields()["age"].QueryValue()) - assert.Equal(t, 0, len(contacts[2].URNs())) - assert.Equal(t, 0, contacts[2].Groups().Count()) - } + cathy, bob, george := contacts[0], contacts[1], contacts[2] + + assert.Equal(t, "Cathy", cathy.Name()) + assert.Equal(t, len(cathy.URNs()), 1) + assert.Equal(t, cathy.URNs()[0].String(), "tel:+16055741111?id=10000&priority=1000") + assert.Equal(t, 1, cathy.Groups().Count()) + assert.Equal(t, 2, cathy.Tickets().Count()) + + cathyTickets := cathy.Tickets().All() + assert.Equal(t, "Problem!", cathyTickets[0].Subject()) + assert.Equal(t, "agent1@nyaruka.com", cathyTickets[0].Assignee().Email()) + assert.Equal(t, "Another Problem!", cathyTickets[1].Subject()) + assert.Nil(t, cathyTickets[1].Assignee()) + + assert.Equal(t, "Yobe", cathy.Fields()["state"].QueryValue()) + assert.Equal(t, "Dokshi", cathy.Fields()["ward"].QueryValue()) + assert.Equal(t, "F", cathy.Fields()["gender"].QueryValue()) + assert.Equal(t, (*flows.FieldValue)(nil), cathy.Fields()["age"]) + + assert.Equal(t, "Bob", bob.Name()) + assert.NotNil(t, bob.Fields()["joined"].QueryValue()) + assert.Equal(t, 2, len(bob.URNs())) + assert.Equal(t, "tel:+16055742222?id=10001&priority=1000", bob.URNs()[0].String()) + assert.Equal(t, "whatsapp:250788373373?id=20121&priority=999", bob.URNs()[1].String()) + assert.Equal(t, 0, bob.Groups().Count()) + assert.Equal(t, 0, bob.Tickets().Count()) // because ticketer no longer exists + + assert.Equal(t, "George", george.Name()) + assert.Equal(t, decimal.RequireFromString("30"), george.Fields()["age"].QueryValue()) + assert.Equal(t, 0, len(george.URNs())) + assert.Equal(t, 0, george.Groups().Count()) + assert.Equal(t, 0, george.Tickets().Count()) // change bob to have a preferred URN and channel of our telephone - channel := org.ChannelByID(models.TwilioChannelID) - err = modelContacts[1].UpdatePreferredURN(ctx, db, org, models.BobURNID, channel) + channel := org.ChannelByID(testdata.TwilioChannel.ID) + err = modelContacts[1].UpdatePreferredURN(ctx, db, org, testdata.Bob.URNID, channel) assert.NoError(t, err) - bob, err := modelContacts[1].FlowContact(org) + bob, err = modelContacts[1].FlowContact(org) assert.NoError(t, err) assert.Equal(t, "tel:+16055742222?channel=74729f45-7f29-4868-9dc4-90e491e3c7d8&id=10001&priority=1000", bob.URNs()[0].String()) assert.Equal(t, "whatsapp:250788373373?id=20121&priority=999", bob.URNs()[1].String()) // add another tel urn to bob - testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("tel:+250788373373"), 10) + testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, urns.URN("tel:+250788373373"), 10) // reload the contact - modelContacts, err = models.LoadContacts(ctx, db, org, []models.ContactID{models.BobID}) + modelContacts, err = models.LoadContacts(ctx, db, org, []models.ContactID{testdata.Bob.ID}) assert.NoError(t, err) // set our preferred channel again @@ -117,17 +136,16 @@ func TestContacts(t *testing.T) { } func TestCreateContact(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() - models.FlushCache() + ctx, _, db, _ := testsuite.Get() - testdata.InsertContactGroup(t, db, models.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Kinyarwanda", "language = kin") + defer testsuite.Reset() + + testdata.InsertContactGroup(db, testdata.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Kinyarwanda", "language = kin") // add an orphaned URN - testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200002"), 100) + testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200002"), 100) - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) contact, flowContact, err := models.CreateContact(ctx, db, oa, models.UserID(1), "Rich", envs.Language(`kin`), []urns.URN{urns.URN("telegram:200001"), urns.URN("telegram:200002")}) @@ -148,10 +166,11 @@ func TestCreateContact(t *testing.T) { } func TestCreateContactRace(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) mdb := testsuite.NewMockDB(db, func(funcName string, call int) error { @@ -176,23 +195,22 @@ func TestCreateContactRace(t *testing.T) { } func TestGetOrCreateContact(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() - testdata.InsertContactGroup(t, db, models.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Telegrammer", `telegram = 100001`) + defer testsuite.Reset() + + testdata.InsertContactGroup(db, testdata.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Telegrammer", `telegram = 100001`) // add some orphaned URNs - testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200001"), 100) - testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200002"), 100) + testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200001"), 100) + testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200002"), 100) var maxContactID models.ContactID db.Get(&maxContactID, `SELECT max(id) FROM contacts_contact`) newContact := func() models.ContactID { maxContactID++; return maxContactID } prevContact := func() models.ContactID { return maxContactID } - models.FlushCache() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) tcs := []struct { @@ -205,34 +223,34 @@ func TestGetOrCreateContact(t *testing.T) { GroupsUUIDs []assets.GroupUUID }{ { - models.Org1, - []urns.URN{models.CathyURN}, - models.CathyID, + testdata.Org1.ID, + []urns.URN{testdata.Cathy.URN}, + testdata.Cathy.ID, false, []urns.URN{"tel:+16055741111?id=10000&priority=1000"}, models.NilChannelID, - []assets.GroupUUID{models.DoctorsGroupUUID}, + []assets.GroupUUID{testdata.DoctorsGroup.UUID}, }, { - models.Org1, - []urns.URN{urns.URN(models.CathyURN.String() + "?foo=bar")}, - models.CathyID, // only URN identity is considered + testdata.Org1.ID, + []urns.URN{urns.URN(testdata.Cathy.URN.String() + "?foo=bar")}, + testdata.Cathy.ID, // only URN identity is considered false, []urns.URN{"tel:+16055741111?id=10000&priority=1000"}, models.NilChannelID, - []assets.GroupUUID{models.DoctorsGroupUUID}, + []assets.GroupUUID{testdata.DoctorsGroup.UUID}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100001")}, newContact(), // creates new contact true, []urns.URN{"telegram:100001?channel=74729f45-7f29-4868-9dc4-90e491e3c7d8&id=20123&priority=1000"}, - models.TwilioChannelID, + testdata.TwilioChannel.ID, []assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100001")}, prevContact(), // returns the same created contact false, @@ -241,7 +259,7 @@ func TestGetOrCreateContact(t *testing.T) { []assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100001"), urns.URN("telegram:100002")}, prevContact(), // same again as other URNs don't exist false, @@ -250,7 +268,7 @@ func TestGetOrCreateContact(t *testing.T) { []assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100002"), urns.URN("telegram:100001")}, prevContact(), // same again as other URNs don't exist false, @@ -259,7 +277,7 @@ func TestGetOrCreateContact(t *testing.T) { []assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:200001"), urns.URN("telegram:100001")}, prevContact(), // same again as other URNs are orphaned false, @@ -268,7 +286,7 @@ func TestGetOrCreateContact(t *testing.T) { []assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100003"), urns.URN("telegram:100004")}, // 2 new URNs newContact(), true, @@ -277,7 +295,7 @@ func TestGetOrCreateContact(t *testing.T) { []assets.GroupUUID{}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100005"), urns.URN("telegram:200002")}, // 1 new, 1 orphaned newContact(), true, @@ -305,10 +323,11 @@ func TestGetOrCreateContact(t *testing.T) { } func TestGetOrCreateContactRace(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) mdb := testsuite.NewMockDB(db, func(funcName string, call int) error { @@ -333,20 +352,19 @@ func TestGetOrCreateContactRace(t *testing.T) { } func TestGetOrCreateContactIDsFromURNs(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() // add an orphaned URN - testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200001"), 100) + testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200001"), 100) var maxContactID models.ContactID db.Get(&maxContactID, `SELECT max(id) FROM contacts_contact`) newContact := func() models.ContactID { maxContactID++; return maxContactID } prevContact := func() models.ContactID { return maxContactID } - models.FlushCache() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + org, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) tcs := []struct { @@ -355,30 +373,30 @@ func TestGetOrCreateContactIDsFromURNs(t *testing.T) { ContactIDs map[urns.URN]models.ContactID }{ { - models.Org1, - []urns.URN{models.CathyURN}, - map[urns.URN]models.ContactID{models.CathyURN: models.CathyID}, + testdata.Org1.ID, + []urns.URN{testdata.Cathy.URN}, + map[urns.URN]models.ContactID{testdata.Cathy.URN: testdata.Cathy.ID}, }, { - models.Org1, - []urns.URN{urns.URN(models.CathyURN.String() + "?foo=bar")}, - map[urns.URN]models.ContactID{urns.URN(models.CathyURN.String() + "?foo=bar"): models.CathyID}, + testdata.Org1.ID, + []urns.URN{urns.URN(testdata.Cathy.URN.String() + "?foo=bar")}, + map[urns.URN]models.ContactID{urns.URN(testdata.Cathy.URN.String() + "?foo=bar"): testdata.Cathy.ID}, }, { - models.Org1, - []urns.URN{models.CathyURN, urns.URN("telegram:100001")}, + testdata.Org1.ID, + []urns.URN{testdata.Cathy.URN, urns.URN("telegram:100001")}, map[urns.URN]models.ContactID{ - models.CathyURN: models.CathyID, + testdata.Cathy.URN: testdata.Cathy.ID, urns.URN("telegram:100001"): newContact(), }, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:100001")}, map[urns.URN]models.ContactID{urns.URN("telegram:100001"): prevContact()}, }, { - models.Org1, + testdata.Org1.ID, []urns.URN{urns.URN("telegram:200001")}, map[urns.URN]models.ContactID{urns.URN("telegram:200001"): newContact()}, // new contact assigned orphaned URN }, @@ -392,11 +410,9 @@ func TestGetOrCreateContactIDsFromURNs(t *testing.T) { } func TestGetOrCreateContactIDsFromURNsRace(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - models.FlushCache() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) mdb := testsuite.NewMockDB(db, func(funcName string, call int) error { @@ -423,56 +439,56 @@ func TestGetOrCreateContactIDsFromURNsRace(t *testing.T) { } func TestGetContactIDsFromReferences(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - ids, err := models.GetContactIDsFromReferences(ctx, db, models.Org1, []*flows.ContactReference{ - flows.NewContactReference(models.CathyUUID, "Cathy"), - flows.NewContactReference(models.BobUUID, "Bob"), + ids, err := models.GetContactIDsFromReferences(ctx, db, testdata.Org1.ID, []*flows.ContactReference{ + flows.NewContactReference(testdata.Cathy.UUID, "Cathy"), + flows.NewContactReference(testdata.Bob.UUID, "Bob"), }) require.NoError(t, err) - assert.ElementsMatch(t, []models.ContactID{models.CathyID, models.BobID}, ids) + assert.ElementsMatch(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, ids) } func TestStopContact(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() // stop kathy - err := models.StopContact(ctx, db, models.Org1, models.CathyID) + err := models.StopContact(ctx, db, testdata.Org1.ID, testdata.Cathy.ID) assert.NoError(t, err) // verify she's only in the stopped group - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = $1`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Cathy.ID).Returns(1) // verify she's stopped - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S' AND is_active = TRUE`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S' AND is_active = TRUE`, testdata.Cathy.ID).Returns(1) } func TestUpdateContactLastSeenAndModifiedOn(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) t0 := time.Now() - err = models.UpdateContactModifiedOn(ctx, db, []models.ContactID{models.CathyID}) + err = models.UpdateContactModifiedOn(ctx, db, []models.ContactID{testdata.Cathy.ID}) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on IS NULL`, []interface{}{t0}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on IS NULL`, t0).Returns(1) t1 := time.Now().Truncate(time.Millisecond) time.Sleep(time.Millisecond * 5) - err = models.UpdateContactLastSeenOn(ctx, db, models.CathyID, t1) + err = models.UpdateContactLastSeenOn(ctx, db, testdata.Cathy.ID, t1) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on = $1`, []interface{}{t1}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on = $1`, t1).Returns(1) - cathy, err := models.LoadContact(ctx, db, oa, models.CathyID) + cathy, err := models.LoadContact(ctx, db, oa, testdata.Cathy.ID) require.NoError(t, err) assert.NotNil(t, cathy.LastSeenOn()) assert.True(t, t1.Equal(*cathy.LastSeenOn())) @@ -487,69 +503,70 @@ func TestUpdateContactLastSeenAndModifiedOn(t *testing.T) { assert.True(t, t2.Equal(*cathy.LastSeenOn())) // and that also updates the database - cathy, err = models.LoadContact(ctx, db, oa, models.CathyID) + cathy, err = models.LoadContact(ctx, db, oa, testdata.Cathy.ID) require.NoError(t, err) assert.True(t, t2.Equal(*cathy.LastSeenOn())) assert.True(t, cathy.ModifiedOn().After(t2)) } func TestUpdateContactModifiedBy(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() err := models.UpdateContactModifiedBy(ctx, db, []models.ContactID{}, models.UserID(0)) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(0)}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = NULL`, testdata.Cathy.ID).Returns(0) - err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{models.CathyID}, models.UserID(0)) + err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{testdata.Cathy.ID}, models.UserID(0)) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(0)}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = NULL`, testdata.Cathy.ID).Returns(0) - err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{models.CathyID}, models.UserID(1)) + err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{testdata.Cathy.ID}, models.UserID(1)) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(1)}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = 1`, testdata.Cathy.ID).Returns(1) } func TestUpdateContactStatus(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() err := models.UpdateContactStatus(ctx, db, []*models.ContactStatusChange{}) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(0) changes := make([]*models.ContactStatusChange, 0, 1) - changes = append(changes, &models.ContactStatusChange{models.CathyID, flows.ContactStatusBlocked}) + changes = append(changes, &models.ContactStatusChange{testdata.Cathy.ID, flows.ContactStatusBlocked}) err = models.UpdateContactStatus(ctx, db, changes) + assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(0) changes = make([]*models.ContactStatusChange, 0, 1) - changes = append(changes, &models.ContactStatusChange{models.CathyID, flows.ContactStatusStopped}) + changes = append(changes, &models.ContactStatusChange{testdata.Cathy.ID, flows.ContactStatusStopped}) err = models.UpdateContactStatus(ctx, db, changes) + assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(1) } func TestUpdateContactURNs(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() + ctx, _, db, _ := testsuite.Get() + + defer testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) numInitialURNs := 0 @@ -562,52 +579,52 @@ func TestUpdateContactURNs(t *testing.T) { assert.Equal(t, expected, actual, "URNs mismatch for contact %d", contactID) } - assertContactURNs(models.CathyID, []string{"tel:+16055741111"}) - assertContactURNs(models.BobID, []string{"tel:+16055742222"}) - assertContactURNs(models.GeorgeID, []string{"tel:+16055743333"}) + assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055741111"}) + assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222"}) + assertContactURNs(testdata.George.ID, []string{"tel:+16055743333"}) - cathyURN := urns.URN(fmt.Sprintf("tel:+16055741111?id=%d", models.CathyURNID)) - bobURN := urns.URN(fmt.Sprintf("tel:+16055742222?id=%d", models.BobURNID)) + cathyURN := urns.URN(fmt.Sprintf("tel:+16055741111?id=%d", testdata.Cathy.URNID)) + bobURN := urns.URN(fmt.Sprintf("tel:+16055742222?id=%d", testdata.Bob.URNID)) // give Cathy a new higher priority URN - err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", cathyURN}}}) + err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", cathyURN}}}) assert.NoError(t, err) - assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055741111"}) + assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055741111"}) // give Bob a new lower priority URN - err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.BobID, models.Org1, []urns.URN{bobURN, "tel:+16055700002"}}}) + err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Bob.ID, testdata.Org1.ID, []urns.URN{bobURN, "tel:+16055700002"}}}) assert.NoError(t, err) - assertContactURNs(models.BobID, []string{"tel:+16055742222", "tel:+16055700002"}) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 0) // shouldn't be any orphan URNs - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+2) // but 2 new URNs + assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222", "tel:+16055700002"}) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`).Returns(0) // shouldn't be any orphan URNs + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn`).Returns(numInitialURNs + 2) // but 2 new URNs // remove a URN from Cathy - err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001"}}}) + err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001"}}}) assert.NoError(t, err) - assertContactURNs(models.CathyID, []string{"tel:+16055700001"}) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 1) // now orphaned + assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001"}) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`).Returns(1) // now orphaned // steal a URN from Bob - err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", "tel:+16055700002"}}}) + err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", "tel:+16055700002"}}}) assert.NoError(t, err) - assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055700002"}) - assertContactURNs(models.BobID, []string{"tel:+16055742222"}) + assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055700002"}) + assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222"}) // steal the URN back from Cathy whilst simulataneously adding new URN to Cathy and not-changing anything for George err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{ - {models.BobID, models.Org1, []urns.URN{"tel:+16055742222", "tel:+16055700002"}}, - {models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", "tel:+16055700003"}}, - {models.GeorgeID, models.Org1, []urns.URN{"tel:+16055743333"}}, + {testdata.Bob.ID, testdata.Org1.ID, []urns.URN{"tel:+16055742222", "tel:+16055700002"}}, + {testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", "tel:+16055700003"}}, + {testdata.George.ID, testdata.Org1.ID, []urns.URN{"tel:+16055743333"}}, }) assert.NoError(t, err) - assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055700003"}) - assertContactURNs(models.BobID, []string{"tel:+16055742222", "tel:+16055700002"}) - assertContactURNs(models.GeorgeID, []string{"tel:+16055743333"}) + assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055700003"}) + assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222", "tel:+16055700002"}) + assertContactURNs(testdata.George.ID, []string{"tel:+16055743333"}) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn`).Returns(numInitialURNs + 3) } diff --git a/core/models/fields_test.go b/core/models/fields_test.go index 4595aa417..edab85c78 100644 --- a/core/models/fields_test.go +++ b/core/models/fields_test.go @@ -1,56 +1,43 @@ -package models +package models_test import ( "testing" "github.com/nyaruka/goflow/assets" + "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 TestFields(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - userFields, systemFields, err := loadFields(ctx, db, 1) - assert.NoError(t, err) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshFields) + require.NoError(t, err) - expectedUserFields := []struct { - Key string - Name string - ValueType assets.FieldType + expectedFields := []struct { + field testdata.Field + key string + name string + valueType assets.FieldType }{ - {"age", "Age", assets.FieldTypeNumber}, - {"district", "District", assets.FieldTypeDistrict}, - {"gender", "Gender", assets.FieldTypeText}, - {"joined", "Joined", assets.FieldTypeDatetime}, - {"state", "State", assets.FieldTypeState}, - {"ward", "Ward", assets.FieldTypeWard}, + {*testdata.GenderField, "gender", "Gender", assets.FieldTypeText}, + {*testdata.AgeField, "age", "Age", assets.FieldTypeNumber}, + {*testdata.CreatedOnField, "created_on", "Created On", assets.FieldTypeDatetime}, + {*testdata.LastSeenOnField, "last_seen_on", "Last Seen On", assets.FieldTypeDatetime}, } + for _, tc := range expectedFields { + field := oa.FieldByUUID(tc.field.UUID) + require.NotNil(t, field, "no such field: %s", tc.field.UUID) - assert.Equal(t, len(expectedUserFields), len(userFields)) - for i, tc := range expectedUserFields { - assert.Equal(t, tc.Key, userFields[i].Key()) - assert.Equal(t, tc.Name, userFields[i].Name()) - assert.Equal(t, tc.ValueType, userFields[i].Type()) - } - - expectedSystemFields := []struct { - Key string - Name string - ValueType assets.FieldType - }{ - {"created_on", "Created On", assets.FieldTypeDatetime}, - {"id", "ID", assets.FieldTypeNumber}, - {"language", "Language", assets.FieldTypeText}, - {"last_seen_on", "Last Seen On", assets.FieldTypeDatetime}, - {"name", "Name", assets.FieldTypeText}, - } + fieldByKey := oa.FieldByKey(tc.key) + assert.Equal(t, field, fieldByKey) - assert.Equal(t, len(expectedSystemFields), len(systemFields)) - for i, tc := range expectedSystemFields { - assert.Equal(t, tc.Key, systemFields[i].Key()) - assert.Equal(t, tc.Name, systemFields[i].Name()) - assert.Equal(t, tc.ValueType, systemFields[i].Type()) + assert.Equal(t, tc.field.UUID, field.UUID(), "uuid mismatch for field %s", tc.field.ID) + assert.Equal(t, tc.key, field.Key()) + assert.Equal(t, tc.name, field.Name()) + assert.Equal(t, tc.valueType, field.Type()) } } diff --git a/core/models/flows.go b/core/models/flows.go index b3bc3dfca..ebc65be60 100644 --- a/core/models/flows.go +++ b/core/models/flows.go @@ -81,14 +81,22 @@ func (f *Flow) FlowType() FlowType { return f.f.FlowType } // Version returns the version this flow was authored in func (f *Flow) Version() string { return f.f.Version } -// IVRRetryWait returns the wait before retrying a failed IVR call -func (f *Flow) IVRRetryWait() time.Duration { +// IVRRetryWait returns the wait before retrying a failed IVR call (nil means no retry) +func (f *Flow) IVRRetryWait() *time.Duration { + wait := ConnectionRetryWait + value := f.f.Config.Get(flowConfigIVRRetryMinutes, nil) fv, isFloat := value.(float64) if isFloat { - return time.Minute * time.Duration(int(fv)) + minutes := int(fv) + if minutes >= 0 { + wait = time.Minute * time.Duration(minutes) + } else { + return nil // ivr_retry -1 means no retry + } } - return ConnectionRetryWait + + return &wait } // IgnoreTriggers returns whether this flow ignores triggers @@ -106,7 +114,7 @@ func (f *Flow) cloneWithNewDefinition(def []byte) *Flow { return &c } -func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) { +func FlowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) { // first try to look up in our assets flow, _ := oa.Flow(flowUUID) if flow != nil { @@ -119,11 +127,11 @@ func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID ass return flowID, err } -func loadFlowByUUID(ctx context.Context, db Queryer, orgID OrgID, flowUUID assets.FlowUUID) (*Flow, error) { +func LoadFlowByUUID(ctx context.Context, db Queryer, orgID OrgID, flowUUID assets.FlowUUID) (*Flow, error) { return loadFlow(ctx, db, selectFlowByUUIDSQL, orgID, flowUUID) } -func loadFlowByID(ctx context.Context, db Queryer, orgID OrgID, flowID FlowID) (*Flow, error) { +func LoadFlowByID(ctx context.Context, db Queryer, orgID OrgID, flowID FlowID) (*Flow, error) { return loadFlow(ctx, db, selectFlowByIDSQL, orgID, flowID) } diff --git a/core/models/flows_test.go b/core/models/flows_test.go index 3ffce2c3e..a668aeefd 100644 --- a/core/models/flows_test.go +++ b/core/models/flows_test.go @@ -1,4 +1,4 @@ -package models +package models_test import ( "testing" @@ -6,72 +6,80 @@ import ( "github.com/nyaruka/goflow/assets" "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/stretchr/testify/assert" ) -func TestFlows(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() +func TestLoadFlows(t *testing.T) { + ctx, rt, db, _ := testsuite.Get() - db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": 30}'::json WHERE id = $1`, IVRFlowID) + db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": 30}'::json WHERE id = $1`, testdata.IVRFlow.ID) + db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": -1}'::json WHERE id = $1`, testdata.SurveyorFlow.ID) + + sixtyMinutes := 60 * time.Minute + thirtyMinutes := 30 * time.Minute tcs := []struct { - OrgID OrgID - FlowID FlowID - FlowUUID assets.FlowUUID - Name string - IVRRetry time.Duration - Found bool + org *testdata.Org + flowID models.FlowID + flowUUID assets.FlowUUID + expectedName string + expectedIVRRetry *time.Duration }{ - {Org1, FavoritesFlowID, FavoritesFlowUUID, "Favorites", 60 * time.Minute, true}, - {Org1, IVRFlowID, IVRFlowUUID, "IVR Flow", 30 * time.Minute, true}, - {Org2, FlowID(0), assets.FlowUUID("51e3c67d-8483-449c-abf7-25e50686f0db"), "", 0, false}, + {testdata.Org1, testdata.Favorites.ID, testdata.Favorites.UUID, "Favorites", &sixtyMinutes}, // will use default IVR retry + {testdata.Org1, testdata.IVRFlow.ID, testdata.IVRFlow.UUID, "IVR Flow", &thirtyMinutes}, // will have explicit IVR retry + {testdata.Org1, testdata.SurveyorFlow.ID, testdata.SurveyorFlow.UUID, "Contact Surveyor", nil}, // will have no IVR retry + {testdata.Org2, models.FlowID(0), assets.FlowUUID("51e3c67d-8483-449c-abf7-25e50686f0db"), "", nil}, } - for _, tc := range tcs { - flow, err := loadFlowByUUID(ctx, db, tc.OrgID, tc.FlowUUID) + for i, tc := range tcs { + // test loading by UUID + flow, err := models.LoadFlowByUUID(ctx, db, tc.org.ID, tc.flowUUID) assert.NoError(t, err) - if tc.Found { - assert.Equal(t, tc.Name, flow.Name()) - assert.Equal(t, tc.FlowID, flow.ID()) - assert.Equal(t, tc.FlowUUID, flow.UUID()) - assert.Equal(t, tc.IVRRetry, flow.IVRRetryWait()) + if tc.expectedName != "" { + assert.Equal(t, tc.flowID, flow.ID()) + assert.Equal(t, tc.flowUUID, flow.UUID()) + assert.Equal(t, tc.expectedName, flow.Name(), "%d: name mismatch", i) + assert.Equal(t, tc.expectedIVRRetry, flow.IVRRetryWait(), "%d: IVR retry mismatch", i) - _, err := goflow.ReadFlow(flow.Definition()) + _, err := goflow.ReadFlow(rt.Config, flow.Definition()) assert.NoError(t, err) } else { assert.Nil(t, flow) } - flow, err = loadFlowByID(ctx, db, tc.OrgID, tc.FlowID) + // test loading by ID + flow, err = models.LoadFlowByID(ctx, db, tc.org.ID, tc.flowID) assert.NoError(t, err) - if tc.Found { - assert.Equal(t, tc.Name, flow.Name()) - assert.Equal(t, tc.FlowID, flow.ID()) - assert.Equal(t, tc.FlowUUID, flow.UUID()) + if tc.expectedName != "" { + assert.Equal(t, tc.flowID, flow.ID()) + assert.Equal(t, tc.flowUUID, flow.UUID()) + assert.Equal(t, tc.expectedName, flow.Name(), "%d: name mismatch", i) + assert.Equal(t, tc.expectedIVRRetry, flow.IVRRetryWait(), "%d: IVR retry mismatch", i) } else { assert.Nil(t, flow) } } } -func TestGetFlowUUID(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - org, _ := GetOrgAssets(ctx, db, Org1) +func TestFlowIDForUUID(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + org, _ := models.GetOrgAssets(ctx, db, testdata.Org1.ID) tx, err := db.BeginTxx(ctx, nil) assert.NoError(t, err) - id, err := flowIDForUUID(ctx, tx, org, FavoritesFlowUUID) + id, err := models.FlowIDForUUID(ctx, tx, org, testdata.Favorites.UUID) assert.NoError(t, err) - assert.Equal(t, FavoritesFlowID, id) + assert.Equal(t, testdata.Favorites.ID, id) // make favorite inactive - tx.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, FavoritesFlowID) + tx.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, testdata.Favorites.ID) tx.Commit() tx, err = db.BeginTxx(ctx, nil) @@ -79,10 +87,10 @@ func TestGetFlowUUID(t *testing.T) { defer tx.Rollback() // clear our assets so it isn't cached - FlushCache() - org, _ = GetOrgAssets(ctx, db, Org1) + models.FlushCache() + org, _ = models.GetOrgAssets(ctx, db, testdata.Org1.ID) - id, err = flowIDForUUID(ctx, tx, org, FavoritesFlowUUID) + id, err = models.FlowIDForUUID(ctx, tx, org, testdata.Favorites.UUID) assert.NoError(t, err) - assert.Equal(t, FavoritesFlowID, id) + assert.Equal(t, testdata.Favorites.ID, id) } diff --git a/core/models/globals_test.go b/core/models/globals_test.go index 598ba88eb..cc5c9cb90 100644 --- a/core/models/globals_test.go +++ b/core/models/globals_test.go @@ -1,25 +1,26 @@ -package models +package models_test import ( "testing" + "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 TestGlobals(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() +func TestLoadGlobals(t *testing.T) { + ctx, _, db, _ := testsuite.Get() // set one of our global values to empty - db.MustExec(`UPDATE globals_global SET value = '' WHERE org_id = $1 AND key = $2`, Org1, "org_name") + db.MustExec(`UPDATE globals_global SET value = '' WHERE org_id = $1 AND key = $2`, testdata.Org1.ID, "org_name") - tx, err := db.BeginTxx(ctx, nil) - assert.NoError(t, err) - defer tx.Rollback() + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshGlobals) + require.NoError(t, err) - globals, err := loadGlobals(ctx, tx, 1) - assert.NoError(t, err) + globals, err := oa.Globals() + require.NoError(t, err) assert.Equal(t, 2, len(globals)) assert.Equal(t, "access_token", globals[0].Key()) diff --git a/core/models/groups.go b/core/models/groups.go index 5a391c60d..ce8ab590b 100644 --- a/core/models/groups.go +++ b/core/models/groups.go @@ -376,7 +376,7 @@ func PopulateDynamicGroup(ctx context.Context, db *sqlx.DB, es *elastic.Client, return 0, errors.Wrapf(err, "error performing query: %s for group: %d", query, groupID) } - // find which need to be added or removed + // find which contacts need to be added or removed adds := make([]ContactID, 0, 100) for _, id := range new { if !present[id] { @@ -409,5 +409,15 @@ func PopulateDynamicGroup(ctx context.Context, db *sqlx.DB, es *elastic.Client, return 0, errors.Wrapf(err, "error marking dynamic group as ready") } + // finally update modified_on for all affected contacts to ensure these changes are seen by rp-indexer + changed := make([]ContactID, 0, len(adds)) + changed = append(changed, adds...) + changed = append(changed, removals...) + + err = UpdateContactModifiedOn(ctx, db, changed) + if err != nil { + return 0, errors.Wrapf(err, "error updating contact modified_on after group population") + } + return len(new), nil } diff --git a/core/models/groups_test.go b/core/models/groups_test.go index 1a214b2e6..e5751c76e 100644 --- a/core/models/groups_test.go +++ b/core/models/groups_test.go @@ -9,6 +9,7 @@ import ( "github.com/nyaruka/goflow/assets" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/lib/pq" "github.com/olivere/elastic/v7" @@ -17,8 +18,9 @@ import ( ) func TestLoadGroups(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.NewMockDB(testsuite.DB(), func(funcName string, call int) error { + ctx, _, db0, _ := testsuite.Get() + + db := testsuite.NewMockDB(db0, func(funcName string, call int) error { // fail first query for groups if funcName == "QueryxContext" && call == 0 { return errors.New("boom") @@ -26,10 +28,10 @@ func TestLoadGroups(t *testing.T) { return nil }) - groups, err := models.LoadGroups(ctx, db, 1) + _, err := models.LoadGroups(ctx, db, testdata.Org1.ID) require.EqualError(t, err, "error querying groups for org: 1: boom") - groups, err = models.LoadGroups(ctx, db, 1) + groups, err := models.LoadGroups(ctx, db, 1) require.NoError(t, err) tcs := []struct { @@ -38,8 +40,8 @@ func TestLoadGroups(t *testing.T) { Name string Query string }{ - {models.DoctorsGroupID, models.DoctorsGroupUUID, "Doctors", ""}, - {models.TestersGroupID, models.TestersGroupUUID, "Testers", ""}, + {testdata.DoctorsGroup.ID, testdata.DoctorsGroup.UUID, "Doctors", ""}, + {testdata.TestersGroup.ID, testdata.TestersGroup.UUID, "Testers", ""}, } assert.Equal(t, 2, len(groups)) @@ -53,8 +55,7 @@ func TestLoadGroups(t *testing.T) { } func TestDynamicGroups(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() // insert an event on our campaign var eventID models.CampaignEventID @@ -62,22 +63,20 @@ func TestDynamicGroups(t *testing.T) { `INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour, campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode) VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I') RETURNING id`, - uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.JoinedFieldID) + uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.JoinedField.ID) // clear Cathy's value testsuite.DB().MustExec( `update contacts_contact set fields = fields - $2 - WHERE id = $1`, models.CathyID, models.JoinedFieldUUID) + WHERE id = $1`, testdata.Cathy.ID, testdata.JoinedField.UUID) // and populate Bob's testsuite.DB().MustExec( fmt.Sprintf(`update contacts_contact set fields = fields || '{"%s": { "text": "2029-09-15T12:00:00+00:00", "datetime": "2029-09-15T12:00:00+00:00" }}'::jsonb - WHERE id = $1`, models.JoinedFieldUUID), models.BobID) + WHERE id = $1`, testdata.JoinedField.UUID), testdata.Bob.ID) - // clear our org cache so we reload org campaigns and events - models.FlushCache() - org, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshCampaigns|models.RefreshGroups) assert.NoError(t, err) esServer := testsuite.NewMockElasticServer() @@ -118,8 +117,8 @@ func TestDynamicGroups(t *testing.T) { } }` - cathyHit := fmt.Sprintf(contactHit, models.CathyID) - bobHit := fmt.Sprintf(contactHit, models.BobID) + cathyHit := fmt.Sprintf(contactHit, testdata.Cathy.ID) + bobHit := fmt.Sprintf(contactHit, testdata.Bob.ID) tcs := []struct { Query string @@ -130,47 +129,45 @@ func TestDynamicGroups(t *testing.T) { { "cathy", cathyHit, - []models.ContactID{models.CathyID}, + []models.ContactID{testdata.Cathy.ID}, []models.ContactID{}, }, { "bob", bobHit, - []models.ContactID{models.BobID}, - []models.ContactID{models.BobID}, + []models.ContactID{testdata.Bob.ID}, + []models.ContactID{testdata.Bob.ID}, }, { "unchanged", bobHit, - []models.ContactID{models.BobID}, - []models.ContactID{models.BobID}, + []models.ContactID{testdata.Bob.ID}, + []models.ContactID{testdata.Bob.ID}, }, } for _, tc := range tcs { - err := models.UpdateGroupStatus(ctx, db, models.DoctorsGroupID, models.GroupStatusInitializing) + err := models.UpdateGroupStatus(ctx, db, testdata.DoctorsGroup.ID, models.GroupStatusInitializing) assert.NoError(t, err) esServer.NextResponse = tc.ESResponse - count, err := models.PopulateDynamicGroup(ctx, db, es, org, models.DoctorsGroupID, tc.Query) + count, err := models.PopulateDynamicGroup(ctx, db, es, oa, testdata.DoctorsGroup.ID, tc.Query) assert.NoError(t, err, "error populating dynamic group for: %s", tc.Query) assert.Equal(t, count, len(tc.ContactIDs)) // assert the current group membership - contactIDs, err := models.ContactIDsForGroupIDs(ctx, db, []models.GroupID{models.DoctorsGroupID}) + contactIDs, err := models.ContactIDsForGroupIDs(ctx, db, []models.GroupID{testdata.DoctorsGroup.ID}) + assert.NoError(t, err) assert.Equal(t, tc.ContactIDs, contactIDs) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from contacts_contactgroup WHERE id = $1 AND status = 'R'`, - []interface{}{models.DoctorsGroupID}, 1, "wrong number of contacts in group for query: %s", tc.Query) + testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup WHERE id = $1 AND status = 'R'`, testdata.DoctorsGroup.ID). + Returns(1, "wrong number of contacts in group for query: %s", tc.Query) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from campaigns_eventfire WHERE event_id = $1`, - []interface{}{eventID}, len(tc.EventContactIDs), "wrong number of contacts with events for query: %s", tc.Query) + testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE event_id = $1`, eventID). + Returns(len(tc.EventContactIDs), "wrong number of contacts with events for query: %s", tc.Query) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from campaigns_eventfire WHERE event_id = $1 AND contact_id = ANY($2)`, - []interface{}{eventID, pq.Array(tc.EventContactIDs)}, len(tc.EventContactIDs), "wrong contacts with events for query: %s", tc.Query) + testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE event_id = $1 AND contact_id = ANY($2)`, eventID, pq.Array(tc.EventContactIDs)). + Returns(len(tc.EventContactIDs), "wrong contacts with events for query: %s", tc.Query) } } diff --git a/core/models/http_logs_test.go b/core/models/http_logs_test.go index c134bd183..48e52a910 100644 --- a/core/models/http_logs_test.go +++ b/core/models/http_logs_test.go @@ -9,37 +9,32 @@ import ( "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) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() // insert a log - log := models.NewClassifierCalledLog(models.Org1, models.WitID, "http://foo.bar", "GET /", "STATUS 200", false, time.Second, time.Now()) + log := models.NewClassifierCalledLog(testdata.Org1.ID, testdata.Wit.ID, "http://foo.bar", "GET /", "STATUS 200", false, time.Second, time.Now()) err := models.InsertHTTPLogs(ctx, db, []*models.HTTPLog{log}) assert.Nil(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = FALSE`, - []interface{}{models.Org1, models.WitID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = FALSE`, testdata.Org1.ID, testdata.Wit.ID).Returns(1) // insert a log with nil response - log = models.NewClassifierCalledLog(models.Org1, models.WitID, "http://foo.bar", "GET /", "", true, time.Second, time.Now()) + log = models.NewClassifierCalledLog(testdata.Org1.ID, testdata.Wit.ID, "http://foo.bar", "GET /", "", true, time.Second, time.Now()) err = models.InsertHTTPLogs(ctx, db, []*models.HTTPLog{log}) assert.Nil(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = TRUE AND response IS NULL`, - []interface{}{models.Org1, models.WitID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = TRUE AND response IS NULL`, testdata.Org1.ID, testdata.Wit.ID).Returns(1) } func TestHTTPLogger(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -49,7 +44,7 @@ func TestHTTPLogger(t *testing.T) { }, })) - mailgun, err := models.LookupTicketerByUUID(ctx, db, models.MailgunUUID) + mailgun, err := models.LookupTicketerByUUID(ctx, db, testdata.Mailgun.UUID) require.NoError(t, err) logger := &models.HTTPLogger{} @@ -71,7 +66,5 @@ func TestHTTPLogger(t *testing.T) { err = logger.Insert(ctx, db) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND ticketer_id = $2`, - []interface{}{models.Org1, models.MailgunID}, 2) + testsuite.AssertQuery(t, 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/imports_test.go b/core/models/imports_test.go index cba7b865b..a079506ac 100644 --- a/core/models/imports_test.go +++ b/core/models/imports_test.go @@ -26,23 +26,20 @@ import ( ) func TestContactImports(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - testsuite.Reset() - defer testsuite.Reset() + ctx, _, db, _ := testsuite.Reset() - models.FlushCache() + defer testsuite.Reset() - testdata.DeleteContactsAndURNs(t, db) + testdata.DeleteContactsAndURNs(db) // add contact in other org to make sure we can't update it - testdata.InsertContact(t, db, models.Org2, "f7a8016d-69a6-434b-aae7-5142ce4a98ba", "Xavier", "spa") + testdata.InsertContact(db, testdata.Org2, "f7a8016d-69a6-434b-aae7-5142ce4a98ba", "Xavier", "spa") // add dynamic group to test imported contacts are added to it - testdata.InsertContactGroup(t, db, models.Org1, "fc32f928-ad37-477c-a88e-003d30fd7406", "Adults", "age >= 40") + testdata.InsertContactGroup(db, testdata.Org1, "fc32f928-ad37-477c-a88e-003d30fd7406", "Adults", "age >= 40") // give our org a country by setting country on a channel - db.MustExec(`UPDATE channels_channel SET country = 'US' WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET country = 'US' WHERE id = $1`, testdata.TwilioChannel.ID) testJSON, err := ioutil.ReadFile("testdata/imports.json") require.NoError(t, err) @@ -66,13 +63,13 @@ func TestContactImports(t *testing.T) { defer uuids.SetGenerator(uuids.DefaultGenerator) for i, tc := range tcs { - importID := testdata.InsertContactImport(t, db, models.Org1) - batchID := testdata.InsertContactImportBatch(t, db, importID, tc.Specs) + importID := testdata.InsertContactImport(db, testdata.Org1) + batchID := testdata.InsertContactImportBatch(db, importID, tc.Specs) batch, err := models.LoadContactImportBatch(ctx, db, batchID) require.NoError(t, err) - err = batch.Import(ctx, db, models.Org1) + err = batch.Import(ctx, db, testdata.Org1.ID) require.NoError(t, err) results := &struct { @@ -127,8 +124,8 @@ func TestContactImports(t *testing.T) { test.AssertEqualJSON(t, tc.Errors, actual.Errors, "errors mismatch in '%s'", tc.Description) - actualJSON, _ := jsonx.Marshal(actual.Contacts) - expectedJSON, _ := jsonx.Marshal(tc.Contacts) + actualJSON := jsonx.MustMarshal(actual.Contacts) + expectedJSON := jsonx.MustMarshal(tc.Contacts) test.AssertEqualJSON(t, expectedJSON, actualJSON, "imported contacts mismatch in '%s'", tc.Description) } else { tcs[i] = actual @@ -145,11 +142,10 @@ func TestContactImports(t *testing.T) { } func TestContactImportBatch(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - importID := testdata.InsertContactImport(t, db, models.Org1) - batchID := testdata.InsertContactImportBatch(t, db, importID, []byte(`[ + importID := testdata.InsertContactImport(db, testdata.Org1) + batchID := testdata.InsertContactImportBatch(db, importID, []byte(`[ {"name": "Norbert", "language": "eng", "urns": ["tel:+16055740001"]}, {"name": "Leah", "urns": ["tel:+16055740002"]} ]`)) @@ -163,10 +159,10 @@ func TestContactImportBatch(t *testing.T) { assert.Equal(t, 0, batch.RecordStart) assert.Equal(t, 2, batch.RecordEnd) - err = batch.Import(ctx, db, models.Org1) + err = batch.Import(ctx, db, testdata.Org1.ID) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactimportbatch WHERE status = 'C' AND finished_on IS NOT NULL`, []interface{}{}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactimportbatch WHERE status = 'C' AND finished_on IS NOT NULL`).Returns(1) } func TestContactSpecUnmarshal(t *testing.T) { diff --git a/core/models/labels_test.go b/core/models/labels_test.go index 915cebb29..d581393ee 100644 --- a/core/models/labels_test.go +++ b/core/models/labels_test.go @@ -1,30 +1,36 @@ -package models +package models_test import ( "testing" + "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 TestLabels(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshLabels) + require.NoError(t, err) - labels, err := loadLabels(ctx, db, 1) - assert.NoError(t, err) + labels, err := oa.Labels() + require.NoError(t, err) tcs := []struct { - ID LabelID + ID models.LabelID Name string }{ - {ReportingLabelID, "Reporting"}, - {TestingLabelID, "Testing"}, + {testdata.ReportingLabel.ID, "Reporting"}, + {testdata.TestingLabel.ID, "Testing"}, } assert.Equal(t, 3, len(labels)) for i, tc := range tcs { - label := labels[i].(*Label) + label := labels[i].(*models.Label) assert.Equal(t, tc.ID, label.ID()) assert.Equal(t, tc.Name, label.Name()) } diff --git a/core/models/locations_test.go b/core/models/locations_test.go index ab29379d3..aad5ccb24 100644 --- a/core/models/locations_test.go +++ b/core/models/locations_test.go @@ -1,25 +1,30 @@ -package models +package models_test import ( "testing" "github.com/nyaruka/goflow/envs" + "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 TestLocations(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() db.MustExec(`INSERT INTO locations_boundaryalias(is_active, created_on, modified_on, name, boundary_id, created_by_id, modified_by_id, org_id) VALUES(TRUE, NOW(), NOW(), 'Soko', 8148, 1, 1, 1);`) db.MustExec(`INSERT INTO locations_boundaryalias(is_active, created_on, modified_on, name, boundary_id, created_by_id, modified_by_id, org_id) VALUES(TRUE, NOW(), NOW(), 'Sokoz', 8148, 1, 1, 2);`) - root, err := loadLocations(ctx, db, 1) - assert.NoError(t, err) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshLocations) + require.NoError(t, err) + + root, err := oa.Locations() + require.NoError(t, err) locations := root[0].FindByName("Nigeria", 0, nil) diff --git a/core/models/models_test.go b/core/models/models_test.go deleted file mode 100644 index 79867b0f6..000000000 --- a/core/models/models_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "os" - "testing" - - "github.com/nyaruka/mailroom/testsuite" -) - -// Custom entry point so we can reset our database -func TestMain(m *testing.M) { - testsuite.Reset() - os.Exit(m.Run()) -} diff --git a/core/models/msgs.go b/core/models/msgs.go index d5eea2cd5..2fd05286e 100644 --- a/core/models/msgs.go +++ b/core/models/msgs.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/gsm7" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" @@ -53,10 +54,10 @@ const ( type MsgType string const ( - TypeInbox = MsgType("I") - TypeFlow = MsgType("F") - TypeIVR = MsgType("V") - TypeUSSD = MsgType("U") + MsgTypeInbox = MsgType("I") + MsgTypeFlow = MsgType("F") + MsgTypeIVR = MsgType("V") + MsgTypeUSSD = MsgType("U") ) type MsgStatus string @@ -73,6 +74,7 @@ const ( MsgStatusQueued = MsgStatus("Q") MsgStatusWired = MsgStatus("W") MsgStatusSent = MsgStatus("S") + MsgStatusDelivered = MsgStatus("D") MsgStatusHandled = MsgStatus("H") MsgStatusErrored = MsgStatus("E") MsgStatusFailed = MsgStatus("F") @@ -99,7 +101,7 @@ type Msg struct { HighPriority bool `db:"high_priority" json:"high_priority"` CreatedOn time.Time `db:"created_on" json:"created_on"` ModifiedOn time.Time `db:"modified_on" json:"modified_on"` - SentOn time.Time `db:"sent_on" json:"sent_on"` + SentOn *time.Time `db:"sent_on" json:"sent_on"` QueuedOn time.Time `db:"queued_on" json:"queued_on"` Direction MsgDirection `db:"direction" json:"direction"` Status MsgStatus `db:"status" json:"status"` @@ -107,7 +109,7 @@ type Msg struct { MsgType MsgType `db:"msg_type"` MsgCount int `db:"msg_count" json:"tps_cost"` ErrorCount int `db:"error_count" json:"error_count"` - NextAttempt time.Time `db:"next_attempt" json:"next_attempt"` + NextAttempt *time.Time `db:"next_attempt" json:"next_attempt"` ExternalID null.String `db:"external_id" json:"external_id"` Attachments pq.StringArray `db:"attachments" json:"attachments"` Metadata null.Map `db:"metadata" json:"metadata,omitempty"` @@ -118,6 +120,7 @@ type Msg struct { ContactURNID *URNID `db:"contact_urn_id" json:"contact_urn_id"` ResponseToID MsgID `db:"response_to_id" json:"response_to_id"` ResponseToExternalID null.String ` json:"response_to_external_id"` + IsResend bool ` json:"is_resend,omitempty"` URN urns.URN ` json:"urn"` URNAuth null.String ` json:"urn_auth,omitempty"` OrgID OrgID `db:"org_id" json:"org_id"` @@ -144,14 +147,14 @@ func (m *Msg) Text() string { return m.m.Text } func (m *Msg) HighPriority() bool { return m.m.HighPriority } func (m *Msg) CreatedOn() time.Time { return m.m.CreatedOn } func (m *Msg) ModifiedOn() time.Time { return m.m.ModifiedOn } -func (m *Msg) SentOn() time.Time { return m.m.SentOn } +func (m *Msg) SentOn() *time.Time { return m.m.SentOn } func (m *Msg) QueuedOn() time.Time { return m.m.QueuedOn } func (m *Msg) Direction() MsgDirection { return m.m.Direction } func (m *Msg) Status() MsgStatus { return m.m.Status } func (m *Msg) Visibility() MsgVisibility { return m.m.Visibility } func (m *Msg) MsgType() MsgType { return m.m.MsgType } func (m *Msg) ErrorCount() int { return m.m.ErrorCount } -func (m *Msg) NextAttempt() time.Time { return m.m.NextAttempt } +func (m *Msg) NextAttempt() *time.Time { return m.m.NextAttempt } func (m *Msg) ExternalID() null.String { return m.m.ExternalID } func (m *Msg) Metadata() map[string]interface{} { return m.m.Metadata.Map() } func (m *Msg) MsgCount() int { return m.m.MsgCount } @@ -164,6 +167,7 @@ func (m *Msg) OrgID() OrgID { return m.m.OrgID } func (m *Msg) TopupID() TopupID { return m.m.TopupID } func (m *Msg) ContactID() ContactID { return m.m.ContactID } func (m *Msg) ContactURNID() *URNID { return m.m.ContactURNID } +func (m *Msg) IsResend() bool { return m.m.IsResend } func (m *Msg) SetTopup(topupID TopupID) { m.m.TopupID = topupID } func (m *Msg) SetChannelID(channelID ChannelID) { m.m.ChannelID = channelID } @@ -223,7 +227,7 @@ func NewIncomingIVR(orgID OrgID, conn *ChannelConnection, in *flows.MsgIn, creat m.Direction = DirectionIn m.Status = MsgStatusHandled m.Visibility = VisibilityVisible - m.MsgType = TypeIVR + m.MsgType = MsgTypeIVR m.ContactID = conn.ContactID() urnID := conn.ContactURNID() @@ -240,14 +244,14 @@ func NewIncomingIVR(orgID OrgID, conn *ChannelConnection, in *flows.MsgIn, creat // add any attachments for _, a := range in.Attachments() { - m.Attachments = append(m.Attachments, string(NormalizeAttachment(a))) + m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a))) } return msg } // NewOutgoingIVR creates a new IVR message for the passed in text with the optional attachment -func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, createdOn time.Time) (*Msg, error) { +func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, createdOn time.Time) *Msg { msg := &Msg{} m := &msg.m @@ -258,7 +262,7 @@ func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, cre m.Direction = DirectionOut m.Status = MsgStatusWired m.Visibility = VisibilityVisible - m.MsgType = TypeIVR + m.MsgType = MsgTypeIVR m.ContactID = conn.ContactID() urnID := conn.ContactURNID() @@ -272,14 +276,15 @@ func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, cre m.OrgID = orgID m.TopupID = NilTopupID m.CreatedOn = createdOn + m.SentOn = &createdOn msg.SetChannelID(conn.ChannelID()) // if we have attachments, add them for _, a := range out.Attachments() { - m.Attachments = append(m.Attachments, string(NormalizeAttachment(a))) + m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a))) } - return msg, nil + return msg } // NewOutgoingMsg creates an outgoing message for the passed in flow message. @@ -299,7 +304,7 @@ func NewOutgoingMsg(org *Org, channel *Channel, contactID ContactID, out *flows. m.Direction = DirectionOut m.Status = status m.Visibility = VisibilityVisible - m.MsgType = TypeFlow + m.MsgType = MsgTypeFlow m.ContactID = contactID m.OrgID = org.ID() m.TopupID = NilTopupID @@ -321,7 +326,7 @@ func NewOutgoingMsg(org *Org, channel *Channel, contactID ContactID, out *flows. // if we have attachments, add them if len(out.Attachments()) > 0 { for _, a := range out.Attachments() { - m.Attachments = append(m.Attachments, string(NormalizeAttachment(a))) + m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a))) } } @@ -361,7 +366,7 @@ func NewIncomingMsg(orgID OrgID, channel *Channel, contactID ContactID, in *flow m.Direction = DirectionIn m.Status = MsgStatusHandled m.Visibility = VisibilityVisible - m.MsgType = TypeFlow + m.MsgType = MsgTypeFlow m.ContactID = contactID m.OrgID = orgID @@ -376,15 +381,69 @@ func NewIncomingMsg(orgID OrgID, channel *Channel, contactID ContactID, in *flow // add any attachments for _, a := range in.Attachments() { - m.Attachments = append(m.Attachments, string(NormalizeAttachment(a))) + m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a))) } return msg } +var loadMessagesSQL = ` +SELECT + id, + broadcast_id, + uuid, + text, + created_on, + direction, + status, + visibility, + msg_count, + error_count, + next_attempt, + external_id, + attachments, + metadata, + channel_id, + connection_id, + contact_id, + contact_urn_id, + response_to_id, + org_id, + topup_id +FROM + msgs_msg +WHERE + org_id = $1 AND + direction = $2 AND + id = ANY($3) +ORDER BY + id ASC` + +// LoadMessages loads the given messages for the passed in org +func LoadMessages(ctx context.Context, db Queryer, orgID OrgID, direction MsgDirection, msgIDs []MsgID) ([]*Msg, error) { + rows, err := db.QueryxContext(ctx, loadMessagesSQL, orgID, direction, pq.Array(msgIDs)) + if err != nil { + return nil, errors.Wrapf(err, "error querying msgs for org: %d", orgID) + } + defer rows.Close() + + msgs := make([]*Msg, 0) + for rows.Next() { + msg := &Msg{} + err = rows.StructScan(&msg.m) + if err != nil { + return nil, errors.Wrap(err, "error scanning msg row") + } + + msgs = append(msgs, msg) + } + + return msgs, nil +} + // NormalizeAttachment will turn any relative URL in the passed in attachment and normalize it to // include the full host for attachment domains -func NormalizeAttachment(attachment utils.Attachment) utils.Attachment { +func NormalizeAttachment(cfg *config.Config, attachment utils.Attachment) utils.Attachment { // don't try to modify geo type attachments which are just coordinates if attachment.ContentType() == "geo" { return attachment @@ -393,9 +452,9 @@ func NormalizeAttachment(attachment utils.Attachment) utils.Attachment { url := attachment.URL() if !strings.HasPrefix(url, "http") { if strings.HasPrefix(url, "/") { - url = fmt.Sprintf("https://%s%s", config.Mailroom.AttachmentDomain, url) + url = fmt.Sprintf("https://%s%s", cfg.AttachmentDomain, url) } else { - url = fmt.Sprintf("https://%s/%s", config.Mailroom.AttachmentDomain, url) + url = fmt.Sprintf("https://%s/%s", cfg.AttachmentDomain, url) } } return utils.Attachment(fmt.Sprintf("%s:%s", attachment.ContentType(), url)) @@ -424,10 +483,10 @@ func InsertMessages(ctx context.Context, tx Queryer, msgs []*Msg) error { const insertMsgSQL = ` INSERT INTO -msgs_msg(uuid, text, high_priority, created_on, modified_on, queued_on, direction, status, attachments, metadata, +msgs_msg(uuid, text, high_priority, created_on, modified_on, queued_on, sent_on, direction, status, attachments, metadata, visibility, msg_type, msg_count, error_count, next_attempt, channel_id, connection_id, response_to_id, contact_id, contact_urn_id, org_id, topup_id, broadcast_id) - VALUES(:uuid, :text, :high_priority, :created_on, now(), now(), :direction, :status, :attachments, :metadata, + VALUES(:uuid, :text, :high_priority, :created_on, now(), now(), :sent_on, :direction, :status, :attachments, :metadata, :visibility, :msg_type, :msg_count, :error_count, :next_attempt, :channel_id, :connection_id, :response_to_id, :contact_id, :contact_urn_id, :org_id, :topup_id, :broadcast_id) RETURNING @@ -517,17 +576,19 @@ type Broadcast struct { GroupIDs []GroupID `json:"group_ids,omitempty"` OrgID OrgID `json:"org_id" db:"org_id"` ParentID BroadcastID `json:"parent_id,omitempty" db:"parent_id"` + TicketID TicketID `json:"ticket_id,omitempty" db:"ticket_id"` } } -func (b *Broadcast) BroadcastID() BroadcastID { return b.b.BroadcastID } +func (b *Broadcast) ID() BroadcastID { return b.b.BroadcastID } +func (b *Broadcast) OrgID() OrgID { return b.b.OrgID } func (b *Broadcast) ContactIDs() []ContactID { return b.b.ContactIDs } func (b *Broadcast) GroupIDs() []GroupID { return b.b.GroupIDs } func (b *Broadcast) URNs() []urns.URN { return b.b.URNs } -func (b *Broadcast) OrgID() OrgID { return b.b.OrgID } func (b *Broadcast) BaseLanguage() envs.Language { return b.b.BaseLanguage } func (b *Broadcast) Translations() map[envs.Language]*BroadcastTranslation { return b.b.Translations } func (b *Broadcast) TemplateState() TemplateState { return b.b.TemplateState } +func (b *Broadcast) TicketID() TicketID { return b.b.TicketID } func (b *Broadcast) MarshalJSON() ([]byte, error) { return json.Marshal(b.b) } func (b *Broadcast) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &b.b) } @@ -535,7 +596,7 @@ func (b *Broadcast) UnmarshalJSON(data []byte) error { return json.Unmarshal(dat // NewBroadcast creates a new broadcast with the passed in parameters func NewBroadcast( orgID OrgID, id BroadcastID, translations map[envs.Language]*BroadcastTranslation, - state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID) *Broadcast { + state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID, ticketID TicketID) *Broadcast { bcast := &Broadcast{} bcast.b.OrgID = orgID @@ -546,6 +607,7 @@ func NewBroadcast( bcast.b.URNs = urns bcast.b.ContactIDs = contactIDs bcast.b.GroupIDs = groupIDs + bcast.b.TicketID = ticketID return bcast } @@ -561,9 +623,10 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (* parent.b.URNs, parent.b.ContactIDs, parent.b.GroupIDs, + parent.b.TicketID, ) // populate our parent id - child.b.ParentID = parent.BroadcastID() + child.b.ParentID = parent.ID() // populate text from our translations child.b.Text.Map = make(map[string]sql.NullString) @@ -577,14 +640,14 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (* // insert our broadcast err := BulkQuery(ctx, "inserting broadcast", db, insertBroadcastSQL, []interface{}{&child.b}) if err != nil { - return nil, errors.Wrapf(err, "error inserting child broadcast for broadcast: %d", parent.BroadcastID()) + return nil, errors.Wrapf(err, "error inserting child broadcast for broadcast: %d", parent.ID()) } // build up all our contact associations contacts := make([]interface{}, 0, len(child.b.ContactIDs)) for _, contactID := range child.b.ContactIDs { contacts = append(contacts, &broadcastContact{ - BroadcastID: child.BroadcastID(), + BroadcastID: child.ID(), ContactID: contactID, }) } @@ -599,7 +662,7 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (* groups := make([]interface{}, 0, len(child.b.GroupIDs)) for _, groupID := range child.b.GroupIDs { groups = append(groups, &broadcastGroup{ - BroadcastID: child.BroadcastID(), + BroadcastID: child.ID(), GroupID: groupID, }) } @@ -618,7 +681,7 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (* return nil, errors.Errorf("attempt to insert new broadcast with URNs that do not have id: %s", urn) } urns = append(urns, &broadcastURN{ - BroadcastID: child.BroadcastID(), + BroadcastID: child.ID(), URNID: urnID, }) } @@ -649,8 +712,8 @@ type broadcastGroup struct { const insertBroadcastSQL = ` INSERT INTO - msgs_broadcast( org_id, parent_id, is_active, created_on, modified_on, status, text, base_language, send_all) - VALUES(:org_id, :parent_id, TRUE, NOW() , NOW(), 'Q', :text, :base_language, FALSE) + msgs_broadcast( org_id, parent_id, ticket_id, created_on, modified_on, status, text, base_language, send_all) + VALUES(:org_id, :parent_id, :ticket_id, NOW() , NOW(), 'Q', :text, :base_language, FALSE) RETURNING id ` @@ -700,7 +763,7 @@ func NewBroadcastFromEvent(ctx context.Context, tx Queryer, org *OrgAssets, even } } - return NewBroadcast(org.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs), nil + return NewBroadcast(org.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs, NilTicketID), nil } func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch { @@ -710,6 +773,7 @@ func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch { batch.b.Translations = b.b.Translations batch.b.TemplateState = b.b.TemplateState batch.b.OrgID = b.b.OrgID + batch.b.TicketID = b.b.TicketID batch.b.ContactIDs = contactIDs return batch } @@ -725,6 +789,7 @@ type BroadcastBatch struct { ContactIDs []ContactID `json:"contact_ids,omitempty"` IsLast bool `json:"is_last"` OrgID OrgID `json:"org_id"` + TicketID TicketID `json:"ticket_id"` } } @@ -733,6 +798,7 @@ func (b *BroadcastBatch) ContactIDs() []ContactID { return b.b.Conta func (b *BroadcastBatch) URNs() map[ContactID]urns.URN { return b.b.URNs } func (b *BroadcastBatch) SetURNs(urns map[ContactID]urns.URN) { b.b.URNs = urns } func (b *BroadcastBatch) OrgID() OrgID { return b.b.OrgID } +func (b *BroadcastBatch) TicketID() TicketID { return b.b.TicketID } func (b *BroadcastBatch) Translations() map[envs.Language]*BroadcastTranslation { return b.b.Translations } @@ -759,10 +825,8 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa repeatedContacts[id] = true } } - } - // if we have URN we need to send to, add those contacts as well if not already repeated - if broadcastURNs != nil { + // if we have URN we need to send to, add those contacts as well if not already repeated for id := range broadcastURNs { if !repeatedContacts[id] { contactIDs = append(contactIDs, id) @@ -946,9 +1010,91 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa return nil, errors.Wrapf(err, "error inserting broadcast messages") } + // if the broadcast was a ticket reply, update the ticket + if bcast.TicketID() != NilTicketID { + err = updateTicketLastActivity(ctx, db, []TicketID{bcast.TicketID()}, dates.Now()) + if err != nil { + return nil, errors.Wrapf(err, "error updating broadcast ticket") + } + } + return msgs, nil } +const updateMsgForResendingSQL = ` + UPDATE + msgs_msg m + SET + channel_id = r.channel_id::int, + topup_id = r.topup_id::int, + status = 'P', + error_count = 0, + queued_on = r.queued_on::timestamp with time zone, + sent_on = NULL, + modified_on = NOW() + FROM ( + VALUES(:id, :channel_id, :topup_id, :queued_on) + ) AS + r(id, channel_id, topup_id, queued_on) + WHERE + m.id = r.id::bigint +` + +// ResendMessages prepares messages for resending by reselecting a channel and marking them as PENDING +func ResendMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa *OrgAssets, msgs []*Msg) error { + channels := oa.SessionAssets().Channels() + resends := make([]interface{}, len(msgs)) + + for i, msg := range msgs { + // reselect channel for this message's URN + urn, err := URNForID(ctx, db, oa, *msg.ContactURNID()) + if err != nil { + return errors.Wrap(err, "error loading URN") + } + msg.m.URN = urn // needs to be set for queueing to courier + + contactURN, err := flows.ParseRawURN(channels, urn, assets.IgnoreMissing) + if err != nil { + return errors.Wrap(err, "error parsing URN") + } + + ch := channels.GetForURN(contactURN, assets.ChannelRoleSend) + if ch != nil { + channel := oa.ChannelByUUID(ch.UUID()) + msg.m.ChannelID = channel.ID() + msg.m.ChannelUUID = channel.UUID() + msg.channel = channel + } else { + msg.m.ChannelID = NilChannelID + msg.m.ChannelUUID = assets.ChannelUUID("") + msg.channel = nil + } + + // allocate a new topup for this message if org uses topups + msg.m.TopupID, err = AllocateTopups(ctx, db, rp, oa.Org(), 1) + if err != nil { + return errors.Wrapf(err, "error allocating topup for message resending") + } + + // mark message as being a resend so it will be queued to courier as such + msg.m.Status = MsgStatusPending + msg.m.QueuedOn = dates.Now() + msg.m.SentOn = nil + msg.m.ErrorCount = 0 + msg.m.IsResend = true + + resends[i] = msg.m + } + + // update the messages in the database + err := BulkQuery(ctx, "updating messages for resending", db, updateMsgForResendingSQL, resends) + if err != nil { + return errors.Wrapf(err, "error updating messages for resending") + } + + return nil +} + // MarkBroadcastSent marks the passed in broadcast as sent func MarkBroadcastSent(ctx context.Context, db Queryer, id BroadcastID) error { // noop if it is a nil id diff --git a/core/models/msgs_test.go b/core/models/msgs_test.go index 6f0478c51..af67c3e04 100644 --- a/core/models/msgs_test.go +++ b/core/models/msgs_test.go @@ -5,11 +5,12 @@ import ( "testing" "time" + "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" @@ -19,8 +20,7 @@ import ( ) func TestOutgoingMsgs(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() tcs := []struct { ChannelUUID assets.ChannelUUID @@ -41,7 +41,7 @@ func TestOutgoingMsgs(t *testing.T) { { ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8", Text: "missing urn id", - ContactID: models.CathyID, + ContactID: testdata.Cathy.ID, URN: urns.URN("tel:+250700000001"), URNID: models.URNID(0), ExpectedStatus: models.MsgStatusQueued, @@ -52,9 +52,9 @@ func TestOutgoingMsgs(t *testing.T) { { ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8", Text: "test outgoing", - ContactID: models.CathyID, - URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)), - URNID: models.CathyURNID, + ContactID: testdata.Cathy.ID, + URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)), + URNID: testdata.Cathy.URNID, QuickReplies: []string{"yes", "no"}, Topic: flows.MsgTopicPurchase, ExpectedStatus: models.MsgStatusQueued, @@ -67,9 +67,9 @@ func TestOutgoingMsgs(t *testing.T) { { ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8", Text: "test outgoing", - ContactID: models.CathyID, - URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)), - URNID: models.CathyURNID, + ContactID: testdata.Cathy.ID, + URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)), + URNID: testdata.Cathy.URNID, Attachments: []utils.Attachment{utils.Attachment("image/jpeg:https://dl-foo.com/image.jpg")}, ExpectedStatus: models.MsgStatusQueued, ExpectedMetadata: map[string]interface{}{}, @@ -78,9 +78,9 @@ func TestOutgoingMsgs(t *testing.T) { { ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8", Text: "suspended org", - ContactID: models.CathyID, - URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)), - URNID: models.CathyURNID, + ContactID: testdata.Cathy.ID, + URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)), + URNID: testdata.Cathy.URNID, SuspendedOrg: true, ExpectedStatus: models.MsgStatusFailed, ExpectedMetadata: map[string]interface{}{}, @@ -94,9 +94,9 @@ func TestOutgoingMsgs(t *testing.T) { tx, err := db.BeginTxx(ctx, nil) require.NoError(t, err) - db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, tc.SuspendedOrg, models.Org1) + db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, tc.SuspendedOrg, testdata.Org1.ID) - oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg) require.NoError(t, err) channel := oa.ChannelByUUID(tc.ChannelUUID) @@ -137,9 +137,9 @@ func TestOutgoingMsgs(t *testing.T) { } func TestGetMessageIDFromUUID(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - msgIn := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "hi there") + ctx, _, db, _ := testsuite.Get() + + msgIn := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hi there", models.MsgStatusHandled) msgID, err := models.GetMessageIDFromUUID(ctx, db, msgIn.UUID()) @@ -147,9 +147,74 @@ func TestGetMessageIDFromUUID(t *testing.T) { assert.Equal(t, models.MsgID(msgIn.ID()), msgID) } +func TestLoadMessages(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + msgIn1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "in 1", models.MsgStatusHandled) + msgOut1 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 1", []utils.Attachment{"image/jpeg:hi.jpg"}, models.MsgStatusSent) + msgOut2 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 2", nil, models.MsgStatusSent) + msgOut3 := testdata.InsertOutgoingMsg(db, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "out 3", nil, models.MsgStatusSent) + testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hi 3", nil, models.MsgStatusSent) + + ids := []models.MsgID{models.MsgID(msgIn1.ID()), models.MsgID(msgOut1.ID()), models.MsgID(msgOut2.ID()), models.MsgID(msgOut3.ID())} + + msgs, err := models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionOut, ids) + + // should only return the outgoing messages for this org + require.NoError(t, err) + assert.Equal(t, 2, len(msgs)) + assert.Equal(t, "out 1", msgs[0].Text()) + assert.Equal(t, []utils.Attachment{"image/jpeg:hi.jpg"}, msgs[0].Attachments()) + assert.Equal(t, "out 2", msgs[1].Text()) + + msgs, err = models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionIn, ids) + + // should only return the incoming message for this org + require.NoError(t, err) + assert.Equal(t, 1, len(msgs)) + assert.Equal(t, "in 1", msgs[0].Text()) +} + +func TestResendMessages(t *testing.T) { + ctx, _, db, rp := testsuite.Get() + + defer testsuite.Reset() + + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) + require.NoError(t, err) + + msgOut1 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 1", nil, models.MsgStatusFailed) + msgOut2 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Bob, "out 2", nil, models.MsgStatusFailed) + testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 3", nil, models.MsgStatusFailed) + + // give Bob's URN an affinity for the Vonage channel + db.MustExec(`UPDATE contacts_contacturn SET channel_id = $1 WHERE id = $2`, testdata.VonageChannel.ID, testdata.Bob.URNID) + + msgs, err := models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionOut, []models.MsgID{models.MsgID(msgOut1.ID()), models.MsgID(msgOut2.ID())}) + require.NoError(t, err) + + now := dates.Now() + + // resend both msgs + err = models.ResendMessages(ctx, db, rp, oa, msgs) + require.NoError(t, err) + + // both messages should now have a channel, a topup and be marked for resending + assert.True(t, msgs[0].IsResend()) + assert.Equal(t, testdata.TwilioChannel.ID, msgs[0].ChannelID()) + assert.Equal(t, models.TopupID(1), msgs[0].TopupID()) + assert.True(t, msgs[1].IsResend()) + assert.Equal(t, testdata.VonageChannel.ID, msgs[1].ChannelID()) + assert.Equal(t, models.TopupID(1), msgs[1].TopupID()) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P' AND queued_on > $1 AND sent_on IS NULL`, now).Returns(2) +} + func TestNormalizeAttachment(t *testing.T) { - config.Mailroom.AttachmentDomain = "foo.bar.com" - defer func() { config.Mailroom.AttachmentDomain = "" }() + _, rt, _, _ := testsuite.Get() + + rt.Config.AttachmentDomain = "foo.bar.com" + defer func() { rt.Config.AttachmentDomain = "" }() tcs := []struct { raw string @@ -163,23 +228,23 @@ func TestNormalizeAttachment(t *testing.T) { } for _, tc := range tcs { - assert.Equal(t, tc.normalized, string(models.NormalizeAttachment(utils.Attachment(tc.raw)))) + assert.Equal(t, tc.normalized, string(models.NormalizeAttachment(rt.Config, utils.Attachment(tc.raw)))) } } func TestMarkMessages(t *testing.T) { - ctx, db, _ := testsuite.Reset() + ctx, _, db, _ := testsuite.Reset() defer testsuite.Reset() - oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg) require.NoError(t, err) - channel := oa.ChannelByUUID(models.TwilioChannelUUID) + channel := oa.ChannelByUUID(testdata.TwilioChannel.UUID) insertMsg := func(text string) *models.Msg { - urn := urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)) + urn := urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)) flowMsg := flows.NewMsgOut(urn, channel.ChannelReference(), text, nil, nil, nil, flows.NilMsgTopic) - msg, err := models.NewOutgoingMsg(oa.Org(), channel, models.CathyID, flowMsg, time.Now()) + msg, err := models.NewOutgoingMsg(oa.Org(), channel, testdata.Cathy.ID, flowMsg, time.Now()) require.NoError(t, err) err = models.InsertMessages(ctx, db, []*models.Msg{msg}) @@ -194,7 +259,7 @@ func TestMarkMessages(t *testing.T) { models.MarkMessagesPending(ctx, db, []*models.Msg{msg1, msg2}) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, 2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(2) // try running on database with BIGINT message ids db.MustExec(`ALTER TABLE "msgs_msg" ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`) @@ -208,5 +273,92 @@ func TestMarkMessages(t *testing.T) { err = models.MarkMessagesPending(ctx, db, []*models.Msg{msg4}) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, 3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(3) +} + +func TestNonPersistentBroadcasts(t *testing.T) { + ctx, _, db, rp := testsuite.Reset() + + defer func() { + db.MustExec(`DELETE FROM msgs_msg`) + db.MustExec(`DELETE FROM tickets_ticket`) + }() + + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Problem", "", "", nil) + modelTicket := ticket.Load(db) + + translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("eng"): {Text: "Hi there"}} + + // create a broadcast which doesn't actually exist in the DB + bcast := models.NewBroadcast( + testdata.Org1.ID, + models.NilBroadcastID, + translations, + models.TemplateStateUnevaluated, + envs.Language("eng"), + []urns.URN{"tel:+593979012345"}, + []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID, testdata.Cathy.ID}, + []models.GroupID{testdata.DoctorsGroup.ID}, + ticket.ID, + ) + + assert.Equal(t, models.NilBroadcastID, bcast.ID()) + assert.Equal(t, testdata.Org1.ID, bcast.OrgID()) + assert.Equal(t, envs.Language("eng"), bcast.BaseLanguage()) + assert.Equal(t, translations, bcast.Translations()) + assert.Equal(t, models.TemplateStateUnevaluated, bcast.TemplateState()) + assert.Equal(t, ticket.ID, bcast.TicketID()) + assert.Equal(t, []urns.URN{"tel:+593979012345"}, bcast.URNs()) + assert.Equal(t, []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID, testdata.Cathy.ID}, bcast.ContactIDs()) + assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, bcast.GroupIDs()) + + batch := bcast.CreateBatch([]models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID}) + + assert.Equal(t, models.NilBroadcastID, batch.BroadcastID()) + assert.Equal(t, testdata.Org1.ID, batch.OrgID()) + assert.Equal(t, envs.Language("eng"), batch.BaseLanguage()) + assert.Equal(t, translations, batch.Translations()) + assert.Equal(t, models.TemplateStateUnevaluated, batch.TemplateState()) + assert.Equal(t, ticket.ID, batch.TicketID()) + assert.Equal(t, []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID}, batch.ContactIDs()) + + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) + require.NoError(t, err) + + msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, batch) + require.NoError(t, err) + + assert.Equal(t, 2, len(msgs)) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE direction = 'O' AND broadcast_id IS NULL AND text = 'Hi there'`).Returns(2) + + // test ticket was updated + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on > $2`, ticket.ID, modelTicket.LastActivityOn()).Returns(1) +} + +func TestNewOutgoingIVR(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) + require.NoError(t, err) + + vonage := oa.ChannelByUUID(testdata.VonageChannel.UUID) + conn, err := models.InsertIVRConnection(ctx, db, testdata.Org1.ID, testdata.VonageChannel.ID, models.NilStartID, testdata.Cathy.ID, testdata.Cathy.URNID, models.ConnectionDirectionOut, models.ConnectionStatusInProgress, "") + require.NoError(t, err) + + createdOn := time.Date(2021, 7, 26, 12, 6, 30, 0, time.UTC) + + flowMsg := flows.NewMsgOut(testdata.Cathy.URN, vonage.ChannelReference(), "Hello", []utils.Attachment{"audio/mp3:http://example.com/hi.mp3"}, nil, nil, flows.NilMsgTopic) + dbMsg := models.NewOutgoingIVR(testdata.Org1.ID, conn, flowMsg, createdOn) + + assert.Equal(t, flowMsg.UUID(), dbMsg.UUID()) + assert.Equal(t, "Hello", dbMsg.Text()) + assert.Equal(t, []utils.Attachment{"audio/mp3:http://example.com/hi.mp3"}, dbMsg.Attachments()) + assert.Equal(t, createdOn, dbMsg.CreatedOn()) + assert.Equal(t, &createdOn, dbMsg.SentOn()) + + err = models.InsertMessages(ctx, db, []*models.Msg{dbMsg}) + require.NoError(t, err) + + testsuite.AssertQuery(t, db, `SELECT text, created_on, sent_on FROM msgs_msg WHERE uuid = $1`, dbMsg.UUID()).Columns(map[string]interface{}{"text": "Hello", "created_on": createdOn, "sent_on": createdOn}) } diff --git a/core/models/orgs.go b/core/models/orgs.go index 8537c7d0f..29f4389ca 100644 --- a/core/models/orgs.go +++ b/core/models/orgs.go @@ -55,19 +55,22 @@ func init() { // OrgID is our type for orgs ids type OrgID int -// UserID is our type for user ids used by modified_by, which can be null -type UserID null.Int +// SessionStorageMode is our type for how we persist our sessions +type SessionStorageMode string const ( // NilOrgID is the id 0 considered as nil org id NilOrgID = OrgID(0) - // NilUserID si the id 0 considered as nil user id - NilUserID = UserID(0) - configSMTPServer = "smtp_server" configDTOneKey = "dtone_key" configDTOneSecret = "dtone_secret" + + configSessionStorageMode = "session_storage_mode" + + DBSessions = SessionStorageMode("db") + S3Sessions = SessionStorageMode("s3") + S3WriteSessions = SessionStorageMode("s3_write") ) // Org is mailroom's type for RapidPro orgs. It also implements the envs.Environment interface for GoFlow @@ -90,6 +93,10 @@ func (o *Org) Suspended() bool { return o.o.Suspended } // UsesTopups returns whether the org uses topups func (o *Org) UsesTopups() bool { return o.o.UsesTopups } +func (o *Org) SessionStorageMode() SessionStorageMode { + return SessionStorageMode(o.ConfigValue(configSessionStorageMode, string(DBSessions))) +} + // DateFormat returns the date format for this org func (o *Org) DateFormat() envs.DateFormat { return o.env.DateFormat() } @@ -175,7 +182,7 @@ func (o *Org) AirtimeService(httpClient *http.Client, httpRetries *httpx.RetryCo } // StoreAttachment saves an attachment to storage -func (o *Org) StoreAttachment(s storage.Storage, filename string, contentType string, content io.ReadCloser) (utils.Attachment, error) { +func (o *Org) StoreAttachment(ctx context.Context, s storage.Storage, filename string, contentType string, content io.ReadCloser) (utils.Attachment, error) { prefix := config.Mailroom.S3MediaPrefix // read the content @@ -192,7 +199,7 @@ func (o *Org) StoreAttachment(s storage.Storage, filename string, contentType st path := o.attachmentPath(prefix, filename) - url, err := s.Put(path, contentType, contentBytes) + url, err := s.Put(ctx, path, contentType, contentBytes) if err != nil { return "", errors.Wrapf(err, "unable to store attachment content") } @@ -229,12 +236,12 @@ func orgFromSession(session flows.Session) *Org { return session.Assets().Source().(*OrgAssets).Org() } -// loadOrg loads the org for the passed in id, returning any error encountered -func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) { +// LoadOrg loads the org for the passed in id, returning any error encountered +func LoadOrg(ctx context.Context, cfg *config.Config, db sqlx.Queryer, orgID OrgID) (*Org, error) { start := time.Now() org := &Org{} - rows, err := db.Queryx(selectOrgByID, orgID, config.Mailroom.MaxValueLength) + rows, err := db.Queryx(selectOrgByID, orgID, cfg.MaxValueLength) if err != nil { return nil, errors.Wrapf(err, "error loading org: %d", orgID) } @@ -264,8 +271,7 @@ SELECT ROW_TO_JSON(o) FROM (SELECT timezone, (SELECT CASE is_anon WHEN TRUE THEN 'urns' WHEN FALSE THEN 'none' END) AS redaction_policy, $2::int AS max_value_length, - (SELECT iso_code FROM orgs_language WHERE id = o.primary_language_id) AS default_language, - (SELECT ARRAY_AGG(iso_code ORDER BY iso_code ASC) FROM orgs_language WHERE org_id = o.id) AS allowed_languages, + flow_languages AS allowed_languages, COALESCE( ( SELECT diff --git a/core/models/orgs_test.go b/core/models/orgs_test.go index 72427a066..08e1e9ec7 100644 --- a/core/models/orgs_test.go +++ b/core/models/orgs_test.go @@ -1,39 +1,40 @@ -package models +package models_test import ( + "context" "os" "testing" "time" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/utils" + "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 TestOrgs(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, rt, db, _ := testsuite.Get() + + tz, _ := time.LoadLocation("America/Los_Angeles") tx, err := db.BeginTxx(ctx, nil) assert.NoError(t, err) defer tx.Rollback() - tx.MustExec("UPDATE channels_channel SET country = 'FR' WHERE id = $1;", TwitterChannelID) - tx.MustExec("UPDATE channels_channel SET country = 'US' WHERE id IN ($1,$2);", TwilioChannelID, VonageChannelID) - tx.MustExec(`INSERT INTO orgs_language(is_active, created_on, modified_on, name, iso_code, created_by_id, modified_by_id, org_id) - VALUES(TRUE, NOW(), NOW(), 'French', 'fra', 1, 1, 2);`) - tx.MustExec(`INSERT INTO orgs_language(is_active, created_on, modified_on, name, iso_code, created_by_id, modified_by_id, org_id) - VALUES(TRUE, NOW(), NOW(), 'English', 'eng', 1, 1, 2);`) + tx.MustExec("UPDATE channels_channel SET country = 'FR' WHERE id = $1;", testdata.TwitterChannel.ID) + tx.MustExec("UPDATE channels_channel SET country = 'US' WHERE id IN ($1,$2);", testdata.TwilioChannel.ID, testdata.VonageChannel.ID) - tx.MustExec("UPDATE orgs_org SET primary_language_id = 2 WHERE id = 2;") + tx.MustExec(`UPDATE orgs_org SET flow_languages = '{"fra", "eng"}' WHERE id = $1`, testdata.Org1.ID) + tx.MustExec(`UPDATE orgs_org SET flow_languages = '{}' WHERE id = $1`, testdata.Org2.ID) - org, err := loadOrg(ctx, tx, 1) + org, err := models.LoadOrg(ctx, rt.Config, tx, testdata.Org1.ID) assert.NoError(t, err) - assert.Equal(t, OrgID(1), org.ID()) + assert.Equal(t, models.OrgID(1), org.ID()) assert.False(t, org.Suspended()) assert.True(t, org.UsesTopups()) assert.Equal(t, envs.DateFormatDayMonthYear, org.DateFormat()) @@ -41,41 +42,39 @@ func TestOrgs(t *testing.T) { assert.Equal(t, envs.RedactionPolicyNone, org.RedactionPolicy()) assert.Equal(t, 640, org.MaxValueLength()) assert.Equal(t, string(envs.Country("US")), string(org.DefaultCountry())) - tz, _ := time.LoadLocation("America/Los_Angeles") assert.Equal(t, tz, org.Timezone()) - assert.Equal(t, 0, len(org.AllowedLanguages())) - assert.Equal(t, envs.Language(""), org.DefaultLanguage()) - assert.Equal(t, "", org.DefaultLocale().ToISO639_2()) + assert.Equal(t, []envs.Language{"fra", "eng"}, org.AllowedLanguages()) + assert.Equal(t, envs.Language("fra"), org.DefaultLanguage()) + assert.Equal(t, "fr-US", org.DefaultLocale().ToBCP47()) - org, err = loadOrg(ctx, tx, 2) + org, err = models.LoadOrg(ctx, rt.Config, tx, testdata.Org2.ID) assert.NoError(t, err) - assert.Equal(t, []envs.Language{"eng", "fra"}, org.AllowedLanguages()) - assert.Equal(t, envs.Language("eng"), org.DefaultLanguage()) - assert.Equal(t, "en", org.DefaultLocale().ToISO639_2()) + assert.Equal(t, []envs.Language{}, org.AllowedLanguages()) + assert.Equal(t, envs.NilLanguage, org.DefaultLanguage()) + assert.Equal(t, "", org.DefaultLocale().ToBCP47()) - _, err = loadOrg(ctx, tx, 99) + _, err = models.LoadOrg(ctx, rt.Config, tx, 99) assert.Error(t, err) } func TestStoreAttachment(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, rt, db, _ := testsuite.Get() - store := testsuite.Storage() + store := testsuite.MediaStorage() defer testsuite.ResetStorage() image, err := os.Open("testdata/test.jpg") require.NoError(t, err) - org, err := loadOrg(ctx, db, Org1) + org, err := models.LoadOrg(ctx, rt.Config, db, testdata.Org1.ID) assert.NoError(t, err) - attachment, err := org.StoreAttachment(store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image) + attachment, err := org.StoreAttachment(context.Background(), store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image) require.NoError(t, err) - assert.Equal(t, utils.Attachment("image/jpeg:_test_storage/media/1/6683/83ba/668383ba-387c-49bc-b164-1213ac0ea7aa.jpg"), attachment) + assert.Equal(t, utils.Attachment("image/jpeg:_test_media_storage/media/1/6683/83ba/668383ba-387c-49bc-b164-1213ac0ea7aa.jpg"), attachment) // err trying to read from same reader again - _, err = org.StoreAttachment(store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image) + _, err = org.StoreAttachment(context.Background(), store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image) assert.EqualError(t, err, "unable to read attachment content: read testdata/test.jpg: file already closed") } diff --git a/core/models/resthooks_test.go b/core/models/resthooks_test.go index 05809763c..435ed4647 100644 --- a/core/models/resthooks_test.go +++ b/core/models/resthooks_test.go @@ -1,44 +1,45 @@ -package models +package models_test import ( "testing" + "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 TestResthooks(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - tx, err := db.BeginTxx(ctx, nil) - assert.NoError(t, err) - defer tx.Rollback() - - tx.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id) + db.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id) VALUES(TRUE, NOW(), NOW(), 'registration', 1, 1, 1);`) - tx.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id) + db.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id) VALUES(TRUE, NOW(), NOW(), 'block', 1, 1, 1);`) - tx.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) + db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'https://foo.bar', 1, 1, 2);`) - tx.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) + db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'https://bar.foo', 1, 1, 2);`) - resthooks, err := loadResthooks(ctx, tx, 1) - assert.NoError(t, err) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshResthooks) + require.NoError(t, err) + + resthooks, err := oa.Resthooks() + require.NoError(t, err) tcs := []struct { - ID ResthookID + ID models.ResthookID Slug string Subscribers []string }{ - {ResthookID(2), "block", []string{"https://bar.foo", "https://foo.bar"}}, - {ResthookID(1), "registration", nil}, + {models.ResthookID(2), "block", []string{"https://bar.foo", "https://foo.bar"}}, + {models.ResthookID(1), "registration", nil}, } assert.Equal(t, 2, len(resthooks)) for i, tc := range tcs { - resthook := resthooks[i].(*Resthook) + resthook := resthooks[i].(*models.Resthook) assert.Equal(t, tc.ID, resthook.ID()) assert.Equal(t, tc.Slug, resthook.Slug()) assert.Equal(t, tc.Subscribers, resthook.Subscribers()) diff --git a/core/models/runs.go b/core/models/runs.go index 4da57c980..f4b327387 100644 --- a/core/models/runs.go +++ b/core/models/runs.go @@ -6,13 +6,18 @@ import ( "database/sql" "encoding/json" "fmt" + "net/url" + "path" "time" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/nyaruka/gocommon/storage" "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/flows/events" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/null" @@ -101,7 +106,8 @@ type Session struct { SessionType FlowType `db:"session_type"` Status SessionStatus `db:"status"` Responded bool `db:"responded"` - Output string `db:"output"` + Output null.String `db:"output"` + OutputURL null.String `db:"output_url"` ContactID ContactID `db:"contact_id"` OrgID OrgID `db:"org_id"` CreatedOn time.Time `db:"created_on"` @@ -141,7 +147,8 @@ func (s *Session) UUID() flows.SessionUUID { return flows.SessionUUID func (s *Session) SessionType() FlowType { return s.s.SessionType } func (s *Session) Status() SessionStatus { return s.s.Status } func (s *Session) Responded() bool { return s.s.Responded } -func (s *Session) Output() string { return s.s.Output } +func (s *Session) Output() string { return string(s.s.Output) } +func (s *Session) OutputURL() string { return string(s.s.OutputURL) } func (s *Session) ContactID() ContactID { return s.s.ContactID } func (s *Session) OrgID() OrgID { return s.s.OrgID } func (s *Session) CreatedOn() time.Time { return s.s.CreatedOn } @@ -155,6 +162,53 @@ func (s *Session) IncomingMsgID() MsgID { return s.incomingMsgID } func (s *Session) IncomingMsgExternalID() null.String { return s.incomingExternalID } func (s *Session) Scene() *Scene { return s.scene } +// WriteSessionsToStorage writes the outputs of the passed in sessions to our storage (S3), updating the +// output_url for each on success. Failure of any will cause all to fail. +func WriteSessionOutputsToStorage(ctx context.Context, st storage.Storage, sessions []*Session) error { + start := time.Now() + + uploads := make([]*storage.Upload, len(sessions)) + for i, s := range sessions { + uploads[i] = &storage.Upload{ + Path: s.StoragePath(config.Mailroom), + Body: []byte(s.Output()), + ContentType: "application/json", + ACL: s3.ObjectCannedACLPrivate, + } + } + + err := st.BatchPut(ctx, uploads) + if err != nil { + return errors.Wrapf(err, "error writing sessions to storage") + } + + for i, s := range sessions { + s.s.OutputURL = null.String(uploads[i].URL) + } + + logrus.WithField("elapsed", time.Since(start)).WithField("count", len(sessions)).Debug("wrote sessions to s3") + + return nil +} + +const storageTSFormat = "20060102T150405.999Z" + +// StoragePath returns the path for the session +func (s *Session) StoragePath(cfg *config.Config) string { + ts := s.CreatedOn().UTC().Format(storageTSFormat) + + // example output: /orgs/1/c/20a5/20a5534c-b2ad-4f18-973a-f1aa3b4e6c74/session_20060102T150405.123Z_8a7fc501-177b-4567-a0aa-81c48e6de1c5_51df83ac21d3cf136d8341f0b11cb1a7.json" + return path.Join( + cfg.S3SessionPrefix, + "orgs", + fmt.Sprintf("%d", s.OrgID()), + "c", + string(s.ContactUUID()[:4]), + string(s.ContactUUID()), + fmt.Sprintf("%s_session_%s_%s.json", ts, s.UUID(), s.OutputMD5()), + ) +} + // ContactUUID returns the UUID of our contact func (s *Session) ContactUUID() flows.ContactUUID { return s.contact.UUID() @@ -320,7 +374,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi s.Status = sessionStatus s.SessionType = sessionType s.Responded = false - s.Output = string(output) + s.Output = null.String(output) s.ContactID = ContactID(fs.Contact().ID()) s.OrgID = org.OrgID() s.CreatedOn = fs.Runs()[0].CreatedOn() @@ -343,7 +397,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi // if this run is waiting, save it as the current flow if r.Status() == flows.RunStatusWaiting { - flowID, err := flowIDForUUID(ctx, tx, org, r.FlowReference().UUID) + flowID, err := FlowIDForUUID(ctx, tx, org, r.FlowReference().UUID) if err != nil { return nil, errors.Wrapf(err, "error loading current flow for UUID: %s", r.FlowReference().UUID) } @@ -358,7 +412,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi } // ActiveSessionForContact returns the active session for the passed in contact, if any -func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, org *OrgAssets, sessionType FlowType, contact *flows.Contact) (*Session, error) { +func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, st storage.Storage, org *OrgAssets, sessionType FlowType, contact *flows.Contact) (*Session, error) { rows, err := db.QueryxContext(ctx, selectLastSessionSQL, sessionType, contact.ID()) if err != nil { return nil, errors.Wrapf(err, "error selecting active session") @@ -380,6 +434,31 @@ func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, org *OrgAssets, s return nil, errors.Wrapf(err, "error scanning session") } + // load our output if necessary + if org.Org().SessionStorageMode() == S3Sessions && session.OutputURL() != "" { + start := time.Now() + + // strip just the path out of our output URL + u, err := url.Parse(session.OutputURL()) + if err != nil { + return nil, errors.Wrapf(err, "error parsing output URL: %s", session.OutputURL()) + } + + // get our session from storage + _, output, err := st.Get(ctx, u.Path) + if err != nil { + logrus.WithField("output_url", session.OutputURL()).WithField("org_id", org.OrgID()).WithField("session_uuid", session.UUID()).WithError(err).Error("error reading in session output from storage") + + // we'll throw an error up only if we don't have a DB backdown + if session.Output() == "" { + return nil, errors.Wrapf(err, "error reading session from storage: %s", session.OutputURL()) + } + } else { + session.s.Output = null.String(output) + logrus.WithField("elapsed", time.Since(start)).WithField("output_url", session.OutputURL()).Debug("loaded session from storage") + } + } + return session, nil } @@ -391,6 +470,7 @@ SELECT status, responded, output, + output_url, contact_id, org_id, created_on, @@ -412,21 +492,21 @@ LIMIT 1 const insertCompleteSessionSQL = ` INSERT INTO - flows_flowsession( uuid, session_type, status, responded, output, contact_id, org_id, created_on, ended_on, wait_started_on, connection_id) - VALUES(:uuid,:session_type,:status,:responded,:output,:contact_id,:org_id, NOW(), NOW(), NULL, :connection_id) + flows_flowsession( uuid, session_type, status, responded, output, output_url, contact_id, org_id, created_on, ended_on, wait_started_on, connection_id) + VALUES(:uuid,:session_type,:status,:responded,:output,:output_url,:contact_id,:org_id, NOW(), NOW(), NULL, :connection_id) RETURNING id ` const insertIncompleteSessionSQL = ` INSERT INTO - flows_flowsession( uuid, session_type, status, responded, output, contact_id, org_id, created_on, current_flow_id, timeout_on, wait_started_on, connection_id) - VALUES(:uuid,:session_type,:status,:responded,:output,:contact_id,:org_id, NOW(), :current_flow_id,:timeout_on,:wait_started_on,:connection_id) + flows_flowsession( uuid, session_type, status, responded, output, output_url, contact_id, org_id, created_on, current_flow_id, timeout_on, wait_started_on, connection_id) + VALUES(:uuid,:session_type,:status,:responded,:output,:output_url,:contact_id,:org_id, NOW(), :current_flow_id,:timeout_on,:wait_started_on,:connection_id) RETURNING id ` // FlowSession creates a flow session for the passed in session object. It also populates the runs we know about func (s *Session) FlowSession(sa flows.SessionAssets, env envs.Environment) (flows.Session, error) { - session, err := goflow.Engine().ReadSession(sa, json.RawMessage(s.s.Output), assets.IgnoreMissing) + session, err := goflow.Engine(config.Mailroom).ReadSession(sa, json.RawMessage(s.s.Output), assets.IgnoreMissing) if err != nil { return nil, errors.Wrapf(err, "unable to unmarshal session") } @@ -460,7 +540,7 @@ func (s *Session) calculateTimeout(fs flows.Session, sprint flows.Sprint) { } // WriteUpdatedSession updates the session based on the state passed in from our engine session, this also takes care of applying any event hooks -func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAssets, fs flows.Session, sprint flows.Sprint, hook SessionCommitHook) error { +func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, st storage.Storage, org *OrgAssets, fs flows.Session, sprint flows.Sprint, hook SessionCommitHook) error { // make sure we have our seen runs if s.seenRuns == nil { return errors.Errorf("missing seen runs, cannot update session") @@ -470,7 +550,7 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi if err != nil { return errors.Wrapf(err, "error marshalling flow session") } - s.s.Output = string(output) + s.s.Output = null.String(output) // map our status over status, found := sessionStatusMap[fs.Status()] @@ -501,7 +581,7 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi for _, r := range fs.Runs() { // if this run is waiting, save it as the current flow if r.Status() == flows.RunStatusWaiting { - flowID, err := flowIDForUUID(ctx, tx, org, r.FlowReference().UUID) + flowID, err := FlowIDForUUID(ctx, tx, org, r.FlowReference().UUID) if err != nil { return errors.Wrapf(err, "error loading flow: %s", r.FlowReference().UUID) } @@ -528,6 +608,15 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi } } + // if writing to S3, do so + sessionMode := org.Org().SessionStorageMode() + if sessionMode == S3Sessions || sessionMode == S3WriteSessions { + err := WriteSessionOutputsToStorage(ctx, st, []*Session{s}) + if err != nil { + logrus.WithError(err).Error("error writing session to s3") + } + } + // write our new session state to the db _, err = tx.NamedExecContext(ctx, updateSessionSQL, s.s) if err != nil { @@ -603,6 +692,7 @@ UPDATE flows_flowsession SET output = :output, + output_url = :output_url, status = :status, ended_on = CASE WHEN :status = 'W' THEN NULL ELSE NOW() END, responded = :responded, @@ -638,7 +728,7 @@ WHERE // WriteSessions writes the passed in session to our database, writes any runs that need to be created // as well as appying any events created in the session -func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAssets, ss []flows.Session, sprints []flows.Sprint, hook SessionCommitHook) ([]*Session, error) { +func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, st storage.Storage, org *OrgAssets, ss []flows.Session, sprints []flows.Sprint, hook SessionCommitHook) ([]*Session, error) { if len(ss) == 0 { return nil, nil } @@ -683,6 +773,16 @@ func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAss } } + // if writing our sessions to S3, do so + sessionMode := org.Org().SessionStorageMode() + if sessionMode == S3Sessions || sessionMode == S3WriteSessions { + err := WriteSessionOutputsToStorage(ctx, st, sessions) + if err != nil { + // for now, continue on for errors, we are still reading from the DB + logrus.WithError(err).Error("error writing sessions to s3") + } + } + // insert our complete sessions first err := BulkQuery(ctx, "insert completed sessions", tx, insertCompleteSessionSQL, completeSessionsI) if err != nil { @@ -769,7 +869,7 @@ func newRun(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, session *Session, return nil, err } - flowID, err := flowIDForUUID(ctx, tx, org, fr.FlowReference().UUID) + flowID, err := FlowIDForUUID(ctx, tx, org, fr.FlowReference().UUID) if err != nil { return nil, errors.Wrapf(err, "unable to load flow with uuid: %s", fr.FlowReference().UUID) } diff --git a/core/models/schedules.go b/core/models/schedules.go index 705d1a834..b3dfe918a 100644 --- a/core/models/schedules.go +++ b/core/models/schedules.go @@ -67,10 +67,24 @@ type Schedule struct { } } -func (s *Schedule) ID() ScheduleID { return s.s.ID } -func (s *Schedule) OrgID() OrgID { return s.s.OrgID } -func (s *Schedule) Broadcast() *Broadcast { return s.s.Broadcast } -func (s *Schedule) FlowStart() *FlowStart { return s.s.FlowStart } +func NewSchedule(period RepeatPeriod, hourOfDay, minuteOfHour, dayOfMonth *int, daysOfWeek string) *Schedule { + sched := &Schedule{} + s := &sched.s + s.RepeatPeriod = period + s.HourOfDay = hourOfDay + s.MinuteOfHour = minuteOfHour + s.DayOfMonth = dayOfMonth + s.DaysOfWeek = null.String(daysOfWeek) + return sched +} + +func (s *Schedule) ID() ScheduleID { return s.s.ID } +func (s *Schedule) OrgID() OrgID { return s.s.OrgID } +func (s *Schedule) Broadcast() *Broadcast { return s.s.Broadcast } +func (s *Schedule) FlowStart() *FlowStart { return s.s.FlowStart } +func (s *Schedule) RepeatPeriod() RepeatPeriod { return s.s.RepeatPeriod } +func (s *Schedule) NextFire() *time.Time { return s.s.NextFire } +func (s *Schedule) LastFire() *time.Time { return s.s.LastFire } func (s *Schedule) Timezone() (*time.Location, error) { return time.LoadLocation(s.s.Timezone) } @@ -253,7 +267,15 @@ SELECT ROW_TO_JSON(s) FROM (SELECT triggers_trigger_groups tg WHERE tg.trigger_id = t.id - ) tg) as group_ids + ) tg) as group_ids, + (SELECT ARRAY_AGG(tg.contactgroup_id) FROM ( + SELECT + tg.contactgroup_id + FROM + triggers_trigger_exclude_groups tg + WHERE + tg.trigger_id = t.id + ) tg) as exclude_group_ids FROM triggers_trigger t JOIN flows_flow f on t.flow_id = f.id diff --git a/core/models/schedules_test.go b/core/models/schedules_test.go index 52cce3eb1..e01ecb0aa 100644 --- a/core/models/schedules_test.go +++ b/core/models/schedules_test.go @@ -1,4 +1,4 @@ -package models +package models_test import ( "testing" @@ -6,106 +6,97 @@ import ( "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/envs" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" - "github.com/nyaruka/null" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestGetExpired(t *testing.T) { - ctx := testsuite.CTX() + ctx, _, db, _ := testsuite.Get() // add a schedule and tie a broadcast to it - db := testsuite.DB() - var s1 ScheduleID + var s1 models.ScheduleID err := db.Get( &s1, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '1 DAY', 1, 1, $1) RETURNING id`, - Org1, - ) - assert.NoError(t, err) - var b1 BroadcastID - err = db.Get( - &b1, - `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id, schedule_id) - VALUES('P', hstore(ARRAY['eng','Test message', 'fra', 'Un Message']), 'eng', TRUE, NOW(), NOW(), TRUE, 1, 1, $1, $2) RETURNING id`, - Org1, s1, + testdata.Org1.ID, ) assert.NoError(t, err) - // add a few contacts to the broadcast - db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2),($1, $3)`, b1, CathyID, GeorgeID) - - // and a group - db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, b1, DoctorsGroupID) + b1 := testdata.InsertBroadcast(db, testdata.Org1, "eng", map[envs.Language]string{"eng": "Test message", "fra": "Un Message"}, s1, + []*testdata.Contact{testdata.Cathy, testdata.George}, []*testdata.Group{testdata.DoctorsGroup}, + ) - // and a URN - db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, CathyURNID) + // add a URN + db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, testdata.Cathy.URNID) // add another and tie a trigger to it - var s2 ScheduleID + var s2 models.ScheduleID err = db.Get( &s2, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '2 DAY', 1, 1, $1) RETURNING id`, - Org1, + testdata.Org1.ID, ) assert.NoError(t, err) - var t1 TriggerID + var t1 models.TriggerID err = db.Get( &t1, `INSERT INTO triggers_trigger(is_active, created_on, modified_on, is_archived, trigger_type, created_by_id, modified_by_id, org_id, flow_id, schedule_id) VALUES(TRUE, NOW(), NOW(), FALSE, 'S', 1, 1, $1, $2, $3) RETURNING id`, - Org1, FavoritesFlowID, s2, + testdata.Org1.ID, testdata.Favorites.ID, s2, ) assert.NoError(t, err) // add a few contacts to the trigger - db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, CathyID, GeorgeID) + db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, testdata.Cathy.ID, testdata.George.ID) // and a group - db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, DoctorsGroupID) + db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, testdata.DoctorsGroup.ID) - var s3 ScheduleID + var s3 models.ScheduleID err = db.Get( &s3, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '3 DAY', 1, 1, $1) RETURNING id`, - Org1, + testdata.Org1.ID, ) assert.NoError(t, err) // get expired schedules - schedules, err := GetUnfiredSchedules(ctx, db) + schedules, err := models.GetUnfiredSchedules(ctx, db) assert.NoError(t, err) assert.Equal(t, 3, len(schedules)) assert.Equal(t, s3, schedules[0].ID()) assert.Nil(t, schedules[0].Broadcast()) - assert.Equal(t, RepeatPeriodNever, schedules[0].s.RepeatPeriod) - assert.NotNil(t, schedules[0].s.NextFire) - assert.Nil(t, schedules[0].s.LastFire) + assert.Equal(t, models.RepeatPeriodNever, schedules[0].RepeatPeriod()) + assert.NotNil(t, schedules[0].NextFire()) + assert.Nil(t, schedules[0].LastFire()) assert.Equal(t, s2, schedules[1].ID()) assert.Nil(t, schedules[1].Broadcast()) start := schedules[1].FlowStart() assert.NotNil(t, start) - assert.Equal(t, FlowTypeMessaging, start.FlowType()) - assert.Equal(t, FavoritesFlowID, start.FlowID()) - assert.Equal(t, Org1, start.OrgID()) - assert.Equal(t, []ContactID{CathyID, GeorgeID}, start.ContactIDs()) - assert.Equal(t, []GroupID{DoctorsGroupID}, start.GroupIDs()) + assert.Equal(t, models.FlowTypeMessaging, start.FlowType()) + assert.Equal(t, testdata.Favorites.ID, start.FlowID()) + assert.Equal(t, testdata.Org1.ID, start.OrgID()) + assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, start.ContactIDs()) + assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, start.GroupIDs()) assert.Equal(t, s1, schedules[2].ID()) bcast := schedules[2].Broadcast() assert.NotNil(t, bcast) assert.Equal(t, envs.Language("eng"), bcast.BaseLanguage()) - assert.Equal(t, TemplateStateUnevaluated, bcast.TemplateState()) + assert.Equal(t, models.TemplateStateUnevaluated, bcast.TemplateState()) assert.Equal(t, "Test message", bcast.Translations()["eng"].Text) assert.Equal(t, "Un Message", bcast.Translations()["fra"].Text) - assert.Equal(t, Org1, bcast.OrgID()) - assert.Equal(t, []ContactID{CathyID, GeorgeID}, bcast.ContactIDs()) - assert.Equal(t, []GroupID{DoctorsGroupID}, bcast.GroupIDs()) + assert.Equal(t, testdata.Org1.ID, bcast.OrgID()) + assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, bcast.ContactIDs()) + assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, bcast.GroupIDs()) assert.Equal(t, []urns.URN{urns.URN("tel:+16055741111?id=10000")}, bcast.URNs()) } @@ -126,11 +117,11 @@ func TestNextFire(t *testing.T) { Label string Now time.Time Location *time.Location - Period RepeatPeriod + Period models.RepeatPeriod HourOfDay *int MinuteOfHour *int DayOfMonth *int - DaysOfWeek null.String + DaysOfWeek string Next []*time.Time Error string }{ @@ -138,14 +129,14 @@ func TestNextFire(t *testing.T) { Label: "no hour of day set", Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, Error: "schedule 0 has no repeat_hour_of_day set", }, { Label: "no minute of hour set", Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), Error: "schedule 0 has no repeat_minute_of_hour set", }, @@ -162,7 +153,7 @@ func TestNextFire(t *testing.T) { Label: "no repeat", Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la), Location: la, - Period: RepeatPeriodNever, + Period: models.RepeatPeriodNever, HourOfDay: ip(12), MinuteOfHour: ip(35), Next: nil, @@ -171,7 +162,7 @@ func TestNextFire(t *testing.T) { Label: "daily repeat on same day", Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), MinuteOfHour: ip(35), Next: []*time.Time{dp(2019, 8, 20, 12, 35, la)}, @@ -180,7 +171,7 @@ func TestNextFire(t *testing.T) { Label: "daily repeat on same hour minute", Now: time.Date(2019, 8, 20, 12, 35, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), MinuteOfHour: ip(35), Next: []*time.Time{dp(2019, 8, 21, 12, 35, la)}, @@ -189,7 +180,7 @@ func TestNextFire(t *testing.T) { Label: "daily repeat for next day", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), MinuteOfHour: ip(35), Next: []*time.Time{dp(2019, 8, 21, 12, 35, la)}, @@ -198,7 +189,7 @@ func TestNextFire(t *testing.T) { Label: "daily repeat for next day across DST start", Now: time.Date(2019, 3, 9, 12, 30, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), MinuteOfHour: ip(30), Next: []*time.Time{ @@ -210,7 +201,7 @@ func TestNextFire(t *testing.T) { Label: "daily repeat for next day across DST end", Now: time.Date(2019, 11, 2, 12, 30, 0, 0, la), Location: la, - Period: RepeatPeriodDaily, + Period: models.RepeatPeriodDaily, HourOfDay: ip(12), MinuteOfHour: ip(30), Next: []*time.Time{ @@ -222,7 +213,7 @@ func TestNextFire(t *testing.T) { Label: "weekly repeat missing days of week", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(35), Error: "schedule 0 repeats weekly but has no repeat_days_of_week", @@ -231,20 +222,20 @@ func TestNextFire(t *testing.T) { Label: "weekly with invalid days of week", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(35), - DaysOfWeek: null.String("Z"), + DaysOfWeek: "Z", Error: "schedule 0 has unknown day of week: Z", }, { Label: "weekly repeat to day later in week", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(35), - DaysOfWeek: null.String("RU"), + DaysOfWeek: "RU", Next: []*time.Time{ dp(2019, 8, 22, 12, 35, la), dp(2019, 8, 25, 12, 35, la), @@ -255,10 +246,10 @@ func TestNextFire(t *testing.T) { Label: "weekly repeat to day later in week using fire date", Now: time.Date(2019, 8, 26, 12, 35, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(35), - DaysOfWeek: null.String("MTWRFSU"), + DaysOfWeek: "MTWRFSU", Next: []*time.Time{ dp(2019, 8, 27, 12, 35, la), dp(2019, 8, 28, 12, 35, la), @@ -272,27 +263,27 @@ func TestNextFire(t *testing.T) { Label: "weekly repeat for next day across DST", Now: time.Date(2019, 3, 9, 12, 30, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(30), - DaysOfWeek: null.String("MTWRFSU"), + DaysOfWeek: "MTWRFSU", Next: []*time.Time{dp(2019, 3, 10, 12, 30, la)}, }, { Label: "weekly repeat to day in next week", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodWeekly, + Period: models.RepeatPeriodWeekly, HourOfDay: ip(12), MinuteOfHour: ip(35), - DaysOfWeek: null.String("M"), + DaysOfWeek: "M", Next: []*time.Time{dp(2019, 8, 26, 12, 35, la)}, }, { Label: "monthly repeat with no day of month set", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), Error: "schedule 0 repeats monthly but has no repeat_day_of_month", @@ -301,7 +292,7 @@ func TestNextFire(t *testing.T) { Label: "monthly repeat to day in same month", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), DayOfMonth: ip(31), @@ -316,7 +307,7 @@ func TestNextFire(t *testing.T) { Label: "monthly repeat to day in same month from fire date", Now: time.Date(2019, 8, 20, 12, 35, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), DayOfMonth: ip(20), @@ -326,7 +317,7 @@ func TestNextFire(t *testing.T) { Label: "monthly repeat to day in next month", Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), DayOfMonth: ip(5), @@ -336,7 +327,7 @@ func TestNextFire(t *testing.T) { Label: "monthly repeat to day that exceeds month", Now: time.Date(2019, 9, 20, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), DayOfMonth: ip(31), @@ -346,7 +337,7 @@ func TestNextFire(t *testing.T) { Label: "monthly repeat to day in next month that exceeds month", Now: time.Date(2019, 8, 31, 13, 57, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(35), DayOfMonth: ip(31), @@ -356,7 +347,7 @@ func TestNextFire(t *testing.T) { Label: "monthy repeat for next month across DST", Now: time.Date(2019, 2, 10, 12, 30, 0, 0, la), Location: la, - Period: RepeatPeriodMonthly, + Period: models.RepeatPeriodMonthly, HourOfDay: ip(12), MinuteOfHour: ip(30), DayOfMonth: ip(10), @@ -367,14 +358,7 @@ func TestNextFire(t *testing.T) { tests: for _, tc := range tcs { // create a fake schedule - sched := &Schedule{} - s := &sched.s - s.RepeatPeriod = tc.Period - s.HourOfDay = tc.HourOfDay - s.MinuteOfHour = tc.MinuteOfHour - s.DayOfMonth = tc.DayOfMonth - s.DaysOfWeek = tc.DaysOfWeek - + sched := models.NewSchedule(tc.Period, tc.HourOfDay, tc.MinuteOfHour, tc.DayOfMonth, tc.DaysOfWeek) now := tc.Now for _, n := range tc.Next { diff --git a/core/models/search_test.go b/core/models/search_test.go index e6b0a9777..c92932aa8 100644 --- a/core/models/search_test.go +++ b/core/models/search_test.go @@ -8,6 +8,7 @@ import ( "github.com/nyaruka/goflow/test" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/olivere/elastic/v7" "github.com/stretchr/testify/assert" @@ -15,9 +16,7 @@ import ( ) func TestContactIDsForQueryPage(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Reset() es := testsuite.NewMockElasticServer() defer es.Close() @@ -44,7 +43,7 @@ func TestContactIDsForQueryPage(t *testing.T) { ExpectedError string }{ { - Group: models.AllContactsGroupUUID, + Group: testdata.AllContactsGroup.UUID, Query: "george", ExpectedESRequest: `{ "_source": false, @@ -113,13 +112,13 @@ func TestContactIDsForQueryPage(t *testing.T) { } ] } - }`, models.GeorgeID), - ExpectedContacts: []models.ContactID{models.GeorgeID}, + }`, testdata.George.ID), + ExpectedContacts: []models.ContactID{testdata.George.ID}, ExpectedTotal: 1, }, { - Group: models.BlockedContactsGroupUUID, - ExcludeIDs: []models.ContactID{models.BobID, models.CathyID}, + Group: testdata.BlockedContactsGroup.UUID, + ExcludeIDs: []models.ContactID{testdata.Bob.ID, testdata.Cathy.ID}, Query: "age > 32", Sort: "-age", ExpectedESRequest: `{ @@ -225,8 +224,8 @@ func TestContactIDsForQueryPage(t *testing.T) { } ] } - }`, models.GeorgeID), - ExpectedContacts: []models.ContactID{models.GeorgeID}, + }`, testdata.George.ID), + ExpectedContacts: []models.ContactID{testdata.George.ID}, ExpectedTotal: 1, }, { @@ -253,9 +252,7 @@ func TestContactIDsForQueryPage(t *testing.T) { } func TestContactIDsForQuery(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Reset() es := testsuite.NewMockElasticServer() defer es.Close() @@ -337,8 +334,8 @@ func TestContactIDsForQuery(t *testing.T) { } ] } - }`, models.GeorgeID), - ExpectedContacts: []models.ContactID{models.GeorgeID}, + }`, testdata.George.ID), + ExpectedContacts: []models.ContactID{testdata.George.ID}, }, { Query: "nobody", ExpectedESRequest: `{ diff --git a/core/models/starts.go b/core/models/starts.go index cbd465944..035f9be15 100644 --- a/core/models/starts.go +++ b/core/models/starts.go @@ -103,13 +103,13 @@ func MarkStartFailed(ctx context.Context, db Queryer, startID StartID) error { // FlowStartBatch represents a single flow batch that needs to be started type FlowStartBatch struct { b struct { - StartID StartID `json:"start_id"` - StartType StartType `json:"start_type"` - OrgID OrgID `json:"org_id"` - CreatedBy string `json:"created_by"` - FlowID FlowID `json:"flow_id"` - FlowType FlowType `json:"flow_type"` - ContactIDs []ContactID `json:"contact_ids"` + StartID StartID `json:"start_id"` + StartType StartType `json:"start_type"` + OrgID OrgID `json:"org_id"` + CreatedByID UserID `json:"created_by_id"` + FlowID FlowID `json:"flow_id"` + FlowType FlowType `json:"flow_type"` + ContactIDs []ContactID `json:"contact_ids"` ParentSummary null.JSON `json:"parent_summary,omitempty"` SessionHistory null.JSON `json:"session_history,omitempty"` @@ -120,13 +120,15 @@ type FlowStartBatch struct { IsLast bool `json:"is_last,omitempty"` TotalContacts int `json:"total_contacts"` + + CreatedBy string `json:"created_by"` // deprecated } } func (b *FlowStartBatch) StartID() StartID { return b.b.StartID } func (b *FlowStartBatch) StartType() StartType { return b.b.StartType } func (b *FlowStartBatch) OrgID() OrgID { return b.b.OrgID } -func (b *FlowStartBatch) CreatedBy() string { return b.b.CreatedBy } +func (b *FlowStartBatch) CreatedByID() UserID { return b.b.CreatedByID } func (b *FlowStartBatch) FlowID() FlowID { return b.b.FlowID } func (b *FlowStartBatch) ContactIDs() []ContactID { return b.b.ContactIDs } func (b *FlowStartBatch) RestartParticipants() RestartParticipants { return b.b.RestartParticipants } @@ -144,19 +146,20 @@ func (b *FlowStartBatch) UnmarshalJSON(data []byte) error { return json.Unmarsha // FlowStart represents the top level flow start in our system type FlowStart struct { s struct { - ID StartID `json:"start_id" db:"id"` - UUID uuids.UUID ` db:"uuid"` - StartType StartType `json:"start_type" db:"start_type"` - OrgID OrgID `json:"org_id" db:"org_id"` - FlowID FlowID `json:"flow_id" db:"flow_id"` - FlowType FlowType `json:"flow_type"` - - GroupIDs []GroupID `json:"group_ids,omitempty"` - ContactIDs []ContactID `json:"contact_ids,omitempty"` - URNs []urns.URN `json:"urns,omitempty"` - Query null.String `json:"query,omitempty" db:"query"` - - CreateContact bool `json:"create_contact"` + ID StartID `json:"start_id" db:"id"` + UUID uuids.UUID ` db:"uuid"` + StartType StartType `json:"start_type" db:"start_type"` + OrgID OrgID `json:"org_id" db:"org_id"` + CreatedByID UserID `json:"created_by_id" db:"created_by_id"` + FlowID FlowID `json:"flow_id" db:"flow_id"` + FlowType FlowType `json:"flow_type"` + + URNs []urns.URN `json:"urns,omitempty"` + ContactIDs []ContactID `json:"contact_ids,omitempty"` + GroupIDs []GroupID `json:"group_ids,omitempty"` + ExcludeGroupIDs []GroupID `json:"exclude_group_ids,omitempty"` // used when loading scheduled triggers as flow starts + Query null.String `json:"query,omitempty" db:"query"` + CreateContact bool `json:"create_contact"` RestartParticipants RestartParticipants `json:"restart_participants" db:"restart_participants"` IncludeActive IncludeActive `json:"include_active" db:"include_active"` @@ -165,20 +168,26 @@ type FlowStart struct { ParentSummary null.JSON `json:"parent_summary,omitempty" db:"parent_summary"` SessionHistory null.JSON `json:"session_history,omitempty" db:"session_history"` - CreatedBy string `json:"created_by"` + CreatedBy string `json:"created_by"` // TODO deprecated } } -func (s *FlowStart) ID() StartID { return s.s.ID } -func (s *FlowStart) OrgID() OrgID { return s.s.OrgID } -func (s *FlowStart) FlowID() FlowID { return s.s.FlowID } -func (s *FlowStart) FlowType() FlowType { return s.s.FlowType } +func (s *FlowStart) ID() StartID { return s.s.ID } +func (s *FlowStart) OrgID() OrgID { return s.s.OrgID } +func (s *FlowStart) CreatedByID() UserID { return s.s.CreatedByID } +func (s *FlowStart) FlowID() FlowID { return s.s.FlowID } +func (s *FlowStart) FlowType() FlowType { return s.s.FlowType } func (s *FlowStart) GroupIDs() []GroupID { return s.s.GroupIDs } func (s *FlowStart) WithGroupIDs(groupIDs []GroupID) *FlowStart { s.s.GroupIDs = groupIDs return s } +func (s *FlowStart) ExcludeGroupIDs() []GroupID { return s.s.ExcludeGroupIDs } +func (s *FlowStart) WithExcludeGroupIDs(groupIDs []GroupID) *FlowStart { + s.s.ExcludeGroupIDs = groupIDs + return s +} func (s *FlowStart) ContactIDs() []ContactID { return s.s.ContactIDs } func (s *FlowStart) WithContactIDs(contactIDs []ContactID) *FlowStart { @@ -354,6 +363,7 @@ func (s *FlowStart) CreateBatch(contactIDs []ContactID, last bool, totalContacts b.b.IsLast = last b.b.TotalContacts = totalContacts b.b.CreatedBy = s.s.CreatedBy + b.b.CreatedByID = s.s.CreatedByID return b } diff --git a/core/models/starts_test.go b/core/models/starts_test.go index 802ffb414..b6260bfca 100644 --- a/core/models/starts_test.go +++ b/core/models/starts_test.go @@ -5,7 +5,10 @@ import ( "fmt" "testing" + "github.com/nyaruka/gocommon/jsonx" + "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/test" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" @@ -15,20 +18,20 @@ import ( ) func TestStarts(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - startID := testdata.InsertFlowStart(t, db, models.Org1, models.SingleMessageFlowID, []models.ContactID{models.CathyID, models.BobID}) + startID := testdata.InsertFlowStart(db, testdata.Org1, testdata.SingleMessage, []*testdata.Contact{testdata.Cathy, testdata.Bob}) startJSON := []byte(fmt.Sprintf(`{ "start_id": %d, "start_type": "M", "org_id": %d, - "created_by": "rowan@nyaruka.com", + "created_by_id": %d, "flow_id": %d, "flow_type": "M", "contact_ids": [%d, %d], - "group_ids": [6789], + "group_ids": [%d], + "exclude_group_ids": [%d], "urns": ["tel:+12025550199"], "query": null, "restart_participants": true, @@ -36,38 +39,42 @@ func TestStarts(t *testing.T) { "parent_summary": {"uuid": "b65b1a22-db6d-4f5a-9b3d-7302368a82e6"}, "session_history": {"parent_uuid": "532a3899-492f-4ffe-aed7-e75ad524efab", "ancestors": 3, "ancestors_since_input": 1}, "extra": {"foo": "bar"} - }`, startID, models.Org1, models.SingleMessageFlowID, models.CathyID, models.BobID)) + }`, startID, testdata.Org1.ID, testdata.Admin.ID, testdata.SingleMessage.ID, testdata.Cathy.ID, testdata.Bob.ID, testdata.DoctorsGroup.ID, testdata.TestersGroup.ID)) start := &models.FlowStart{} err := json.Unmarshal(startJSON, start) require.NoError(t, err) assert.Equal(t, startID, start.ID()) - assert.Equal(t, models.Org1, start.OrgID()) - assert.Equal(t, models.SingleMessageFlowID, start.FlowID()) + assert.Equal(t, testdata.Org1.ID, start.OrgID()) + assert.Equal(t, testdata.Admin.ID, start.CreatedByID()) + assert.Equal(t, testdata.SingleMessage.ID, start.FlowID()) assert.Equal(t, models.FlowTypeMessaging, start.FlowType()) assert.Equal(t, "", start.Query()) assert.Equal(t, models.DoRestartParticipants, start.RestartParticipants()) assert.Equal(t, models.DoIncludeActive, start.IncludeActive()) + assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, start.ContactIDs()) + assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, start.GroupIDs()) + assert.Equal(t, []models.GroupID{testdata.TestersGroup.ID}, start.ExcludeGroupIDs()) assert.Equal(t, json.RawMessage(`{"uuid": "b65b1a22-db6d-4f5a-9b3d-7302368a82e6"}`), start.ParentSummary()) assert.Equal(t, json.RawMessage(`{"parent_uuid": "532a3899-492f-4ffe-aed7-e75ad524efab", "ancestors": 3, "ancestors_since_input": 1}`), start.SessionHistory()) assert.Equal(t, json.RawMessage(`{"foo": "bar"}`), start.Extra()) - err = models.MarkStartStarted(ctx, db, startID, 2, []models.ContactID{models.GeorgeID}) + err = models.MarkStartStarted(ctx, db, startID, 2, []models.ContactID{testdata.George.ID}) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'S' AND contact_count = 2`, []interface{}{startID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart_contacts WHERE flowstart_id = $1`, []interface{}{startID}, 3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'S' AND contact_count = 2`, startID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart_contacts WHERE flowstart_id = $1`, startID).Returns(3) - batch := start.CreateBatch([]models.ContactID{models.CathyID, models.BobID}, false, 3) + batch := start.CreateBatch([]models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, false, 3) assert.Equal(t, startID, batch.StartID()) assert.Equal(t, models.StartTypeManual, batch.StartType()) - assert.Equal(t, models.SingleMessageFlowID, batch.FlowID()) - assert.Equal(t, []models.ContactID{models.CathyID, models.BobID}, batch.ContactIDs()) + assert.Equal(t, testdata.SingleMessage.ID, batch.FlowID()) + assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, batch.ContactIDs()) assert.Equal(t, models.DoRestartParticipants, batch.RestartParticipants()) assert.Equal(t, models.DoIncludeActive, batch.IncludeActive()) - assert.Equal(t, "rowan@nyaruka.com", batch.CreatedBy()) + assert.Equal(t, testdata.Admin.ID, batch.CreatedByID()) assert.False(t, batch.IsLast()) assert.Equal(t, 3, batch.TotalContacts()) @@ -79,11 +86,44 @@ func TestStarts(t *testing.T) { assert.NoError(t, err) assert.Equal(t, flows.SessionUUID("532a3899-492f-4ffe-aed7-e75ad524efab"), history.ParentUUID) - history, err = models.ReadSessionHistory([]byte(`{`)) + _, err = models.ReadSessionHistory([]byte(`{`)) assert.EqualError(t, err, "unexpected end of JSON input") err = models.MarkStartComplete(ctx, db, startID) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'C'`, []interface{}{startID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'C'`, startID).Returns(1) +} + +func TestStartsBuilding(t *testing.T) { + uuids.SetGenerator(uuids.NewSeededGenerator(12345)) + defer uuids.SetGenerator(uuids.DefaultGenerator) + + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeManual, models.FlowTypeMessaging, testdata.Favorites.ID, models.DoRestartParticipants, models.DoIncludeActive). + WithGroupIDs([]models.GroupID{testdata.DoctorsGroup.ID}). + WithExcludeGroupIDs([]models.GroupID{testdata.TestersGroup.ID}). + WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}). + WithQuery(`language != ""`). + WithCreateContact(true) + + marshalled, err := jsonx.Marshal(start) + require.NoError(t, err) + + test.AssertEqualJSON(t, []byte(fmt.Sprintf(`{ + "UUID": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", + "contact_ids": [%d, %d], + "create_contact": true, + "created_by": "", + "created_by_id": null, + "exclude_group_ids": [%d], + "flow_id": %d, + "flow_type": "M", + "group_ids": [%d], + "include_active": true, + "org_id": 1, + "query": "language != \"\"", + "restart_participants": true, + "start_id": null, + "start_type": "M" + }`, testdata.Cathy.ID, testdata.Bob.ID, testdata.TestersGroup.ID, testdata.Favorites.ID, testdata.DoctorsGroup.ID)), marshalled) } diff --git a/core/models/templates.go b/core/models/templates.go index 580e08374..137bd7c6e 100644 --- a/core/models/templates.go +++ b/core/models/templates.go @@ -44,6 +44,7 @@ type TemplateTranslation struct { Channel assets.ChannelReference `json:"channel" validate:"required"` Language envs.Language `json:"language" validate:"required"` Country null.String `json:"country"` + Namespace string `json:"namespace"` Content string `json:"content" validate:"required"` VariableCount int `json:"variable_count"` } @@ -59,6 +60,7 @@ func (t *TemplateTranslation) Channel() assets.ChannelReference { return t.t.Cha func (t *TemplateTranslation) Language() envs.Language { return t.t.Language } func (t *TemplateTranslation) Country() envs.Country { return envs.Country(t.t.Country) } func (t *TemplateTranslation) Content() string { return t.t.Content } +func (t *TemplateTranslation) Namespace() string { return t.t.Namespace } func (t *TemplateTranslation) VariableCount() int { return t.t.VariableCount } // loads the templates for the passed in org @@ -96,6 +98,7 @@ SELECT ROW_TO_JSON(r) FROM (SELECT tr.language as language, tr.country as country, tr.content as content, + tr.namespace as namespace, tr.variable_count as variable_count, JSON_BUILD_OBJECT('uuid', c.uuid, 'name', c.name) as channel FROM diff --git a/core/models/templates_test.go b/core/models/templates_test.go index 02c5f6b20..189953821 100644 --- a/core/models/templates_test.go +++ b/core/models/templates_test.go @@ -1,20 +1,25 @@ -package models +package models_test import ( "testing" "github.com/nyaruka/goflow/envs" + "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 TestTemplates(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - templates, err := loadTemplates(ctx, db, 1) - assert.NoError(t, err) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTemplates) + require.NoError(t, err) + + templates, err := oa.Templates() + require.NoError(t, err) assert.Equal(t, 2, len(templates)) assert.Equal(t, "goodbye", templates[0].Name()) @@ -24,13 +29,15 @@ func TestTemplates(t *testing.T) { tt := templates[0].Translations()[0] assert.Equal(t, envs.Language("fra"), tt.Language()) assert.Equal(t, envs.NilCountry, tt.Country()) - assert.Equal(t, TwitterChannelUUID, tt.Channel().UUID) + assert.Equal(t, "", tt.Namespace()) + assert.Equal(t, testdata.TwitterChannel.UUID, tt.Channel().UUID) assert.Equal(t, "Salut!", tt.Content()) assert.Equal(t, 1, len(templates[1].Translations())) tt = templates[1].Translations()[0] assert.Equal(t, envs.Language("eng"), tt.Language()) assert.Equal(t, envs.Country("US"), tt.Country()) - assert.Equal(t, TwitterChannelUUID, tt.Channel().UUID) + assert.Equal(t, "2d40b45c_25cd_4965_9019_f05d0124c5fa", tt.Namespace()) + assert.Equal(t, testdata.TwitterChannel.UUID, tt.Channel().UUID) assert.Equal(t, "Hi {{1}}, are you still experiencing problems with {{2}}?", tt.Content()) } diff --git a/core/models/test_constants.go b/core/models/test_constants.go deleted file mode 100644 index 06d3105ee..000000000 --- a/core/models/test_constants.go +++ /dev/null @@ -1,151 +0,0 @@ -package models - -import ( - "github.com/nyaruka/gocommon/urns" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/assets" - "github.com/nyaruka/goflow/flows" -) - -// Constants used in tests, these are tied to the DB created by the -// RapidPro `mailroom_db` management command. These need to live in the master /models -// dir because we are using typed values and otherwire we'd have a circular dependency. -// -// Solution there would be to create a new package for ID types which would let us put -// these in testsuite or the like. -// -// Note that integer ids MAY be fragile depending on how clumsy people are adding things -// to the mailroom_db command (hint, add things to the end). If this turns into an issue -// we could start deriving these instead from the UUIDs. - -var Org1 = OrgID(1) -var Org1UUID = uuids.UUID("bf0514a5-9407-44c9-b0f9-3f36f9c18414") - -var TwilioChannelID = ChannelID(10000) -var TwilioChannelUUID = assets.ChannelUUID("74729f45-7f29-4868-9dc4-90e491e3c7d8") - -var VonageChannelID = ChannelID(10001) -var VonageChannelUUID = assets.ChannelUUID("19012bfd-3ce3-4cae-9bb9-76cf92c73d49") - -var TwitterChannelID = ChannelID(10002) -var TwitterChannelUUID = assets.ChannelUUID("0f661e8b-ea9d-4bd3-9953-d368340acf91") - -var CathyID = ContactID(10000) -var CathyUUID = flows.ContactUUID("6393abc0-283d-4c9b-a1b3-641a035c34bf") -var CathyURN = urns.URN("tel:+16055741111") -var CathyURNID = URNID(10000) - -var BobID = ContactID(10001) -var BobUUID = flows.ContactUUID("b699a406-7e44-49be-9f01-1a82893e8a10") -var BobURN = urns.URN("tel:+16055742222") -var BobURNID = URNID(10001) -var GeorgeID = ContactID(10002) -var GeorgeUUID = flows.ContactUUID("8d024bcd-f473-4719-a00a-bd0bb1190135") -var GeorgeURN = urns.URN("tel:+16055743333") -var GeorgeURNID = URNID(10002) - -var AlexandriaID = ContactID(10003) -var AlexandriaUUID = flows.ContactUUID("9709c157-4606-4d41-9df3-9e9c9b4ae2d4") -var AlexandriaURN = urns.URN("tel:+16055744444") -var AlexandriaURNID = URNID(10003) - -var FavoritesFlowID = FlowID(10000) -var FavoritesFlowUUID = assets.FlowUUID("9de3663f-c5c5-4c92-9f45-ecbc09abcc85") - -var PickNumberFlowID = FlowID(10001) -var PickNumberFlowUUID = assets.FlowUUID("5890fe3a-f204-4661-b74d-025be4ee019c") - -var SingleMessageFlowID = FlowID(10004) -var SingleMessageFlowUUID = assets.FlowUUID("a7c11d68-f008-496f-b56d-2d5cf4cf16a5") - -var IVRFlowID = FlowID(10003) -var IVRFlowUUID = assets.FlowUUID("2f81d0ea-4d75-4843-9371-3f7465311cce") - -var SurveyorFlowID = FlowID(10005) -var SurveyorFlowUUID = assets.FlowUUID("ed8cf8d4-a42c-4ce1-a7e3-44a2918e3cec") - -var IncomingExtraFlowID = FlowID(10006) -var IncomingExtraFlowUUID = assets.FlowUUID("376d3de6-7f0e-408c-80d6-b1919738bc80") - -var ParentTimeoutID = FlowID(10007) -var ParentTimeoutUUID = assets.FlowUUID("81c0f323-7e06-4e0c-a960-19c20f17117c") - -var CampaignFlowID = FlowID(10009) -var CampaignFlowUUID = assets.FlowUUID("3a92a964-3a8d-420b-9206-2cd9d884ac30") - -var DoctorRemindersCampaignUUID = CampaignUUID("72aa12c5-cc11-4bc7-9406-044047845c70") -var DoctorRemindersCampaignID = CampaignID(10000) - -var RemindersEvent1ID = CampaignEventID(10000) -var RemindersEvent2ID = CampaignEventID(10001) - -var DoctorsGroupID = GroupID(10000) -var DoctorsGroupUUID = assets.GroupUUID("c153e265-f7c9-4539-9dbc-9b358714b638") - -var AllContactsGroupID = GroupID(1) -var AllContactsGroupUUID = assets.GroupUUID("d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008") - -var BlockedContactsGroupID = GroupID(2) -var BlockedContactsGroupUUID = assets.GroupUUID("9295ebab-5c2d-4eb1-86f9-7c15ed2f3219") - -var TestersGroupID = GroupID(10001) -var TestersGroupUUID = assets.GroupUUID("5e9d8fab-5e7e-4f51-b533-261af5dea70d") - -var CreatedOnFieldID = FieldID(3) -var LastSeenOnFieldID = FieldID(5) - -var AgeFieldUUID = assets.FieldUUID("903f51da-2717-47c7-a0d3-f2f32877013d") -var GenderFieldUUID = assets.FieldUUID("3a5891e4-756e-4dc9-8e12-b7a766168824") - -var JoinedFieldID = FieldID(8) -var JoinedFieldUUID = assets.FieldUUID("d83aae24-4bbf-49d0-ab85-6bfd201eac6d") - -var ReportingLabelID = LabelID(10000) -var ReportingLabelUUID = assets.LabelUUID("ebc4dedc-91c4-4ed4-9dd6-daa05ea82698") - -var TestingLabelID = LabelID(10001) -var TestingLabelUUID = assets.LabelUUID("a6338cdc-7938-4437-8b05-2d5d785e3a08") - -// classifiers - -var LuisID = ClassifierID(1) -var LuisUUID = assets.ClassifierUUID("097e026c-ae79-4740-af67-656dbedf0263") - -var WitID = ClassifierID(2) -var WitUUID = assets.ClassifierUUID("ff2a817c-040a-4eb2-8404-7d92e8b79dd0") - -var BothubID = ClassifierID(3) -var BothubUUID = assets.ClassifierUUID("859b436d-3005-4e43-9ad5-3de5f26ede4c") - -// ticketers - -var MailgunID = TicketerID(1) -var MailgunUUID = assets.TicketerUUID("f9c9447f-a291-4f3c-8c79-c089bbd4e713") - -var ZendeskID = TicketerID(2) -var ZendeskUUID = assets.TicketerUUID("4ee6d4f3-f92b-439b-9718-8da90c05490b") - -var RocketChatID = TicketerID(3) -var RocketChatUUID = assets.TicketerUUID("6c50665f-b4ff-4e37-9625-bc464fe6a999") - -var InternalID = TicketerID(4) -var InternalUUID = assets.TicketerUUID("8bd48029-6ca1-46a8-aa14-68f7213b82b3") - -// constants for org 2, just a few here - -var Org2 = OrgID(2) -var Org2UUID = uuids.UUID("3ae7cdeb-fd96-46e5-abc4-a4622f349921") - -var Org2ChannelID = ChannelID(20000) -var Org2ChannelUUID = assets.ChannelUUID("a89bc872-3763-4b95-91d9-31d4e56c6651") - -var Org2FredID = ContactID(20000) -var Org2FredUUID = flows.ContactUUID("26d20b72-f7d8-44dc-87f2-aae046dbff95") -var Org2FredURN = urns.URN("tel:+250700000005") -var Org2FredURNID = URNID(20000) - -var Org2FavoritesFlowID = FlowID(20000) -var Org2FavoritesFlowUUID = assets.FlowUUID("f161bd16-3c60-40bd-8c92-228ce815b9cd") - -var Org2SingleMessageFlowID = FlowID(20001) -var Org2SingleMessageFlowUUID = assets.FlowUUID("5277916d-6011-41ac-a4a4-f6ac6a4f1dd9") diff --git a/core/models/ticket_events.go b/core/models/ticket_events.go new file mode 100644 index 000000000..a18331d51 --- /dev/null +++ b/core/models/ticket_events.go @@ -0,0 +1,106 @@ +package models + +import ( + "context" + "encoding/json" + "time" + + "github.com/nyaruka/gocommon/dates" + "github.com/nyaruka/null" +) + +type TicketEventID int +type TicketEventType string + +const ( + TicketEventTypeOpened TicketEventType = "O" + TicketEventTypeAssigned TicketEventType = "A" + TicketEventTypeNote TicketEventType = "N" + TicketEventTypeClosed TicketEventType = "C" + TicketEventTypeReopened TicketEventType = "R" +) + +type TicketEvent struct { + e struct { + ID TicketEventID `json:"id" db:"id"` + OrgID OrgID `json:"org_id" db:"org_id"` + ContactID ContactID `json:"contact_id" db:"contact_id"` + TicketID TicketID `json:"ticket_id" db:"ticket_id"` + EventType TicketEventType `json:"event_type" db:"event_type"` + Note null.String `json:"note,omitempty" db:"note"` + AssigneeID UserID `json:"assignee_id,omitempty" db:"assignee_id"` + CreatedByID UserID `json:"created_by_id,omitempty" db:"created_by_id"` + CreatedOn time.Time `json:"created_on" db:"created_on"` + } +} + +func NewTicketOpenedEvent(t *Ticket, userID UserID, assigneeID UserID) *TicketEvent { + return newTicketEvent(t, userID, TicketEventTypeOpened, "", assigneeID) +} + +func NewTicketAssignedEvent(t *Ticket, userID UserID, assigneeID UserID, note string) *TicketEvent { + return newTicketEvent(t, userID, TicketEventTypeAssigned, note, assigneeID) +} + +func NewTicketNoteEvent(t *Ticket, userID UserID, note string) *TicketEvent { + return newTicketEvent(t, userID, TicketEventTypeNote, note, NilUserID) +} + +func NewTicketClosedEvent(t *Ticket, userID UserID) *TicketEvent { + return newTicketEvent(t, userID, TicketEventTypeClosed, "", NilUserID) +} + +func NewTicketReopenedEvent(t *Ticket, userID UserID) *TicketEvent { + return newTicketEvent(t, userID, TicketEventTypeReopened, "", NilUserID) +} + +func newTicketEvent(t *Ticket, userID UserID, eventType TicketEventType, note string, assigneeID UserID) *TicketEvent { + event := &TicketEvent{} + e := &event.e + e.OrgID = t.OrgID() + e.ContactID = t.ContactID() + e.TicketID = t.ID() + e.EventType = eventType + e.Note = null.String(note) + e.AssigneeID = assigneeID + e.CreatedOn = dates.Now() + e.CreatedByID = userID + return event +} + +func (e *TicketEvent) ID() TicketEventID { return e.e.ID } +func (e *TicketEvent) OrgID() OrgID { return e.e.OrgID } +func (e *TicketEvent) ContactID() ContactID { return e.e.ContactID } +func (e *TicketEvent) TicketID() TicketID { return e.e.TicketID } +func (e *TicketEvent) EventType() TicketEventType { return e.e.EventType } +func (e *TicketEvent) Note() null.String { return e.e.Note } +func (e *TicketEvent) AssigneeID() UserID { return e.e.AssigneeID } +func (e *TicketEvent) CreatedByID() UserID { return e.e.CreatedByID } + +// MarshalJSON is our custom marshaller so that our inner struct get output +func (e *TicketEvent) MarshalJSON() ([]byte, error) { + return json.Marshal(e.e) +} + +// UnmarshalJSON is our custom marshaller so that our inner struct get output +func (e *TicketEvent) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &e.e) +} + +const insertTicketEventsSQL = ` +INSERT INTO + tickets_ticketevent(org_id, contact_id, ticket_id, event_type, note, assignee_id, created_on, created_by_id) + VALUES(:org_id, :contact_id, :ticket_id, :event_type, :note, :assignee_id, :created_on, :created_by_id) +RETURNING + id +` + +func InsertTicketEvents(ctx context.Context, db Queryer, evts []*TicketEvent) error { + // convert to interface arrray + is := make([]interface{}, len(evts)) + for i := range evts { + is[i] = &evts[i].e + } + + return BulkQuery(ctx, "inserting ticket events", db, insertTicketEventsSQL, is) +} diff --git a/core/models/ticket_events_test.go b/core/models/ticket_events_test.go new file mode 100644 index 000000000..c9de363cd --- /dev/null +++ b/core/models/ticket_events_test.go @@ -0,0 +1,58 @@ +package models_test + +import ( + "testing" + + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/nyaruka/null" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTicketEvents(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer func() { + db.MustExec(`DELETE FROM tickets_ticketevent`) + db.MustExec(`DELETE FROM tickets_ticket`) + }() + + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", nil) + modelTicket := ticket.Load(db) + + e1 := models.NewTicketOpenedEvent(modelTicket, testdata.Admin.ID, testdata.Agent.ID) + assert.Equal(t, testdata.Org1.ID, e1.OrgID()) + assert.Equal(t, testdata.Cathy.ID, e1.ContactID()) + assert.Equal(t, ticket.ID, e1.TicketID()) + assert.Equal(t, models.TicketEventTypeOpened, e1.EventType()) + assert.Equal(t, null.NullString, e1.Note()) + assert.Equal(t, testdata.Admin.ID, e1.CreatedByID()) + + e2 := models.NewTicketAssignedEvent(modelTicket, testdata.Admin.ID, testdata.Agent.ID, "please handle") + assert.Equal(t, models.TicketEventTypeAssigned, e2.EventType()) + assert.Equal(t, testdata.Agent.ID, e2.AssigneeID()) + assert.Equal(t, null.String("please handle"), e2.Note()) + assert.Equal(t, testdata.Admin.ID, e2.CreatedByID()) + + e3 := models.NewTicketNoteEvent(modelTicket, testdata.Agent.ID, "please handle") + assert.Equal(t, models.TicketEventTypeNote, e3.EventType()) + assert.Equal(t, null.String("please handle"), e3.Note()) + assert.Equal(t, testdata.Agent.ID, e3.CreatedByID()) + + e4 := models.NewTicketClosedEvent(modelTicket, testdata.Agent.ID) + assert.Equal(t, models.TicketEventTypeClosed, e4.EventType()) + assert.Equal(t, testdata.Agent.ID, e4.CreatedByID()) + + e5 := models.NewTicketReopenedEvent(modelTicket, testdata.Editor.ID) + assert.Equal(t, models.TicketEventTypeReopened, e5.EventType()) + assert.Equal(t, testdata.Editor.ID, e5.CreatedByID()) + + err := models.InsertTicketEvents(ctx, db, []*models.TicketEvent{e1, e2, e3, e4, e5}) + require.NoError(t, err) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent`).Returns(5) + testsuite.AssertQuery(t, db, `SELECT assignee_id, note FROM tickets_ticketevent WHERE id = $1`, e2.ID()). + Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID), "note": "please handle"}) +} diff --git a/core/models/tickets.go b/core/models/tickets.go index 1e90014ec..4f1f0267f 100644 --- a/core/models/tickets.go +++ b/core/models/tickets.go @@ -12,6 +12,7 @@ import ( "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/utils/dbutil" "github.com/nyaruka/null" @@ -22,7 +23,31 @@ import ( "github.com/sirupsen/logrus" ) -type TicketID int +type TicketID null.Int + +// NilTicketID is our constant for a nil ticket id +const NilTicketID = TicketID(0) + +// MarshalJSON marshals into JSON. 0 values will become null +func (i TicketID) MarshalJSON() ([]byte, error) { + return null.Int(i).MarshalJSON() +} + +// UnmarshalJSON unmarshals from JSON. null values become 0 +func (i *TicketID) UnmarshalJSON(b []byte) error { + return null.UnmarshalInt(b, (*null.Int)(i)) +} + +// Value returns the db value, null is returned for 0 +func (i TicketID) Value() (driver.Value, error) { + return null.Int(i).Value() +} + +// Scan scans from the db value. null values become 0 +func (i *TicketID) Scan(value interface{}) error { + return null.ScanInt(value, (*null.Int)(i)) +} + type TicketerID null.Int type TicketStatus string @@ -35,31 +60,33 @@ const ( func init() { goflow.RegisterTicketServiceFactory( func(session flows.Session, ticketer *flows.Ticketer) (flows.TicketService, error) { - return ticketer.Asset().(*Ticketer).AsService(ticketer) + return ticketer.Asset().(*Ticketer).AsService(config.Mailroom, 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"` - Subject string `db:"subject"` - Body string `db:"body"` - Config null.Map `db:"config"` - OpenedOn time.Time `db:"opened_on"` - ModifiedOn time.Time `db:"modified_on"` - ClosedOn *time.Time `db:"closed_on"` + 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"` + Subject string `db:"subject"` + Body string `db:"body"` + AssigneeID UserID `db:"assignee_id"` + Config null.Map `db:"config"` + OpenedOn time.Time `db:"opened_on"` + ModifiedOn time.Time `db:"modified_on"` + ClosedOn *time.Time `db:"closed_on"` + LastActivityOn time.Time `db:"last_activity_on"` } } // NewTicket creates a new open ticket -func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketerID TicketerID, externalID, subject, body string, config map[string]interface{}) *Ticket { +func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketerID TicketerID, externalID, subject, body string, assigneeID UserID, config map[string]interface{}) *Ticket { t := &Ticket{} t.t.UUID = uuid t.t.OrgID = orgID @@ -69,23 +96,50 @@ func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketer t.t.Status = TicketStatusOpen t.t.Subject = subject t.t.Body = body + t.t.AssigneeID = assigneeID t.t.Config = null.NewMap(config) return t } -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) Subject() string { return t.t.Subject } -func (t *Ticket) Body() string { return t.t.Body } +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) Subject() string { return t.t.Subject } +func (t *Ticket) Body() string { return t.t.Body } +func (t *Ticket) AssigneeID() UserID { return t.t.AssigneeID } +func (t *Ticket) LastActivityOn() time.Time { return t.t.LastActivityOn } func (t *Ticket) Config(key string) string { return t.t.Config.GetString(key, "") } +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") + } + + var flowUser *flows.User + if t.AssigneeID() != NilUserID { + user := oa.UserByID(t.AssigneeID()) + if user != nil { + flowUser = oa.SessionAssets().Users().Get(user.Email()) + } + } + + return flows.NewTicket( + t.UUID(), + oa.SessionAssets().Ticketers().Get(modelTicketer.UUID()), + t.Subject(), + t.Body(), + string(t.ExternalID()), + flowUser, + ), nil +} + // ForwardIncoming forwards an incoming message from a contact to this ticket func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment) error { ticketer := org.TicketerByID(t.t.TicketerID) @@ -93,7 +147,7 @@ func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets return errors.Errorf("can't find ticketer with id %d", t.t.TicketerID) } - service, err := ticketer.AsService(flows.NewTicketer(ticketer)) + service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer)) if err != nil { return err } @@ -101,7 +155,9 @@ func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets logger := &HTTPLogger{} err = service.Forward(t, msgUUID, text, attachments, logger.Ticketer(ticketer)) - return logger.Insert(ctx, db) + logger.Insert(ctx, db) + + return err } const selectOpenTicketsSQL = ` @@ -115,10 +171,12 @@ SELECT t.status AS status, t.subject AS subject, t.body AS body, + t.assignee_id AS assignee_id, t.config AS config, t.opened_on AS opened_on, t.modified_on AS modified_on, - t.closed_on AS closed_on + t.closed_on AS closed_on, + t.last_activity_on AS last_activity_on FROM tickets_ticket t WHERE @@ -142,21 +200,21 @@ SELECT t.status AS status, t.subject AS subject, t.body AS body, + t.assignee_id AS assignee_id, t.config AS config, t.opened_on AS opened_on, t.modified_on AS modified_on, - t.closed_on AS closed_on + t.closed_on AS closed_on, + t.last_activity_on AS last_activity_on FROM tickets_ticket t WHERE - t.org_id = $1 AND - t.id = ANY($2) AND - t.status = $3 + t.id = ANY($1) ` // LoadTickets loads all of the tickets with the given ids -func LoadTickets(ctx context.Context, db Queryer, orgID OrgID, ids []TicketID, status TicketStatus) ([]*Ticket, error) { - return loadTickets(ctx, db, selectTicketsByIDSQL, orgID, pq.Array(ids), status) +func LoadTickets(ctx context.Context, db Queryer, ids []TicketID) ([]*Ticket, error) { + return loadTickets(ctx, db, selectTicketsByIDSQL, pq.Array(ids)) } func loadTickets(ctx context.Context, db Queryer, query string, params ...interface{}) ([]*Ticket, error) { @@ -181,50 +239,54 @@ func loadTickets(ctx context.Context, db Queryer, query string, params ...interf const selectTicketByUUIDSQL = ` SELECT - id, - uuid, - org_id, - contact_id, - ticketer_id, - external_id, - status, - subject, - body, - config, - opened_on, - modified_on, - closed_on + t.id AS id, + t.uuid AS uuid, + t.org_id AS org_id, + t.contact_id AS contact_id, + t.ticketer_id AS ticketer_id, + t.external_id AS external_id, + t.status AS status, + t.subject AS subject, + t.body AS body, + t.assignee_id AS assignee_id, + t.config AS config, + t.opened_on AS opened_on, + t.modified_on AS modified_on, + t.closed_on AS closed_on, + t.last_activity_on AS last_activity_on FROM - tickets_ticket + tickets_ticket t WHERE - uuid = $1 + t.uuid = $1 ` // LookupTicketByUUID looks up the ticket with the passed in UUID -func LookupTicketByUUID(ctx context.Context, db Queryer, uuid flows.TicketUUID) (*Ticket, error) { +func LookupTicketByUUID(ctx context.Context, db *sqlx.DB, uuid flows.TicketUUID) (*Ticket, error) { return lookupTicket(ctx, db, selectTicketByUUIDSQL, uuid) } const selectTicketByExternalIDSQL = ` SELECT - id, - uuid, - org_id, - contact_id, - ticketer_id, - external_id, - status, - subject, - body, - config, - opened_on, - modified_on, - closed_on + t.id AS id, + t.uuid AS uuid, + t.org_id AS org_id, + t.contact_id AS contact_id, + t.ticketer_id AS ticketer_id, + t.external_id AS external_id, + t.status AS status, + t.subject AS subject, + t.body AS body, + t.assignee_id AS assignee_id, + t.config AS config, + t.opened_on AS opened_on, + t.modified_on AS modified_on, + t.closed_on AS closed_on, + t.last_activity_on AS last_activity_on FROM - tickets_ticket + tickets_ticket t WHERE - ticketer_id = $1 AND - external_id = $2 + t.ticketer_id = $1 AND + t.external_id = $2 ` // LookupTicketByExternalID looks up the ticket with the passed in ticketer and external ID @@ -254,8 +316,8 @@ func lookupTicket(ctx context.Context, db Queryer, query string, params ...inter const insertTicketSQL = ` INSERT INTO - tickets_ticket(uuid, org_id, contact_id, ticketer_id, external_id, status, subject, body, config, opened_on, modified_on) - VALUES( :uuid, :org_id, :contact_id, :ticketer_id, :external_id, :status, :subject, :body, :config, NOW(), NOW() ) + tickets_ticket(uuid, org_id, contact_id, ticketer_id, external_id, status, subject, body, assignee_id, config, opened_on, modified_on, last_activity_on) + VALUES( :uuid, :org_id, :contact_id, :ticketer_id, :external_id, :status, :subject, :body, :assignee_id, :config, NOW(), NOW() , NOW()) RETURNING id ` @@ -274,45 +336,106 @@ func InsertTickets(ctx context.Context, tx Queryer, tickets []*Ticket) error { return BulkQuery(ctx, "inserted tickets", tx, insertTicketSQL, ts) } -const updateTicketExternalIDSQL = ` -UPDATE - tickets_ticket -SET - external_id = $2 -WHERE - id = $1 -` - // UpdateTicketExternalID updates the external ID of the given ticket func UpdateTicketExternalID(ctx context.Context, db Queryer, ticket *Ticket, externalID string) error { t := &ticket.t t.ExternalID = null.String(externalID) - return Exec(ctx, "update ticket external ID", db, updateTicketExternalIDSQL, t.ID, t.ExternalID) + return Exec(ctx, "update ticket external ID", db, `UPDATE tickets_ticket SET external_id = $2 WHERE id = $1`, t.ID, t.ExternalID) +} + +// UpdateTicketConfig updates the passed in ticket's config with any passed in values +func UpdateTicketConfig(ctx context.Context, db Queryer, ticket *Ticket, config map[string]string) error { + t := &ticket.t + for key, value := range config { + t.Config.Map()[key] = value + } + + return Exec(ctx, "update ticket config", db, `UPDATE tickets_ticket SET config = $2 WHERE id = $1`, t.ID, t.Config) +} + +// UpdateTicketLastActivity updates the last_activity_on of the given tickets to be now +func UpdateTicketLastActivity(ctx context.Context, db Queryer, tickets []*Ticket) error { + now := dates.Now() + ids := make([]TicketID, len(tickets)) + for i, t := range tickets { + t.t.LastActivityOn = now + ids[i] = t.ID() + } + return updateTicketLastActivity(ctx, db, ids, now) +} + +func updateTicketLastActivity(ctx context.Context, db Queryer, ids []TicketID, now time.Time) error { + return Exec(ctx, "update ticket last activity", db, `UPDATE tickets_ticket SET last_activity_on = $2 WHERE id = ANY($1)`, pq.Array(ids), now) } -const updateTicketAndKeepOpenSQL = ` +const assignTicketSQL = ` UPDATE tickets_ticket SET - status = $2, - config = $3, - modified_on = $4, - closed_on = NULL + assignee_id = $2, + modified_on = $3, + last_activity_on = $3 WHERE - id = $1 + id = ANY($1) ` -// UpdateAndKeepOpenTicket updates the passed in ticket to ensure it's open and updates the config with any passed in values -func UpdateAndKeepOpenTicket(ctx context.Context, db Queryer, ticket *Ticket, config map[string]string) error { +// AssignTickets assigns the passed in tickets +func AssignTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, assigneeID UserID, note string) (map[*Ticket]*TicketEvent, error) { + ids := make([]TicketID, 0, len(tickets)) + events := make([]*TicketEvent, 0, len(tickets)) + eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) now := dates.Now() - t := &ticket.t - t.Status = TicketStatusOpen - t.ModifiedOn = now - for key, value := range config { - t.Config.Map()[key] = value + + for _, ticket := range tickets { + if ticket.AssigneeID() != assigneeID { + ids = append(ids, ticket.ID()) + t := &ticket.t + t.AssigneeID = assigneeID + t.ModifiedOn = now + t.LastActivityOn = now + + e := NewTicketAssignedEvent(ticket, userID, assigneeID, note) + events = append(events, e) + eventsByTicket[ticket] = e + } + } + + // mark the tickets as assigned in the db + err := Exec(ctx, "assign tickets", db, assignTicketSQL, pq.Array(ids), assigneeID, now) + if err != nil { + return nil, errors.Wrapf(err, "error updating tickets") } - return Exec(ctx, "update ticket", db, updateTicketAndKeepOpenSQL, t.ID, t.Status, t.Config, t.ModifiedOn) + err = InsertTicketEvents(ctx, db, events) + if err != nil { + return nil, errors.Wrapf(err, "error inserting ticket events") + } + + return eventsByTicket, nil +} + +// NoteTickets adds a note to the passed in tickets +func NoteTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, note string) (map[*Ticket]*TicketEvent, error) { + events := make([]*TicketEvent, 0, len(tickets)) + eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) + + for _, ticket := range tickets { + e := NewTicketNoteEvent(ticket, userID, note) + events = append(events, e) + eventsByTicket[ticket] = e + } + + err := UpdateTicketLastActivity(ctx, db, tickets) + if err != nil { + return nil, errors.Wrapf(err, "error updating ticket activity") + } + + err = InsertTicketEvents(ctx, db, events) + if err != nil { + return nil, errors.Wrapf(err, "error inserting ticket events") + } + + return eventsByTicket, nil } const closeTicketSQL = ` @@ -321,43 +444,65 @@ UPDATE SET status = 'C', modified_on = $2, - closed_on = $2 + closed_on = $2, + last_activity_on = $2 WHERE id = ANY($1) ` // CloseTickets closes the passed in tickets -func CloseTickets(ctx context.Context, db Queryer, org *OrgAssets, tickets []*Ticket, externally bool, logger *HTTPLogger) error { +func CloseTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, externally bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) { byTicketer := make(map[TicketerID][]*Ticket) - ids := make([]TicketID, len(tickets)) + ids := make([]TicketID, 0, len(tickets)) + events := make([]*TicketEvent, 0, len(tickets)) + eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) now := dates.Now() - for i, ticket := range tickets { - byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket) - ids[i] = ticket.ID() - t := &ticket.t - t.Status = TicketStatusClosed - t.ModifiedOn = now - t.ClosedOn = &now + + 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 + t.ModifiedOn = now + t.ClosedOn = &now + t.LastActivityOn = now + + e := NewTicketClosedEvent(ticket, userID) + events = append(events, e) + eventsByTicket[ticket] = e + } } if externally { for ticketerID, ticketerTickets := range byTicketer { - ticketer := org.TicketerByID(ticketerID) + ticketer := oa.TicketerByID(ticketerID) if ticketer != nil { - service, err := ticketer.AsService(flows.NewTicketer(ticketer)) + service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer)) if err != nil { - return err + return nil, err } err = service.Close(ticketerTickets, logger.Ticketer(ticketer)) if err != nil { - return err + return nil, err } } } } - return Exec(ctx, "close tickets", db, closeTicketSQL, pq.Array(ids), now) + // mark the tickets as closed in the db + err := Exec(ctx, "close tickets", db, closeTicketSQL, pq.Array(ids), now) + if err != nil { + return nil, errors.Wrapf(err, "error updating tickets") + } + + err = InsertTicketEvents(ctx, db, events) + if err != nil { + return nil, errors.Wrapf(err, "error inserting ticket events") + } + + return eventsByTicket, nil } const reopenTicketSQL = ` @@ -366,43 +511,65 @@ UPDATE SET status = 'O', modified_on = $2, - closed_on = NULL + closed_on = NULL, + last_activity_on = $2 WHERE id = ANY($1) ` // ReopenTickets reopens the passed in tickets -func ReopenTickets(ctx context.Context, db Queryer, org *OrgAssets, tickets []*Ticket, externally bool, logger *HTTPLogger) error { +func ReopenTickets(ctx context.Context, db Queryer, org *OrgAssets, userID UserID, tickets []*Ticket, externally bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) { byTicketer := make(map[TicketerID][]*Ticket) - ids := make([]TicketID, len(tickets)) + ids := make([]TicketID, 0, len(tickets)) + events := make([]*TicketEvent, 0, len(tickets)) + eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets)) now := dates.Now() - for i, ticket := range tickets { - byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket) - ids[i] = ticket.ID() - t := &ticket.t - t.Status = TicketStatusOpen - t.ModifiedOn = now - t.ClosedOn = nil + + 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 + t.ModifiedOn = now + t.ClosedOn = nil + t.LastActivityOn = now + + e := NewTicketReopenedEvent(ticket, userID) + events = append(events, e) + eventsByTicket[ticket] = e + } } if externally { for ticketerID, ticketerTickets := range byTicketer { ticketer := org.TicketerByID(ticketerID) if ticketer != nil { - service, err := ticketer.AsService(flows.NewTicketer(ticketer)) + service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer)) if err != nil { - return err + return nil, err } err = service.Reopen(ticketerTickets, logger.Ticketer(ticketer)) if err != nil { - return err + return nil, err } } } } - return Exec(ctx, "reopen tickets", db, reopenTicketSQL, pq.Array(ids), now) + // mark the tickets as opened in the db + err := Exec(ctx, "reopen tickets", db, reopenTicketSQL, pq.Array(ids), now) + if err != nil { + return nil, errors.Wrapf(err, "error updating tickets") + } + + err = InsertTicketEvents(ctx, db, events) + if err != nil { + return nil, errors.Wrapf(err, "error inserting ticket events") + } + + return eventsByTicket, nil } // Ticketer is our type for a ticketer asset @@ -435,27 +602,23 @@ 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(ticketer *flows.Ticketer) (TicketService, error) { - httpClient, httpRetries, _ := goflow.HTTP() +func (t *Ticketer) AsService(cfg *config.Config, ticketer *flows.Ticketer) (TicketService, error) { + httpClient, httpRetries, _ := goflow.HTTP(cfg) initFunc := ticketServices[t.Type()] if initFunc != nil { - return initFunc(httpClient, httpRetries, ticketer, t.t.Config) + return initFunc(cfg, httpClient, httpRetries, ticketer, t.t.Config) } return nil, errors.Errorf("unrecognized ticket service type '%s'", t.Type()) } -const updateTicketerConfigSQL = ` -UPDATE - tickets_ticketer -SET - config = $2 -WHERE - id = $1 -` - // UpdateConfig updates the configuration of this ticketer with the given values func (t *Ticketer) UpdateConfig(ctx context.Context, db Queryer, add map[string]string, remove map[string]bool) error { for key, value := range add { @@ -471,7 +634,7 @@ func (t *Ticketer) UpdateConfig(ctx context.Context, db Queryer, add map[string] dbMap[key] = value } - return Exec(ctx, "update ticketer config", db, updateTicketerConfigSQL, t.t.ID, null.NewMap(dbMap)) + return Exec(ctx, "update ticketer config", db, `UPDATE tickets_ticketer SET config = $2 WHERE id = $1`, t.t.ID, null.NewMap(dbMap)) } // TicketService extends the engine's ticket service and adds support for forwarding new incoming messages @@ -484,7 +647,7 @@ type TicketService interface { } // TicketServiceFunc is a func which creates a ticket service -type TicketServiceFunc func(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (TicketService, error) +type TicketServiceFunc func(*config.Config, *http.Client, *httpx.RetryConfig, *flows.Ticketer, map[string]string) (TicketService, error) var ticketServices = map[string]TicketServiceFunc{} diff --git a/core/models/tickets_test.go b/core/models/tickets_test.go index f18228788..d8da4b2ae 100644 --- a/core/models/tickets_test.go +++ b/core/models/tickets_test.go @@ -2,13 +2,17 @@ package models_test import ( "testing" + "time" + "github.com/jmoiron/sqlx" + "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/models" _ "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" "github.com/stretchr/testify/assert" @@ -16,37 +20,35 @@ import ( ) func TestTicketers(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Reset() // can load directly by UUID - ticketer, err := models.LookupTicketerByUUID(ctx, db, models.ZendeskUUID) + ticketer, err := models.LookupTicketerByUUID(ctx, db, testdata.Zendesk.UUID) assert.NoError(t, err) - assert.Equal(t, models.ZendeskID, ticketer.ID()) - assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + 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, db, models.Org1) + org1, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) - ticketer = org1.TicketerByID(models.ZendeskID) - assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + 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(models.ZendeskUUID) - assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + 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, db, map[string]string{"new-key": "foo"}, map[string]bool{"push_id": true}) - models.FlushCache() - org1, _ = models.GetOrgAssets(ctx, db, models.Org1) - ticketer = org1.TicketerByID(models.ZendeskID) + org1, _ = models.GetOrgAssetsWithRefresh(ctx, db, 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 @@ -54,67 +56,63 @@ func TestTicketers(t *testing.T) { } func TestTickets(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - defer httpx.SetRequestor(httpx.DefaultRequestor) + defer deleteTickets(db) - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ - "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { - httpx.NewMockResponse(200, nil, `{ - "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - "message": "Queued. Thank you." - }`), - }, - })) + deleteTickets(db) ticket1 := models.NewTicket( "2ef57efc-d85f-4291-b330-e4afe68af5fe", - models.Org1, - models.CathyID, - models.MailgunID, + testdata.Org1.ID, + testdata.Cathy.ID, + testdata.Mailgun.ID, "EX12345", "New Ticket", "Where are my cookies?", + testdata.Admin.ID, map[string]interface{}{ "contact-display": "Cathy", }, ) ticket2 := models.NewTicket( "64f81be1-00ff-48ef-9e51-97d6f924c1a4", - models.Org1, - models.BobID, - models.ZendeskID, + testdata.Org1.ID, + testdata.Bob.ID, + testdata.Zendesk.ID, "EX7869", "New Zen Ticket", "Where are my trousers?", + models.NilUserID, nil, ) ticket3 := models.NewTicket( "28ef8ddc-b221-42f3-aeae-ee406fc9d716", - models.Org2, - models.AlexandriaID, - models.ZendeskID, + testdata.Org2.ID, + testdata.Alexandria.ID, + testdata.Zendesk.ID, "EX6677", "Other Org Ticket", "Where are my pants?", + testdata.Org2Admin.ID, nil, ) assert.Equal(t, flows.TicketUUID("2ef57efc-d85f-4291-b330-e4afe68af5fe"), ticket1.UUID()) - assert.Equal(t, models.Org1, ticket1.OrgID()) - assert.Equal(t, models.CathyID, ticket1.ContactID()) - assert.Equal(t, models.MailgunID, ticket1.TicketerID()) + 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, "New Ticket", ticket1.Subject()) 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, db, []*models.Ticket{ticket1, ticket2, ticket3}) assert.NoError(t, err) // check all tickets were created - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE status = 'O' AND closed_on IS NULL`, nil, 3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE status = 'O' AND closed_on IS NULL`).Returns(3) // can lookup a ticket by UUID tk1, err := models.LookupTicketByUUID(ctx, db, "2ef57efc-d85f-4291-b330-e4afe68af5fe") @@ -122,37 +120,223 @@ func TestTickets(t *testing.T) { assert.Equal(t, "New Ticket", tk1.Subject()) // can lookup a ticket by external ID and ticketer - tk2, err := models.LookupTicketByExternalID(ctx, db, models.ZendeskID, "EX7869") + tk2, err := models.LookupTicketByExternalID(ctx, db, testdata.Zendesk.ID, "EX7869") assert.NoError(t, err) assert.Equal(t, "New Zen Ticket", tk2.Subject()) // can lookup open tickets by contact - org1, _ := models.GetOrgAssets(ctx, db, models.Org1) - cathy, err := models.LoadContact(ctx, db, org1, models.CathyID) + org1, _ := models.GetOrgAssets(ctx, db, testdata.Org1.ID) + cathy, err := models.LoadContact(ctx, db, org1, testdata.Cathy.ID) require.NoError(t, err) tks, err := models.LoadOpenTicketsForContact(ctx, db, cathy) assert.NoError(t, err) assert.Equal(t, 1, len(tks)) assert.Equal(t, "New Ticket", tks[0].Subject()) +} - err = models.UpdateAndKeepOpenTicket(ctx, db, ticket1, map[string]string{"last-message-id": "2352"}) - assert.NoError(t, err) +func TestUpdateTicketConfig(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer deleteTickets(db) + + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket := ticket.Load(db) + + // empty configs are null + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE config IS NULL AND id = $1`, ticket.ID).Returns(1) + + models.UpdateTicketConfig(ctx, db, modelTicket, map[string]string{"foo": "2352", "bar": "abc"}) + + testsuite.AssertQuery(t, 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, db, modelTicket, map[string]string{"foo": "6547", "zed": "xyz"}) + + testsuite.AssertQuery(t, 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, _, db, _ := testsuite.Get() + + defer deleteTickets(db) + + now := time.Date(2021, 6, 22, 15, 59, 30, 123456789, time.UTC) + + defer dates.SetNowSource(dates.DefaultNowSource) + dates.SetNowSource(dates.NewFixedNowSource(now)) + + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket := ticket.Load(db) + + models.UpdateTicketLastActivity(ctx, db, []*models.Ticket{modelTicket}) + + assert.Equal(t, now, modelTicket.LastActivityOn()) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on = $2`, ticket.ID, modelTicket.LastActivityOn()).Returns(1) + +} + +func TestAssignTickets(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer deleteTickets(db) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers) + require.NoError(t, err) + + ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket1 := ticket1.Load(db) + + ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil) + modelTicket2 := ticket2.Load(db) + + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Ignore", "", "", nil) + + evts, err := models.AssignTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, testdata.Agent.ID, "please handle these") + require.NoError(t, err) + assert.Equal(t, 2, len(evts)) + assert.Equal(t, models.TicketEventTypeAssigned, evts[modelTicket1].EventType()) + assert.Equal(t, models.TicketEventTypeAssigned, evts[modelTicket2].EventType()) + + // check tickets are now assigned + testsuite.AssertQuery(t, db, `SELECT assignee_id FROM tickets_ticket WHERE id = $1`, ticket1.ID).Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID)}) + testsuite.AssertQuery(t, db, `SELECT assignee_id FROM tickets_ticket WHERE id = $1`, ticket2.ID).Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID)}) + + // and there are new assigned events + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND note = 'please handle these'`).Returns(2) +} + +func TestNoteTickets(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer deleteTickets(db) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers) + require.NoError(t, err) + + ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket1 := ticket1.Load(db) + + ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil) + modelTicket2 := ticket2.Load(db) + + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Ignore", "", "", nil) + + evts, err := models.NoteTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, "spam") + require.NoError(t, err) + assert.Equal(t, 2, len(evts)) + assert.Equal(t, models.TicketEventTypeNote, evts[modelTicket1].EventType()) + assert.Equal(t, models.TicketEventTypeNote, evts[modelTicket2].EventType()) + + // check there are new note events + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'N' AND note = 'spam'`).Returns(2) +} + +func TestCloseTickets(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer httpx.SetRequestor(httpx.DefaultRequestor) + defer deleteTickets(db) + + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { + httpx.NewMockResponse(200, nil, `{ + "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", + "message": "Queued. Thank you." + }`), + }, + })) - // check ticket remains open and config was updated - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'O' AND config='{"contact-display": "Cathy", "last-message-id": "2352"}'::jsonb AND closed_on IS NULL`, []interface{}{models.Org1}, 1) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers) + require.NoError(t, err) + + ticket1 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket1 := ticket1.Load(db) + + ticket2 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil) + modelTicket2 := ticket2.Load(db) logger := &models.HTTPLogger{} + evts, err := models.CloseTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, logger) + require.NoError(t, err) + assert.Equal(t, 1, len(evts)) + assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket1].EventType()) - err = models.CloseTickets(ctx, db, org1, []*models.Ticket{ticket1}, true, logger) - assert.NoError(t, err) + // check ticket #1 is now closed + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND status = 'C' AND closed_on IS NOT NULL`, ticket1.ID).Returns(1) - // check ticket is now closed - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'C' AND closed_on IS NOT NULL`, []interface{}{models.Org1}, 1) + // and there's closed event for it + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE org_id = $1 AND ticket_id = $2 AND event_type = 'C'`, + []interface{}{testdata.Org1.ID, ticket1.ID}, 1) - err = models.UpdateAndKeepOpenTicket(ctx, db, ticket1, map[string]string{"last-message-id": "6754"}) - assert.NoError(t, err) + // and the logger has an http log it can insert for that ticketer + require.NoError(t, logger.Insert(ctx, db)) + + testsuite.AssertQuery(t, 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 closed + testsuite.AssertQuery(t, 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(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket3 := ticket3.Load(db) + + evts, err = models.CloseTickets(ctx, db, oa, models.NilUserID, []*models.Ticket{modelTicket3}, false, logger) + require.NoError(t, err) + assert.Equal(t, 1, len(evts)) + assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket3].EventType()) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'C' AND created_by_id IS NULL`, ticket3.ID).Returns(1) +} + +func TestReopenTickets(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + defer httpx.SetRequestor(httpx.DefaultRequestor) + defer deleteTickets(db) + + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": { + httpx.NewMockResponse(200, nil, `{ + "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", + "message": "Queued. Thank you." + }`), + }, + })) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers) + require.NoError(t, err) + + ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil) + modelTicket1 := ticket1.Load(db) + + ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil) + modelTicket2 := ticket2.Load(db) + + logger := &models.HTTPLogger{} + evts, err := models.ReopenTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, logger) + require.NoError(t, err) + assert.Equal(t, 1, len(evts)) + assert.Equal(t, models.TicketEventTypeReopened, evts[modelTicket1].EventType()) + + // check ticket #1 is now closed + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND status = 'O' AND closed_on IS NULL`, ticket1.ID).Returns(1) + + // and there's reopened event for it + testsuite.AssertQuery(t, 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, db)) + + testsuite.AssertQuery(t, 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 + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'R'`, ticket2.ID).Returns(0) +} - // check ticket is open again - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'O' AND closed_on IS NULL`, []interface{}{models.Org1}, 2) +func deleteTickets(db *sqlx.DB) { + db.MustExec(`DELETE FROM request_logs_httplog`) + db.MustExec(`DELETE FROM tickets_ticketevent`) + db.MustExec(`DELETE FROM tickets_ticket`) } diff --git a/core/models/topups.go b/core/models/topups.go index 04725698f..46f6773a1 100644 --- a/core/models/topups.go +++ b/core/models/topups.go @@ -48,7 +48,7 @@ func AllocateTopups(ctx context.Context, db Queryer, rp *redis.Pool, org *Org, a } // no active topup found, lets calculate it - topup, err := calculateActiveTopup(ctx, db, org.ID()) + topup, err := CalculateActiveTopup(ctx, db, org.ID()) if err != nil { return NilTopupID, err } @@ -99,8 +99,8 @@ end return {activeTopup, remaining} `) -// calculateActiveTopup loads the active topup for the passed in org -func calculateActiveTopup(ctx context.Context, db Queryer, orgID OrgID) (*Topup, error) { +// CalculateActiveTopup loads the active topup for the passed in org +func CalculateActiveTopup(ctx context.Context, db Queryer, orgID OrgID) (*Topup, error) { topup := &Topup{} rows, err := db.QueryxContext(ctx, selectActiveTopup, orgID) if err != nil { diff --git a/core/models/topups_test.go b/core/models/topups_test.go index 54e8faaf5..c126149e0 100644 --- a/core/models/topups_test.go +++ b/core/models/topups_test.go @@ -1,17 +1,18 @@ -package models +package models_test import ( "testing" + "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 TestTopups(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() + ctx, rt, db, rp := testsuite.Get() tx, err := db.BeginTxx(ctx, nil) assert.NoError(t, err) @@ -21,19 +22,19 @@ func TestTopups(t *testing.T) { VALUES(TRUE, 1000000, 1),(TRUE, 99000, 2),(TRUE, 998, 2)`) tcs := []struct { - OrgID OrgID - TopupID TopupID + OrgID models.OrgID + TopupID models.TopupID Remaining int }{ - {Org1, NilTopupID, 0}, - {Org2, TopupID(2), 2}, + {testdata.Org1.ID, models.NilTopupID, 0}, + {testdata.Org2.ID, models.TopupID(2), 2}, } for _, tc := range tcs { - topup, err := calculateActiveTopup(ctx, tx, tc.OrgID) + topup, err := models.CalculateActiveTopup(ctx, tx, tc.OrgID) assert.NoError(t, err) - if tc.TopupID == NilTopupID { + if tc.TopupID == models.NilTopupID { assert.Nil(t, topup) } else { assert.NotNil(t, topup) @@ -43,29 +44,31 @@ func TestTopups(t *testing.T) { } tc2s := []struct { - OrgID OrgID - TopupID TopupID + OrgID models.OrgID + TopupID models.TopupID }{ - {Org1, NilTopupID}, - {Org2, TopupID(2)}, - {Org2, TopupID(2)}, - {Org2, NilTopupID}, + {testdata.Org1.ID, models.NilTopupID}, + {testdata.Org2.ID, models.TopupID(2)}, + {testdata.Org2.ID, models.TopupID(2)}, + {testdata.Org2.ID, models.NilTopupID}, } for _, tc := range tc2s { - org, err := loadOrg(ctx, tx, tc.OrgID) + org, err := models.LoadOrg(ctx, rt.Config, tx, tc.OrgID) assert.NoError(t, err) - topup, err := AllocateTopups(ctx, tx, rp, org, 1) + topup, err := models.AllocateTopups(ctx, tx, rp, org, 1) assert.NoError(t, err) assert.Equal(t, tc.TopupID, topup) tx.MustExec(`INSERT INTO orgs_topupcredits(is_squashed, used, topup_id) VALUES(TRUE, 1, $1)`, tc.OrgID) } // topups can be disabled for orgs - tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, Org1) - org, err := loadOrg(ctx, tx, Org1) - topup, err := AllocateTopups(ctx, tx, rp, org, 1) + tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, testdata.Org1.ID) + org, err := models.LoadOrg(ctx, rt.Config, tx, testdata.Org1.ID) + require.NoError(t, err) + + topup, err := models.AllocateTopups(ctx, tx, rp, org, 1) assert.NoError(t, err) - assert.Equal(t, NilTopupID, topup) + assert.Equal(t, models.NilTopupID, topup) } diff --git a/core/models/triggers.go b/core/models/triggers.go index 15d3b8509..094c267ba 100644 --- a/core/models/triggers.go +++ b/core/models/triggers.go @@ -2,6 +2,7 @@ package models import ( "context" + "sort" "strings" "time" @@ -31,14 +32,15 @@ const ( MissedCallTriggerType = TriggerType("M") NewConversationTriggerType = TriggerType("N") ReferralTriggerType = TriggerType("R") - CallTriggerType = TriggerType("V") + IncomingCallTriggerType = TriggerType("V") ScheduleTriggerType = TriggerType("S") + TicketClosedTriggerType = TriggerType("T") ) // match type constants const ( - MatchFirst = "F" - MatchOnly = "O" + MatchFirst MatchType = "F" + MatchOnly MatchType = "O" ) // NilTriggerID is the nil value for trigger IDs @@ -47,29 +49,31 @@ const NilTriggerID = TriggerID(0) // Trigger represents a trigger in an organization type Trigger struct { t struct { - ID TriggerID `json:"id"` - FlowID FlowID `json:"flow_id"` - TriggerType TriggerType `json:"trigger_type"` - Keyword string `json:"keyword"` - MatchType MatchType `json:"match_type"` - ChannelID ChannelID `json:"channel_id"` - ReferrerID string `json:"referrer_id"` - GroupIDs []GroupID `json:"group_ids"` - ContactIDs []ContactID `json:"contact_ids,omitempty"` + ID TriggerID `json:"id"` + FlowID FlowID `json:"flow_id"` + TriggerType TriggerType `json:"trigger_type"` + Keyword string `json:"keyword"` + MatchType MatchType `json:"match_type"` + ChannelID ChannelID `json:"channel_id"` + ReferrerID string `json:"referrer_id"` + IncludeGroupIDs []GroupID `json:"include_group_ids"` + ExcludeGroupIDs []GroupID `json:"exclude_group_ids"` + ContactIDs []ContactID `json:"contact_ids,omitempty"` } } // ID returns the id of this trigger func (t *Trigger) ID() TriggerID { return t.t.ID } -func (t *Trigger) FlowID() FlowID { return t.t.FlowID } -func (t *Trigger) TriggerType() TriggerType { return t.t.TriggerType } -func (t *Trigger) Keyword() string { return t.t.Keyword } -func (t *Trigger) MatchType() MatchType { return t.t.MatchType } -func (t *Trigger) ChannelID() ChannelID { return t.t.ChannelID } -func (t *Trigger) ReferrerID() string { return t.t.ReferrerID } -func (t *Trigger) GroupIDs() []GroupID { return t.t.GroupIDs } -func (t *Trigger) ContactIDs() []ContactID { return t.t.ContactIDs } +func (t *Trigger) FlowID() FlowID { return t.t.FlowID } +func (t *Trigger) TriggerType() TriggerType { return t.t.TriggerType } +func (t *Trigger) Keyword() string { return t.t.Keyword } +func (t *Trigger) MatchType() MatchType { return t.t.MatchType } +func (t *Trigger) ChannelID() ChannelID { return t.t.ChannelID } +func (t *Trigger) ReferrerID() string { return t.t.ReferrerID } +func (t *Trigger) IncludeGroupIDs() []GroupID { return t.t.IncludeGroupIDs } +func (t *Trigger) ExcludeGroupIDs() []GroupID { return t.t.ExcludeGroupIDs } +func (t *Trigger) ContactIDs() []ContactID { return t.t.ContactIDs } func (t *Trigger) KeywordMatchType() triggers.KeywordMatchType { if t.t.MatchType == MatchFirst { return triggers.KeywordMatchTypeFirstWord @@ -105,6 +109,7 @@ func loadTriggers(ctx context.Context, db Queryer, orgID OrgID) ([]*Trigger, err if err != nil { return nil, errors.Wrap(err, "error scanning label row") } + triggers = append(triggers, trigger) } @@ -113,176 +118,182 @@ func loadTriggers(ctx context.Context, db Queryer, orgID OrgID) ([]*Trigger, err return triggers, nil } -// FindMatchingNewConversationTrigger returns the matching trigger for the passed in trigger type -func FindMatchingNewConversationTrigger(org *OrgAssets, channel *Channel) *Trigger { - var match *Trigger - for _, t := range org.Triggers() { - if t.TriggerType() == NewConversationTriggerType { - // exact match? return right away - if t.ChannelID() == channel.ID() { - return t - } +// FindMatchingMsgTrigger finds the best match trigger for an incoming message from the given contact +func FindMatchingMsgTrigger(oa *OrgAssets, contact *flows.Contact, text string) *Trigger { + // determine our message keyword + words := utils.TokenizeString(text) + keyword := "" + only := false + if len(words) > 0 { + // our keyword is our first word + keyword = strings.ToLower(words[0]) + only = len(words) == 1 + } - // trigger has no channel filter, record this as match - if t.ChannelID() == NilChannelID && match == nil { - match = t - } - } + candidates := findTriggerCandidates(oa, KeywordTriggerType, func(t *Trigger) bool { + return t.Keyword() == keyword && (t.MatchType() == MatchFirst || (t.MatchType() == MatchOnly && only)) + }) + + // if we have a matching keyword trigger return that, otherwise we move on to catchall triggers.. + byKeyword := findBestTriggerMatch(candidates, nil, contact) + if byKeyword != nil { + return byKeyword } - return match + candidates = findTriggerCandidates(oa, CatchallTriggerType, nil) + + return findBestTriggerMatch(candidates, nil, contact) } -// FindMatchingMissedCallTrigger finds any trigger set up for incoming calls (these would be IVR flows) -func FindMatchingMissedCallTrigger(org *OrgAssets) *Trigger { - for _, t := range org.Triggers() { - if t.TriggerType() == MissedCallTriggerType { - return t - } - } +// FindMatchingIncomingCallTrigger finds the best match trigger for incoming calls +func FindMatchingIncomingCallTrigger(oa *OrgAssets, contact *flows.Contact) *Trigger { + candidates := findTriggerCandidates(oa, IncomingCallTriggerType, nil) - return nil + return findBestTriggerMatch(candidates, nil, contact) } -// FindMatchingMOCallTrigger finds any trigger set up for incoming calls (these would be IVR flows) -// Contact is needed as this trigger can be filtered by contact group -func FindMatchingMOCallTrigger(org *OrgAssets, contact *Contact) *Trigger { - // build a set of the groups this contact is in - groupIDs := make(map[GroupID]bool, 10) - for _, g := range contact.Groups() { - groupIDs[g.ID()] = true - } +// FindMatchingMissedCallTrigger finds the best match trigger for missed incoming calls +func FindMatchingMissedCallTrigger(oa *OrgAssets) *Trigger { + candidates := findTriggerCandidates(oa, MissedCallTriggerType, nil) - var match *Trigger - for _, t := range org.Triggers() { - if t.TriggerType() == CallTriggerType { - // this trigger has no groups, it's a match! - if len(t.GroupIDs()) == 0 { - if match == nil { - match = t - } - continue - } + return findBestTriggerMatch(candidates, nil, nil) +} - // test whether we are part of this trigger's group - for _, g := range t.GroupIDs() { - if groupIDs[g] { - // group keyword matches always take precedence, can return right away - return t - } - } - } +// FindMatchingNewConversationTrigger finds the best match trigger for new conversation channel events +func FindMatchingNewConversationTrigger(oa *OrgAssets, channel *Channel) *Trigger { + candidates := findTriggerCandidates(oa, NewConversationTriggerType, nil) + + return findBestTriggerMatch(candidates, channel, nil) +} + +// FindMatchingReferralTrigger finds the best match trigger for referral click channel events +func FindMatchingReferralTrigger(oa *OrgAssets, channel *Channel, referrerID string) *Trigger { + // first try to find matching referrer ID + candidates := findTriggerCandidates(oa, ReferralTriggerType, func(t *Trigger) bool { + return strings.EqualFold(t.ReferrerID(), referrerID) + }) + + match := findBestTriggerMatch(candidates, channel, nil) + if match != nil { + return match } - return match + // if that didn't work look for an empty referrer ID + candidates = findTriggerCandidates(oa, ReferralTriggerType, func(t *Trigger) bool { + return t.ReferrerID() == "" + }) + + return findBestTriggerMatch(candidates, channel, nil) } -// FindMatchingReferralTrigger returns the matching trigger for the passed in trigger type -// Matches are based on referrer_id first (if present), then channel, then any referrer trigger -func FindMatchingReferralTrigger(org *OrgAssets, channel *Channel, referrerID string) *Trigger { - var match *Trigger - for _, t := range org.Triggers() { - if t.TriggerType() == ReferralTriggerType { - // matches referrer id? that takes top precedence, return right away - if strings.ToLower(referrerID) != "" && strings.ToLower(referrerID) == strings.ToLower(t.ReferrerID()) && (t.ChannelID() == NilChannelID || t.ChannelID() == channel.ID()) { - return t - } +// FindMatchingTicketClosedTrigger finds the best match trigger for ticket closed events +func FindMatchingTicketClosedTrigger(oa *OrgAssets, contact *flows.Contact) *Trigger { + candidates := findTriggerCandidates(oa, TicketClosedTriggerType, nil) - // if this trigger has no referrer id, maybe we match by channel - if t.ReferrerID() == "" { - // matches channel? that is a good match - if t.ChannelID() == channel.ID() { - match = t - } else if match == nil && t.ChannelID() == NilChannelID { - // otherwise if we haven't been set yet, pick that - match = t - } - } + return findBestTriggerMatch(candidates, nil, contact) +} + +// finds trigger candidates based on type and optional filter +func findTriggerCandidates(oa *OrgAssets, type_ TriggerType, filter func(*Trigger) bool) []*Trigger { + candidates := make([]*Trigger, 0, 10) + + for _, t := range oa.Triggers() { + if t.TriggerType() == type_ && (filter == nil || filter(t)) { + candidates = append(candidates, t) } } - return match + return candidates } -// FindMatchingMsgTrigger returns the matching trigger (if any) for the passed in text and channel id -// TODO: with a different structure this could probably be a lot faster.. IE, we could have a map -// of list of triggers by keyword that is built when we load the triggers, then just evaluate against that. -func FindMatchingMsgTrigger(org *OrgAssets, contact *flows.Contact, text string) *Trigger { - // build a set of the groups this contact is in - groupIDs := make(map[GroupID]bool, 10) - for _, g := range contact.Groups().All() { - groupIDs[g.Asset().(*Group).ID()] = true +type triggerMatch struct { + trigger *Trigger + score int +} + +// matching triggers are given a score based on how they matched, and this score is used to select the most +// specific match: +// +// channel (4) + include (2) + exclude (1) = 7 +// channel (4) + include (2) = 6 +// channel (4) + exclude (1) = 5 +// channel (4) = 4 +// include (2) + exclude (1) = 3 +// include (2) = 2 +// exclude (1) = 1 +// +const triggerScoreByChannel = 4 +const triggerScoreByInclusion = 2 +const triggerScoreByExclusion = 1 + +func findBestTriggerMatch(candidates []*Trigger, channel *Channel, contact *flows.Contact) *Trigger { + matches := make([]*triggerMatch, 0, len(candidates)) + + var groupIDs map[GroupID]bool + + if contact != nil { + // build a set of the groups this contact is in + groupIDs = make(map[GroupID]bool, 10) + for _, g := range contact.Groups().All() { + groupIDs[g.Asset().(*Group).ID()] = true + } } - // determine our message keyword - words := utils.TokenizeString(text) - keyword := "" - only := false - if len(words) > 0 { - // our keyword is our first word - keyword = strings.ToLower(words[0]) - only = len(words) == 1 + for _, t := range candidates { + matched, score := triggerMatchQualifiers(t, channel, groupIDs) + if matched { + matches = append(matches, &triggerMatch{t, score}) + } } - var match, catchAll, groupCatchAll *Trigger - for _, t := range org.Triggers() { - if t.TriggerType() == KeywordTriggerType { - // does this match based on the rules of the trigger? - matched := (t.Keyword() == keyword && (t.MatchType() == MatchFirst || (t.MatchType() == MatchOnly && only))) + if len(matches) == 0 { + return nil + } - // no match? move on - if !matched { - continue - } + // sort the matches to get them in descending order of score + sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) - // this trigger has no groups, it's a match! - if len(t.GroupIDs()) == 0 { - if match == nil { - match = t - } - continue - } + return matches[0].trigger +} - // test whether we are part of this trigger's group - for _, g := range t.GroupIDs() { - if groupIDs[g] { - // group keyword matches always take precedence, can return right away - return t - } - } - } else if t.TriggerType() == CatchallTriggerType { - // if this catch all is on no groups, save it as our catch all - if len(t.GroupIDs()) == 0 { - if catchAll == nil { - catchAll = t - } - continue - } +// matches against the qualifiers (inclusion groups, exclusion groups, channel) on this trigger and returns a score +func triggerMatchQualifiers(t *Trigger, channel *Channel, contactGroups map[GroupID]bool) (bool, int) { + score := 0 - // otherwise see if this catchall matches our group - if groupCatchAll == nil { - for _, g := range t.GroupIDs() { - if groupIDs[g] { - groupCatchAll = t - break - } - } - } + if channel != nil && t.ChannelID() != NilChannelID { + if t.ChannelID() == channel.ID() { + score += triggerScoreByChannel + } else { + return false, 0 } } - // have a normal match? return that - if match != nil { - return match + if len(t.IncludeGroupIDs()) > 0 { + inGroup := false + // if contact is in any of the groups to include that's a match by inclusion + for _, g := range t.IncludeGroupIDs() { + if contactGroups[g] { + inGroup = true + score += triggerScoreByInclusion + break + } + } + if !inGroup { + return false, 0 + } } - // otherwise return our group catch all if we found one - if groupCatchAll != nil { - return groupCatchAll + if len(t.ExcludeGroupIDs()) > 0 { + // if contact is in none of the groups to exclude that's a match by exclusion + for _, g := range t.ExcludeGroupIDs() { + if contactGroups[g] { + return false, 0 + } + } + score += triggerScoreByExclusion } - // or our global catchall - return catchAll + return true, score } const selectTriggersSQL = ` @@ -294,10 +305,12 @@ SELECT ROW_TO_JSON(r) FROM (SELECT t.match_type as match_type, t.channel_id as channel_id, COALESCE(t.referrer_id, '') as referrer_id, - ARRAY_REMOVE(ARRAY_AGG(g.contactgroup_id), NULL) as group_ids + ARRAY_REMOVE(ARRAY_AGG(DISTINCT ig.contactgroup_id), NULL) as include_group_ids, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT eg.contactgroup_id), NULL) as exclude_group_ids FROM triggers_trigger t - LEFT OUTER JOIN triggers_trigger_groups g ON t.id = g.trigger_id + LEFT OUTER JOIN triggers_trigger_groups ig ON t.id = ig.trigger_id + LEFT OUTER JOIN triggers_trigger_exclude_groups eg ON t.id = eg.trigger_id WHERE t.org_id = $1 AND t.is_active = TRUE AND @@ -335,7 +348,8 @@ SET WHERE id = ANY($1) AND NOT EXISTS (SELECT * FROM triggers_trigger_contacts WHERE trigger_id = triggers_trigger.id) AND - NOT EXISTS (SELECT * FROM triggers_trigger_groups WHERE trigger_id = triggers_trigger.id) + NOT EXISTS (SELECT * FROM triggers_trigger_groups WHERE trigger_id = triggers_trigger.id) AND + NOT EXISTS (SELECT * FROM triggers_trigger_exclude_groups WHERE trigger_id = triggers_trigger.id) ` // ArchiveContactTriggers removes the given contacts from any triggers and archives any triggers diff --git a/core/models/triggers_test.go b/core/models/triggers_test.go index 0128db280..d94772937 100644 --- a/core/models/triggers_test.go +++ b/core/models/triggers_test.go @@ -1,151 +1,296 @@ -package models +package models_test import ( - "fmt" "testing" + "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func insertTrigger(t *testing.T, db *sqlx.DB, active bool, flowID FlowID, triggerType TriggerType, keyword string, matchType MatchType, groupIDs []GroupID, contactIDs []ContactID, referrerID string, channelID ChannelID) TriggerID { - var triggerID TriggerID - err := db.Get(&triggerID, - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, referrer_id, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id) - VALUES($1, now(), now(), $2, $6, false, $3, $4, $5, 1, 1, 1, $7) RETURNING id`, active, keyword, flowID, triggerType, matchType, referrerID, channelID) +func TestLoadTriggers(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() - assert.NoError(t, err) + db.MustExec(`DELETE FROM triggers_trigger`) + farmersGroup := testdata.InsertContactGroup(db, testdata.Org1, assets.GroupUUID(uuids.New()), "Farmers", "") - // insert any group associations - for _, g := range groupIDs { - db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, triggerID, g) + // create trigger for other org to ensure it isn't loaded + testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2Favorites, nil, nil) + + tcs := []struct { + id models.TriggerID + type_ models.TriggerType + flowID models.FlowID + keyword string + keywordMatchType models.MatchType + referrerID string + includeGroups []models.GroupID + excludeGroups []models.GroupID + includeContacts []models.ContactID + channelID models.ChannelID + }{ + { + id: testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil), + type_: models.KeywordTriggerType, + flowID: testdata.Favorites.ID, + keyword: "join", + keywordMatchType: models.MatchFirst, + }, + { + id: testdata.InsertKeywordTrigger( + db, testdata.Org1, testdata.PickANumber, "start", models.MatchOnly, + []*testdata.Group{testdata.DoctorsGroup, testdata.TestersGroup}, + []*testdata.Group{farmersGroup}, + ), + type_: models.KeywordTriggerType, + flowID: testdata.PickANumber.ID, + keyword: "start", + keywordMatchType: models.MatchOnly, + includeGroups: []models.GroupID{testdata.DoctorsGroup.ID, testdata.TestersGroup.ID}, + excludeGroups: []models.GroupID{farmersGroup.ID}, + }, + { + id: testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup, testdata.TestersGroup}, []*testdata.Group{farmersGroup}), + type_: models.IncomingCallTriggerType, + flowID: testdata.Favorites.ID, + includeGroups: []models.GroupID{testdata.DoctorsGroup.ID, testdata.TestersGroup.ID}, + excludeGroups: []models.GroupID{farmersGroup.ID}, + }, + { + id: testdata.InsertMissedCallTrigger(db, testdata.Org1, testdata.Favorites), + type_: models.MissedCallTriggerType, + flowID: testdata.Favorites.ID, + }, + { + id: testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwilioChannel), + type_: models.NewConversationTriggerType, + flowID: testdata.Favorites.ID, + channelID: testdata.TwilioChannel.ID, + }, + { + id: testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "", nil), + type_: models.ReferralTriggerType, + flowID: testdata.Favorites.ID, + }, + { + id: testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "3256437635", testdata.TwilioChannel), + type_: models.ReferralTriggerType, + flowID: testdata.Favorites.ID, + referrerID: "3256437635", + channelID: testdata.TwilioChannel.ID, + }, + { + id: testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.Favorites, nil, nil), + type_: models.CatchallTriggerType, + flowID: testdata.Favorites.ID, + }, } - // insert any contact associations - for _, c := range contactIDs { - db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2)`, triggerID, c) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + require.Equal(t, len(tcs), len(oa.Triggers())) + + for i, tc := range tcs { + actual := oa.Triggers()[i] + + assert.Equal(t, tc.id, actual.ID(), "id mismatch in trigger #%d", i) + assert.Equal(t, tc.type_, actual.TriggerType(), "type mismatch in trigger #%d", i) + assert.Equal(t, tc.flowID, actual.FlowID(), "flow id mismatch in trigger #%d", i) + assert.Equal(t, tc.keyword, actual.Keyword(), "keyword mismatch in trigger #%d", i) + assert.Equal(t, tc.keywordMatchType, actual.MatchType(), "match type mismatch in trigger #%d", i) + assert.Equal(t, tc.referrerID, actual.ReferrerID(), "referrer id mismatch in trigger #%d", i) + assert.ElementsMatch(t, tc.includeGroups, actual.IncludeGroupIDs(), "include groups mismatch in trigger #%d", i) + assert.ElementsMatch(t, tc.excludeGroups, actual.ExcludeGroupIDs(), "exclude groups mismatch in trigger #%d", i) + assert.ElementsMatch(t, tc.includeContacts, actual.ContactIDs(), "include contacts mismatch in trigger #%d", i) + assert.Equal(t, tc.channelID, actual.ChannelID(), "channel id mismatch in trigger #%d", i) + } +} + +func TestFindMatchingMsgTrigger(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() + + db.MustExec(`DELETE FROM triggers_trigger`) + + joinID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil) + resistID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, nil, nil) + doctorsID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, []*testdata.Group{testdata.DoctorsGroup}, nil) + doctorsAndNotTestersID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, []*testdata.Group{testdata.DoctorsGroup}, []*testdata.Group{testdata.TestersGroup}) + doctorsCatchallID := testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, []*testdata.Group{testdata.DoctorsGroup}, nil) + othersAllID := testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, nil, nil) + + // trigger for other org + testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2Favorites, nil, nil) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + testdata.DoctorsGroup.Add(db, testdata.Bob) + testdata.TestersGroup.Add(db, testdata.Bob) + + _, cathy := testdata.Cathy.Load(db, oa) + _, george := testdata.George.Load(db, oa) + _, bob := testdata.Bob.Load(db, oa) + + tcs := []struct { + text string + contact *flows.Contact + expectedTriggerID models.TriggerID + }{ + {"join", cathy, joinID}, + {"JOIN", cathy, joinID}, + {"join this", cathy, joinID}, + {"resist", george, resistID}, + {"resist", bob, doctorsID}, + {"resist", cathy, doctorsAndNotTestersID}, + {"resist this", cathy, doctorsCatchallID}, + {"other", cathy, doctorsCatchallID}, + {"other", george, othersAllID}, + {"", george, othersAllID}, } - return triggerID + for _, tc := range tcs { + trigger := models.FindMatchingMsgTrigger(oa, tc.contact, tc.text) + + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch for %s sending '%s'", tc.contact.Name(), tc.text) + } } -func TestChannelTriggers(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() +func TestFindMatchingIncomingCallTrigger(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() - fooID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "foo", TwitterChannelID) - barID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "bar", NilChannelID) - bazID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "", TwitterChannelID) + doctorsAndNotTestersTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, []*testdata.Group{testdata.TestersGroup}) + doctorsTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, nil) + notTestersTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, nil, []*testdata.Group{testdata.TestersGroup}) + everyoneTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, nil, nil) - FlushCache() + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + testdata.DoctorsGroup.Add(db, testdata.Bob) + testdata.TestersGroup.Add(db, testdata.Bob, testdata.Alexandria) - org, err := GetOrgAssets(ctx, db, Org1) - assert.NoError(t, err) + _, cathy := testdata.Cathy.Load(db, oa) + _, bob := testdata.Bob.Load(db, oa) + _, george := testdata.George.Load(db, oa) + _, alexa := testdata.Alexandria.Load(db, oa) tcs := []struct { - ReferrerID string - Channel ChannelID - TriggerID TriggerID + contact *flows.Contact + expectedTriggerID models.TriggerID }{ - {"", TwilioChannelID, NilTriggerID}, - {"foo", TwilioChannelID, NilTriggerID}, - {"foo", TwitterChannelID, fooID}, - {"FOO", TwitterChannelID, fooID}, - {"bar", TwilioChannelID, barID}, - {"bar", TwitterChannelID, barID}, - {"zap", TwilioChannelID, NilTriggerID}, - {"zap", TwitterChannelID, bazID}, + {cathy, doctorsAndNotTestersTriggerID}, // they're in doctors and not in testers + {bob, doctorsTriggerID}, // they're in doctors and testers + {george, notTestersTriggerID}, // they're not in doctors and not in testers + {alexa, everyoneTriggerID}, // they're not in doctors but are in testers } - for i, tc := range tcs { - channel := org.ChannelByID(tc.Channel) - - trigger := FindMatchingReferralTrigger(org, channel, tc.ReferrerID) - if trigger == nil { - assert.Equal(t, tc.TriggerID, NilTriggerID, "%d: did not get back expected trigger", i) - } else { - assert.Equal(t, tc.TriggerID, trigger.ID(), "%d: did not get back expected trigger", i) - } + for _, tc := range tcs { + trigger := models.FindMatchingIncomingCallTrigger(oa, tc.contact) + + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch for %s", tc.contact.Name()) } } -func TestTriggers(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() +func TestFindMatchingMissedCallTrigger(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() - joinID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID) - resistID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, nil, nil, "", NilChannelID) - farmersID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID) - farmersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID) - othersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, nil, nil, "", NilChannelID) + testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, nil, nil) - FlushCache() + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) - org, err := GetOrgAssets(ctx, db, Org1) - assert.NoError(t, err) + // no missed call trigger yet + trigger := models.FindMatchingMissedCallTrigger(oa) + assert.Nil(t, trigger) - contactIDs := []ContactID{CathyID, GeorgeID} - contacts, err := LoadContacts(ctx, db, org, contactIDs) - assert.NoError(t, err) + triggerID := testdata.InsertMissedCallTrigger(db, testdata.Org1, testdata.Favorites) - cathy, err := contacts[0].FlowContact(org) - assert.NoError(t, err) + oa, err = models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + trigger = models.FindMatchingMissedCallTrigger(oa) + assertTrigger(t, triggerID, trigger) +} - george, err := contacts[1].FlowContact(org) - assert.NoError(t, err) +func TestFindMatchingNewConversationTrigger(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() + + twilioTriggerID := testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwilioChannel) + noChTriggerID := testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, nil) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) tcs := []struct { - Text string - Contact *flows.Contact - TriggerID TriggerID + channelID models.ChannelID + expectedTriggerID models.TriggerID }{ - {"join", cathy, joinID}, - {"JOIN", cathy, joinID}, - {"join this", cathy, joinID}, - {"resist", george, resistID}, - {"resist", cathy, farmersID}, - {"resist this", cathy, farmersAllID}, - {"other", cathy, farmersAllID}, - {"other", george, othersAllID}, - {"", george, othersAllID}, + {testdata.TwilioChannel.ID, twilioTriggerID}, + {testdata.VonageChannel.ID, noChTriggerID}, } - for _, tc := range tcs { - testID := fmt.Sprintf("'%s' sent by %s", tc.Text, tc.Contact.Name()) + for i, tc := range tcs { + channel := oa.ChannelByID(tc.channelID) + trigger := models.FindMatchingNewConversationTrigger(oa, channel) - actualTriggerID := NilTriggerID - actualTrigger := FindMatchingMsgTrigger(org, tc.Contact, tc.Text) - if actualTrigger != nil { - actualTriggerID = actualTrigger.ID() - } + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i) + } +} + +func TestFindMatchingReferralTrigger(t *testing.T) { + ctx, _, db, _ := testsuite.Reset() + + fooID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "foo", testdata.TwitterChannel) + barID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "bar", nil) + bazID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "", testdata.TwitterChannel) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + tcs := []struct { + referrerID string + channelID models.ChannelID + expectedTriggerID models.TriggerID + }{ + {"", testdata.TwilioChannel.ID, models.NilTriggerID}, + {"foo", testdata.TwilioChannel.ID, models.NilTriggerID}, + {"foo", testdata.TwitterChannel.ID, fooID}, + {"FOO", testdata.TwitterChannel.ID, fooID}, + {"bar", testdata.TwilioChannel.ID, barID}, + {"bar", testdata.TwitterChannel.ID, barID}, + {"zap", testdata.TwilioChannel.ID, models.NilTriggerID}, + {"zap", testdata.TwitterChannel.ID, bazID}, + } + + for i, tc := range tcs { + channel := oa.ChannelByID(tc.channelID) + trigger := models.FindMatchingReferralTrigger(oa, channel, tc.referrerID) - assert.Equal(t, tc.TriggerID, actualTriggerID, "did not get back expected trigger for %s", testID) + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i) } } func TestArchiveContactTriggers(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() - - everybodyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID) - cathyOnly1ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID}, "", NilChannelID) - cathyOnly2ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "this", MatchOnly, nil, []ContactID{CathyID}, "", NilChannelID) - cathyAndGeorgeID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID, GeorgeID}, "", NilChannelID) - cathyAndGroupID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, []GroupID{DoctorsGroupID}, []ContactID{CathyID}, "", NilChannelID) - georgeOnlyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{GeorgeID}, "", NilChannelID) - - err := ArchiveContactTriggers(ctx, db, []ContactID{CathyID, BobID}) + ctx, _, db, _ := testsuite.Reset() + + everybodyID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil) + cathyOnly1ID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy}) + cathyOnly2ID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy}) + cathyAndGeorgeID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy, testdata.George}) + cathyAndGroupID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, nil, []*testdata.Contact{testdata.Cathy}) + georgeOnlyID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.George}) + + err := models.ArchiveContactTriggers(ctx, db, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}) require.NoError(t, err) - assertTriggerArchived := func(id TriggerID, archived bool) { + assertTriggerArchived := func(id models.TriggerID, archived bool) { var isArchived bool db.Get(&isArchived, `SELECT is_archived FROM triggers_trigger WHERE id = $1`, id) assert.Equal(t, archived, isArchived, `is_archived mismatch for trigger %d`, id) @@ -158,3 +303,11 @@ func TestArchiveContactTriggers(t *testing.T) { assertTriggerArchived(cathyAndGroupID, false) assertTriggerArchived(georgeOnlyID, false) } + +func assertTrigger(t *testing.T, expected models.TriggerID, actual *models.Trigger, msgAndArgs ...interface{}) { + if actual == nil { + assert.Equal(t, expected, models.NilTriggerID, msgAndArgs...) + } else { + assert.Equal(t, expected, actual.ID(), msgAndArgs...) + } +} diff --git a/core/models/users.go b/core/models/users.go new file mode 100644 index 000000000..a3af281b5 --- /dev/null +++ b/core/models/users.go @@ -0,0 +1,139 @@ +package models + +import ( + "context" + "database/sql" + "database/sql/driver" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/mailroom/utils/dbutil" + "github.com/nyaruka/null" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const ( + // NilUserID is the id 0 considered as nil user id + NilUserID = UserID(0) +) + +// UserID is our type for user ids, which can be null +type UserID null.Int + +// MarshalJSON marshals into JSON. 0 values will become null +func (i UserID) MarshalJSON() ([]byte, error) { + return null.Int(i).MarshalJSON() +} + +// UnmarshalJSON unmarshals from JSON. null values become 0 +func (i *UserID) UnmarshalJSON(b []byte) error { + return null.UnmarshalInt(b, (*null.Int)(i)) +} + +// Value returns the db value, null is returned for 0 +func (i UserID) Value() (driver.Value, error) { + return null.Int(i).Value() +} + +// Scan scans from the db value. null values become 0 +func (i *UserID) Scan(value interface{}) error { + return null.ScanInt(value, (*null.Int)(i)) +} + +type UserRole string + +const ( + UserRoleAdministrator UserRole = "A" + UserRoleEditor UserRole = "E" + UserRoleViewer UserRole = "V" + UserRoleAgent UserRole = "T" + UserRoleSurveyor UserRole = "S" +) + +// User is our type for a user asset +type User struct { + u struct { + ID UserID `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Role UserRole `json:"role"` + } +} + +// ID returns the ID +func (u *User) ID() UserID { return u.u.ID } + +// Email returns the email address +func (u *User) Email() string { return u.u.Email } + +// Role returns the user's role in the current org +func (u *User) Role() UserRole { return u.u.Role } + +// Name returns the name +func (u *User) Name() string { + names := make([]string, 0, 2) + if u.u.FirstName != "" { + names = append(names, u.u.FirstName) + } + if u.u.LastName != "" { + names = append(names, u.u.LastName) + } + return strings.Join(names, " ") +} + +var _ assets.User = (*User)(nil) + +const selectOrgUsersSQL = ` +SELECT ROW_TO_JSON(r) FROM (SELECT + u.id AS "id", + u.email AS "email", + u.first_name as "first_name", + u.last_name as "last_name", + o.role AS "role" +FROM + auth_user u +INNER JOIN ( + SELECT user_id, 'A' AS "role" FROM orgs_org_administrators WHERE org_id = $1 + UNION + SELECT user_id, 'E' AS "role" FROM orgs_org_editors WHERE org_id = $1 + UNION + SELECT user_id, 'V' AS "role" FROM orgs_org_viewers WHERE org_id = $1 + UNION + SELECT user_id, 'T' AS "role" FROM orgs_org_agents WHERE org_id = $1 + UNION + SELECT user_id, 'S' AS "role" FROM orgs_org_surveyors WHERE org_id = $1 +) o ON o.user_id = u.id +WHERE + u.is_active = TRUE +ORDER BY + u.email ASC +) r;` + +// loadUsers loads all the users for the passed in org +func loadUsers(ctx context.Context, db sqlx.Queryer, orgID OrgID) ([]assets.User, error) { + start := time.Now() + + rows, err := db.Queryx(selectOrgUsersSQL, orgID) + if err != nil && err != sql.ErrNoRows { + return nil, errors.Wrapf(err, "error querying users for org: %d", orgID) + } + defer rows.Close() + + users := make([]assets.User, 0, 10) + for rows.Next() { + user := &User{} + err := dbutil.ReadJSONRow(rows, &user.u) + if err != nil { + return nil, errors.Wrapf(err, "error unmarshalling user") + } + users = append(users, user) + } + + logrus.WithField("elapsed", time.Since(start)).WithField("org_id", orgID).WithField("count", len(users)).Debug("loaded users") + + return users, nil +} diff --git a/core/models/users_test.go b/core/models/users_test.go new file mode 100644 index 000000000..e6fec5886 --- /dev/null +++ b/core/models/users_test.go @@ -0,0 +1,50 @@ +package models_test + +import ( + "testing" + + "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 TestLoadUsers(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshUsers) + require.NoError(t, err) + + users, err := oa.Users() + require.NoError(t, err) + + expectedUsers := []struct { + id models.UserID + email string + name string + role models.UserRole + }{ + {testdata.Admin.ID, testdata.Admin.Email, "Andy Admin", models.UserRoleAdministrator}, + {testdata.Agent.ID, testdata.Agent.Email, "Ann D'Agent", models.UserRoleAgent}, + {testdata.Editor.ID, testdata.Editor.Email, "Ed McEditor", models.UserRoleEditor}, + {testdata.Surveyor.ID, testdata.Surveyor.Email, "Steve Surveys", models.UserRoleSurveyor}, + {testdata.Viewer.ID, testdata.Viewer.Email, "Veronica Views", models.UserRoleViewer}, + } + + require.Equal(t, len(expectedUsers), len(users)) + + for i, expected := range expectedUsers { + assetUser := users[i] + assert.Equal(t, expected.email, assetUser.Email()) + assert.Equal(t, expected.name, assetUser.Name()) + + modelUser := assetUser.(*models.User) + assert.Equal(t, expected.id, modelUser.ID()) + assert.Equal(t, expected.email, modelUser.Email()) + assert.Equal(t, expected.role, modelUser.Role()) + + assert.Equal(t, modelUser, oa.UserByID(expected.id)) + assert.Equal(t, modelUser, oa.UserByEmail(expected.email)) + } +} diff --git a/core/models/utils_test.go b/core/models/utils_test.go index 2f6011831..1d5d54fae 100644 --- a/core/models/utils_test.go +++ b/core/models/utils_test.go @@ -10,8 +10,8 @@ import ( ) func TestBulkQueryBatches(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + defer testsuite.Reset() db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name TEXT, age INT)`) @@ -35,8 +35,8 @@ func TestBulkQueryBatches(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, foo1.ID) assert.Equal(t, 2, foo2.ID) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'A' AND age = 30`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'B' AND age = 31`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'A' AND age = 30`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'B' AND age = 31`).Returns(1) // test when multiple batches are required foo3 := &foo{Name: "C", Age: 32} @@ -51,10 +51,10 @@ func TestBulkQueryBatches(t *testing.T) { assert.Equal(t, 5, foo5.ID) assert.Equal(t, 6, foo6.ID) assert.Equal(t, 7, foo7.ID) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'C' AND age = 32`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'D' AND age = 33`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'E' AND age = 34`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'F' AND age = 35`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'G' AND age = 36`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo `, nil, 7) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'C' AND age = 32`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'D' AND age = 33`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'E' AND age = 34`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'F' AND age = 35`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'G' AND age = 36`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo `).Returns(7) } diff --git a/core/models/webhook_event_test.go b/core/models/webhook_event_test.go index 1fff60882..562686134 100644 --- a/core/models/webhook_event_test.go +++ b/core/models/webhook_event_test.go @@ -1,37 +1,37 @@ -package models +package models_test import ( "testing" "time" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestWebhookEvents(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() // create a resthook to insert against - var resthookID ResthookID + var resthookID models.ResthookID db.Get(&resthookID, `INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1) RETURNING id;`) tcs := []struct { - OrgID OrgID - ResthookID ResthookID + OrgID models.OrgID + ResthookID models.ResthookID Data string }{ - {Org1, resthookID, `{"foo":"bar"}`}, + {testdata.Org1.ID, resthookID, `{"foo":"bar"}`}, } for _, tc := range tcs { - e := NewWebhookEvent(tc.OrgID, tc.ResthookID, tc.Data, time.Now()) - err := InsertWebhookEvents(ctx, db, []*WebhookEvent{e}) + e := models.NewWebhookEvent(tc.OrgID, tc.ResthookID, tc.Data, time.Now()) + err := models.InsertWebhookEvents(ctx, db, []*models.WebhookEvent{e}) assert.NoError(t, err) assert.NotZero(t, e.ID()) - testsuite.AssertQueryCount(t, db, ` - SELECT count(*) FROM api_webhookevent WHERE org_id = $1 AND resthook_id = $2 AND data = $3 - `, []interface{}{tc.OrgID, tc.ResthookID, tc.Data}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM api_webhookevent WHERE org_id = $1 AND resthook_id = $2 AND data = $3`, tc.OrgID, tc.ResthookID, tc.Data).Returns(1) } } diff --git a/core/models/webhook_results_test.go b/core/models/webhook_results_test.go index 4c9523ddb..250cba0c9 100644 --- a/core/models/webhook_results_test.go +++ b/core/models/webhook_results_test.go @@ -1,20 +1,22 @@ -package models +package models_test import ( "testing" "time" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestWebhookResults(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() tcs := []struct { - OrgID OrgID - ContactID ContactID + OrgID models.OrgID + ContactID models.ContactID URL string Request string StatusCode int @@ -22,19 +24,18 @@ func TestWebhookResults(t *testing.T) { Duration time.Duration RequestTime int }{ - {Org1, CathyID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1501, 1501}, - {Org1, BobID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1502, 1502}, + {testdata.Org1.ID, testdata.Cathy.ID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1501, 1501}, + {testdata.Org1.ID, testdata.Bob.ID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1502, 1502}, } for _, tc := range tcs { - r := NewWebhookResult(tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.Duration, time.Now()) - err := InsertWebhookResults(ctx, db, []*WebhookResult{r}) + r := models.NewWebhookResult(tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.Duration, time.Now()) + err := models.InsertWebhookResults(ctx, db, []*models.WebhookResult{r}) assert.NoError(t, err) assert.NotZero(t, r.ID()) - testsuite.AssertQueryCount(t, db, ` - SELECT count(*) FROM api_webhookresult WHERE org_id = $1 AND contact_id = $2 AND url = $3 AND request = $4 AND - status_code = $5 AND response = $6 AND request_time = $7 - `, []interface{}{tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.RequestTime}, 1) + testsuite.AssertQuery(t, db, + `SELECT count(*) FROM api_webhookresult WHERE org_id = $1 AND contact_id = $2 AND url = $3 AND request = $4 AND status_code = $5 AND response = $6 AND request_time = $7`, + tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.RequestTime).Returns(1) } } diff --git a/core/msgio/android.go b/core/msgio/android.go index 67c80db81..b623b2c74 100644 --- a/core/msgio/android.go +++ b/core/msgio/android.go @@ -45,11 +45,11 @@ func SyncAndroidChannels(fc *fcm.Client, channels []*models.Channel) { } // CreateFCMClient creates an FCM client based on the configured FCM API key -func CreateFCMClient() *fcm.Client { - if config.Mailroom.FCMKey == "" { +func CreateFCMClient(cfg *config.Config) *fcm.Client { + if cfg.FCMKey == "" { return nil } - client, err := fcm.NewClient(config.Mailroom.FCMKey) + client, err := fcm.NewClient(cfg.FCMKey) if err != nil { panic(errors.Wrap(err, "unable to create FCM client")) } diff --git a/core/msgio/android_test.go b/core/msgio/android_test.go index a442763b9..d851dad88 100644 --- a/core/msgio/android_test.go +++ b/core/msgio/android_test.go @@ -8,7 +8,6 @@ import ( "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/msgio" "github.com/nyaruka/mailroom/testsuite" @@ -64,8 +63,7 @@ func newMockFCMEndpoint(tokens ...string) *MockFCMEndpoint { } func TestSyncAndroidChannels(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() mockFCM := newMockFCMEndpoint("FCMID3") defer mockFCM.Stop() @@ -73,16 +71,16 @@ func TestSyncAndroidChannels(t *testing.T) { fc := mockFCM.Client("FCMKEY123") // create some Android channels - channel1ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": ""}) // no FCM ID - channel2ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) // invalid FCM ID - channel3ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) // valid FCM ID + testChannel1 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": ""}) // no FCM ID + testChannel2 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) // invalid FCM ID + testChannel3 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) // valid FCM ID - oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshChannels) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshChannels) require.NoError(t, err) - channel1 := oa.ChannelByID(channel1ID) - channel2 := oa.ChannelByID(channel2ID) - channel3 := oa.ChannelByID(channel3ID) + channel1 := oa.ChannelByID(testChannel1.ID) + channel2 := oa.ChannelByID(testChannel2.ID) + channel3 := oa.ChannelByID(testChannel3.ID) msgio.SyncAndroidChannels(fc, []*models.Channel{channel1, channel2, channel3}) @@ -97,11 +95,13 @@ func TestSyncAndroidChannels(t *testing.T) { } func TestCreateFCMClient(t *testing.T) { - config.Mailroom.FCMKey = "1234" + _, rt, _, _ := testsuite.Get() - assert.NotNil(t, msgio.CreateFCMClient()) + rt.Config.FCMKey = "1234" - config.Mailroom.FCMKey = "" + assert.NotNil(t, msgio.CreateFCMClient(rt.Config)) - assert.Nil(t, msgio.CreateFCMClient()) + rt.Config.FCMKey = "" + + assert.Nil(t, msgio.CreateFCMClient(rt.Config)) } diff --git a/core/msgio/courier_test.go b/core/msgio/courier_test.go index 65b236976..f9609fdfa 100644 --- a/core/msgio/courier_test.go +++ b/core/msgio/courier_test.go @@ -1,10 +1,8 @@ package msgio_test import ( - "encoding/json" "testing" - "github.com/gomodule/redigo/redis" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/msgio" "github.com/nyaruka/mailroom/testsuite" @@ -15,68 +13,62 @@ import ( ) func TestQueueCourierMessages(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - rc := testsuite.RC() - testsuite.Reset() - models.FlushCache() - + ctx, _, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() // create an Andoid channel - androidChannelID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID"}) + androidChannel := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID"}) - oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg|models.RefreshChannels) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg|models.RefreshChannels) require.NoError(t, err) tests := []struct { Description string Msgs []msgSpec - QueueSizes map[string]int + QueueSizes map[string][]int }{ { Description: "2 queueable messages", Msgs: []msgSpec{ { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, }, - QueueSizes: map[string]int{ - "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 2, + QueueSizes: map[string][]int{ + "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {2}, }, }, { Description: "1 queueable message and 1 failed", Msgs: []msgSpec{ { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, Failed: true, }, { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, }, - QueueSizes: map[string]int{ - "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 1, + QueueSizes: map[string][]int{ + "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {1}, }, }, { Description: "0 messages", Msgs: []msgSpec{}, - QueueSizes: map[string]int{ - "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 0, - }, + QueueSizes: map[string][]int{}, }, } @@ -91,41 +83,18 @@ func TestQueueCourierMessages(t *testing.T) { rc.Do("FLUSHDB") msgio.QueueCourierMessages(rc, contactID, msgs) - assertCourierQueueSizes(t, rc, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description) + testsuite.AssertCourierQueues(t, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description) } // check that trying to queue a courier message will panic assert.Panics(t, func() { ms := msgSpec{ - ChannelID: androidChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: androidChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, } - msgio.QueueCourierMessages(rc, models.CathyID, []*models.Msg{ms.createMsg(t, db, oa)}) + msgio.QueueCourierMessages(rc, testdata.Cathy.ID, []*models.Msg{ms.createMsg(t, db, oa)}) }) testsuite.Reset() } - -func assertCourierQueueSizes(t *testing.T, rc redis.Conn, sizes map[string]int, msgAndArgs ...interface{}) { - for queueKey, size := range sizes { - if size == 0 { - result, err := rc.Do("ZCARD", queueKey) - require.NoError(t, err) - assert.Equal(t, size, int(result.(int64))) - } else { - result, err := rc.Do("ZPOPMAX", queueKey) - require.NoError(t, err) - - results := result.([]interface{}) - assert.Equal(t, 2, len(results)) // result is (item, score) - - batchJSON := results[0].([]byte) - var batch []map[string]interface{} - err = json.Unmarshal(batchJSON, &batch) - require.NoError(t, err) - - assert.Equal(t, size, len(batch), msgAndArgs...) - } - } -} diff --git a/core/msgio/send.go b/core/msgio/send.go index 850886890..e05026a3f 100644 --- a/core/msgio/send.go +++ b/core/msgio/send.go @@ -4,6 +4,7 @@ import ( "context" "github.com/edganiukov/fcm" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/apex/log" @@ -53,9 +54,7 @@ func SendMessages(ctx context.Context, db models.Queryer, rp *redis.Pool, fc *fc // in the case of errors we do want to change the messages back to pending however so they // get queued later. (for the common case messages are only inserted and queued, without a status update) - for _, msg := range contactMsgs { - pending = append(pending, msg) - } + pending = append(pending, contactMsgs...) } } } @@ -63,7 +62,7 @@ func SendMessages(ctx context.Context, db models.Queryer, rp *redis.Pool, fc *fc // if we have any android messages, trigger syncs for the unique channels if len(androidChannels) > 0 { if fc == nil { - fc = CreateFCMClient() + fc = CreateFCMClient(config.Mailroom) } SyncAndroidChannels(fc, androidChannels) } diff --git a/core/msgio/send_test.go b/core/msgio/send_test.go index ec60a6d2c..4f8475b92 100644 --- a/core/msgio/send_test.go +++ b/core/msgio/send_test.go @@ -1,6 +1,7 @@ package msgio_test import ( + "context" "fmt" "testing" "time" @@ -29,9 +30,9 @@ func (m *msgSpec) createMsg(t *testing.T, db *sqlx.DB, oa *models.OrgAssets) *mo // Only way to create a failed outgoing message is to suspend the org and reload the org. // However the channels have to be fetched from the same org assets thus why this uses its // own org assets instance. - ctx := testsuite.CTX() - db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, m.Failed, models.Org1) - oaOrg, _ := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg) + ctx := context.Background() + db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, m.Failed, testdata.Org1.ID) + oaOrg, _ := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg) var channel *models.Channel var channelRef *assets.ChannelReference @@ -53,9 +54,7 @@ func (m *msgSpec) createMsg(t *testing.T, db *sqlx.DB, oa *models.OrgAssets) *mo } func TestSendMessages(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() + ctx, _, db, rp := testsuite.Get() rc := rp.Get() defer rc.Close() @@ -65,17 +64,17 @@ func TestSendMessages(t *testing.T) { fc := mockFCM.Client("FCMKEY123") // create some Andoid channels - androidChannel1ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID1"}) - androidChannel2ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) - testdata.InsertChannel(t, db, models.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) + androidChannel1 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID1"}) + androidChannel2 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) + testdata.InsertChannel(db, testdata.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) - oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshChannels) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshChannels) require.NoError(t, err) tests := []struct { Description string Msgs []msgSpec - QueueSizes map[string]int + QueueSizes map[string][]int FCMTokensSynced []string PendingMsgs int }{ @@ -83,23 +82,23 @@ func TestSendMessages(t *testing.T) { Description: "2 messages for Courier, and 1 Android", Msgs: []msgSpec{ { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, { - ChannelID: androidChannel1ID, - ContactID: models.BobID, - URNID: models.BobURNID, + ChannelID: androidChannel1.ID, + ContactID: testdata.Bob.ID, + URNID: testdata.Bob.URNID, }, { - ChannelID: models.TwilioChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: testdata.TwilioChannel.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, }, - QueueSizes: map[string]int{ - "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 2, + QueueSizes: map[string][]int{ + "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {2}, }, FCMTokensSynced: []string{"FCMID1"}, PendingMsgs: 0, @@ -108,22 +107,22 @@ func TestSendMessages(t *testing.T) { Description: "each Android channel synced once", Msgs: []msgSpec{ { - ChannelID: androidChannel1ID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: androidChannel1.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, { - ChannelID: androidChannel2ID, - ContactID: models.BobID, - URNID: models.BobURNID, + ChannelID: androidChannel2.ID, + ContactID: testdata.Bob.ID, + URNID: testdata.Bob.URNID, }, { - ChannelID: androidChannel1ID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ChannelID: androidChannel1.ID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, }, - QueueSizes: map[string]int{}, + QueueSizes: map[string][]int{}, FCMTokensSynced: []string{"FCMID1", "FCMID2"}, PendingMsgs: 0, }, @@ -132,11 +131,11 @@ func TestSendMessages(t *testing.T) { Msgs: []msgSpec{ { ChannelID: models.NilChannelID, - ContactID: models.CathyID, - URNID: models.CathyURNID, + ContactID: testdata.Cathy.ID, + URNID: testdata.Cathy.URNID, }, }, - QueueSizes: map[string]int{}, + QueueSizes: map[string][]int{}, FCMTokensSynced: []string{}, PendingMsgs: 1, }, @@ -153,7 +152,7 @@ func TestSendMessages(t *testing.T) { msgio.SendMessages(ctx, db, rp, fc, msgs) - assertCourierQueueSizes(t, rc, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description) + testsuite.AssertCourierQueues(t, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description) // check the FCM tokens that were synced actualTokens := make([]string, len(mockFCM.Messages)) @@ -163,6 +162,6 @@ func TestSendMessages(t *testing.T) { assert.Equal(t, tc.FCMTokensSynced, actualTokens, "FCM tokens mismatch in '%s'", tc.Description) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, tc.PendingMsgs, `pending messages mismatch in '%s'`, tc.Description) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(tc.PendingMsgs, `pending messages mismatch in '%s'`, tc.Description) } } diff --git a/core/runner/runner.go b/core/runner/runner.go index c5fc9e3c2..af5d99e56 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -14,6 +14,7 @@ import ( "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/locker" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -62,7 +63,7 @@ type StartOptions struct { type TriggerBuilder func(contact *flows.Contact) flows.Trigger // ResumeFlow resumes the passed in session using the passed in session -func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) { +func ResumeFlow(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) { start := time.Now() sa := oa.SessionAssets() @@ -72,7 +73,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org // if this flow just isn't available anymore, log this error if err == models.ErrNotFound { logrus.WithField("contact_uuid", session.Contact().UUID()).WithField("session_id", session.ID()).WithField("flow_id", session.CurrentFlowID()).Error("unable to find flow in resume") - return nil, models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitFailed, time.Now()) + return nil, models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitFailed, time.Now()) } return nil, errors.Wrapf(err, "error loading session flow: %d", session.CurrentFlowID()) } @@ -97,13 +98,13 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org txCTX, cancel := context.WithTimeout(ctx, commitTimeout) defer cancel() - tx, err := db.BeginTxx(txCTX, nil) + tx, err := rt.DB.BeginTxx(txCTX, nil) if err != nil { return nil, errors.Wrapf(err, "error starting transaction") } // write our updated session and runs - err = session.WriteUpdatedSession(txCTX, tx, rp, oa, fs, sprint, hook) + err = session.WriteUpdatedSession(txCTX, tx, rt.RP, rt.SessionStorage, oa, fs, sprint, hook) if err != nil { tx.Rollback() return nil, errors.Wrapf(err, "error updating session for resume") @@ -120,12 +121,12 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout) defer cancel() - tx, err = db.BeginTxx(txCTX, nil) + tx, err = rt.DB.BeginTxx(txCTX, nil) if err != nil { return nil, errors.Wrapf(err, "error starting transaction for post commit hooks") } - err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, []*models.Scene{session.Scene()}) + err = models.ApplyEventPostCommitHooks(txCTX, tx, rt.RP, oa, []*models.Scene{session.Scene()}) if err == nil { err = tx.Commit() } @@ -141,7 +142,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org // StartFlowBatch starts the flow for the passed in org, contacts and flow func StartFlowBatch( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, + ctx context.Context, rt *runtime.Runtime, batch *models.FlowStartBatch) ([]*models.Session, error) { start := time.Now() @@ -149,7 +150,7 @@ func StartFlowBatch( // if this is our last start, no matter what try to set the start as complete as a last step if batch.IsLast() { defer func() { - err := models.MarkStartComplete(ctx, db, batch.StartID()) + err := models.MarkStartComplete(ctx, rt.DB, batch.StartID()) if err != nil { logrus.WithError(err).WithField("start_id", batch.StartID).Error("error marking start as complete") } @@ -157,7 +158,7 @@ func StartFlowBatch( } // create our org assets - oa, err := models.GetOrgAssets(ctx, db, batch.OrgID()) + oa, err := models.GetOrgAssets(ctx, rt.DB, batch.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error creating assets for org: %d", batch.OrgID()) } @@ -172,6 +173,15 @@ func StartFlowBatch( return nil, errors.Wrapf(err, "error loading campaign flow: %d", batch.FlowID()) } + // get the user that created this flow start if there was one + var flowUser *flows.User + if batch.CreatedByID() != models.NilUserID { + user := oa.UserByID(batch.CreatedByID()) + if user != nil { + flowUser = oa.SessionAssets().Users().Get(user.Email()) + } + } + var params *types.XObject if len(batch.Extra()) > 0 { params, err = types.ReadXObject(batch.Extra()) @@ -208,7 +218,7 @@ func StartFlowBatch( if batchStart { tb = tb.AsBatch() } - return tb.WithUser(batch.CreatedBy()).WithOrigin(startTypeToOrigin[batch.StartType()]).Build() + return tb.WithUser(flowUser).WithOrigin(startTypeToOrigin[batch.StartType()]).Build() } // before committing our runs we want to set the start they are associated with @@ -230,7 +240,7 @@ func StartFlowBatch( options.TriggerBuilder = triggerBuilder options.CommitHook = updateStartID - sessions, err := StartFlow(ctx, db, rp, oa, flow, batch.ContactIDs(), options) + sessions, err := StartFlow(ctx, rt, oa, flow, batch.ContactIDs(), options) if err != nil { return nil, errors.Wrapf(err, "error starting flow batch") } @@ -244,7 +254,7 @@ func StartFlowBatch( // FireCampaignEvents starts the flow for the passed in org, contact and flow func FireCampaignEvents( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, + ctx context.Context, rt *runtime.Runtime, orgID models.OrgID, fires []*models.EventFire, flowUUID assets.FlowUUID, campaign *triggers.CampaignReference, eventUUID triggers.CampaignEventUUID) ([]models.ContactID, error) { @@ -264,7 +274,7 @@ func FireCampaignEvents( } // create our org assets - oa, err := models.GetOrgAssets(ctx, db, orgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, orgID) if err != nil { return nil, errors.Wrapf(err, "error creating assets for org: %d", orgID) } @@ -274,7 +284,7 @@ func FireCampaignEvents( // no longer active? delete these event fires and return if dbEvent == nil { - err := models.DeleteEventFires(ctx, db, fires) + err := models.DeleteEventFires(ctx, rt.DB, fires) if err != nil { return nil, errors.Wrapf(err, "error deleting events for already fired events") } @@ -284,7 +294,7 @@ func FireCampaignEvents( // try to load our flow flow, err := oa.Flow(flowUUID) if err == models.ErrNotFound { - err := models.DeleteEventFires(ctx, db, fires) + err := models.DeleteEventFires(ctx, rt.DB, fires) if err != nil { return nil, errors.Wrapf(err, "error deleting events for archived or inactive flow") } @@ -317,7 +327,7 @@ func FireCampaignEvents( // if this is an ivr flow, we need to create a task to perform the start there if dbFlow.FlowType() == models.FlowTypeVoice { // Trigger our IVR flow start - err := TriggerIVRFlow(ctx, db, rp, oa.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error { + err := TriggerIVRFlow(ctx, rt, oa.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error { return models.MarkEventsFired(ctx, tx, fires, time.Now(), models.FireResultFired) }) if err != nil { @@ -370,7 +380,7 @@ func FireCampaignEvents( return nil } - sessions, err := StartFlow(ctx, db, rp, oa, dbFlow, contactIDs, options) + sessions, err := StartFlow(ctx, rt, oa, dbFlow, contactIDs, options) if err != nil { logrus.WithField("contact_ids", contactIDs).WithError(err).Errorf("error starting flow for campaign event: %s", eventUUID) } else { @@ -379,7 +389,7 @@ func FireCampaignEvents( for _, e := range skippedContacts { fires = append(fires, e) } - err = models.MarkEventsFired(ctx, db, fires, fired, models.FireResultSkipped) + err = models.MarkEventsFired(ctx, rt.DB, fires, fired, models.FireResultSkipped) if err != nil { logrus.WithField("fire_ids", fires).WithError(err).Errorf("error marking events as skipped: %s", eventUUID) } @@ -399,7 +409,7 @@ func FireCampaignEvents( // StartFlow runs the passed in flow for the passed in contact func StartFlow( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, + ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, flow *models.Flow, contactIDs []models.ContactID, options *StartOptions) ([]*models.Session, error) { if len(contactIDs) == 0 { @@ -412,7 +422,7 @@ func StartFlow( // filter out anybody who has has a flow run in this flow if appropriate if !options.RestartParticipants { // find all participants that have been in this flow - started, err := models.FindFlowStartedOverlap(ctx, db, flow.ID(), contactIDs) + started, err := models.FindFlowStartedOverlap(ctx, rt.DB, flow.ID(), contactIDs) if err != nil { return nil, errors.Wrapf(err, "error finding others started flow: %d", flow.ID()) } @@ -424,7 +434,7 @@ func StartFlow( // filter out our list of contacts to only include those that should be started if !options.IncludeActive { // find all participants active in any flow - active, err := models.FindActiveSessionOverlap(ctx, db, flow.FlowType(), contactIDs) + active, err := models.FindActiveSessionOverlap(ctx, rt.DB, flow.FlowType(), contactIDs) if err != nil { return nil, errors.Wrapf(err, "error finding other active flow: %d", flow.ID()) } @@ -464,7 +474,7 @@ func StartFlow( // try up to a second to get a lock for a contact for _, contactID := range remaining { lockID := models.ContactLock(oa.OrgID(), contactID) - lock, err := locker.GrabLock(rp, lockID, time.Minute*5, time.Second) + lock, err := locker.GrabLock(rt.RP, lockID, time.Minute*5, time.Second) if err != nil { return nil, errors.Wrapf(err, "error attempting to grab lock") } @@ -478,13 +488,13 @@ func StartFlow( // defer unlocking if we exit due to error defer func() { if !released[lockID] { - locker.ReleaseLock(rp, lockID, lock) + locker.ReleaseLock(rt.RP, lockID, lock) } }() } // load our locked contacts - contacts, err := models.LoadContacts(ctx, db, oa, locked) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, locked) if err != nil { return nil, errors.Wrapf(err, "error loading contacts to start") } @@ -500,20 +510,18 @@ func StartFlow( triggers = append(triggers, trigger) } - ss, err := StartFlowForContacts(ctx, db, rp, oa, flow, triggers, options.CommitHook, options.Interrupt) + ss, err := StartFlowForContacts(ctx, rt, oa, flow, triggers, options.CommitHook, options.Interrupt) if err != nil { return nil, errors.Wrapf(err, "error starting flow for contacts") } // append all the sessions that were started - for _, s := range ss { - sessions = append(sessions, s) - } + sessions = append(sessions, ss...) // release all our locks for i := range locked { lockID := models.ContactLock(oa.OrgID(), locked[i]) - locker.ReleaseLock(rp, lockID, locks[i]) + locker.ReleaseLock(rt.RP, lockID, locks[i]) released[lockID] = true } @@ -526,7 +534,7 @@ func StartFlow( // StartFlowForContacts runs the passed in flow for the passed in contact func StartFlowForContacts( - ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, + ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, flow *models.Flow, triggers []flows.Trigger, hook models.SessionCommitHook, interrupt bool) ([]*models.Session, error) { sa := oa.SessionAssets() @@ -547,7 +555,7 @@ func StartFlowForContacts( log := log.WithField("contact_uuid", trigger.Contact().UUID()) start := time.Now() - session, sprint, err := goflow.Engine().NewSession(sa, trigger) + session, sprint, err := goflow.Engine(rt.Config).NewSession(sa, trigger) if err != nil { log.WithError(err).Errorf("error starting flow") continue @@ -567,7 +575,7 @@ func StartFlowForContacts( txCTX, cancel := context.WithTimeout(ctx, commitTimeout*time.Duration(len(sessions))) defer cancel() - tx, err := db.BeginTxx(txCTX, nil) + tx, err := rt.DB.BeginTxx(txCTX, nil) if err != nil { return nil, errors.Wrapf(err, "error starting transaction") } @@ -588,7 +596,7 @@ func StartFlowForContacts( } // write our session to the db - dbSessions, err := models.WriteSessions(txCTX, tx, rp, oa, sessions, sprints, hook) + dbSessions, err := models.WriteSessions(txCTX, tx, rt.RP, rt.SessionStorage, oa, sessions, sprints, hook) if err == nil { // commit it at once commitStart := time.Now() @@ -613,7 +621,7 @@ func StartFlowForContacts( txCTX, cancel := context.WithTimeout(ctx, commitTimeout) defer cancel() - tx, err := db.BeginTxx(txCTX, nil) + tx, err := rt.DB.BeginTxx(txCTX, nil) if err != nil { return nil, errors.Wrapf(err, "error starting transaction for retry") } @@ -628,7 +636,7 @@ func StartFlowForContacts( } } - dbSession, err := models.WriteSessions(txCTX, tx, rp, oa, []flows.Session{session}, []flows.Sprint{sprint}, hook) + dbSession, err := models.WriteSessions(txCTX, tx, rt.RP, rt.SessionStorage, oa, []flows.Session{session}, []flows.Sprint{sprint}, hook) if err != nil { tx.Rollback() log.WithField("contact_uuid", session.Contact().UUID()).WithError(err).Errorf("error writing session to db") @@ -650,7 +658,7 @@ func StartFlowForContacts( txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout*time.Duration(len(sessions))) defer cancel() - tx, err = db.BeginTxx(txCTX, nil) + tx, err = rt.DB.BeginTxx(txCTX, nil) if err != nil { return nil, errors.Wrapf(err, "error starting transaction for post commit hooks") } @@ -660,7 +668,7 @@ func StartFlowForContacts( scenes = append(scenes, s.Scene()) } - err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, scenes) + err = models.ApplyEventPostCommitHooks(txCTX, tx, rt.RP, oa, scenes) if err == nil { err = tx.Commit() } @@ -675,14 +683,14 @@ func StartFlowForContacts( txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout) defer cancel() - tx, err := db.BeginTxx(txCTX, nil) + tx, err := rt.DB.BeginTxx(txCTX, nil) if err != nil { tx.Rollback() log.WithError(err).Error("error starting transaction to retry post commits") continue } - err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, []*models.Scene{session.Scene()}) + err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, []*models.Scene{session.Scene()}) if err != nil { tx.Rollback() log.WithError(err).Errorf("error applying post commit hook") @@ -708,8 +716,8 @@ type DBHook func(ctx context.Context, tx *sqlx.Tx) error // TriggerIVRFlow will create a new flow start with the passed in flow and set of contacts. This will cause us to // request calls to start, which once we get the callback will trigger our actual flow to start. -func TriggerIVRFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID, hook DBHook) error { - tx, _ := db.BeginTxx(ctx, nil) +func TriggerIVRFlow(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID, hook DBHook) error { + tx, _ := rt.DB.BeginTxx(ctx, nil) // create our start start := models.NewFlowStart(orgID, models.StartTypeTrigger, models.FlowTypeVoice, flowID, models.DoRestartParticipants, models.DoIncludeActive). @@ -742,7 +750,7 @@ func TriggerIVRFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, orgID mode task := start.CreateBatch(contactIDs, true, len(contactIDs)) // queue this to our ivr starter, it will take care of creating the connections then calling back in - rc := rp.Get() + rc := rt.RP.Get() defer rc.Close() err = queue.AddTask(rc, queue.BatchQueue, queue.StartIVRFlowBatch, int(orgID), task, queue.HighPriority) if err != nil { diff --git a/core/runner/runner_test.go b/core/runner/runner_test.go index eace94449..10df36d02 100644 --- a/core/runner/runner_test.go +++ b/core/runner/runner_test.go @@ -1,4 +1,4 @@ -package runner +package runner_test import ( "encoding/json" @@ -11,104 +11,98 @@ import ( "github.com/nyaruka/goflow/flows/triggers" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/core/runner" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/lib/pq" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCampaignStarts(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() - rp := testsuite.RP() + ctx, rt, db, _ := testsuite.Reset() - campaign := triggers.NewCampaignReference(triggers.CampaignUUID(models.DoctorRemindersCampaignUUID), "Doctor Reminders") + campaign := triggers.NewCampaignReference(triggers.CampaignUUID(testdata.RemindersCampaign.UUID), "Doctor Reminders") // create our event fires now := time.Now() - db.MustExec(`INSERT INTO campaigns_eventfire(event_id, scheduled, contact_id) VALUES($1, $2, $3),($1, $2, $4),($1, $2, $5);`, models.RemindersEvent2ID, now, models.CathyID, models.BobID, models.AlexandriaID) + db.MustExec(`INSERT INTO campaigns_eventfire(event_id, scheduled, contact_id) VALUES($1, $2, $3),($1, $2, $4),($1, $2, $5);`, testdata.RemindersEvent2.ID, now, testdata.Cathy.ID, testdata.Bob.ID, testdata.Alexandria.ID) // create an active session for Alexandria to test skipping - db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'M', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), models.Org1, models.AlexandriaID, models.PickNumberFlowID) + db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'M', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), testdata.Org1.ID, testdata.Alexandria.ID, testdata.PickANumber.ID) // create an active voice call for Cathy to make sure it doesn't get interrupted or cause skipping - db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'V', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), models.Org1, models.CathyID, models.IVRFlowID) + db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'V', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), testdata.Org1.ID, testdata.Cathy.ID, testdata.IVRFlow.ID) // set our event to skip - db.MustExec(`UPDATE campaigns_campaignevent SET start_mode = 'S' WHERE id= $1`, models.RemindersEvent2ID) + db.MustExec(`UPDATE campaigns_campaignevent SET start_mode = 'S' WHERE id= $1`, testdata.RemindersEvent2.ID) - contacts := []models.ContactID{models.CathyID, models.BobID} + contacts := []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID} fires := []*models.EventFire{ { FireID: 1, - EventID: models.RemindersEvent2ID, - ContactID: models.CathyID, + EventID: testdata.RemindersEvent2.ID, + ContactID: testdata.Cathy.ID, Scheduled: now, }, { FireID: 2, - EventID: models.RemindersEvent2ID, - ContactID: models.BobID, + EventID: testdata.RemindersEvent2.ID, + ContactID: testdata.Bob.ID, Scheduled: now, }, { FireID: 3, - EventID: models.RemindersEvent2ID, - ContactID: models.AlexandriaID, + EventID: testdata.RemindersEvent2.ID, + ContactID: testdata.Alexandria.ID, Scheduled: now, }, } - sessions, err := FireCampaignEvents(ctx, db, rp, models.Org1, fires, models.CampaignFlowUUID, campaign, "e68f4c70-9db1-44c8-8498-602d6857235e") + sessions, err := runner.FireCampaignEvents(ctx, rt, testdata.Org1.ID, fires, testdata.CampaignFlow.UUID, campaign, "e68f4c70-9db1-44c8-8498-602d6857235e") assert.NoError(t, err) assert.Equal(t, 2, len(sessions), "expected only two sessions to be created") - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1) - AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, - []interface{}{pq.Array(contacts)}, 2, "expected only two sessions to be created", - ) + testsuite.AssertQuery(t, db, + `SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1) AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, pq.Array(contacts)). + Returns(2, "expected only two sessions to be created") - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = ANY($1) and flow_id = $2 AND is_active = FALSE AND responded = FALSE AND org_id = 1 AND parent_id IS NULL AND exit_type = 'C' AND status = 'C' AND results IS NOT NULL AND path IS NOT NULL AND events IS NOT NULL AND session_id IS NOT NULL`, - []interface{}{pq.Array(contacts), models.CampaignFlowID}, 2, "expected only two runs to be created", - ) + pq.Array(contacts), testdata.CampaignFlow.ID).Returns(2, "expected only two runs to be created") - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1) AND text like '% it is time to consult with your patients.' AND org_id = 1 AND status = 'Q' AND queued_on IS NOT NULL AND direction = 'O' AND topup_id IS NOT NULL AND msg_type = 'F' AND channel_id = $2`, - []interface{}{pq.Array(contacts), models.TwilioChannelID}, 2, "expected only two messages to be sent", - ) + pq.Array(contacts), testdata.TwilioChannel.ID).Returns(2, "expected only two messages to be sent") - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from campaigns_eventfire WHERE fired IS NULL`, nil, 0, "expected all events to be fired") + testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE fired IS NULL`). + Returns(0, "expected all events to be fired") - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1,$2) AND event_id = $3 AND fired_result = 'F'`, []interface{}{models.CathyID, models.BobID, models.RemindersEvent2ID}, 2, "expected bob and cathy to have their event sent to fired") + testsuite.AssertQuery(t, db, + `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1,$2) AND event_id = $3 AND fired_result = 'F'`, testdata.Cathy.ID, testdata.Bob.ID, testdata.RemindersEvent2.ID). + Returns(2, "expected bob and cathy to have their event sent to fired") - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1) AND event_id = $2 AND fired_result = 'S'`, []interface{}{models.AlexandriaID, models.RemindersEvent2ID}, 1, "expected alexandria to have her event set to skipped") + testsuite.AssertQuery(t, db, + `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1) AND event_id = $2 AND fired_result = 'S'`, testdata.Alexandria.ID, testdata.RemindersEvent2.ID). + Returns(1, "expected alexandria to have her event set to skipped") - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from flows_flowsession WHERE status = 'W' AND contact_id = $1 AND session_type = 'V'`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, + `SELECT count(*) from flows_flowsession WHERE status = 'W' AND contact_id = $1 AND session_type = 'V'`, testdata.Cathy.ID).Returns(1) } func TestBatchStart(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() - rp := testsuite.RP() + ctx, rt, db, _ := testsuite.Reset() // create a start object - testdata.InsertFlowStart(t, db, models.Org1, models.SingleMessageFlowID, nil) + testdata.InsertFlowStart(db, testdata.Org1, testdata.SingleMessage, nil) // and our batch object - contactIDs := []models.ContactID{models.CathyID, models.BobID} + contactIDs := []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID} tcs := []struct { Flow models.FlowID @@ -119,12 +113,12 @@ func TestBatchStart(t *testing.T) { Count int TotalCount int }{ - {models.SingleMessageFlowID, true, true, nil, "Hey, how are you?", 2, 2}, - {models.SingleMessageFlowID, false, true, nil, "Hey, how are you?", 0, 2}, - {models.SingleMessageFlowID, false, false, nil, "Hey, how are you?", 0, 2}, - {models.SingleMessageFlowID, true, false, nil, "Hey, how are you?", 2, 4}, + {testdata.SingleMessage.ID, true, true, nil, "Hey, how are you?", 2, 2}, + {testdata.SingleMessage.ID, false, true, nil, "Hey, how are you?", 0, 2}, + {testdata.SingleMessage.ID, false, false, nil, "Hey, how are you?", 0, 2}, + {testdata.SingleMessage.ID, true, false, nil, "Hey, how are you?", 2, 4}, { - Flow: models.IncomingExtraFlowID, + Flow: testdata.IncomingExtraFlow.ID, Restart: true, IncludeActive: false, Extra: json.RawMessage([]byte(`{"name":"Fred", "age":33}`)), @@ -142,75 +136,63 @@ func TestBatchStart(t *testing.T) { WithExtra(tc.Extra) batch := start.CreateBatch(contactIDs, true, len(contactIDs)) - sessions, err := StartFlowBatch(ctx, db, rp, batch) - assert.NoError(t, err) + sessions, err := runner.StartFlowBatch(ctx, rt, batch) + require.NoError(t, err) assert.Equal(t, tc.Count, len(sessions), "%d: unexpected number of sessions created", i) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1) - AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND created_on > $2`, - []interface{}{pq.Array(contactIDs), last}, tc.Count, "%d: unexpected number of sessions", i, - ) + AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND created_on > $2`, pq.Array(contactIDs), last). + Returns(tc.Count, "%d: unexpected number of sessions", i) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = ANY($1) and flow_id = $2 AND is_active = FALSE AND responded = FALSE AND org_id = 1 AND parent_id IS NULL AND exit_type = 'C' AND status = 'C' AND results IS NOT NULL AND path IS NOT NULL AND events IS NOT NULL - AND session_id IS NOT NULL`, - []interface{}{pq.Array(contactIDs), tc.Flow}, tc.TotalCount, "%d: unexpected number of runs", i, - ) + AND session_id IS NOT NULL`, pq.Array(contactIDs), tc.Flow). + Returns(tc.TotalCount, "%d: unexpected number of runs", i) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1) - AND text = $2 AND org_id = 1 AND status = 'Q' + testsuite.AssertQuery(t, db, + `SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1) AND text = $2 AND org_id = 1 AND status = 'Q' AND queued_on IS NOT NULL AND direction = 'O' AND topup_id IS NOT NULL AND msg_type = 'F' AND channel_id = $3`, - []interface{}{pq.Array(contactIDs), tc.Msg, models.TwilioChannelID}, tc.TotalCount, "%d: unexpected number of messages", i, - ) + pq.Array(contactIDs), tc.Msg, testdata.TwilioChannel.ID). + Returns(tc.TotalCount, "%d: unexpected number of messages", i) last = time.Now() } } -func TestContactRuns(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() - rp := testsuite.RP() +func TestResume(t *testing.T) { + ctx, rt, db, _ := testsuite.Reset() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) - assert.NoError(t, err) + defer testsuite.ResetStorage() - flow, err := oa.FlowByID(models.FavoritesFlowID) - assert.NoError(t, err) + // write sessions to storage as well + db.MustExec(`UPDATE orgs_org set config = '{"session_storage_mode": "s3"}' WHERE id = 1`) + defer testsuite.ResetDB() - // load our contact - contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{models.CathyID}) - assert.NoError(t, err) + oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg) + require.NoError(t, err) - contact, err := contacts[0].FlowContact(oa) - assert.NoError(t, err) + flow, err := oa.FlowByID(testdata.Favorites.ID) + require.NoError(t, err) + + _, contact := testdata.Cathy.Load(db, oa) trigger := triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact).Manual().Build() - sessions, err := StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, nil, true) + sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, nil, true) assert.NoError(t, err) assert.NotNil(t, sessions) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND current_flow_id = $2 - AND status = 'W' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, - []interface{}{contact.ID(), flow.ID()}, 1, - ) + AND status = 'W' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, contact.ID(), flow.ID()).Returns(1) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND flow_id = $2 - AND is_active = TRUE AND responded = FALSE AND org_id = 1`, - []interface{}{contact.ID(), flow.ID()}, 1, - ) + AND is_active = TRUE AND responded = FALSE AND org_id = 1`, contact.ID(), flow.ID()).Returns(1) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like '%favorite color%'`, - []interface{}{contact.ID()}, 1, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like '%favorite color%'`, contact.ID()).Returns(1) tcs := []struct { Message string @@ -228,41 +210,36 @@ func TestContactRuns(t *testing.T) { session := sessions[0] for i, tc := range tcs { // answer our first question - msg := flows.NewMsgIn(flows.MsgUUID(uuids.New()), models.CathyURN, nil, tc.Message, nil) + msg := flows.NewMsgIn(flows.MsgUUID(uuids.New()), testdata.Cathy.URN, nil, tc.Message, nil) msg.SetID(10) resume := resumes.NewMsg(oa.Env(), contact, msg) - session, err = ResumeFlow(ctx, db, rp, oa, session, resume, nil) + session, err = runner.ResumeFlow(ctx, rt, oa, session, resume, nil) assert.NoError(t, err) assert.NotNil(t, session) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND current_flow_id = $2 - AND status = $3 AND responded = TRUE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, - []interface{}{contact.ID(), flow.ID(), tc.SessionStatus}, 1, "%d: didn't find expected session", i, - ) + AND status = $3 AND responded = TRUE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND output_url IS NOT NULL`, contact.ID(), flow.ID(), tc.SessionStatus). + Returns(1, "%d: didn't find expected session", i) runIsActive := tc.RunStatus == models.RunStatusActive || tc.RunStatus == models.RunStatusWaiting runQuery := `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND flow_id = $2 - AND status = $3 AND is_active = $4 AND responded = TRUE AND org_id = 1 AND current_node_uuid IS NOT NULL - AND json_array_length(path::json) = $5 AND json_array_length(events::json) = $6 - AND session_id IS NOT NULL ` + AND status = $3 AND is_active = $4 AND responded = TRUE AND org_id = 1 AND current_node_uuid IS NOT NULL + AND json_array_length(path::json) = $5 AND json_array_length(events::json) = $6 + AND session_id IS NOT NULL` if runIsActive { - runQuery += `AND expires_on IS NOT NULL` + runQuery += ` AND expires_on IS NOT NULL` } else { - runQuery += `AND expires_on IS NULL` + runQuery += ` AND expires_on IS NULL` } - testsuite.AssertQueryCount(t, db, - runQuery, - []interface{}{contact.ID(), flow.ID(), tc.RunStatus, runIsActive, tc.PathLength, tc.EventLength}, 1, "%d: didn't find expected run", i, - ) + testsuite.AssertQuery(t, db, runQuery, contact.ID(), flow.ID(), tc.RunStatus, runIsActive, tc.PathLength, tc.EventLength). + Returns(1, "%d: didn't find expected run", i) - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like $2`, - []interface{}{contact.ID(), tc.Substring}, 1, "%d: didn't find expected message", i, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like $2`, contact.ID(), tc.Substring). + Returns(1, "%d: didn't find expected message", i) } } diff --git a/core/tasks/base.go b/core/tasks/base.go index 7d8061d5b..b6f8ca2dd 100644 --- a/core/tasks/base.go +++ b/core/tasks/base.go @@ -9,6 +9,7 @@ import ( "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/pkg/errors" ) @@ -19,7 +20,7 @@ var registeredTypes = map[string](func() Task){} func RegisterType(name string, initFunc func() Task) { registeredTypes[name] = initFunc - mailroom.AddTaskFunction(name, func(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { + mailroom.AddTaskFunction(name, func(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { // decode our task body typedTask, err := ReadTask(task.Type, task.Task) if err != nil { @@ -29,7 +30,7 @@ func RegisterType(name string, initFunc func() Task) { ctx, cancel := context.WithTimeout(ctx, typedTask.Timeout()) defer cancel() - return typedTask.Perform(ctx, mr, models.OrgID(task.OrgID)) + return typedTask.Perform(ctx, rt, models.OrgID(task.OrgID)) }) } @@ -39,7 +40,7 @@ type Task interface { Timeout() time.Duration // Perform performs the task - Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error + Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error } //------------------------------------------------------------------------------------------ diff --git a/core/tasks/base_test.go b/core/tasks/base_test.go index 3761d3570..9f75b8e7d 100644 --- a/core/tasks/base_test.go +++ b/core/tasks/base_test.go @@ -5,7 +5,7 @@ import ( "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks" - "github.com/nyaruka/mailroom/core/tasks/groups" + "github.com/nyaruka/mailroom/core/tasks/contacts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +18,7 @@ func TestReadTask(t *testing.T) { }`)) require.NoError(t, err) - typedTask := task.(*groups.PopulateDynamicGroupTask) + typedTask := task.(*contacts.PopulateDynamicGroupTask) assert.Equal(t, models.GroupID(23), typedTask.GroupID) assert.Equal(t, "gender = F", typedTask.Query) } diff --git a/core/tasks/broadcasts/worker.go b/core/tasks/broadcasts/worker.go index a7c5364eb..ceb2f5420 100644 --- a/core/tasks/broadcasts/worker.go +++ b/core/tasks/broadcasts/worker.go @@ -12,6 +12,7 @@ import ( "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/msgio" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -26,7 +27,7 @@ func init() { } // handleSendBroadcast creates all the batches of contacts that need to be sent to -func handleSendBroadcast(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { +func handleSendBroadcast(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { ctx, cancel := context.WithTimeout(ctx, time.Minute*60) defer cancel() @@ -40,7 +41,7 @@ func handleSendBroadcast(ctx context.Context, mr *mailroom.Mailroom, task *queue return errors.Wrapf(err, "error unmarshalling broadcast: %s", string(task.Task)) } - return CreateBroadcastBatches(ctx, mr.DB, mr.RP, broadcast) + return CreateBroadcastBatches(ctx, rt.DB, rt.RP, broadcast) } // CreateBroadcastBatches takes our master broadcast and creates batches of broadcast sends for all the unique contacts @@ -130,7 +131,7 @@ func CreateBroadcastBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, bc } // handleSendBroadcastBatch sends our messages -func handleSendBroadcastBatch(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { +func handleSendBroadcastBatch(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { ctx, cancel := context.WithTimeout(ctx, time.Minute*60) defer cancel() @@ -145,7 +146,7 @@ func handleSendBroadcastBatch(ctx context.Context, mr *mailroom.Mailroom, task * } // try to send the batch - return SendBroadcastBatch(ctx, mr.DB, mr.RP, broadcast) + return SendBroadcastBatch(ctx, rt.DB, rt.RP, broadcast) } // SendBroadcastBatch sends the passed in broadcast batch diff --git a/core/tasks/broadcasts/worker_test.go b/core/tasks/broadcasts/worker_test.go index e54356393..475a75c76 100644 --- a/core/tasks/broadcasts/worker_test.go +++ b/core/tasks/broadcasts/worker_test.go @@ -20,14 +20,11 @@ import ( ) func TestBroadcastEvents(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - rp := testsuite.RP() - db := testsuite.DB() - rc := testsuite.RC() + ctx, _, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) eng := envs.Language("eng") @@ -39,20 +36,20 @@ func TestBroadcastEvents(t *testing.T) { }, } - doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors") + doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors") doctorsOnly := []*assets.GroupReference{doctors} - cathy := flows.NewContactReference(models.CathyUUID, "Cathy") + cathy := flows.NewContactReference(testdata.Cathy.UUID, "Cathy") cathyOnly := []*flows.ContactReference{cathy} // add an extra URN fo cathy - testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 1001) + testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 1001) // change george's URN to an invalid twitter URN so it can't be sent db.MustExec( - `UPDATE contacts_contacturn SET identity = 'twitter:invalid-urn', scheme = 'twitter', path='invalid-urn' WHERE id = $1`, models.GeorgeURNID, + `UPDATE contacts_contacturn SET identity = 'twitter:invalid-urn', scheme = 'twitter', path='invalid-urn' WHERE id = $1`, testdata.George.URNID, ) - george := flows.NewContactReference(models.GeorgeUUID, "George") + george := flows.NewContactReference(testdata.George.UUID, "George") georgeOnly := []*flows.ContactReference{george} tcs := []struct { @@ -112,9 +109,8 @@ func TestBroadcastEvents(t *testing.T) { assert.Equal(t, tc.BatchCount, count, "%d: unexpected batch count", i) // assert our count of total msgs created - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, - []interface{}{lastNow, tc.MsgText}, tc.MsgCount, "%d: unexpected msg count", i) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, lastNow, tc.MsgText). + Returns(tc.MsgCount, "%d: unexpected msg count", i) lastNow = time.Now() time.Sleep(10 * time.Millisecond) @@ -122,23 +118,19 @@ func TestBroadcastEvents(t *testing.T) { } func TestBroadcastTask(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - rp := testsuite.RP() - db := testsuite.DB() - rc := testsuite.RC() + ctx, _, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) assert.NoError(t, err) eng := envs.Language("eng") // insert a broadcast so we can check it is being set to sent - var legacyID models.BroadcastID - err = db.Get(&legacyID, - `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id) - VALUES('P', '"base"=>"hi @(PROPER(contact.name)) legacy"'::hstore, 'base', TRUE, NOW(), NOW(), FALSE, 1, 1, 1) RETURNING id`) - assert.NoError(t, err) + legacyID := testdata.InsertBroadcast(db, testdata.Org1, "base", map[envs.Language]string{"base": "hi @(PROPER(contact.name)) legacy"}, models.NilScheduleID, nil, nil) + + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "", "", nil) + modelTicket := ticket.Load(db) evaluated := map[envs.Language]*models.BroadcastTranslation{ eng: { @@ -164,11 +156,11 @@ func TestBroadcastTask(t *testing.T) { }, } - doctorsOnly := []models.GroupID{models.DoctorsGroupID} - cathyOnly := []models.ContactID{models.CathyID} + doctorsOnly := []models.GroupID{testdata.DoctorsGroup.ID} + cathyOnly := []models.ContactID{testdata.Cathy.ID} // add an extra URN fo cathy - testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 1001) + testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 1001) tcs := []struct { BroadcastID models.BroadcastID @@ -178,14 +170,54 @@ func TestBroadcastTask(t *testing.T) { GroupIDs []models.GroupID ContactIDs []models.ContactID URNs []urns.URN + TicketID models.TicketID Queue string BatchCount int MsgCount int MsgText string }{ - {models.NilBroadcastID, evaluated, models.TemplateStateEvaluated, eng, doctorsOnly, cathyOnly, nil, queue.BatchQueue, 2, 121, "hello world"}, - {legacyID, legacy, models.TemplateStateLegacy, eng, nil, cathyOnly, nil, queue.HandlerQueue, 1, 1, "hi Cathy legacy URN: +12065551212 Gender: F"}, - {models.NilBroadcastID, template, models.TemplateStateUnevaluated, eng, nil, cathyOnly, nil, queue.HandlerQueue, 1, 1, "hi Cathy from Nyaruka goflow URN: tel:+12065551212 Gender: F"}, + { + models.NilBroadcastID, + evaluated, + models.TemplateStateEvaluated, + eng, + doctorsOnly, + cathyOnly, + nil, + ticket.ID, + queue.BatchQueue, + 2, + 121, + "hello world", + }, + { + legacyID, + legacy, + models.TemplateStateLegacy, + eng, + nil, + cathyOnly, + nil, + models.NilTicketID, + queue.HandlerQueue, + 1, + 1, + "hi Cathy legacy URN: +12065551212 Gender: F", + }, + { + models.NilBroadcastID, + template, + models.TemplateStateUnevaluated, + eng, + nil, + cathyOnly, + nil, + models.NilTicketID, + queue.HandlerQueue, + 1, + 1, + "hi Cathy from Nyaruka goflow URN: tel:+12065551212 Gender: F", + }, } lastNow := time.Now() @@ -193,7 +225,7 @@ func TestBroadcastTask(t *testing.T) { for i, tc := range tcs { // handle our start task - bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs) + bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs, tc.TicketID) err = CreateBroadcastBatches(ctx, db, rp, bcast) assert.NoError(t, err) @@ -221,15 +253,19 @@ func TestBroadcastTask(t *testing.T) { assert.Equal(t, tc.BatchCount, count, "%d: unexpected batch count", i) // assert our count of total msgs created - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, - []interface{}{lastNow, tc.MsgText}, tc.MsgCount, "%d: unexpected msg count", i) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, lastNow, tc.MsgText). + Returns(tc.MsgCount, "%d: unexpected msg count", i) // make sure our broadcast is marked as sent if tc.BroadcastID != models.NilBroadcastID { - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_broadcast WHERE id = $1 AND status = 'S'`, - []interface{}{tc.BroadcastID}, 1, "%d: broadcast not marked as sent", i) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_broadcast WHERE id = $1 AND status = 'S'`, tc.BroadcastID). + Returns(1, "%d: broadcast not marked as sent", i) + } + + // if we had a ticket, make sure its last_activity_on was updated + if tc.TicketID != models.NilTicketID { + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on > $2`, tc.TicketID, modelTicket.LastActivityOn()). + Returns(1, "%d: ticket last_activity_on not updated", i) } lastNow = time.Now() diff --git a/core/tasks/campaigns/cron.go b/core/tasks/campaigns/cron.go index 13f8e4b4e..3d9b5d7bf 100644 --- a/core/tasks/campaigns/cron.go +++ b/core/tasks/campaigns/cron.go @@ -3,6 +3,7 @@ package campaigns import ( "context" "fmt" + "sync" "time" "github.com/nyaruka/goflow/assets" @@ -10,6 +11,7 @@ import ( "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/nyaruka/mailroom/utils/marker" @@ -30,12 +32,12 @@ func init() { } // StartCampaignCron starts our cron job of firing expired campaign events -func StartCampaignCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, campaignsLock, time.Second*60, +func StartCampaignCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, campaignsLock, time.Second*60, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return fireCampaignEvents(ctx, mr.DB, mr.RP, lockName, lockValue) + return fireCampaignEvents(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) diff --git a/core/tasks/campaigns/cron_test.go b/core/tasks/campaigns/cron_test.go index a16f90856..ab25e5b88 100644 --- a/core/tasks/campaigns/cron_test.go +++ b/core/tasks/campaigns/cron_test.go @@ -4,31 +4,26 @@ import ( "testing" "time" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/tasks" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCampaigns(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() - rc := testsuite.RC() - defer rc.Close() + ctx, rt, db, rp := testsuite.Reset() - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil} + rc := rp.Get() + defer rc.Close() // let's create a campaign event fire for one of our contacts (for now this is totally hacked, they aren't in the group and // their relative to date isn't relative, but this still tests execution) - db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID) + rt.DB.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID) time.Sleep(10 * time.Millisecond) // schedule our campaign to be started @@ -44,32 +39,27 @@ func TestCampaigns(t *testing.T) { require.NoError(t, err) // work on that task - err = typedTask.Perform(ctx, mr, models.OrgID(task.OrgID)) + err = typedTask.Perform(ctx, rt, models.OrgID(task.OrgID)) assert.NoError(t, err) // should now have a flow run for that contact and flow - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, []interface{}{models.CathyID, models.FavoritesFlowID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, []interface{}{models.GeorgeID, models.FavoritesFlowID}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, testdata.Cathy.ID, testdata.Favorites.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, testdata.George.ID, testdata.Favorites.ID).Returns(1) } func TestIVRCampaigns(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() - rc := testsuite.RC() + ctx, rt, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil} - // let's create a campaign event fire for one of our contacts (for now this is totally hacked, they aren't in the group and // their relative to date isn't relative, but this still tests execution) - db.MustExec(`UPDATE campaigns_campaignevent SET flow_id = $1 WHERE id = $2`, models.IVRFlowID, models.RemindersEvent1ID) - db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID) + rt.DB.MustExec(`UPDATE campaigns_campaignevent SET flow_id = $1 WHERE id = $2`, testdata.IVRFlow.ID, testdata.RemindersEvent1.ID) + rt.DB.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID) time.Sleep(10 * time.Millisecond) // schedule our campaign to be started - err := fireCampaignEvents(ctx, db, rp, campaignsLock, "lock") + err := fireCampaignEvents(ctx, rt.DB, rt.RP, campaignsLock, "lock") assert.NoError(t, err) // then actually work on the event @@ -81,16 +71,16 @@ func TestIVRCampaigns(t *testing.T) { require.NoError(t, err) // work on that task - err = typedTask.Perform(ctx, mr, models.OrgID(task.OrgID)) + err = typedTask.Perform(ctx, rt, models.OrgID(task.OrgID)) assert.NoError(t, err) // should now have a flow start created - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`, []interface{}{models.IVRFlowID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, []interface{}{models.CathyID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, []interface{}{models.GeorgeID}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`, testdata.IVRFlow.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, testdata.Cathy.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, testdata.George.ID).Returns(1) // event should be marked as fired - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from campaigns_eventfire WHERE event_id = $1 AND fired IS NOT NULL;`, []interface{}{models.RemindersEvent1ID}, 2) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) from campaigns_eventfire WHERE event_id = $1 AND fired IS NOT NULL;`, testdata.RemindersEvent1.ID).Returns(2) // pop our next task, should be the start task, err = queue.PopNextTask(rc, queue.BatchQueue) diff --git a/core/tasks/campaigns/fire_campaign_event.go b/core/tasks/campaigns/fire_campaign_event.go index e5536c0ec..a034cf85c 100644 --- a/core/tasks/campaigns/fire_campaign_event.go +++ b/core/tasks/campaigns/fire_campaign_event.go @@ -7,10 +7,10 @@ import ( "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows/triggers" - "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/runner" "github.com/nyaruka/mailroom/core/tasks" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/marker" "github.com/pkg/errors" @@ -47,9 +47,9 @@ func (t *FireCampaignEventTask) Timeout() time.Duration { // - creates the trigger for that event // - runs the flow that is to be started through our engine // - saves the flow run and session resulting from our run -func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error { - db := mr.DB - rp := mr.RP +func (t *FireCampaignEventTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error { + db := rt.DB + rp := rt.RP log := logrus.WithField("comp", "campaign_worker").WithField("event_id", t.EventID) // grab all the fires for this event @@ -82,7 +82,7 @@ func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailro campaign := triggers.NewCampaignReference(triggers.CampaignUUID(t.CampaignUUID), t.CampaignName) - started, err := runner.FireCampaignEvents(ctx, db, rp, orgID, fires, t.FlowUUID, campaign, triggers.CampaignEventUUID(t.EventUUID)) + started, err := runner.FireCampaignEvents(ctx, rt, orgID, fires, t.FlowUUID, campaign, triggers.CampaignEventUUID(t.EventUUID)) // remove all the contacts that were started for _, contactID := range started { @@ -93,7 +93,10 @@ func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailro if len(contactMap) > 0 { rc := rp.Get() for _, failed := range contactMap { - marker.RemoveTask(rc, campaignsLock, fmt.Sprintf("%d", failed.FireID)) + rerr := marker.RemoveTask(rc, campaignsLock, fmt.Sprintf("%d", failed.FireID)) + if rerr != nil { + log.WithError(rerr).WithField("fire_id", failed.FireID).Error("error unmarking campaign fire") + } } rc.Close() } diff --git a/core/tasks/campaigns/schedule_campaign_event.go b/core/tasks/campaigns/schedule_campaign_event.go index 24fe5490d..c420f542d 100644 --- a/core/tasks/campaigns/schedule_campaign_event.go +++ b/core/tasks/campaigns/schedule_campaign_event.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/locker" "github.com/pkg/errors" @@ -33,9 +33,9 @@ func (t *ScheduleCampaignEventTask) Timeout() time.Duration { } // Perform creates the actual event fires to schedule the given campaign event -func (t *ScheduleCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error { - db := mr.DB - rp := mr.RP +func (t *ScheduleCampaignEventTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error { + db := rt.DB + rp := rt.RP lockKey := fmt.Sprintf(scheduleLockKey, t.CampaignEventID) lock, err := locker.GrabLock(rp, lockKey, time.Hour, time.Minute*5) diff --git a/core/tasks/campaigns/schedule_campaign_event_test.go b/core/tasks/campaigns/schedule_campaign_event_test.go index 495f35b55..59a198029 100644 --- a/core/tasks/campaigns/schedule_campaign_event_test.go +++ b/core/tasks/campaigns/schedule_campaign_event_test.go @@ -5,35 +5,27 @@ import ( "time" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks/campaigns" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestScheduleCampaignEvent(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil} - - models.FlushCache() + ctx, rt, db, _ := testsuite.Reset() // add bob, george and alexandria to doctors group which campaign is based on - db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.BobID, models.DoctorsGroupID) - db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.GeorgeID, models.DoctorsGroupID) - db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.AlexandriaID, models.DoctorsGroupID) + testdata.DoctorsGroup.Add(db, testdata.Bob, testdata.George, testdata.Alexandria) // give bob and george values for joined in the future - db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-01-01T00:00:00Z"}}' WHERE id = $1`, models.BobID) - db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-08-18T11:31:30Z"}}' WHERE id = $1`, models.GeorgeID) + db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-01-01T00:00:00Z"}}' WHERE id = $1`, testdata.Bob.ID) + db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-08-18T11:31:30Z"}}' WHERE id = $1`, testdata.George.ID) // give alexandria a value in the past - db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2015-01-01T00:00:00Z"}}' WHERE id = $1`, models.AlexandriaID) + db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2015-01-01T00:00:00Z"}}' WHERE id = $1`, testdata.Alexandria.ID) db.MustExec(`DELETE FROM campaigns_eventfire`) @@ -42,62 +34,62 @@ func TestScheduleCampaignEvent(t *testing.T) { // 2. +10 Minutes send message // schedule first event... - task := &campaigns.ScheduleCampaignEventTask{CampaignEventID: models.RemindersEvent1ID} - err := task.Perform(ctx, mr, models.Org1) + task := &campaigns.ScheduleCampaignEventTask{CampaignEventID: testdata.RemindersEvent1.ID} + err := task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) // cathy has no value for joined and alexandia has a value too far in past, but bob and george will have values... - assertContactFires(t, models.RemindersEvent1ID, map[models.ContactID]time.Time{ - models.BobID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), // 12:00 in PST - models.GeorgeID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), // 12:00 in PST with DST + assertContactFires(t, testdata.RemindersEvent1.ID, map[models.ContactID]time.Time{ + testdata.Bob.ID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), // 12:00 in PST + testdata.George.ID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), // 12:00 in PST with DST }) // schedule second event... - task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: models.RemindersEvent2ID} - err = task.Perform(ctx, mr, models.Org1) + task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: testdata.RemindersEvent2.ID} + err = task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - assertContactFires(t, models.RemindersEvent2ID, map[models.ContactID]time.Time{ - models.BobID: time.Date(2030, 1, 1, 0, 10, 0, 0, time.UTC), - models.GeorgeID: time.Date(2030, 8, 18, 11, 42, 0, 0, time.UTC), + assertContactFires(t, testdata.RemindersEvent2.ID, map[models.ContactID]time.Time{ + testdata.Bob.ID: time.Date(2030, 1, 1, 0, 10, 0, 0, time.UTC), + testdata.George.ID: time.Date(2030, 8, 18, 11, 42, 0, 0, time.UTC), }) // fires for first event unaffected - assertContactFires(t, models.RemindersEvent1ID, map[models.ContactID]time.Time{ - models.BobID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), - models.GeorgeID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), + assertContactFires(t, testdata.RemindersEvent1.ID, map[models.ContactID]time.Time{ + testdata.Bob.ID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), + testdata.George.ID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), }) // remove alexandria from campaign group - db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.AlexandriaID) + db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Alexandria.ID) // bump created_on for cathy and alexandria - db.MustExec(`UPDATE contacts_contact SET created_on = '2035-01-01T00:00:00Z' WHERE id = $1 OR id = $2`, models.CathyID, models.AlexandriaID) + db.MustExec(`UPDATE contacts_contact SET created_on = '2035-01-01T00:00:00Z' WHERE id = $1 OR id = $2`, testdata.Cathy.ID, testdata.Alexandria.ID) // create new campaign event based on created_on + 5 minutes - event3 := insertCampaignEvent(t, models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID, 5, "M") + event3 := insertCampaignEvent(t, testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID, 5, "M") task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: event3} - err = task.Perform(ctx, mr, models.Org1) + err = task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) // only cathy is in the group and new enough to have a fire assertContactFires(t, event3, map[models.ContactID]time.Time{ - models.CathyID: time.Date(2035, 1, 1, 0, 5, 0, 0, time.UTC), + testdata.Cathy.ID: time.Date(2035, 1, 1, 0, 5, 0, 0, time.UTC), }) // create new campaign event based on last_seen_on + 1 day - event4 := insertCampaignEvent(t, models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.LastSeenOnFieldID, 1, "D") + event4 := insertCampaignEvent(t, testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.LastSeenOnField.ID, 1, "D") // bump last_seen_on for bob - db.MustExec(`UPDATE contacts_contact SET last_seen_on = '2040-01-01T00:00:00Z' WHERE id = $1`, models.BobID) + db.MustExec(`UPDATE contacts_contact SET last_seen_on = '2040-01-01T00:00:00Z' WHERE id = $1`, testdata.Bob.ID) task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: event4} - err = task.Perform(ctx, mr, models.Org1) + err = task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) assertContactFires(t, event4, map[models.ContactID]time.Time{ - models.BobID: time.Date(2040, 1, 2, 0, 0, 0, 0, time.UTC), + testdata.Bob.ID: time.Date(2040, 1, 2, 0, 0, 0, 0, time.UTC), }) } diff --git a/core/tasks/contacts/import_contact_batch.go b/core/tasks/contacts/import_contact_batch.go index a3a321664..16c114a8b 100644 --- a/core/tasks/contacts/import_contact_batch.go +++ b/core/tasks/contacts/import_contact_batch.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks" + "github.com/nyaruka/mailroom/runtime" "github.com/pkg/errors" ) @@ -28,13 +28,13 @@ func (t *ImportContactBatchTask) Timeout() time.Duration { } // Perform figures out the membership for a query based group then repopulates it -func (t *ImportContactBatchTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error { - batch, err := models.LoadContactImportBatch(ctx, mr.DB, t.ContactImportBatchID) +func (t *ImportContactBatchTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error { + batch, err := models.LoadContactImportBatch(ctx, rt.DB, t.ContactImportBatchID) if err != nil { return errors.Wrapf(err, "unable to load contact import batch with id %d", t.ContactImportBatchID) } - if err := batch.Import(ctx, mr.DB, orgID); err != nil { + if err := batch.Import(ctx, rt.DB, orgID); err != nil { return errors.Wrapf(err, "unable to import contact import batch %d", t.ContactImportBatchID) } diff --git a/core/tasks/contacts/import_contact_batch_test.go b/core/tasks/contacts/import_contact_batch_test.go index 0823a84c2..77571371d 100644 --- a/core/tasks/contacts/import_contact_batch_test.go +++ b/core/tasks/contacts/import_contact_batch_test.go @@ -3,10 +3,7 @@ package contacts_test import ( "testing" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/core/handlers" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks/contacts" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" @@ -15,22 +12,19 @@ import ( ) func TestImportContactBatch(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, rt, db, _ := testsuite.Get() - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil} - - importID := testdata.InsertContactImport(t, db, models.Org1) - batchID := testdata.InsertContactImportBatch(t, db, importID, []byte(`[ + importID := testdata.InsertContactImport(db, testdata.Org1) + batchID := testdata.InsertContactImportBatch(db, importID, []byte(`[ {"name": "Norbert", "language": "eng", "urns": ["tel:+16055740001"]}, {"name": "Leah", "urns": ["tel:+16055740002"]} ]`)) task := &contacts.ImportContactBatchTask{ContactImportBatchID: batchID} - err := task.Perform(ctx, mr, models.Org1) + err := task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Norbert' AND language = 'eng'`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Leah' AND language IS NULL`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Norbert' AND language = 'eng'`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Leah' AND language IS NULL`).Returns(1) } diff --git a/core/tasks/groups/populate_dynamic_group.go b/core/tasks/contacts/populate_dynamic_group.go similarity index 82% rename from core/tasks/groups/populate_dynamic_group.go rename to core/tasks/contacts/populate_dynamic_group.go index eb5cdb36a..50eb9a4f3 100644 --- a/core/tasks/groups/populate_dynamic_group.go +++ b/core/tasks/contacts/populate_dynamic_group.go @@ -1,13 +1,13 @@ -package groups +package contacts import ( "context" "fmt" "time" - "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/locker" "github.com/pkg/errors" @@ -35,13 +35,13 @@ func (t *PopulateDynamicGroupTask) Timeout() time.Duration { } // Perform figures out the membership for a query based group then repopulates it -func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error { +func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error { lockKey := fmt.Sprintf(populateLockKey, t.GroupID) - lock, err := locker.GrabLock(mr.RP, lockKey, time.Hour, time.Minute*5) + lock, err := locker.GrabLock(rt.RP, lockKey, time.Hour, time.Minute*5) if err != nil { return errors.Wrapf(err, "error grabbing lock to repopulate dynamic group: %d", t.GroupID) } - defer locker.ReleaseLock(mr.RP, lockKey, lock) + defer locker.ReleaseLock(rt.RP, lockKey, lock) start := time.Now() log := logrus.WithFields(logrus.Fields{ @@ -52,12 +52,12 @@ func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, mr *mailroom.Mai log.Info("starting population of dynamic group") - oa, err := models.GetOrgAssets(ctx, mr.DB, orgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, orgID) if err != nil { return errors.Wrapf(err, "unable to load org when populating group: %d", t.GroupID) } - count, err := models.PopulateDynamicGroup(ctx, mr.DB, mr.ElasticClient, oa, t.GroupID, t.Query) + count, err := models.PopulateDynamicGroup(ctx, rt.DB, rt.ES, oa, t.GroupID, t.Query) if err != nil { return errors.Wrapf(err, "error populating dynamic group: %d", t.GroupID) } diff --git a/core/tasks/groups/populate_dynamic_group_test.go b/core/tasks/contacts/populate_dynamic_group_test.go similarity index 52% rename from core/tasks/groups/populate_dynamic_group_test.go rename to core/tasks/contacts/populate_dynamic_group_test.go index a397abbb9..686ede1ee 100644 --- a/core/tasks/groups/populate_dynamic_group_test.go +++ b/core/tasks/contacts/populate_dynamic_group_test.go @@ -1,13 +1,11 @@ -package groups_test +package contacts_test import ( "fmt" "testing" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/core/tasks/groups" + "github.com/nyaruka/gocommon/dates" + "github.com/nyaruka/mailroom/core/tasks/contacts" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" @@ -16,21 +14,17 @@ import ( ) func TestPopulateTask(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, rt, db, _ := testsuite.Reset() mes := testsuite.NewMockElasticServer() defer mes.Close() - es, err := elastic.NewClient( elastic.SetURL(mes.URL()), elastic.SetHealthcheck(false), elastic.SetSniff(false), ) require.NoError(t, err) - - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: es} + rt.ES = es mes.NextResponse = fmt.Sprintf(`{ "_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAbgc0WS1hqbHlfb01SM2lLTWJRMnVOSVZDdw==", @@ -56,16 +50,19 @@ func TestPopulateTask(t *testing.T) { } ] } - }`, models.CathyID) + }`, testdata.Cathy.ID) - groupID := testdata.InsertContactGroup(t, db, models.Org1, "e52fee05-2f95-4445-aef6-2fe7dac2fd56", "Women", "gender = F") + group := testdata.InsertContactGroup(db, testdata.Org1, "e52fee05-2f95-4445-aef6-2fe7dac2fd56", "Women", "gender = F") + start := dates.Now() - task := &groups.PopulateDynamicGroupTask{ - GroupID: groupID, + task := &contacts.PopulateDynamicGroupTask{ + GroupID: group.ID, Query: "gender = F", } - err = task.Perform(ctx, mr, models.Org1) + err = task.Perform(ctx, rt, testdata.Org1.ID) require.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, []interface{}{groupID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, group.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, group.ID).Returns(int64(testdata.Cathy.ID)) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_on > $2`, testdata.Cathy.ID, start).Returns(1) } diff --git a/core/tasks/expirations/cron.go b/core/tasks/expirations/cron.go index 86b070f21..bebc215f1 100644 --- a/core/tasks/expirations/cron.go +++ b/core/tasks/expirations/cron.go @@ -3,12 +3,14 @@ package expirations import ( "context" "fmt" + "sync" "time" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks/handler" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/nyaruka/mailroom/utils/marker" @@ -29,12 +31,12 @@ func init() { } // StartExpirationCron starts our cron job of expiring runs every minute -func StartExpirationCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, expirationLock, time.Second*60, +func StartExpirationCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, expirationLock, time.Second*60, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return expireRuns(ctx, mr.DB, mr.RP, lockName, lockValue) + return expireRuns(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) return nil @@ -106,7 +108,7 @@ func expireRuns(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName strin // ok, queue this task task := handler.NewExpirationTask(expiration.OrgID, expiration.ContactID, *expiration.SessionID, expiration.RunID, expiration.ExpiresOn) - err = handler.AddHandleTask(rc, expiration.ContactID, task) + err = handler.QueueHandleTask(rc, expiration.ContactID, task) if err != nil { return errors.Wrapf(err, "error adding new expiration task") } diff --git a/core/tasks/expirations/cron_test.go b/core/tasks/expirations/cron_test.go index eab4e8162..91708cea5 100644 --- a/core/tasks/expirations/cron_test.go +++ b/core/tasks/expirations/cron_test.go @@ -6,8 +6,6 @@ import ( "testing" "time" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/flows" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" @@ -25,67 +23,63 @@ func TestMain(m *testing.M) { } func TestExpirations(t *testing.T) { - ctx := testsuite.CTX() - rp := testsuite.RP() - rc := testsuite.RC() + ctx, _, db, rp := testsuite.Get() + rc := rp.Get() defer rc.Close() err := marker.ClearTasks(rc, expirationLock) assert.NoError(t, err) - // need to create a session that has an expired timeout - db := testsuite.DB() - // create a few sessions - s1 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.CathyID, models.SessionStatusWaiting, nil) - s2 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.GeorgeID, models.SessionStatusWaiting, nil) - s3 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.BobID, models.SessionStatusWaiting, nil) + s1 := testdata.InsertFlowSession(db, testdata.Org1, testdata.Cathy, models.SessionStatusWaiting, nil) + s2 := testdata.InsertFlowSession(db, testdata.Org1, testdata.George, models.SessionStatusWaiting, nil) + s3 := testdata.InsertFlowSession(db, testdata.Org1, testdata.Bob, models.SessionStatusWaiting, nil) // simple run, no parent r1ExpiresOn := time.Now() - testdata.InsertFlowRun(t, db, "f240ab19-ed5d-4b51-b934-f2fbb9f8e5ad", models.Org1, s1, models.CathyID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r1ExpiresOn) + testdata.InsertFlowRun(db, testdata.Org1, s1, testdata.Cathy, testdata.Favorites, models.RunStatusWaiting, "", &r1ExpiresOn) // parent run r2ExpiresOn := time.Now().Add(time.Hour * 24) - testdata.InsertFlowRun(t, db, "c4126b59-7a61-4ed5-a2da-c7857580355b", models.Org1, s2, models.GeorgeID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r2ExpiresOn) + testdata.InsertFlowRun(db, testdata.Org1, s2, testdata.George, testdata.Favorites, models.RunStatusWaiting, "", &r2ExpiresOn) // child run r3ExpiresOn := time.Now() - testdata.InsertFlowRun(t, db, "a87b7079-5a3c-4e5f-8a6a-62084807c522", models.Org1, s2, models.GeorgeID, models.FavoritesFlowID, models.RunStatusWaiting, "c4126b59-7a61-4ed5-a2da-c7857580355b", &r3ExpiresOn) + testdata.InsertFlowRun(db, testdata.Org1, s2, testdata.George, testdata.Favorites, models.RunStatusWaiting, "c4126b59-7a61-4ed5-a2da-c7857580355b", &r3ExpiresOn) // run with no session r4ExpiresOn := time.Now() - testdata.InsertFlowRun(t, db, "d64fac33-933f-44b4-a6e4-53283d07a609", models.Org1, models.SessionID(0), models.CathyID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r4ExpiresOn) + testdata.InsertFlowRun(db, testdata.Org1, models.SessionID(0), testdata.Cathy, testdata.Favorites, models.RunStatusWaiting, "", &r4ExpiresOn) // run with no expires_on - testdata.InsertFlowRun(t, db, "4391fdc4-25ca-4e66-8e05-0cd3a6cbb6a2", models.Org1, s3, models.BobID, models.FavoritesFlowID, models.RunStatusWaiting, "", nil) + testdata.InsertFlowRun(db, testdata.Org1, s3, testdata.Bob, testdata.Favorites, models.RunStatusWaiting, "", nil) time.Sleep(10 * time.Millisecond) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.CathyID}, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.CathyID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Cathy.ID).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Cathy.ID).Returns(0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.GeorgeID}, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.GeorgeID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.George.ID).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.George.ID).Returns(0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.BobID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.BobID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Bob.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Bob.ID).Returns(0) // expire our runs err = expireRuns(ctx, db, rp, expirationLock, "foo") assert.NoError(t, err) // shouldn't have any active runs or sessions - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.CathyID}, 0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Cathy.ID).Returns(0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Cathy.ID).Returns(1) // should still have two active runs for George as it needs to continue - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.GeorgeID}, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.GeorgeID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.George.ID).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.George.ID).Returns(0) // runs without expires_on won't be expired - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.BobID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.BobID}, 0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Bob.ID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Bob.ID).Returns(0) // should have created one task task, err := queue.PopNextTask(rc, queue.HandlerQueue) @@ -98,7 +92,7 @@ func TestExpirations(t *testing.T) { assert.NoError(t, err) // assert its the right contact - assert.Equal(t, models.GeorgeID, eventTask.ContactID) + assert.Equal(t, testdata.George.ID, eventTask.ContactID) // no other tasks task, err = queue.PopNextTask(rc, queue.HandlerQueue) diff --git a/core/tasks/handler/cron.go b/core/tasks/handler/cron.go index 615769153..4ef26d8f8 100644 --- a/core/tasks/handler/cron.go +++ b/core/tasks/handler/cron.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "sync" "time" "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/nyaruka/mailroom/utils/marker" @@ -29,19 +31,19 @@ func init() { } // StartRetryCron starts our cron job of retrying pending incoming messages -func StartRetryCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, retryLock, time.Minute*5, +func StartRetryCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, retryLock, time.Minute*5, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return retryPendingMsgs(ctx, mr.DB, mr.RP, lockName, lockValue) + return RetryPendingMsgs(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) return nil } -// retryPendingMsgs looks for any pending msgs older than five minutes and queues them to be handled again -func retryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName string, lockValue string) error { +// RetryPendingMsgs looks for any pending msgs older than five minutes and queues them to be handled again +func RetryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName string, lockValue string) error { if !config.Mailroom.RetryPendingMessages { return nil } @@ -104,7 +106,7 @@ func retryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName } // queue this event up for handling - err = AddHandleTask(rc, contactID, task) + err = QueueHandleTask(rc, contactID, task) if err != nil { return errors.Wrapf(err, "error queuing retry for task") } diff --git a/core/tasks/handler/cron_test.go b/core/tasks/handler/cron_test.go index 50b712cfa..65cb5bdc3 100644 --- a/core/tasks/handler/cron_test.go +++ b/core/tasks/handler/cron_test.go @@ -1,4 +1,4 @@ -package handler +package handler_test import ( "testing" @@ -9,21 +9,20 @@ import ( _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/core/tasks/handler" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestRetryMsgs(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - rp := testsuite.RP() - ctx := testsuite.CTX() - + ctx, rt, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() // noop does nothing - err := retryPendingMsgs(ctx, db, rp, "test", "test") + err := handler.RetryPendingMsgs(ctx, db, rp, "test", "test") assert.NoError(t, err) testMsgs := []struct { @@ -40,20 +39,20 @@ func TestRetryMsgs(t *testing.T) { db.MustExec( `INSERT INTO msgs_msg(uuid, org_id, channel_id, contact_id, contact_urn_id, text, direction, status, created_on, visibility, msg_count, error_count, next_attempt) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, 'V', 1, 0, NOW())`, - uuids.New(), models.Org1, models.TwilioChannelID, models.CathyID, models.CathyURNID, msg.Text, models.DirectionIn, msg.Status, msg.CreatedOn) + uuids.New(), testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, msg.Text, models.DirectionIn, msg.Status, msg.CreatedOn) } - err = retryPendingMsgs(ctx, db, rp, "test", "test") + err = handler.RetryPendingMsgs(ctx, db, rp, "test", "test") assert.NoError(t, err) // should have one message requeued task, _ := queue.PopNextTask(rc, queue.HandlerQueue) assert.NotNil(t, task) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err) // message should be handled now - testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_msg WHERE text = 'pending' AND status = 'H'`, []interface{}{}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_msg WHERE text = 'pending' AND status = 'H'`).Returns(1) // only one message was queued task, _ = queue.PopNextTask(rc, queue.HandlerQueue) diff --git a/core/tasks/handler/handler_test.go b/core/tasks/handler/handler_test.go index 35757163a..bd8b1c75b 100644 --- a/core/tasks/handler/handler_test.go +++ b/core/tasks/handler/handler_test.go @@ -1,4 +1,4 @@ -package handler +package handler_test import ( "encoding/json" @@ -12,100 +12,102 @@ import ( _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/core/tasks/handler" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" - "github.com/gomodule/redigo/redis" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMsgEvents(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - rp := testsuite.RP() - ctx := testsuite.CTX() - + ctx, rt, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, models.FavoritesFlowID) + testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "start", models.MatchOnly, nil, nil) + testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.IVRFlow, "ivr", models.MatchOnly, nil, nil) - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 2) RETURNING id`, models.Org2FavoritesFlowID) + testdata.InsertKeywordTrigger(db, testdata.Org2, testdata.Org2Favorites, "start", models.MatchOnly, nil, nil) + testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2SingleMessage, nil, nil) - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), '', false, $1, 'C', 'O', 1, 1, 2) RETURNING id`, models.Org2SingleMessageFlowID) + // give Cathy and Bob some tickets... + openTickets := map[*testdata.Contact][]*testdata.Ticket{ + testdata.Cathy: { + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Hi there", "Ok", "", nil), + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Hi again", "Ok", "", nil), + }, + } + closedTickets := map[*testdata.Contact][]*testdata.Ticket{ + testdata.Cathy: { + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Old", "", "", nil), + }, + testdata.Bob: { + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Hi there", "Ok", "", nil), + }, + } - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), 'ivr', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, models.IVRFlowID) + db.MustExec(`UPDATE tickets_ticket SET last_activity_on = '2021-01-01T00:00:00Z'`) // clear all of Alexandria's URNs - db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.AlexandriaID) + db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Alexandria.ID) models.FlushCache() + // insert a dummy message into the database that will get the updates from handling each message event which pretends to be it + dbMsg := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "", models.MsgStatusPending) + tcs := []struct { - Hook func() - ContactID models.ContactID - URN urns.URN - URNID models.URNID - Message string - Response string - ChannelID models.ChannelID - OrgID models.OrgID + Hook func() + Org *testdata.Org + Channel *testdata.Channel + Contact *testdata.Contact + Text string + ExpectedReply string + ExpectedType models.MsgType }{ - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "noop", "", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "start other", "", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "purple", "I don't know that color. Try again.", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "blue", "Good choice, I like Blue too! What is your favorite beer?", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "MUTZIG", "Mmmmm... delicious Mutzig. If only they made blue Mutzig! Lastly, what is your name?", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "Cathy", "Thanks Cathy, we are all done!", models.TwitterChannelID, models.Org1}, - {nil, models.CathyID, models.CathyURN, models.CathyURNID, "noop", "", models.TwitterChannelID, models.Org1}, - - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "other", "Hey, how are you?", models.Org2ChannelID, models.Org2}, - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2}, - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "green", "Good choice, I like Green too! What is your favorite beer?", models.Org2ChannelID, models.Org2}, - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "primus", "Mmmmm... delicious Primus. If only they made green Primus! Lastly, what is your name?", models.Org2ChannelID, models.Org2}, - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "george", "Thanks george, we are all done!", models.Org2ChannelID, models.Org2}, - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "blargh", "Hey, how are you?", models.Org2ChannelID, models.Org2}, - - {nil, models.BobID, models.BobURN, models.BobURNID, "ivr", "", models.TwitterChannelID, models.Org1}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "noop", "", models.MsgTypeInbox}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "start other", "", models.MsgTypeInbox}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "start", "What is your favorite color?", models.MsgTypeFlow}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "purple", "I don't know that color. Try again.", models.MsgTypeFlow}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "blue", "Good choice, I like Blue too! What is your favorite beer?", models.MsgTypeFlow}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "MUTZIG", "Mmmmm... delicious Mutzig. If only they made blue Mutzig! Lastly, what is your name?", models.MsgTypeFlow}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "Cathy", "Thanks Cathy, we are all done!", models.MsgTypeFlow}, + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "noop", "", models.MsgTypeInbox}, + + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "other", "Hey, how are you?", models.MsgTypeFlow}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "green", "Good choice, I like Green too! What is your favorite beer?", models.MsgTypeFlow}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "primus", "Mmmmm... delicious Primus. If only they made green Primus! Lastly, what is your name?", models.MsgTypeFlow}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "george", "Thanks george, we are all done!", models.MsgTypeFlow}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "blargh", "Hey, how are you?", models.MsgTypeFlow}, + + {nil, testdata.Org1, testdata.TwitterChannel, testdata.Bob, "ivr", "", models.MsgTypeFlow}, // no URN on contact but handle event, session gets started but no message created - {nil, models.AlexandriaID, models.AlexandriaURN, models.AlexandriaURNID, "start", "", models.TwilioChannelID, models.Org1}, + {nil, testdata.Org1, testdata.TwilioChannel, testdata.Alexandria, "start", "", models.MsgTypeFlow}, // start Fred back in our favorite flow, then make it inactive, will be handled by catch-all - {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2}, + {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow}, {func() { - db.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, models.Org2FavoritesFlowID) - }, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red", "Hey, how are you?", models.Org2ChannelID, models.Org2}, + db.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, testdata.Org2Favorites.ID) + }, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red", "Hey, how are you?", models.MsgTypeFlow}, // start Fred back in our favorites flow to test retries {func() { - db.MustExec(`UPDATE flows_flow SET is_active = TRUE WHERE id = $1`, models.Org2FavoritesFlowID) - }, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2}, + db.MustExec(`UPDATE flows_flow SET is_active = TRUE WHERE id = $1`, testdata.Org2Favorites.ID) + }, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow}, } - makeMsgTask := func(orgID models.OrgID, channelID models.ChannelID, contactID models.ContactID, urn urns.URN, urnID models.URNID, text string) *queue.Task { - event := &MsgEvent{ - ContactID: contactID, - OrgID: orgID, - ChannelID: channelID, - MsgID: flows.MsgID(1), - MsgUUID: flows.MsgUUID(uuids.New()), - URN: urn, - URNID: urnID, + makeMsgTask := func(org *testdata.Org, channel *testdata.Channel, contact *testdata.Contact, text string) *queue.Task { + event := &handler.MsgEvent{ + ContactID: contact.ID, + OrgID: org.ID, + ChannelID: channel.ID, + MsgID: dbMsg.ID(), + MsgUUID: dbMsg.UUID(), + URN: contact.URN, + URNID: contact.URNID, Text: text, } @@ -113,8 +115,8 @@ func TestMsgEvents(t *testing.T) { assert.NoError(t, err) task := &queue.Task{ - Type: MsgEventType, - OrgID: int(orgID), + Type: handler.MsgEventType, + OrgID: int(org.ID), Task: eventJSON, } @@ -126,26 +128,44 @@ func TestMsgEvents(t *testing.T) { for i, tc := range tcs { models.FlushCache() + // reset our dummy db message into an unhandled state + db.MustExec(`UPDATE msgs_msg SET status = 'P', msg_type = NULL WHERE id = $1`, dbMsg.ID()) + // run our hook if we have one if tc.Hook != nil { tc.Hook() } - task := makeMsgTask(tc.OrgID, tc.ChannelID, tc.ContactID, tc.URN, tc.URNID, tc.Message) + task := makeMsgTask(tc.Org, tc.Channel, tc.Contact, tc.Text) - err := AddHandleTask(rc, tc.ContactID, task) + err := handler.QueueHandleTask(rc, tc.Contact.ID, task) assert.NoError(t, err, "%d: error adding task", i) task, err = queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err, "%d: error popping next task", i) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err, "%d: error when handling event", i) - // if we are meant to have a response - var text string - db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.ContactID, last) - assert.Equal(t, text, tc.Response, "%d: response: '%s' does not contain '%s'", i, text, tc.Response) + // check that message is marked as handled with expected type + testsuite.AssertQuery(t, db, `SELECT msg_type, status FROM msgs_msg WHERE id = $1`, dbMsg.ID()). + Columns(map[string]interface{}{"msg_type": string(tc.ExpectedType), "status": "H"}, "%d: msg state mismatch", i) + + // if we are meant to have a reply, check it + if tc.ExpectedReply != "" { + testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.Contact.ID, last). + Returns(tc.ExpectedReply, "%d: response mismatch", i) + } + + // check any open tickets for this contact where updated + numOpenTickets := len(openTickets[tc.Contact]) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE contact_id = $1 AND status = 'O' AND last_activity_on > $2`, tc.Contact.ID, last). + Returns(numOpenTickets, "%d: updated open ticket mismatch", i) + + // check any closed tickets are unchanged + numClosedTickets := len(closedTickets[tc.Contact]) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE contact_id = $1 AND status = 'C' AND last_activity_on = '2021-01-01T00:00:00Z'`, tc.Contact.ID). + Returns(numClosedTickets, "%d: unchanged closed ticket mismatch", i) last = time.Now() } @@ -156,27 +176,25 @@ func TestMsgEvents(t *testing.T) { assert.NotNil(t, task) assert.Equal(t, queue.StartIVRFlowBatch, task.Type) - // should have 7 queued priority messages - count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", models.Org2ChannelUUID))) - assert.NoError(t, err) - assert.Equal(t, 9, count) + // check messages queued to courier + testsuite.AssertCourierQueues(t, map[string][]int{ + fmt.Sprintf("msgs:%s|10/1", testdata.TwitterChannel.UUID): {1, 1, 1, 1, 1}, + fmt.Sprintf("msgs:%s|10/1", testdata.Org2Channel.UUID): {1, 1, 1, 1, 1, 1, 1, 1, 1}, + }) // Fred's sessions should not have a timeout because courier will set them - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from flows_flowsession where contact_id = $1 and timeout_on IS NULL AND wait_started_on IS NOT NULL`, - []interface{}{models.Org2FredID}, 2, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1 and timeout_on IS NULL AND wait_started_on IS NOT NULL`, testdata.Org2Contact.ID).Returns(2) // force an error by marking our run for fred as complete (our session is still active so this will blow up) - db.MustExec(`UPDATE flows_flowrun SET is_active = FALSE WHERE contact_id = $1`, models.Org2FredID) - task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red") - AddHandleTask(rc, models.Org2FredID, task) + db.MustExec(`UPDATE flows_flowrun SET is_active = FALSE WHERE contact_id = $1`, testdata.Org2Contact.ID) + task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red") + handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task) // should get requeued three times automatically for i := 0; i < 3; i++ { task, _ = queue.PopNextTask(rc, queue.HandlerQueue) assert.NotNil(t, task) - err := handleContactEvent(ctx, db, rp, task) + err := handler.HandleEvent(ctx, rt, task) assert.NoError(t, err) } @@ -186,67 +204,45 @@ func TestMsgEvents(t *testing.T) { assert.Nil(t, task) // mark Fred's flow as inactive - db.MustExec(`UPDATE flows_flow SET is_active = FALSE where id = $1`, models.Org2FavoritesFlowID) + db.MustExec(`UPDATE flows_flow SET is_active = FALSE where id = $1`, testdata.Org2Favorites.ID) models.FlushCache() // try to resume now - task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red") - AddHandleTask(rc, models.Org2FredID, task) + task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red") + handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task) task, _ = queue.PopNextTask(rc, queue.HandlerQueue) assert.NotNil(t, task) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err) // should get our catch all trigger - text := "" - db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' ORDER BY id DESC LIMIT 1`, models.Org2FredID) - assert.Equal(t, "Hey, how are you?", text) + testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' ORDER BY id DESC LIMIT 1`, testdata.Org2Contact.ID).Returns("Hey, how are you?") previous := time.Now() // and should have failed previous session - testsuite.AssertQueryCount(t, db, - `SELECT count(*) from flows_flowsession where contact_id = $1 and status = 'F' and current_flow_id = $2`, - []interface{}{models.Org2FredID, models.Org2FavoritesFlowID}, 2, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1 and status = 'F' and current_flow_id = $2`, testdata.Org2Contact.ID, testdata.Org2Favorites.ID).Returns(2) // trigger should also not start a new session - task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start") - AddHandleTask(rc, models.Org2FredID, task) + task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start") + handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task) task, _ = queue.PopNextTask(rc, queue.HandlerQueue) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err) - db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2 ORDER BY id DESC LIMIT 1`, models.Org2FredID, previous) - assert.Equal(t, "Hey, how are you?", text) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2`, testdata.Org2Contact.ID, previous).Returns(0) } func TestChannelEvents(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - rp := testsuite.RP() - ctx := testsuite.CTX() - + ctx, rt, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() - logrus.Info("starting channel test") - - // trigger on our twitter channel for new conversations and favorites flow - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id) - VALUES(TRUE, now(), now(), NULL, false, $1, 'N', NULL, 1, 1, 1, $2) RETURNING id`, - models.FavoritesFlowID, models.TwitterChannelID) - - // trigger on our vonage channel for referral and number flow - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id) - VALUES(TRUE, now(), now(), NULL, false, $1, 'R', NULL, 1, 1, 1, $2) RETURNING id`, - models.PickNumberFlowID, models.VonageChannelID) + // add some channel event triggers + testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwitterChannel) + testdata.InsertReferralTrigger(db, testdata.Org1, testdata.PickANumber, "", testdata.VonageChannel) // add a URN for cathy so we can test twitter URNs - testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("twitterid:123456"), 10) + testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, urns.URN("twitterid:123456"), 10) tcs := []struct { EventType models.ChannelEventType @@ -258,11 +254,11 @@ func TestChannelEvents(t *testing.T) { Response string UpdateLastSeen bool }{ - {NewConversationEventType, models.CathyID, models.CathyURNID, models.Org1, models.TwitterChannelID, nil, "What is your favorite color?", true}, - {NewConversationEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "", true}, - {WelcomeMessageEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "", false}, - {ReferralEventType, models.CathyID, models.CathyURNID, models.Org1, models.TwitterChannelID, nil, "", true}, - {ReferralEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "Pick a number between 1-10.", true}, + {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "What is your favorite color?", true}, + {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", true}, + {handler.WelcomeMessageEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", false}, + {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "", true}, + {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "Pick a number between 1-10.", true}, } models.FlushCache() @@ -281,21 +277,19 @@ func TestChannelEvents(t *testing.T) { Task: eventJSON, } - err = AddHandleTask(rc, tc.ContactID, task) + err = handler.QueueHandleTask(rc, tc.ContactID, task) assert.NoError(t, err, "%d: error adding task", i) task, err = queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err, "%d: error popping next task", i) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err, "%d: error when handling event", i) // if we are meant to have a response if tc.Response != "" { - var text string - err = db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND contact_urn_id = $2 AND created_on > $3 ORDER BY id DESC LIMIT 1`, tc.ContactID, tc.URNID, start) - assert.NoError(t, err) - assert.Equal(t, tc.Response, text, "%d: response: '%s' is not '%s'", i, text, tc.Response) + testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND contact_urn_id = $2 AND created_on > $3 ORDER BY id DESC LIMIT 1`, tc.ContactID, tc.URNID, start). + Returns(tc.Response, "%d: response mismatch", i) } if tc.UpdateLastSeen { @@ -307,108 +301,117 @@ func TestChannelEvents(t *testing.T) { } } -func TestStopEvent(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - rp := testsuite.RP() - ctx := testsuite.CTX() +func TestTicketEvents(t *testing.T) { + ctx, rt, db, _ := testsuite.Reset() + rc := rt.RP.Get() + defer rc.Close() + + // add a ticket closed trigger + testdata.InsertTicketClosedTrigger(rt.DB, testdata.Org1, testdata.Favorites) + + ticket := testdata.InsertClosedTicket(rt.DB, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where are my shoes?", "", nil) + modelTicket := ticket.Load(db) + + event := models.NewTicketClosedEvent(modelTicket, testdata.Admin.ID) + + err := handler.QueueTicketEvent(rc, testdata.Cathy.ID, event) + require.NoError(t, err) + + task, err := queue.PopNextTask(rc, queue.HandlerQueue) + require.NoError(t, err) + + err = handler.HandleEvent(ctx, rt, task) + require.NoError(t, err) + testsuite.AssertQuery(t, rt.DB, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text = 'What is your favorite color?'`, testdata.Cathy.ID).Returns(1) +} + +func TestStopEvent(t *testing.T) { + ctx, rt, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() // schedule an event for cathy and george - db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID) + db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID) // and george to doctors group, cathy is already part of it - db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2);`, models.DoctorsGroupID, models.GeorgeID) + db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2);`, testdata.DoctorsGroup.ID, testdata.George.ID) - event := &StopEvent{OrgID: models.Org1, ContactID: models.CathyID} + event := &handler.StopEvent{OrgID: testdata.Org1.ID, ContactID: testdata.Cathy.ID} eventJSON, err := json.Marshal(event) + require.NoError(t, err) task := &queue.Task{ - Type: StopEventType, - OrgID: int(models.Org1), + Type: handler.StopEventType, + OrgID: int(testdata.Org1.ID), Task: eventJSON, } - err = AddHandleTask(rc, models.CathyID, task) + err = handler.QueueHandleTask(rc, testdata.Cathy.ID, task) assert.NoError(t, err, "error adding task") task, err = queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err, "error popping next task") - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err, "error when handling event") // check that only george is in our group - testsuite.AssertQueryCount(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, []interface{}{models.DoctorsGroupID, models.CathyID}, 0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, []interface{}{models.DoctorsGroupID, models.GeorgeID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, testdata.DoctorsGroup.ID, testdata.Cathy.ID).Returns(0) + testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, testdata.DoctorsGroup.ID, testdata.George.ID).Returns(1) // that cathy is stopped - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(1) // and has no upcoming events - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.CathyID}, 0) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.GeorgeID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.Cathy.ID).Returns(0) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.George.ID).Returns(1) } func TestTimedEvents(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - rp := testsuite.RP() - ctx := testsuite.CTX() - + ctx, rt, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() // start to start our favorites flow - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, - models.FavoritesFlowID, - ) - - models.FlushCache() + testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "start", models.MatchOnly, nil, nil) tcs := []struct { EventType string - ContactID models.ContactID - URN urns.URN - URNID models.URNID + Contact *testdata.Contact Message string Response string ChannelID models.ChannelID OrgID models.OrgID }{ - // start the flow - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1}, + // 0: start the flow + {handler.MsgEventType, testdata.Cathy, "start", "What is your favorite color?", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // this expiration does nothing because the times don't match - {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "bad", "", models.TwitterChannelID, models.Org1}, + // 1: this expiration does nothing because the times don't match + {handler.ExpirationEventType, testdata.Cathy, "bad", "", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // this checks that the flow wasn't expired - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "red", "Good choice, I like Red too! What is your favorite beer?", models.TwitterChannelID, models.Org1}, + // 2: this checks that the flow wasn't expired + {handler.MsgEventType, testdata.Cathy, "red", "Good choice, I like Red too! What is your favorite beer?", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // this expiration will actually take - {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "good", "", models.TwitterChannelID, models.Org1}, + // 3: this expiration will actually take + {handler.ExpirationEventType, testdata.Cathy, "good", "", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // we won't get a response as we will be out of the flow - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "mutzig", "", models.TwitterChannelID, models.Org1}, + // 4: we won't get a response as we will be out of the flow + {handler.MsgEventType, testdata.Cathy, "mutzig", "", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // start the parent expiration flow - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "parent", "Child", models.TwitterChannelID, models.Org1}, + // 5: start the parent expiration flow + {handler.MsgEventType, testdata.Cathy, "parent", "Child", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // respond, should bring us out - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "hi", "Completed", models.TwitterChannelID, models.Org1}, + // 6: respond, should bring us out + {handler.MsgEventType, testdata.Cathy, "hi", "Completed", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // expiring our child should be a no op - {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "child", "", models.TwitterChannelID, models.Org1}, + // 7: expiring our child should be a no op + {handler.ExpirationEventType, testdata.Cathy, "child", "", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // respond one last time, should be done - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "done", "Ended", models.TwitterChannelID, models.Org1}, + // 8: respond one last time, should be done + {handler.MsgEventType, testdata.Cathy, "done", "Ended", testdata.TwitterChannel.ID, testdata.Org1.ID}, - // start our favorite flow again - {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1}, + // 9: start our favorite flow again + {handler.MsgEventType, testdata.Cathy, "start", "What is your favorite color?", testdata.TwitterChannel.ID, testdata.Org1.ID}, } last := time.Now() @@ -420,15 +423,15 @@ func TestTimedEvents(t *testing.T) { time.Sleep(50 * time.Millisecond) var task *queue.Task - if tc.EventType == MsgEventType { - event := &MsgEvent{ - ContactID: tc.ContactID, + if tc.EventType == handler.MsgEventType { + event := &handler.MsgEvent{ + ContactID: tc.Contact.ID, OrgID: tc.OrgID, ChannelID: tc.ChannelID, MsgID: flows.MsgID(1), MsgUUID: flows.MsgUUID(uuids.New()), - URN: tc.URN, - URNID: tc.URNID, + URN: tc.Contact.URN, + URNID: tc.Contact.URNID, Text: tc.Message, } @@ -440,7 +443,7 @@ func TestTimedEvents(t *testing.T) { OrgID: int(tc.OrgID), Task: eventJSON, } - } else if tc.EventType == ExpirationEventType { + } else if tc.EventType == handler.ExpirationEventType { var expiration time.Time if tc.Message == "bad" { expiration = time.Now() @@ -454,43 +457,43 @@ func TestTimedEvents(t *testing.T) { expiration = time.Now().Add(time.Hour * 24) } - task = newTimedTask( - ExpirationEventType, + task = handler.NewExpirationTask( tc.OrgID, - tc.ContactID, + tc.Contact.ID, sessionID, runID, expiration, ) } - err := AddHandleTask(rc, tc.ContactID, task) + err := handler.QueueHandleTask(rc, tc.Contact.ID, task) assert.NoError(t, err, "%d: error adding task", i) task, err = queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err, "%d: error popping next task", i) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err, "%d: error when handling event", i) - var text string - db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.ContactID, last) - assert.Equal(t, text, tc.Response, "%d: response: '%s' does not match '%s'", i, text, tc.Response) + if tc.Response != "" { + testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.Contact.ID, last). + Returns(tc.Response, "%d: response: mismatch", i) + } - err = db.Get(&sessionID, `SELECT id FROM flows_flowsession WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID) + err = db.Get(&sessionID, `SELECT id FROM flows_flowsession WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID) assert.NoError(t, err) - err = db.Get(&runID, `SELECT id FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID) + err = db.Get(&runID, `SELECT id FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID) assert.NoError(t, err) - err = db.Get(&runExpiration, `SELECT expires_on FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID) + err = db.Get(&runExpiration, `SELECT expires_on FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID) assert.NoError(t, err) last = time.Now() } // test the case of a run and session no longer being the most recent but somehow still active, expiration should still work - r, err := db.QueryContext(ctx, `SELECT id, session_id from flows_flowrun WHERE contact_id = $1 and is_active = FALSE order by created_on asc limit 1`, models.CathyID) + r, err := db.QueryContext(ctx, `SELECT id, session_id from flows_flowrun WHERE contact_id = $1 and is_active = FALSE order by created_on asc limit 1`, testdata.Cathy.ID) assert.NoError(t, err) defer r.Close() r.Next() @@ -505,26 +508,25 @@ func TestTimedEvents(t *testing.T) { db.MustExec(`ALTER TABLE flows_flowrun ENABLE TRIGGER temba_flowrun_path_change`) // try to expire the run - task := newTimedTask( - ExpirationEventType, - models.Org1, - models.CathyID, + task := handler.NewExpirationTask( + testdata.Org1.ID, + testdata.Cathy.ID, sessionID, runID, expiration, ) - err = AddHandleTask(rc, models.CathyID, task) + err = handler.QueueHandleTask(rc, testdata.Cathy.ID, task) assert.NoError(t, err) task, err = queue.PopNextTask(rc, queue.HandlerQueue) assert.NoError(t, err) - err = handleContactEvent(ctx, db, rp, task) + err = handler.HandleEvent(ctx, rt, task) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowrun WHERE is_active = FALSE AND status = 'F' AND id = $1`, []interface{}{runID}, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowsession WHERE status = 'F' AND id = $1`, []interface{}{sessionID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowrun WHERE is_active = FALSE AND status = 'F' AND id = $1`, runID).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession WHERE status = 'F' AND id = $1`, sessionID).Returns(1) testsuite.ResetDB() } diff --git a/core/tasks/handler/queue.go b/core/tasks/handler/queue.go new file mode 100644 index 000000000..6520a4317 --- /dev/null +++ b/core/tasks/handler/queue.go @@ -0,0 +1,75 @@ +package handler + +import ( + "encoding/json" + "fmt" + + "github.com/gomodule/redigo/redis" + "github.com/nyaruka/gocommon/dates" + "github.com/nyaruka/gocommon/jsonx" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/core/queue" + "github.com/pkg/errors" +) + +// QueueHandleTask queues a single task for the given contact +func QueueHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task) error { + return queueHandleTask(rc, contactID, task, false) +} + +// queueHandleTask queues a single task for the passed in contact. `front` specifies whether the task +// should be inserted in front of all other tasks for that contact +func queueHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task, front bool) error { + // marshal our task + taskJSON, err := json.Marshal(task) + if err != nil { + return errors.Wrapf(err, "error marshalling contact task") + } + + // first push the event on our contact queue + contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, contactID) + if front { + _, err = redis.Int64(rc.Do("lpush", contactQ, string(taskJSON))) + + } else { + _, err = redis.Int64(rc.Do("rpush", contactQ, string(taskJSON))) + } + if err != nil { + return errors.Wrapf(err, "error adding contact event") + } + + return queueContactTask(rc, models.OrgID(task.OrgID), contactID) +} + +// pushes a single contact task on our queue. Note this does not push the actual content of the task +// only that a task exists for the contact, addHandleTask should be used if the task has already been pushed +// off the contact specific queue. +func queueContactTask(rc redis.Conn, orgID models.OrgID, contactID models.ContactID) error { + // create our contact event + contactTask := &HandleEventTask{ContactID: contactID} + + // then add a handle task for that contact on our global handler queue + err := queue.AddTask(rc, queue.HandlerQueue, queue.HandleContactEvent, int(orgID), contactTask, queue.DefaultPriority) + if err != nil { + return errors.Wrapf(err, "error adding handle event task") + } + return nil +} + +// QueueTicketEvent queues a ticket event to be handled +func QueueTicketEvent(rc redis.Conn, contactID models.ContactID, evt *models.TicketEvent) error { + eventJSON := jsonx.MustMarshal(evt) + var task *queue.Task + + switch evt.EventType() { + case models.TicketEventTypeClosed: + task = &queue.Task{ + Type: TicketClosedEventType, + OrgID: int(evt.OrgID()), + Task: eventJSON, + QueuedOn: dates.Now(), + } + } + + return queueHandleTask(rc, contactID, task, false) +} diff --git a/core/tasks/handler/worker.go b/core/tasks/handler/worker.go index b4feb920c..c5924a7ce 100644 --- a/core/tasks/handler/worker.go +++ b/core/tasks/handler/worker.go @@ -20,6 +20,8 @@ import ( "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/runner" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/utils/dbutil" "github.com/nyaruka/mailroom/utils/locker" "github.com/nyaruka/null" "github.com/pkg/errors" @@ -35,63 +37,20 @@ const ( MsgEventType = "msg_event" ExpirationEventType = "expiration_event" TimeoutEventType = "timeout_event" + TicketClosedEventType = "ticket_closed" ) func init() { - mailroom.AddTaskFunction(queue.HandleContactEvent, handleEvent) + mailroom.AddTaskFunction(queue.HandleContactEvent, HandleEvent) } -// AddHandleTask adds a single task for the passed in contact. -func AddHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task) error { - return addHandleTask(rc, contactID, task, false) +func HandleEvent(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { + return handleContactEvent(ctx, rt, task) } -// addContactTask pushes a single contact task on our queue. Note this does not push the actual content of the task -// only that a task exists for the contact, addHandleTask should be used if the task has already been pushed -// off the contact specific queue. -func addContactTask(rc redis.Conn, orgID models.OrgID, contactID models.ContactID) error { - // create our contact event - contactTask := &HandleEventTask{ContactID: contactID} - - // then add a handle task for that contact on our global handler queue - err := queue.AddTask(rc, queue.HandlerQueue, queue.HandleContactEvent, int(orgID), contactTask, queue.DefaultPriority) - if err != nil { - return errors.Wrapf(err, "error adding handle event task") - } - return nil -} - -// addHandleTask adds a single task for the passed in contact. `front` specifies whether the task -// should be inserted in front of all other tasks for that contact -func addHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task, front bool) error { - // marshal our task - taskJSON, err := json.Marshal(task) - if err != nil { - return errors.Wrapf(err, "error marshalling contact task") - } - - // first push the event on our contact queue - contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, contactID) - if front { - _, err = redis.Int64(rc.Do("lpush", contactQ, string(taskJSON))) - - } else { - _, err = redis.Int64(rc.Do("rpush", contactQ, string(taskJSON))) - } - if err != nil { - return errors.Wrapf(err, "error adding contact event") - } - - return addContactTask(rc, models.OrgID(task.OrgID), contactID) -} - -func handleEvent(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { - return handleContactEvent(ctx, mr.DB, mr.RP, task) -} - -// handleContactEvent is called when an event comes in for a contact. to make sure we don't get into -// a situation of being off by one, this task ingests and handles all the events for a contact, one by one -func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *queue.Task) error { +// Called when an event comes in for a contact. To make sure we don't get into a situation of being off by one, +// this task ingests and handles all the events for a contact, one by one. +func handleContactEvent(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { ctx, cancel := context.WithTimeout(ctx, time.Minute*5) defer cancel() @@ -103,16 +62,16 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * // acquire the lock for this contact lockID := models.ContactLock(models.OrgID(task.OrgID), eventTask.ContactID) - lock, err := locker.GrabLock(rp, lockID, time.Minute*5, time.Second*10) + lock, err := locker.GrabLock(rt.RP, lockID, time.Minute*5, time.Second*10) if err != nil { return errors.Wrapf(err, "error acquiring lock for contact %d", eventTask.ContactID) } // we didn't get the lock within our timeout, skip and requeue for later if lock == "" { - rc := rp.Get() + rc := rt.RP.Get() defer rc.Close() - err = addContactTask(rc, models.OrgID(task.OrgID), eventTask.ContactID) + err = queueContactTask(rc, models.OrgID(task.OrgID), eventTask.ContactID) if err != nil { return errors.Wrapf(err, "error re-adding contact task after failing to get lock") } @@ -122,13 +81,13 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * }).Info("failed to get lock for contact, requeued and skipping") return nil } - defer locker.ReleaseLock(rp, lockID, lock) + defer locker.ReleaseLock(rt.RP, lockID, lock) // read all the events for this contact, one by one contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, eventTask.ContactID) for { // pop the next event off this contacts queue - rc := rp.Get() + rc := rt.RP.Get() event, err := redis.String(rc.Do("lpop", contactQ)) rc.Close() @@ -160,7 +119,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * if err != nil { return errors.Wrapf(err, "error unmarshalling stop event: %s", event) } - err = handleStopEvent(ctx, db, rp, evt) + err = handleStopEvent(ctx, rt.DB, rt.RP, evt) case NewConversationEventType, ReferralEventType, MOMissEventType, WelcomeMessageEventType: evt := &models.ChannelEvent{} @@ -168,7 +127,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * if err != nil { return errors.Wrapf(err, "error unmarshalling channel event: %s", event) } - _, err = HandleChannelEvent(ctx, db, rp, models.ChannelEventType(contactEvent.Type), evt, nil) + _, err = HandleChannelEvent(ctx, rt, models.ChannelEventType(contactEvent.Type), evt, nil) case MsgEventType: msg := &MsgEvent{} @@ -176,7 +135,15 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * if err != nil { return errors.Wrapf(err, "error unmarshalling msg event: %s", event) } - err = handleMsgEvent(ctx, db, rp, msg) + err = handleMsgEvent(ctx, rt, msg) + + case TicketClosedEventType: + evt := &models.TicketEvent{} + err = json.Unmarshal(contactEvent.Task, evt) + if err != nil { + return errors.Wrapf(err, "error unmarshalling ticket event: %s", event) + } + err = handleTicketEvent(ctx, rt, evt) case TimeoutEventType, ExpirationEventType: evt := &TimedEvent{} @@ -184,7 +151,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * if err != nil { return errors.Wrapf(err, "error unmarshalling timeout event: %s", event) } - err = handleTimedEvent(ctx, db, rp, contactEvent.Type, evt) + err = handleTimedEvent(ctx, rt, contactEvent.Type, evt) default: return errors.Errorf("unknown contact event type: %s", contactEvent.Type) @@ -204,10 +171,14 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * "event": event, }) + if qerr := dbutil.AsQueryError(err); qerr != nil { + log.WithFields(qerr.Fields()) + } + contactEvent.ErrorCount++ if contactEvent.ErrorCount < 3 { - rc := rp.Get() - retryErr := addHandleTask(rc, eventTask.ContactID, contactEvent, true) + rc := rt.RP.Get() + retryErr := queueHandleTask(rc, eventTask.ContactID, contactEvent, true) if retryErr != nil { logrus.WithError(retryErr).Error("error requeuing errored contact event") } @@ -223,7 +194,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task * } // handleTimedEvent is called for timeout events -func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType string, event *TimedEvent) error { +func handleTimedEvent(ctx context.Context, rt *runtime.Runtime, eventType string, event *TimedEvent) error { start := time.Now() log := logrus.WithFields(logrus.Fields{ "event_type": eventType, @@ -231,13 +202,13 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp "run_id": event.RunID, "session_id": event.SessionID, }) - oa, err := models.GetOrgAssets(ctx, db, event.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID) if err != nil { return errors.Wrapf(err, "error loading org") } // load our contact - contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID}) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID}) if err != nil { return errors.Wrapf(err, "error loading contact") } @@ -256,7 +227,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp } // get the active session for this contact - session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeMessaging, contact) + session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeMessaging, contact) if err != nil { return errors.Wrapf(err, "error loading active session for contact") } @@ -264,7 +235,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp // if we didn't find a session or it is another session then this flow got interrupted and this is a race, fail it if session == nil || session.ID() != event.SessionID { log.Error("expiring run with mismatched session, session for run no longer active, failing runs and session") - err = models.ExitSessions(ctx, db, []models.SessionID{event.SessionID}, models.ExitFailed, time.Now()) + err = models.ExitSessions(ctx, rt.DB, []models.SessionID{event.SessionID}, models.ExitFailed, time.Now()) if err != nil { return errors.Wrapf(err, "error failing expired runs for session that is no longer active") } @@ -277,7 +248,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp case ExpirationEventType: // check that our expiration is still the same - expiration, err := models.RunExpiration(ctx, db, event.RunID) + expiration, err := models.RunExpiration(ctx, rt.DB, event.RunID) if err != nil { return errors.Wrapf(err, "unable to load expiration for run") } @@ -313,7 +284,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp return errors.Errorf("unknown event type: %s", eventType) } - _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, nil) + _, err = runner.ResumeFlow(ctx, rt, oa, session, resume, nil) if err != nil { return errors.Wrapf(err, "error resuming flow for timeout") } @@ -323,8 +294,8 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp } // HandleChannelEvent is called for channel events -func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType models.ChannelEventType, event *models.ChannelEvent, conn *models.ChannelConnection) (*models.Session, error) { - oa, err := models.GetOrgAssets(ctx, db, event.OrgID()) +func HandleChannelEvent(ctx context.Context, rt *runtime.Runtime, eventType models.ChannelEventType, event *models.ChannelEvent, conn *models.ChannelConnection) (*models.Session, error) { + oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error loading org") } @@ -337,7 +308,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT } // load our contact - contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID()}) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID()}) if err != nil { return nil, errors.Wrapf(err, "error loading contact") } @@ -350,12 +321,24 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT modelContact := contacts[0] if models.ContactSeenEvents[eventType] { - err = modelContact.UpdateLastSeenOn(ctx, db, event.OccurredOn()) + err = modelContact.UpdateLastSeenOn(ctx, rt.DB, event.OccurredOn()) if err != nil { return nil, errors.Wrap(err, "error updating contact last_seen_on") } } + // make sure this URN is our highest priority (this is usually a noop) + err = modelContact.UpdatePreferredURN(ctx, rt.DB, oa, event.URNID(), channel) + if err != nil { + return nil, errors.Wrapf(err, "error changing primary URN") + } + + // build our flow contact + contact, err := modelContact.FlowContact(oa) + if err != nil { + return nil, errors.Wrapf(err, "error creating flow contact") + } + // do we have associated trigger? var trigger *models.Trigger @@ -371,7 +354,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT trigger = models.FindMatchingMissedCallTrigger(oa) case models.MOCallEventType: - trigger = models.FindMatchingMOCallTrigger(oa, modelContact) + trigger = models.FindMatchingIncomingCallTrigger(oa, contact) case models.WelcomeMessageEventType: trigger = nil @@ -380,20 +363,8 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT return nil, errors.Errorf("unknown channel event type: %s", eventType) } - // make sure this URN is our highest priority (this is usually a noop) - err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID(), channel) - if err != nil { - return nil, errors.Wrapf(err, "error changing primary URN") - } - - // build our flow contact - contact, err := modelContact.FlowContact(oa) - if err != nil { - return nil, errors.Wrapf(err, "error creating flow contact") - } - if event.IsNewContact() { - err = models.CalculateDynamicGroups(ctx, db, oa, contact) + err = models.CalculateDynamicGroups(ctx, rt.DB, oa, contact) if err != nil { return nil, errors.Wrapf(err, "unable to initialize new contact") } @@ -417,7 +388,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT // if this is an IVR flow, we need to trigger that start (which happens in a different queue) if flow.FlowType() == models.FlowTypeVoice && conn == nil { - err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil) + err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil) if err != nil { return nil, errors.Wrapf(err, "error while triggering ivr flow") } @@ -470,7 +441,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT } } - sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{flowTrigger}, hook, true) + sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{flowTrigger}, hook, true) if err != nil { return nil, errors.Wrapf(err, "error starting flow for contact") } @@ -507,27 +478,27 @@ func handleStopEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *St } // handleMsgEvent is called when a new message arrives from a contact -func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *MsgEvent) error { - oa, err := models.GetOrgAssets(ctx, db, event.OrgID) +func handleMsgEvent(ctx context.Context, rt *runtime.Runtime, event *MsgEvent) error { + oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID) if err != nil { return errors.Wrapf(err, "error loading org") } // allocate a topup for this message if org uses topups - topupID, err := models.AllocateTopups(ctx, db, rp, oa.Org(), 1) + topupID, err := models.AllocateTopups(ctx, rt.DB, rt.RP, oa.Org(), 1) if err != nil { return errors.Wrapf(err, "error allocating topup for incoming message") } // load our contact - contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID}) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID}) if err != nil { return errors.Wrapf(err, "error loading contact") } // contact has been deleted, ignore this message but mark it as handled if len(contacts) == 0 { - err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID) + err := models.UpdateMessage(ctx, rt.DB, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.MsgTypeInbox, topupID) if err != nil { return errors.Wrapf(err, "error updating message for deleted contact") } @@ -541,7 +512,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if we have URNs make sure the message URN is our highest priority (this is usually a noop) if len(modelContact.URNs()) > 0 { - err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID, channel) + err = modelContact.UpdatePreferredURN(ctx, rt.DB, oa, event.URNID, channel) if err != nil { return errors.Wrapf(err, "error changing primary URN") } @@ -555,7 +526,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if this channel is no longer active or this contact is blocked, ignore this message (mark it as handled) if channel == nil || modelContact.Status() == models.ContactStatusBlocked { - err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID) + err := models.UpdateMessage(ctx, rt.DB, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.MsgTypeInbox, topupID) if err != nil { return errors.Wrapf(err, "error marking blocked or nil channel message as handled") } @@ -565,7 +536,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // stopped contact? they are unstopped if they send us an incoming message newContact := event.NewContact if modelContact.Status() == models.ContactStatusStopped { - err := modelContact.Unstop(ctx, db) + err := modelContact.Unstop(ctx, rt.DB) if err != nil { return errors.Wrapf(err, "error unstopping contact") } @@ -575,26 +546,26 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if this is a new contact, we need to calculate dynamic groups and campaigns if newContact { - err = models.CalculateDynamicGroups(ctx, db, oa, contact) + err = models.CalculateDynamicGroups(ctx, rt.DB, oa, contact) if err != nil { return errors.Wrapf(err, "unable to initialize new contact") } } // look up any open tickets for this contact and forward this message to them - tickets, err := models.LoadOpenTicketsForContact(ctx, db, modelContact) + tickets, err := models.LoadOpenTicketsForContact(ctx, rt.DB, modelContact) if err != nil { return errors.Wrapf(err, "unable to look up open tickets for contact") } for _, ticket := range tickets { - ticket.ForwardIncoming(ctx, db, oa, event.MsgUUID, event.Text, event.Attachments) + ticket.ForwardIncoming(ctx, rt.DB, oa, event.MsgUUID, event.Text, event.Attachments) } // find any matching triggers trigger := models.FindMatchingMsgTrigger(oa, contact, event.Text) // get any active session for this contact - session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeMessaging, contact) + session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeMessaging, contact) if err != nil { return errors.Wrapf(err, "error loading active session for contact") } @@ -606,7 +577,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // flow this session is in is gone, interrupt our session and reset it if err == models.ErrNotFound { - err = models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitFailed, time.Now()) + err = models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitFailed, time.Now()) session = nil } @@ -619,19 +590,15 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg msgIn.SetExternalID(string(event.MsgExternalID)) msgIn.SetID(event.MsgID) - // build our hook to mark our message as handled - hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { + // build our hook to mark a flow message as handled + flowMsgHook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error { // set our incoming message event on our session if len(sessions) != 1 { return errors.Errorf("handle hook called with more than one session") } sessions[0].SetIncomingMsg(event.MsgID, event.MsgExternalID) - err = models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID) - if err != nil { - return errors.Wrapf(err, "error marking message as handled") - } - return nil + return markMsgHandled(ctx, tx, contact, msgIn, models.MsgTypeFlow, topupID, tickets) } // we found a trigger and their session is nil or doesn't ignore keywords @@ -647,9 +614,10 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg if flow != nil { // if this is an IVR flow, we need to trigger that start (which happens in a different queue) if flow.FlowType() == models.FlowTypeVoice { - err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, func(ctx context.Context, tx *sqlx.Tx) error { - return models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID) - }) + ivrMsgHook := func(ctx context.Context, tx *sqlx.Tx) error { + return markMsgHandled(ctx, tx, contact, msgIn, models.MsgTypeFlow, topupID, tickets) + } + err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, ivrMsgHook) if err != nil { return errors.Wrapf(err, "error while triggering ivr flow") } @@ -658,7 +626,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // otherwise build the trigger and start the flow directly trigger := triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact).Msg(msgIn).WithMatch(trigger.Match()).Build() - _, err = runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true) + _, err = runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, flowMsgHook, true) if err != nil { return errors.Wrapf(err, "error starting flow for contact") } @@ -669,7 +637,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg // if there is a session, resume it if session != nil && flow != nil { resume := resumes.NewMsg(oa.Env(), contact, msgIn) - _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook) + _, err = runner.ResumeFlow(ctx, rt, oa, session, resume, flowMsgHook) if err != nil { return errors.Wrapf(err, "error resuming flow for contact") } @@ -677,14 +645,113 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg } // this message didn't trigger and new sessions or resume any existing ones, so handle as inbox - err = handleAsInbox(ctx, db, rp, oa, contact, msgIn, topupID) + err = handleAsInbox(ctx, rt.DB, rt.RP, oa, contact, msgIn, topupID, tickets) if err != nil { return errors.Wrapf(err, "error handling inbox message") } return nil } -func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, contact *flows.Contact, msg *flows.MsgIn, topupID models.TopupID) error { +func handleTicketEvent(ctx context.Context, rt *runtime.Runtime, event *models.TicketEvent) error { + oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID()) + if err != nil { + return errors.Wrapf(err, "error loading org") + } + + // load our ticket + tickets, err := models.LoadTickets(ctx, rt.DB, []models.TicketID{event.TicketID()}) + if err != nil { + return errors.Wrapf(err, "error loading ticket") + } + // ticket has been deleted ignore this event + if len(tickets) == 0 { + return nil + } + + modelTicket := tickets[0] + + // load our contact + contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{modelTicket.ContactID()}) + if err != nil { + return errors.Wrapf(err, "error loading contact") + } + + // contact has been deleted ignore this event + if len(contacts) == 0 { + return nil + } + + modelContact := contacts[0] + + // build our flow contact + contact, err := modelContact.FlowContact(oa) + if err != nil { + return errors.Wrapf(err, "error creating flow contact") + } + + // do we have associated trigger? + var trigger *models.Trigger + + switch event.EventType() { + case models.TicketEventTypeClosed: + trigger = models.FindMatchingTicketClosedTrigger(oa, contact) + default: + return errors.Errorf("unknown ticket event type: %s", event.EventType()) + } + + // no trigger, noop, move on + if trigger == nil { + logrus.WithField("ticket_id", event.TicketID).WithField("event_type", event.EventType()).Info("ignoring ticket event, no trigger found") + return nil + } + + // load our flow + flow, err := oa.FlowByID(trigger.FlowID()) + if err == models.ErrNotFound { + return nil + } + if err != nil { + return errors.Wrapf(err, "error loading flow for trigger") + } + + // if this is an IVR flow, we need to trigger that start (which happens in a different queue) + if flow.FlowType() == models.FlowTypeVoice { + err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil) + if err != nil { + return errors.Wrapf(err, "error while triggering ivr flow") + } + return nil + } + + // build our flow ticket + ticket, err := tickets[0].FlowTicket(oa) + if err != nil { + return errors.Wrapf(err, "error creating flow contact") + } + + // build our flow trigger + var flowTrigger flows.Trigger + + switch event.EventType() { + case models.TicketEventTypeClosed: + flowTrigger = triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact). + Ticket(ticket, triggers.TicketEventTypeClosed). + Build() + default: + return errors.Errorf("unknown ticket event type: %s", event.EventType()) + } + + _, err = runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{flowTrigger}, nil, true) + if err != nil { + return errors.Wrapf(err, "error starting flow for contact") + } + return nil +} + +// handles a message as an inbox message +func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, contact *flows.Contact, msg *flows.MsgIn, topupID models.TopupID, tickets []*models.Ticket) error { + // usually last_seen_on is updated by handling the msg_received event in the engine sprint, but since this is an inbox + // message we manually create that event and handle it msgEvent := events.NewMsgReceived(msg) contact.SetLastSeenOn(msgEvent.CreatedOn()) contactEvents := map[*flows.Contact][]flows.Event{contact: {msgEvent}} @@ -694,11 +761,23 @@ func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models. return errors.Wrap(err, "error handling inbox message events") } - err = models.UpdateMessage(ctx, db, msg.ID(), models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, topupID) + return markMsgHandled(ctx, db, contact, msg, models.MsgTypeInbox, topupID, tickets) +} + +// utility to mark as message as handled and update any open contact tickets +func markMsgHandled(ctx context.Context, db models.Queryer, contact *flows.Contact, msg *flows.MsgIn, msgType models.MsgType, topupID models.TopupID, tickets []*models.Ticket) error { + err := models.UpdateMessage(ctx, db, msg.ID(), models.MsgStatusHandled, models.VisibilityVisible, msgType, topupID) if err != nil { return errors.Wrapf(err, "error marking message as handled") } + if len(tickets) > 0 { + err = models.UpdateTicketLastActivity(ctx, db, tickets) + if err != nil { + return errors.Wrapf(err, "error updating last activity for open tickets") + } + } + return nil } @@ -735,7 +814,7 @@ type StopEvent struct { OccurredOn time.Time `json:"occurred_on"` } -// NewTimeoutEvent creates a new event task for the passed in timeout event +// creates a new event task for the passed in timeout event func newTimedTask(eventType string, orgID models.OrgID, contactID models.ContactID, sessionID models.SessionID, runID models.FlowRunID, eventTime time.Time) *queue.Task { event := &TimedEvent{ OrgID: orgID, diff --git a/core/tasks/interrupts/interrupt_sessions.go b/core/tasks/interrupts/interrupt_sessions.go index e1c049b75..dd25a4423 100644 --- a/core/tasks/interrupts/interrupt_sessions.go +++ b/core/tasks/interrupts/interrupt_sessions.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks" + "github.com/nyaruka/mailroom/runtime" "github.com/lib/pq" "github.com/pkg/errors" @@ -63,8 +63,8 @@ func (t *InterruptSessionsTask) Timeout() time.Duration { return time.Hour } -func (t *InterruptSessionsTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error { - db := mr.DB +func (t *InterruptSessionsTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error { + db := rt.DB sessionIDs := make(map[models.SessionID]bool) for _, sid := range t.SessionIDs { diff --git a/core/tasks/interrupts/interrupt_sessions_test.go b/core/tasks/interrupts/interrupt_sessions_test.go index 6d271f3c5..c45733e72 100644 --- a/core/tasks/interrupts/interrupt_sessions_test.go +++ b/core/tasks/interrupts/interrupt_sessions_test.go @@ -4,21 +4,16 @@ import ( "testing" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" ) func TestInterrupts(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil} + ctx, rt, db, _ := testsuite.Reset() insertConnection := func(orgID models.OrgID, channelID models.ChannelID, contactID models.ContactID, urnID models.URNID) models.ConnectionID { var connectionID models.ConnectionID @@ -57,23 +52,23 @@ func TestInterrupts(t *testing.T) { [5]string{"W", "W", "W", "W", "I"}, }, { - []models.ContactID{models.CathyID}, nil, nil, + []models.ContactID{testdata.Cathy.ID}, nil, nil, [5]string{"I", "W", "W", "W", "I"}, }, { - []models.ContactID{models.CathyID, models.GeorgeID}, nil, nil, + []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, nil, nil, [5]string{"I", "I", "W", "W", "I"}, }, { - nil, []models.ChannelID{models.TwilioChannelID}, nil, + nil, []models.ChannelID{testdata.TwilioChannel.ID}, nil, [5]string{"W", "W", "I", "W", "I"}, }, { - nil, nil, []models.FlowID{models.PickNumberFlowID}, + nil, nil, []models.FlowID{testdata.PickANumber.ID}, [5]string{"W", "W", "W", "I", "I"}, }, { - []models.ContactID{models.CathyID, models.GeorgeID}, []models.ChannelID{models.TwilioChannelID}, []models.FlowID{models.PickNumberFlowID}, + []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, []models.ChannelID{testdata.TwilioChannel.ID}, []models.FlowID{testdata.PickANumber.ID}, [5]string{"I", "I", "I", "I", "I"}, }, } @@ -83,18 +78,18 @@ func TestInterrupts(t *testing.T) { db.MustExec(`UPDATE flows_flowsession SET status='C', ended_on=NOW() WHERE status = 'W';`) // twilio connection - twilioConnectionID := insertConnection(models.Org1, models.TwilioChannelID, models.AlexandriaID, models.AlexandriaURNID) + twilioConnectionID := insertConnection(testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Alexandria.ID, testdata.Alexandria.URNID) sessionIDs := make([]models.SessionID, 5) // insert our dummy contact sessions - sessionIDs[0] = insertSession(models.Org1, models.CathyID, models.NilConnectionID, models.FavoritesFlowID) - sessionIDs[1] = insertSession(models.Org1, models.GeorgeID, models.NilConnectionID, models.FavoritesFlowID) - sessionIDs[2] = insertSession(models.Org1, models.AlexandriaID, twilioConnectionID, models.FavoritesFlowID) - sessionIDs[3] = insertSession(models.Org1, models.BobID, models.NilConnectionID, models.PickNumberFlowID) + sessionIDs[0] = insertSession(testdata.Org1.ID, testdata.Cathy.ID, models.NilConnectionID, testdata.Favorites.ID) + sessionIDs[1] = insertSession(testdata.Org1.ID, testdata.George.ID, models.NilConnectionID, testdata.Favorites.ID) + sessionIDs[2] = insertSession(testdata.Org1.ID, testdata.Alexandria.ID, twilioConnectionID, testdata.Favorites.ID) + sessionIDs[3] = insertSession(testdata.Org1.ID, testdata.Bob.ID, models.NilConnectionID, testdata.PickANumber.ID) // a session we always end explicitly - sessionIDs[4] = insertSession(models.Org1, models.BobID, models.NilConnectionID, models.FavoritesFlowID) + sessionIDs[4] = insertSession(testdata.Org1.ID, testdata.Bob.ID, models.NilConnectionID, testdata.Favorites.ID) // create our task task := &InterruptSessionsTask{ @@ -105,7 +100,7 @@ func TestInterrupts(t *testing.T) { } // execute it - err := task.Perform(ctx, mr, models.Org1) + err := task.Perform(ctx, rt, testdata.Org1.ID) assert.NoError(t, err) // check session statuses are as expected @@ -116,12 +111,8 @@ func TestInterrupts(t *testing.T) { assert.Equal(t, tc.StatusesAfter[j], status, "%d: status mismatch for session #%d", i, j) // check for runs with a different status to the session - testsuite.AssertQueryCount( - t, db, - `SELECT count(*) FROM flows_flowrun WHERE session_id = $1 AND status != $2`, - []interface{}{sID, tc.StatusesAfter[j]}, 0, - "%d: unexpected un-interrupted runs for session #%d", i, j, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE session_id = $1 AND status != $2`, sID, tc.StatusesAfter[j]). + Returns(0, "%d: unexpected un-interrupted runs for session #%d", i, j) } } } diff --git a/core/tasks/ivr/cron.go b/core/tasks/ivr/cron.go index 26a23a089..181d51cb4 100644 --- a/core/tasks/ivr/cron.go +++ b/core/tasks/ivr/cron.go @@ -2,6 +2,7 @@ package ivr import ( "context" + "sync" "time" "github.com/nyaruka/goflow/flows" @@ -9,6 +10,7 @@ import ( "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/ivr" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/gomodule/redigo/redis" @@ -27,20 +29,20 @@ func init() { } // StartIVRCron starts our cron job of retrying errored calls -func StartIVRCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, retryIVRLock, time.Minute, +func StartIVRCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, retryIVRLock, time.Minute, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return retryCalls(ctx, mr.Config, mr.DB, mr.RP, retryIVRLock, lockValue) + return retryCalls(ctx, rt.Config, rt.DB, rt.RP, retryIVRLock, lockValue) }, ) - cron.StartCron(mr.Quit, mr.RP, expireIVRLock, time.Minute, + cron.StartCron(quit, rt.RP, expireIVRLock, time.Minute, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return expireCalls(ctx, mr.Config, mr.DB, mr.RP, expireIVRLock, lockValue) + return expireCalls(ctx, rt.Config, rt.DB, rt.RP, expireIVRLock, lockValue) }, ) diff --git a/core/tasks/ivr/cron_test.go b/core/tasks/ivr/cron_test.go index c37a5b62e..9ff686f63 100644 --- a/core/tasks/ivr/cron_test.go +++ b/core/tasks/ivr/cron_test.go @@ -10,13 +10,13 @@ import ( "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/tasks/starts" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestRetries(t *testing.T) { - ctx, db, rp := testsuite.Reset() - models.FlushCache() - + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() @@ -24,11 +24,11 @@ func TestRetries(t *testing.T) { ivr.RegisterClientType(models.ChannelType("ZZ"), newMockClient) // update our twilio channel to be of type 'ZZ' and set max_concurrent_events to 1 - db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, testdata.TwilioChannel.ID) // create a flow start for cathy - start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive). - WithContactIDs([]models.ContactID{models.CathyID}) + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive). + WithContactIDs([]models.ContactID{testdata.Cathy.ID}) // call our master starter err := starts.CreateFlowBatches(ctx, db, rp, nil, start) @@ -45,7 +45,8 @@ func TestRetries(t *testing.T) { client.callID = ivr.CallID("call1") err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1) // change our call to be errored instead of wired db.MustExec(`UPDATE channels_channelconnection SET status = 'E', next_attempt = NOW() WHERE external_id = 'call1';`) @@ -55,16 +56,18 @@ func TestRetries(t *testing.T) { assert.NoError(t, err) // should now be in wired state - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1) // back to retry and make the channel inactive db.MustExec(`UPDATE channels_channelconnection SET status = 'E', next_attempt = NOW() WHERE external_id = 'call1';`) - db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID) models.FlushCache() err = retryCalls(ctx, config.Mailroom, db, rp, "retry_test", "retry_test") assert.NoError(t, err) // this time should be failed - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusFailed, "call1"}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.Cathy.ID, models.ConnectionStatusFailed, "call1").Returns(1) } diff --git a/core/tasks/ivr/worker.go b/core/tasks/ivr/worker.go index c4a00858b..603f9f366 100644 --- a/core/tasks/ivr/worker.go +++ b/core/tasks/ivr/worker.go @@ -12,6 +12,7 @@ import ( "github.com/nyaruka/mailroom/core/ivr" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -20,7 +21,7 @@ func init() { mailroom.AddTaskFunction(queue.StartIVRFlowBatch, handleFlowStartTask) } -func handleFlowStartTask(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { +func handleFlowStartTask(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { // decode our task body if task.Type != queue.StartIVRFlowBatch { return errors.Errorf("unknown event type passed to ivr worker: %s", task.Type) @@ -31,7 +32,7 @@ func handleFlowStartTask(ctx context.Context, mr *mailroom.Mailroom, task *queue return errors.Wrapf(err, "error unmarshalling flow start batch: %s", string(task.Task)) } - return HandleFlowStartBatch(ctx, mr.Config, mr.DB, mr.RP, batch) + return HandleFlowStartBatch(ctx, rt.Config, rt.DB, rt.RP, batch) } // HandleFlowStartBatch starts a batch of contacts in an IVR flow diff --git a/core/tasks/ivr/worker_test.go b/core/tasks/ivr/worker_test.go index 6a4151591..6ae035b59 100644 --- a/core/tasks/ivr/worker_test.go +++ b/core/tasks/ivr/worker_test.go @@ -6,8 +6,6 @@ import ( "net/http" "testing" - "github.com/gomodule/redigo/redis" - "github.com/jmoiron/sqlx" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/mailroom/config" @@ -15,16 +13,17 @@ import ( "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/tasks/starts" - "github.com/pkg/errors" - "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + + "github.com/gomodule/redigo/redis" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) func TestIVR(t *testing.T) { - ctx, db, rp := testsuite.Reset() - models.FlushCache() - + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() @@ -32,11 +31,11 @@ func TestIVR(t *testing.T) { ivr.RegisterClientType(models.ChannelType("ZZ"), newMockClient) // update our twilio channel to be of type 'ZZ' and set max_concurrent_events to 1 - db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, testdata.TwilioChannel.ID) // create a flow start for cathy - start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive). - WithContactIDs([]models.ContactID{models.CathyID}) + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive). + WithContactIDs([]models.ContactID{testdata.Cathy.ID}) // call our master starter err := starts.CreateFlowBatches(ctx, db, rp, nil, start) @@ -52,20 +51,20 @@ func TestIVR(t *testing.T) { client.callError = errors.Errorf("unable to create call") err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2`, []interface{}{models.CathyID, models.ConnectionStatusFailed}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2`, testdata.Cathy.ID, models.ConnectionStatusFailed).Returns(1) client.callError = nil client.callID = ivr.CallID("call1") err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1) // trying again should put us in a throttled state (queued) client.callError = nil client.callID = ivr.CallID("call1") err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND next_attempt IS NOT NULL;`, []interface{}{models.CathyID, models.ConnectionStatusQueued}, 1) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND next_attempt IS NOT NULL;`, testdata.Cathy.ID, models.ConnectionStatusQueued).Returns(1) } var client = &MockClient{} diff --git a/core/tasks/schedules/cron.go b/core/tasks/schedules/cron.go index afad0527f..5f5c30304 100644 --- a/core/tasks/schedules/cron.go +++ b/core/tasks/schedules/cron.go @@ -2,6 +2,7 @@ package schedules import ( "context" + "sync" "time" "github.com/gomodule/redigo/redis" @@ -9,6 +10,7 @@ import ( "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -23,15 +25,15 @@ func init() { } // StartCheckSchedules starts our cron job of firing schedules every minute -func StartCheckSchedules(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, scheduleLock, time.Minute*1, +func StartCheckSchedules(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, scheduleLock, time.Minute*1, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() // we sleep 1 second since we fire right on the minute and want to make sure to fire // things that are schedules right at the minute as well (and DB time may be slightly drifted) time.Sleep(time.Second * 1) - return checkSchedules(ctx, mr.DB, mr.RP, lockName, lockValue) + return checkSchedules(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) return nil diff --git a/core/tasks/schedules/cron_test.go b/core/tasks/schedules/cron_test.go index 064c4e317..1cda0267c 100644 --- a/core/tasks/schedules/cron_test.go +++ b/core/tasks/schedules/cron_test.go @@ -3,47 +3,36 @@ package schedules import ( "testing" + "github.com/nyaruka/goflow/envs" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" ) func TestCheckSchedules(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - rp := testsuite.RP() - + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() // add a schedule and tie a broadcast to it - db := testsuite.DB() var s1 models.ScheduleID err := db.Get( &s1, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '1 DAY', 1, 1, $1) RETURNING id`, - models.Org1, - ) - assert.NoError(t, err) - var b1 models.BroadcastID - err = db.Get( - &b1, - `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id, schedule_id) - VALUES('P', hstore(ARRAY['eng','Test message', 'fra', 'Un Message']), 'eng', TRUE, NOW(), NOW(), TRUE, 1, 1, $1, $2) RETURNING id`, - models.Org1, s1, + testdata.Org1.ID, ) assert.NoError(t, err) - // add a few contacts to the broadcast - db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2),($1, $3)`, b1, models.CathyID, models.GeorgeID) - - // and a group - db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, b1, models.DoctorsGroupID) + b1 := testdata.InsertBroadcast(db, testdata.Org1, "eng", map[envs.Language]string{"eng": "Test message", "fra": "Un Message"}, s1, + []*testdata.Contact{testdata.Cathy, testdata.George}, []*testdata.Group{testdata.DoctorsGroup}, + ) - // and a URN - db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, models.CathyURNID) + // add a URN + db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, testdata.Cathy.URNID) // add another and tie a trigger to it var s2 models.ScheduleID @@ -51,7 +40,7 @@ func TestCheckSchedules(t *testing.T) { &s2, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '2 DAY', 1, 1, $1) RETURNING id`, - models.Org1, + testdata.Org1.ID, ) assert.NoError(t, err) var t1 models.TriggerID @@ -59,22 +48,22 @@ func TestCheckSchedules(t *testing.T) { &t1, `INSERT INTO triggers_trigger(is_active, created_on, modified_on, is_archived, trigger_type, created_by_id, modified_by_id, org_id, flow_id, schedule_id) VALUES(TRUE, NOW(), NOW(), FALSE, 'S', 1, 1, $1, $2, $3) RETURNING id`, - models.Org1, models.FavoritesFlowID, s2, + testdata.Org1.ID, testdata.Favorites.ID, s2, ) assert.NoError(t, err) // add a few contacts to the trigger - db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, models.CathyID, models.GeorgeID) + db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, testdata.Cathy.ID, testdata.George.ID) // and a group - db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, models.DoctorsGroupID) + db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, testdata.DoctorsGroup.ID) var s3 models.ScheduleID err = db.Get( &s3, `INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id) VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '3 DAY', 1, 1, $1) RETURNING id`, - models.Org1, + testdata.Org1.ID, ) assert.NoError(t, err) @@ -83,29 +72,23 @@ func TestCheckSchedules(t *testing.T) { assert.NoError(t, err) // should have one flow start added to our DB ready to go - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`, - []interface{}{models.FavoritesFlowID}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P'`, testdata.Favorites.ID).Returns(1) // with the right count of groups and contacts - testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowstart_contacts WHERE flowstart_id = 1`, nil, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowstart_groups WHERE flowstart_id = 1`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowstart_contacts WHERE flowstart_id = 1`).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowstart_groups WHERE flowstart_id = 1`).Returns(1) // and one broadcast as well - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_broadcast WHERE org_id = $1 AND parent_id = $2 AND text = hstore(ARRAY['eng','Test message', 'fra', 'Un Message']) - AND status = 'Q' AND base_language = 'eng';`, - []interface{}{models.Org1, b1}, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_broadcast WHERE org_id = $1 AND parent_id = $2 + AND text = hstore(ARRAY['eng','Test message', 'fra', 'Un Message']) AND status = 'Q' AND base_language = 'eng'`, testdata.Org1.ID, b1).Returns(1) // with the right count of groups, contacts, urns - testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_urns WHERE broadcast_id = 2`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_contacts WHERE broadcast_id = 2`, nil, 2) - testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_groups WHERE broadcast_id = 2`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_urns WHERE broadcast_id = 2`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_contacts WHERE broadcast_id = 2`).Returns(2) + testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_groups WHERE broadcast_id = 2`).Returns(1) // we shouldn't have any pending schedules since there were all one time fires, but all should have last fire - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM schedules_schedule WHERE next_fire IS NULL and last_fire < NOW();`, - nil, 3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM schedules_schedule WHERE next_fire IS NULL and last_fire < NOW();`).Returns(3) // check the tasks created task, err := queue.PopNextTask(rc, queue.BatchQueue) diff --git a/core/tasks/starts/worker.go b/core/tasks/starts/worker.go index 4d4f19d9f..8d09d5e95 100644 --- a/core/tasks/starts/worker.go +++ b/core/tasks/starts/worker.go @@ -11,6 +11,7 @@ import ( "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/runner" + "github.com/nyaruka/mailroom/runtime" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" @@ -30,7 +31,7 @@ func init() { } // handleFlowStart creates all the batches of contacts to start in a flow -func handleFlowStart(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { +func handleFlowStart(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { ctx, cancel := context.WithTimeout(ctx, time.Minute*60) defer cancel() @@ -44,9 +45,9 @@ func handleFlowStart(ctx context.Context, mr *mailroom.Mailroom, task *queue.Tas return errors.Wrapf(err, "error unmarshalling flow start task: %s", string(task.Task)) } - err = CreateFlowBatches(ctx, mr.DB, mr.RP, mr.ElasticClient, startTask) + err = CreateFlowBatches(ctx, rt.DB, rt.RP, rt.ES, startTask) if err != nil { - models.MarkStartFailed(ctx, mr.DB, startTask.ID()) + models.MarkStartFailed(ctx, rt.DB, startTask.ID()) // if error is user created query error.. don't escalate error to sentry isQueryError, _ := contactql.IsQueryError(err) @@ -97,11 +98,11 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela createdContactIDs = append(createdContactIDs, contact.ID()) } - // now add all the ids for our groups + // if we have inclusion groups, add all the contact ids from those groups if len(start.GroupIDs()) > 0 { rows, err := db.QueryxContext(ctx, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = ANY($1)`, pq.Array(start.GroupIDs())) if err != nil { - return errors.Wrapf(err, "error selecting contacts for groups") + return errors.Wrapf(err, "error querying contacts from inclusion groups") } defer rows.Close() @@ -115,7 +116,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela } } - // finally, if we have a query, add the contacts that match that as well + // if we have a query, add the contacts that match that as well if start.Query() != "" { matches, err := models.ContactIDsForQuery(ctx, ec, oa, start.Query()) if err != nil { @@ -127,6 +128,24 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela } } + // finally, if we have exclusion groups, remove all the contact ids from those groups + if len(start.ExcludeGroupIDs()) > 0 { + rows, err := db.QueryxContext(ctx, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = ANY($1)`, pq.Array(start.ExcludeGroupIDs())) + if err != nil { + return errors.Wrapf(err, "error querying contacts from exclusion groups") + } + defer rows.Close() + + var contactID models.ContactID + for rows.Next() { + err := rows.Scan(&contactID) + if err != nil { + return errors.Wrapf(err, "error scanning contact id") + } + delete(contactIDs, contactID) + } + } + rc := rp.Get() defer rc.Close() @@ -185,7 +204,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela } // HandleFlowStartBatch starts a batch of contacts in a flow -func handleFlowStartBatch(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error { +func handleFlowStartBatch(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error { ctx, cancel := context.WithTimeout(ctx, time.Minute*15) defer cancel() @@ -200,7 +219,7 @@ func handleFlowStartBatch(ctx context.Context, mr *mailroom.Mailroom, task *queu } // start these contacts in our flow - _, err = runner.StartFlowBatch(ctx, mr.DB, mr.RP, startBatch) + _, err = runner.StartFlowBatch(ctx, rt, startBatch) if err != nil { return errors.Wrapf(err, "error starting flow batch: %s", string(task.Task)) } diff --git a/core/tasks/starts/worker_test.go b/core/tasks/starts/worker_test.go index 3c5500ce3..a60c82aab 100644 --- a/core/tasks/starts/worker_test.go +++ b/core/tasks/starts/worker_test.go @@ -6,13 +6,12 @@ import ( "testing" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/mailroom" - "github.com/nyaruka/mailroom/config" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/runner" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/olivere/elastic/v7" "github.com/stretchr/testify/assert" @@ -20,11 +19,8 @@ import ( ) func TestStarts(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - rp := testsuite.RP() - db := testsuite.DB() - rc := testsuite.RC() + ctx, rt, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() mes := testsuite.NewMockElasticServer() @@ -36,22 +32,22 @@ func TestStarts(t *testing.T) { elastic.SetSniff(false), ) require.NoError(t, err) - - mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: rp, ElasticClient: es} + rt.ES = es // convert our single message flow to an actual background flow that shouldn't interrupt - db.MustExec(`UPDATE flows_flow SET flow_type = 'B' WHERE id = $1`, models.SingleMessageFlowID) + db.MustExec(`UPDATE flows_flow SET flow_type = 'B' WHERE id = $1`, testdata.SingleMessage.ID) // insert a flow run for one of our contacts // TODO: can be replaced with a normal flow start of another flow once we support flows with waits db.MustExec( `INSERT INTO flows_flowrun(uuid, status, is_active, created_on, modified_on, responded, contact_id, flow_id, org_id) - VALUES($1, 'W', TRUE, now(), now(), FALSE, $2, $3, 1);`, uuids.New(), models.GeorgeID, models.FavoritesFlowID) + VALUES($1, 'W', TRUE, now(), now(), FALSE, $2, $3, 1);`, uuids.New(), testdata.George.ID, testdata.Favorites.ID) tcs := []struct { label string flowID models.FlowID groupIDs []models.GroupID + excludeGroupIDs []models.GroupID contactIDs []models.ContactID createContact bool query string @@ -67,41 +63,41 @@ func TestStarts(t *testing.T) { }{ { label: "Empty flow start", - flowID: models.FavoritesFlowID, + flowID: testdata.Favorites.ID, queue: queue.BatchQueue, expectedContactCount: 0, expectedBatchCount: 0, expectedTotalCount: 0, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 1, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 1, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Single group", - flowID: models.FavoritesFlowID, - groupIDs: []models.GroupID{models.DoctorsGroupID}, + flowID: testdata.Favorites.ID, + groupIDs: []models.GroupID{testdata.DoctorsGroup.ID}, queue: queue.BatchQueue, expectedContactCount: 121, expectedBatchCount: 2, expectedTotalCount: 121, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Group and Contact (but all already active)", - flowID: models.FavoritesFlowID, - groupIDs: []models.GroupID{models.DoctorsGroupID}, - contactIDs: []models.ContactID{models.CathyID}, + flowID: testdata.Favorites.ID, + groupIDs: []models.GroupID{testdata.DoctorsGroup.ID}, + contactIDs: []models.ContactID{testdata.Cathy.ID}, queue: queue.BatchQueue, expectedContactCount: 121, expectedBatchCount: 2, expectedTotalCount: 0, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Contact restart", - flowID: models.FavoritesFlowID, - contactIDs: []models.ContactID{models.CathyID}, + flowID: testdata.Favorites.ID, + contactIDs: []models.ContactID{testdata.Cathy.ID}, restartParticipants: true, includeActive: true, queue: queue.HandlerQueue, @@ -109,47 +105,47 @@ func TestStarts(t *testing.T) { expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Previous group and one new contact", - flowID: models.FavoritesFlowID, - groupIDs: []models.GroupID{models.DoctorsGroupID}, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.Favorites.ID, + groupIDs: []models.GroupID{testdata.DoctorsGroup.ID}, + contactIDs: []models.ContactID{testdata.Bob.ID}, queue: queue.BatchQueue, expectedContactCount: 122, expectedBatchCount: 2, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Single contact, no restart", - flowID: models.FavoritesFlowID, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.Favorites.ID, + contactIDs: []models.ContactID{testdata.Bob.ID}, queue: queue.HandlerQueue, expectedContactCount: 1, expectedBatchCount: 1, expectedTotalCount: 0, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Single contact, include active, but no restart", - flowID: models.FavoritesFlowID, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.Favorites.ID, + contactIDs: []models.ContactID{testdata.Bob.ID}, includeActive: true, queue: queue.HandlerQueue, expectedContactCount: 1, expectedBatchCount: 1, expectedTotalCount: 0, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Single contact, include active and restart", - flowID: models.FavoritesFlowID, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.Favorites.ID, + contactIDs: []models.ContactID{testdata.Bob.ID}, restartParticipants: true, includeActive: true, queue: queue.HandlerQueue, @@ -157,11 +153,11 @@ func TestStarts(t *testing.T) { expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Query start", - flowID: models.FavoritesFlowID, + flowID: testdata.Favorites.ID, query: "bob", queryResponse: fmt.Sprintf(`{ "_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAbgc0WS1hqbHlfb01SM2lLTWJRMnVOSVZDdw==", @@ -189,7 +185,7 @@ func TestStarts(t *testing.T) { } ] } - }`, models.BobID), + }`, testdata.Bob.ID), restartParticipants: true, includeActive: true, queue: queue.HandlerQueue, @@ -197,11 +193,11 @@ func TestStarts(t *testing.T) { expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Query start with invalid query", - flowID: models.FavoritesFlowID, + flowID: testdata.Favorites.ID, query: "xyz = 45", restartParticipants: true, includeActive: true, @@ -210,42 +206,56 @@ func TestStarts(t *testing.T) { expectedBatchCount: 0, expectedTotalCount: 0, expectedStatus: models.StartStatusFailed, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "New Contact", - flowID: models.FavoritesFlowID, + flowID: testdata.Favorites.ID, createContact: true, queue: queue.HandlerQueue, expectedContactCount: 1, expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 124, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 124, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, { label: "Other messaging flow", - flowID: models.PickNumberFlowID, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.PickANumber.ID, + contactIDs: []models.ContactID{testdata.Bob.ID}, includeActive: true, queue: queue.HandlerQueue, expectedContactCount: 1, expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 1, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 1, testdata.SingleMessage.ID: 0}, }, { label: "Background flow", - flowID: models.SingleMessageFlowID, - contactIDs: []models.ContactID{models.BobID}, + flowID: testdata.SingleMessage.ID, + contactIDs: []models.ContactID{testdata.Bob.ID}, + includeActive: true, + queue: queue.HandlerQueue, + expectedContactCount: 1, + expectedBatchCount: 1, + expectedTotalCount: 1, + expectedStatus: models.StartStatusComplete, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 1, testdata.SingleMessage.ID: 0}, + }, + { + label: "Exclude group", + flowID: testdata.Favorites.ID, + contactIDs: []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, + excludeGroupIDs: []models.GroupID{testdata.DoctorsGroup.ID}, // should exclude Cathy + restartParticipants: true, includeActive: true, queue: queue.HandlerQueue, expectedContactCount: 1, expectedBatchCount: 1, expectedTotalCount: 1, expectedStatus: models.StartStatusComplete, - expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 1, models.SingleMessageFlowID: 0}, + expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 124, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0}, }, } @@ -253,8 +263,9 @@ func TestStarts(t *testing.T) { mes.NextResponse = tc.queryResponse // handle our start task - start := models.NewFlowStart(models.Org1, models.StartTypeManual, models.FlowTypeMessaging, tc.flowID, tc.restartParticipants, tc.includeActive). + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeManual, models.FlowTypeMessaging, tc.flowID, tc.restartParticipants, tc.includeActive). WithGroupIDs(tc.groupIDs). + WithExcludeGroupIDs(tc.excludeGroupIDs). WithContactIDs(tc.contactIDs). WithQuery(tc.query). WithCreateContact(tc.createContact) @@ -265,7 +276,7 @@ func TestStarts(t *testing.T) { startJSON, err := json.Marshal(start) require.NoError(t, err) - err = handleFlowStart(ctx, mr, &queue.Task{Type: queue.StartFlow, Task: startJSON}) + err = handleFlowStart(ctx, rt, &queue.Task{Type: queue.StartFlow, Task: startJSON}) assert.NoError(t, err) // pop all our tasks and execute them @@ -284,7 +295,7 @@ func TestStarts(t *testing.T) { err = json.Unmarshal(task.Task, batch) assert.NoError(t, err) - _, err = runner.StartFlowBatch(ctx, db, rp, batch) + _, err = runner.StartFlowBatch(ctx, rt, batch) assert.NoError(t, err) } @@ -292,22 +303,20 @@ func TestStarts(t *testing.T) { assert.Equal(t, tc.expectedBatchCount, count, "unexpected batch count in '%s'", tc.label) // assert our count of total flow runs created - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE flow_id = $1 AND start_id = $2`, - []interface{}{tc.flowID, start.ID()}, tc.expectedTotalCount, "unexpected total run count in '%s'", tc.label) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE flow_id = $1 AND start_id = $2`, tc.flowID, start.ID()).Returns(tc.expectedTotalCount, "unexpected total run count in '%s'", tc.label) // assert final status - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where status = $2 AND id = $1`, - []interface{}{start.ID(), tc.expectedStatus}, 1, "status mismatch in '%s'", tc.label) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart where status = $2 AND id = $1`, start.ID(), tc.expectedStatus).Returns(1, "status mismatch in '%s'", tc.label) // assert final contact count if tc.expectedStatus != models.StartStatusFailed { - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where contact_count = $2 AND id = $1`, + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart where contact_count = $2 AND id = $1`, []interface{}{start.ID(), tc.expectedContactCount}, 1, "contact count mismatch in '%s'", tc.label) } // assert count of active runs by flow for flowID, activeRuns := range tc.expectedActiveRuns { - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE status = 'W' AND flow_id = $1`, []interface{}{flowID}, activeRuns, "active runs mismatch for flow #%d in '%s'", flowID, tc.label) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE status = 'W' AND flow_id = $1`, flowID).Returns(activeRuns, "active runs mismatch for flow #%d in '%s'", flowID, tc.label) } } } diff --git a/core/tasks/stats/cron.go b/core/tasks/stats/cron.go index eaa539805..7cada0365 100644 --- a/core/tasks/stats/cron.go +++ b/core/tasks/stats/cron.go @@ -2,11 +2,13 @@ package stats import ( "context" + "sync" "time" "github.com/nyaruka/librato" "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/sirupsen/logrus" @@ -23,12 +25,12 @@ func init() { } // StartStatsCron starts our cron job of posting stats every minute -func StartStatsCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, expirationLock, time.Second*60, +func StartStatsCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, expirationLock, time.Second*60, func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return dumpStats(ctx, mr.DB, mr.RP, lockName, lockValue) + return dumpStats(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) return nil diff --git a/core/tasks/timeouts/cron.go b/core/tasks/timeouts/cron.go index 2db269218..dd6d6c4d8 100644 --- a/core/tasks/timeouts/cron.go +++ b/core/tasks/timeouts/cron.go @@ -3,11 +3,13 @@ package timeouts import ( "context" "fmt" + "sync" "time" "github.com/nyaruka/mailroom" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks/handler" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/utils/cron" "github.com/nyaruka/mailroom/utils/marker" @@ -26,13 +28,13 @@ func init() { mailroom.AddInitFunction(StartTimeoutCron) } -// StartTimeoutCron starts our cron job of continuing timed out sessions every defined interval in config TimeoutTime -func StartTimeoutCron(mr *mailroom.Mailroom) error { - cron.StartCron(mr.Quit, mr.RP, timeoutLock, time.Second*time.Duration(mr.Config.TimeoutTime), +// StartTimeoutCron starts our cron job of continuing timed out sessions every minute +func StartTimeoutCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error { + cron.StartCron(quit, rt.RP, timeoutLock, time.Second*time.Duration(rt.Config.TimeoutTime), func(lockName string, lockValue string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() - return timeoutSessions(ctx, mr.DB, mr.RP, lockName, lockValue) + return timeoutSessions(ctx, rt.DB, rt.RP, lockName, lockValue) }, ) return nil @@ -77,7 +79,7 @@ func timeoutSessions(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName // ok, queue this task task := handler.NewTimeoutTask(timeout.OrgID, timeout.ContactID, timeout.SessionID, timeout.TimeoutOn) - err = handler.AddHandleTask(rc, timeout.ContactID, task) + err = handler.QueueHandleTask(rc, timeout.ContactID, task) if err != nil { return errors.Wrapf(err, "error adding new handle task") } diff --git a/core/tasks/timeouts/cron_test.go b/core/tasks/timeouts/cron_test.go index 4d8691d9a..fa42398f2 100644 --- a/core/tasks/timeouts/cron_test.go +++ b/core/tasks/timeouts/cron_test.go @@ -5,8 +5,6 @@ import ( "testing" "time" - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/flows" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" @@ -19,11 +17,8 @@ import ( ) func TestTimeouts(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() - rc := testsuite.RC() + ctx, _, db, rp := testsuite.Reset() + rc := rp.Get() defer rc.Close() err := marker.ClearTasks(rc, timeoutLock) @@ -31,9 +26,9 @@ func TestTimeouts(t *testing.T) { // need to create a session that has an expired timeout s1TimeoutOn := time.Now() - testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.CathyID, models.SessionStatusWaiting, &s1TimeoutOn) + testdata.InsertFlowSession(db, testdata.Org1, testdata.Cathy, models.SessionStatusWaiting, &s1TimeoutOn) s2TimeoutOn := time.Now().Add(time.Hour * 24) - testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.GeorgeID, models.SessionStatusWaiting, &s2TimeoutOn) + testdata.InsertFlowSession(db, testdata.Org1, testdata.George, models.SessionStatusWaiting, &s2TimeoutOn) time.Sleep(10 * time.Millisecond) @@ -52,7 +47,7 @@ func TestTimeouts(t *testing.T) { assert.NoError(t, err) // assert its the right contact - assert.Equal(t, models.CathyID, eventTask.ContactID) + assert.Equal(t, testdata.Cathy.ID, eventTask.ContactID) // no other task, err = queue.PopNextTask(rc, queue.HandlerQueue) diff --git a/go.mod b/go.mod index 67b103412..cc1d736bb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/nyaruka/mailroom require ( github.com/Masterminds/semver v1.5.0 github.com/apex/log v1.1.4 + github.com/aws/aws-sdk-go v1.35.20 github.com/buger/jsonparser v1.0.0 github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible @@ -17,8 +18,8 @@ require ( github.com/lib/pq v1.4.0 github.com/mattn/go-sqlite3 v1.10.0 // indirect github.com/nyaruka/ezconf v0.2.1 - github.com/nyaruka/gocommon v1.8.0 - github.com/nyaruka/goflow v0.115.2 + github.com/nyaruka/gocommon v1.13.0 + github.com/nyaruka/goflow v0.130.1 github.com/nyaruka/librato v1.0.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null v1.2.0 @@ -30,7 +31,7 @@ require ( github.com/prometheus/common v0.9.1 github.com/shopspring/decimal v1.2.0 github.com/sirupsen/logrus v1.5.0 - github.com/stretchr/testify v1.6.1 + github.com/stretchr/testify v1.7.0 gopkg.in/go-playground/validator.v9 v9.31.0 ) diff --git a/go.sum b/go.sum index 76d54edca..751a73d9c 100644 --- a/go.sum +++ b/go.sum @@ -132,10 +132,10 @@ github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8= github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= -github.com/nyaruka/gocommon v1.8.0 h1:cO+iYi1hjuaso1IvCC5QA7quAh6ThWALn4DTp1ajeYM= -github.com/nyaruka/gocommon v1.8.0/go.mod h1:r5UqoAdoP9VLb/wmtF1O0v73PQc79tZaVjbXlO16PUA= -github.com/nyaruka/goflow v0.115.2 h1:K3gVtXB6XACnSDAEgPhxd230hS18SBQnYam0reUYN1w= -github.com/nyaruka/goflow v0.115.2/go.mod h1:l+T3dqlKk2RB9yxYHSRhbW7elUZcyFOrYoeqXX1kyrI= +github.com/nyaruka/gocommon v1.13.0 h1:WPL//ekajA30KinYRr6IrdP1igNZpcUAfABleHCuxPQ= +github.com/nyaruka/gocommon v1.13.0/go.mod h1:Jn7UIE8zwIr4JaviDf4PZrrQlN8r6QGVhOuaF/JoKus= +github.com/nyaruka/goflow v0.130.1 h1:ai84idJkjgn2XJdv735/4qz72L0AuOYtEX8+GpL4xs0= +github.com/nyaruka/goflow v0.130.1/go.mod h1:Xp1p21TyYiMM/fVQNWQRok/fZ1ZeNoeQGUd//LvYxq4= github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= @@ -190,8 +190,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= diff --git a/mailroom.go b/mailroom.go index 91fc2ae69..ff28ef888 100644 --- a/mailroom.go +++ b/mailroom.go @@ -12,6 +12,7 @@ import ( "github.com/nyaruka/gocommon/storage" "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/gomodule/redigo/redis" @@ -22,7 +23,7 @@ import ( ) // InitFunction is a function that will be called when mailroom starts -type InitFunction func(mr *Mailroom) error +type InitFunction func(runtime *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error var initFunctions = make([]InitFunction, 0) @@ -32,7 +33,7 @@ func AddInitFunction(initFunc InitFunction) { } // TaskFunction is the function that will be called for a type of task -type TaskFunction func(ctx context.Context, mr *Mailroom, task *queue.Task) error +type TaskFunction func(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error var taskFunctions = make(map[string]TaskFunction) @@ -43,16 +44,12 @@ func AddTaskFunction(taskType string, taskFunc TaskFunction) { // Mailroom is a service for handling RapidPro events type Mailroom struct { - Config *config.Config - DB *sqlx.DB - RP *redis.Pool - ElasticClient *elastic.Client - Storage storage.Storage + ctx context.Context + cancel context.CancelFunc - Quit chan bool - CTX context.Context - Cancel context.CancelFunc - WaitGroup *sync.WaitGroup + rt *runtime.Runtime + wg *sync.WaitGroup + quit chan bool batchForeman *Foreman handlerForeman *Foreman @@ -63,48 +60,50 @@ type Mailroom struct { // NewMailroom creates and returns a new mailroom instance func NewMailroom(config *config.Config) *Mailroom { mr := &Mailroom{ - Config: config, - Quit: make(chan bool), - WaitGroup: &sync.WaitGroup{}, + rt: &runtime.Runtime{Config: config}, + quit: make(chan bool), + wg: &sync.WaitGroup{}, } - mr.CTX, mr.Cancel = context.WithCancel(context.Background()) - mr.batchForeman = NewForeman(mr, queue.BatchQueue, config.BatchWorkers) - mr.handlerForeman = NewForeman(mr, queue.HandlerQueue, config.HandlerWorkers) + mr.ctx, mr.cancel = context.WithCancel(context.Background()) + mr.batchForeman = NewForeman(mr.rt, mr.wg, queue.BatchQueue, config.BatchWorkers) + mr.handlerForeman = NewForeman(mr.rt, mr.wg, queue.HandlerQueue, config.HandlerWorkers) return mr } // Start starts the mailroom service func (mr *Mailroom) Start() error { + c := mr.rt.Config + log := logrus.WithFields(logrus.Fields{ "state": "starting", }) // parse and test our db config - dbURL, err := url.Parse(mr.Config.DB) + dbURL, err := url.Parse(c.DB) if err != nil { - return fmt.Errorf("unable to parse DB URL '%s': %s", mr.Config.DB, err) + return fmt.Errorf("unable to parse DB URL '%s': %s", c.DB, err) } if dbURL.Scheme != "postgres" { - return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", mr.Config.DB) + return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", c.DB) } // build our db - db, err := sqlx.Open("postgres", mr.Config.DB) + db, err := sqlx.Open("postgres", c.DB) if err != nil { - return fmt.Errorf("unable to open DB with config: '%s': %s", mr.Config.DB, err) + return fmt.Errorf("unable to open DB with config: '%s': %s", c.DB, err) } // configure our pool - mr.DB = db - mr.DB.SetMaxIdleConns(8) - mr.DB.SetMaxOpenConns(mr.Config.DBPoolSize) - mr.DB.SetConnMaxLifetime(time.Minute * 30) + db.SetMaxIdleConns(8) + db.SetMaxOpenConns(c.DBPoolSize) + db.SetConnMaxLifetime(time.Minute * 30) + mr.rt.DB = db // try connecting ctx, cancel := context.WithTimeout(context.Background(), time.Second) - err = mr.DB.PingContext(ctx) + err = db.PingContext(ctx) cancel() if err != nil { log.Error("db not reachable") @@ -113,9 +112,9 @@ func (mr *Mailroom) Start() error { } // parse and test our redis config - redisURL, err := url.Parse(mr.Config.Redis) + redisURL, err := url.Parse(mr.rt.Config.Redis) if err != nil { - return fmt.Errorf("unable to parse Redis URL '%s': %s", mr.Config.Redis, err) + return fmt.Errorf("unable to parse Redis URL '%s': %s", c.Redis, err) } // create our pool @@ -125,7 +124,7 @@ func (mr *Mailroom) Start() error { MaxIdle: 4, // only keep up to this many idle IdleTimeout: 240 * time.Second, // how long to wait before reaping a connection Dial: func() (redis.Conn, error) { - conn, err := redis.Dial("tcp", fmt.Sprintf("%s", redisURL.Host)) + conn, err := redis.Dial("tcp", redisURL.Host) if err != nil { return nil, err } @@ -146,7 +145,7 @@ func (mr *Mailroom) Start() error { return conn, err }, } - mr.RP = redisPool + mr.rt.RP = redisPool // test our redis connection conn := redisPool.Get() @@ -159,33 +158,48 @@ func (mr *Mailroom) Start() error { } // create our storage (S3 or file system) - if mr.Config.AWSAccessKeyID != "" { + if mr.rt.Config.AWSAccessKeyID != "" { s3Client, err := storage.NewS3Client(&storage.S3Options{ - AWSAccessKeyID: mr.Config.AWSAccessKeyID, - AWSSecretAccessKey: mr.Config.AWSSecretAccessKey, - Endpoint: mr.Config.S3Endpoint, - Region: mr.Config.S3Region, - DisableSSL: mr.Config.S3DisableSSL, - ForcePathStyle: mr.Config.S3ForcePathStyle, + AWSAccessKeyID: c.AWSAccessKeyID, + AWSSecretAccessKey: c.AWSSecretAccessKey, + Endpoint: c.S3Endpoint, + Region: c.S3Region, + DisableSSL: c.S3DisableSSL, + ForcePathStyle: c.S3ForcePathStyle, }) if err != nil { return err } - mr.Storage = storage.NewS3(s3Client, mr.Config.S3MediaBucket) + mr.rt.MediaStorage = storage.NewS3(s3Client, mr.rt.Config.S3MediaBucket, c.S3Region, 32) + mr.rt.SessionStorage = storage.NewS3(s3Client, mr.rt.Config.S3SessionBucket, c.S3Region, 32) } else { - mr.Storage = storage.NewFS("_storage") + mr.rt.MediaStorage = storage.NewFS("_storage") + mr.rt.SessionStorage = storage.NewFS("_storage") } - // test our storage - err = mr.Storage.Test() + // test our media storage + ctx, cancel = context.WithTimeout(context.Background(), time.Second*10) + err = mr.rt.MediaStorage.Test(ctx) + cancel() + + if err != nil { + log.WithError(err).Error(mr.rt.MediaStorage.Name() + " media storage not available") + } else { + log.Info(mr.rt.MediaStorage.Name() + " media storage ok") + } + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*10) + err = mr.rt.SessionStorage.Test(ctx) + cancel() + if err != nil { - log.WithError(err).Error(mr.Storage.Name() + " storage not available") + log.WithError(err).Warn(mr.rt.SessionStorage.Name() + " session storage not available") } else { - log.Info(mr.Storage.Name() + " storage ok") + log.Info(mr.rt.SessionStorage.Name() + " session storage ok") } // initialize our elastic client - mr.ElasticClient, err = newElasticClient(mr.Config.Elastic) + mr.rt.ES, err = newElasticClient(c.Elastic) if err != nil { log.WithError(err).Error("unable to connect to elastic, check configuration") } else { @@ -193,18 +207,18 @@ func (mr *Mailroom) Start() error { } // warn if we won't be doing FCM syncing - if config.Mailroom.FCMKey == "" { + if c.FCMKey == "" { logrus.Error("fcm not configured, no syncing of android channels") } for _, initFunc := range initFunctions { - initFunc(mr) + initFunc(mr.rt, mr.wg, mr.quit) } // if we have a librato token, configure it - if mr.Config.LibratoToken != "" { + if c.LibratoToken != "" { host, _ := os.Hostname() - librato.Configure(mr.Config.LibratoUsername, mr.Config.LibratoToken, host, time.Second, mr.WaitGroup) + librato.Configure(c.LibratoUsername, c.LibratoToken, host, time.Second, mr.wg) librato.Start() } @@ -213,7 +227,7 @@ func (mr *Mailroom) Start() error { mr.handlerForeman.Start() // start our web server - mr.webserver = web.NewServer(mr.CTX, mr.Config, mr.DB, mr.RP, mr.Storage, mr.ElasticClient, mr.WaitGroup) + mr.webserver = web.NewServer(mr.ctx, c, mr.rt.DB, mr.rt.RP, mr.rt.MediaStorage, mr.rt.ES, mr.wg) mr.webserver.Start() logrus.Info("mailroom started") @@ -227,14 +241,14 @@ func (mr *Mailroom) Stop() error { mr.batchForeman.Stop() mr.handlerForeman.Stop() librato.Stop() - close(mr.Quit) - mr.Cancel() + close(mr.quit) + mr.cancel() // stop our web server mr.webserver.Stop() - mr.WaitGroup.Wait() - mr.ElasticClient.Stop() + mr.wg.Wait() + mr.rt.ES.Stop() logrus.Info("mailroom stopped") return nil } diff --git a/mailroom_test.dump b/mailroom_test.dump index 7ab0873bf..f17efbfcf 100644 Binary files a/mailroom_test.dump and b/mailroom_test.dump differ diff --git a/runtime/runtime.go b/runtime/runtime.go new file mode 100644 index 000000000..69ae2ed6c --- /dev/null +++ b/runtime/runtime.go @@ -0,0 +1,20 @@ +package runtime + +import ( + "github.com/gomodule/redigo/redis" + "github.com/jmoiron/sqlx" + "github.com/nyaruka/gocommon/storage" + "github.com/nyaruka/mailroom/config" + "github.com/olivere/elastic/v7" +) + +// Runtime represents the set of services required to run many Mailroom functions. Used as a wrapper for +// those services to simplify call signatures but not create a direct dependency to Mailroom or Server +type Runtime struct { + DB *sqlx.DB + RP *redis.Pool + ES *elastic.Client + MediaStorage storage.Storage + SessionStorage storage.Storage + Config *config.Config +} diff --git a/services/tickets/intern/service.go b/services/tickets/intern/service.go index 4bd11db29..42c103b6c 100644 --- a/services/tickets/intern/service.go +++ b/services/tickets/intern/service.go @@ -4,9 +4,9 @@ import ( "net/http" "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" ) @@ -23,13 +23,13 @@ type service struct { } // NewService creates a new internal ticket service -func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { +func NewService(rtCfg *config.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(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - return flows.NewTicket(flows.TicketUUID(uuids.New()), s.ticketer.Reference(), subject, body, ""), nil + return flows.OpenTicket(s.ticketer, subject, body), nil } // Forward is a noop diff --git a/services/tickets/intern/service_test.go b/services/tickets/intern/service_test.go index 108bd8b97..a7836ac0d 100644 --- a/services/tickets/intern/service_test.go +++ b/services/tickets/intern/service_test.go @@ -13,12 +13,16 @@ import ( "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) { + _, rt, _, _ := testsuite.Get() + session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) require.NoError(t, err) @@ -28,6 +32,7 @@ func TestOpenAndForward(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal")) svc, err := intern.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -39,17 +44,13 @@ func TestOpenAndForward(t *testing.T) { ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log) assert.NoError(t, err) - assert.Equal(t, &flows.Ticket{ - UUID: flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), - Ticketer: ticketer.Reference(), - Subject: "Need help", - Body: "Where are my cookies?", - ExternalID: "", - }, ticket) - + assert.Equal(t, flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), ticket.UUID()) + assert.Equal(t, "Need help", ticket.Subject()) + 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, models.Org1, models.CathyID, models.InternalID, "", "Need help", "Where are my cookies?", nil) + dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Internal.ID, "", "Need help", "Where are my cookies?", models.NilUserID, nil) logger = &flows.HTTPLogger{} err = svc.Forward( @@ -66,16 +67,18 @@ func TestOpenAndForward(t *testing.T) { } func TestCloseAndReopen(t *testing.T) { + _, rt, _, _ := testsuite.Get() + defer uuids.SetGenerator(uuids.DefaultGenerator) uuids.SetGenerator(uuids.NewSeededGenerator(12345)) ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal")) - svc, err := intern.NewService(http.DefaultClient, nil, ticketer, nil) + 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", models.Org1, models.CathyID, models.InternalID, "12", "New ticket", "Where my cookies?", nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.InternalID, "14", "Second ticket", "Where my shoes?", nil) + ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Internal.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil) + ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Internal.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil) err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) diff --git a/services/tickets/mailgun/service.go b/services/tickets/mailgun/service.go index 17015cbcc..3dd435da8 100644 --- a/services/tickets/mailgun/service.go +++ b/services/tickets/mailgun/service.go @@ -8,9 +8,9 @@ import ( "text/template" "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/services/tickets" @@ -83,7 +83,7 @@ type service struct { } // NewService creates a new mailgun email-based ticket service -func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { +func NewService(rtCfg *config.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] @@ -108,10 +108,10 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete // Open opens a ticket which for mailgun means just sending an initial email func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - ticketUUID := flows.TicketUUID(uuids.New()) + ticket := flows.OpenTicket(s.ticketer, subject, body) contactDisplay := tickets.GetContactDisplay(session.Environment(), session.Contact()) - from := s.ticketAddress(contactDisplay, ticketUUID) + from := s.ticketAddress(contactDisplay, ticket.UUID()) context := s.templateContext(subject, body, "", string(session.Contact().UUID()), contactDisplay) fullBody := evaluateTemplate(openBodyTemplate, context) @@ -123,7 +123,8 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow return nil, errors.Wrap(err, "error calling mailgun API") } - return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, msgID), nil + 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 { diff --git a/services/tickets/mailgun/service_test.go b/services/tickets/mailgun/service_test.go index e9c1a0ee3..26636fdc2 100644 --- a/services/tickets/mailgun/service_test.go +++ b/services/tickets/mailgun/service_test.go @@ -16,12 +16,16 @@ import ( "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) { + _, rt, _, _ := testsuite.Get() + session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) require.NoError(t, err) @@ -51,6 +55,7 @@ func TestOpenAndForward(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun")) _, err = mailgun.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -59,6 +64,7 @@ func TestOpenAndForward(t *testing.T) { 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, @@ -81,19 +87,15 @@ func TestOpenAndForward(t *testing.T) { ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log) assert.NoError(t, err) - assert.Equal(t, &flows.Ticket{ - UUID: flows.TicketUUID("9688d21d-95aa-4bed-afc7-f31b35731a3d"), - Ticketer: ticketer.Reference(), - Subject: "Need help", - Body: "Where are my cookies?", - ExternalID: "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", - }, ticket) - + assert.Equal(t, flows.TicketUUID("9688d21d-95aa-4bed-afc7-f31b35731a3d"), ticket.UUID()) + assert.Equal(t, "Need help", ticket.Subject()) + assert.Equal(t, "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, models.Org1, models.CathyID, models.MailgunID, "", "Need help", "Where are my cookies?", map[string]interface{}{ - "contact-uuid": string(models.CathyUUID), + dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Mailgun.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{ + "contact-uuid": string(testdata.Cathy.UUID), "contact-display": "Cathy", }) @@ -112,6 +114,8 @@ func TestOpenAndForward(t *testing.T) { } func TestCloseAndReopen(t *testing.T) { + _, rt, _, _ := testsuite.Get() + defer uuids.SetGenerator(uuids.DefaultGenerator) defer httpx.SetRequestor(httpx.DefaultRequestor) @@ -135,6 +139,7 @@ func TestCloseAndReopen(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun")) svc, err := mailgun.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -149,8 +154,8 @@ func TestCloseAndReopen(t *testing.T) { require.NoError(t, err) logger := &flows.HTTPLogger{} - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.ZendeskID, "12", "New ticket", "Where my cookies?", nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.ZendeskID, "14", "Second ticket", "Where my shoes?", nil) + ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil) + ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Zendesk.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil) err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) diff --git a/services/tickets/mailgun/testdata/receive.json b/services/tickets/mailgun/testdata/receive.json index 30ba19d46..78564d881 100644 --- a/services/tickets/mailgun/testdata/receive.json +++ b/services/tickets/mailgun/testdata/receive.json @@ -46,7 +46,7 @@ "body": [ { "name": "recipient", - "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com" + "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" }, { "name": "sender", @@ -178,7 +178,7 @@ "body": [ { "name": "recipient", - "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com" + "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" }, { "name": "sender", @@ -213,18 +213,18 @@ "status": 200, "response": { "action": "rejected", - "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef" + "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%2Bc69f103c-db64-4481-815b-1112890419ef%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", + "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": "c69f103c-db64-4481-815b-1112890419ef", + "ticket_uuid": "$cathy_ticket_uuid$", "msg_uuid": "692926ea-09d6-4942-bd38-d266ec8d3716" }, "db_assertions": [ @@ -245,7 +245,7 @@ "body": [ { "name": "recipient", - "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com" + "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" }, { "name": "sender", @@ -296,12 +296,12 @@ "status": 200, "response": { "action": "forwarded", - "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef", + "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_storage/media/1/8720/f157/8720f157-ca1c-432f-9c0b-2014ddc77094.txt,image/jpeg:https:///_test_storage/media/1/c34b/6c7d/c34b6c7d-fa06-4563-92a3-d648ab64bccb.jpg}'", + "query": "select count(*) from msgs_msg where direction = 'O' AND uuid = '5802813d-6c58-4292-8228-9728778b6c98' AND attachments = '{text/plain:https:///_test_media_storage/media/1/8720/f157/8720f157-ca1c-432f-9c0b-2014ddc77094.txt,image/jpeg:https:///_test_media_storage/media/1/c34b/6c7d/c34b6c7d-fa06-4563-92a3-d648ab64bccb.jpg}'", "count": 1 }, { @@ -325,7 +325,7 @@ "body": [ { "name": "recipient", - "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com" + "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com" }, { "name": "sender", @@ -360,7 +360,7 @@ "status": 200, "response": { "action": "closed", - "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef" + "ticket_uuid": "$cathy_ticket_uuid$" }, "db_assertions": [ { diff --git a/services/tickets/mailgun/web.go b/services/tickets/mailgun/web.go index c56c817cf..df8bfdbd0 100644 --- a/services/tickets/mailgun/web.go +++ b/services/tickets/mailgun/web.go @@ -12,6 +12,7 @@ import ( "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" @@ -60,13 +61,13 @@ type receiveResponse struct { 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, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { +func handleReceive(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, 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(s.Config.MailgunSigningKey) { + if !request.verify(rt.Config.MailgunSigningKey) { return errors.New("request signature validation failed"), http.StatusForbidden, nil } @@ -87,7 +88,7 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model } // look up the ticket and ticketer - ticket, ticketer, svc, err := tickets.FromTicketUUID(s.CTX, s.DB, flows.TicketUUID(match[0][1]), typeMailgun) + ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(match[0][1]), typeMailgun) if err != nil { return err, http.StatusBadRequest, nil } @@ -103,10 +104,14 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model return &receiveResponse{Action: "rejected", TicketUUID: ticket.UUID()}, http.StatusOK, nil } + oa, err := models.GetOrgAssets(ctx, rt.DB, 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" { - org, _ := models.GetOrgAssets(ctx, s.DB, ticket.OrgID()) - err = models.CloseTickets(ctx, s.DB, org, []*models.Ticket{ticket}, true, l) + err = tickets.CloseTicket(ctx, rt, oa, ticket, true, l) if err != nil { return errors.Wrapf(err, "error closing ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil } @@ -114,16 +119,21 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model return &receiveResponse{Action: "closed", TicketUUID: ticket.UUID()}, http.StatusOK, nil } - // update our ticket - config := map[string]string{ - ticketConfigLastMessageID: request.MessageID, - } - err = models.UpdateAndKeepOpenTicket(ctx, s.DB, ticket, config) + // 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 } - msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, request.StrippedText, files) + // reopen ticket if necessary + if ticket.Status() != models.TicketStatusOpen { + err = tickets.ReopenTicket(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 } diff --git a/services/tickets/mailgun/web_test.go b/services/tickets/mailgun/web_test.go index 53087d9fb..ec8d1cb02 100644 --- a/services/tickets/mailgun/web_test.go +++ b/services/tickets/mailgun/web_test.go @@ -3,18 +3,22 @@ package mailgun import ( "testing" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" ) func TestReceive(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Reset() + + defer func() { + db.MustExec(`DELETE FROM msgs_msg`) + db.MustExec(`DELETE FROM tickets_ticketevent`) + db.MustExec(`DELETE FROM tickets_ticket`) + }() // create a mailgun ticket for Cathy - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "") + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil) - web.RunWebTests(t, "testdata/receive.json") + web.RunWebTests(t, "testdata/receive.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } diff --git a/services/tickets/rocketchat/service.go b/services/tickets/rocketchat/service.go index c2a841637..66e09bb2d 100644 --- a/services/tickets/rocketchat/service.go +++ b/services/tickets/rocketchat/service.go @@ -1,17 +1,18 @@ package rocketchat import ( + "net/http" + "strconv" + "time" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" - "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/pkg/errors" - "net/http" - "strconv" - "time" ) const ( @@ -34,7 +35,7 @@ type service struct { } // NewService creates a new RocketChat ticket service -func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { +func NewService(rtCfg *config.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { baseURL := config[configBaseURL] secret := config[configSecret] @@ -53,6 +54,7 @@ type VisitorToken models.ContactID // Open opens a ticket which for RocketChat means open a room associated to a visitor user func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { + ticket := flows.OpenTicket(s.ticketer, subject, body) contact := session.Contact() email := "" phone := "" @@ -70,7 +72,6 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow } } - ticketUUID := flows.TicketUUID(uuids.New()) room := &Room{ Visitor: Visitor{ Token: VisitorToken(contact.ID()).String(), @@ -79,7 +80,7 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow Email: email, Phone: phone, }, - TicketID: string(ticketUUID), + TicketID: string(ticket.UUID()), } room.SessionStart = session.Runs()[0].CreatedOn().Add(-time.Minute).Format(time.RFC3339) @@ -101,7 +102,8 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow return nil, errors.Wrap(err, "error calling RocketChat") } - return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, roomID), nil + ticket.SetExternalID(roomID) + return ticket, nil } func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { diff --git a/services/tickets/rocketchat/service_test.go b/services/tickets/rocketchat/service_test.go index b4b92692d..2e6a191bc 100644 --- a/services/tickets/rocketchat/service_test.go +++ b/services/tickets/rocketchat/service_test.go @@ -1,6 +1,10 @@ package rocketchat_test import ( + "net/http" + "testing" + "time" + "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/uuids" @@ -12,14 +16,16 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/services/tickets/rocketchat" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "net/http" - "testing" - "time" ) func TestOpenAndForward(t *testing.T) { + _, rt, _, _ := testsuite.Get() + defer dates.SetNowSource(dates.DefaultNowSource) dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 0, time.UTC))) @@ -44,6 +50,7 @@ func TestOpenAndForward(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "rocketchat")) _, err = rocketchat.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -52,6 +59,7 @@ func TestOpenAndForward(t *testing.T) { assert.EqualError(t, err, "missing base_url or secret config") svc, err := rocketchat.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -69,20 +77,15 @@ func TestOpenAndForward(t *testing.T) { logger = &flows.HTTPLogger{} ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log) assert.NoError(t, err) - - assert.Equal(t, &flows.Ticket{ - UUID: flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), - Ticketer: ticketer.Reference(), - Subject: "Need help", - Body: "Where are my cookies?", - ExternalID: "uiF7ybjsv7PSJGSw6", - }, ticket) - + assert.Equal(t, flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), ticket.UUID()) + assert.Equal(t, "Need help", ticket.Subject()) + assert.Equal(t, "Where are my cookies?", ticket.Body()) + assert.Equal(t, "uiF7ybjsv7PSJGSw6", ticket.ExternalID()) assert.Equal(t, 1, len(logger.Logs)) test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) - dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.RocketChatID, "", "Need help", "Where are my cookies?", map[string]interface{}{ - "contact-uuid": string(models.CathyUUID), + dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.RocketChat.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{ + "contact-uuid": string(testdata.Cathy.UUID), "contact-display": "Cathy", }) logger = &flows.HTTPLogger{} @@ -96,11 +99,14 @@ func TestOpenAndForward(t *testing.T) { "audio/ogg:https://link.to/audio.ogg", } err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", attachments, logger.Log) + require.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.Get() + defer uuids.SetGenerator(uuids.DefaultGenerator) defer httpx.SetRequestor(httpx.DefaultRequestor) @@ -115,6 +121,7 @@ func TestCloseAndReopen(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "rocketchat")) svc, err := rocketchat.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -125,8 +132,8 @@ func TestCloseAndReopen(t *testing.T) { ) require.NoError(t, err) - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.RocketChatID, "X5gwXeaxbnGDaq8Q3", "New ticket", "Where my cookies?", nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.RocketChatID, "cq7AokJHKkGhAMoBK", "Second ticket", "Where my shoes?", nil) + ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.RocketChat.ID, "X5gwXeaxbnGDaq8Q3", "New ticket", "Where my cookies?", models.NilUserID, nil) + ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.RocketChat.ID, "cq7AokJHKkGhAMoBK", "Second ticket", "Where my shoes?", models.NilUserID, nil) logger := &flows.HTTPLogger{} err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) diff --git a/services/tickets/rocketchat/testdata/event_callback.json b/services/tickets/rocketchat/testdata/event_callback.json index a8d952911..75add7bfd 100644 --- a/services/tickets/rocketchat/testdata/event_callback.json +++ b/services/tickets/rocketchat/testdata/event_callback.json @@ -111,7 +111,7 @@ }, "body": { "type": "other", - "ticketID": "c69f103c-db64-4481-815b-1112890419ef", + "ticketID": "$cathy_ticket_uuid$", "visitor": { "token": "1234" }, @@ -133,7 +133,7 @@ }, "body": { "type": "agent-message", - "ticketID": "c69f103c-db64-4481-815b-1112890419ef", + "ticketID": "$cathy_ticket_uuid$", "visitor": { "token": "1234" }, @@ -165,15 +165,17 @@ }, "body": { "type": "agent-message", - "ticketID": "c69f103c-db64-4481-815b-1112890419ef", + "ticketID": "$cathy_ticket_uuid$", "visitor": { "token": "1234" }, "data": { - "attachments": [{ - "type": "image/jpg", - "url": "https://link.to/image.jpg" - }] + "attachments": [ + { + "type": "image/jpg", + "url": "https://link.to/image.jpg" + } + ] } }, "http_mocks": { @@ -190,7 +192,7 @@ }, "db_assertions": [ { - "query": "select count(*) from msgs_msg where direction = 'O' and attachments = '{text/plain:https:///_test_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'", + "query": "select count(*) from msgs_msg where direction = 'O' and attachments = '{text/plain:https:///_test_media_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'", "count": 1 } ] @@ -204,7 +206,7 @@ }, "body": { "type": "close-room", - "ticketID": "c69f103c-db64-4481-815b-1112890419ef", + "ticketID": "$cathy_ticket_uuid$", "visitor": { "token": "1234" } diff --git a/services/tickets/rocketchat/web.go b/services/tickets/rocketchat/web.go index 1b2e0d11c..d3424f37b 100644 --- a/services/tickets/rocketchat/web.go +++ b/services/tickets/rocketchat/web.go @@ -4,15 +4,17 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "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" - "net/http" ) func init() { @@ -35,11 +37,11 @@ type agentMessageData struct { } `json:"attachments"` } -func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { +func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer")) // look up ticketer - ticketer, _, err := tickets.FromTicketerUUID(ctx, s.DB, ticketerUUID, typeRocketChat) + ticketer, _, err := tickets.FromTicketerUUID(ctx, rt.DB, ticketerUUID, typeRocketChat) if err != nil { return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil } @@ -56,7 +58,7 @@ func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l } // look up ticket - ticket, _, _, err := tickets.FromTicketUUID(ctx, s.DB, flows.TicketUUID(request.TicketID), typeRocketChat) + ticket, _, _, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(request.TicketID), typeRocketChat) if err != nil { return errors.Errorf("no such ticket %s", request.TicketID), http.StatusNotFound, nil } @@ -88,10 +90,10 @@ func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l attachments = append(attachments, attachment.URL) } - _, err = tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, data.Text, files) + _, err = tickets.SendReply(ctx, rt, ticket, data.Text, files) case "close-room": - err = models.CloseTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l) + err = tickets.CloseTicket(ctx, rt, nil, ticket, false, l) default: err = errors.New("invalid event type") diff --git a/services/tickets/rocketchat/web_test.go b/services/tickets/rocketchat/web_test.go index c9f9fd53d..20cc9f3f0 100644 --- a/services/tickets/rocketchat/web_test.go +++ b/services/tickets/rocketchat/web_test.go @@ -1,11 +1,11 @@ package rocketchat_test import ( - "github.com/nyaruka/mailroom/core/models" + "testing" + "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" - "testing" ) func TestEventCallback(t *testing.T) { @@ -13,7 +13,7 @@ func TestEventCallback(t *testing.T) { db := testsuite.DB() // create a rocketchat ticket for Cathy - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.RocketChatID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234") + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.RocketChat, "Need help", "Have you seen my cookies?", "1234", nil) - web.RunWebTests(t, "testdata/event_callback.json") + web.RunWebTests(t, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } diff --git a/services/tickets/utils.go b/services/tickets/utils.go index 7f45f4f4c..35563e10b 100644 --- a/services/tickets/utils.go +++ b/services/tickets/utils.go @@ -10,18 +10,19 @@ import ( "path/filepath" "time" + "github.com/jmoiron/sqlx" "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/storage" "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/config" "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/gomodule/redigo/redis" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) @@ -55,7 +56,7 @@ func FromTicketUUID(ctx context.Context, db *sqlx.DB, uuid flows.TicketUUID, tic } // and load it as a service - svc, err := ticketer.AsService(flows.NewTicketer(ticketer)) + svc, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer)) if err != nil { return nil, nil, nil, errors.Wrap(err, "error loading ticketer service") } @@ -71,7 +72,7 @@ func FromTicketerUUID(ctx context.Context, db *sqlx.DB, uuid assets.TicketerUUID } // and load it as a service - svc, err := ticketer.AsService(flows.NewTicketer(ticketer)) + svc, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer)) if err != nil { return nil, nil, errors.Wrap(err, "error loading ticketer service") } @@ -80,9 +81,9 @@ func FromTicketerUUID(ctx context.Context, db *sqlx.DB, uuid assets.TicketerUUID } // SendReply sends a message reply from the ticket system user to the contact -func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.Storage, ticket *models.Ticket, text string, files []*File) (*models.Msg, error) { +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, db, ticket.OrgID()) + oa, err := models.GetOrgAssets(ctx, rt.DB, ticket.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error looking up org #%d", ticket.OrgID()) } @@ -92,7 +93,7 @@ func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.S for i, file := range files { filename := string(uuids.New()) + filepath.Ext(file.URL) - attachments[i], err = oa.Org().StoreAttachment(store, filename, file.ContentType, file.Body) + attachments[i], err = oa.Org().StoreAttachment(ctx, rt.MediaStorage, filename, file.ContentType, file.Body) if err != nil { return nil, errors.Wrapf(err, "error storing attachment %s for ticket reply", file.URL) } @@ -103,14 +104,14 @@ func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.S translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("base"): base} // we'll use a broadcast to send this message - bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil) + bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil, ticket.ID()) batch := bcast.CreateBatch([]models.ContactID{ticket.ContactID()}) - msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, batch) + msgs, err := models.CreateBroadcastMessages(ctx, rt.DB, rt.RP, oa, batch) if err != nil { return nil, errors.Wrapf(err, "error creating message batch") } - msgio.SendMessages(ctx, db, rp, nil, msgs) + msgio.SendMessages(ctx, rt.DB, rt.RP, nil, msgs) return msgs[0], nil } @@ -139,3 +140,29 @@ func FetchFile(url string, headers map[string]string) (*File, error) { return &File{URL: url, ContentType: contentType, Body: ioutil.NopCloser(bytes.NewReader(trace.ResponseBody))}, nil } + +// CloseTicket closes the given ticket, and creates and queues a closed event +func CloseTicket(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error { + events, err := models.CloseTickets(ctx, rt.DB, oa, models.NilUserID, []*models.Ticket{ticket}, externally, 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 +} + +// ReopenTicket reopens the given ticket +func ReopenTicket(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error { + _, err := models.ReopenTickets(ctx, rt.DB, oa, models.NilUserID, []*models.Ticket{ticket}, externally, l) + return err +} diff --git a/services/tickets/utils_test.go b/services/tickets/utils_test.go index 97feebb4e..d433101c0 100644 --- a/services/tickets/utils_test.go +++ b/services/tickets/utils_test.go @@ -3,10 +3,12 @@ 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/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/services/tickets" @@ -20,13 +22,12 @@ import ( ) func TestGetContactDisplay(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) - contact, err := models.LoadContact(ctx, db, oa, models.CathyID) + contact, err := models.LoadContact(ctx, db, oa, testdata.Cathy.ID) require.NoError(t, err) flowContact, err := contact.FlowContact(oa) @@ -46,19 +47,14 @@ func TestGetContactDisplay(t *testing.T) { } func TestFromTicketUUID(t *testing.T) { - testsuite.ResetDB() - ctx := testsuite.CTX() - db := testsuite.DB() - - ticket1UUID := flows.TicketUUID("f7358870-c3dd-450d-b5ae-db2eb50216ba") - ticket2UUID := flows.TicketUUID("44b7d9b5-6ddd-4a6a-a1c0-8b70ecd06339") + ctx, _, db, _ := testsuite.Reset() // create some tickets - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, ticket1UUID, "Need help", "Have you seen my cookies?", "") - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, ticket2UUID, "Need help", "Have you seen my shoes?", "") + ticket1 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil) + ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my shoes?", "", nil) // break mailgun configuration - db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, models.MailgunID) + db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, testdata.Mailgun.ID) models.FlushCache() @@ -67,18 +63,19 @@ func TestFromTicketUUID(t *testing.T) { 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, db, ticket1UUID, "zendesk") + _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1.UUID, "zendesk") assert.EqualError(t, err, "error looking up ticketer #1") // err if ticketer isn't configured correctly and can't be loaded as a service - _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1UUID, "mailgun") + _, _, _, err = tickets.FromTicketUUID(ctx, db, 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, db, ticket2UUID, "zendesk") + ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, db, ticket2.UUID, "zendesk") - assert.Equal(t, ticket2UUID, ticket.UUID()) - assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + 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) testsuite.ResetDB() @@ -86,29 +83,28 @@ func TestFromTicketUUID(t *testing.T) { } func TestFromTicketerUUID(t *testing.T) { - testsuite.ResetDB() - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Reset() // break mailgun configuration - db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, models.MailgunID) + 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, db, "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, db, models.MailgunUUID, "zendesk") + _, _, err = tickets.FromTicketerUUID(ctx, db, 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, db, models.MailgunUUID, "mailgun") + _, _, err = tickets.FromTicketerUUID(ctx, db, 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, db, models.ZendeskUUID, "zendesk") + ticketer, svc, err := tickets.FromTicketerUUID(ctx, db, testdata.Zendesk.UUID, "zendesk") - assert.Equal(t, models.ZendeskUUID, ticketer.UUID()) + assert.NoError(t, err) + assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID()) assert.Implements(t, (*models.TicketService)(nil), svc) testsuite.ResetDB() @@ -116,10 +112,8 @@ func TestFromTicketerUUID(t *testing.T) { } func TestSendReply(t *testing.T) { - testsuite.ResetDB() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() + ctx, rt, db, _ := testsuite.Reset() + defer testsuite.ResetStorage() defer uuids.SetGenerator(uuids.DefaultGenerator) @@ -130,23 +124,68 @@ func TestSendReply(t *testing.T) { image := &tickets.File{URL: "http://coolfiles.com/a.jpg", ContentType: "image/jpeg", Body: imageBody} - ticketUUID := flows.TicketUUID("f7358870-c3dd-450d-b5ae-db2eb50216ba") - // create a ticket - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, ticketUUID, "Need help", "Have you seen my cookies?", "") - - ticket, err := models.LookupTicketByUUID(ctx, db, ticketUUID) - require.NoError(t, err) + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil) + modelTicket := ticket.Load(db) - msg, err := tickets.SendReply(ctx, db, rp, testsuite.Storage(), ticket, "I'll get back to you", []*tickets.File{image}) + 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, models.CathyID, msg.ContactID()) - assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg"}, msg.Attachments()) - assert.FileExists(t, "_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg") + assert.Equal(t, testdata.Cathy.ID, msg.ContactID()) + assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_media_storage/media/1/e718/7099/e7187099-7d38-4f60-955c-325957214c42.jpg"}, msg.Attachments()) + assert.FileExists(t, "_test_media_storage/media/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, db, rp, testsuite.Storage(), ticket, "I'll get back to you", []*tickets.File{image}) + _, 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, db, _ := testsuite.Reset() + + 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, `{ + "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", + "message": "Queued. Thank you." + }`), + }, + })) + + // create an open ticket + ticket1 := models.NewTicket( + "2ef57efc-d85f-4291-b330-e4afe68af5fe", + testdata.Org1.ID, + testdata.Cathy.ID, + testdata.Mailgun.ID, + "EX12345", + "New Ticket", + "Where are my cookies?", + models.NilUserID, + map[string]interface{}{ + "contact-display": "Cathy", + }, + ) + err := models.InsertTickets(ctx, db, []*models.Ticket{ticket1}) + require.NoError(t, err) + + // create a close ticket trigger + testdata.InsertTicketClosedTrigger(db, testdata.Org1, testdata.Favorites) + + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) + require.NoError(t, err) + + logger := &models.HTTPLogger{} + + err = tickets.CloseTicket(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:31Z"},"queued_on":"2021-06-08T16:40:34Z"}`}) +} diff --git a/services/tickets/zendesk/service.go b/services/tickets/zendesk/service.go index c18edabdd..51af565f9 100644 --- a/services/tickets/zendesk/service.go +++ b/services/tickets/zendesk/service.go @@ -8,7 +8,6 @@ import ( "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/config" @@ -38,6 +37,7 @@ func init() { } type service struct { + rtConfig *config.Config restClient *RESTClient pushClient *PushClient ticketer *flows.Ticketer @@ -49,7 +49,7 @@ type service struct { } // NewService creates a new zendesk ticket service -func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { +func NewService(rtCfg *config.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] @@ -60,6 +60,7 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete if subdomain != "" && secret != "" && oAuthToken != "" && instancePushID != "" && pushToken != "" { return &service{ + rtConfig: rtCfg, restClient: NewRESTClient(httpClient, httpRetries, subdomain, oAuthToken), pushClient: NewPushClient(httpClient, httpRetries, subdomain, pushToken), ticketer: ticketer, @@ -75,13 +76,13 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete // Open opens a ticket which for mailgun means just sending an initial email func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - ticketUUID := flows.TicketUUID(uuids.New()) + ticket := flows.OpenTicket(s.ticketer, subject, body) contactDisplay := session.Contact().Format(session.Environment()) msg := &ExternalResource{ - ExternalID: string(ticketUUID), // there's no local msg so use ticket UUID instead + ExternalID: string(ticket.UUID()), // there's no local msg so use ticket UUID instead Message: body, - ThreadID: string(ticketUUID), + ThreadID: string(ticket.UUID()), CreatedAt: dates.Now(), Author: Author{ ExternalID: string(session.Contact().UUID()), @@ -94,7 +95,7 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow return nil, err } - return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, ""), nil + return ticket, nil } func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { @@ -250,7 +251,7 @@ func (s *service) push(msg *ExternalResource, logHTTP flows.HTTPLogCallback) err // 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 := config.Mailroom.S3MediaPrefix + prefix := s.rtConfig.S3MediaPrefix if !strings.HasPrefix(prefix, "/") { prefix = "/" + prefix } diff --git a/services/tickets/zendesk/service_test.go b/services/tickets/zendesk/service_test.go index 4fb11f62e..5f423abf1 100644 --- a/services/tickets/zendesk/service_test.go +++ b/services/tickets/zendesk/service_test.go @@ -16,12 +16,16 @@ import ( "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) { + _, rt, _, _ := testsuite.Get() + session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) require.NoError(t, err) @@ -56,6 +60,7 @@ func TestOpenAndForward(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk")) _, err = zendesk.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -64,6 +69,7 @@ func TestOpenAndForward(t *testing.T) { 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, @@ -86,21 +92,16 @@ func TestOpenAndForward(t *testing.T) { logger = &flows.HTTPLogger{} ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log) - assert.NoError(t, err) - assert.Equal(t, &flows.Ticket{ - UUID: flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), - Ticketer: ticketer.Reference(), - Subject: "Need help", - Body: "Where are my cookies?", - ExternalID: "", - }, ticket) - + assert.Equal(t, flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), ticket.UUID()) + assert.Equal(t, "Need help", ticket.Subject()) + 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, models.Org1, models.CathyID, models.ZendeskID, "", "Need help", "Where are my cookies?", map[string]interface{}{ - "contact-uuid": string(models.CathyUUID), + dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{ + "contact-uuid": string(testdata.Cathy.UUID), "contact-display": "Cathy", }) @@ -119,6 +120,8 @@ func TestOpenAndForward(t *testing.T) { } func TestCloseAndReopen(t *testing.T) { + _, rt, _, _ := testsuite.Get() + 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": { @@ -143,6 +146,7 @@ func TestCloseAndReopen(t *testing.T) { ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk")) svc, err := zendesk.NewService( + rt.Config, http.DefaultClient, nil, ticketer, @@ -157,8 +161,8 @@ func TestCloseAndReopen(t *testing.T) { require.NoError(t, err) logger := &flows.HTTPLogger{} - ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.ZendeskID, "12", "New ticket", "Where my cookies?", nil) - ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.ZendeskID, "14", "Second ticket", "Where my shoes?", nil) + ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil) + ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Zendesk.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil) err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) diff --git a/services/tickets/zendesk/testdata/channelback.json b/services/tickets/zendesk/testdata/channelback.json index 95e3c27a4..8247c6fe3 100644 --- a/services/tickets/zendesk/testdata/channelback.json +++ b/services/tickets/zendesk/testdata/channelback.json @@ -23,7 +23,7 @@ "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=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesxyz%22%7D", + "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" @@ -33,7 +33,7 @@ "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=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D", + "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", @@ -50,7 +50,7 @@ "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=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D", + "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": [ { @@ -66,7 +66,7 @@ }, "db_assertions": [ { - "query": "select count(*) from msgs_msg where direction = 'O' and text = 'Like this' and attachments = '{text/plain:https:///_test_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'", + "query": "select count(*) from msgs_msg where direction = 'O' and text = 'Like this' and attachments = '{text/plain:https:///_test_media_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'", "count": 1 } ] diff --git a/services/tickets/zendesk/testdata/event_callback.json b/services/tickets/zendesk/testdata/event_callback.json index e7c88bd73..f35d8fb67 100644 --- a/services/tickets/zendesk/testdata/event_callback.json +++ b/services/tickets/zendesk/testdata/event_callback.json @@ -185,7 +185,7 @@ "resource_events": [ { "type_id": "comment_on_new_ticket", - "external_id": "c69f103c-db64-4481-815b-1112890419ef", + "external_id": "$cathy_ticket_uuid$", "comment_id": 111, "ticket_id": 222 } diff --git a/services/tickets/zendesk/web.go b/services/tickets/zendesk/web.go index 46afdaf36..0f7c9bdae 100644 --- a/services/tickets/zendesk/web.go +++ b/services/tickets/zendesk/web.go @@ -13,6 +13,7 @@ import ( "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" @@ -48,7 +49,7 @@ type channelbackResponse struct { AllowChannelback bool `json:"allow_channelback"` } -func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleChannelback(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &channelbackRequest{} if err := web.DecodeAndValidateForm(request, r); err != nil { return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil @@ -61,7 +62,7 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int } // lookup the ticket and ticketer - ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, s.DB, flows.TicketUUID(request.ThreadID), typeZendesk) + ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(request.ThreadID), typeZendesk) if err != nil { return err, http.StatusBadRequest, nil } @@ -71,9 +72,17 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int return errors.New("ticketer secret mismatch"), http.StatusUnauthorized, nil } - err = models.UpdateAndKeepOpenTicket(ctx, s.DB, ticket, nil) - if err != nil { - return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusBadRequest, nil + // reopen ticket if necessary + if ticket.Status() != models.TicketStatusOpen { + oa, err := models.GetOrgAssets(ctx, rt.DB, ticket.OrgID()) + if err != nil { + return err, http.StatusBadRequest, nil + } + + err = tickets.ReopenTicket(ctx, rt, oa, ticket, false, nil) + if err != nil { + return errors.Wrapf(err, "error reopening ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil + } } // fetch files @@ -85,7 +94,7 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int } } - msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, request.Message, files) + msg, err := tickets.SendReply(ctx, rt, ticket, request.Message, files) if err != nil { return err, http.StatusBadRequest, nil } @@ -123,14 +132,14 @@ type eventCallbackRequest struct { Events []*channelEvent `json:"events" validate:"required"` } -func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { +func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { request := &eventCallbackRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return err, http.StatusBadRequest, nil } for _, e := range request.Events { - if err := processChannelEvent(ctx, s.DB, e, l); err != nil { + if err := processChannelEvent(ctx, rt.DB, e, l); err != nil { return err, http.StatusBadRequest, nil } } @@ -244,11 +253,11 @@ type targetRequest struct { Status string `json:"status"` } -func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { +func handleTicketerTarget(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer")) // look up our ticketer - ticketer, _, err := tickets.FromTicketerUUID(ctx, s.DB, ticketerUUID, typeZendesk) + ticketer, _, err := tickets.FromTicketerUUID(ctx, rt.DB, ticketerUUID, typeZendesk) if err != nil || ticketer == nil { return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil } @@ -266,7 +275,7 @@ func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l } // lookup ticket - ticket, err := models.LookupTicketByExternalID(ctx, s.DB, ticketer.ID(), fmt.Sprintf("%d", request.ID)) + 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 @@ -275,9 +284,9 @@ func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l if request.Event == "status_changed" { switch strings.ToLower(request.Status) { case statusSolved, statusClosed: - err = models.CloseTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l) + err = tickets.CloseTicket(ctx, rt, nil, ticket, false, l) case statusOpen: - err = models.ReopenTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l) + err = tickets.ReopenTicket(ctx, rt, nil, ticket, false, l) } if err != nil { diff --git a/services/tickets/zendesk/web_test.go b/services/tickets/zendesk/web_test.go index 47da4f24c..c00c0a17c 100644 --- a/services/tickets/zendesk/web_test.go +++ b/services/tickets/zendesk/web_test.go @@ -3,38 +3,34 @@ package zendesk import ( "testing" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" ) func TestChannelback(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Reset() // create a zendesk ticket for Cathy - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234") + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil) - web.RunWebTests(t, "testdata/channelback.json") + web.RunWebTests(t, "testdata/channelback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } func TestEventCallback(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Reset() // create a zendesk ticket for Cathy - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234") + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil) - web.RunWebTests(t, "testdata/event_callback.json") + web.RunWebTests(t, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } func TestTarget(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() + _, _, db, _ := testsuite.Reset() // create a zendesk ticket for Cathy - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234") + ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil) - web.RunWebTests(t, "testdata/target.json") + web.RunWebTests(t, "testdata/target.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } diff --git a/testsuite/assert.go b/testsuite/assert.go new file mode 100644 index 000000000..ae7e89721 --- /dev/null +++ b/testsuite/assert.go @@ -0,0 +1,103 @@ +package testsuite + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/gomodule/redigo/redis" + "github.com/jmoiron/sqlx" + "github.com/nyaruka/gocommon/jsonx" + "github.com/nyaruka/goflow/test" + "github.com/nyaruka/mailroom/core/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// AssertCourierQueues asserts the sizes of message batches in the named courier queues +func AssertCourierQueues(t *testing.T, expected map[string][]int, errMsg ...interface{}) { + rc := RC() + defer rc.Close() + + queueKeys, err := redis.Strings(rc.Do("KEYS", "msgs:????????-*")) + require.NoError(t, err) + + actual := make(map[string][]int, len(queueKeys)) + for _, queueKey := range queueKeys { + size, err := redis.Int64(rc.Do("ZCARD", queueKey)) + require.NoError(t, err) + actual[queueKey] = make([]int, size) + + if size > 0 { + results, err := redis.Values(rc.Do("ZPOPMAX", queueKey, size)) + require.NoError(t, err) + require.Equal(t, int(size*2), len(results)) // result is (item, score, item, score, ...) + + // unmarshal each item in the queue as a batch of messages + for i := 0; i < int(size); i++ { + batchJSON := results[i*2].([]byte) + var batch []map[string]interface{} + err = json.Unmarshal(batchJSON, &batch) + require.NoError(t, err) + + actual[queueKey][i] = len(batch) + } + } + } + + assert.Equal(t, expected, actual, errMsg...) +} + +// AssertContactTasks asserts that the given contact has the given tasks queued for them +func AssertContactTasks(t *testing.T, orgID models.OrgID, contactID models.ContactID, expected []string, msgAndArgs ...interface{}) { + rc := RC() + defer rc.Close() + + tasks, err := redis.Strings(rc.Do("LRANGE", fmt.Sprintf("c:%d:%d", orgID, contactID), 0, -1)) + require.NoError(t, err) + + expectedJSON := jsonx.MustMarshal(expected) + actualJSON := jsonx.MustMarshal(tasks) + + test.AssertEqualJSON(t, expectedJSON, actualJSON, "") +} + +// AssertQuery creates a new query on which one can assert things +func AssertQuery(t *testing.T, db *sqlx.DB, sql string, args ...interface{}) *Query { + return &Query{t, db, sql, args} +} + +type Query struct { + t *testing.T + db *sqlx.DB + sql string + args []interface{} +} + +func (q *Query) Returns(expected interface{}, msgAndArgs ...interface{}) { + q.t.Helper() + + // get a variable of same type to hold actual result + actual := expected + + err := q.db.Get(&actual, q.sql, q.args...) + assert.NoError(q.t, err, msgAndArgs...) + + // not sure why but if you pass an int you get back an int64.. + switch expected.(type) { + case int: + actual = int(actual.(int64)) + } + + assert.Equal(q.t, expected, actual, msgAndArgs...) +} + +func (q *Query) Columns(expected map[string]interface{}, msgAndArgs ...interface{}) { + q.t.Helper() + + actual := make(map[string]interface{}, len(expected)) + + err := q.db.QueryRowx(q.sql, q.args...).MapScan(actual) + assert.NoError(q.t, err, msgAndArgs...) + assert.Equal(q.t, expected, actual, msgAndArgs...) +} diff --git a/testsuite/testdata/channels.go b/testsuite/testdata/channels.go index bc41db26b..0abf0b191 100644 --- a/testsuite/testdata/channels.go +++ b/testsuite/testdata/channels.go @@ -1,24 +1,27 @@ package testdata import ( - "testing" - "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/null" "github.com/jmoiron/sqlx" "github.com/lib/pq" - "github.com/stretchr/testify/require" ) +type Channel struct { + ID models.ChannelID + UUID assets.ChannelUUID +} + // InsertChannel inserts a channel -func InsertChannel(t *testing.T, db *sqlx.DB, orgID models.OrgID, channelType, name string, schemes []string, role string, config map[string]interface{}) models.ChannelID { +func InsertChannel(db *sqlx.DB, org *Org, channelType, name string, schemes []string, role string, config map[string]interface{}) *Channel { + uuid := assets.ChannelUUID(uuids.New()) var id models.ChannelID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO channels_channel(uuid, org_id, channel_type, name, schemes, role, config, last_seen, is_active, created_on, modified_on, created_by_id, modified_by_id) - VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), TRUE, NOW(), NOW(), 1, 1) RETURNING id`, uuids.New(), orgID, channelType, name, pq.Array(schemes), role, null.NewMap(config), - ) - require.NoError(t, err) - return id + VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), TRUE, NOW(), NOW(), 1, 1) RETURNING id`, uuid, org.ID, channelType, name, pq.Array(schemes), role, null.NewMap(config), + )) + return &Channel{id, uuid} } diff --git a/testsuite/testdata/constants.go b/testsuite/testdata/constants.go new file mode 100644 index 000000000..a8cce2cc6 --- /dev/null +++ b/testsuite/testdata/constants.go @@ -0,0 +1,104 @@ +package testdata + +import ( + "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/mailroom/core/models" +) + +// Constants used in tests, these are tied to the DB created by the RapidPro `mailroom_db` management command. + +type Org struct { + ID models.OrgID + UUID uuids.UUID +} + +type User struct { + ID models.UserID + Email string +} + +type Classifier struct { + ID models.ClassifierID + UUID assets.ClassifierUUID +} + +type Campaign struct { + ID models.CampaignID + UUID models.CampaignUUID +} + +type CampaignEvent struct { + ID models.CampaignEventID +} + +var Org1 = &Org{1, "bf0514a5-9407-44c9-b0f9-3f36f9c18414"} +var Admin = &User{3, "admin1@nyaruka.com"} +var Editor = &User{4, "editor1@nyaruka.com"} +var Viewer = &User{5, "viewer1@nyaruka.com"} +var Agent = &User{6, "agent1@nyaruka.com"} +var Surveyor = &User{7, "surveyor1@nyaruka.com"} + +var TwilioChannel = &Channel{10000, "74729f45-7f29-4868-9dc4-90e491e3c7d8"} +var VonageChannel = &Channel{10001, "19012bfd-3ce3-4cae-9bb9-76cf92c73d49"} +var TwitterChannel = &Channel{10002, "0f661e8b-ea9d-4bd3-9953-d368340acf91"} + +var Cathy = &Contact{10000, "6393abc0-283d-4c9b-a1b3-641a035c34bf", "tel:+16055741111", 10000} +var Bob = &Contact{10001, "b699a406-7e44-49be-9f01-1a82893e8a10", "tel:+16055742222", 10001} +var George = &Contact{10002, "8d024bcd-f473-4719-a00a-bd0bb1190135", "tel:+16055743333", 10002} +var Alexandria = &Contact{10003, "9709c157-4606-4d41-9df3-9e9c9b4ae2d4", "tel:+16055744444", 10003} + +var Favorites = &Flow{10000, "9de3663f-c5c5-4c92-9f45-ecbc09abcc85"} +var PickANumber = &Flow{10001, "5890fe3a-f204-4661-b74d-025be4ee019c"} +var SingleMessage = &Flow{10004, "a7c11d68-f008-496f-b56d-2d5cf4cf16a5"} +var IVRFlow = &Flow{10003, "2f81d0ea-4d75-4843-9371-3f7465311cce"} +var SurveyorFlow = &Flow{10005, "ed8cf8d4-a42c-4ce1-a7e3-44a2918e3cec"} +var IncomingExtraFlow = &Flow{10006, "376d3de6-7f0e-408c-80d6-b1919738bc80"} +var ParentTimeoutFlow = &Flow{10007, "81c0f323-7e06-4e0c-a960-19c20f17117c"} +var CampaignFlow = &Flow{10009, "3a92a964-3a8d-420b-9206-2cd9d884ac30"} + +var CreatedOnField = &Field{3, "fd18a69d-7514-4b76-9fad-072641995e17"} +var LastSeenOnField = &Field{5, "660ebe03-b717-4a80-aebf-9b7c718266e1"} +var GenderField = &Field{6, "3a5891e4-756e-4dc9-8e12-b7a766168824"} +var AgeField = &Field{7, "903f51da-2717-47c7-a0d3-f2f32877013d"} +var JoinedField = &Field{8, "d83aae24-4bbf-49d0-ab85-6bfd201eac6d"} + +var AllContactsGroup = &Group{1, "d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008"} +var BlockedContactsGroup = &Group{2, "9295ebab-5c2d-4eb1-86f9-7c15ed2f3219"} +var DoctorsGroup = &Group{10000, "c153e265-f7c9-4539-9dbc-9b358714b638"} +var TestersGroup = &Group{10001, "5e9d8fab-5e7e-4f51-b533-261af5dea70d"} + +var ReportingLabel = &Label{10000, "ebc4dedc-91c4-4ed4-9dd6-daa05ea82698"} +var TestingLabel = &Label{10001, "a6338cdc-7938-4437-8b05-2d5d785e3a08"} + +var Mailgun = &Ticketer{1, "f9c9447f-a291-4f3c-8c79-c089bbd4e713"} +var Zendesk = &Ticketer{2, "4ee6d4f3-f92b-439b-9718-8da90c05490b"} +var RocketChat = &Ticketer{3, "6c50665f-b4ff-4e37-9625-bc464fe6a999"} +var Internal = &Ticketer{4, "8bd48029-6ca1-46a8-aa14-68f7213b82b3"} + +var Luis = &Classifier{1, "097e026c-ae79-4740-af67-656dbedf0263"} +var Wit = &Classifier{2, "ff2a817c-040a-4eb2-8404-7d92e8b79dd0"} +var Bothub = &Classifier{3, "859b436d-3005-4e43-9ad5-3de5f26ede4c"} + +var RemindersCampaign = &Campaign{10000, "72aa12c5-cc11-4bc7-9406-044047845c70"} +var RemindersEvent1 = &CampaignEvent{10000} +var RemindersEvent2 = &CampaignEvent{10001} + +// secondary org.. only a few things +var Org2 = &Org{2, "3ae7cdeb-fd96-46e5-abc4-a4622f349921"} +var Org2Admin = &User{8, "admin2@nyaruka.com"} +var Org2Channel = &Channel{20000, "a89bc872-3763-4b95-91d9-31d4e56c6651"} +var Org2Contact = &Contact{20000, "26d20b72-f7d8-44dc-87f2-aae046dbff95", "tel:+250700000005", 20000} +var Org2Favorites = &Flow{20000, "f161bd16-3c60-40bd-8c92-228ce815b9cd"} +var Org2SingleMessage = &Flow{20001, "5277916d-6011-41ac-a4a4-f6ac6a4f1dd9"} + +func must(err error, checks ...bool) { + if err != nil { + panic(err) + } + for _, check := range checks { + if !check { + panic("check failed") + } + } +} diff --git a/testsuite/testdata/contacts.go b/testsuite/testdata/contacts.go index 359f41dd9..558509873 100644 --- a/testsuite/testdata/contacts.go +++ b/testsuite/testdata/contacts.go @@ -1,7 +1,7 @@ package testdata import ( - "testing" + "context" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" @@ -11,46 +11,81 @@ import ( "github.com/nyaruka/null" "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" ) +type Contact struct { + ID models.ContactID + UUID flows.ContactUUID + URN urns.URN + URNID models.URNID +} + +func (c *Contact) Load(db *sqlx.DB, oa *models.OrgAssets) (*models.Contact, *flows.Contact) { + contacts, err := models.LoadContacts(context.Background(), db, oa, []models.ContactID{c.ID}) + must(err, len(contacts) == 1) + + flowContact, err := contacts[0].FlowContact(oa) + must(err) + + return contacts[0], flowContact +} + +type Group struct { + ID models.GroupID + UUID assets.GroupUUID +} + +func (g *Group) Add(db *sqlx.DB, contacts ...*Contact) { + for _, c := range contacts { + db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2)`, g.ID, c.ID) + } +} + +type Field struct { + ID models.FieldID + UUID assets.FieldUUID +} + // InsertContact inserts a contact -func InsertContact(t *testing.T, db *sqlx.DB, orgID models.OrgID, uuid flows.ContactUUID, name string, language envs.Language) models.ContactID { +func InsertContact(db *sqlx.DB, org *Org, uuid flows.ContactUUID, name string, language envs.Language) *Contact { var id models.ContactID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, created_on, modified_on, created_by_id, modified_by_id) - VALUES($1, TRUE, 'A', $2, $3, $4, NOW(), NOW(), 1, 1) RETURNING id`, orgID, uuid, name, language, - ) - require.NoError(t, err) - return id + VALUES($1, TRUE, 'A', $2, $3, $4, NOW(), NOW(), 1, 1) RETURNING id`, org.ID, uuid, name, language, + )) + return &Contact{id, uuid, "", models.NilURNID} } // InsertContactGroup inserts a contact group -func InsertContactGroup(t *testing.T, db *sqlx.DB, orgID models.OrgID, uuid assets.GroupUUID, name, query string) models.GroupID { +func InsertContactGroup(db *sqlx.DB, org *Org, uuid assets.GroupUUID, name, query string) *Group { var id models.GroupID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO contacts_contactgroup(uuid, org_id, group_type, name, query, status, is_active, created_by_id, created_on, modified_by_id, modified_on) - VALUES($1, $2, 'U', $3, $4, 'R', TRUE, 1, NOW(), 1, NOW()) RETURNING id`, uuid, models.Org1, name, null.String(query), - ) - require.NoError(t, err) - return id + VALUES($1, $2, 'U', $3, $4, 'R', TRUE, 1, NOW(), 1, NOW()) RETURNING id`, uuid, org.ID, name, null.String(query), + )) + return &Group{id, uuid} } // InsertContactURN inserts a contact URN -func InsertContactURN(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, urn urns.URN, priority int) models.URNID { +func InsertContactURN(db *sqlx.DB, org *Org, contact *Contact, urn urns.URN, priority int) models.URNID { scheme, path, _, _ := urn.ToParts() + contactID := models.NilContactID + if contact != nil { + contactID = contact.ID + } + var id models.URNID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO contacts_contacturn(org_id, contact_id, scheme, path, identity, priority) - VALUES($1, $2, $3, $4, $5, $6) RETURNING id`, orgID, contactID, scheme, path, urn.Identity(), priority, - ) - require.NoError(t, err) + VALUES($1, $2, $3, $4, $5, $6) RETURNING id`, org.ID, contactID, scheme, path, urn.Identity(), priority, + )) return id } // DeleteContactsAndURNs deletes all contacts and URNs -func DeleteContactsAndURNs(t *testing.T, db *sqlx.DB) { +func DeleteContactsAndURNs(db *sqlx.DB) { + db.MustExec(`DELETE FROM msgs_msg`) db.MustExec(`DELETE FROM contacts_contacturn`) db.MustExec(`DELETE FROM contacts_contactgroup_contacts`) db.MustExec(`DELETE FROM contacts_contact`) diff --git a/testsuite/testdata/flows.go b/testsuite/testdata/flows.go index 9ae8fdff6..fdccee3db 100644 --- a/testsuite/testdata/flows.go +++ b/testsuite/testdata/flows.go @@ -1,54 +1,55 @@ package testdata import ( - "testing" "time" "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/null" "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" ) +type Flow struct { + ID models.FlowID + UUID assets.FlowUUID +} + // InsertFlowStart inserts a flow start -func InsertFlowStart(t *testing.T, db *sqlx.DB, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID) models.StartID { +func InsertFlowStart(db *sqlx.DB, org *Org, flow *Flow, contacts []*Contact) models.StartID { var id models.StartID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO flows_flowstart(uuid, org_id, flow_id, start_type, created_on, modified_on, restart_participants, include_active, contact_count, status, created_by_id) - VALUES($1, $2, $3, 'M', NOW(), NOW(), TRUE, TRUE, 2, 'P', 1) RETURNING id`, uuids.New(), orgID, flowID, - ) - require.NoError(t, err) + VALUES($1, $2, $3, 'M', NOW(), NOW(), TRUE, TRUE, 2, 'P', 1) RETURNING id`, uuids.New(), org.ID, flow.ID, + )) - for i := range contactIDs { - db.MustExec(`INSERT INTO flows_flowstart_contacts(flowstart_id, contact_id) VALUES($1, $2)`, id, contactIDs[i]) + for _, c := range contacts { + db.MustExec(`INSERT INTO flows_flowstart_contacts(flowstart_id, contact_id) VALUES($1, $2)`, id, c.ID) } return id } // InsertFlowSession inserts a flow session -func InsertFlowSession(t *testing.T, db *sqlx.DB, uuid flows.SessionUUID, orgID models.OrgID, contactID models.ContactID, status models.SessionStatus, timeoutOn *time.Time) models.SessionID { +func InsertFlowSession(db *sqlx.DB, org *Org, contact *Contact, status models.SessionStatus, timeoutOn *time.Time) models.SessionID { var id models.SessionID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO flows_flowsession(uuid, org_id, contact_id, status, responded, created_on, timeout_on, session_type) - VALUES($1, $2, $3, $4, TRUE, NOW(), $5, 'M') RETURNING id`, uuid, orgID, contactID, status, timeoutOn, - ) - require.NoError(t, err) + VALUES($1, $2, $3, $4, TRUE, NOW(), $5, 'M') RETURNING id`, uuids.New(), org.ID, contact.ID, status, timeoutOn, + )) return id } // InsertFlowRun inserts a flow run -func InsertFlowRun(t *testing.T, db *sqlx.DB, uuid flows.RunUUID, orgID models.OrgID, sessionID models.SessionID, contactID models.ContactID, flowID models.FlowID, status models.RunStatus, parent flows.RunUUID, expiresOn *time.Time) models.FlowRunID { +func InsertFlowRun(db *sqlx.DB, org *Org, sessionID models.SessionID, contact *Contact, flow *Flow, status models.RunStatus, parent flows.RunUUID, expiresOn *time.Time) models.FlowRunID { isActive := status == models.RunStatusActive || status == models.RunStatusWaiting var id models.FlowRunID - err := db.Get(&id, + must(db.Get(&id, `INSERT INTO flows_flowrun(uuid, org_id, session_id, contact_id, flow_id, status, is_active, parent_uuid, responded, created_on, modified_on, expires_on) - VALUES($1, $2, $3, $4, $5, $6, $7, $8, TRUE, NOW(), NOW(), $9) RETURNING id`, uuid, orgID, null.Int(sessionID), contactID, flowID, status, isActive, null.String(parent), expiresOn, - ) - require.NoError(t, err) + VALUES($1, $2, $3, $4, $5, $6, $7, $8, TRUE, NOW(), NOW(), $9) RETURNING id`, uuids.New(), org.ID, null.Int(sessionID), contact.ID, flow.ID, status, isActive, null.String(parent), expiresOn, + )) return id } diff --git a/testsuite/testdata/imports.go b/testsuite/testdata/imports.go index 366b9663e..dae4bc614 100644 --- a/testsuite/testdata/imports.go +++ b/testsuite/testdata/imports.go @@ -2,34 +2,31 @@ package testdata import ( "encoding/json" - "testing" "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/mailroom/core/models" "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" ) // InsertContactImport inserts a contact import -func InsertContactImport(t *testing.T, db *sqlx.DB, orgID models.OrgID) models.ContactImportID { +func InsertContactImport(db *sqlx.DB, org *Org) models.ContactImportID { var importID models.ContactImportID - err := db.Get(&importID, `INSERT INTO contacts_contactimport(org_id, file, original_filename, headers, mappings, num_records, group_id, started_on, created_on, created_by_id, modified_on, modified_by_id, is_active) - VALUES($1, 'contact_imports/1234.xlsx', 'contacts.xlsx', '{"Name", "URN:Tel"}', '{}', 30, NULL, $2, $2, 1, $2, 1, TRUE) RETURNING id`, models.Org1, dates.Now()) - require.NoError(t, err) + must(db.Get(&importID, `INSERT INTO contacts_contactimport(org_id, file, original_filename, headers, mappings, num_records, group_id, started_on, created_on, created_by_id, modified_on, modified_by_id, is_active) + VALUES($1, 'contact_imports/1234.xlsx', 'contacts.xlsx', '{"Name", "URN:Tel"}', '{}', 30, NULL, $2, $2, 1, $2, 1, TRUE) RETURNING id`, org.ID, dates.Now(), + )) return importID } // InsertContactImportBatch inserts a contact import batch -func InsertContactImportBatch(t *testing.T, db *sqlx.DB, importID models.ContactImportID, specs json.RawMessage) models.ContactImportBatchID { +func InsertContactImportBatch(db *sqlx.DB, importID models.ContactImportID, specs json.RawMessage) models.ContactImportBatchID { var splitSpecs []json.RawMessage - err := jsonx.Unmarshal(specs, &splitSpecs) - require.NoError(t, err) + must(jsonx.Unmarshal(specs, &splitSpecs)) var batchID models.ContactImportBatchID - err = db.Get(&batchID, `INSERT INTO contacts_contactimportbatch(contact_import_id, status, specs, record_start, record_end, num_created, num_updated, num_errored, errors, finished_on) - VALUES($1, 'P', $2, 0, $3, 0, 0, 0, '[]', NULL) RETURNING id`, importID, specs, len(splitSpecs)) - require.NoError(t, err) + must(db.Get(&batchID, `INSERT INTO contacts_contactimportbatch(contact_import_id, status, specs, record_start, record_end, num_created, num_updated, num_errored, errors, finished_on) + VALUES($1, 'P', $2, 0, $3, 0, 0, 0, '[]', NULL) RETURNING id`, importID, specs, len(splitSpecs), + )) return batchID } diff --git a/testsuite/testdata/msgs.go b/testsuite/testdata/msgs.go index f4fc62964..2614f1a05 100644 --- a/testsuite/testdata/msgs.go +++ b/testsuite/testdata/msgs.go @@ -1,27 +1,78 @@ package testdata import ( - "testing" + "database/sql" + "time" - "github.com/nyaruka/gocommon/urns" + "github.com/lib/pq" + "github.com/lib/pq/hstore" + "github.com/nyaruka/gocommon/dates" "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/jmoiron/sqlx" - "github.com/stretchr/testify/require" ) +type Label struct { + ID models.LabelID + UUID assets.LabelUUID +} + // InsertIncomingMsg inserts an incoming message -func InsertIncomingMsg(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, urn urns.URN, urnID models.URNID, text string) *flows.MsgIn { +func InsertIncomingMsg(db *sqlx.DB, org *Org, channel *Channel, contact *Contact, text string, status models.MsgStatus) *flows.MsgIn { msgUUID := flows.MsgUUID(uuids.New()) var id flows.MsgID - err := db.Get(&id, - `INSERT INTO msgs_msg(uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id) - VALUES($1, $2, NOW(), 'I', 'P', 'V', 1, 0, NOW(), $3, $4, $5) RETURNING id`, msgUUID, text, contactID, urnID, orgID) - require.NoError(t, err) + must(db.Get(&id, + `INSERT INTO msgs_msg(uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id, channel_id) + VALUES($1, $2, NOW(), 'I', $3, 'V', 1, 0, NOW(), $4, $5, $6, $7) RETURNING id`, msgUUID, text, status, contact.ID, contact.URNID, org.ID, channel.ID, + )) + + msg := flows.NewMsgIn(msgUUID, contact.URN, assets.NewChannelReference(channel.UUID, ""), text, nil) + msg.SetID(id) + return msg +} + +// InsertOutgoingMsg inserts an outgoing message +func InsertOutgoingMsg(db *sqlx.DB, org *Org, channel *Channel, contact *Contact, text string, attachments []utils.Attachment, status models.MsgStatus) *flows.MsgOut { + msg := flows.NewMsgOut(contact.URN, assets.NewChannelReference(channel.UUID, ""), text, attachments, nil, nil, flows.NilMsgTopic) - msg := flows.NewMsgIn(msgUUID, urn, nil, text, nil) + var sentOn *time.Time + if status == models.MsgStatusWired || status == models.MsgStatusSent || status == models.MsgStatusDelivered { + t := dates.Now() + sentOn = &t + } + + var id flows.MsgID + must(db.Get(&id, + `INSERT INTO msgs_msg(uuid, text, attachments, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id, channel_id, sent_on) + VALUES($1, $2, $3, NOW(), 'O', $4, 'V', 1, 0, NOW(), $5, $6, $7, $8, $9) RETURNING id`, msg.UUID(), text, pq.Array(attachments), status, contact.ID, contact.URNID, org.ID, channel.ID, sentOn, + )) msg.SetID(id) return msg } + +func InsertBroadcast(db *sqlx.DB, org *Org, baseLanguage envs.Language, text map[envs.Language]string, schedID models.ScheduleID, contacts []*Contact, groups []*Group) models.BroadcastID { + textMap := make(map[string]sql.NullString, len(text)) + for lang, t := range text { + textMap[string(lang)] = sql.NullString{String: t, Valid: true} + } + + var id models.BroadcastID + must(db.Get(&id, + `INSERT INTO msgs_broadcast(org_id, base_language, text, schedule_id, status, send_all, created_on, modified_on, created_by_id, modified_by_id) + VALUES($1, $2, $3, $4, 'P', TRUE, NOW(), NOW(), 1, 1) RETURNING id`, org.ID, baseLanguage, hstore.Hstore{Map: textMap}, schedID, + )) + + for _, contact := range contacts { + db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2)`, id, contact.ID) + } + for _, group := range groups { + db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, id, group.ID) + } + + return id +} diff --git a/testsuite/testdata/tickets.go b/testsuite/testdata/tickets.go index 477357aad..c6dba78c4 100644 --- a/testsuite/testdata/tickets.go +++ b/testsuite/testdata/tickets.go @@ -1,33 +1,60 @@ package testdata import ( - "testing" + "context" + "time" + "github.com/nyaruka/gocommon/dates" + "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/mailroom/core/models" "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" ) +type Ticket struct { + ID models.TicketID + UUID flows.TicketUUID +} + +func (k *Ticket) Load(db *sqlx.DB) *models.Ticket { + tickets, err := models.LoadTickets(context.Background(), db, []models.TicketID{k.ID}) + must(err, len(tickets) == 1) + return tickets[0] +} + +type Ticketer struct { + ID models.TicketerID + UUID assets.TicketerUUID +} + // InsertOpenTicket inserts an open ticket -func InsertOpenTicket(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, ticketerID models.TicketerID, uuid flows.TicketUUID, subject, body, externalID string) models.TicketID { - var id models.TicketID - err := db.Get(&id, - `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on) - VALUES($1, $2, $3, $4, 'O', $5, $6, $7, NOW(), NOW()) RETURNING id`, uuid, orgID, contactID, ticketerID, subject, body, externalID, - ) - require.NoError(t, err) - return id +func InsertOpenTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, subject, body, externalID string, assignee *User) *Ticket { + return insertTicket(db, org, contact, ticketer, models.TicketStatusOpen, subject, body, externalID, assignee) } // InsertClosedTicket inserts a closed ticket -func InsertClosedTicket(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, ticketerID models.TicketerID, uuid flows.TicketUUID, subject, body, externalID string) models.TicketID { +func InsertClosedTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, subject, body, externalID string, assignee *User) *Ticket { + return insertTicket(db, org, contact, ticketer, models.TicketStatusClosed, subject, body, externalID, assignee) +} + +func insertTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, status models.TicketStatus, subject, body, externalID string, assignee *User) *Ticket { + uuid := flows.TicketUUID(uuids.New()) + var closedOn *time.Time + if status == models.TicketStatusClosed { + t := dates.Now() + closedOn = &t + } + assigneeID := models.NilUserID + if assignee != nil { + assigneeID = assignee.ID + } + var id models.TicketID - err := db.Get(&id, - `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on, closed_on) - VALUES($1, $2, $3, $4, 'C', $5, $6, $7, NOW(), NOW(), NOW()) RETURNING id`, uuid, orgID, contactID, ticketerID, subject, body, externalID, - ) - require.NoError(t, err) - return id + must(db.Get(&id, + `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on, closed_on, last_activity_on, assignee_id) + VALUES($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW(), $9, NOW(), $10) RETURNING id`, uuid, org.ID, contact.ID, ticketer.ID, status, subject, body, externalID, closedOn, assigneeID, + )) + return &Ticket{id, uuid} } diff --git a/testsuite/testdata/triggers.go b/testsuite/testdata/triggers.go new file mode 100644 index 000000000..a83b612d3 --- /dev/null +++ b/testsuite/testdata/triggers.go @@ -0,0 +1,68 @@ +package testdata + +import ( + "github.com/nyaruka/mailroom/core/models" + + "github.com/jmoiron/sqlx" +) + +func InsertKeywordTrigger(db *sqlx.DB, org *Org, flow *Flow, keyword string, matchType models.MatchType, includeGroups []*Group, excludeGroups []*Group) models.TriggerID { + return insertTrigger(db, org, models.KeywordTriggerType, flow, keyword, matchType, includeGroups, excludeGroups, nil, "", nil) +} + +func InsertIncomingCallTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group) models.TriggerID { + return insertTrigger(db, org, models.IncomingCallTriggerType, flow, "", "", includeGroups, excludeGroups, nil, "", nil) +} + +func InsertMissedCallTrigger(db *sqlx.DB, org *Org, flow *Flow) models.TriggerID { + return insertTrigger(db, org, models.MissedCallTriggerType, flow, "", "", nil, nil, nil, "", nil) +} + +func InsertNewConversationTrigger(db *sqlx.DB, org *Org, flow *Flow, channel *Channel) models.TriggerID { + return insertTrigger(db, org, models.NewConversationTriggerType, flow, "", "", nil, nil, nil, "", channel) +} + +func InsertReferralTrigger(db *sqlx.DB, org *Org, flow *Flow, referrerID string, channel *Channel) models.TriggerID { + return insertTrigger(db, org, models.ReferralTriggerType, flow, "", "", nil, nil, nil, referrerID, channel) +} + +func InsertCatchallTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group) models.TriggerID { + return insertTrigger(db, org, models.CatchallTriggerType, flow, "", "", includeGroups, excludeGroups, nil, "", nil) +} + +func InsertScheduledTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group, includeContacts []*Contact) models.TriggerID { + return insertTrigger(db, org, models.ScheduleTriggerType, flow, "", "", includeGroups, excludeGroups, includeContacts, "", nil) +} + +func InsertTicketClosedTrigger(db *sqlx.DB, org *Org, flow *Flow) models.TriggerID { + return insertTrigger(db, org, models.TicketClosedTriggerType, flow, "", "", nil, nil, nil, "", nil) +} + +func insertTrigger(db *sqlx.DB, org *Org, triggerType models.TriggerType, flow *Flow, keyword string, matchType models.MatchType, includeGroups, excludeGroups []*Group, contactIDs []*Contact, referrerID string, channel *Channel) models.TriggerID { + channelID := models.NilChannelID + if channel != nil { + channelID = channel.ID + } + + var id models.TriggerID + must(db.Get(&id, + `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, referrer_id, is_archived, + flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id) + VALUES(TRUE, now(), now(), $1, $5, false, $2, $3, $4, 1, 1, $7, $6) RETURNING id`, keyword, flow.ID, triggerType, matchType, referrerID, channelID, org.ID, + )) + + // insert group associations + for _, g := range includeGroups { + db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, id, g.ID) + } + for _, g := range excludeGroups { + db.MustExec(`INSERT INTO triggers_trigger_exclude_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, id, g.ID) + } + + // insert contact associations + for _, c := range contactIDs { + db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2)`, id, c.ID) + } + + return id +} diff --git a/testsuite/testsuite.go b/testsuite/testsuite.go index 10d880c12..1b67bccca 100644 --- a/testsuite/testsuite.go +++ b/testsuite/testsuite.go @@ -7,25 +7,44 @@ import ( "os/exec" "path" "strings" - "testing" "github.com/nyaruka/gocommon/storage" + "github.com/nyaruka/mailroom/config" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" ) -const storageDir = "_test_storage" +const MediaStorageDir = "_test_media_storage" +const SessionStorageDir = "_test_session_storage" // Reset clears out both our database and redis DB -func Reset() (context.Context, *sqlx.DB, *redis.Pool) { - logrus.SetLevel(logrus.DebugLevel) +func Reset() (context.Context, *runtime.Runtime, *sqlx.DB, *redis.Pool) { ResetDB() ResetRP() - return CTX(), DB(), RP() + models.FlushCache() + logrus.SetLevel(logrus.DebugLevel) + + return Get() +} + +// Get returns the various runtime things a test might need +func Get() (context.Context, *runtime.Runtime, *sqlx.DB, *redis.Pool) { + db := DB() + rp := RP() + rt := &runtime.Runtime{ + RP: rp, + DB: db, + ES: nil, + MediaStorage: MediaStorage(), + SessionStorage: SessionStorage(), + Config: config.NewMailroomConfig(), + } + return context.Background(), rt, db, rp } // ResetDB resets our database to our base state from our RapidPro dump @@ -38,6 +57,7 @@ func Reset() (context.Context, *sqlx.DB, *redis.Pool) { func ResetDB() { db := sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC") defer db.Close() + db.MustExec("drop owned by mailroom_test cascade") dir, _ := os.Getwd() @@ -52,8 +72,7 @@ func ResetDB() { // DB returns an open test database pool func DB() *sqlx.DB { - db := sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC") - return db + return sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC") } // ResetRP resets our redis database @@ -96,19 +115,23 @@ func RC() redis.Conn { return conn } -// CTX returns our background testing context -func CTX() context.Context { - return context.Background() +// MediaStorage returns our media storage for tests +func MediaStorage() storage.Storage { + return storage.NewFS(MediaStorageDir) } -// Storage returns our storage for tests -func Storage() storage.Storage { - return storage.NewFS(storageDir) +// SessionStorage returns our session storage for tests +func SessionStorage() storage.Storage { + return storage.NewFS(SessionStorageDir) } // ResetStorage clears our storage for tests func ResetStorage() { - if err := os.RemoveAll(storageDir); err != nil { + if err := os.RemoveAll(MediaStorageDir); err != nil { + panic(err) + } + + if err := os.RemoveAll(SessionStorageDir); err != nil { panic(err) } } @@ -121,13 +144,3 @@ func mustExec(command string, args ...string) { panic(fmt.Sprintf("error restoring database: %s: %s", err, string(output))) } } - -// AssertQueryCount can be used to assert that a query returns the expected number of -func AssertQueryCount(t *testing.T, db *sqlx.DB, sql string, args []interface{}, count int, errMsg ...interface{}) { - var c int - err := db.Get(&c, sql, args...) - if err != nil { - assert.Fail(t, "error performing query: %s - %s", sql, err) - } - assert.Equal(t, count, c, errMsg...) -} diff --git a/utils/cron/cron.go b/utils/cron/cron.go index 9fb81f002..6c9f68c5f 100644 --- a/utils/cron/cron.go +++ b/utils/cron/cron.go @@ -28,7 +28,7 @@ func StartCron(quit chan bool, rp *redis.Pool, name string, interval time.Durati defer log.Info("exiting") // we run expiration every minute on the minute - for true { + for { select { case <-quit: // we are exiting, return so our goroutine can exit diff --git a/utils/dbutil/errors.go b/utils/dbutil/errors.go index fb20bd441..ebb316e48 100644 --- a/utils/dbutil/errors.go +++ b/utils/dbutil/errors.go @@ -1,6 +1,12 @@ package dbutil -import "github.com/lib/pq" +import ( + "errors" + "fmt" + + "github.com/lib/pq" + "github.com/sirupsen/logrus" +) // IsUniqueViolation returns true if the given error is a violation of unique constraint func IsUniqueViolation(err error) bool { @@ -9,3 +15,41 @@ func IsUniqueViolation(err error) bool { } return false } + +// QueryError is an error type for failed SQL queries +type QueryError struct { + cause error + message string + sql string + sqlArgs []interface{} +} + +func (e *QueryError) Error() string { + return e.message + ": " + e.cause.Error() +} + +func (e *QueryError) Unwrap() error { + return e.cause +} + +func (e *QueryError) Fields() logrus.Fields { + return logrus.Fields{ + "sql": fmt.Sprintf("%.1000s", e.sql), + "sql_args": e.sqlArgs, + } +} + +func NewQueryErrorf(cause error, sql string, sqlArgs []interface{}, message string, msgArgs ...interface{}) error { + return &QueryError{ + cause: cause, + message: fmt.Sprintf(message, msgArgs...), + sql: sql, + sqlArgs: sqlArgs, + } +} + +func AsQueryError(err error) *QueryError { + var qerr *QueryError + errors.As(err, &qerr) + return qerr +} diff --git a/utils/dbutil/errors_test.go b/utils/dbutil/errors_test.go index f06b2ce32..712e13a4c 100644 --- a/utils/dbutil/errors_test.go +++ b/utils/dbutil/errors_test.go @@ -5,6 +5,7 @@ import ( "github.com/lib/pq" "github.com/nyaruka/mailroom/utils/dbutil" + "github.com/sirupsen/logrus" "github.com/pkg/errors" "github.com/stretchr/testify/assert" @@ -16,3 +17,27 @@ func TestIsUniqueViolation(t *testing.T) { assert.True(t, dbutil.IsUniqueViolation(err)) assert.False(t, dbutil.IsUniqueViolation(errors.New("boom"))) } + +func TestQueryError(t *testing.T) { + var err error = &pq.Error{Code: pq.ErrorCode("22025"), Message: "unsupported Unicode escape sequence"} + + qerr := dbutil.NewQueryErrorf(err, "SELECT * FROM foo WHERE id = $1", []interface{}{234}, "error selecting foo %d", 234) + assert.Error(t, qerr) + assert.Equal(t, `error selecting foo 234: pq: unsupported Unicode escape sequence`, qerr.Error()) + + // can unwrap to the original error + var pqerr *pq.Error + assert.True(t, errors.As(qerr, &pqerr)) + assert.Equal(t, err, pqerr) + + // can unwrap a wrapped error to find the first query error + wrapped := errors.Wrap(errors.Wrap(qerr, "error doing this"), "error doing that") + unwrapped := dbutil.AsQueryError(wrapped) + assert.Equal(t, qerr, unwrapped) + + // nil if error was never a query error + wrapped = errors.Wrap(errors.New("error doing this"), "error doing that") + assert.Nil(t, dbutil.AsQueryError(wrapped)) + + assert.Equal(t, logrus.Fields{"sql": "SELECT * FROM foo WHERE id = $1", "sql_args": []interface{}{234}}, unwrapped.Fields()) +} diff --git a/utils/dbutil/json_test.go b/utils/dbutil/json_test.go index 6441eb239..70901a8f4 100644 --- a/utils/dbutil/json_test.go +++ b/utils/dbutil/json_test.go @@ -3,18 +3,17 @@ package dbutil_test import ( "testing" - "github.com/jmoiron/sqlx" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/utils/dbutil" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadJSONRow(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() type group struct { UUID string `json:"uuid"` @@ -29,7 +28,7 @@ func TestReadJSONRow(t *testing.T) { } // if query returns valid JSON which can be unmarshaled into our struct, all good - rows := queryRows(`SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, models.TestersGroupID) + rows := queryRows(`SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, testdata.TestersGroup.ID) g := &group{} err := dbutil.ReadJSONRow(rows, g) @@ -38,12 +37,12 @@ func TestReadJSONRow(t *testing.T) { assert.Equal(t, "Testers", g.Name) // error if row value is not JSON - rows = queryRows(`SELECT id FROM contacts_contactgroup g WHERE id = $1`, models.TestersGroupID) + rows = queryRows(`SELECT id FROM contacts_contactgroup g WHERE id = $1`, testdata.TestersGroup.ID) err = dbutil.ReadJSONRow(rows, g) assert.EqualError(t, err, "error unmarshalling row JSON: json: cannot unmarshal number into Go value of type dbutil_test.group") // error if rows aren't ready to be scanned - e.g. next hasn't been called - rows, err = db.QueryxContext(ctx, `SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, models.TestersGroupID) + rows, err = db.QueryxContext(ctx, `SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, testdata.TestersGroup.ID) require.NoError(t, err) err = dbutil.ReadJSONRow(rows, g) assert.EqualError(t, err, "error scanning row JSON: sql: Scan called without calling Next") diff --git a/utils/dbutil/query.go b/utils/dbutil/query.go index c7b00bbf1..4b2ade841 100644 --- a/utils/dbutil/query.go +++ b/utils/dbutil/query.go @@ -29,7 +29,7 @@ func BulkQuery(ctx context.Context, tx Queryer, query string, structs []interfac rows, err := tx.QueryxContext(ctx, bulkQuery, args...) if err != nil { - return errors.Wrapf(err, "error making bulk query: %.100s", bulkQuery) + return NewQueryErrorf(err, bulkQuery, args, "error making bulk query") } defer rows.Close() diff --git a/utils/dbutil/query_test.go b/utils/dbutil/query_test.go index 5c3801335..c28859565 100644 --- a/utils/dbutil/query_test.go +++ b/utils/dbutil/query_test.go @@ -24,11 +24,11 @@ func TestBulkSQL(t *testing.T) { sql := `INSERT INTO contacts_contact (id, name) VALUES(:id, :name)` // try with zero structs - query, args, err := dbutil.BulkSQL(db, sql, []interface{}{}) + _, _, err = dbutil.BulkSQL(db, sql, []interface{}{}) assert.EqualError(t, err, "can't generate bulk sql with zero structs") // try with one struct - query, args, err = dbutil.BulkSQL(db, sql, []interface{}{contact{ID: 1, Name: "Bob"}}) + query, args, err := dbutil.BulkSQL(db, sql, []interface{}{contact{ID: 1, Name: "Bob"}}) assert.NoError(t, err) assert.Equal(t, `INSERT INTO contacts_contact (id, name) VALUES($1, $2)`, query) assert.Equal(t, []interface{}{1, "Bob"}, args) @@ -41,11 +41,11 @@ func TestBulkSQL(t *testing.T) { } func TestBulkQuery(t *testing.T) { - ctx := testsuite.CTX() - db := testsuite.DB() + ctx, _, db, _ := testsuite.Get() + defer testsuite.Reset() - db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name TEXT, age INT)`) + db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name VARCHAR(3), age INT)`) type foo struct { ID int `db:"id"` @@ -66,13 +66,19 @@ func TestBulkQuery(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, foo1.ID) assert.Equal(t, 2, foo2.ID) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Bob' AND age = 64`, nil, 1) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Jon' AND age = 34`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Bob' AND age = 64`).Returns(1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Jon' AND age = 34`).Returns(1) // returning ids is optional foo3 := &foo{Name: "Jim", Age: 54} err = dbutil.BulkQuery(ctx, db, `INSERT INTO foo (name, age) VALUES(:name, :age)`, []interface{}{foo3}) assert.NoError(t, err) assert.Equal(t, 0, foo3.ID) - testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Jim' AND age = 54`, nil, 1) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Jim' AND age = 54`).Returns(1) + + // try with a struct that is invalid + foo4 := &foo{Name: "Jonny", Age: 34} + err = dbutil.BulkQuery(ctx, db, `INSERT INTO foo (name, age) VALUES(:name, :age)`, []interface{}{foo4}) + assert.EqualError(t, err, "error making bulk query: pq: value too long for type character varying(3)") + assert.Equal(t, 0, foo4.ID) } diff --git a/utils/locker/locker.go b/utils/locker/locker.go index e2191ee62..36340f9d8 100644 --- a/utils/locker/locker.go +++ b/utils/locker/locker.go @@ -9,8 +9,6 @@ import ( "github.com/pkg/errors" ) -const sleep = time.Second * 1 - // GrabLock grabs the passed in lock from redis in an atomic operation. It returns the lock value // if successful. It will retry until the retry period, returning empty string if not acquired // in that time. diff --git a/web/contact/contact.go b/web/contact/contact.go index 9c9c2b397..da97d6524 100644 --- a/web/contact/contact.go +++ b/web/contact/contact.go @@ -10,6 +10,7 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" @@ -42,14 +43,14 @@ type createRequest struct { } // handles a request to create the given contact -func handleCreate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleCreate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &createRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } // grab our org - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } @@ -59,13 +60,13 @@ func handleCreate(ctx context.Context, s *web.Server, r *http.Request) (interfac return err, http.StatusBadRequest, nil } - _, contact, err := models.CreateContact(ctx, s.DB, oa, request.UserID, c.Name, c.Language, c.URNs) + _, contact, err := models.CreateContact(ctx, rt.DB, oa, request.UserID, c.Name, c.Language, c.URNs) if err != nil { return err, http.StatusBadRequest, nil } modifiersByContact := map[*flows.Contact][]flows.Modifier{contact: c.Mods} - _, err = models.ApplyModifiers(ctx, s.DB, s.RP, oa, modifiersByContact) + _, err = models.ApplyModifiers(ctx, rt.DB, rt.RP, oa, modifiersByContact) if err != nil { return nil, http.StatusInternalServerError, errors.Wrap(err, "error modifying new contact") } @@ -118,14 +119,14 @@ type modifyResult struct { } // handles a request to apply the passed in actions -func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleModify(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &modifyRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } // grab our org assets - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } @@ -137,7 +138,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac } // load our contacts - contacts, err := models.LoadContacts(ctx, s.DB, oa, request.ContactIDs) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, request.ContactIDs) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load contact") } @@ -153,7 +154,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac modifiersByContact[flowContact] = mods } - eventsByContact, err := models.ApplyModifiers(ctx, s.DB, s.RP, oa, modifiersByContact) + eventsByContact, err := models.ApplyModifiers(ctx, rt.DB, rt.RP, oa, modifiersByContact) if err != nil { return nil, http.StatusBadRequest, err } @@ -185,19 +186,19 @@ type resolveRequest struct { } // handles a request to resolve a contact -func handleResolve(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleResolve(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &resolveRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } // grab our org - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } - _, contact, created, err := models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{request.URN}, request.ChannelID) + _, contact, created, err := models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{request.URN}, request.ChannelID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error getting or creating contact") } diff --git a/web/contact/contact_test.go b/web/contact/contact_test.go index 857baa93d..e35c40f54 100644 --- a/web/contact/contact_test.go +++ b/web/contact/contact_test.go @@ -8,6 +8,7 @@ import ( _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" ) @@ -16,11 +17,11 @@ func TestCreateContacts(t *testing.T) { db := testsuite.DB() // detach Cathy's tel URN - db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID) + db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID) db.MustExec(`ALTER SEQUENCE contacts_contact_id_seq RESTART WITH 30000`) - web.RunWebTests(t, "testdata/create.json") + web.RunWebTests(t, "testdata/create.json", nil) } func TestModifyContacts(t *testing.T) { @@ -28,27 +29,27 @@ func TestModifyContacts(t *testing.T) { db := testsuite.DB() // to be deterministic, update the creation date on cathy - db.MustExec(`UPDATE contacts_contact SET created_on = $1 WHERE id = $2`, time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC), models.CathyID) + db.MustExec(`UPDATE contacts_contact SET created_on = $1 WHERE id = $2`, time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC), testdata.Cathy.ID) // make our campaign group dynamic - db.MustExec(`UPDATE contacts_contactgroup SET query = 'age > 18' WHERE id = $1`, models.DoctorsGroupID) + db.MustExec(`UPDATE contacts_contactgroup SET query = 'age > 18' WHERE id = $1`, testdata.DoctorsGroup.ID) // insert an event on our campaign that is based on created on db.MustExec( `INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour, campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode) VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I')`, - uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID) + uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID) // for simpler tests we clear out cathy's fields and groups to start - db.MustExec(`UPDATE contacts_contact SET fields = NULL WHERE id = $1`, models.CathyID) - db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.CathyID) - db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID) + db.MustExec(`UPDATE contacts_contact SET fields = NULL WHERE id = $1`, testdata.Cathy.ID) + db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Cathy.ID) + db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID) // because we made changes to a group above, need to make sure we don't use stale org assets models.FlushCache() - web.RunWebTests(t, "testdata/modify.json") + web.RunWebTests(t, "testdata/modify.json", nil) models.FlushCache() } @@ -58,9 +59,9 @@ func TestResolveContacts(t *testing.T) { db := testsuite.DB() // detach Cathy's tel URN - db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID) + db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID) db.MustExec(`ALTER SEQUENCE contacts_contact_id_seq RESTART WITH 30000`) - web.RunWebTests(t, "testdata/resolve.json") + web.RunWebTests(t, "testdata/resolve.json", nil) } diff --git a/web/contact/search.go b/web/contact/search.go index 9be8880ad..33a57fc12 100644 --- a/web/contact/search.go +++ b/web/contact/search.go @@ -8,6 +8,7 @@ import ( "github.com/nyaruka/goflow/contactql" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" @@ -65,7 +66,7 @@ type searchResponse struct { } // handles a contact search request -func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleSearch(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &searchRequest{ Offset: 0, PageSize: 50, @@ -76,13 +77,13 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac } // grab our org assets - oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups) + oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } // perform our search - parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, s.ElasticClient, oa, + parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, rt.ES, oa, request.GroupUUID, request.ExcludeIDs, request.Query, request.Sort, request.Offset, request.PageSize) if err != nil { @@ -135,6 +136,7 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac type parseRequest struct { OrgID models.OrgID `json:"org_id" validate:"required"` Query string `json:"query" validate:"required"` + ParseOnly bool `json:"parse_only"` GroupUUID assets.GroupUUID `json:"group_uuid"` } @@ -154,28 +156,28 @@ type parseResponse struct { Query string `json:"query"` ElasticQuery interface{} `json:"elastic_query"` Metadata *contactql.Inspection `json:"metadata,omitempty"` - - // deprecated - Fields []string `json:"fields"` - AllowAsGroup bool `json:"allow_as_group"` } // handles a query parsing request -func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleParseQuery(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &parseRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } // grab our org assets - oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups) + oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") } env := oa.Env() - parsed, err := contactql.ParseQuery(env, request.Query, oa.SessionAssets()) + var resolver contactql.Resolver + if !request.ParseOnly { + resolver = oa.SessionAssets() + } + parsed, err := contactql.ParseQuery(env, request.Query, resolver) if err != nil { isQueryError, qerr := contactql.IsQueryError(err) if isQueryError { @@ -185,34 +187,23 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte } // normalize and inspect the query - normalized := "" - var metadata *contactql.Inspection - allowAsGroup := false - fields := make([]string, 0) - - if parsed != nil { - normalized = parsed.String() - metadata = contactql.Inspect(parsed) - fields = append(fields, metadata.Attributes...) - for _, f := range metadata.Fields { - fields = append(fields, f.Key) + normalized := parsed.String() + metadata := contactql.Inspect(parsed) + + var elasticSource interface{} + if !request.ParseOnly { + eq := models.BuildElasticQuery(oa, request.GroupUUID, models.NilContactStatus, nil, parsed) + elasticSource, err = eq.Source() + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error getting elastic source") } - allowAsGroup = metadata.AllowAsGroup - } - - eq := models.BuildElasticQuery(oa, request.GroupUUID, models.NilContactStatus, nil, parsed) - eqj, err := eq.Source() - if err != nil { - return nil, http.StatusInternalServerError, err } // build our response response := &parseResponse{ Query: normalized, - ElasticQuery: eqj, + ElasticQuery: elasticSource, Metadata: metadata, - Fields: fields, - AllowAsGroup: allowAsGroup, } return response, http.StatusOK, nil diff --git a/web/contact/search_test.go b/web/contact/search_test.go index 180adcfb6..b884e96f4 100644 --- a/web/contact/search_test.go +++ b/web/contact/search_test.go @@ -16,6 +16,7 @@ import ( _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" "github.com/olivere/elastic/v7" @@ -23,10 +24,8 @@ import ( ) func TestSearch(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() + ctx, _, db, rp := testsuite.Reset() + wg := &sync.WaitGroup{} es := testsuite.NewMockElasticServer() @@ -73,7 +72,7 @@ func TestSearch(t *testing.T) { } ] } - }`, models.CathyID) + }`, testdata.Cathy.ID) tcs := []struct { URL string @@ -96,34 +95,34 @@ func TestSearch(t *testing.T) { { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "birthday = tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "birthday = tomorrow", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID), ExpectedStatus: 400, ExpectedError: "can't resolve 'birthday' to attribute, scheme or field", }, { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "age > tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "age > tomorrow", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID), ExpectedStatus: 400, ExpectedError: "can't convert 'tomorrow' to a number", }, { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s"}`, models.AllContactsGroupUUID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID), ESResponse: singleESResponse, ExpectedStatus: 200, - ExpectedHits: []models.ContactID{models.CathyID}, + ExpectedHits: []models.ContactID{testdata.Cathy.ID}, ExpectedQuery: `name ~ "Cathy"`, ExpectedFields: []string{"name"}, }, { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s", "exclude_ids": [%d, %d]}`, models.AllContactsGroupUUID, models.BobID, models.GeorgeID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s", "exclude_ids": [%d, %d]}`, testdata.AllContactsGroup.UUID, testdata.Bob.ID, testdata.George.ID), ESResponse: singleESResponse, ExpectedStatus: 200, - ExpectedHits: []models.ContactID{models.CathyID}, + ExpectedHits: []models.ContactID{testdata.Cathy.ID}, ExpectedQuery: `name ~ "Cathy"`, ExpectedFields: []string{"name"}, ExpectedESRequest: `{ @@ -179,20 +178,20 @@ func TestSearch(t *testing.T) { { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "AGE = 10 and gender = M", "group_uuid": "%s"}`, models.AllContactsGroupUUID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "AGE = 10 and gender = M", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID), ESResponse: singleESResponse, ExpectedStatus: 200, - ExpectedHits: []models.ContactID{models.CathyID}, + ExpectedHits: []models.ContactID{testdata.Cathy.ID}, ExpectedQuery: `age = 10 AND gender = "M"`, ExpectedFields: []string{"age", "gender"}, }, { Method: "POST", URL: "/mr/contact/search", - Body: fmt.Sprintf(`{"org_id": 1, "query": "", "group_uuid": "%s"}`, models.AllContactsGroupUUID), + Body: fmt.Sprintf(`{"org_id": 1, "query": "", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID), ESResponse: singleESResponse, ExpectedStatus: 200, - ExpectedHits: []models.ContactID{models.CathyID}, + ExpectedHits: []models.ContactID{testdata.Cathy.ID}, ExpectedQuery: ``, ExpectedFields: []string{}, }, @@ -238,8 +237,8 @@ func TestSearch(t *testing.T) { } } -func TestParse(t *testing.T) { +func TestParseQuery(t *testing.T) { testsuite.Reset() - web.RunWebTests(t, "testdata/parse_query.json") + web.RunWebTests(t, "testdata/parse_query.json", nil) } diff --git a/web/contact/testdata/parse_query.json b/web/contact/testdata/parse_query.json index 19b5aed1d..a4e0d0b64 100644 --- a/web/contact/testdata/parse_query.json +++ b/web/contact/testdata/parse_query.json @@ -43,6 +43,35 @@ } } }, + { + "label": "query with invalid property but parse_only = true", + "method": "POST", + "path": "/mr/contact/parse_query", + "body": { + "org_id": 1, + "query": "birthday = tomorrow AND tel = 12345", + "parse_only": true + }, + "status": 200, + "response": { + "query": "birthday = \"tomorrow\" AND tel = 12345", + "elastic_query": null, + "metadata": { + "attributes": [], + "schemes": [ + "tel" + ], + "fields": [ + { + "key": "birthday", + "name": "" + } + ], + "groups": [], + "allow_as_group": true + } + } + }, { "label": "valid query without group", "method": "POST", @@ -107,11 +136,7 @@ ], "groups": [], "allow_as_group": true - }, - "fields": [ - "age" - ], - "allow_as_group": true + } } }, { @@ -184,11 +209,7 @@ ], "groups": [], "allow_as_group": true - }, - "fields": [ - "age" - ], - "allow_as_group": true + } } }, { @@ -236,11 +257,7 @@ } ], "allow_as_group": false - }, - "fields": [ - "group" - ], - "allow_as_group": false + } } } ] \ No newline at end of file diff --git a/web/contact/testdata/resolve.json b/web/contact/testdata/resolve.json index 534a75a3b..0299cdb14 100644 --- a/web/contact/testdata/resolve.json +++ b/web/contact/testdata/resolve.json @@ -35,7 +35,7 @@ "name": "Bob", "status": "active", "timezone": "America/Los_Angeles", - "created_on": "2021-04-09T14:51:56.517774Z", + "created_on": "2020-12-31T16:45:30Z", "urns": [ "tel:+16055742222?id=10001&priority=1000" ], diff --git a/web/contact/utils_test.go b/web/contact/utils_test.go index 8218886e5..736e9b478 100644 --- a/web/contact/utils_test.go +++ b/web/contact/utils_test.go @@ -7,6 +7,7 @@ import ( "github.com/nyaruka/goflow/envs" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web/contact" "github.com/stretchr/testify/assert" @@ -14,11 +15,9 @@ import ( ) func TestSpecToCreation(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - ctx := testsuite.CTX() + ctx, _, db, _ := testsuite.Get() - oa, err := models.GetOrgAssets(ctx, db, models.Org1) + oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID) require.NoError(t, err) sa := oa.SessionAssets() diff --git a/web/docs/docs.go b/web/docs/docs.go index a6568f30d..f4e00b499 100644 --- a/web/docs/docs.go +++ b/web/docs/docs.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" ) @@ -13,8 +14,8 @@ func init() { docServer = http.StripPrefix("/mr/docs", http.FileServer(http.Dir("docs"))) // redirect non slashed docs to slashed version so relative URLs work - web.RegisterRoute(http.MethodGet, "/mr/docs", func(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error { - http.Redirect(rawW, r, "/mr/docs/", 301) + web.RegisterRoute(http.MethodGet, "/mr/docs", func(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error { + http.Redirect(rawW, r, "/mr/docs/", http.StatusMovedPermanently) return nil }) @@ -22,7 +23,7 @@ func init() { web.RegisterRoute(http.MethodGet, "/mr/docs/*", handleDocs) } -func handleDocs(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error { +func handleDocs(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error { docServer.ServeHTTP(rawW, r) return nil } diff --git a/web/expression/expression.go b/web/expression/expression.go index de136fb5d..a83ec2d4b 100644 --- a/web/expression/expression.go +++ b/web/expression/expression.go @@ -6,6 +6,7 @@ import ( "github.com/nyaruka/goflow/flows/definition/legacy/expressions" "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" @@ -29,7 +30,7 @@ type migrateResponse struct { Migrated string `json:"migrated"` } -func handleMigrate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleMigrate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &migrateRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil diff --git a/web/expression/expression_test.go b/web/expression/expression_test.go index 45bacda0d..f376187aa 100644 --- a/web/expression/expression_test.go +++ b/web/expression/expression_test.go @@ -9,5 +9,5 @@ import ( func TestServer(t *testing.T) { testsuite.Reset() - web.RunWebTests(t, "testdata/migrate.json") + web.RunWebTests(t, "testdata/migrate.json", nil) } diff --git a/web/flow/flow.go b/web/flow/flow.go index 73ce5f5bf..ab5fcf390 100644 --- a/web/flow/flow.go +++ b/web/flow/flow.go @@ -11,6 +11,7 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/Masterminds/semver" @@ -36,20 +37,20 @@ type migrateRequest struct { ToVersion *semver.Version `json:"to_version"` } -func handleMigrate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleMigrate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &migrateRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } // do a JSON to JSON migration of the definition - migrated, err := goflow.MigrateDefinition(request.Flow, request.ToVersion) + migrated, err := goflow.MigrateDefinition(rt.Config, request.Flow, request.ToVersion) if err != nil { return errors.Wrapf(err, "unable to migrate flow"), http.StatusUnprocessableEntity, nil } // try to read result to check that it's valid - _, err = goflow.ReadFlow(migrated) + _, err = goflow.ReadFlow(rt.Config, migrated) if err != nil { return errors.Wrapf(err, "unable to read migrated flow"), http.StatusUnprocessableEntity, nil } @@ -71,13 +72,13 @@ type inspectRequest struct { OrgID models.OrgID `json:"org_id"` } -func handleInspect(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleInspect(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &inspectRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - flow, err := goflow.ReadFlow(request.Flow) + flow, err := goflow.ReadFlow(rt.Config, request.Flow) if err != nil { return errors.Wrapf(err, "unable to read flow"), http.StatusUnprocessableEntity, nil } @@ -85,7 +86,7 @@ func handleInspect(ctx context.Context, s *web.Server, r *http.Request) (interfa var sa flows.SessionAssets // if we have an org ID, create session assets to look for missing dependencies if request.OrgID != models.NilOrgID { - oa, err := models.GetOrgAssetsWithRefresh(ctx, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows) + oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows) if err != nil { return nil, 0, err } @@ -110,7 +111,7 @@ type cloneRequest struct { Flow json.RawMessage `json:"flow" validate:"required"` } -func handleClone(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleClone(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &cloneRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil @@ -123,7 +124,7 @@ func handleClone(ctx context.Context, s *web.Server, r *http.Request) (interface } // read flow to check that cloning produced something valid - _, err = goflow.ReadFlow(cloneJSON) + _, err = goflow.ReadFlow(rt.Config, cloneJSON) if err != nil { return errors.Wrapf(err, "unable to clone flow"), http.StatusUnprocessableEntity, nil } @@ -143,13 +144,13 @@ type changeLanguageRequest struct { Flow json.RawMessage `json:"flow" validate:"required"` } -func handleChangeLanguage(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleChangeLanguage(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &changeLanguageRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil } - flow, err := goflow.ReadFlow(request.Flow) + flow, err := goflow.ReadFlow(rt.Config, request.Flow) if err != nil { return errors.Wrapf(err, "unable to read flow"), http.StatusUnprocessableEntity, nil } diff --git a/web/flow/flow_test.go b/web/flow/flow_test.go index 84c20faec..2017392d1 100644 --- a/web/flow/flow_test.go +++ b/web/flow/flow_test.go @@ -7,8 +7,8 @@ import ( ) func TestServer(t *testing.T) { - web.RunWebTests(t, "testdata/change_language.json") - web.RunWebTests(t, "testdata/clone.json") - web.RunWebTests(t, "testdata/inspect.json") - web.RunWebTests(t, "testdata/migrate.json") + web.RunWebTests(t, "testdata/change_language.json", nil) + web.RunWebTests(t, "testdata/clone.json", nil) + web.RunWebTests(t, "testdata/inspect.json", nil) + web.RunWebTests(t, "testdata/migrate.json", nil) } diff --git a/web/ivr/ivr.go b/web/ivr/ivr.go index b18a3bb76..c77b256b3 100644 --- a/web/ivr/ivr.go +++ b/web/ivr/ivr.go @@ -16,6 +16,7 @@ import ( "github.com/nyaruka/mailroom/core/ivr" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/tasks/handler" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/go-chi/chi" @@ -29,10 +30,10 @@ func init() { web.RegisterRoute(http.MethodPost, "/mr/ivr/c/{uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}/incoming", newIVRHandler(handleIncomingCall)) } -type ivrHandlerFn func(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) +type ivrHandlerFn func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) func newIVRHandler(handler ivrHandlerFn) web.Handler { - return func(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) error { + return func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error { recorder := httpx.NewRecorder(r, w) // immediately save our request body so we have a complete channel log @@ -42,7 +43,7 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler { } ww := recorder.ResponseWriter - channel, connection, rerr := handler(ctx, s, r, ww) + channel, connection, rerr := handler(ctx, rt, r, ww) if channel != nil { trace, err := recorder.End() @@ -58,7 +59,7 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler { } log := models.NewChannelLog(trace, isError, desc, channel, connection) - err = models.InsertChannelLogs(ctx, s.DB, []*models.ChannelLog{log}) + err = models.InsertChannelLogs(ctx, rt.DB, []*models.ChannelLog{log}) if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error writing ivr channel log") } @@ -68,17 +69,17 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler { } } -func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { +func handleIncomingCall(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { channelUUID := assets.ChannelUUID(chi.URLParam(r, "uuid")) // load the org id for this UUID (we could load the entire channel here but we want to take the same paths through everything else) - orgID, err := models.OrgIDForChannelUUID(ctx, s.DB, channelUUID) + orgID, err := models.OrgIDForChannelUUID(ctx, rt.DB, channelUUID) if err != nil { return nil, nil, writeClientError(w, err) } // load our org assets - oa, err := models.GetOrgAssets(ctx, s.DB, orgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, orgID) if err != nil { return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets")) } @@ -108,12 +109,12 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h } // get the contact for this URN - contact, _, _, err := models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{urn}, channel.ID()) + contact, _, _, err := models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{urn}, channel.ID()) if err != nil { return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to get contact by urn")) } - urn, err = models.URNForURN(ctx, s.DB, oa, urn) + urn, err = models.URNForURN(ctx, rt.DB, oa, urn) if err != nil { return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load urn")) } @@ -134,7 +135,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h // create our connection conn, err := models.InsertIVRConnection( - ctx, s.DB, oa.OrgID(), channel.ID(), models.NilStartID, contact.ID(), urnID, + ctx, rt.DB, oa.OrgID(), channel.ID(), models.NilStartID, contact.ID(), urnID, models.ConnectionDirectionIn, models.ConnectionStatusInProgress, externalID, ) if err != nil { @@ -142,7 +143,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h } // try to handle this event - session, err := handler.HandleChannelEvent(ctx, s.DB, s.RP, models.MOCallEventType, event, conn) + session, err := handler.HandleChannelEvent(ctx, rt, models.MOCallEventType, event, conn) if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error handling incoming call") @@ -152,10 +153,10 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h // we got a session back so we have an active call trigger if session != nil { // build our resume URL - resumeURL := buildResumeURL(channel, conn, urn) + resumeURL := buildResumeURL(rt.Config, channel, conn, urn) // have our client output our session status - err = client.WriteSessionResponse(ctx, s.RP, channel, conn, session, urn, resumeURL, r, w) + err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, session, urn, resumeURL, r, w) if err != nil { return channel, conn, errors.Wrapf(err, "error writing ivr response for start") } @@ -166,13 +167,13 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h // no session means no trigger, create a missed call event instead // we first create an incoming call channel event and see if that matches event = models.NewChannelEvent(models.MOMissEventType, oa.OrgID(), channel.ID(), contact.ID(), urnID, nil, false) - err = event.Insert(ctx, s.DB) + err = event.Insert(ctx, rt.DB) if err != nil { return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "error inserting channel event")) } // try to handle it, this time looking for a missed call event - session, err = handler.HandleChannelEvent(ctx, s.DB, s.RP, models.MOMissEventType, event, nil) + session, err = handler.HandleChannelEvent(ctx, rt, models.MOMissEventType, event, nil) if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error handling missed call") return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "error handling missed call")) @@ -210,8 +211,8 @@ func writeClientError(w http.ResponseWriter, err error) error { return errors.Wrapf(err, "error writing error") } -func buildResumeURL(channel *models.Channel, conn *models.ChannelConnection, urn urns.URN) string { - domain := channel.ConfigValue(models.ChannelConfigCallbackDomain, config.Mailroom.Domain) +func buildResumeURL(cfg *config.Config, channel *models.Channel, conn *models.ChannelConnection, urn urns.URN) string { + domain := channel.ConfigValue(models.ChannelConfigCallbackDomain, cfg.Domain) form := url.Values{ "action": []string{actionResume}, "connection": []string{fmt.Sprintf("%d", conn.ID())}, @@ -222,7 +223,7 @@ func buildResumeURL(channel *models.Channel, conn *models.ChannelConnection, urn } // handleFlow handles all incoming IVR requests related to a flow (status is handled elsewhere) -func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { +func handleFlow(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { ctx, cancel := context.WithTimeout(ctx, time.Second*55) defer cancel() @@ -232,13 +233,13 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp } // load our connection - conn, err := models.SelectChannelConnection(ctx, s.DB, request.ConnectionID) + conn, err := models.SelectChannelConnection(ctx, rt.DB, request.ConnectionID) if err != nil { return nil, nil, errors.Wrapf(err, "unable to load channel connection with id: %d", request.ConnectionID) } // load our org assets - oa, err := models.GetOrgAssets(ctx, s.DB, conn.OrgID()) + oa, err := models.GetOrgAssets(ctx, rt.DB, conn.OrgID()) if err != nil { return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets")) } @@ -262,7 +263,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp } // load our contact - contacts, err := models.LoadContacts(ctx, s.DB, oa, []models.ContactID{conn.ContactID()}) + contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{conn.ContactID()}) if err != nil { return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "no such contact")) } @@ -274,7 +275,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp } // load the URN for this connection - urn, err := models.URNForID(ctx, s.DB, oa, conn.ContactURNID()) + urn, err := models.URNForID(ctx, rt.DB, oa, conn.ContactURNID()) if err != nil { return channel, conn, client.WriteErrorResponse(w, errors.Errorf("unable to find connection urn: %d", conn.ContactURNID())) } @@ -290,27 +291,27 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp return channel, conn, client.WriteErrorResponse(w, errors.Errorf("unable to find URN: %s on contact: %d", urn, conn.ContactID())) } - resumeURL := buildResumeURL(channel, conn, urn) + resumeURL := buildResumeURL(rt.Config, channel, conn, urn) // if this a start, start our contact switch request.Action { case actionStart: err = ivr.StartIVRFlow( - ctx, s.DB, s.RP, client, resumeURL, + ctx, rt, client, resumeURL, oa, channel, conn, contacts[0], urn, conn.StartID(), r, w, ) case actionResume: err = ivr.ResumeIVRFlow( - ctx, s.Config, s.DB, s.RP, s.Storage, resumeURL, client, + ctx, rt, resumeURL, client, oa, channel, conn, contacts[0], urn, r, w, ) case actionStatus: err = ivr.HandleIVRStatus( - ctx, s.DB, s.RP, oa, client, conn, + ctx, rt, oa, client, conn, r, w, ) @@ -321,27 +322,27 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp // had an error? mark our connection as errored and log it if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error while handling IVR") - return channel, conn, ivr.WriteErrorResponse(ctx, s.DB, client, conn, w, err) + return channel, conn, ivr.WriteErrorResponse(ctx, rt.DB, client, conn, w, err) } return channel, conn, nil } // handleStatus handles all incoming IVR events / status updates -func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { +func handleStatus(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) { ctx, cancel := context.WithTimeout(ctx, time.Second*55) defer cancel() channelUUID := assets.ChannelUUID(chi.URLParam(r, "uuid")) // load the org id for this UUID (we could load the entire channel here but we want to take the same paths through everything else) - orgID, err := models.OrgIDForChannelUUID(ctx, s.DB, channelUUID) + orgID, err := models.OrgIDForChannelUUID(ctx, rt.DB, channelUUID) if err != nil { return nil, nil, writeClientError(w, err) } // load our org assets - oa, err := models.GetOrgAssets(ctx, s.DB, orgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, orgID) if err != nil { return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets")) } @@ -365,7 +366,7 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re } // preprocess this status - body, err := client.PreprocessStatus(ctx, s.DB, s.RP, r) + body, err := client.PreprocessStatus(ctx, rt.DB, rt.RP, r) if err != nil { return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "error while preprocessing status")) } @@ -383,7 +384,7 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re } // load our connection - conn, err := models.SelectChannelConnectionByExternalID(ctx, s.DB, channel.ID(), models.ConnectionTypeIVR, externalID) + conn, err := models.SelectChannelConnectionByExternalID(ctx, rt.DB, channel.ID(), models.ConnectionTypeIVR, externalID) if errors.Cause(err) == sql.ErrNoRows { return channel, nil, client.WriteEmptyResponse(w, "unknown connection, ignoring") } @@ -391,12 +392,12 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load channel connection with id: %s", externalID)) } - err = ivr.HandleIVRStatus(ctx, s.DB, s.RP, oa, client, conn, r, w) + err = ivr.HandleIVRStatus(ctx, rt, oa, client, conn, r, w) // had an error? mark our connection as errored and log it if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error while handling status") - return channel, conn, ivr.WriteErrorResponse(ctx, s.DB, client, conn, w, err) + return channel, conn, ivr.WriteErrorResponse(ctx, rt.DB, client, conn, w, err) } return channel, conn, nil diff --git a/web/ivr/ivr_test.go b/web/ivr/ivr_test.go index 0b29a4bb8..0b43f31ae 100644 --- a/web/ivr/ivr_test.go +++ b/web/ivr/ivr_test.go @@ -20,7 +20,9 @@ import ( "github.com/sirupsen/logrus" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" _ "github.com/nyaruka/mailroom/core/handlers" "github.com/nyaruka/mailroom/core/ivr/twiml" @@ -29,9 +31,10 @@ import ( ) func TestTwilioIVR(t *testing.T) { - ctx, db, rp := testsuite.Reset() + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() + defer testsuite.ResetStorage() // start test server @@ -60,18 +63,18 @@ func TestTwilioIVR(t *testing.T) { twiml.IgnoreSignatures = true wg := &sync.WaitGroup{} - server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.Storage(), nil, wg) + server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg) server.Start() defer server.Stop() // add auth tokens - db.MustExec(`UPDATE channels_channel SET config = '{"auth_token": "token", "account_sid": "sid", "callback_domain": "localhost:8090"}' WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET config = '{"auth_token": "token", "account_sid": "sid", "callback_domain": "localhost:8090"}' WHERE id = $1`, testdata.TwilioChannel.ID) // create a flow start for cathy and george parentSummary := json.RawMessage(`{"flow": {"name": "IVR Flow", "uuid": "2f81d0ea-4d75-4843-9371-3f7465311cce"}, "uuid": "8bc73097-ac57-47fb-82e5-184f8ec6dbef", "status": "active", "contact": {"id": 10000, "name": "Cathy", "urns": ["tel:+16055741111?id=10000&priority=50"], "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", "fields": {"gender": {"text": "F"}}, "groups": [{"name": "Doctors", "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638"}], "timezone": "America/Los_Angeles", "created_on": "2019-07-23T09:35:01.439614-07:00"}, "results": {}}`) - start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive). - WithContactIDs([]models.ContactID{models.CathyID, models.GeorgeID}). + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive). + WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.George.ID}). WithParentSummary(parentSummary) err := models.InsertFlowStarts(ctx, db, []*models.FlowStart{start}) @@ -92,16 +95,12 @@ func TestTwilioIVR(t *testing.T) { err = ivr_tasks.HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, - []interface{}{models.CathyID, models.ConnectionStatusWired, "Call1"}, - 1, - ) - testsuite.AssertQueryCount(t, db, + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.Cathy.ID, models.ConnectionStatusWired, "Call1").Returns(1) + + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, - []interface{}{models.GeorgeID, models.ConnectionStatusWired, "Call2"}, - 1, - ) + testdata.George.ID, models.ConnectionStatusWired, "Call2").Returns(1) tcs := []struct { Action string @@ -113,7 +112,7 @@ func TestTwilioIVR(t *testing.T) { }{ { Action: "start", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: nil, StatusCode: 200, @@ -121,7 +120,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -133,7 +132,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -145,7 +144,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -157,7 +156,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -169,7 +168,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -185,7 +184,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallStatus": []string{"in-progress"}, @@ -200,7 +199,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "status", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "CallSid": []string{"Call1"}, @@ -212,7 +211,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "start", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(2), Form: nil, StatusCode: 200, @@ -220,7 +219,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "resume", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(2), Form: url.Values{ "CallStatus": []string{"completed"}, @@ -232,7 +231,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "incoming", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(3), Form: url.Values{ "CallSid": []string{"Call2"}, @@ -244,7 +243,7 @@ func TestTwilioIVR(t *testing.T) { }, { Action: "status", - ChannelUUID: models.TwilioChannelUUID, + ChannelUUID: testdata.TwilioChannel.UUID, ConnectionID: models.ConnectionID(3), Form: url.Values{ "CallSid": []string{"Call2"}, @@ -273,6 +272,7 @@ func TestTwilioIVR(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) assert.Equal(t, tc.StatusCode, resp.StatusCode) body, _ := ioutil.ReadAll(resp.Body) @@ -283,80 +283,38 @@ func TestTwilioIVR(t *testing.T) { } // check our final state of sessions, runs, msgs, connections - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`, - []interface{}{models.CathyID}, - 8, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`, - []interface{}{}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`, - []interface{}{models.CathyID}, - 5, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`, - []interface{}{}, - 9, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, - []interface{}{models.GeorgeID}, - 2, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, - []interface{}{models.GeorgeID}, - 1, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`, testdata.Cathy.ID).Returns(8) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`, testdata.Cathy.ID).Returns(5) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`).Returns(9) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2 + AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, testdata.George.ID).Returns(2) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, testdata.George.ID).Returns(1) } func TestVonageIVR(t *testing.T) { - ctx, db, rp := testsuite.Reset() + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() + defer testsuite.ResetStorage() - models.FlushCache() // deactivate our twilio channel - db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID) + db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID) // add auth tokens - db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, models.VonageChannelID) + db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, testdata.VonageChannel.ID) // start test server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -396,7 +354,7 @@ func TestVonageIVR(t *testing.T) { defer ts.Close() wg := &sync.WaitGroup{} - server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.Storage(), nil, wg) + server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg) server.Start() defer server.Stop() @@ -405,8 +363,8 @@ func TestVonageIVR(t *testing.T) { // create a flow start for cathy and george extra := json.RawMessage(`{"ref_id":"123"}`) - start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive). - WithContactIDs([]models.ContactID{models.CathyID, models.GeorgeID}). + start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive). + WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.George.ID}). WithExtra(extra) models.InsertFlowStarts(ctx, db, []*models.FlowStart{start}) @@ -425,16 +383,11 @@ func TestVonageIVR(t *testing.T) { err = ivr_tasks.HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch) assert.NoError(t, err) - testsuite.AssertQueryCount(t, db, - `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, - []interface{}{models.CathyID, models.ConnectionStatusWired, "Call1"}, - 1, - ) - testsuite.AssertQueryCount(t, db, - `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, - []interface{}{models.GeorgeID, models.ConnectionStatusWired, "Call2"}, - 1, - ) + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.Cathy.ID, models.ConnectionStatusWired, "Call1").Returns(1) + + testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, + testdata.George.ID, models.ConnectionStatusWired, "Call2").Returns(1) tcs := []struct { Label string @@ -449,7 +402,7 @@ func TestVonageIVR(t *testing.T) { { Label: "start and prompt", Action: "start", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Body: `{"from":"12482780345","to":"12067799294","uuid":"80c9a606-717e-48b9-ae22-ce00269cbb08","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`, StatusCode: 200, @@ -458,7 +411,7 @@ func TestVonageIVR(t *testing.T) { { Label: "invalid dtmf", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"gather"}, @@ -470,7 +423,7 @@ func TestVonageIVR(t *testing.T) { { Label: "dtmf 1", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"gather"}, @@ -482,7 +435,7 @@ func TestVonageIVR(t *testing.T) { { Label: "dtmf too large", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"gather"}, @@ -494,7 +447,7 @@ func TestVonageIVR(t *testing.T) { { Label: "dtmf 56", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"gather"}, @@ -506,7 +459,7 @@ func TestVonageIVR(t *testing.T) { { Label: "recording callback", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"recording_url"}, @@ -519,7 +472,7 @@ func TestVonageIVR(t *testing.T) { { Label: "resume with recording", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"record"}, @@ -532,7 +485,7 @@ func TestVonageIVR(t *testing.T) { { Label: "transfer answered", Action: "status", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Body: `{"uuid": "Call3", "status": "answered"}`, StatusCode: 200, @@ -541,7 +494,7 @@ func TestVonageIVR(t *testing.T) { { Label: "transfer completed", Action: "status", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Body: `{"uuid": "Call3", "duration": "25", "status": "completed"}`, StatusCode: 200, @@ -550,7 +503,7 @@ func TestVonageIVR(t *testing.T) { { Label: "transfer callback", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Form: url.Values{ "wait_type": []string{"dial"}, @@ -563,7 +516,7 @@ func TestVonageIVR(t *testing.T) { { Label: "call complete", Action: "status", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(1), Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call1","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"completed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`, StatusCode: 200, @@ -572,7 +525,7 @@ func TestVonageIVR(t *testing.T) { { Label: "new call", Action: "start", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(2), Body: `{"from":"12482780345","to":"12067799294","uuid":"Call2","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`, StatusCode: 200, @@ -581,7 +534,7 @@ func TestVonageIVR(t *testing.T) { { Label: "new call dtmf 1", Action: "resume", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(2), Form: url.Values{ "wait_type": []string{"gather"}, @@ -593,7 +546,7 @@ func TestVonageIVR(t *testing.T) { { Label: "new call ended", Action: "status", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(2), Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call2","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"completed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`, StatusCode: 200, @@ -602,7 +555,7 @@ func TestVonageIVR(t *testing.T) { { Label: "incoming call", Action: "incoming", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(3), Body: `{"from":"12482780345","to":"12067799294","uuid":"Call4","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`, StatusCode: 200, @@ -611,7 +564,7 @@ func TestVonageIVR(t *testing.T) { { Label: "failed call", Action: "status", - ChannelUUID: models.VonageChannelUUID, + ChannelUUID: testdata.VonageChannel.UUID, ConnectionID: models.ConnectionID(3), Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call4","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"failed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`, StatusCode: 200, @@ -639,6 +592,7 @@ func TestVonageIVR(t *testing.T) { req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) assert.Equal(t, tc.StatusCode, resp.StatusCode) body, _ := ioutil.ReadAll(resp.Body) @@ -650,64 +604,24 @@ func TestVonageIVR(t *testing.T) { } // check our final state of sessions, runs, msgs, connections - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, - []interface{}{models.CathyID}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`, - []interface{}{models.CathyID}, - 9, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`, - []interface{}{}, - 1, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`, - []interface{}{models.CathyID}, - 5, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`, - []interface{}{}, - 10, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, - []interface{}{models.GeorgeID}, - 3, - ) - - testsuite.AssertQueryCount(t, db, - `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, - []interface{}{models.GeorgeID}, - 1, - ) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, testdata.Cathy.ID).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' + AND connection_id = 1 AND status = 'W' AND direction = 'O'`, testdata.Cathy.ID).Returns(9) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`).Returns(1) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' + AND connection_id = 1 AND status = 'H' AND direction = 'I'`, testdata.Cathy.ID).Returns(5) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`).Returns(10) + + testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' + AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, testdata.George.ID).Returns(3) + testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, testdata.George.ID).Returns(1) } diff --git a/web/msg/msg.go b/web/msg/msg.go new file mode 100644 index 000000000..949ffb6d2 --- /dev/null +++ b/web/msg/msg.go @@ -0,0 +1,64 @@ +package msg + +import ( + "context" + "net/http" + + "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/runtime" + "github.com/nyaruka/mailroom/web" + + "github.com/pkg/errors" +) + +func init() { + web.RegisterJSONRoute(http.MethodPost, "/mr/msg/resend", web.RequireAuthToken(handleResend)) +} + +// Request to resend failed messages. +// +// { +// "org_id": 1, +// "msg_ids": [123456, 345678] +// } +// +type resendRequest struct { + OrgID models.OrgID `json:"org_id" validate:"required"` + MsgIDs []models.MsgID `json:"msg_ids" validate:"required"` +} + +// handles a request to resend the given messages +func handleResend(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { + request := &resendRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + // grab our org + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") + } + + msgs, err := models.LoadMessages(ctx, rt.DB, request.OrgID, models.DirectionOut, request.MsgIDs) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error loading messages to resend") + } + + err = models.ResendMessages(ctx, rt.DB, rt.RP, oa, msgs) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error resending messages") + } + + msgio.SendMessages(ctx, rt.DB, rt.RP, nil, msgs) + + // response is the ids of the messages that were actually resent + resentMsgIDs := make([]flows.MsgID, len(msgs)) + for i, m := range msgs { + resentMsgIDs[i] = m.ID() + } + return map[string]interface{}{"msg_ids": resentMsgIDs}, http.StatusOK, nil +} diff --git a/web/msg/msg_test.go b/web/msg/msg_test.go new file mode 100644 index 000000000..83f2363e3 --- /dev/null +++ b/web/msg/msg_test.go @@ -0,0 +1,25 @@ +package msg_test + +import ( + "fmt" + "testing" + + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/nyaruka/mailroom/web" +) + +func TestServer(t *testing.T) { + _, _, db, _ := testsuite.Get() + + cathyIn := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hello", models.MsgStatusHandled) + cathyOut := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "how can we help", nil, models.MsgStatusSent) + bobOut := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.VonageChannel, testdata.Bob, "this failed", nil, models.MsgStatusFailed) + + web.RunWebTests(t, "testdata/resend.json", map[string]string{ + "cathy_msgin_id": fmt.Sprintf("%d", cathyIn.ID()), + "cathy_msgout_id": fmt.Sprintf("%d", cathyOut.ID()), + "bob_msgout_id": fmt.Sprintf("%d", bobOut.ID()), + }) +} diff --git a/web/msg/testdata/resend.json b/web/msg/testdata/resend.json new file mode 100644 index 000000000..605a991b3 --- /dev/null +++ b/web/msg/testdata/resend.json @@ -0,0 +1,52 @@ +[ + { + "label": "illegal method", + "method": "GET", + "path": "/mr/msg/resend", + "status": 405, + "response": { + "error": "illegal method: GET" + } + }, + { + "label": "invalid org_id", + "method": "POST", + "path": "/mr/msg/resend", + "body": { + "org_id": 1234, + "msg_ids": [ + 1234 + ] + }, + "status": 500, + "response": { + "error": "unable to load org assets: error loading environment for org 1234: no org with id: 1234" + } + }, + { + "label": "response is the ids of the messages that were actually resent", + "method": "POST", + "path": "/mr/msg/resend", + "body": { + "org_id": 1, + "msg_ids": [ + $cathy_msgin_id$, + $cathy_msgout_id$, + $bob_msgout_id$ + ] + }, + "status": 200, + "response": { + "msg_ids": [ + $cathy_msgout_id$, + $bob_msgout_id$ + ] + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM msgs_msg WHERE status = 'P'", + "count": 2 + } + ] + } +] \ No newline at end of file diff --git a/web/org/metrics.go b/web/org/metrics.go index 5d7d5e325..bcfe6be68 100644 --- a/web/org/metrics.go +++ b/web/org/metrics.go @@ -9,6 +9,7 @@ import ( "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" dto "github.com/prometheus/client_model/go" @@ -47,8 +48,8 @@ type groupCountRow struct { Count int64 `db:"count"` } -func calculateGroupCounts(ctx context.Context, s *web.Server, org *models.OrgReference) (*dto.MetricFamily, error) { - rows, err := s.DB.QueryxContext(ctx, groupCountsSQL, org.ID) +func calculateGroupCounts(ctx context.Context, rt *runtime.Runtime, org *models.OrgReference) (*dto.MetricFamily, error) { + rows, err := rt.DB.QueryxContext(ctx, groupCountsSQL, org.ID) if err != nil { return nil, errors.Wrapf(err, "error querying group counts for org") } @@ -144,8 +145,8 @@ type channelStats struct { Counts map[string]int64 } -func calculateChannelCounts(ctx context.Context, s *web.Server, org *models.OrgReference) (*dto.MetricFamily, error) { - rows, err := s.DB.QueryxContext(ctx, channelCountsSQL, org.ID) +func calculateChannelCounts(ctx context.Context, rt *runtime.Runtime, org *models.OrgReference) (*dto.MetricFamily, error) { + rows, err := rt.DB.QueryxContext(ctx, channelCountsSQL, org.ID) if err != nil { return nil, errors.Wrapf(err, "error querying channel counts for org") } @@ -260,7 +261,7 @@ func calculateChannelCounts(ctx context.Context, s *web.Server, org *models.OrgR return family, err } -func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error { +func handleMetrics(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error { // we should have basic auth headers, username should be metrics username, token, ok := r.BasicAuth() if !ok || username != "metrics" { @@ -270,7 +271,7 @@ func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW htt } orgUUID := uuids.UUID(chi.URLParam(r, "uuid")) - org, err := models.LookupOrgByUUIDAndToken(ctx, s.DB, orgUUID, "Prometheus", token) + org, err := models.LookupOrgByUUIDAndToken(ctx, rt.DB, orgUUID, "Prometheus", token) if err != nil { return errors.Wrapf(err, "error looking up org for token") } @@ -281,12 +282,12 @@ func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW htt return nil } - groups, err := calculateGroupCounts(ctx, s, org) + groups, err := calculateGroupCounts(ctx, rt, org) if err != nil { return errors.Wrapf(err, "error calculating group counts for org: %d", org.ID) } - channels, err := calculateChannelCounts(ctx, s, org) + channels, err := calculateChannelCounts(ctx, rt, org) if err != nil { return errors.Wrapf(err, "error calculating channel counts for org: %d", org.ID) } diff --git a/web/org/metrics_test.go b/web/org/metrics_test.go index 30743a8e5..10943425e 100644 --- a/web/org/metrics_test.go +++ b/web/org/metrics_test.go @@ -1,4 +1,4 @@ -package org +package org_test import ( "fmt" @@ -9,20 +9,21 @@ import ( "time" "github.com/nyaruka/mailroom/config" - "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" + "github.com/stretchr/testify/assert" ) func TestMetrics(t *testing.T) { - ctx, db, rp := testsuite.Reset() + ctx, _, db, rp := testsuite.Reset() promToken := "2d26a50841ff48237238bbdd021150f6a33a4196" - db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 12, 1);`, models.Org1, promToken) + db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 12, 1);`, testdata.Org1.ID, promToken) adminToken := "5c26a50841ff48237238bbdd021150f6a33a4199" - db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 8, 1);`, models.Org1, adminToken) + db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 8, 1);`, testdata.Org1.ID, adminToken) wg := &sync.WaitGroup{} server := web.NewServer(ctx, config.Mailroom, db, rp, nil, nil, wg) @@ -42,46 +43,46 @@ func TestMetrics(t *testing.T) { }{ { Label: "no username", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID), Username: "", Password: "", Response: `{"error": "invalid authentication"}`, }, { Label: "invalid password", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID), Username: "metrics", Password: "invalid", Response: `{"error": "invalid authentication"}`, }, { Label: "invalid username", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID), Username: "invalid", Password: promToken, Response: `{"error": "invalid authentication"}`, }, { Label: "valid login, wrong org", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org2UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org2.UUID), Username: "metrics", Password: promToken, Response: `{"error": "invalid authentication"}`, }, { Label: "valid login, invalid user", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID), Username: "metrics", Password: adminToken, Response: `{"error": "invalid authentication"}`, }, { Label: "valid", - URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID), + URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID), Username: "metrics", Password: promToken, Contains: []string{ - `rapidpro_group_contact_count{group_name="Active",group_uuid="b97f69f7-5edf-45c7-9fda-d37066eae91d",group_type="system",org="UNICEF"} 124`, + `rapidpro_group_contact_count{group_name="Active",group_uuid="14f6ea01-456b-4417-b0b8-35e942f549f1",group_type="system",org="UNICEF"} 124`, `rapidpro_group_contact_count{group_name="Doctors",group_uuid="c153e265-f7c9-4539-9dbc-9b358714b638",group_type="user",org="UNICEF"} 121`, `rapidpro_channel_msg_count{channel_name="Vonage",channel_uuid="19012bfd-3ce3-4cae-9bb9-76cf92c73d49",channel_type="NX",msg_direction="out",msg_type="message",org="UNICEF"} 0`, }, diff --git a/web/po/po.go b/web/po/po.go index 20f5badfa..be71c2f8f 100644 --- a/web/po/po.go +++ b/web/po/po.go @@ -10,6 +10,7 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/goflow/utils/i18n" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/go-chi/chi/middleware" @@ -38,13 +39,13 @@ type exportRequest struct { ExcludeArguments bool `json:"exclude_arguments"` } -func handleExport(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error { +func handleExport(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error { request := &exportRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return errors.Wrapf(err, "request failed validation") } - flows, err := loadFlows(ctx, s.DB, request.OrgID, request.FlowIDs) + flows, err := loadFlows(ctx, rt.DB, request.OrgID, request.FlowIDs) if err != nil { return err } @@ -80,7 +81,7 @@ type importForm struct { Language envs.Language `form:"language" validate:"required"` } -func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleImport(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { form := &importForm{} if err := web.DecodeAndValidateForm(form, r); err != nil { return err, http.StatusBadRequest, nil @@ -96,7 +97,7 @@ func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interfac return errors.Wrapf(err, "invalid po file"), http.StatusBadRequest, nil } - flows, err := loadFlows(ctx, s.DB, form.OrgID, form.FlowIDs) + flows, err := loadFlows(ctx, rt.DB, form.OrgID, form.FlowIDs) if err != nil { return err, http.StatusBadRequest, nil } diff --git a/web/po/po_test.go b/web/po/po_test.go index 00cc880c9..011018e47 100644 --- a/web/po/po_test.go +++ b/web/po/po_test.go @@ -7,6 +7,6 @@ import ( ) func TestServer(t *testing.T) { - web.RunWebTests(t, "testdata/export.json") - web.RunWebTests(t, "testdata/import.json") + web.RunWebTests(t, "testdata/export.json", nil) + web.RunWebTests(t, "testdata/import.json", nil) } diff --git a/web/po/testdata/favorites.po b/web/po/testdata/favorites.po index a9ffb4c2f..25ad7f8b6 100644 --- a/web/po/testdata/favorites.po +++ b/web/po/testdata/favorites.po @@ -10,88 +10,88 @@ msgstr "" "Language-3: \n" "Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85\n" -#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0 +#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0 msgid "All Responses" msgstr "" -#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0 -#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/arguments:0 +#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0 +#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0 msgid "Blue" msgstr "" -#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0 -#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/arguments:0 +#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/arguments:0 +#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0 msgid "Cyan" msgstr "" -#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0 +#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0 msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?" msgstr "" -#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0 -#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/arguments:0 +#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/arguments:0 +#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0 msgid "Green" msgstr "" -#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0 +#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0 msgid "I don't know that color. Try again." msgstr "" -#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0 +#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0 msgid "I don't know that one, try again please." msgstr "" -#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0 +#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0 msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?" msgstr "" -#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 -#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/arguments:0 +#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/arguments:0 +#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0 msgid "Mutzig" msgstr "" -#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0 +#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/arguments:0 msgid "Navy" msgstr "" -#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0 +#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0 msgid "No Response" msgstr "" -#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0 -#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0 +#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 +#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 msgid "Other" msgstr "" -#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0 -#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/arguments:0 +#: Favorites/58119801-ed31-4538-888d-23779a01707f/arguments:0 +#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0 msgid "Primus" msgstr "" -#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 -#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/arguments:0 +#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0 +#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/arguments:0 msgid "Red" msgstr "" -#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0 -#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/arguments:0 +#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0 +#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/arguments:0 msgid "Skol" msgstr "" -#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0 +#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0 msgid "Sorry you can't participate right now, I'll try again later." msgstr "" -#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0 +#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0 msgid "Thanks @results.name, we are all done!" msgstr "" -#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0 -#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/arguments:0 +#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/arguments:0 +#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0 msgid "Turbo King" msgstr "" -#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0 +#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0 msgid "What is your favorite color?" msgstr "" diff --git a/web/po/testdata/import.json b/web/po/testdata/import.json index d7b9988a9..a0826ed51 100644 --- a/web/po/testdata/import.json +++ b/web/po/testdata/import.json @@ -45,13 +45,13 @@ "expire_after_minutes": 720, "localization": { "spa": { - "34a421ac-34cb-49d8-a2a5-534f52c60851": { - "name": [ + "8d2e259c-bc3c-464f-8c15-985bc736e212": { + "arguments": [ "Azul" ] }, "baf07ebb-8a2a-4e63-aa08-d19aa408cd45": { - "arguments": [ + "name": [ "Azul" ] } @@ -59,186 +59,186 @@ }, "nodes": [ { - "uuid": "10c9c241-777f-4010-a841-6e87abed8520", + "uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542", "actions": [ { "type": "send_msg", - "uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a", + "uuid": "943f85bb-50bc-40c3-8d6f-57dbe34c87f7", "text": "What is your favorite color?" } ], "exits": [ { - "uuid": "943f85bb-50bc-40c3-8d6f-57dbe34c87f7", - "destination_uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542" + "uuid": "9631dddf-0dd7-4310-b263-5f7cad4795e0", + "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991" } ] }, { - "uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93", + "uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a", "actions": [ { "type": "send_msg", - "uuid": "9631dddf-0dd7-4310-b263-5f7cad4795e0", + "uuid": "66c38ec3-0acd-4bf7-a5d5-278af1bee492", "text": "I don't know that color. Try again." } ], "exits": [ { - "uuid": "66c38ec3-0acd-4bf7-a5d5-278af1bee492", - "destination_uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542" + "uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac", + "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991" } ] }, { - "uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542", + "uuid": "333fa9a0-85a3-47c5-817e-153a1a124991", "router": { "type": "switch", "wait": { "type": "msg", "timeout": { "seconds": 300, - "category_uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612" + "category_uuid": "7624633a-01a9-48f0-abca-957e7290df0a" } }, "result_name": "Color", "categories": [ { - "uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8", + "uuid": "58284598-805a-4740-8966-dcb09e3b670a", "name": "Red", - "exit_uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac" + "exit_uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464" }, { - "uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da", + "uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6", "name": "Green", - "exit_uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464" + "exit_uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529" }, { - "uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851", + "uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45", "name": "Blue", - "exit_uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529" + "exit_uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c" }, { - "uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a", + "uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef", "name": "Cyan", - "exit_uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c" + "exit_uuid": "405cf157-1e43-46d8-a0d1-49adcb539267" }, { - "uuid": "5563a722-9680-419c-a792-b1fa9df92e06", + "uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8", "name": "Other", - "exit_uuid": "405cf157-1e43-46d8-a0d1-49adcb539267" + "exit_uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae" }, { - "uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612", + "uuid": "7624633a-01a9-48f0-abca-957e7290df0a", "name": "No Response", - "exit_uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae" + "exit_uuid": "5563a722-9680-419c-a792-b1fa9df92e06" } ], "operand": "@input", "cases": [ { - "uuid": "58284598-805a-4740-8966-dcb09e3b670a", + "uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da", "type": "has_any_word", "arguments": [ "Red" ], - "category_uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8" + "category_uuid": "58284598-805a-4740-8966-dcb09e3b670a" }, { - "uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6", + "uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851", "type": "has_any_word", "arguments": [ "Green" ], - "category_uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da" + "category_uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6" }, { - "uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45", + "uuid": "8d2e259c-bc3c-464f-8c15-985bc736e212", "type": "has_any_word", "arguments": [ "Blue" ], - "category_uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851" + "category_uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45" }, { - "uuid": "8d2e259c-bc3c-464f-8c15-985bc736e212", + "uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a", "type": "has_any_word", "arguments": [ "Navy" ], - "category_uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851" + "category_uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45" }, { - "uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef", + "uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612", "type": "has_any_word", "arguments": [ "Cyan" ], - "category_uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a" + "category_uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef" } ], - "default_category_uuid": "5563a722-9680-419c-a792-b1fa9df92e06" + "default_category_uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8" }, "exits": [ - { - "uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac", - "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991" - }, { "uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464", - "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991" + "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" }, { "uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529", - "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991" + "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" }, { - "uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c" + "uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c", + "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" }, { - "uuid": "405cf157-1e43-46d8-a0d1-49adcb539267", - "destination_uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93" + "uuid": "405cf157-1e43-46d8-a0d1-49adcb539267" }, { "uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae", - "destination_uuid": "1b828e78-e478-4357-9472-47a30ec1f60b" + "destination_uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a" + }, + { + "uuid": "5563a722-9680-419c-a792-b1fa9df92e06", + "destination_uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93" } ] }, { - "uuid": "333fa9a0-85a3-47c5-817e-153a1a124991", + "uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0", "actions": [ { "type": "send_msg", - "uuid": "7624633a-01a9-48f0-abca-957e7290df0a", + "uuid": "4cadf512-1299-468f-85e4-26af9edec193", "text": "Good choice, I like @results.color.category_localized too! What is your favorite beer?" } ], "exits": [ { - "uuid": "4cadf512-1299-468f-85e4-26af9edec193", - "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db" + "uuid": "aac779a9-e2a6-4a11-9efa-9670e081a33a", + "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf" } ] }, { - "uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434", + "uuid": "a84399b1-0e7b-42ee-8759-473137b510db", "actions": [ { "type": "send_msg", - "uuid": "aac779a9-e2a6-4a11-9efa-9670e081a33a", + "uuid": "0f0e66a8-9062-444f-b636-3d5374466e31", "text": "I don't know that one, try again please." } ], "exits": [ { - "uuid": "0f0e66a8-9062-444f-b636-3d5374466e31", - "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db" + "uuid": "0891f63c-9e82-42bb-a815-8b44aff33046", + "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf" } ] }, { - "uuid": "a84399b1-0e7b-42ee-8759-473137b510db", + "uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf", "router": { "type": "switch", "wait": { @@ -247,109 +247,109 @@ "result_name": "Beer", "categories": [ { - "uuid": "a813de57-c92a-4128-804d-56e80b332142", + "uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9", "name": "Mutzig", - "exit_uuid": "0891f63c-9e82-42bb-a815-8b44aff33046" + "exit_uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e" }, { - "uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b", + "uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267", "name": "Primus", - "exit_uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e" + "exit_uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77" }, { - "uuid": "58119801-ed31-4538-888d-23779a01707f", + "uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1", "name": "Turbo King", - "exit_uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77" + "exit_uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8" }, { - "uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43", + "uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91", "name": "Skol", - "exit_uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8" + "exit_uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121" }, { - "uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38", + "uuid": "a813de57-c92a-4128-804d-56e80b332142", "name": "Other", - "exit_uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121" + "exit_uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38" } ], "operand": "@input", "cases": [ { - "uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9", + "uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b", "type": "has_any_word", "arguments": [ "Mutzig" ], - "category_uuid": "a813de57-c92a-4128-804d-56e80b332142" + "category_uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9" }, { - "uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267", + "uuid": "58119801-ed31-4538-888d-23779a01707f", "type": "has_any_word", "arguments": [ "Primus" ], - "category_uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b" + "category_uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267" }, { - "uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1", + "uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43", "type": "has_any_word", "arguments": [ "Turbo King" ], - "category_uuid": "58119801-ed31-4538-888d-23779a01707f" + "category_uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1" }, { - "uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91", + "uuid": "ada3d96a-a1a2-41eb-aac7-febdb98a9b4c", "type": "has_any_word", "arguments": [ "Skol" ], - "category_uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43" + "category_uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91" } ], - "default_category_uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38" + "default_category_uuid": "a813de57-c92a-4128-804d-56e80b332142" }, "exits": [ - { - "uuid": "0891f63c-9e82-42bb-a815-8b44aff33046", - "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" - }, { "uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e", - "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" + "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434" }, { "uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77", - "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" + "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434" }, { "uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8", - "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0" + "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434" }, { "uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121", "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434" + }, + { + "uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38", + "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db" } ] }, { - "uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0", + "uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434", "actions": [ { "type": "send_msg", - "uuid": "ada3d96a-a1a2-41eb-aac7-febdb98a9b4c", + "uuid": "fc551cb4-e797-4076-b40a-433c44ad492b", "text": "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?" } ], "exits": [ { - "uuid": "fc551cb4-e797-4076-b40a-433c44ad492b", - "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf" + "uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207", + "destination_uuid": "1b828e78-e478-4357-9472-47a30ec1f60b" } ] }, { - "uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf", + "uuid": "1b828e78-e478-4357-9472-47a30ec1f60b", "router": { "type": "switch", "wait": { @@ -358,49 +358,49 @@ "result_name": "Name", "categories": [ { - "uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574", + "uuid": "a602e75e-0814-4034-bb95-770906ddfe34", "name": "All Responses", - "exit_uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207" + "exit_uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574" } ], "operand": "@input", "cases": [], - "default_category_uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574" + "default_category_uuid": "a602e75e-0814-4034-bb95-770906ddfe34" }, "exits": [ { - "uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207", - "destination_uuid": "b4664fbd-3495-4fc6-aa8b-b397857dcd68" + "uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574", + "destination_uuid": "10c9c241-777f-4010-a841-6e87abed8520" } ] }, { - "uuid": "b4664fbd-3495-4fc6-aa8b-b397857dcd68", + "uuid": "10c9c241-777f-4010-a841-6e87abed8520", "actions": [ { "type": "send_msg", - "uuid": "a602e75e-0814-4034-bb95-770906ddfe34", + "uuid": "e92b12c5-1817-468e-aa2f-8791fb6247e9", "text": "Thanks @results.name, we are all done!" } ], "exits": [ { - "uuid": "e92b12c5-1817-468e-aa2f-8791fb6247e9" + "uuid": "cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48" } ] }, { - "uuid": "1b828e78-e478-4357-9472-47a30ec1f60b", + "uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93", "actions": [ { "type": "send_msg", - "uuid": "cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48", + "uuid": "1470d5e6-08dd-479b-a207-9b2b27b924d3", "text": "Sorry you can't participate right now, I'll try again later." } ], "exits": [ { - "uuid": "1470d5e6-08dd-479b-a207-9b2b27b924d3" + "uuid": "cc711204-3dd4-499d-9d37-b477bf5c5458" } ] } @@ -410,71 +410,71 @@ "10c9c241-777f-4010-a841-6e87abed8520": { "type": "execute_actions", "position": { - "top": 0, - "left": 100 + "top": 805, + "left": 191 } }, "1b828e78-e478-4357-9472-47a30ec1f60b": { - "type": "execute_actions", + "type": "wait_for_response", "position": { - "top": 1278, - "left": 752 + "top": 702, + "left": 191 } }, "333fa9a0-85a3-47c5-817e-153a1a124991": { - "type": "execute_actions", + "type": "wait_for_response", "position": { - "top": 237, - "left": 131 + "top": 129, + "left": 98 } }, "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434": { "type": "execute_actions", "position": { - "top": 265, - "left": 512 + "top": 535, + "left": 191 } }, "48fd5325-d660-4404-bdf3-05ad1b024cc0": { "type": "execute_actions", "position": { - "top": 535, - "left": 191 + "top": 237, + "left": 131 } }, "5253c207-46e8-42a9-998e-a3e54e0e0542": { - "type": "wait_for_response", + "type": "execute_actions", "position": { - "top": 129, - "left": 98 + "top": 0, + "left": 100 } }, "8c2504ef-0acc-405f-9efe-d5fc2c434a93": { "type": "execute_actions", "position": { - "top": 8, - "left": 456 + "top": 1278, + "left": 752 } }, "a84399b1-0e7b-42ee-8759-473137b510db": { - "type": "wait_for_response", + "type": "execute_actions", "position": { - "top": 387, - "left": 112 + "top": 265, + "left": 512 } }, "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf": { "type": "wait_for_response", "position": { - "top": 702, - "left": 191 + "top": 387, + "left": 112 } }, - "b4664fbd-3495-4fc6-aa8b-b397857dcd68": { + "f4495f19-37ee-4e51-a7d5-d99ef6be147a": { "type": "execute_actions", "position": { - "top": 805, - "left": 191 + "top": 8, + "left": 456 } } }, diff --git a/web/po/testdata/multiple_flows.es.po b/web/po/testdata/multiple_flows.es.po index 802df8126..5a91e2e83 100644 --- a/web/po/testdata/multiple_flows.es.po +++ b/web/po/testdata/multiple_flows.es.po @@ -10,110 +10,110 @@ msgstr "" "Language-3: spa\n" "Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85; 5890fe3a-f204-4661-b74d-025be4ee019c\n" -#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/arguments:0 +#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/arguments:0 msgid "1" msgstr "" -#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0 +#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/name:0 msgid "1-10" msgstr "" -#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/arguments:1 +#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/arguments:1 msgid "10" msgstr "" -#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0 -#: Pick+a+Number/5bf24536-9ae1-466a-9b76-5c82626d3153/name:0 +#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0 +#: Pick+a+Number/ee9c1a1d-3426-4f07-83c8-dc3c1949fe6c/name:0 msgid "All Responses" msgstr "" -#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0 -#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/arguments:0 +#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0 +#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0 msgid "Blue" msgstr "" -#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0 -#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/arguments:0 +#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/arguments:0 +#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0 msgid "Cyan" msgstr "" -#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0 +#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0 msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?" msgstr "" -#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0 -#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/arguments:0 +#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/arguments:0 +#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0 msgid "Green" msgstr "" -#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0 +#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0 msgid "I don't know that color. Try again." msgstr "" -#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0 +#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0 msgid "I don't know that one, try again please." msgstr "" -#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0 +#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0 msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?" msgstr "" -#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 -#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/arguments:0 +#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/arguments:0 +#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0 msgid "Mutzig" msgstr "" -#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0 +#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/arguments:0 msgid "Navy" msgstr "" -#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0 +#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0 msgid "No Response" msgstr "" -#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0 -#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0 -#: Pick+a+Number/0d15ae52-5ad9-4d64-9c64-e27545d48a19/name:0 +#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 +#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 +#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0 msgid "Other" msgstr "" -#: Pick+a+Number/bb3276d9-543b-427b-9d00-a926dabc8e24/text:0 +#: Pick+a+Number/1b0564e8-c806-4b08-9e3d-06370d9c064c/text:0 msgid "Pick a number between 1-10." msgstr "" -#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0 -#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/arguments:0 +#: Favorites/58119801-ed31-4538-888d-23779a01707f/arguments:0 +#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0 msgid "Primus" msgstr "" -#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 -#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/arguments:0 +#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0 +#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/arguments:0 msgid "Red" msgstr "" -#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0 -#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/arguments:0 +#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0 +#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/arguments:0 msgid "Skol" msgstr "" -#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0 +#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0 msgid "Sorry you can't participate right now, I'll try again later." msgstr "" -#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0 +#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0 msgid "Thanks @results.name, we are all done!" msgstr "" -#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0 -#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/arguments:0 +#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/arguments:0 +#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0 msgid "Turbo King" msgstr "" -#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0 +#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0 msgid "What is your favorite color?" msgstr "" -#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/text:0 +#: Pick+a+Number/41f97c7a-3397-4076-95ab-3f1aa9e2acb2/text:0 msgid "You picked @results.number!" msgstr "" diff --git a/web/po/testdata/multiple_flows_noargs.es.po b/web/po/testdata/multiple_flows_noargs.es.po index 81d0a3ea4..6ed3ab183 100644 --- a/web/po/testdata/multiple_flows_noargs.es.po +++ b/web/po/testdata/multiple_flows_noargs.es.po @@ -10,90 +10,90 @@ msgstr "" "Language-3: spa\n" "Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85; 5890fe3a-f204-4661-b74d-025be4ee019c\n" -#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0 +#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/name:0 msgid "1-10" msgstr "" -#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0 -#: Pick+a+Number/5bf24536-9ae1-466a-9b76-5c82626d3153/name:0 +#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0 +#: Pick+a+Number/ee9c1a1d-3426-4f07-83c8-dc3c1949fe6c/name:0 msgid "All Responses" msgstr "" -#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0 +#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0 msgid "Blue" msgstr "" -#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0 +#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0 msgid "Cyan" msgstr "" -#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0 +#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0 msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?" msgstr "" -#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0 +#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0 msgid "Green" msgstr "" -#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0 +#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0 msgid "I don't know that color. Try again." msgstr "" -#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0 +#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0 msgid "I don't know that one, try again please." msgstr "" -#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0 +#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0 msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?" msgstr "" -#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 +#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0 msgid "Mutzig" msgstr "" -#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0 +#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0 msgid "No Response" msgstr "" -#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0 -#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0 -#: Pick+a+Number/0d15ae52-5ad9-4d64-9c64-e27545d48a19/name:0 +#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 +#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0 +#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0 msgid "Other" msgstr "" -#: Pick+a+Number/bb3276d9-543b-427b-9d00-a926dabc8e24/text:0 +#: Pick+a+Number/1b0564e8-c806-4b08-9e3d-06370d9c064c/text:0 msgid "Pick a number between 1-10." msgstr "" -#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0 +#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0 msgid "Primus" msgstr "" -#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0 +#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0 msgid "Red" msgstr "" -#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0 +#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0 msgid "Skol" msgstr "" -#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0 +#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0 msgid "Sorry you can't participate right now, I'll try again later." msgstr "" -#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0 +#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0 msgid "Thanks @results.name, we are all done!" msgstr "" -#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0 +#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0 msgid "Turbo King" msgstr "" -#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0 +#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0 msgid "What is your favorite color?" msgstr "" -#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/text:0 +#: Pick+a+Number/41f97c7a-3397-4076-95ab-3f1aa9e2acb2/text:0 msgid "You picked @results.number!" msgstr "" diff --git a/web/server.go b/web/server.go index 3fa07747b..fd68fa6b3 100644 --- a/web/server.go +++ b/web/server.go @@ -3,7 +3,6 @@ package web import ( "compress/flate" "context" - "encoding/json" "fmt" "net/http" "sync" @@ -12,6 +11,7 @@ import ( "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/storage" "github.com/nyaruka/mailroom/config" + "github.com/nyaruka/mailroom/runtime" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -33,8 +33,8 @@ const ( MaxRequestBytes int64 = 1048576 * 32 // 32MB ) -type JSONHandler func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) -type Handler func(ctx context.Context, s *Server, r *http.Request, w http.ResponseWriter) error +type JSONHandler func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) +type Handler func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error type jsonRoute struct { method string @@ -61,14 +61,16 @@ func RegisterRoute(method string, pattern string, handler Handler) { } // NewServer creates a new web server, it will need to be started after being created -func NewServer(ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage, elasticClient *elastic.Client, wg *sync.WaitGroup) *Server { +func NewServer(ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage, es *elastic.Client, wg *sync.WaitGroup) *Server { s := &Server{ - CTX: ctx, - RP: rp, - DB: db, - Storage: store, - ElasticClient: elasticClient, - Config: config, + ctx: ctx, + rt: &runtime.Runtime{ + RP: rp, + DB: db, + ES: es, + MediaStorage: store, + Config: config, + }, wg: wg, } @@ -115,7 +117,7 @@ func (s *Server) WrapJSONHandler(handler JSONHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-type", "application/json") - value, status, err := handler(r.Context(), s, r) + value, status, err := handler(r.Context(), s.rt, r) // handler errored (a hard error) if err != nil { @@ -151,16 +153,15 @@ func (s *Server) WrapJSONHandler(handler JSONHandler) http.HandlerFunc { // WrapHandler wraps a simple Handler, taking care of passing down server and handling errors func (s *Server) WrapHandler(handler Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - err := handler(r.Context(), s, r, w) + err := handler(r.Context(), s.rt, r, w) if err == nil { return } logrus.WithError(err).WithField("http_request", r).Error("error handling request") w.WriteHeader(http.StatusInternalServerError) - serialized, _ := json.Marshal(NewErrorResponse(err)) + serialized := jsonx.MustMarshal(NewErrorResponse(err)) w.Write(serialized) - return } } @@ -181,7 +182,7 @@ func (s *Server) Start() { } }() - logrus.WithField("address", s.Config.Address).WithField("port", s.Config.Port).Info("server started") + logrus.WithField("address", s.rt.Config.Address).WithField("port", s.rt.Config.Port).Info("server started") } // Stop stops our web server @@ -192,30 +193,26 @@ func (s *Server) Stop() { } } -func handleIndex(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { +func handleIndex(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { response := map[string]string{ "url": fmt.Sprintf("%s", r.URL), "component": "mailroom", - "version": s.Config.Version, + "version": rt.Config.Version, } return response, http.StatusOK, nil } -func handle404(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { +func handle404(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { return errors.Errorf("not found: %s", r.URL.String()), http.StatusNotFound, nil } -func handle405(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { +func handle405(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { return errors.Errorf("illegal method: %s", r.Method), http.StatusMethodNotAllowed, nil } type Server struct { - CTX context.Context - RP *redis.Pool - DB *sqlx.DB - Storage storage.Storage - Config *config.Config - ElasticClient *elastic.Client + ctx context.Context + rt *runtime.Runtime wg *sync.WaitGroup diff --git a/web/server_test.go b/web/server_test.go index b5df99b60..1748fb144 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -5,5 +5,5 @@ import ( ) func TestServer(t *testing.T) { - RunWebTests(t, "testdata/server.json") + RunWebTests(t, "testdata/server.json", nil) } diff --git a/web/simulation/simulation.go b/web/simulation/simulation.go index 10713b144..4d6793a2c 100644 --- a/web/simulation/simulation.go +++ b/web/simulation/simulation.go @@ -5,7 +5,7 @@ import ( "encoding/json" "net/http" - "github.com/jmoiron/sqlx" + "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/assets/static/types" "github.com/nyaruka/goflow/excellent/tools" @@ -17,10 +17,14 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" ) +var testChannel = assets.NewChannelReference("440099cf-200c-4d45-a8e7-4a564f4a0e8b", "Test Channel") +var testURN = urns.URN("tel:+12065551212") + func init() { web.RegisterJSONRoute(http.MethodPost, "/mr/sim/start", web.RequireAuthToken(handleStart)) web.RegisterJSONRoute(http.MethodPost, "/mr/sim/resume", web.RequireAuthToken(handleResume)) @@ -114,20 +118,20 @@ func handleSimulationEvents(ctx context.Context, db models.Queryer, oa *models.O } // handles a request to /start -func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleStart(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &startRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation") } // grab our org assets - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets") } // create clone of assets for simulation - oa, err = oa.CloneForSimulation(s.CTX, s.DB, request.flows(), request.channels()) + oa, err = oa.CloneForSimulation(ctx, rt.DB, request.flows(), request.channels()) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to clone org") } @@ -138,18 +142,18 @@ func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to read trigger") } - return triggerFlow(ctx, s.DB, oa, trigger) + return triggerFlow(ctx, rt, oa, trigger) } // triggerFlow creates a new session with the passed in trigger, returning our standard response -func triggerFlow(ctx context.Context, db *sqlx.DB, oa *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) { +func triggerFlow(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) { // start our flow session - session, sprint, err := goflow.Simulator().NewSession(oa.SessionAssets(), trigger) + session, sprint, err := goflow.Simulator(rt.Config).NewSession(oa.SessionAssets(), trigger) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting session") } - err = handleSimulationEvents(ctx, db, oa, sprint.Events()) + err = handleSimulationEvents(ctx, rt.DB, oa, sprint.Events()) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events") } @@ -177,25 +181,25 @@ type resumeRequest struct { Resume json.RawMessage `json:"resume" validate:"required"` } -func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleResume(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &resumeRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return nil, http.StatusBadRequest, err } // grab our org assets - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) if err != nil { return nil, http.StatusBadRequest, err } // create clone of assets for simulation - oa, err = oa.CloneForSimulation(s.CTX, s.DB, request.flows(), request.channels()) + oa, err = oa.CloneForSimulation(ctx, rt.DB, request.flows(), request.channels()) if err != nil { return nil, http.StatusBadRequest, err } - session, err := goflow.Simulator().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) + session, err := goflow.Simulator(rt.Config).ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, err } @@ -230,8 +234,18 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac } if triggeredFlow != nil { - trigger := triggers.NewBuilder(oa.Env(), triggeredFlow.FlowReference(), resume.Contact()).Msg(msgResume.Msg()).WithMatch(trigger.Match()).Build() - return triggerFlow(ctx, s.DB, oa, trigger) + tb := triggers.NewBuilder(oa.Env(), triggeredFlow.FlowReference(), resume.Contact()) + + var sessionTrigger flows.Trigger + if triggeredFlow.FlowType() == models.FlowTypeVoice { + // TODO this should trigger a msg trigger with a connection but first we need to rework + // non-simulation IVR triggers to use that so that this is consistent. + sessionTrigger = tb.Manual().WithConnection(testChannel, testURN).Build() + } else { + sessionTrigger = tb.Msg(msgResume.Msg()).WithMatch(trigger.Match()).Build() + } + + return triggerFlow(ctx, rt, oa, sessionTrigger) } } } @@ -248,7 +262,7 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, err } - err = handleSimulationEvents(ctx, s.DB, oa, sprint.Events()) + err = handleSimulationEvents(ctx, rt.DB, oa, sprint.Events()) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events") } diff --git a/web/simulation/simulation_test.go b/web/simulation/simulation_test.go index 8c256febc..bc7033772 100644 --- a/web/simulation/simulation_test.go +++ b/web/simulation/simulation_test.go @@ -11,10 +11,13 @@ import ( "testing" "time" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" + "github.com/stretchr/testify/assert" ) @@ -85,60 +88,7 @@ const ( "name": "Twitter", "uuid": "0f661e8b-ea9d-4bd3-9953-d368340acf91" }, - "text": "I like blue!", - "urn": "tel:+12065551212", - "uuid": "9bf91c2b-ce58-4cef-aacc-281e03f69ab5" - }, - "resumed_on": "2000-01-01T00:00:00.000000000-00:00", - "type": "msg" - }, - "assets": { - "channels": [ - { - "uuid": "440099cf-200c-4d45-a8e7-4a564f4a0e8b", - "name": "Test Channel", - "address": "+18005551212", - "schemes": ["tel"], - "roles": ["send", "receive", "call"], - "country": "US" - } - ] - }, - "session": $$SESSION$$ - }` - - triggerResumeBody = ` - { - "org_id": 1, - "resume": { - "contact": { - "created_on": "2000-01-01T00:00:00.000000000-00:00", - "fields": {}, - "id": 1234567, - "language": "eng", - "name": "Ben Haggerty", - "timezone": "America/Guayaquil", - "urns": [ - "tel:+12065551212" - ], - "uuid": "ba96bf7f-bc2a-4873-a7c7-254d1927c4e3" - }, - "environment": { - "allowed_languages": [ - "eng", - "fra" - ], - "date_format": "YYYY-MM-DD", - "default_language": "eng", - "time_format": "hh:mm", - "timezone": "America/New_York" - }, - "msg": { - "channel": { - "name": "Twitter", - "uuid": "0f661e8b-ea9d-4bd3-9953-d368340acf91" - }, - "text": "trigger", + "text": "$$MESSAGE$$", "urn": "tel:+12065551212", "uuid": "9bf91c2b-ce58-4cef-aacc-281e03f69ab5" }, @@ -249,10 +199,8 @@ const ( ) func TestServer(t *testing.T) { - testsuite.Reset() - ctx := testsuite.CTX() - db := testsuite.DB() - rp := testsuite.RP() + ctx, _, db, rp := testsuite.Reset() + wg := &sync.WaitGroup{} server := web.NewServer(ctx, config.Mailroom, db, rp, nil, nil, wg) @@ -262,51 +210,52 @@ func TestServer(t *testing.T) { time.Sleep(time.Second) defer server.Stop() - session := "" + + var session json.RawMessage // add a trigger for our campaign flow with 'trigger' - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), 'trigger', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, - models.CampaignFlowID, - ) + testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.CampaignFlow, "trigger", models.MatchOnly, nil, nil) + + // and a trigger which will trigger an IVR flow + testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.IVRFlow, "ivr", models.MatchOnly, nil, nil) // also add a catch all - db.MustExec( - `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived, - flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id) - VALUES(TRUE, now(), now(), NULL, false, $1, 'C', NULL, 1, 1, 1) RETURNING id`, - models.CampaignFlowID, - ) + testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.CampaignFlow, nil, nil) tcs := []struct { - URL string - Method string - Body string - Status int - Response string + URL string + Method string + Body string + Message string + ExpectedStatus int + ExpectedResponse string }{ - {"/mr/sim/start", "GET", "", 405, "illegal"}, - {"/mr/sim/start", "POST", startBody, 200, "What is your favorite color?"}, - {"/mr/sim/resume", "GET", "", 405, "illegal"}, - {"/mr/sim/resume", "POST", resumeBody, 200, "Good choice, I like Blue too! What is your favorite beer?"}, - {"/mr/sim/start", "POST", customStartBody, 200, "Your channel is Test Channel"}, - {"/mr/sim/start", "POST", startBody, 200, "What is your favorite color?"}, - {"/mr/sim/resume", "POST", triggerResumeBody, 200, "it is time to consult with your patients"}, - {"/mr/sim/resume", "POST", resumeBody, 200, "it is time to consult with your patients"}, + {"/mr/sim/start", "GET", "", "", 405, "illegal"}, + {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"}, + {"/mr/sim/resume", "POST", resumeBody, "I like blue!", 200, "Good choice, I like Blue too! What is your favorite beer?"}, + + // start with a definition of the flow to override what we have in assets + {"/mr/sim/start", "POST", customStartBody, "", 200, "Your channel is Test Channel"}, + + // start regular flow again but resume with a message that matches the campaign flow trigger + {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"}, + {"/mr/sim/resume", "POST", resumeBody, "trigger", 200, "it is time to consult with your patients"}, + {"/mr/sim/resume", "POST", resumeBody, "I like blue!", 200, "it is time to consult with your patients"}, + + // start favorties again but this time resume with a message that matches the IVR flow trigger + {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"}, + {"/mr/sim/resume", "POST", resumeBody, "ivr", 200, "Hello there. Please enter one or two."}, } for i, tc := range tcs { - var body io.Reader + bodyStr := strings.Replace(tc.Body, "$$MESSAGE$$", tc.Message, -1) // in the case of a resume, we have to sub in our session body from our start - if strings.Contains(tc.Body, "$$SESSION$$") { - tc.Body = strings.Replace(tc.Body, "$$SESSION$$", session, -1) - } + bodyStr = strings.Replace(bodyStr, "$$SESSION$$", string(session), -1) + var body io.Reader if tc.Body != "" { - body = bytes.NewReader([]byte(tc.Body)) + body = bytes.NewReader([]byte(bodyStr)) } req, err := http.NewRequest(tc.Method, "http://localhost:8090"+tc.URL, body) @@ -315,7 +264,7 @@ func TestServer(t *testing.T) { resp, err := http.DefaultClient.Do(req) assert.NoError(t, err, "%d: error making request", i) - assert.Equal(t, tc.Status, resp.StatusCode, "%d: unexpected status", i) + assert.Equal(t, tc.ExpectedStatus, resp.StatusCode, "%d: unexpected status", i) content, err := ioutil.ReadAll(resp.Body) assert.NoError(t, err, "%d: error reading body", i) @@ -324,9 +273,8 @@ func TestServer(t *testing.T) { if resp.StatusCode == 200 { // save the session for use in a resume parsed := make(map[string]interface{}) - json.Unmarshal(content, &parsed) - sessionJSON, _ := json.Marshal(parsed["session"]) - session = string(sessionJSON) + jsonx.MustUnmarshal(content, &parsed) + session = jsonx.MustMarshal(parsed["session"]) context, hasContext := parsed["context"] if hasContext { @@ -335,6 +283,6 @@ func TestServer(t *testing.T) { } } - assert.True(t, strings.Contains(string(content), tc.Response), "%d: did not find string: %s in body: %s", i, tc.Response, string(content)) + assert.Contains(t, string(content), tc.ExpectedResponse, "%d: did not find expected response content") } } diff --git a/web/surveyor/surveyor.go b/web/surveyor/surveyor.go index 9f846fa51..322f89779 100644 --- a/web/surveyor/surveyor.go +++ b/web/surveyor/surveyor.go @@ -14,6 +14,7 @@ import ( "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/goflow" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/web" "github.com/pkg/errors" @@ -49,7 +50,7 @@ type submitResponse struct { } // handles a surveyor request -func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) { +func handleSubmit(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { request := &submitRequest{} if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation") @@ -57,7 +58,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac // grab our org assets orgID := ctx.Value(web.OrgIDKey).(models.OrgID) - oa, err := models.GetOrgAssets(s.CTX, s.DB, orgID) + oa, err := models.GetOrgAssets(ctx, rt.DB, orgID) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets") } @@ -68,7 +69,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Errorf("missing request user") } - fs, err := goflow.Engine().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) + fs, err := goflow.Engine(rt.Config).ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing) if err != nil { return nil, http.StatusBadRequest, errors.Wrapf(err, "error reading session") } @@ -96,12 +97,12 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac // create / fetch our contact based on the highest priority URN urn := fs.Contact().URNs()[0].URN() - _, flowContact, _, err = models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{urn}, models.NilChannelID) + _, flowContact, _, err = models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{urn}, models.NilChannelID) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to look up contact") } } else { - _, flowContact, err = models.CreateContact(ctx, s.DB, oa, models.NilUserID, "", envs.NilLanguage, nil) + _, flowContact, err = models.CreateContact(ctx, rt.DB, oa, models.NilUserID, "", envs.NilLanguage, nil) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to create contact") } @@ -121,19 +122,17 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac fs.SetContact(flowContact) // append our session events to our modifiers events, the union will be used to update the db/contact - for _, e := range sessionEvents { - modifierEvents = append(modifierEvents, e) - } + modifierEvents = append(modifierEvents, sessionEvents...) // create our sprint sprint := engine.NewSprint(mods, modifierEvents) // write our session out - tx, err := s.DB.BeginTxx(ctx, nil) + tx, err := rt.DB.BeginTxx(ctx, nil) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction for session write") } - sessions, err := models.WriteSessions(ctx, tx, s.RP, oa, []flows.Session{fs}, []flows.Sprint{sprint}, nil) + sessions, err := models.WriteSessions(ctx, tx, rt.RP, rt.MediaStorage, oa, []flows.Session{fs}, []flows.Sprint{sprint}, nil) if err == nil && len(sessions) == 0 { err = errors.Errorf("no sessions written") } @@ -146,13 +145,13 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac return nil, http.StatusInternalServerError, errors.Wrapf(err, "error committing sessions") } - tx, err = s.DB.BeginTxx(ctx, nil) + tx, err = rt.DB.BeginTxx(ctx, nil) if err != nil { return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction for post commit hooks") } // write our post commit hooks - err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, oa, []*models.Scene{sessions[0].Scene()}) + err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, []*models.Scene{sessions[0].Scene()}) if err != nil { tx.Rollback() return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying post commit hooks") diff --git a/web/surveyor/surveyor_test.go b/web/surveyor/surveyor_test.go index 6060beeca..3ad89b463 100644 --- a/web/surveyor/surveyor_test.go +++ b/web/surveyor/surveyor_test.go @@ -14,6 +14,7 @@ import ( "github.com/nyaruka/mailroom/config" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" "github.com/nyaruka/mailroom/web" "github.com/buger/jsonparser" @@ -22,7 +23,7 @@ import ( ) func TestSurveyor(t *testing.T) { - ctx, db, rp := testsuite.Reset() + ctx, _, db, rp := testsuite.Reset() rc := rp.Get() defer rc.Close() @@ -48,48 +49,48 @@ func TestSurveyor(t *testing.T) { }{ {"contact_surveyor_submission.json", "", 401, "missing authorization", nil}, {"contact_surveyor_submission.json", "invalid", 401, "invalid authorization", []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 0}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 0}, }}, // new contact is created (our test db already has a bob, he should be unaffected) {"contact_surveyor_submission.json", "sesame", 201, `"status": "C"`, []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id AND is_active = FALSE`, 1}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND org_id = 1`, 2}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, - Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, - Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1}, - Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 4}, - Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 3}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id AND is_active = FALSE`, 1}, + {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND org_id = 1`, 2}, + {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, + {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, + {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, + {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1}, + {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 4}, + {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 3}, }}, // dupe submission should fail due to run UUIDs being duplicated {"contact_surveyor_submission.json", "sesame", 500, `error writing runs`, []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 1}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 1}, }}, // but submission with new UUIDs should succeed, new run is created but not contact {"contact_surveyor_submission2.json", "sesame", 201, `"status": "C"`, []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 2}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, - Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, - Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1}, - Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 8}, - Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 6}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 2}, + {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, + {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, + {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, + {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1}, + {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 8}, + {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 6}, }}, // group removal is ONLY in the modifier {"remove_group.json", "sesame", 201, `"status": "C"`, []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 3}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, - Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, - Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 3}, + {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0}, + {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1}, + {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1}, + {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0}, }}, // new contact, new session, group and field no longer exist {"missing_group_field.json", "sesame", 201, `"status": "C"`, []Assertion{ - Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 1}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'c7fa24ca-48f9-45bf-b923-f95aa49c3cd2'`, 0}, - Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Fred' AND fields = jsonb_build_object()`, 1}, - Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123488' AND contact_id = :contact_id`, 1}, - Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0}, + {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 1}, + {`SELECT count(*) FROM contacts_contact WHERE uuid = 'c7fa24ca-48f9-45bf-b923-f95aa49c3cd2'`, 0}, + {`SELECT count(*) FROM contacts_contact WHERE name = 'Fred' AND fields = jsonb_build_object()`, 1}, + {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123488' AND contact_id = :contact_id`, 1}, + {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0}, }}, } @@ -102,10 +103,10 @@ func TestSurveyor(t *testing.T) { } args := &AssertionArgs{ - FlowID: models.SurveyorFlowID, - OrgID: models.Org1, - AgeFieldUUID: models.AgeFieldUUID, - TestersGroupID: models.TestersGroupID, + FlowID: testdata.SurveyorFlow.ID, + OrgID: testdata.Org1.ID, + AgeFieldUUID: testdata.AgeField.UUID, + TestersGroupID: testdata.TestersGroup.ID, } for i, tc := range tcs { @@ -114,7 +115,7 @@ func TestSurveyor(t *testing.T) { submission, err := ioutil.ReadFile(path) assert.NoError(t, err) - url := fmt.Sprintf("http://localhost:8090/mr/surveyor/submit") + url := "http://localhost:8090/mr/surveyor/submit" req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(submission)) assert.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -124,6 +125,7 @@ func TestSurveyor(t *testing.T) { } resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) assert.Equal(t, tc.StatusCode, resp.StatusCode, "unexpected status code for %s", testID) body, _ := ioutil.ReadAll(resp.Body) diff --git a/web/testing.go b/web/testing.go index 1073d3635..65e506af4 100644 --- a/web/testing.go +++ b/web/testing.go @@ -28,7 +28,7 @@ import ( ) // RunWebTests runs the tests in the passed in filename, optionally updating them if the update flag is set -func RunWebTests(t *testing.T, truthFile string) { +func RunWebTests(t *testing.T, truthFile string, substitutions map[string]string) { rp := testsuite.RP() db := testsuite.DB() wg := &sync.WaitGroup{} @@ -40,7 +40,7 @@ func RunWebTests(t *testing.T, truthFile string) { defer testsuite.ResetStorage() - server := NewServer(context.Background(), config.Mailroom, db, rp, testsuite.Storage(), nil, wg) + server := NewServer(context.Background(), config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg) server.Start() defer server.Stop() @@ -69,6 +69,10 @@ func RunWebTests(t *testing.T, truthFile string) { tcJSON, err := ioutil.ReadFile(truthFile) require.NoError(t, err) + for key, value := range substitutions { + tcJSON = bytes.ReplaceAll(tcJSON, []byte("$"+key+"$"), []byte(value)) + } + err = json.Unmarshal(tcJSON, &tcs) require.NoError(t, err) @@ -148,7 +152,7 @@ func RunWebTests(t *testing.T, truthFile string) { } for _, dba := range tc.DBAssertions { - testsuite.AssertQueryCount(t, db, dba.Query, nil, dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query) + testsuite.AssertQuery(t, db, dba.Query).Returns(dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query) } } else { diff --git a/web/ticket/assign.go b/web/ticket/assign.go new file mode 100644 index 000000000..271ca7af8 --- /dev/null +++ b/web/ticket/assign.go @@ -0,0 +1,58 @@ +package ticket + +import ( + "context" + "net/http" + + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" +) + +func init() { + web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/assign", web.RequireAuthToken(web.WithHTTPLogs(handleAssign))) +} + +type assignRequest struct { + bulkTicketRequest + + AssigneeID models.UserID `json:"assignee_id"` + Note string `json:"note"` +} + +// Assigns the tickets with the given ids to the given user +// +// { +// "org_id": 123, +// "user_id": 234, +// "ticket_ids": [1234, 2345], +// "assignee_id": 567, +// "note": "please look at these" +// } +// +func handleAssign(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { + request := &assignRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + // grab our org assets + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") + } + + tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs) + if err != nil { + return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) + } + + evts, err := models.AssignTickets(ctx, rt.DB, oa, request.UserID, tickets, request.AssigneeID, request.Note) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error assigning tickets") + } + + return newBulkResponse(evts), http.StatusOK, nil +} diff --git a/web/ticket/base.go b/web/ticket/base.go new file mode 100644 index 000000000..d67a2907d --- /dev/null +++ b/web/ticket/base.go @@ -0,0 +1,28 @@ +package ticket + +import ( + "sort" + + "github.com/nyaruka/mailroom/core/models" +) + +type bulkTicketRequest struct { + OrgID models.OrgID `json:"org_id" validate:"required"` + UserID models.UserID `json:"user_id" validate:"required"` + TicketIDs []models.TicketID `json:"ticket_ids"` +} + +type bulkTicketResponse struct { + ChangedIDs []models.TicketID `json:"changed_ids"` +} + +func newBulkResponse(changed map[*models.Ticket]*models.TicketEvent) *bulkTicketResponse { + ids := make([]models.TicketID, 0, len(changed)) + for t := range changed { + ids = append(ids, t.ID()) + } + + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + return &bulkTicketResponse{ChangedIDs: ids} +} diff --git a/web/ticket/base_test.go b/web/ticket/base_test.go new file mode 100644 index 000000000..f94f8846a --- /dev/null +++ b/web/ticket/base_test.go @@ -0,0 +1,56 @@ +package ticket + +import ( + "testing" + + _ "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/mailroom/web" +) + +func TestTicketAssign(t *testing.T) { + _, _, db, _ := testsuite.Reset() + + // create 2 open tickets and 1 closed one for Cathy across two different ticketers + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", testdata.Agent) + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", nil) + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Bob, testdata.Zendesk, "Problem", "", "", nil) + + web.RunWebTests(t, "testdata/assign.json", nil) +} + +func TestTicketNote(t *testing.T) { + _, _, db, _ := testsuite.Reset() + + // create 2 open tickets and 1 closed one for Cathy across two different ticketers + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", testdata.Agent) + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", nil) + + web.RunWebTests(t, "testdata/note.json", nil) +} + +func TestTicketClose(t *testing.T) { + _, _, db, _ := testsuite.Reset() + + // create 2 open tickets and 1 closed one for Cathy across two different ticketers + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", nil) + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", testdata.Editor) + + web.RunWebTests(t, "testdata/close.json", nil) +} + +func TestTicketReopen(t *testing.T) { + _, _, db, _ := testsuite.Reset() + + // create 2 closed tickets and 1 open one for Cathy + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin) + testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", nil) + testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", testdata.Editor) + + web.RunWebTests(t, "testdata/reopen.json", nil) +} diff --git a/web/ticket/close.go b/web/ticket/close.go new file mode 100644 index 000000000..3979297ba --- /dev/null +++ b/web/ticket/close.go @@ -0,0 +1,60 @@ +package ticket + +import ( + "context" + "net/http" + + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/core/tasks/handler" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" +) + +func init() { + web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.WithHTTPLogs(handleClose))) +} + +// Closes any open tickets with the given ids +// +// { +// "org_id": 123, +// "user_id": 234, +// "ticket_ids": [1234, 2345] +// } +// +func handleClose(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { + request := &bulkTicketRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + // grab our org assets + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") + } + + tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs) + if err != nil { + return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) + } + + evts, err := models.CloseTickets(ctx, rt.DB, oa, request.UserID, tickets, true, l) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error closing tickets") + } + + rc := rt.RP.Get() + defer rc.Close() + + for t, e := range evts { + err = handler.QueueTicketEvent(rc, t.ContactID(), e) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "error queueing ticket event for ticket %d", t.ID()) + } + } + + return newBulkResponse(evts), http.StatusOK, nil +} diff --git a/web/ticket/note.go b/web/ticket/note.go new file mode 100644 index 000000000..e6c3c3a4f --- /dev/null +++ b/web/ticket/note.go @@ -0,0 +1,56 @@ +package ticket + +import ( + "context" + "net/http" + + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" +) + +func init() { + web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/note", web.RequireAuthToken(web.WithHTTPLogs(handleNote))) +} + +type noteRequest struct { + bulkTicketRequest + + Note string `json:"note"` +} + +// Assigns the tickets with the given ids to the given user +// +// { +// "org_id": 123, +// "user_id": 234, +// "ticket_ids": [1234, 2345], +// "note": "spam" +// } +// +func handleNote(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { + request := ¬eRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + // grab our org assets + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") + } + + tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs) + if err != nil { + return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) + } + + evts, err := models.NoteTickets(ctx, rt.DB, oa, request.UserID, tickets, request.Note) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrap(err, "error adding notes to tickets") + } + + return newBulkResponse(evts), http.StatusOK, nil +} diff --git a/web/ticket/reopen.go b/web/ticket/reopen.go new file mode 100644 index 000000000..11c1dba6d --- /dev/null +++ b/web/ticket/reopen.go @@ -0,0 +1,50 @@ +package ticket + +import ( + "context" + "net/http" + + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/web" + + "github.com/pkg/errors" +) + +func init() { + web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.WithHTTPLogs(handleReopen))) +} + +// Reopens any closed tickets with the given ids +// +// { +// "org_id": 123, +// "user_id": 234, +// "ticket_ids": [1234, 2345] +// } +// +func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { + request := &bulkTicketRequest{} + if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { + return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil + } + + // grab our org assets + oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID) + if err != nil { + return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") + } + + tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs) + if err != nil { + return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) + } + + evts, err := models.ReopenTickets(ctx, rt.DB, oa, request.UserID, tickets, true, l) + if err != nil { + return nil, http.StatusBadRequest, errors.Wrapf(err, "error reopening tickets for org: %d", request.OrgID) + } + + return newBulkResponse(evts), http.StatusOK, nil +} diff --git a/web/ticket/testdata/assign.json b/web/ticket/testdata/assign.json new file mode 100644 index 000000000..2cde0f7f5 --- /dev/null +++ b/web/ticket/testdata/assign.json @@ -0,0 +1,64 @@ +[ + { + "label": "assigns the given tickets to the given user", + "method": "POST", + "path": "/mr/ticket/assign", + "body": { + "org_id": 1, + "user_id": 3, + "ticket_ids": [ + 1, + 2, + 3 + ], + "assignee_id": 6, + "note": "please handle" + }, + "status": 200, + "response": { + "changed_ids": [ + 1, + 3 + ] + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM tickets_ticket WHERE assignee_id = 6", + "count": 3 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND created_by_id = 3 AND note = 'please handle'", + "count": 2 + } + ] + }, + { + "label": "unassigns the given tickets if user is null", + "method": "POST", + "path": "/mr/ticket/assign", + "body": { + "org_id": 1, + "user_id": 3, + "ticket_ids": [ + 1 + ], + "assignee_id": null + }, + "status": 200, + "response": { + "changed_ids": [ + 1 + ] + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM tickets_ticket WHERE id = 1 AND assignee_id IS NULL", + "count": 1 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND created_by_id = 3", + "count": 3 + } + ] + } +] \ No newline at end of file diff --git a/web/ticket/testdata/close.json b/web/ticket/testdata/close.json index 42959e6f3..9b3916e13 100644 --- a/web/ticket/testdata/close.json +++ b/web/ticket/testdata/close.json @@ -13,6 +13,7 @@ "path": "/mr/ticket/close", "body": { "org_id": 1, + "user_id": 3, "ticket_ids": [ 1 ] @@ -31,6 +32,10 @@ { "query": "SELECT count(*) FROM tickets_ticket WHERE status = 'C'", "count": 2 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'C' AND created_by_id = 3", + "count": 1 } ] }, @@ -48,6 +53,7 @@ "path": "/mr/ticket/close", "body": { "org_id": 1, + "user_id": 3, "ticket_ids": [ 1, 2 @@ -67,6 +73,10 @@ { "query": "SELECT count(*) FROM tickets_ticket WHERE status = 'C'", "count": 3 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'C' AND created_by_id = 3", + "count": 2 } ] } diff --git a/web/ticket/testdata/note.json b/web/ticket/testdata/note.json new file mode 100644 index 000000000..4b0191437 --- /dev/null +++ b/web/ticket/testdata/note.json @@ -0,0 +1,29 @@ +[ + { + "label": "adds a note to the given tickets", + "method": "POST", + "path": "/mr/ticket/note", + "body": { + "org_id": 1, + "user_id": 3, + "ticket_ids": [ + 1, + 3 + ], + "note": "please handle" + }, + "status": 200, + "response": { + "changed_ids": [ + 1, + 3 + ] + }, + "db_assertions": [ + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'N' AND created_by_id = 3 AND note = 'please handle'", + "count": 2 + } + ] + } +] \ No newline at end of file diff --git a/web/ticket/testdata/reopen.json b/web/ticket/testdata/reopen.json index 98a4e2b3d..c995abecc 100644 --- a/web/ticket/testdata/reopen.json +++ b/web/ticket/testdata/reopen.json @@ -13,6 +13,7 @@ "path": "/mr/ticket/reopen", "body": { "org_id": 1, + "user_id": 3, "ticket_ids": [ 1, 3 @@ -32,6 +33,10 @@ { "query": "SELECT count(*) FROM tickets_ticket WHERE status = 'O'", "count": 2 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'R' AND created_by_id = 3", + "count": 1 } ] }, @@ -49,6 +54,7 @@ "path": "/mr/ticket/reopen", "body": { "org_id": 1, + "user_id": 3, "ticket_ids": [ 1, 2 @@ -68,6 +74,10 @@ { "query": "SELECT count(*) FROM tickets_ticket WHERE status = 'O'", "count": 3 + }, + { + "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'R' AND created_by_id = 3", + "count": 2 } ] } diff --git a/web/ticket/ticket.go b/web/ticket/ticket.go deleted file mode 100644 index 8c16ad67f..000000000 --- a/web/ticket/ticket.go +++ /dev/null @@ -1,98 +0,0 @@ -package ticket - -import ( - "context" - "net/http" - - "github.com/nyaruka/goflow/utils" - "github.com/nyaruka/mailroom/core/models" - "github.com/nyaruka/mailroom/web" - - "github.com/pkg/errors" -) - -func init() { - web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.WithHTTPLogs(handleClose))) - web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.WithHTTPLogs(handleReopen))) -} - -type bulkTicketRequest struct { - OrgID models.OrgID `json:"org_id" validate:"required"` - TicketIDs []models.TicketID `json:"ticket_ids"` -} - -type bulkTicketResponse struct { - ChangedIDs []models.TicketID `json:"changed_ids"` -} - -func newBulkResponse(changed []*models.Ticket) *bulkTicketResponse { - ids := make([]models.TicketID, len(changed)) - for i := range changed { - ids[i] = changed[i].ID() - } - return &bulkTicketResponse{ChangedIDs: ids} -} - -// Closes any open tickets with the given ids -// -// { -// "org_id": 123, -// "ticket_ids": [1234, 2345] -// } -// -func handleClose(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { - request := &bulkTicketRequest{} - if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { - return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil - } - - // grab our org assets - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) - if err != nil { - return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") - } - - tickets, err := models.LoadTickets(ctx, s.DB, request.OrgID, request.TicketIDs, models.TicketStatusOpen) - if err != nil { - return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) - } - - err = models.CloseTickets(ctx, s.DB, oa, tickets, true, l) - if err != nil { - return nil, http.StatusBadRequest, errors.Wrapf(err, "error closing tickets for org: %d", request.OrgID) - } - - return newBulkResponse(tickets), http.StatusOK, nil -} - -// Reopens any closed tickets with the given ids -// -// { -// "org_id": 123, -// "ticket_ids": [1234, 2345] -// } -// -func handleReopen(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { - request := &bulkTicketRequest{} - if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil { - return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil - } - - // grab our org assets - oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID) - if err != nil { - return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets") - } - - tickets, err := models.LoadTickets(ctx, s.DB, request.OrgID, request.TicketIDs, models.TicketStatusClosed) - if err != nil { - return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID) - } - - err = models.ReopenTickets(ctx, s.DB, oa, tickets, true, l) - if err != nil { - return nil, http.StatusBadRequest, errors.Wrapf(err, "error reopening tickets for org: %d", request.OrgID) - } - - return newBulkResponse(tickets), http.StatusOK, nil -} diff --git a/web/ticket/ticket_test.go b/web/ticket/ticket_test.go deleted file mode 100644 index 37615c5b1..000000000 --- a/web/ticket/ticket_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package ticket - -import ( - "testing" - - "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/goflow/flows" - "github.com/nyaruka/mailroom/core/models" - _ "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/mailroom/web" -) - -func TestTicketClose(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - - // create 2 open tickets and 1 closed one for Cathy across two different ticketers - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, flows.TicketUUID(uuids.New()), "Need help", "Have you seen my cookies?", "17") - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "More help", "Have you seen my cookies?", "21") - testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "Old question", "Have you seen my cookies?", "34") - - web.RunWebTests(t, "testdata/close.json") -} - -func TestTicketReopen(t *testing.T) { - testsuite.Reset() - db := testsuite.DB() - - // create 2 closed tickets and 1 open one for Cathy - testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.MailgunID, flows.TicketUUID(uuids.New()), "Need help", "Have you seen my cookies?", "17") - testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "More help", "Have you seen my cookies?", "21") - testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "Old question", "Have you seen my cookies?", "34") - - web.RunWebTests(t, "testdata/reopen.json") -} diff --git a/web/wrappers.go b/web/wrappers.go index ee8bb72d0..f5619ac1c 100644 --- a/web/wrappers.go +++ b/web/wrappers.go @@ -7,13 +7,14 @@ import ( "strings" "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" "github.com/pkg/errors" ) // RequireUserToken wraps a JSON handler to require passing of an API token via the authorization header func RequireUserToken(handler JSONHandler) JSONHandler { - return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { + return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { token := r.Header.Get("authorization") if !strings.HasPrefix(token, "Token ") { return errors.New("missing authorization header"), http.StatusUnauthorized, nil @@ -23,7 +24,7 @@ func RequireUserToken(handler JSONHandler) JSONHandler { token = token[6:] // try to look it up - rows, err := s.DB.QueryContext(s.CTX, ` + rows, err := rt.DB.QueryContext(ctx, ` SELECT user_id, org_id @@ -58,34 +59,34 @@ func RequireUserToken(handler JSONHandler) JSONHandler { // we are authenticated set our user id ang org id on our context and call our sub handler ctx = context.WithValue(ctx, UserIDKey, userID) ctx = context.WithValue(ctx, OrgIDKey, orgID) - return handler(ctx, s, r) + return handler(ctx, rt, r) } } // RequireAuthToken wraps a handler to require that our request to have our global authorization header func RequireAuthToken(handler JSONHandler) JSONHandler { - return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { + return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { auth := r.Header.Get("authorization") - if s.Config.AuthToken != "" && fmt.Sprintf("Token %s", s.Config.AuthToken) != auth { + if rt.Config.AuthToken != "" && fmt.Sprintf("Token %s", rt.Config.AuthToken) != auth { return fmt.Errorf("invalid or missing authorization header, denying"), http.StatusUnauthorized, nil } // we are authenticated, call our chain - return handler(ctx, s, r) + return handler(ctx, rt, r) } } // LoggingJSONHandler is a JSON web handler which logs HTTP logs -type LoggingJSONHandler func(ctx context.Context, s *Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) +type LoggingJSONHandler func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) // WithHTTPLogs wraps a handler to create a handler which can record and save HTTP logs func WithHTTPLogs(handler LoggingJSONHandler) JSONHandler { - return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) { + return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) { logger := &models.HTTPLogger{} - response, status, err := handler(ctx, s, r, logger) + response, status, err := handler(ctx, rt, r, logger) - if err := logger.Insert(ctx, s.DB); err != nil { + if err := logger.Insert(ctx, rt.DB); err != nil { return nil, http.StatusInternalServerError, errors.Wrap(err, "error writing HTTP logs") } diff --git a/web/wrappers_test.go b/web/wrappers_test.go index 8b7cca585..6e41a52e8 100644 --- a/web/wrappers_test.go +++ b/web/wrappers_test.go @@ -8,14 +8,16 @@ import ( "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) { - testsuite.ResetDB() + ctx, rt, _, _ := testsuite.Reset() defer httpx.SetRequestor(httpx.DefaultRequestor) httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ @@ -25,8 +27,8 @@ func TestWithHTTPLogs(t *testing.T) { }, })) - handler := func(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { - ticketer, _ := models.LookupTicketerByUUID(ctx, s.DB, models.MailgunUUID) + handler := func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { + ticketer, _ := models.LookupTicketerByUUID(ctx, rt.DB, testdata.Mailgun.UUID) logger := l.Ticketer(ticketer) @@ -47,9 +49,8 @@ func TestWithHTTPLogs(t *testing.T) { } // simulate handler being invoked by server - server := &web.Server{DB: testsuite.DB()} wrapped := web.WithHTTPLogs(handler) - response, status, err := wrapped(testsuite.CTX(), server, nil) + response, status, err := wrapped(ctx, rt, nil) // check response from handler assert.Equal(t, map[string]string{"status": "OK"}, response) @@ -57,5 +58,5 @@ func TestWithHTTPLogs(t *testing.T) { assert.NoError(t, err) // check HTTP logs were created - testsuite.AssertQueryCount(t, testsuite.DB(), `select count(*) from request_logs_httplog where ticketer_id = $1;`, []interface{}{models.MailgunID}, 2) + testsuite.AssertQuery(t, testsuite.DB(), `select count(*) from request_logs_httplog where ticketer_id = $1;`, testdata.Mailgun.ID).Returns(2) } diff --git a/workers.go b/workers.go index 3c0288134..71591015b 100644 --- a/workers.go +++ b/workers.go @@ -3,16 +3,19 @@ package mailroom import ( "context" "runtime/debug" + "sync" "time" "github.com/nyaruka/mailroom/core/queue" + "github.com/nyaruka/mailroom/runtime" "github.com/sirupsen/logrus" ) // Foreman takes care of managing our set of workers and assigns msgs for each to send type Foreman struct { - mr *Mailroom + rt *runtime.Runtime + wg *sync.WaitGroup queue string workers []*Worker availableWorkers chan *Worker @@ -20,9 +23,10 @@ type Foreman struct { } // NewForeman creates a new Foreman for the passed in server with the number of max workers -func NewForeman(mr *Mailroom, queue string, maxWorkers int) *Foreman { +func NewForeman(rt *runtime.Runtime, wg *sync.WaitGroup, queue string, maxWorkers int) *Foreman { foreman := &Foreman{ - mr: mr, + rt: rt, + wg: wg, queue: queue, workers: make([]*Worker, maxWorkers), availableWorkers: make(chan *Worker, maxWorkers), @@ -56,8 +60,8 @@ func (f *Foreman) Stop() { // Assign is our main loop for the Foreman, it takes care of popping the next outgoing task from our // backend and assigning them to workers func (f *Foreman) Assign() { - f.mr.WaitGroup.Add(1) - defer f.mr.WaitGroup.Done() + f.wg.Add(1) + defer f.wg.Done() log := logrus.WithField("comp", "foreman") log.WithFields(logrus.Fields{ @@ -68,7 +72,7 @@ func (f *Foreman) Assign() { lastSleep := false - for true { + for { select { // return if we have been told to stop case <-f.quit: @@ -78,7 +82,7 @@ func (f *Foreman) Assign() { // otherwise, grab the next task and assign it to a worker case worker := <-f.availableWorkers: // see if we have a task to work on - rc := f.mr.RP.Get() + rc := f.rt.RP.Get() task, err := queue.PopNextTask(rc, f.queue) rc.Close() @@ -109,7 +113,6 @@ type Worker struct { id int foreman *Foreman job chan *queue.Task - log *logrus.Entry } // NewWorker creates a new worker responsible for working on events @@ -124,14 +127,15 @@ func NewWorker(foreman *Foreman, id int) *Worker { // Start starts our Worker's goroutine and has it start waiting for tasks from the foreman func (w *Worker) Start() { + w.foreman.wg.Add(1) + go func() { - w.foreman.mr.WaitGroup.Add(1) - defer w.foreman.mr.WaitGroup.Done() + defer w.foreman.wg.Done() log := logrus.WithField("queue", w.foreman.queue).WithField("worker_id", w.id) log.Debug("started") - for true { + for { // list ourselves as available for work w.foreman.availableWorkers <- w @@ -166,7 +170,7 @@ func (w *Worker) handleTask(task *queue.Task) { } // mark our task as complete - rc := w.foreman.mr.RP.Get() + rc := w.foreman.rt.RP.Get() err := queue.MarkTaskComplete(rc, w.foreman.queue, task.OrgID) if err != nil { log.WithError(err) @@ -179,7 +183,7 @@ func (w *Worker) handleTask(task *queue.Task) { taskFunc, found := taskFunctions[task.Type] if found { - err := taskFunc(context.Background(), w.foreman.mr, task) + err := taskFunc(context.Background(), w.foreman.rt, task) if err != nil { log.WithError(err).WithField("task", string(task.Task)).WithField("task_type", task.Type).WithField("org_id", task.OrgID).Error("error running task") }