From fcf2d3d1ad5f4f85542bd92f7bdaf23ec26c8c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 10 Jun 2026 11:01:30 +0200 Subject: [PATCH] feat(users): track last login and last active timestamps (#516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds two timestamp columns to `users`: - **`last_logged_in_at`** — set by an `UpdateLastLoggedInAt` listener on Laravel's `Login` event. Records each explicit authentication (login form or remember-me re-auth). Does **not** update on plain session-based requests. - **`last_active_at`** — set by a `TrackLastActiveAt` middleware on authenticated web requests. Records the last moment the user did anything. Throttled to at most one write per 5 minutes to avoid a DB write on every request. ## Why `last_logged_in_at` answers "when did they last authenticate"; `last_active_at` answers "when were they last using the app" (any screen/activity), which a session-based login does not capture. ## Tests - Login records `last_logged_in_at` - Authenticated request records `last_active_at` - Throttle window respected (no rewrite within 5 min) and refreshed once it passes Migrations not yet run on environments. --- app/Http/Middleware/TrackLastActiveAt.php | 40 +++++++++++++++++++ app/Listeners/UpdateLastLoggedInAt.php | 22 ++++++++++ app/Models/User.php | 7 ++++ app/Providers/AppServiceProvider.php | 3 ++ bootstrap/app.php | 2 + ...4_add_last_logged_in_at_to_users_table.php | 28 +++++++++++++ ...4055_add_last_active_at_to_users_table.php | 28 +++++++++++++ tests/Feature/Auth/AuthenticationTest.php | 12 ++++++ tests/Feature/TrackLastActiveAtTest.php | 29 ++++++++++++++ 9 files changed, 171 insertions(+) create mode 100644 app/Http/Middleware/TrackLastActiveAt.php create mode 100644 app/Listeners/UpdateLastLoggedInAt.php create mode 100644 database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php create mode 100644 database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php create mode 100644 tests/Feature/TrackLastActiveAtTest.php diff --git a/app/Http/Middleware/TrackLastActiveAt.php b/app/Http/Middleware/TrackLastActiveAt.php new file mode 100644 index 00000000..be5298cf --- /dev/null +++ b/app/Http/Middleware/TrackLastActiveAt.php @@ -0,0 +1,40 @@ +user(); + + if ($user instanceof User) { + $lastActiveAt = $user->last_active_at; + + if ($lastActiveAt === null + || $lastActiveAt->lte(now()->subSeconds(self::THROTTLE_SECONDS))) { + $user->last_active_at = now(); + $user->saveQuietly(); + } + } + + return $response; + } +} diff --git a/app/Listeners/UpdateLastLoggedInAt.php b/app/Listeners/UpdateLastLoggedInAt.php new file mode 100644 index 00000000..47084ef5 --- /dev/null +++ b/app/Listeners/UpdateLastLoggedInAt.php @@ -0,0 +1,22 @@ +user; + + if (! $user instanceof User) { + return; + } + + $user->forceFill([ + 'last_logged_in_at' => $user->freshTimestamp(), + ])->saveQuietly(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 43faf88b..5ccb5784 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\DripEmailType; use App\Notifications\VerifyEmailNotification; +use Carbon\Carbon; use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; @@ -22,6 +23,10 @@ use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Pennant\Concerns\HasFeatures; +/** + * @property ?Carbon $last_logged_in_at + * @property ?Carbon $last_active_at + */ class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { /** @use HasFactory */ @@ -69,6 +74,8 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'two_factor_confirmed_at' => 'datetime', 'onboarded_at' => 'datetime', 'paywall_seen_at' => 'datetime', + 'last_logged_in_at' => 'datetime', + 'last_active_at' => 'datetime', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e50792b1..2bc1b8c1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,8 +11,10 @@ use App\Listeners\ApplyAutomationRules; use App\Listeners\AssignTransactionToBudget; use App\Listeners\PostStripeEventToDiscord; use App\Listeners\UnassignTransactionFromBudget; +use App\Listeners\UpdateLastLoggedInAt; use App\Services\Banking\EnableBankingProvider; use App\Services\Discord\DiscordWebhook; +use Illuminate\Auth\Events\Login; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; @@ -54,6 +56,7 @@ class AppServiceProvider extends ServiceProvider Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class); Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class); Event::listen(WebhookReceived::class, PostStripeEventToDiscord::class); + Event::listen(Login::class, UpdateLastLoggedInAt::class); RateLimiter::for('emails', function (object $job): Limit { return Limit::perSecond(30); diff --git a/bootstrap/app.php b/bootstrap/app.php index 402cb719..c04f75df 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetLocale; use App\Http\Middleware\SetSentryUser; +use App\Http\Middleware\TrackLastActiveAt; use App\Jobs\SyncBankingConnectionJob; use App\Services\AuthEntryPointService; use Illuminate\Foundation\Application; @@ -43,6 +44,7 @@ return Application::configure(basePath: dirname(__DIR__)) SetSentryUser::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + TrackLastActiveAt::class, BlockDemoAccountActions::class.':auto', ]); diff --git a/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php b/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php new file mode 100644 index 00000000..20bff3f1 --- /dev/null +++ b/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_logged_in_at')->nullable()->after('onboarded_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_logged_in_at'); + }); + } +}; diff --git a/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php b/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php new file mode 100644 index 00000000..b444167e --- /dev/null +++ b/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_active_at')->nullable()->after('last_logged_in_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_active_at'); + }); + } +}; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 077259b3..6d760e74 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -41,6 +41,18 @@ test('users can authenticate using the login screen', function () { ->assertCookie('whisper_money_returning_user'); }); +test('logging in records the last logged in date', function () { + $user = User::factory()->withoutTwoFactor()->create(['last_logged_in_at' => null]); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + expect($user->fresh()->last_logged_in_at)->not->toBeNull(); +}); + test('users with two factor enabled are redirected to two factor challenge', function () { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); diff --git a/tests/Feature/TrackLastActiveAtTest.php b/tests/Feature/TrackLastActiveAtTest.php new file mode 100644 index 00000000..9dd5d5f0 --- /dev/null +++ b/tests/Feature/TrackLastActiveAtTest.php @@ -0,0 +1,29 @@ +onboarded()->create(['last_active_at' => null]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at)->not->toBeNull(); +}); + +test('the last active date is not updated again within the throttle window', function () { + $recent = now()->subMinute(); + $user = User::factory()->onboarded()->create(['last_active_at' => $recent]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at->timestamp)->toBe($recent->timestamp); +}); + +test('the last active date is refreshed once the throttle window passes', function () { + $stale = now()->subHour(); + $user = User::factory()->onboarded()->create(['last_active_at' => $stale]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at->timestamp)->toBeGreaterThan($stale->timestamp); +});