diff --git a/.gitignore b/.gitignore index 27e8277..575c8b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .idea vendor composer.lock + +.history/ diff --git a/README.md b/README.md index 77c62b7..4fe09a6 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ An easy way to set a timezone for a user in your application and then show date/ ## How does it work -This package listens for the `\Illuminate\Auth\Events\Login` event and will then automatically set a `timezone` on your `user` model (stored in the database). +This package listens for the `\Illuminate\Auth\Events\Login` event and will then automatically set a `timezone` on your `user` model (stored in the database). It decides whether to update user timezone or not according to user `detect_timezone` attribute, or, if not set, according to default value in config. For non-authorized routes, where auth user info is not accessible, package will use default timezone from its config. This package uses the [torann/geoip](http://lyften.com/projects/laravel-geoip/doc/) package which looks up the users location based on their IP address. The package also returns information like the users currency and users timezone. [You can configure this package separately if you require](#custom-configuration). - ## How to use +## How to use You can show dates to your user in their timezone by using @@ -34,24 +34,56 @@ Or use our nice blade directive ## Installation -Pull in the package using Composer +### Pull in the package using Composer -``` +```bash composer require jamesmills/laravel-timezone ``` -Publish database migrations - -``` +### Default timezone attributes location + +By default, timezone attributes placed into `users` table. If you wish to use package with this default, see instructions below. + +#### Publish database migrations + +```bash php artisan vendor:publish --provider="JamesMills\LaravelTimezone\LaravelTimezoneServiceProvider" --tag=migrations ``` -Run the database migrations. This will add a `timezone` column to your `users` table. +Run the database migrations. This will add `timezone` and `detect_timezone` columns to your `users` table. Note that migration will be placed to default Laravel migrations folder, so if you use custom folder, you should move migration file to appropriate location. -``` +```bash php artisan migrate ``` +#### Update User model + +Add `JamesMills\LaravelTimezone\Traits\HasTimezone` trait to your `user` model: + +```php +use HasTimezone; +``` + +If you wish to work with `detect_timezone` attribute directly, you can add boolean cast for your `User` model: + +```php +protected $casts = [ + 'detect_timezone' => 'boolean', +]; +``` + +If you wish to set per-user timezone overwriting at user creation time, you can add `detect_timezone` attribute to your `User` model fillable property: + +```php +protected $fillable = [ + 'detect_timezone', + ]; +``` + +### Custom timezone attributes location + +If you wish to use different location for `timezone` and `detect_timezone` attributes, e.g. `Profile` model, you should override `HasTimezone` trait and use overriden one in your `User` model. + ## Examples ### Showing date/time to the user in their timezone @@ -72,6 +104,14 @@ If you wish you can set a custom format and also include a nice version of the t // 2018-07-04 3:32 New York, America ``` +If you wish to further work with converted Carbon instance, you can use toLocal method: + +```php +{{ Timezone::toLocal($post->created_at)->diffForHumans() }} + +// diff calculated relative to datetime with user-end timezone +``` + ### Using blade directive Making your life easier one small step at a time @@ -125,7 +165,7 @@ To override this configuration, you just need to change the `flash` property ins ### Overwrite existing timezones in the database -By default, the timezone will be overwritten at each login with the current user timezone. This behavior can be restricted to only update the timezone if it is blank by setting the `'overwrite' => false,` config option. +User timezone will be overwritten at each login with the current user timezone if `detect_timezone` is set to true for this user. If this attribute is not set, by default, the timezone will be overwritten. This behavior can be restricted to only update the timezone if it is blank by setting the `'overwrite' => false,` config option. ### Default Format diff --git a/src/LaravelTimezoneServiceProvider.php b/src/LaravelTimezoneServiceProvider.php index e20f239..ef068d9 100644 --- a/src/LaravelTimezoneServiceProvider.php +++ b/src/LaravelTimezoneServiceProvider.php @@ -17,6 +17,16 @@ class LaravelTimezoneServiceProvider extends ServiceProvider */ protected $defer = false; + private function registerEventListener(): void + { + Event::listen( + config('timezone.timezone_check.events', null) ?? [ + \Illuminate\Auth\Events\Login::class, + \Laravel\Passport\Events\AccessTokenCreated::class, + ], + config('timezone.timezone_check.listener', null) ?? UpdateUsersTimezone::class + ); + } /** * Perform post-registration booting of services. @@ -26,9 +36,9 @@ class LaravelTimezoneServiceProvider extends ServiceProvider public function boot() { // Allow migrations publish - if (! class_exists('AddTimezoneColumnToUsersTable')) { + if (!class_exists('AddTimezoneColumnsToUsersTable')) { $this->publishes([ - __DIR__ . '/database/migrations/add_timezone_column_to_users_table.php.stub' => database_path('/migrations/' . date('Y_m_d_His') . '_add_timezone_column_to_users_table.php'), + __DIR__ . '/database/migrations/add_timezone_columns_to_users_table.php.stub' => database_path('/migrations/' . date('Y_m_d_His') . '_add_timezone_columns_to_users_table.php'), ], 'migrations'); } @@ -76,17 +86,4 @@ public function register() 'timezone' ); } - - /** - * - */ - private function registerEventListener(): void - { - $events = [ - \Illuminate\Auth\Events\Login::class, - \Laravel\Passport\Events\AccessTokenCreated::class, - ]; - - Event::listen($events, UpdateUsersTimezone::class); - } } diff --git a/src/Listeners/Auth/UpdateUsersTimezone.php b/src/Listeners/Auth/UpdateUsersTimezone.php index 7e3eac1..558e93f 100644 --- a/src/Listeners/Auth/UpdateUsersTimezone.php +++ b/src/Listeners/Auth/UpdateUsersTimezone.php @@ -4,142 +4,154 @@ use Illuminate\Auth\Events\Login; use Illuminate\Support\Facades\Auth; + use Laravel\Passport\Events\AccessTokenCreated; -use Torann\GeoIP\Location; +use JamesMills\LaravelTimezone\Traits\FlashesMessage; +use JamesMills\LaravelTimezone\Traits\RetrievesGeoIpTimezone; class UpdateUsersTimezone { + use RetrievesGeoIpTimezone; + use FlashesMessage; - /** - * Handle the event. - * - * @return void - */ - public function handle($event) + private function getFromLookup(): ?string { - $user = null; - - /** - * If the event is AccessTokenCreated, - * we logged the user and return, - * stopping the execution. - * - * The Auth::loginUsingId dispatches a Login event, - * making this listener be called again. - */ - if ($event instanceof AccessTokenCreated) { - Auth::loginUsingId($event->userId); + $result = null; - return; - } + foreach (config('timezone.lookup', []) as $type => $keys) { + if (empty($keys)) { + continue; + } - /** - * If the event is Login, we get the user from the web guard. - */ - if ($event instanceof Login) { - $user = Auth::user(); - } + $result = $this->lookup($type, $keys); - /** - * If no user is found, we just return. Nothing to do here. - */ - if (is_null($user)) { - return; + if ($result === null) { + continue; + } } - $ip = $this->getFromLookup(); - $geoip_info = geoip()->getLocation($ip); + return $result; + } - if ($user->timezone != $geoip_info['timezone']) { - if (config('timezone.overwrite') == true || $user->timezone == null) { - $user->timezone = $geoip_info['timezone']; - $user->save(); + private function lookup($type, $keys): ?string + { + $value = null; - $this->notify($geoip_info); + foreach ($keys as $key) { + if (!request()->$type->has($key)) { + continue; } + $value = request()->$type->get($key); } + + return $value; } - /** - * @param Location $geoip_info - */ - private function notify(Location $geoip_info) + protected function notify(array $info): void { - if (config('timezone.flash') == 'off') { + if (config('timezone.flash', 'off') === 'off') { return; } - $message = 'We have set your timezone to ' . $geoip_info['timezone']; + if ($info['timezone'] === null) { + $key = config('timezone.messages.fail.key', 'error'); + $message = config('timezone.messages.fail.message'); + } else { + if ($info['default']) { + $key = config('timezone.messages.default.key', 'warning'); + $message = config('timezone.messages.default.message'); + } else { + $key = config('timezone.messages.success.key', 'info'); + $message = config('timezone.messages.success.message'); + } - if (config('timezone.flash') == 'laravel') { - request()->session()->flash('success', $message); + if ($message !== null) { + $message = sprintf($message, $info['timezone']); + } + } + if ($message === null) { return; } - if (config('timezone.flash') == 'laracasts') { - flash()->success($message); - + if (config('timezone.flash') === 'laravel') { + $this->flashLaravelMessage($key, $message); return; } - if (config('timezone.flash') == 'mercuryseries') { - flashy()->success($message); - + if (config('timezone.flash') === 'laracasts') { + $this->flashLaracastsMessage($key, $message); return; } - if (config('timezone.flash') == 'spatie') { - flash()->success($message); - + if (config('timezone.flash') === 'mercuryseries') { + $this->flashMercuryseriesMessage($key, $message); return; } - if (config('timezone.flash') == 'mckenziearts') { - notify()->success($message); + if (config('timezone.flash') === 'spatie') { + $this->flashSpatieMessage($key, $message); + return; + } + if (config('timezone.flash') === 'mckenziearts') { + $this->flashMckenzieartsMessage($key, $message); return; } } - /** - * @return mixed - */ - private function getFromLookup() + public function handle($event): void { - $result = null; + $user = null; - foreach (config('timezone.lookup') as $type => $keys) { - if (empty($keys)) { - continue; - } + /** + * If the event is AccessTokenCreated, + * we logged the user and return, + * stopping the execution. + * + * The Auth::loginUsingId dispatches a Login event, + * making this listener be called again. + */ + if ($event instanceof AccessTokenCreated) { + Auth::loginUsingId($event->userId); - $result = $this->lookup($type, $keys); + return; + } - if (is_null($result)) { - continue; - } + /** + * If the event is Login, we get the user from the web guard. + */ + if ($event instanceof Login) { + $user = Auth::user(); } - return $result; - } + /** + * If no user is found or it has no timezone-related methods, return. + */ + if ( + $user === null || + !method_exists($user, 'getTimezone') || + !method_exists($user, 'getDetectTimezone') || + !method_exists($user, 'setTimezone') || + !method_exists($user, 'setDetectTimezone') + ) { + return; + } - /** - * @param $type - * @param $keys - * @return string|null - */ - private function lookup($type, $keys) - { - $value = null; + $overwrite = $user->getDetectTimezone() ?? config('timezone.overwrite', true); + $timezone = $user->getTimezone(); - foreach ($keys as $key) { - if (!request()->$type->has($key)) { - continue; + if ($timezone === null || $overwrite === true) { + $info = $this->getGeoIpTimezone($this->getFromLookup()); + + if ($timezone === null || $timezone !== $info['timezone']) { + if ($info['timezone'] !== null) { + $user->setTimezone($info['timezone']); + $user->save(); + } + + $this->notify($info); } - $value = request()->$type->get($key); } - - return $value; } } diff --git a/src/Timezone.php b/src/Timezone.php index bc2b6d1..b18eec4 100644 --- a/src/Timezone.php +++ b/src/Timezone.php @@ -6,57 +6,55 @@ class Timezone { - /** - * @param Carbon\Carbon|null $date - * @param null $format - * @param bool $format_timezone - * @return string - */ - public function convertToLocal(?Carbon $date, $format = null, $format_timezone = false) : string + protected function formatTimezone(Carbon $date): string { - if (is_null($date)) { - return 'Empty'; - } - - $timezone = (auth()->user()->timezone) ?? config('app.timezone'); - - $date->setTimezone($timezone); + $timezone = $date->format('e'); + $parts = explode('/', $timezone); - if (is_null($format)) { - return $date->format(config('timezone.format')); + if (count($parts) > 1) { + return str_replace('_', ' ', $parts[1]) . ', ' . $parts[0]; } - $formatted_date_time = $date->format($format); + return str_replace('_', ' ', $parts[0]); + } - if ($format_timezone) { - return $formatted_date_time . ' ' . $this->formatTimezone($date); + public function toLocal(?Carbon $date): ?Carbon + { + if ($date === null) { + return null; } - return $formatted_date_time; - } + // TODO(sergotail): use geoip timezone suggestion for non-authorized users too + // (make it configurable) + $timezone = auth()->user()->getTimezone() ?? + config('timezone.default', null) ?? + config('app.timezone'); - /** - * @param $date - * @return Carbon\Carbon - */ - public function convertFromLocal($date) : Carbon - { - return Carbon::parse($date, auth()->user()->timezone)->setTimezone('UTC'); + return $date->copy()->setTimezone($timezone); } - /** - * @param Carbon\Carbon $date - * @return string - */ - private function formatTimezone(Carbon $date) : string - { - $timezone = $date->format('e'); - $parts = explode('/', $timezone); + public function convertToLocal( + ?Carbon $date, + ?string $format = null, + bool $displayTimezone = false + ): string { + $date = $this->toLocal($date); - if (count($parts) > 1) { - return str_replace('_', ' ', $parts[1]) . ', ' . $parts[0]; + if ($date === null) { + return config('timezone.empty_date', 'Empty'); } - return str_replace('_', ' ', $parts[0]); + $formatted = $date->format($format ?? config('timezone.format', 'jS F Y g:i:a')); + + if ($displayTimezone) { + return $formatted . ' ' . $this->formatTimezone($date); + } + + return $formatted; + } + + public function convertFromLocal($date): Carbon + { + return Carbon::parse($date, auth()->user()->getTimezone())->setTimezone('UTC'); } } diff --git a/src/Traits/FlashesMessage.php b/src/Traits/FlashesMessage.php new file mode 100644 index 0000000..ca0b99b --- /dev/null +++ b/src/Traits/FlashesMessage.php @@ -0,0 +1,31 @@ +session()->flash($key, $message); + } + + protected function flashLaracastsMessage(string $key, string $message): void + { + flash()->success($message); + } + + protected function flashMercuryseriesMessage(string $key, string $message): void + { + flashy()->success($message); + } + + protected function flashSpatieMessage(string $key, string $message): void + { + flash()->success($message); + } + + protected function flashMckenzieartsMessage(string $key, string $message): void + { + notify()->success($message); + } +} diff --git a/src/Traits/HasTimezone.php b/src/Traits/HasTimezone.php new file mode 100644 index 0000000..ff7f260 --- /dev/null +++ b/src/Traits/HasTimezone.php @@ -0,0 +1,40 @@ +timezone; + if ($timezone === null) { + return null; + } + + return (string) $timezone; + } + + public function getDetectTimezone(): ?bool + { + $detectTimezone = $this->detect_timezone; + if ($detectTimezone === null) { + return null; + } + + return (bool) $detectTimezone; + } + + public function setTimezone(?string $timezone) + { + $this->timezone = $timezone; + + return $this; + } + + public function setDetectTimezone(?bool $detectTimezone) + { + $this->detect_timezone = $detectTimezone; + + return $this; + } +} diff --git a/src/Traits/RetrievesGeoIpTimezone.php b/src/Traits/RetrievesGeoIpTimezone.php new file mode 100644 index 0000000..98a6466 --- /dev/null +++ b/src/Traits/RetrievesGeoIpTimezone.php @@ -0,0 +1,16 @@ +getLocation($ip); + + return [ + 'timezone' => ($info['time_zone'] ?? [])['name'] ?? ($info['timezone'] ?? null), + 'default' => $info['default'] ?? false, + ]; + } +} diff --git a/src/config/timezone.php b/src/config/timezone.php index 7b992b0..b0f870e 100644 --- a/src/config/timezone.php +++ b/src/config/timezone.php @@ -4,24 +4,24 @@ /* |-------------------------------------------------------------------------- - | Flash messages + | Default Timezone |-------------------------------------------------------------------------- | - | Here you may configure if to use the laracasts/flash package for flash - | notifications when a users timezone is set. - | options [off, laravel, laracasts, mercuryseries, spatie, mckenziearts] + | Here you may configure default timezone for requests with no auth user info. + | If null specified, app.timezone config value will be used. | */ - 'flash' => 'laravel', + 'default' => null, /* |-------------------------------------------------------------------------- - | Overwrite Existing Timezone + | Overwrite Existing Timezone Default Value |-------------------------------------------------------------------------- | | Here you may configure if you would like to overwrite existing | timezones if they have been already set in the database. + | Note that this is default value, per-user value checked for existence first. | options [true, false] | */ @@ -30,7 +30,25 @@ /* |-------------------------------------------------------------------------- - | Overwrite Default Format + | Lookup Array + |-------------------------------------------------------------------------- + | + | Here you may configure the lookup array whom it will be used to fetch + | the user remote address. + | When a key is found inside the lookup array that key it will be used. + | + */ + + 'lookup' => [ + 'server' => [ + 'REMOTE_ADDR', + ], + 'headers' => [], + ], + + /* + |-------------------------------------------------------------------------- + | DateTime Default Format |-------------------------------------------------------------------------- | | Here you may configure if you would like to overwrite the @@ -40,23 +58,94 @@ 'format' => 'jS F Y g:i:a', + /* + |-------------------------------------------------------------------------- + | Empty DateTime String + |-------------------------------------------------------------------------- + | + | Here you may configure string to show when date is null. + | If null specified, string 'Empty' will be used. + | + */ + + 'empty_date' => null, + + /* + |-------------------------------------------------------------------------- + | Flash messages + |-------------------------------------------------------------------------- + | + | Here you may configure if to use the laracasts/flash package for flash + | notifications when a users timezone is set. + | options [off, laravel, laracasts, mercuryseries, spatie, mckenziearts] + | + */ + + 'flash' => 'laravel', + /* |-------------------------------------------------------------------------- | Lookup Array |-------------------------------------------------------------------------- | - | Here you may configure the lookup array whom it will be used to fetch the user remote address. - | When a key is found inside the lookup array that key it will be used. + | Here you may configure flash messages for timezone setup results. + | If null specified, no message will be flashed for current case. | */ - 'lookup' => [ - 'server' => [ - 'REMOTE_ADDR', + 'messages' => [ + 'fail' => [ + 'key' => 'error', + 'message' => 'We cannot determine your timezone', ], - 'headers' => [ + 'default' => [ + 'key' => 'warning', + 'message' => 'We cannot determine your timezone and have set it to default: %s', + ], + + 'success' => [ + 'key' => 'info', + 'message' => 'We have set your timezone to %s', ], ], + 'timezone_check' => [ + /* + |-------------------------------------------------------------------------- + | Timezone Check Events + |-------------------------------------------------------------------------- + | + | Here you may configure which events will be listen for user timezone check. + | If null specified, default Laravel Login and AccessTokenCreated events will be listen. + | This option is useful only if custom event handler for custom events specified. + | Examples: + | 1) null + | 2) \App\Events\MyLoginEvent::class + | 3) [ + | \App\Events\MyLoginEvent1::class, + | \App\Events\MyLoginEvent2::class, + | ] + | + */ + + 'events' => null, + + /* + |-------------------------------------------------------------------------- + | Timezone Check Event Listener + |-------------------------------------------------------------------------- + | + | Here you may configure which event handler will be used for user timezone check. + | If null specified, package default event listener will be used. + | This option is useful only if you are using custom login events or want to + | override default event listener, e.g. RetrievesGeoIpTimezone trait. + | Examples: + | null + | \App\EventListeners\MyTimezoneCheckEventListener::class + | + */ + + 'listener' => null, + ], ]; diff --git a/src/database/migrations/add_timezone_column_to_users_table.php.stub b/src/database/migrations/add_timezone_columns_to_users_table.php.stub old mode 100755 new mode 100644 similarity index 63% rename from src/database/migrations/add_timezone_column_to_users_table.php.stub rename to src/database/migrations/add_timezone_columns_to_users_table.php.stub index 9799032..dbf4a8b --- a/src/database/migrations/add_timezone_column_to_users_table.php.stub +++ b/src/database/migrations/add_timezone_columns_to_users_table.php.stub @@ -2,8 +2,9 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; -class AddTimezoneColumnToUsersTable extends Migration +class AddTimezoneColumnsToUsersTable extends Migration { /** * Run the migrations. @@ -17,6 +18,12 @@ class AddTimezoneColumnToUsersTable extends Migration $table->string('timezone')->after('remember_token')->nullable(); }); } + + if (!Schema::hasColumn('users', 'detect_timezone')) { + Schema::table('users', function (Blueprint $table) { + $table->boolean('detect_timezone')->after('timezone')->nullable(); + }); + } } /** @@ -28,6 +35,7 @@ class AddTimezoneColumnToUsersTable extends Migration { Schema::table('users', function (Blueprint $table) { $table->dropColumn('timezone'); + $table->dropColumn('detect_timezone'); }); } }