Fix inactive account removal logic and checks

This commit is contained in:
DariusIII
2026-03-11 17:06:11 +01:00
parent 6953b85b4c
commit fe844e652c
6 changed files with 283 additions and 14 deletions
+20 -13
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Enums\UserRole;
use App\Models\User;
use App\Services\UserInactivityEvaluator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -32,20 +34,25 @@ class RemoveInactiveAccounts implements ShouldQueue
{
$purgeDays = (int) config('nntmux.purge_inactive_users_days');
$threshold = now()->subDays($purgeDays);
$evaluator = new UserInactivityEvaluator;
User::query()->where('roles_id', 1)->where(function ($q) use ($threshold) {
$q->where(function ($qq) use ($threshold) {
$qq->whereNotNull('lastlogin')->where('lastlogin', '<', $threshold);
})->orWhere(function ($qq) use ($threshold) {
// Only treat null lastlogin as inactive if the account is older than threshold
$qq->whereNull('lastlogin')->where('created_at', '<', $threshold);
User::query()
->where('roles_id', UserRole::USER->value)
->where('created_at', '<', $threshold)
->chunkById(100, static function ($users) use ($evaluator, $threshold): void {
$users
->filter(
static fn (User $user): bool => $evaluator->shouldPurge(
createdAt: $user->created_at,
updatedAt: $user->updated_at,
lastLoginAt: $user->lastlogin,
apiAccessAt: $user->apiaccess,
lastDownloadAt: $user->lastdownload,
grabs: $user->grabs,
threshold: $threshold,
)
)
->each(static fn (User $user) => $user->delete());
});
})->where(function ($q) use ($threshold) {
$q->where(function ($qq) use ($threshold) {
$qq->whereNotNull('apiaccess')->where('apiaccess', '<', $threshold);
})->orWhere(function ($qq) use ($threshold) {
$qq->whereNull('apiaccess')->where('created_at', '<', $threshold);
});
});
}
}
+5 -1
View File
@@ -52,6 +52,7 @@ use Spatie\Permission\Traits\HasRoles;
* @property string|null $resetguid
* @property Carbon|null $lastlogin
* @property Carbon|null $apiaccess
* @property Carbon|null $lastdownload
* @property int $invites
* @property int|null $invitedby
* @property bool $movieview
@@ -166,6 +167,7 @@ final class User extends Authenticatable
'email_verified_at' => 'datetime',
'lastlogin' => 'datetime',
'apiaccess' => 'datetime',
'lastdownload' => 'datetime',
'rolechangedate' => 'datetime',
'pending_role_start_date' => 'datetime',
'created_at' => 'datetime',
@@ -1411,7 +1413,9 @@ final class User extends Authenticatable
*/
public static function incrementGrabs(int $id, int $num = 1): void
{
static::find($id)?->increment('grabs', $num);
static::query()
->whereKey($id)
->increment('grabs', $num, ['lastdownload' => now()]);
}
// ===== Validation Methods =====
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonInterface;
final class UserInactivityEvaluator
{
public function shouldPurge(
?CarbonInterface $createdAt,
?CarbonInterface $updatedAt,
?CarbonInterface $lastLoginAt,
?CarbonInterface $apiAccessAt,
?CarbonInterface $lastDownloadAt,
int $grabs,
CarbonInterface $threshold
): bool {
return $this->isActivityStale($lastLoginAt, $createdAt, $threshold)
&& $this->isActivityStale($apiAccessAt, $createdAt, $threshold)
&& $this->isDownloadActivityStale($createdAt, $updatedAt, $lastDownloadAt, $grabs, $threshold);
}
private function isActivityStale(
?CarbonInterface $activityAt,
?CarbonInterface $fallbackAt,
CarbonInterface $threshold
): bool {
$comparisonDate = $activityAt ?? $fallbackAt;
return $comparisonDate !== null && $comparisonDate->lt($threshold);
}
private function isDownloadActivityStale(
?CarbonInterface $createdAt,
?CarbonInterface $updatedAt,
?CarbonInterface $lastDownloadAt,
int $grabs,
CarbonInterface $threshold
): bool {
if ($lastDownloadAt !== null) {
return $lastDownloadAt->lt($threshold);
}
if ($grabs > 0) {
return $updatedAt !== null && $updatedAt->lt($threshold);
}
return $createdAt !== null && $createdAt->lt($threshold);
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dateTime('lastdownload')->nullable()->after('apiaccess');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('lastdownload');
});
}
};
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\ReleaseReport;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class ReleaseReportControllerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware();
Cache::flush();
Schema::dropIfExists('settings');
Schema::dropIfExists('release_reports');
Schema::dropIfExists('releases');
Schema::create('settings', function (Blueprint $table): void {
$table->string('name')->primary();
$table->text('value')->nullable();
});
Schema::create('releases', function (Blueprint $table): void {
$table->increments('id');
});
Schema::create('release_reports', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('releases_id');
$table->unsignedInteger('users_id');
$table->string('reason', 255);
$table->text('description')->nullable();
$table->string('status', 255);
$table->unsignedInteger('reviewed_by')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
});
}
public function test_store_accepts_a_numeric_string_release_id(): void
{
$this->mockAuthenticatedUserId();
$releaseId = $this->createRelease();
$response = $this->postJson(route('release-report.store'), [
'release_id' => (string) $releaseId,
'reason' => 'spam',
'description' => 'Runtime regression coverage for numeric string ids.',
]);
$response
->assertOk()
->assertJson([
'success' => true,
]);
$this->assertDatabaseHas('release_reports', [
'releases_id' => $releaseId,
'users_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
}
public function test_check_reported_accepts_a_numeric_string_release_id(): void
{
$this->mockAuthenticatedUserId();
$releaseId = $this->createRelease();
ReleaseReport::query()->create([
'releases_id' => $releaseId,
'users_id' => 1,
'reason' => 'duplicate',
'description' => null,
'status' => 'pending',
]);
$response = $this->getJson(route('release-report.check', [
'release_id' => (string) $releaseId,
]));
$response
->assertOk()
->assertJson([
'has_reported' => true,
]);
}
private function mockAuthenticatedUserId(int $userId = 1): void
{
Auth::shouldReceive('check')->andReturn(false);
Auth::shouldReceive('id')->andReturn($userId);
}
private function createRelease(int $id = 1): int
{
DB::table('releases')->insert([
'id' => $id,
]);
return $id;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Services\UserInactivityEvaluator;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\TestCase;
final class UserInactivityEvaluatorTest extends TestCase
{
public function test_it_flags_users_with_no_recent_login_api_or_download_activity(): void
{
$evaluator = new UserInactivityEvaluator;
$threshold = Carbon::parse('2026-03-11 12:00:00')->subDays(180);
$staleDate = $threshold->copy()->subDays(181);
$this->assertTrue($evaluator->shouldPurge(
createdAt: $staleDate,
updatedAt: $staleDate,
lastLoginAt: null,
apiAccessAt: null,
lastDownloadAt: null,
grabs: 0,
threshold: $threshold,
));
}
public function test_it_keeps_users_with_recent_download_activity(): void
{
$evaluator = new UserInactivityEvaluator;
$now = Carbon::parse('2026-03-11 12:00:00');
$threshold = $now->copy()->subDays(180);
$staleDate = $threshold->copy()->subDays(181);
$this->assertFalse($evaluator->shouldPurge(
createdAt: $staleDate,
updatedAt: $now->copy()->subHours(2),
lastLoginAt: $staleDate,
apiAccessAt: $staleDate,
lastDownloadAt: $now->copy()->subHours(2),
grabs: 12,
threshold: $threshold,
));
}
public function test_it_keeps_legacy_downloaders_while_lastdownload_is_still_null(): void
{
$evaluator = new UserInactivityEvaluator;
$now = Carbon::parse('2026-03-11 12:00:00');
$threshold = $now->copy()->subDays(180);
$staleDate = $threshold->copy()->subDays(181);
$this->assertFalse($evaluator->shouldPurge(
createdAt: $staleDate,
updatedAt: $now->copy()->subHours(6),
lastLoginAt: $staleDate,
apiAccessAt: $staleDate,
lastDownloadAt: null,
grabs: 15,
threshold: $threshold,
));
}
}