diff --git a/laravel/app/Console/Commands/Notifications/Send.php b/laravel/app/Console/Commands/Notifications/Send.php new file mode 100644 index 00000000..94f558ab --- /dev/null +++ b/laravel/app/Console/Commands/Notifications/Send.php @@ -0,0 +1,48 @@ +command('inspire')->hourly(); + //$schedule->command('inspire')->hourly(); + $schedule->command('notifications:send')->daily(); } } diff --git a/laravel/app/Jobs/Job.php b/laravel/app/Jobs/Job.php deleted file mode 100644 index 55ece29a..00000000 --- a/laravel/app/Jobs/Job.php +++ /dev/null @@ -1,21 +0,0 @@ -where('status', 'new') + ->get()->groupBy('user_to'); + + //send batched notifications to each user via email + foreach ($user_notifications as $user_id => $notifications) + { + $user = User::find($user_id); + + //grab all notification metadata with the layout stored in them + $notification_metadata = $notifications->map(function($notification) { + $metadata = $notification->metadata; //copy + $metadata['layout'] = $notification->layout; + return $metadata; + }); + + Log::info("Sending daily email to user: {$user->email}"); + Mail::send('emails/user-daily-digest', compact('notification_metadata'), function ($message) use ($user) + { + $message->to($user->email, $user->name)->subject('Daily Volunteer Digest - Some things you may want to look over...'); + }); + + // Update all notifications to sent + $notification_ids = $notifications->pluck('id'); + Notification::whereIn('id', $notification_ids)->update(['status' => 'sent']); + } + } +} diff --git a/laravel/app/Listeners/SendUserShiftConfirmation.php b/laravel/app/Listeners/SendUserShiftConfirmation.php index bf432953..5291f3ac 100644 --- a/laravel/app/Listeners/SendUserShiftConfirmation.php +++ b/laravel/app/Listeners/SendUserShiftConfirmation.php @@ -3,6 +3,8 @@ namespace App\Listeners; use Mail; +use App\Models\Notification; +use App\Models\User; use App\Events\SlotChanged; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Contracts\Queue\ShouldQueue; @@ -29,27 +31,37 @@ public function handle(SlotChanged $event) { if ($event->change['status'] === 'taken') { - $slot = $event->slot; - $user_email = $event->change['email']; - $user_name = $event->change['name']; - $event_name = $event->slot->schedule->shift->event->name; - $shift_name = $event->slot->schedule->shift->name; - $start_date = $event->slot->start_date; - $start_time = $event->slot->start_date; - $end_time = $event->slot->end_time; - $admin_assigned = false; if (isset($event->change['admin_assigned'])) { $admin_assigned = $event->change['admin_assigned']; } - $event_data = compact('slot', 'user_email', 'user_name', 'event_name', 'shift_name', 'start_date', 'start_time', 'end_time', 'admin_assigned'); + $user = User::where('name', $event->change['name'])->first(); - Mail::send('emails/user-shift-confirmation', $event_data, function ($message) use ($user_email, $user_name, $shift_name) - { - $message->to($user_email, $user_name)->subject('Confirmation Email - ' . $shift_name . ' shift!'); - }); + Notification::queue($user, 'email', 'user-shift-confirmation', [ + 'event' => 'slot_taken', + 'slot_id' => $event->slot->id, + 'user_email' => $event->change['email'], + 'user_name' => $event->change['name'], + 'event_name' => $event->slot->schedule->shift->event->name, + 'shift_name' => $event->slot->schedule->shift->name, + 'start_date' => $event->slot->start_date, + 'start_time' => $event->slot->start_time, + 'end_time' => $event->slot->end_time, + 'admin_assigned' => $admin_assigned, + ]); + } + else + { + // Get the most recently created notification for the slot + $notification = Notification::where('user_to', $event->slot->user->id) + ->where('metadata->slot_id', $event->slot->id) + ->orderBy('created_at', 'desc') + ->first(); + + $notification->status = 'canceled'; + $notification->save(); } } } diff --git a/laravel/app/Models/Notification.php b/laravel/app/Models/Notification.php new file mode 100644 index 00000000..d889400f --- /dev/null +++ b/laravel/app/Models/Notification.php @@ -0,0 +1,74 @@ + 'array', + ]; + + public function from() + { + return $this->belongsTo('App\Models\User', 'user_from'); + } + + public function to() + { + return $this->belongsTo('App\Models\User', 'user_to'); + } + + public static function send(User $user_to, $type, $layout, $metadata, User $user_from = null) + { + // Email template variables + $templateVars = ['user' => $user_to]; + + // Check if any template variables were set in the metadata + if(isset($metadata['template-vars'])) + { + // Prevent template vars from being saved in the database + $templateVars = array_merge($templateVars, $metadata['template-vars']); + unset($metadata['template-vars']); + } + + $notification = new Notification; + $notification->type = $type; + $notification->layout = $layout; + $notification->status = 'new'; + $notification->metadata = $metadata; + $notification->user_to = $user_to->id; + $notification->user_from = $user_from ? $user_from->id : null; + $notification->save(); + + if($type == 'email') + { + Mail::send($metadata['template'], $templateVars, function ($message) use ($user_to, $metadata) + { + $message->to($user_to->email, $user_to->name)->subject($metadata['subject']); + }); + + $notification->status = 'sent'; + $notification->save(); + } + + return $notification; + } + + public static function queue(User $user_to, $type, $layout, $metadata, User $user_from = null) + { + $notification = new Notification; + $notification->type = $type; + $notification->layout = $layout; + $notification->status = 'new'; + $notification->metadata = $metadata; + $notification->user_to = $user_to->id; + $notification->user_from = $user_from ? $user_from->id : null; + $notification->save(); + + return $notification; + } +} diff --git a/laravel/database/factories/NotificationFactory.php b/laravel/database/factories/NotificationFactory.php new file mode 100644 index 00000000..7155a7be --- /dev/null +++ b/laravel/database/factories/NotificationFactory.php @@ -0,0 +1,25 @@ +define(Notification::class, function (Faker $faker, array $data) { + + if(!isset($data['schedule_id'])) + { + Log::warning("Using Factory[Notification] without setting user_to"); + } + + return [ + 'type' => 'info', + 'status' => 'new', + 'layout' => 'notification-test', + 'metadata' => [ + 'event' => 'test_event', + ], + 'user_to' => function() { + return factory(User::class)->create(); + }, + ]; +}); diff --git a/laravel/database/factories/ScheduleFactory.php b/laravel/database/factories/ScheduleFactory.php index e77002f9..9c2c5509 100644 --- a/laravel/database/factories/ScheduleFactory.php +++ b/laravel/database/factories/ScheduleFactory.php @@ -2,6 +2,8 @@ use App\Models\Department; use App\Models\Event; +use App\Models\EventRole; +use App\Models\Role; use App\Models\Schedule; use App\Models\Shift; use Carbon\Carbon; @@ -22,23 +24,37 @@ $duration_min = 2; //hours $duration_max = 8; //hours + $days_min = 2; + $days_max = 4; + $volunteer_min = 1; $volunteer_max = 3; - $start_datetime = Carbon::tomorrow(); - $end_datetime = $start_datetime->copy()->addDays($faker->numberBetween(2,4)); - - $duration = Carbon::createFromTime($faker->numberBetween($duration_min, $duration_max)); - $start_time = Carbon::createFromTime($faker->numberBetween(0, 23)); - $end_time = $start_time->addHours($duration->hour); - return [ - 'start_date' => $start_datetime->format('Y-m-d'), - 'end_date' => $end_datetime->format('Y-m-d'), - 'start_time' => $start_time->format('H:M:S'), - 'end_time' => $end_time->format('H:M:S'), - 'duration' => $duration->format('H:M:S'), + 'start_date' => Carbon::tomorrow()->addDays(1)->format('Y-m-d'), + 'end_date' => function($schedule) use ($faker, $days_min, $days_max) + { + $duration = $faker->numberBetween($days_min,$days_max); + $start_date = Carbon::createFromFormat('Y-m-d', $schedule['start_date']); + $end_date = $start_date->addDays($duration); + return $end_date->format('Y-m-d'); + }, + 'start_time' => Carbon::createFromTime($faker->numberBetween(0, 23))->format('H:i:s'), + 'end_time' => function($schedule) use ($faker, $duration_min, $duration_max) + { + $duration = $faker->numberBetween($duration_min, $duration_max); + $start_time = Carbon::createFromFormat('H:i:s', $schedule['start_time']); + $end_time = $start_time->addHours($duration); + return $end_time->format('H:i:s'); + }, + 'duration' => function($schedule) + { + $start_time = Carbon::createFromFormat('H:i:s', $schedule['start_time']); + $end_time = Carbon::createFromFormat('H:i:s', $schedule['end_time']); + $duration = $end_time->diff($start_time); + return $duration->format('%H:%I:%S'); + }, 'volunteers' => $faker->numberBetween($volunteer_min, $volunteer_max), 'department_id' => function ($schedule) { @@ -63,3 +79,21 @@ }, ]; }); + +$factory->afterCreating(Schedule::class, function(Schedule $schedule, Faker $faker) +{ + //find the admin role + $volunteer_role = Role::where('name', 'volunteer')->first(); + //if there is no admin role, create it + if (!$volunteer_role) + { + $volunteer_role = factory(Role::class)->create([ + 'name' => 'volunteer', + ]); + } + + $schedule->roles()->save(factory(EventRole::class)->make([ + 'role_id' => $volunteer_role->id, + 'foreign_id' => $schedule->id, + ])); +}); diff --git a/laravel/database/factories/SlotFactory.php b/laravel/database/factories/SlotFactory.php index 5b2be74e..233edfbd 100644 --- a/laravel/database/factories/SlotFactory.php +++ b/laravel/database/factories/SlotFactory.php @@ -3,6 +3,7 @@ use App\Models\Schedule; use App\Models\Slot; use Faker\Generator as Faker; +use Carbon\Carbon; $factory->define(Slot::class, function (Faker $faker, array $data) { @@ -10,16 +11,29 @@ { Log::warning("Using Factory[Slot] without setting schedule_id"); } - + + $duration_min = 2; //hours + $duration_max = 8; //hours + return [ - 'start_date' => $faker->dateTimeThisYear->format('Y-m-d'), - 'start_time' => $faker->time('H:i'), - 'end_time' => $faker->time('H:i'), + 'start_date' => Carbon::tomorrow()->addDays(1)->format('Y-m-d'), + 'start_time' => Carbon::createFromTime($faker->numberBetween(0, 23))->format('H:i:s'), + 'end_time' => function($schedule) use ($faker, $duration_min, $duration_max) + { + $duration = $faker->numberBetween($duration_min, $duration_max); + $start_time = Carbon::createFromFormat('H:i:s', $schedule['start_time']); + $end_time = $start_time->addHours($duration); + return $end_time->format('H:i:s'); + }, 'row' => 1, - 'schedule_id' => function () + 'schedule_id' => function ($schedule) { - return factory(Schedule::class)->create()->id; + return factory(Schedule::class)->create([ + 'start_date' => $schedule['start_date'], + 'start_time' => $schedule['start_time'], + 'end_time' => $schedule['end_time'], + ])->id; }, ]; }); diff --git a/laravel/database/factories/UserFactory.php b/laravel/database/factories/UserFactory.php index 1902cd69..e9d54c2e 100644 --- a/laravel/database/factories/UserFactory.php +++ b/laravel/database/factories/UserFactory.php @@ -22,6 +22,24 @@ ]; }); +$factory->afterCreating(User::class, function (User $user, Faker $faker) +{ + //find the volunteer role + $volunteer_role = Role::where('name', 'volunteer')->first(); + //if there is no volunteer role, create it + if (!$volunteer_role) + { + $volunteer_role = factory(Role::class)->create([ + 'name' => 'volunteer', + ]); + } + + $user->roles()->save(factory(UserRole::class)->make([ + 'role_id' => $volunteer_role->id, + 'user_id' => $user->id, + ])); +}); + $factory->afterCreatingState(User::class, 'admin', function (User $user, Faker $faker) { //find the admin role diff --git a/laravel/database/migrations/2019_06_27_045447_create_notifications_table.php b/laravel/database/migrations/2019_06_27_045447_create_notifications_table.php new file mode 100644 index 00000000..fa6619bb --- /dev/null +++ b/laravel/database/migrations/2019_06_27_045447_create_notifications_table.php @@ -0,0 +1,40 @@ +increments('id'); + $table->string('type'); + $table->string('status'); + $table->text('layout'); + $table->json('metadata'); + $table->integer('user_from')->unsigned()->nullable(); + $table->foreign('user_from')->references('id')->on('users')->onDelete('set null'); + $table->integer('user_to')->unsigned(); + $table->foreign('user_to')->references('id')->on('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('notifications'); + } +} diff --git a/laravel/package-lock.json b/laravel/package-lock.json index 98a5c7a8..6c2a56a4 100644 --- a/laravel/package-lock.json +++ b/laravel/package-lock.json @@ -1956,7 +1956,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -2011,7 +2012,8 @@ "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -2164,12 +2166,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -2188,6 +2192,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2288,6 +2293,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2373,7 +2379,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2429,6 +2436,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2472,12 +2480,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/laravel/resources/views/emails/notification-test.blade.php b/laravel/resources/views/emails/notification-test.blade.php new file mode 100644 index 00000000..231674fd --- /dev/null +++ b/laravel/resources/views/emails/notification-test.blade.php @@ -0,0 +1,5 @@ +
+ And you turned out to PHP positive baby! *electric guitar noises* +
diff --git a/laravel/resources/views/emails/user-daily-digest.blade.php b/laravel/resources/views/emails/user-daily-digest.blade.php new file mode 100644 index 00000000..2fc42b5d --- /dev/null +++ b/laravel/resources/views/emails/user-daily-digest.blade.php @@ -0,0 +1,15 @@ ++ Thank you for being a part of {{ env('SITE_NAME') }}. + Here's your daily dump of some weird stuff. +
+ +@foreach($notification_metadata as $metadata) + @include('emails.'.$metadata['layout'], $metadata) ++ If any of these look strange, please log into your account and review your shifts. +
diff --git a/laravel/resources/views/emails/user-shift-confirmation.blade.php b/laravel/resources/views/emails/user-shift-confirmation.blade.php index a44675c3..a583f96b 100644 --- a/laravel/resources/views/emails/user-shift-confirmation.blade.php +++ b/laravel/resources/views/emails/user-shift-confirmation.blade.php @@ -1,27 +1,30 @@ -- This is a confirmation email for the {{ $shift_name }} shift - that you were recently assigned to for the {{ $event_name }} event. -
-@else -- This is a confirmation email for the {{ $shift_name }} shift - you recently picked up for the {{ $shift_name }} event. -
-@endif + @if ($admin_assigned) ++ This is a confirmation email for the {{ $shift_name }} shift + that you were recently assigned to for the {{ $event_name }} event. +
+ @else ++ This is a confirmation email for the {{ $shift_name }} shift + you recently picked up for the {{ $shift_name }} event. +
+ @endif + ++ This shift takes place on {{ $start_date }} between the times of + {{ $start_time }} and {{ $end_time }}. +
+ ++ If you did NOT sign-up for this shift or would like to CANCEL this + shift, click here. +
+ ++ Otherwise, we look forward to seeing you there! +
+- This shift takes place on {{ $start_date}} between the times of - {{ $start_time }} and {{ $end_time }}. -
- -- If you did NOT sign-up for this shift or would like to CANCEL this - shift, click here. -
- -- Otherwise, we look forward to seeing you there! -
diff --git a/laravel/resources/views/info/notification-test.blade.php b/laravel/resources/views/info/notification-test.blade.php new file mode 100644 index 00000000..231674fd --- /dev/null +++ b/laravel/resources/views/info/notification-test.blade.php @@ -0,0 +1,5 @@ ++ And you turned out to PHP positive baby! *electric guitar noises* +
diff --git a/laravel/resources/views/warning/notification-test.blade.php b/laravel/resources/views/warning/notification-test.blade.php new file mode 100644 index 00000000..231674fd --- /dev/null +++ b/laravel/resources/views/warning/notification-test.blade.php @@ -0,0 +1,5 @@ ++ And you turned out to PHP positive baby! *electric guitar noises* +
diff --git a/laravel/tests/Feature/BatchNotificationTest.php b/laravel/tests/Feature/BatchNotificationTest.php new file mode 100644 index 00000000..43158082 --- /dev/null +++ b/laravel/tests/Feature/BatchNotificationTest.php @@ -0,0 +1,86 @@ +create(); + $slot = factory(Slot::class)->create(); + + // When + $response = $this->actingAs($user)->post("/slot/$slot->id/take"); + + // Then + $this->assertDatabaseHas('notifications', [ + 'user_to' => $user->id, + 'metadata->slot_id' => $slot->id, + ]); + } + + /** + * @test + * @return void + */ + public function send_notifications_in_batch() + { + // Given + $user = factory(User::class)->create(); + $slots = factory(Slot::class, 5)->create(); + + // When + $this->actingAs($user); //act as the user dawg + $slots->each(function ($slot) use ($user) + { + $this->post("/slot/$slot->id/take"); + }); + SendUserMailJob::dispatchNow(); + + // Then + $user_notifications = Notification::where('user_to', $user->id) + ->where('status', 'sent'); + $this->assertEquals($user_notifications->count(), $slots->count()); + } + + /** + * @test + * @return void + */ + public function cancel_slot_notification() + { + // Given + $user = factory(User::class)->create(); + $slot = factory(Slot::class)->create(); + + // When + $this->actingAs($user); + // take it + $this->post("/slot/$slot->id/take"); + // drop it + $response = $this->post("/slot/$slot->id/release"); + //bop it + SendUserMailJob::dispatchNow(); + + // Then + //check it + $this->assertDatabaseHas('notifications', [ + 'user_to' => $user->id, + 'status' => 'canceled', + ]); + } +} diff --git a/laravel/tests/Unit/NotificationTest.php b/laravel/tests/Unit/NotificationTest.php new file mode 100644 index 00000000..e9aee3be --- /dev/null +++ b/laravel/tests/Unit/NotificationTest.php @@ -0,0 +1,45 @@ +create(); + + $this->assertDatabaseHas('notifications', [ + 'id' => $notification->id, + ]); + } + + /** + * @test + * @return void + */ + public function email_notification_is_sent() + { + $notification = factory(Notification::class)->create([ + 'type' => 'email', + ]); + + SendUserMailJob::dispatchNow(); + + $this->assertDatabaseHas('notifications', [ + 'id' => $notification->id, + 'status' => 'sent', + ]); + } +}