├── public ├── favicon.ico ├── robots.txt ├── assets │ ├── css │ │ └── main.css │ └── js │ │ └── main.js ├── .htaccess └── index.php ├── app ├── Listeners │ └── .gitkeep ├── Http │ ├── Requests │ │ └── Request.php │ ├── Controllers │ │ ├── Controller.php │ │ ├── QuestionController.php │ │ ├── QuestionResponseController.php │ │ └── SurveyController.php │ ├── Middleware │ │ ├── VerifyCsrfToken.php │ │ ├── EncryptCookies.php │ │ ├── RedirectIfAuthenticated.php │ │ └── Authenticate.php │ ├── Kernel.php │ └── routes.php ├── Survey.php ├── ResponseTranscription.php ├── Question.php ├── Providers │ ├── AppServiceProvider.php │ ├── EventServiceProvider.php │ └── RouteServiceProvider.php ├── Jobs │ └── Job.php ├── Twilio │ └── SurveyParser.php ├── Console │ ├── Kernel.php │ └── Commands │ │ ├── InitializeHeroku.php │ │ └── LoadSurveys.php ├── QuestionResponse.php └── Exceptions │ └── Handler.php ├── database ├── seeds │ ├── .gitkeep │ └── DatabaseSeeder.php ├── migrations │ ├── .gitkeep │ ├── 2015_08_04_201213_CreateSurveysTable.php │ ├── 2016_03_31_195434_create_response_transcriptions_table.php │ ├── 2015_08_04_203121_CreateQuestionsTable.php │ └── 2015_08_06_221628_CreateQuestionsResponsesTable.php ├── .gitignore └── factories │ └── ModelFactory.php ├── resources └── views │ ├── vendor │ └── .gitkeep │ ├── errors │ └── 503.blade.php │ ├── layouts │ └── master.blade.php │ └── surveys │ └── results.blade.php ├── storage ├── app │ └── .gitignore ├── logs │ └── .gitignore └── framework │ ├── cache │ └── .gitignore │ ├── views │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── bootstrap ├── cache │ └── .gitignore ├── autoload.php └── app.php ├── Procfile ├── .gitignore ├── .gitattributes ├── number-conf.png ├── webhook-conf.png ├── .github └── dependabot.yml ├── .env.example ├── .travis.yml ├── tests ├── TestCase.php └── app │ ├── Twilio │ └── SurveyParserTest.php │ └── Http │ └── Controllers │ ├── QuestionControllerTest.php │ ├── QuestionResponseControllerTest.php │ └── SurveyControllerTest.php ├── bear_survey.json ├── server.php ├── app.json ├── phpunit.xml ├── config ├── services.php ├── compile.php ├── view.php ├── broadcasting.php ├── cache.php ├── filesystems.php ├── database.php ├── session.php └── app.php ├── composer.json ├── artisan └── readme.md /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/Listeners/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/seeds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | -------------------------------------------------------------------------------- /resources/views/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 public 2 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /node_modules 3 | Homestead.yaml 4 | .env 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.less linguist-vendored 4 | -------------------------------------------------------------------------------- /number-conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/automated-survey-laravel/HEAD/number-conf.png -------------------------------------------------------------------------------- /webhook-conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/automated-survey-laravel/HEAD/webhook-conf.png -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | routes.php 3 | compiled.php 4 | services.json 5 | events.scanned.php 6 | routes.scanned.php 7 | down 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /app/Http/Requests/Request.php: -------------------------------------------------------------------------------- 1 | hasMany('App\Question'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/assets/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin-left: 10%; 3 | margin-right: 10%; 4 | } 5 | 6 | span.voice-response-text, i.play-icon { 7 | line-height: 30px; 8 | } 9 | 10 | i.play-icon { 11 | vertical-align: middle; 12 | cursor: pointer; 13 | } 14 | 15 | footer i { 16 | color: #ff0000; 17 | } 18 | -------------------------------------------------------------------------------- /public/assets/js/main.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | var audioElements = $('audio.voice-response'); 3 | var playButtons = $('i.play-icon'); 4 | 5 | _.each(_.zip(playButtons, audioElements), function(playPair) { 6 | $(playPair[0]).click(function() { 7 | playPair[1].play(); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/ResponseTranscription.php: -------------------------------------------------------------------------------- 1 | belongsTo('App\QuestionResponse'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | belongsTo('App\Survey', 'survey_id'); 14 | } 15 | 16 | public function responses() 17 | { 18 | return $this->hasMany('App\QuestionResponse'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | call(UserTableSeeder::class); 18 | 19 | Model::reguard(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 5.6 4 | addons: 5 | postgresql: '9.4' 6 | install: 7 | - pecl install PDO_PGSQL 8 | - composer install 9 | env: 10 | global: 11 | - APP_ENV=testing 12 | - DB_CONNECTION=pgsql 13 | - DATABASE_URL_TEST=postgres://postgres:@localhost:5432/surveys_testing 14 | - APP_KEY=e1Jl3R8i3Dxic5bxtG6km6tCfY5sknhq 15 | before_script: 16 | - psql -c 'create database surveys_testing;' -U postgres 17 | - php artisan migrate 18 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Redirect Trailing Slashes If Not A Folder... 9 | RewriteCond %{REQUEST_FILENAME} !-d 10 | RewriteRule ^(.*)/$ /$1 [L,R=301] 11 | 12 | # Handle Front Controller... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_FILENAME} !-f 15 | RewriteRule ^ index.php [L] 16 | 17 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); 22 | 23 | return $app; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bear_survey.json: -------------------------------------------------------------------------------- 1 | { 2 | "questions": [ 3 | { 4 | "body": "What type of bear is best?", 5 | "kind": "free-answer" 6 | }, 7 | { 8 | "body": "In a scale of 1 to 10, how cute do you find koalas?", 9 | "kind": "numeric" 10 | }, 11 | { 12 | "body": "Do you think bears beat beets?", 13 | "kind": "yes-no" 14 | }, 15 | { 16 | "body": "What's the relationship between Battlestar Galactica and bears?", 17 | "kind": "free-answer" 18 | }, 19 | { 20 | "body": "Do sloths qualify as bears?", 21 | "kind": "free-answer" 22 | } 23 | 24 | ], 25 | "title": "About bears" 26 | } 27 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | $uri = urldecode( 11 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 12 | ); 13 | 14 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 15 | // built-in PHP web server. This provides a convenient way to test a Laravel 16 | // application without having installed a "real" web server software here. 17 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 18 | return false; 19 | } 20 | 21 | require_once __DIR__.'/public/index.php'; 22 | -------------------------------------------------------------------------------- /app/Twilio/SurveyParser.php: -------------------------------------------------------------------------------- 1 | survey = collect($parsedSurvey); 16 | } 17 | 18 | public function title() 19 | { 20 | return $this->survey->get('title', 'Untitled survey'); 21 | } 22 | 23 | public function questions() 24 | { 25 | return Collection::make($this->survey->get('questions', collect())); 26 | } 27 | } -------------------------------------------------------------------------------- /database/factories/ModelFactory.php: -------------------------------------------------------------------------------- 1 | define(App\User::class, function ($faker) { 15 | return [ 16 | 'name' => $faker->name, 17 | 'email' => $faker->email, 18 | 'password' => str_random(10), 19 | 'remember_token' => str_random(10), 20 | ]; 21 | }); 22 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title'); 19 | $table->timestamps(); 20 | } 21 | ); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::drop('surveys'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Automated surveys with Laravel", 3 | "description": "Perform automated phone surveys with Twilio, TwiML and Laravel", 4 | "keywords": [ 5 | "twilio", 6 | "surveys", 7 | "laravel", 8 | "php" 9 | ], 10 | "scripts": { 11 | "postdeploy": "php artisan heroku:initialize bear_survey.json" 12 | }, 13 | "addons": ["heroku-postgresql:hobby-dev"] 14 | , 15 | "env": { 16 | "APP_KEY": { 17 | "description": "App key for Laravel", 18 | "generator": "secret" 19 | } 20 | }, 21 | "website": "https://github.com/TwilioDevEd/automated-survey-laravel", 22 | "repository": "https://github.com/TwilioDevEd/automated-survey-laravel", 23 | "logo": "https://s3.amazonaws.com/howtodocs/twilio-logo.png", 24 | "success_url": "/" 25 | } 26 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'App\Listeners\EventListener', 18 | ], 19 | ]; 20 | 21 | /** 22 | * Register any other events for your application. 23 | * 24 | * @param \Illuminate\Contracts\Events\Dispatcher $events 25 | * @return void 26 | */ 27 | public function boot(DispatcherContract $events) 28 | { 29 | parent::boot($events); 30 | 31 | // 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 26 | } 27 | 28 | /** 29 | * Handle an incoming request. 30 | * 31 | * @param \Illuminate\Http\Request $request 32 | * @param \Closure $next 33 | * @return mixed 34 | */ 35 | public function handle($request, Closure $next) 36 | { 37 | if ($this->auth->check()) { 38 | return redirect('/home'); 39 | } 40 | 41 | return $next($request); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | app/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => '', 19 | 'secret' => '', 20 | ], 21 | 22 | 'mandrill' => [ 23 | 'secret' => '', 24 | ], 25 | 26 | 'ses' => [ 27 | 'key' => '', 28 | 'secret' => '', 29 | 'region' => 'us-east-1', 30 | ], 31 | 32 | 'stripe' => [ 33 | 'model' => App\User::class, 34 | 'key' => '', 35 | 'secret' => '', 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /app/Http/Kernel.php: -------------------------------------------------------------------------------- 1 | \App\Http\Middleware\Authenticate::class, 29 | 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 30 | 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /app/QuestionResponse.php: -------------------------------------------------------------------------------- 1 | belongsTo('App\Question'); 15 | } 16 | 17 | public function responseTranscription() 18 | { 19 | return $this->hasOne('App\ResponseTranscription'); 20 | } 21 | 22 | public function scopeResponsesForSurveyByCall($query, $surveyId) 23 | { 24 | return $query 25 | ->join('questions', 'questions.id', '=', 'question_responses.question_id') 26 | ->join('surveys', 'surveys.id', '=', 'questions.survey_id') 27 | ->leftJoin('response_transcriptions', 'response_transcriptions.question_response_id', '=', 'question_responses.id') 28 | ->where('surveys.id', '=', $surveyId) 29 | ->orderBy('question_responses.session_sid') 30 | ->orderBy('question_responses.id'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 26 | } 27 | 28 | /** 29 | * Handle an incoming request. 30 | * 31 | * @param \Illuminate\Http\Request $request 32 | * @param \Closure $next 33 | * @return mixed 34 | */ 35 | public function handle($request, Closure $next) 36 | { 37 | if ($this->auth->guest()) { 38 | if ($request->ajax()) { 39 | return response('Unauthorized.', 401); 40 | } else { 41 | return redirect()->guest('auth/login'); 42 | } 43 | } 44 | 45 | return $next($request); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/compile.php: -------------------------------------------------------------------------------- 1 | [ 17 | // 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled File Providers 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may list service providers which define a "compiles" function 26 | | that returns additional files that should be compiled, providing an 27 | | easy way to get common files from any packages you are utilizing. 28 | | 29 | */ 30 | 31 | 'providers' => [ 32 | // 33 | ], 34 | 35 | ]; 36 | -------------------------------------------------------------------------------- /config/view.php: -------------------------------------------------------------------------------- 1 | [ 17 | realpath(base_path('resources/views')), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => realpath(storage_path('framework/views')), 32 | 33 | ]; 34 | -------------------------------------------------------------------------------- /app/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->text('transcription'); 18 | $table->integer('question_response_id'); 19 | $table->foreign('question_response_id')->references('id')->on('question_responses')->onDelete('cascade'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::table( 32 | 'response_transcriptions', function (Blueprint $table) { 33 | $table->dropForeign('response_transcriptions_question_response_id_foreign'); 34 | } 35 | ); 36 | Schema::drop('response_transcriptions'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/2015_08_04_203121_CreateQuestionsTable.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('body'); 19 | $table->enum('kind', ['free-answer', 'yes-no', 'numeric']); 20 | $table->integer('survey_id'); 21 | $table->timestamps(); 22 | 23 | $table->foreign('survey_id')->references('id')->on('surveys')->onDelete('cascade');; 24 | } 25 | ); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::table( 36 | 'questions', function (Blueprint $table) { 37 | $table->dropForeign('questions_survey_id_foreign'); 38 | } 39 | ); 40 | Schema::drop('questions'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | group(['namespace' => $this->namespace], function ($router) { 41 | require app_path('Http/routes.php'); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Console/Commands/InitializeHeroku.php: -------------------------------------------------------------------------------- 1 | argument('fileName'); 42 | 43 | Artisan::call('migrate', ['--force' => 1]); 44 | $this->info('Database migrated'); 45 | 46 | Artisan::call( 47 | 'surveys:load', ['fileName' => $fileName] 48 | ); 49 | $this->info('Survey loaded into database'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /resources/views/errors/503.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Be right back. 5 | 6 | 7 | 8 | 39 | 40 | 41 |
42 |
43 |
Be right back.
44 |
45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/app/Twilio/SurveyParserTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($parser->title(), 'About bears'); 16 | } 17 | public function testQuestions() 18 | { 19 | $parser = new SurveyParser(self::SAMPLE_SURVEY); 20 | $firstQuestion = ['body' => 'What type of bear is best?', 21 | 'kind' => 'free-answer']; 22 | 23 | $secondQuestion = ['body' => 'In a scale of 1 to 10 how cute do you find koalas?', 24 | 'kind' => 'numeric']; 25 | 26 | $thirdQuestion = ['body' => 'Do you think bears beat beets?', 27 | 'kind' => 'yes-no']; 28 | 29 | $this->assertEquals( 30 | $parser->questions()->toArray(), 31 | [$firstQuestion, $secondQuestion, $thirdQuestion] 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/migrations/2015_08_06_221628_CreateQuestionsResponsesTable.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->text('response'); 19 | $table->enum('type', ['voice', 'sms']); 20 | $table->string('session_sid'); 21 | $table->integer('question_id'); 22 | $table->timestamps(); 23 | 24 | $table->foreign('question_id')->references('id')->on('questions')->onDelete('cascade'); 25 | } 26 | ); 27 | 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::table( 38 | 'question_responses', function (Blueprint $table) { 39 | $table->dropForeign('question_responses_question_id_foreign'); 40 | } 41 | ); 42 | Schema::drop('question_responses'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /resources/views/layouts/master.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Automated surveys - @yield('title', 'surveys') 7 | 8 | 9 | 14 |
15 | @yield('content') 16 |
17 | 21 | 22 | 23 | 24 | 25 | @yield('scripts') 26 | 27 | 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twilio/automated-surveys", 3 | "description": "The Laravel Framework.", 4 | "license": "MIT", 5 | "type": "project", 6 | "require": { 7 | "php": ">=5.5.9", 8 | "laravel/framework": "5.1.*", 9 | "laravelcollective/html": "~5.0", 10 | "twilio/sdk": "^5.0" 11 | }, 12 | "require-dev": { 13 | "fzaninotto/faker": "~1.4", 14 | "mockery/mockery": "0.9.*", 15 | "phpunit/phpunit": "~4.0" 16 | }, 17 | "autoload": { 18 | "classmap": [ 19 | "database" 20 | ], 21 | "psr-4": { 22 | "App\\": "app/", 23 | "Twilio\\": "app/Twilio/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "classmap": [ 28 | "tests/TestCase.php" 29 | ] 30 | }, 31 | "scripts": { 32 | "post-install-cmd": [ 33 | "php artisan clear-compiled", 34 | "php artisan optimize" 35 | ], 36 | "pre-update-cmd": [ 37 | "php artisan clear-compiled" 38 | ], 39 | "post-update-cmd": [ 40 | "php artisan optimize" 41 | ], 42 | "post-root-package-install": [ 43 | "php -r \"copy('.env.example', '.env');\"" 44 | ], 45 | "post-create-project-cmd": [ 46 | "php artisan key:generate" 47 | ] 48 | }, 49 | "config": { 50 | "preferred-install": "dist" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/Console/Commands/LoadSurveys.php: -------------------------------------------------------------------------------- 1 | argument('fileName'); 41 | $surveyJSON = file_get_contents($filename); 42 | 43 | $parser = new \App\Twilio\SurveyParser($surveyJSON); 44 | 45 | $survey = new \App\Survey(); 46 | $survey->title = $parser->title(); 47 | $survey->save(); 48 | 49 | $parser->questions()->each( 50 | function ($question) use ($survey) { 51 | $questionToSave = new \App\Question($question); 52 | $questionToSave->survey()->associate($survey); 53 | $questionToSave->save(); 54 | } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'pusher'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Broadcast Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may define all of the broadcast connections that will be used 24 | | to broadcast events to other systems or over websockets. Samples of 25 | | each available type of connection are provided inside this array. 26 | | 27 | */ 28 | 29 | 'connections' => [ 30 | 31 | 'pusher' => [ 32 | 'driver' => 'pusher', 33 | 'key' => env('PUSHER_KEY'), 34 | 'secret' => env('PUSHER_SECRET'), 35 | 'app_id' => env('PUSHER_APP_ID'), 36 | ], 37 | 38 | 'redis' => [ 39 | 'driver' => 'redis', 40 | 'connection' => 'default', 41 | ], 42 | 43 | 'log' => [ 44 | 'driver' => 'log', 45 | ], 46 | 47 | ], 48 | 49 | ]; 50 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 32 | 33 | $status = $kernel->handle( 34 | $input = new Symfony\Component\Console\Input\ArgvInput, 35 | new Symfony\Component\Console\Output\ConsoleOutput 36 | ); 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Shutdown The Application 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Once Artisan has finished running. We will fire off the shutdown events 44 | | so that any final work may be done by the application before we shut 45 | | down the process. This is the last thing to happen to the request. 46 | | 47 | */ 48 | 49 | $kernel->terminate($input, $status); 50 | 51 | exit($status); 52 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | /* 11 | |-------------------------------------------------------------------------- 12 | | Register The Auto Loader 13 | |-------------------------------------------------------------------------- 14 | | 15 | | Composer provides a convenient, automatically generated class loader for 16 | | our application. We just need to utilize it! We'll simply require it 17 | | into the script here so that we don't have to worry about manual 18 | | loading any of our classes later on. It feels nice to relax. 19 | | 20 | */ 21 | 22 | require __DIR__.'/../bootstrap/autoload.php'; 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Turn On The Lights 27 | |-------------------------------------------------------------------------- 28 | | 29 | | We need to illuminate PHP development, so let us turn on the lights. 30 | | This bootstraps the framework and gets it ready for use, then it 31 | | will load up this application so that we can run it and send 32 | | the responses back to the browser and delight our users. 33 | | 34 | */ 35 | 36 | $app = require_once __DIR__.'/../bootstrap/app.php'; 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Run The Application 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Once we have the application, we can handle the incoming request 44 | | through the kernel, and send the associated response back to 45 | | the client's browser allowing them to enjoy the creative 46 | | and wonderful application we have prepared for them. 47 | | 48 | */ 49 | 50 | $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); 51 | 52 | $response = $kernel->handle( 53 | $request = Illuminate\Http\Request::capture() 54 | ); 55 | 56 | $response->send(); 57 | 58 | $kernel->terminate($request, $response); 59 | -------------------------------------------------------------------------------- /app/Http/routes.php: -------------------------------------------------------------------------------- 1 | 'survey.results', 'uses' => 'SurveyController@showResults'] 19 | ); 20 | Route::get( 21 | '/', 22 | ['as' => 'root', 'uses' => 'SurveyController@showFirstSurveyResults'] 23 | ); 24 | Route::post( 25 | '/voice/connect', 26 | ['as' => 'voice.connect', 'uses' => 'SurveyController@connectVoice'] 27 | ); 28 | Route::post( 29 | '/sms/connect', 30 | ['as' => 'sms.connect', 'uses' => 'SurveyController@connectSms'] 31 | ); 32 | Route::get( 33 | '/survey/{id}/voice', 34 | ['as' => 'survey.show.voice', 'uses' => 'SurveyController@showVoice'] 35 | ); 36 | Route::get( 37 | '/survey/{id}/sms', 38 | ['as' => 'survey.show.sms', 'uses' => 'SurveyController@showSms'] 39 | ); 40 | Route::get( 41 | '/survey/{survey}/question/{question}/voice', 42 | ['as' => 'question.show.voice', 'uses' => 'QuestionController@showVoice'] 43 | ); 44 | Route::get( 45 | '/survey/{survey}/question/{question}/sms', 46 | ['as' => 'question.show.sms', 'uses' => 'QuestionController@showSms'] 47 | ); 48 | Route::post( 49 | '/survey/{survey}/question/{question}/response/voice', 50 | ['as' => 'response.store.voice', 'uses' => 'QuestionResponseController@storeVoice'] 51 | ); 52 | Route::post( 53 | '/survey/{survey}/question/{question}/response/sms', 54 | ['as' => 'response.store.sms', 'uses' => 'QuestionResponseController@storeSms'] 55 | ); 56 | Route::post( 57 | '/survey/{survey}/question/{question}/response/transcription', 58 | ['as' => 'response.transcription.store', 'uses' => 'QuestionResponseController@storeTranscription'] 59 | ); 60 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_DRIVER', 'file'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Cache Stores 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may define all of the cache "stores" for your application as 24 | | well as their drivers. You may even define multiple stores for the 25 | | same cache driver to group types of items stored in your caches. 26 | | 27 | */ 28 | 29 | 'stores' => [ 30 | 31 | 'apc' => [ 32 | 'driver' => 'apc', 33 | ], 34 | 35 | 'array' => [ 36 | 'driver' => 'array', 37 | ], 38 | 39 | 'database' => [ 40 | 'driver' => 'database', 41 | 'table' => 'cache', 42 | 'connection' => null, 43 | ], 44 | 45 | 'file' => [ 46 | 'driver' => 'file', 47 | 'path' => storage_path('framework/cache'), 48 | ], 49 | 50 | 'memcached' => [ 51 | 'driver' => 'memcached', 52 | 'servers' => [ 53 | [ 54 | 'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100, 55 | ], 56 | ], 57 | ], 58 | 59 | 'redis' => [ 60 | 'driver' => 'redis', 61 | 'connection' => 'default', 62 | ], 63 | 64 | ], 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Cache Key Prefix 69 | |-------------------------------------------------------------------------- 70 | | 71 | | When utilizing a RAM based store such as APC or Memcached, there might 72 | | be other applications utilizing the same cache. So, we'll specify a 73 | | value to get prefixed to all our keys so we can avoid collisions. 74 | | 75 | */ 76 | 77 | 'prefix' => 'laravel', 78 | 79 | ]; 80 | -------------------------------------------------------------------------------- /resources/views/surveys/results.blade.php: -------------------------------------------------------------------------------- 1 | @extends('layouts.master') 2 | 3 | @section('content') 4 |

Results for survey: {{ $survey->title }}

5 |
6 | 54 |
55 | @stop 56 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | 'local', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Default Cloud Filesystem Disk 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Many applications store files both locally and in the cloud. For this 26 | | reason, you may specify a default "cloud" driver here. This driver 27 | | will be bound as the Cloud disk implementation in the container. 28 | | 29 | */ 30 | 31 | 'cloud' => 's3', 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Filesystem Disks 36 | |-------------------------------------------------------------------------- 37 | | 38 | | Here you may configure as many filesystem "disks" as you wish, and you 39 | | may even configure multiple disks of the same driver. Defaults have 40 | | been setup for each driver as an example of the required options. 41 | | 42 | */ 43 | 44 | 'disks' => [ 45 | 46 | 'local' => [ 47 | 'driver' => 'local', 48 | 'root' => storage_path('app'), 49 | ], 50 | 51 | 'ftp' => [ 52 | 'driver' => 'ftp', 53 | 'host' => 'ftp.example.com', 54 | 'username' => 'your-username', 55 | 'password' => 'your-password', 56 | 57 | // Optional FTP Settings... 58 | // 'port' => 21, 59 | // 'root' => '', 60 | // 'passive' => true, 61 | // 'ssl' => true, 62 | // 'timeout' => 30, 63 | ], 64 | 65 | 's3' => [ 66 | 'driver' => 's3', 67 | 'key' => 'your-key', 68 | 'secret' => 'your-secret', 69 | 'region' => 'your-region', 70 | 'bucket' => 'your-bucket', 71 | ], 72 | 73 | 'rackspace' => [ 74 | 'driver' => 'rackspace', 75 | 'username' => 'your-username', 76 | 'key' => 'your-key', 77 | 'container' => 'your-container', 78 | 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', 79 | 'region' => 'IAD', 80 | 'url_type' => 'publicURL', 81 | ], 82 | 83 | ], 84 | 85 | ]; 86 | -------------------------------------------------------------------------------- /tests/app/Http/Controllers/QuestionControllerTest.php: -------------------------------------------------------------------------------- 1 | beginDatabaseTransaction(); 20 | $this->survey = new Survey(['title' => 'Testing survey']); 21 | $this->question = new Question(['body' => 'What is this?', 'kind' => 'free-answer']); 22 | 23 | $this->survey->save(); 24 | $this->question->survey()->associate($this->survey)->save(); 25 | } 26 | 27 | public function testShowVoiceQuestion() 28 | { 29 | $response = $this->call( 30 | 'GET', 31 | route('question.show.voice', ['question' => $this->question->id, 'survey' => $this->survey->id]) 32 | ); 33 | 34 | $savingUrl = route( 35 | 'response.store.voice', 36 | ['question' => $this->question->id, 37 | 'survey' => $this->survey->id], false 38 | ); 39 | 40 | $absoluteSavingUrl = route( 41 | 'response.store.voice', 42 | ['question' => $this->question->id, 43 | 'survey' => $this->survey->id] 44 | ); 45 | 46 | $transcriptionUrl = route( 47 | 'response.transcription.store', 48 | ['question' => $this->question->id, 49 | 'survey' => $this->survey->id] 50 | ); 51 | 52 | $responseDocument = new SimpleXMLElement($response->getContent()); 53 | 54 | $this->assertContains($this->question->body, $response->getContent()); 55 | $this->assertContains($savingUrl, $response->getContent()); 56 | $this->assertNotContains($absoluteSavingUrl, $response->getContent()); 57 | $this->assertEquals($transcriptionUrl, strval($responseDocument->Record->attributes()['transcribeCallback'])); 58 | $this->assertTrue(boolval($responseDocument->Record->attributes()['transcribe'])); 59 | } 60 | 61 | public function testShowSmsQuestion() { 62 | $response = $this->call( 63 | 'GET', 64 | route('question.show.sms', ['question' => $this->question->id, 'survey' => $this->survey->id]) 65 | ); 66 | $cookies = $response->headers->getCookies(); 67 | 68 | $this->assertCount(1, $cookies); 69 | $this->assertEquals('current_question', $cookies[0]->getName()); 70 | $this->assertEquals($this->question->id, $cookies[0]->getValue()); 71 | 72 | $messageDocument = new SimpleXMLElement($response->getContent()); 73 | 74 | $this->assertEquals( 75 | $this->question->body . "\n\nReply to this message with your answer", 76 | strval($messageDocument->Message) 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/Http/Controllers/QuestionController.php: -------------------------------------------------------------------------------- 1 | _responseWithXmlType($this->_commandForVoice($questionToAsk)); 25 | } 26 | 27 | /** 28 | * Display the specified resource. 29 | * 30 | * @param int $id 31 | * @return Response 32 | */ 33 | public function showSms($surveyId, $questionId) 34 | { 35 | $questionToAsk = Question::find($questionId); 36 | return $this->_responseWithXmlType($this->_commandForSms($questionToAsk)); 37 | } 38 | 39 | private function _messageForSmsQuestion($question) { 40 | $questionPhrases = collect( 41 | [ 42 | 'free-answer' => "\n\nReply to this message with your answer", 43 | 'yes-no' => "\n\nReply with \"YES\" or \"NO\" to this message", 44 | 'numeric' => "\n\nReply with a number from 1 to 10 to this message" 45 | ] 46 | ); 47 | 48 | return $questionPhrases->get($question->kind, "\n\nReply to this message with your answer"); 49 | } 50 | 51 | private function _messageForVoiceQuestion($question) 52 | { 53 | $questionPhrases = collect( 54 | [ 55 | 'free-answer' => "Please record your answer after the beep and then hit the pound sign", 56 | 'yes-no' => "Please press the one key for yes and the zero key for no and then hit the pound sign", 57 | 'numeric' => "Please press a number between 1 and 10 and then hit the pound sign" 58 | ] 59 | ); 60 | 61 | return $questionPhrases->get($question->kind, "Please press a number and then the pound sign"); 62 | } 63 | 64 | private function _commandForSms($question) 65 | { 66 | $smsResponse = new Twiml(); 67 | 68 | $messageBody = $question->body . $this->_messageForSmsQuestion($question); 69 | $smsResponse->message($messageBody); 70 | 71 | return response($smsResponse)->withCookie('current_question', $question->id); 72 | } 73 | 74 | private function _commandForVoice($question) 75 | { 76 | $voiceResponse = new Twiml(); 77 | 78 | $voiceResponse->say($question->body); 79 | $voiceResponse->say($this->_messageForVoiceQuestion($question)); 80 | $voiceResponse = $this->_registerResponseCommand($voiceResponse, $question); 81 | 82 | return response($voiceResponse); 83 | } 84 | 85 | private function _registerResponseCommand($voiceResponse, $question) 86 | { 87 | $storeResponseURL = route( 88 | 'response.store.voice', 89 | ['question' => $question->id, 90 | 'survey' => $question->survey->id], 91 | false 92 | ); 93 | 94 | if ($question->kind === 'free-answer') { 95 | $transcribeUrl = route( 96 | 'response.transcription.store', 97 | ['question' => $question->id, 98 | 'survey' => $question->survey->id] 99 | ); 100 | $voiceResponse->record( 101 | ['method' => 'POST', 102 | 'action' => $storeResponseURL, 103 | 'transcribe' => true, 104 | 'transcribeCallback' => $transcribeUrl] 105 | ); 106 | } elseif ($question->kind === 'yes-no') { 107 | $voiceResponse->gather(['method' => 'POST', 'action' => $storeResponseURL]); 108 | } elseif ($question->kind === 'numeric') { 109 | $voiceResponse->gather(['method' => 'POST', 'action' => $storeResponseURL]); 110 | } 111 | return $voiceResponse; 112 | } 113 | 114 | private function _responseWithXmlType($response) { 115 | return $response->header('Content-Type', 'application/xml'); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /config/database.php: -------------------------------------------------------------------------------- 1 | environment('testing')) { 4 | $dbConfig = parse_url(env('DATABASE_URL_TEST')); 5 | } else { 6 | $dbConfig = parse_url(env('DATABASE_URL')); 7 | } 8 | 9 | $host = $dbConfig['host']; 10 | $username = $dbConfig['user']; 11 | $password = $dbConfig['pass']; 12 | $database = substr($dbConfig['path'], 1); 13 | 14 | return [ 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | PDO Fetch Style 19 | |-------------------------------------------------------------------------- 20 | | 21 | | By default, database results will be returned as instances of the PHP 22 | | stdClass object; however, you may desire to retrieve records in an 23 | | array format for simplicity. Here you can tweak the fetch style. 24 | | 25 | */ 26 | 27 | 'fetch' => PDO::FETCH_CLASS, 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Default Database Connection Name 32 | |-------------------------------------------------------------------------- 33 | | 34 | | Here you may specify which of the database connections below you wish 35 | | to use as your default connection for all database work. Of course 36 | | you may use many connections at once using the Database library. 37 | | 38 | */ 39 | 40 | 'default' => env('DB_CONNECTION', 'pgsql'), 41 | 42 | /* 43 | |-------------------------------------------------------------------------- 44 | | Database Connections 45 | |-------------------------------------------------------------------------- 46 | | 47 | | Here are each of the database connections setup for your application. 48 | | Of course, examples of configuring each database platform that is 49 | | supported by Laravel is shown below to make development simple. 50 | | 51 | | 52 | | All database work in Laravel is done through the PHP PDO facilities 53 | | so make sure you have the driver for your particular database of 54 | | choice installed on your machine before you begin development. 55 | | 56 | */ 57 | 58 | 59 | 60 | 'connections' => [ 61 | 62 | 'sqlite' => [ 63 | 'driver' => 'sqlite', 64 | 'database' => storage_path('database.sqlite'), 65 | 'prefix' => '', 66 | ], 67 | 68 | 'mysql' => [ 69 | 'driver' => 'mysql', 70 | 'host' => env('DB_HOST', 'localhost'), 71 | 'database' => env('DB_DATABASE', 'forge'), 72 | 'username' => env('DB_USERNAME', 'forge'), 73 | 'password' => env('DB_PASSWORD', ''), 74 | 'charset' => 'utf8', 75 | 'collation' => 'utf8_unicode_ci', 76 | 'prefix' => '', 77 | 'strict' => false, 78 | ], 79 | 80 | 'pgsql' => [ 81 | 'driver' => 'pgsql', 82 | 'host' => $host, 83 | 'database' => $database, 84 | 'username' => $username, 85 | 'password' => $password, 86 | 'charset' => 'utf8', 87 | 'prefix' => '', 88 | 'schema' => 'public', 89 | ], 90 | 91 | 'sqlsrv' => [ 92 | 'driver' => 'sqlsrv', 93 | 'host' => env('DB_HOST', 'localhost'), 94 | 'database' => env('DB_DATABASE', 'forge'), 95 | 'username' => env('DB_USERNAME', 'forge'), 96 | 'password' => env('DB_PASSWORD', ''), 97 | 'charset' => 'utf8', 98 | 'prefix' => '', 99 | ], 100 | 101 | ], 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Migration Repository Table 106 | |-------------------------------------------------------------------------- 107 | | 108 | | This table keeps track of all the migrations that have already run for 109 | | your application. Using this information, we can determine which of 110 | | the migrations on disk haven't actually been run in the database. 111 | | 112 | */ 113 | 114 | 'migrations' => 'migrations', 115 | 116 | /* 117 | |-------------------------------------------------------------------------- 118 | | Redis Databases 119 | |-------------------------------------------------------------------------- 120 | | 121 | | Redis is an open source, fast, and advanced key-value store that also 122 | | provides a richer set of commands than a typical key-value systems 123 | | such as APC or Memcached. Laravel makes it easy to dig right in. 124 | | 125 | */ 126 | 127 | 'redis' => [ 128 | 129 | 'cluster' => false, 130 | 131 | 'default' => [ 132 | 'host' => '127.0.0.1', 133 | 'port' => 6379, 134 | 'database' => 0, 135 | ], 136 | 137 | ], 138 | 139 | ]; 140 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Automated Surveys Using Laravel 2 | 3 | [![Build Status](https://travis-ci.org/TwilioDevEd/automated-survey-laravel.svg?branch=master)](https://travis-ci.org/TwilioDevEd/automated-survey-laravel) 4 | 5 | This application demonstrates how to use Twilio and TwiML to perform 6 | automated phone surveys. 7 | 8 | [Read the full tutorial](https://www.twilio.com/docs/tutorials/walkthrough/automated-survey/php/laravel)! 9 | 10 | ## Running locally 11 | 12 | 1. Clone the repository and `cd` into it. 13 | 14 | 1. Install the application's dependencies with [Composer](https://getcomposer.org/). 15 | 16 | ```bash 17 | composer install 18 | ``` 19 | 20 | 1. The application uses PostgreSQL as the persistence layer. You should install it 21 | if you don't have it. The easiest way is by using [Postgres.app](http://postgresapp.com/). 22 | 23 | 1. Create a database. 24 | 25 | ```bash 26 | createdb surveys 27 | ``` 28 | 29 | 1. Copy the sample configuration file and edit it to match your configuration. 30 | 31 | ```bash 32 | cp .env.example .env 33 | ``` 34 | 35 | 1. Generate an `APP_KEY`. 36 | 37 | ```bash 38 | php artisan key:generate 39 | ``` 40 | 41 | 1. Run the migrations. 42 | 43 | ```bash 44 | php artisan migrate 45 | ``` 46 | 47 | 1. Load a survey. 48 | 49 | ```bash 50 | php artisan heroku:initialize bear_survey.json 51 | ``` 52 | 53 | 1. Expose the application to the wider Internet using [ngrok](https://ngrok.com/) 54 | 55 | ```bash 56 | ngrok http 8000 57 | ``` 58 | Now you have a public URL that will forward requests to your localhost. It should 59 | look like this: 60 | 61 | ``` 62 | http://.ngrok.io 63 | ``` 64 | 65 | 1. Configure Twilio to call your webhooks. 66 | 67 | You will also need to configure Twilio to send requests to your application 68 | when an SMS or a voice call is received. 69 | 70 | You will need to provision at least one Twilio number with SMS and voice capabilities. 71 | You can buy a number [right 72 | here](https://www.twilio.com/user/account/phone-numbers/search). Once you have 73 | a number you need to configure it to work with your application. Open 74 | [the number management page](https://www.twilio.com/user/account/phone-numbers/incoming) 75 | and open a number's configuration by clicking on it. 76 | 77 | [Learn how to configure a Twilio phone number for Programmable Voice](https://www.twilio.com/docs/voice/quickstart/php#configure-your-webhook-url) 78 | [Learn how to configure a Twilio phone number for Programmable SMS](https://support.twilio.com/hc/en-us/articles/223136047-Configure-a-Twilio-Phone-Number-to-Receive-and-Respond-to-Messages) 79 | 80 | For this application you must set the voice webhook of your number. 81 | It will look something like this: 82 | 83 | ``` 84 | http://.ngrok.io/voice/connect 85 | ``` 86 | 87 | The SMS webhook should look something like this: 88 | 89 | ``` 90 | http://.ngrok.io/sms/connect 91 | ``` 92 | 93 | For this application you must set the `POST` method on the configuration for both webhooks. 94 | 95 | 1. Run the application using Artisan. 96 | 97 | ```bash 98 | php artisan serve 99 | ``` 100 | 101 | It is `artisan serve` default behavior to use `http://localhost:8000` when 102 | the application is run. This means that the ip addresses where your app will be 103 | reachable on you local machine will vary depending on the operating system. 104 | 105 | The most common scenario is that your app will be reachable through address 106 | `http://127.0.0.1:8000`. This is important because ngrok creates the 107 | tunnel using that address only. So, if `http://127.0.0.1:8000` is not reachable 108 | in your local machine when you run the app, you must set it so that artisan uses this 109 | address. Here's how to set that up: 110 | 111 | ```bash 112 | php artisan serve --host=127.0.0.1 113 | ``` 114 | 115 | ## How to Demo 116 | 117 | 1. Set up your application to run locally or in production. 118 | 119 | 1. Update your [Twilio Number](https://www.twilio.com/user/account/phone-numbers/incoming)'s 120 | voice and SMS webhooks with your ngrok url. 121 | 122 | 1. Give your number a call or send yourself an SMS with the "start" command. 123 | 124 | 1. Follow the instructions until you answer all the questions. 125 | 126 | 1. When you are notified that you have reached the end of the survey, visit your 127 | application's root to check the results at: 128 | 129 | http://localhost:8000 130 | 131 | 132 | ## Running the tests 133 | 134 | The tests interact with the database so you'll first need to migrate 135 | your test database. First, set the `DATABASE_URL_TEST` and then run: 136 | 137 | ```bash 138 | createdb surveys_test 139 | APP_ENV=testing php artisan migrate 140 | ``` 141 | 142 | Run at the top-level directory. 143 | 144 | ```bash 145 | phpunit 146 | ``` 147 | 148 | If you don't have phpunit installed on your system, you can follow [these 149 | instructions](https://phpunit.de/manual/current/en/installation.html) to 150 | install it. 151 | 152 | ## Meta 153 | 154 | * No warranty expressed or implied. Software is as is. Diggity. 155 | * [MIT License](http://www.opensource.org/licenses/mit-license.html) 156 | * Lovingly crafted by Twilio Developer Education. 157 | -------------------------------------------------------------------------------- /app/Http/Controllers/QuestionResponseController.php: -------------------------------------------------------------------------------- 1 | responses()->create( 28 | ['response' => $this->_responseFromVoiceRequest($question, $request), 29 | 'type' => 'voice', 30 | 'session_sid' => $request->input('CallSid')] 31 | ); 32 | 33 | $nextQuestion = $this->_questionAfter($question); 34 | 35 | if (is_null($nextQuestion)) { 36 | return $this->_responseWithXmlType($this->_voiceMessageAfterLastQuestion()); 37 | } else { 38 | return $this->_responseWithXmlType( 39 | $this->_redirectToQuestion($nextQuestion, 'question.show.voice') 40 | ); 41 | } 42 | } 43 | 44 | /** 45 | * Store a newly created resource in storage. 46 | * 47 | * @param Request $request 48 | * @return Response 49 | */ 50 | public function storeSms($surveyId, $questionId, Request $request) 51 | { 52 | $answer = trim($request->input('Body')); 53 | $question = Question::find($questionId); 54 | if ($question->kind === 'yes-no') { 55 | $answer = strtolower($answer) === 'yes' ? 1 : 0; 56 | } 57 | $newResponse = $question->responses()->create( 58 | ['response' => $answer, 59 | 'type' => 'sms', 60 | 'session_sid' => $request->cookie('survey_session')] 61 | ); 62 | 63 | $nextQuestion = $this->_questionAfter($question); 64 | 65 | if (is_null($nextQuestion)) { 66 | return $this->_responseWithXmlType($this->_smsMessageAfterLastQuestion()); 67 | } else { 68 | return $this->_responseWithXmlType( 69 | $this->_redirectToQuestion($nextQuestion, 'question.show.sms') 70 | ); 71 | } 72 | } 73 | 74 | public function storeTranscription($surveyId, $questionId, Request $request) 75 | { 76 | $callSid = $request->input('CallSid'); 77 | $question = Question::find($questionId); 78 | $questionResponse = $question->responses()->where('session_sid', $callSid)->firstOrFail(); 79 | $questionResponse->responseTranscription()->create( 80 | ['transcription' => $this->_transcriptionMessageIfCompleted($request)] 81 | ); 82 | } 83 | 84 | private function _responseFromVoiceRequest($question, $request) 85 | { 86 | if ($question->kind === 'free-answer') { 87 | return $request->input('RecordingUrl'); 88 | } else { 89 | return $request->input('Digits'); 90 | } 91 | } 92 | 93 | private function _questionAfter($question) 94 | { 95 | $survey = Survey::find($question->survey_id); 96 | $allQuestions = $survey->questions()->orderBy('id', 'asc')->get(); 97 | $position = $allQuestions->search($question); 98 | $nextQuestion = $allQuestions->get($position + 1); 99 | return $nextQuestion; 100 | } 101 | 102 | private function _redirectToQuestion($question, $route) 103 | { 104 | $questionUrl = route( 105 | $route, 106 | ['question' => $question->id, 'survey' => $question->survey->id] 107 | ); 108 | $redirectResponse = new Twiml(); 109 | $redirectResponse->redirect($questionUrl, ['method' => 'GET']); 110 | 111 | return response($redirectResponse); 112 | } 113 | 114 | private function _voiceMessageAfterLastQuestion() 115 | { 116 | $voiceResponse = new Twiml(); 117 | $voiceResponse->say('That was the last question'); 118 | $voiceResponse->say('Thank you for participating in this survey'); 119 | $voiceResponse->say('Good-bye'); 120 | $voiceResponse->hangup(); 121 | 122 | return response($voiceResponse); 123 | } 124 | 125 | private function _smsMessageAfterLastQuestion() { 126 | $messageResponse = new Twiml(); 127 | $messageResponse->message( 128 | "That was the last question.\n" . 129 | "Thank you for participating in this survey.\n" . 130 | 'Good bye.' 131 | ); 132 | return response($messageResponse) 133 | ->withCookie(Cookie::forget('survey_session')) 134 | ->withCookie(Cookie::forget('current_question')); 135 | } 136 | 137 | private function _transcriptionMessageIfCompleted($request) 138 | { 139 | if ($request->input('TranscriptionStatus') === 'completed') { 140 | return $request->input('TranscriptionText'); 141 | } 142 | return 'An error occurred while transcribing the answer'; 143 | } 144 | 145 | private function _responseWithXmlType($response) 146 | { 147 | return $response->header('Content-Type', 'application/xml'); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /config/session.php: -------------------------------------------------------------------------------- 1 | env('SESSION_DRIVER', 'file'), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Session Lifetime 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Here you may specify the number of minutes that you wish the session 27 | | to be allowed to remain idle before it expires. If you want them 28 | | to immediately expire on the browser closing, set that option. 29 | | 30 | */ 31 | 32 | 'lifetime' => 120, 33 | 34 | 'expire_on_close' => false, 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Session Encryption 39 | |-------------------------------------------------------------------------- 40 | | 41 | | This option allows you to easily specify that all of your session data 42 | | should be encrypted before it is stored. All encryption will be run 43 | | automatically by Laravel and you can use the Session like normal. 44 | | 45 | */ 46 | 47 | 'encrypt' => false, 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Session File Location 52 | |-------------------------------------------------------------------------- 53 | | 54 | | When using the native session driver, we need a location where session 55 | | files may be stored. A default has been set for you but a different 56 | | location may be specified. This is only needed for file sessions. 57 | | 58 | */ 59 | 60 | 'files' => storage_path('framework/sessions'), 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Session Database Connection 65 | |-------------------------------------------------------------------------- 66 | | 67 | | When using the "database" or "redis" session drivers, you may specify a 68 | | connection that should be used to manage these sessions. This should 69 | | correspond to a connection in your database configuration options. 70 | | 71 | */ 72 | 73 | 'connection' => null, 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Session Database Table 78 | |-------------------------------------------------------------------------- 79 | | 80 | | When using the "database" session driver, you may specify the table we 81 | | should use to manage the sessions. Of course, a sensible default is 82 | | provided for you; however, you are free to change this as needed. 83 | | 84 | */ 85 | 86 | 'table' => 'sessions', 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Session Sweeping Lottery 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Some session drivers must manually sweep their storage location to get 94 | | rid of old sessions from storage. Here are the chances that it will 95 | | happen on a given request. By default, the odds are 2 out of 100. 96 | | 97 | */ 98 | 99 | 'lottery' => [2, 100], 100 | 101 | /* 102 | |-------------------------------------------------------------------------- 103 | | Session Cookie Name 104 | |-------------------------------------------------------------------------- 105 | | 106 | | Here you may change the name of the cookie used to identify a session 107 | | instance by ID. The name specified here will get used every time a 108 | | new session cookie is created by the framework for every driver. 109 | | 110 | */ 111 | 112 | 'cookie' => 'laravel_session', 113 | 114 | /* 115 | |-------------------------------------------------------------------------- 116 | | Session Cookie Path 117 | |-------------------------------------------------------------------------- 118 | | 119 | | The session cookie path determines the path for which the cookie will 120 | | be regarded as available. Typically, this will be the root path of 121 | | your application but you are free to change this when necessary. 122 | | 123 | */ 124 | 125 | 'path' => '/', 126 | 127 | /* 128 | |-------------------------------------------------------------------------- 129 | | Session Cookie Domain 130 | |-------------------------------------------------------------------------- 131 | | 132 | | Here you may change the domain of the cookie used to identify a session 133 | | in your application. This will determine which domains the cookie is 134 | | available to in your application. A sensible default has been set. 135 | | 136 | */ 137 | 138 | 'domain' => null, 139 | 140 | /* 141 | |-------------------------------------------------------------------------- 142 | | HTTPS Only Cookies 143 | |-------------------------------------------------------------------------- 144 | | 145 | | By setting this option to true, session cookies will only be sent back 146 | | to the server if the browser has a HTTPS connection. This will keep 147 | | the cookie from being sent to you if it can not be done securely. 148 | | 149 | */ 150 | 151 | 'secure' => false, 152 | 153 | ]; 154 | -------------------------------------------------------------------------------- /app/Http/Controllers/SurveyController.php: -------------------------------------------------------------------------------- 1 | get() 24 | ->groupBy('session_sid') 25 | ->values(); 26 | 27 | return response()->view( 28 | 'surveys.results', 29 | ['survey' => $survey, 'responses' => $responsesByCall] 30 | ); 31 | } 32 | 33 | public function showFirstSurveyResults() 34 | { 35 | $firstSurvey = $this->_getFirstSurvey(); 36 | return redirect(route('survey.results', ['survey' => $firstSurvey->id])) 37 | ->setStatusCode(303); 38 | } 39 | 40 | public function connectVoice() 41 | { 42 | $response = new Twiml(); 43 | $redirectResponse = $this->_redirectWithFirstSurvey('survey.show.voice', $response); 44 | return $this->_responseWithXmlType($redirectResponse); 45 | } 46 | 47 | /** 48 | * Display the specified resource. 49 | * 50 | * @param int $id 51 | * @return Response 52 | */ 53 | public function showVoice($id) 54 | { 55 | $surveyToTake = Survey::find($id); 56 | $voiceResponse = new Twiml(); 57 | 58 | if (is_null($surveyToTake)) { 59 | return $this->_responseWithXmlType($this->_noSuchVoiceSurvey($voiceResponse)); 60 | } 61 | $surveyTitle = $surveyToTake->title; 62 | $voiceResponse->say("Hello and thank you for taking the $surveyTitle survey!"); 63 | $voiceResponse->redirect($this->_urlForFirstQuestion($surveyToTake, 'voice'), ['method' => 'GET']); 64 | 65 | return $this->_responseWithXmlType(response($voiceResponse)); 66 | } 67 | 68 | /** 69 | * Display the specified resource. 70 | * 71 | * @param int $id 72 | * @return Response 73 | */ 74 | public function showSms($id) 75 | { 76 | $surveyToTake = Survey::find($id); 77 | $voiceResponse = new Twiml(); 78 | 79 | if (is_null($surveyToTake)) { 80 | return $this->_responseWithXmlType($this->_noSuchSmsSurvey($voiceResponse)); 81 | } 82 | 83 | $surveyTitle = $surveyToTake->title; 84 | $voiceResponse->message("Hello and thank you for taking the $surveyTitle survey!"); 85 | $voiceResponse->redirect($this->_urlForFirstQuestion($surveyToTake, 'sms'), ['method' => 'GET']); 86 | 87 | return $this->_responseWithXmlType(response($voiceResponse)); 88 | } 89 | 90 | public function connectSms(Request $request) 91 | { 92 | $response = $this->_getNextSmsStepFromCookies($request); 93 | return $this->_responseWithXmlType($response); 94 | } 95 | 96 | private function _getNextSmsStepFromCookies($request) { 97 | $response = new Twiml(); 98 | if (strtolower(trim($request->input('Body'))) === self::START_SMS_SURVEY_COMMAND) { 99 | $messageSid = $request->input('MessageSid'); 100 | 101 | return $this->_redirectWithFirstSurvey('survey.show.sms', $response) 102 | ->withCookie('survey_session', $messageSid); 103 | } 104 | 105 | $currentQuestion = $request->cookie('current_question'); 106 | $surveySession = $request->cookie('survey_session'); 107 | 108 | if ($this->_noActiveSurvey($currentQuestion, $surveySession)) { 109 | return $this->_smsSuggestCommand($response); 110 | } 111 | 112 | return $this->_redirectToStoreSmsResponse($response, $currentQuestion); 113 | } 114 | 115 | private function _redirectWithFirstSurvey($routeName, $response) 116 | { 117 | $firstSurvey = $this->_getFirstSurvey(); 118 | 119 | if (is_null($firstSurvey)) { 120 | if ($routeName === 'survey.show.voice') { 121 | return $this->_noSuchVoiceSurvey($response); 122 | } 123 | return $this->_noSuchSmsSurvey($response); 124 | } 125 | 126 | $response->redirect( 127 | route($routeName, ['id' => $firstSurvey->id]), 128 | ['method' => 'GET'] 129 | ); 130 | return response($response); 131 | } 132 | 133 | private function _noActiveSurvey($currentQuestion, $surveySession) { 134 | $noCurrentQuestion = is_null($currentQuestion) || $currentQuestion == 'deleted'; 135 | $noSurveySession = is_null($surveySession) || $surveySession == 'deleted'; 136 | 137 | return $noCurrentQuestion || $noSurveySession; 138 | } 139 | 140 | private function _redirectToStoreSmsResponse($response, $currentQuestion) { 141 | $firstSurvey = $this->_getFirstSurvey(); 142 | $storeRoute = route('response.store.sms', ['survey' => $firstSurvey->id, 'question' => $currentQuestion]); 143 | $response->redirect($storeRoute, ['method' => 'POST']); 144 | 145 | return response($response); 146 | } 147 | 148 | private function _smsSuggestCommand($response) { 149 | $response->message('You have no active surveys. Reply with "Start" to begin.'); 150 | return response($response); 151 | } 152 | 153 | private function _noSuchSmsSurvey($messageResponse) 154 | { 155 | $messageResponse->message('Sorry, we could not find the survey to take. Good-bye'); 156 | return response($messageResponse); 157 | } 158 | 159 | private function _urlForFirstQuestion($survey, $routeType) 160 | { 161 | return route( 162 | 'question.show.' . $routeType, 163 | ['survey' => $survey->id, 164 | 'question' => $survey->questions()->orderBy('id')->first()->id] 165 | ); 166 | } 167 | 168 | private function _noSuchVoiceSurvey($voiceResponse) 169 | { 170 | $voiceResponse->say('Sorry, we could not find the survey to take'); 171 | $voiceResponse->say('Good-bye'); 172 | $voiceResponse->hangup(); 173 | 174 | return response($voiceResponse); 175 | } 176 | 177 | private function _getFirstSurvey() { 178 | return Survey::orderBy('id', 'DESC')->get()->first(); 179 | } 180 | 181 | private function _responseWithXmlType($response) { 182 | return $response->header('Content-Type', 'application/xml'); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /tests/app/Http/Controllers/QuestionResponseControllerTest.php: -------------------------------------------------------------------------------- 1 | beginDatabaseTransaction(); 21 | $this->survey = new Survey(['title' => 'Testing survey']); 22 | $this->questionOne = new Question(['body' => 'What is this? Question 1', 'kind' => 'free-answer']); 23 | $this->questionTwo = new Question(['body' => 'What is that? Question 2', 'kind' => 'numeric']); 24 | $this->survey->save(); 25 | $this->questionOne->survey()->associate($this->survey)->save(); 26 | $this->questionTwo->survey()->associate($this->survey)->save(); 27 | } 28 | 29 | public function testStoreVoiceResponse() 30 | { 31 | $responseForQuestion = [ 32 | 'RecordingUrl' => '//somefake.mp3', 33 | 'CallSid' => '7h1515un1qu3', 34 | 'Digits' => '10' 35 | ]; 36 | 37 | $firstResponse = $this->call( 38 | 'POST', 39 | route( 40 | 'response.store.voice', 41 | ['question' => $this->questionOne->id, 42 | 'survey' => $this->survey->id] 43 | ), 44 | $responseForQuestion 45 | ); 46 | 47 | $routeToNextQuestion = route('question.show.voice', ['question' => $this->questionTwo->id, 'survey' => $this->survey->id], false); 48 | $routeToNextQuestionAbsolute = route('question.show.voice', ['question' => $this->questionTwo->id, 'survey' => $this->survey->id], true); 49 | $this->assertContains($routeToNextQuestion, $firstResponse->getContent()); 50 | 51 | $secondResponse = $this->call( 52 | 'POST', 53 | route( 54 | 'response.store.voice', 55 | ['question' => $this->questionTwo->id, 56 | 'survey' => $this->survey->id] 57 | ), 58 | $responseForQuestion 59 | ); 60 | 61 | $this->assertNotContains('Redirect', $secondResponse->getContent()); 62 | $this->assertContains('That was the last question', $secondResponse->getContent()); 63 | } 64 | 65 | public function testStoreSmsResponse() 66 | { 67 | $this->assertCount(0, QuestionResponse::all()); 68 | 69 | $firstResponse = $this->call( 70 | 'POST', 71 | route( 72 | 'response.store.sms', 73 | ['question' => $this->questionOne->id, 74 | 'survey' => $this->survey->id] 75 | ), 76 | ['Body' => 'Some answer'], 77 | ['survey_session' => 'session_SID'] 78 | ); 79 | 80 | $messageDocument = new SimpleXMLElement($firstResponse->getContent()); 81 | $this->assertCount(1, QuestionResponse::all()); 82 | $questionResponse = QuestionResponse::first(); 83 | 84 | $this->assertEquals('Some answer', $questionResponse->response); 85 | $this->assertEquals('session_SID', $questionResponse->session_sid); 86 | $this->assertEquals('sms', $questionResponse->type); 87 | $this->assertEquals( 88 | route('question.show.sms', ['survey' => $this->survey->id, 'question' => $this->questionTwo->id]), 89 | strval($messageDocument->Redirect) 90 | ); 91 | } 92 | 93 | public function testStoreLastQuestionSmsAnswer() { 94 | $this->assertCount(0, QuestionResponse::all()); 95 | 96 | $firstResponse = $this->call( 97 | 'POST', 98 | route( 99 | 'response.store.sms', 100 | ['question' => $this->questionTwo->id, 101 | 'survey' => $this->survey->id] 102 | ), 103 | ['Body' => 'Some answer two'], 104 | ['survey_session' => 'session_SID'] 105 | ); 106 | 107 | $cookies = $firstResponse->headers->getCookies(); 108 | $messageDocument = new SimpleXMLElement($firstResponse->getContent()); 109 | $this->assertCount(1, QuestionResponse::all()); 110 | $questionResponse = QuestionResponse::first(); 111 | 112 | $this->assertCount(2, $cookies); 113 | $this->assertEquals('Some answer two', $questionResponse->response); 114 | $this->assertEquals('session_SID', $questionResponse->session_sid); 115 | $this->assertEquals('sms', $questionResponse->type); 116 | $this->assertEquals($this->questionTwo->id, $questionResponse->question_id); 117 | $this->assertEquals( 118 | "That was the last question.\n" . 119 | "Thank you for participating in this survey.\n" . 120 | 'Good bye.', 121 | strval($messageDocument->Message) 122 | ); 123 | } 124 | 125 | public function testUpdateResponseWithTranscription() { 126 | $questionResponse = $this->questionOne->responses()->create( 127 | ['response' => 'Some answer', 128 | 'type' => 'voice', 129 | 'session_sid' => 'call_sid'] 130 | ); 131 | $this->assertNull($questionResponse->responseTranscription); 132 | 133 | $response = $this->call( 134 | 'POST', 135 | route( 136 | 'response.transcription.store', 137 | ['survey' => $this->survey->id, 'question' => $this->questionOne->id, 'response' => $questionResponse->id] 138 | ), 139 | ['TranscriptionText' => 'transcribed answer!', 140 | 'TranscriptionStatus' => 'completed', 141 | 'CallSid' => 'call_sid'] 142 | ); 143 | $questionResponse = $questionResponse->fresh(); 144 | $transcription = $questionResponse->responseTranscription; 145 | 146 | $this->assertNotNull($transcription); 147 | $this->assertEquals('transcribed answer!', $transcription->transcription); 148 | } 149 | 150 | public function testUpdateResponseWithTranscriptionError() { 151 | $questionResponse = $this->questionOne->responses()->create( 152 | ['response' => 'Some answer', 153 | 'type' => 'voice', 154 | 'session_sid' => 'call_sid'] 155 | ); 156 | $this->assertNull($questionResponse->responseTranscription); 157 | 158 | $response = $this->call( 159 | 'POST', 160 | route( 161 | 'response.transcription.store', 162 | ['survey' => $this->survey->id, 'question' => $this->questionOne->id] 163 | ), 164 | ['TranscriptionText' => 'Some error occurred', 165 | 'TranscriptionStatus' => 'failed', 166 | 'CallSid' => 'call_sid'] 167 | ); 168 | $questionResponse = $questionResponse->fresh(); 169 | $transcription = $questionResponse->responseTranscription; 170 | 171 | $this->assertNotNull($transcription); 172 | $this->assertEquals('An error occurred while transcribing the answer', $transcription->transcription); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /tests/app/Http/Controllers/SurveyControllerTest.php: -------------------------------------------------------------------------------- 1 | beginDatabaseTransaction(); 22 | 23 | $appBasePath = base_path(); 24 | Artisan::call( 25 | 'surveys:load', ['fileName' => "$appBasePath/bear_survey.json"] 26 | ); 27 | 28 | $this->firstSurvey = \App\Survey::all()->first(); 29 | } 30 | 31 | /** 32 | * GET redirects to first voice survey 33 | * 34 | * @return void 35 | */ 36 | public function testRedirectToFirstVoiceSurvey() 37 | { 38 | $response = $this->call('POST', '/voice/connect'); 39 | $this->assertEquals(200, $response->getStatusCode()); 40 | 41 | $redirectDocument = new SimpleXMLElement($response->getContent()); 42 | $this->assertContains(route('survey.show.voice', ['id' => $this->firstSurvey->id]), strval($redirectDocument->Redirect)); 43 | $this->assertEquals('GET', strval($redirectDocument->Redirect->attributes()['method'])); 44 | } 45 | 46 | /** 47 | * GET redirects to first sms survey 48 | * 49 | * @return void 50 | */ 51 | public function testRedirectToFirstSmsSurvey() 52 | { 53 | $response = $this->call('POST', '/sms/connect', ['Body' => 'Start', 'MessageSid' => 'unique_SID']); 54 | $this->assertEquals(200, $response->getStatusCode()); 55 | 56 | $redirectDocument = new SimpleXMLElement($response->getContent()); 57 | $cookies = $response->headers->getCookies(); 58 | 59 | $this->assertCount(1, $cookies); 60 | $this->assertEquals('survey_session', $cookies[0]->getName()); 61 | $this->assertEquals('unique_SID', $cookies[0]->getValue()); 62 | $this->assertContains(route('survey.show.sms', ['id' => $this->firstSurvey->id]), strval($redirectDocument->Redirect)); 63 | $this->assertEquals('GET', strval($redirectDocument->Redirect->attributes()['method'])); 64 | } 65 | 66 | public function testSuggestCommandWhenNoSurveyIsStarted() { 67 | $response = $this->call('POST', '/sms/connect', ['Body' => 'not start command']); 68 | $this->assertEquals(200, $response->getStatusCode()); 69 | $replyDocument = new SimpleXMLElement($response->getContent()); 70 | 71 | $this->assertEquals('You have no active surveys. Reply with "Start" to begin.', strval($replyDocument->Message)); 72 | } 73 | 74 | public function testRedirectToStoreSmsAnswer() 75 | { 76 | $response = $this->call( 77 | 'POST', 78 | '/sms/connect', 79 | ['Body' => 'Some answer'], 80 | ['survey_session' => 'message_sid', 'current_question' => '1']); 81 | 82 | $this->assertEquals(200, $response->getStatusCode()); 83 | $redirectDocument = new SimpleXMLElement($response->getContent()); 84 | 85 | $this->assertContains( 86 | route('response.store.sms', ['survey' => $this->firstSurvey->id, 'question' => 1]), 87 | strval($redirectDocument->Redirect) 88 | ); 89 | $this->assertEquals('POST', strval($redirectDocument->Redirect->attributes()['method'])); 90 | } 91 | 92 | /** 93 | * GET test voice welcome response 94 | * 95 | * @return void 96 | */ 97 | public function testVoiceSurveyWelcomeResponse() 98 | { 99 | $response = $this->call( 100 | 'GET', 101 | route('survey.show.voice', ['id' => $this->firstSurvey->id]) 102 | ); 103 | 104 | $welcomeDocument = new SimpleXMLElement($response->getContent()); 105 | $surveyTitle = $this->firstSurvey->title; 106 | 107 | $this->assertEquals("Hello and thank you for taking the $surveyTitle survey!", strval($welcomeDocument->Say)); 108 | $this->assertContains( 109 | route( 110 | 'question.show.voice', 111 | ['survey' => $this->firstSurvey->id, 'question' => $this->firstSurvey->questions()->first()->id] 112 | ), 113 | strval($welcomeDocument->Redirect) 114 | ); 115 | } 116 | 117 | /** 118 | * GET test SMS welcome response 119 | * 120 | * @return void 121 | */ 122 | public function testSmsSurveyWelcomeResponse() 123 | { 124 | $response = $this->call( 125 | 'GET', 126 | route('survey.show.sms', ['id' => $this->firstSurvey->id]) 127 | ); 128 | 129 | $welcomeDocument = new SimpleXMLElement($response->getContent()); 130 | $surveyTitle = $this->firstSurvey->title; 131 | 132 | $this->assertEquals("Hello and thank you for taking the $surveyTitle survey!", strval($welcomeDocument->Message)); 133 | $this->assertContains( 134 | route( 135 | 'question.show.sms', 136 | ['survey' => $this->firstSurvey->id, 'question' => $this->firstSurvey->questions()->first()->id] 137 | ), 138 | strval($welcomeDocument->Redirect) 139 | ); 140 | } 141 | 142 | public function testSmsSurveyWelcomeResponseNoSurvey() 143 | { 144 | // Only one survey exists for testing. Trying to get survey id + 1 has no match 145 | $response = $this->call( 146 | 'GET', 147 | route('survey.show.sms', ['id' => $this->firstSurvey->id + 1]) 148 | ); 149 | $welcomeDocument = new SimpleXMLElement($response->getContent()); 150 | 151 | $this->assertEquals('Sorry, we could not find the survey to take. Good-bye', strval($welcomeDocument->Message)); 152 | } 153 | 154 | /** 155 | * GET test question response index 156 | * 157 | * @return void 158 | */ 159 | public function testQuestionSurveyResults() 160 | { 161 | $responseDataOne= ['type' => 'voice', 'response' => '//faketyfake.mp3', 'session_sid' => '4l505up3run1qu3']; 162 | $responseDataTwo = ['type' => 'voice', 'response' => '//somefakesound.mp3', 'session_sid' => '5up3run1qu3']; 163 | 164 | $question = new Question(['body' => 'What is this?', 'kind' => 'free-answer']); 165 | $question->survey()->associate($this->firstSurvey); 166 | $question->save(); 167 | 168 | $question->responses()->createMany([$responseDataOne, $responseDataTwo]); 169 | 170 | $question->push(); 171 | 172 | $response = $this->call( 173 | 'GET', 174 | route('survey.results', ['id' => $this->firstSurvey->id]) 175 | ); 176 | 177 | $this->assertEquals($response->original['responses']->count(), 2); 178 | 179 | $actualResponseOne = $response->original['responses']->get(0)->toArray()[0]; 180 | $actualResponseTwo = $response->original['responses']->get(1)->toArray()[0]; 181 | 182 | $this->assertArraySubset($responseDataOne, $actualResponseOne); 183 | $this->assertArraySubset($responseDataTwo, $actualResponseTwo); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /config/app.php: -------------------------------------------------------------------------------- 1 | env('APP_DEBUG', false), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Application URL 21 | |-------------------------------------------------------------------------- 22 | | 23 | | This URL is used by the console to properly generate URLs when using 24 | | the Artisan command line tool. You should set this to the root of 25 | | your application so that it is used when running Artisan tasks. 26 | | 27 | */ 28 | 29 | 'url' => 'http://localhost', 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Application Timezone 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Here you may specify the default timezone for your application, which 37 | | will be used by the PHP date and date-time functions. We have gone 38 | | ahead and set this to a sensible default for you out of the box. 39 | | 40 | */ 41 | 42 | 'timezone' => 'UTC', 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Application Locale Configuration 47 | |-------------------------------------------------------------------------- 48 | | 49 | | The application locale determines the default locale that will be used 50 | | by the translation service provider. You are free to set this value 51 | | to any of the locales which will be supported by the application. 52 | | 53 | */ 54 | 55 | 'locale' => 'en', 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Application Fallback Locale 60 | |-------------------------------------------------------------------------- 61 | | 62 | | The fallback locale determines the locale to use when the current one 63 | | is not available. You may change the value to correspond to any of 64 | | the language folders that are provided through your application. 65 | | 66 | */ 67 | 68 | 'fallback_locale' => 'en', 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Encryption Key 73 | |-------------------------------------------------------------------------- 74 | | 75 | | This key is used by the Illuminate encrypter service and should be set 76 | | to a random, 32 character string, otherwise these encrypted strings 77 | | will not be safe. Please do this before deploying an application! 78 | | 79 | */ 80 | 81 | 'key' => substr(env('APP_KEY', 'SomeRandomStringSomeRandomString'), 0, 32), 82 | 83 | 'cipher' => 'AES-256-CBC', 84 | 85 | /* 86 | |-------------------------------------------------------------------------- 87 | | Logging Configuration 88 | |-------------------------------------------------------------------------- 89 | | 90 | | Here you may configure the log settings for your application. Out of 91 | | the box, Laravel uses the Monolog PHP logging library. This gives 92 | | you a variety of powerful log handlers / formatters to utilize. 93 | | 94 | | Available Settings: "single", "daily", "syslog", "errorlog" 95 | | 96 | */ 97 | 98 | 'log' => 'single', 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | Autoloaded Service Providers 103 | |-------------------------------------------------------------------------- 104 | | 105 | | The service providers listed here will be automatically loaded on the 106 | | request to your application. Feel free to add your own services to 107 | | this array to grant expanded functionality to your applications. 108 | | 109 | */ 110 | 111 | 'providers' => [ 112 | Collective\Html\HtmlServiceProvider::class, 113 | /* 114 | * Laravel Framework Service Providers... 115 | */ 116 | Illuminate\Foundation\Providers\ArtisanServiceProvider::class, 117 | Illuminate\Auth\AuthServiceProvider::class, 118 | Illuminate\Broadcasting\BroadcastServiceProvider::class, 119 | Illuminate\Bus\BusServiceProvider::class, 120 | Illuminate\Cache\CacheServiceProvider::class, 121 | Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, 122 | Illuminate\Routing\ControllerServiceProvider::class, 123 | Illuminate\Cookie\CookieServiceProvider::class, 124 | Illuminate\Database\DatabaseServiceProvider::class, 125 | Illuminate\Encryption\EncryptionServiceProvider::class, 126 | Illuminate\Filesystem\FilesystemServiceProvider::class, 127 | Illuminate\Foundation\Providers\FoundationServiceProvider::class, 128 | Illuminate\Hashing\HashServiceProvider::class, 129 | Illuminate\Mail\MailServiceProvider::class, 130 | Illuminate\Pagination\PaginationServiceProvider::class, 131 | Illuminate\Pipeline\PipelineServiceProvider::class, 132 | Illuminate\Queue\QueueServiceProvider::class, 133 | Illuminate\Redis\RedisServiceProvider::class, 134 | Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, 135 | Illuminate\Session\SessionServiceProvider::class, 136 | Illuminate\Translation\TranslationServiceProvider::class, 137 | Illuminate\Validation\ValidationServiceProvider::class, 138 | Illuminate\View\ViewServiceProvider::class, 139 | 140 | /* 141 | * Application Service Providers... 142 | */ 143 | App\Providers\AppServiceProvider::class, 144 | App\Providers\EventServiceProvider::class, 145 | App\Providers\RouteServiceProvider::class, 146 | 147 | ], 148 | 149 | /* 150 | |-------------------------------------------------------------------------- 151 | | Class Aliases 152 | |-------------------------------------------------------------------------- 153 | | 154 | | This array of class aliases will be registered when this application 155 | | is started. However, feel free to register as many as you wish as 156 | | the aliases are "lazy" loaded so they don't hinder performance. 157 | | 158 | */ 159 | 160 | 'aliases' => [ 161 | 162 | 'App' => Illuminate\Support\Facades\App::class, 163 | 'Artisan' => Illuminate\Support\Facades\Artisan::class, 164 | 'Auth' => Illuminate\Support\Facades\Auth::class, 165 | 'Blade' => Illuminate\Support\Facades\Blade::class, 166 | 'Bus' => Illuminate\Support\Facades\Bus::class, 167 | 'Cache' => Illuminate\Support\Facades\Cache::class, 168 | 'Config' => Illuminate\Support\Facades\Config::class, 169 | 'Cookie' => Illuminate\Support\Facades\Cookie::class, 170 | 'Crypt' => Illuminate\Support\Facades\Crypt::class, 171 | 'DB' => Illuminate\Support\Facades\DB::class, 172 | 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 173 | 'Event' => Illuminate\Support\Facades\Event::class, 174 | 'File' => Illuminate\Support\Facades\File::class, 175 | 'Form' => Collective\Html\FormFacade::class, 176 | 'Html' => Collective\Html\HtmlFacade::class, 177 | 'Hash' => Illuminate\Support\Facades\Hash::class, 178 | 'Input' => Illuminate\Support\Facades\Input::class, 179 | 'Inspiring' => Illuminate\Foundation\Inspiring::class, 180 | 'Lang' => Illuminate\Support\Facades\Lang::class, 181 | 'Log' => Illuminate\Support\Facades\Log::class, 182 | 'Mail' => Illuminate\Support\Facades\Mail::class, 183 | 'Password' => Illuminate\Support\Facades\Password::class, 184 | 'Queue' => Illuminate\Support\Facades\Queue::class, 185 | 'Redirect' => Illuminate\Support\Facades\Redirect::class, 186 | 'Redis' => Illuminate\Support\Facades\Redis::class, 187 | 'Request' => Illuminate\Support\Facades\Request::class, 188 | 'Response' => Illuminate\Support\Facades\Response::class, 189 | 'Route' => Illuminate\Support\Facades\Route::class, 190 | 'Schema' => Illuminate\Support\Facades\Schema::class, 191 | 'Session' => Illuminate\Support\Facades\Session::class, 192 | 'Storage' => Illuminate\Support\Facades\Storage::class, 193 | 'URL' => Illuminate\Support\Facades\URL::class, 194 | 'Validator' => Illuminate\Support\Facades\Validator::class, 195 | 'View' => Illuminate\Support\Facades\View::class, 196 | 197 | ], 198 | 199 | ]; 200 | --------------------------------------------------------------------------------