├── 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 |
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 |
7 | @foreach ($responses as $response)
8 | -
9 |
10 |
11 | Response from: {{ $response->first()->session_sid }}
12 |
13 | Survey type:
14 | @if($response->first()->type == 'voice')
15 |
16 | @else
17 |
18 | @endif
19 | {{ $response->first()->type }}
20 |
21 |
22 |
23 | @foreach ($response as $questionResponse)
24 |
25 | - Question: {{ $questionResponse->question->body }}
26 | - Answer type: {{ $questionResponse->question->kind }}
27 | -
28 | @if($questionResponse->question->kind === 'free-answer' && $questionResponse->type === 'voice')
29 |
30 |
Response:
31 |
32 |
33 |
34 | @elseif($questionResponse->question->kind === 'yes-no')
35 | @if($questionResponse->response == 1)
36 | YES
37 | @else
38 | NO
39 | @endif
40 | @else
41 | {{ $questionResponse->response }}
42 | @endif
43 |
44 | @if(!is_null($questionResponse->transcription))
45 | - Transcribed Answer: {{ $questionResponse->transcription }}
46 | @endif
47 |
48 | @endforeach
49 |
50 |
51 |
52 | @endforeach
53 |
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 | [](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 |
--------------------------------------------------------------------------------