mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(cleanup): purge stale unsubscribed teams (#11668)
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CleanupUnsubscribedTeams extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-unsubscribed-teams
|
||||
{--months=6 : Months since the subscription ended}
|
||||
{--yes : Delete eligible teams instead of running a dry run}';
|
||||
|
||||
protected $description = 'Delete teams whose subscription ended long ago, with their servers, resources, projects, and users without another team';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on Coolify Cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$months = filter_var($this->option('months'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
if ($months === false) {
|
||||
$this->error('The --months option must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subMonths($months);
|
||||
$teams = $this->eligibleTeams($cutoff)
|
||||
->with('subscription')
|
||||
->withCount(['servers', 'projects', 'members'])
|
||||
->get();
|
||||
$orphanedUserIds = $this->orphanedUsers($teams->modelKeys())->pluck('id');
|
||||
|
||||
$this->info("Found {$teams->count()} ".Str::plural('team', $teams->count())." with a subscription that ended before {$cutoff->toDateString()}.");
|
||||
$this->info("Found {$orphanedUserIds->count()} ".Str::plural('user', $orphanedUserIds->count()).' without another team.');
|
||||
|
||||
if ($teams->isNotEmpty()) {
|
||||
$this->table(
|
||||
['Team', 'Name', 'Servers', 'Projects', 'Members', 'Subscription updated'],
|
||||
$teams->map(fn (Team $team) => [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$team->servers_count,
|
||||
$team->projects_count,
|
||||
$team->members_count,
|
||||
$team->subscription->updated_at?->toDateString(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->option('yes')) {
|
||||
$this->warn('Dry run only. Use --yes to delete eligible teams.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($teams as $team) {
|
||||
DB::transaction(fn () => $this->deleteTeam($team));
|
||||
}
|
||||
|
||||
$deletedUserCount = 0;
|
||||
|
||||
foreach (User::query()->whereKey($orphanedUserIds)->whereDoesntHave('teams')->lazyById(100) as $user) {
|
||||
if ($user->delete()) {
|
||||
$deletedUserCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Deleted {$teams->count()} ".Str::plural('team', $teams->count())." and {$deletedUserCount} ".Str::plural('user', $deletedUserCount).'.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams whose Stripe subscription ended (no subscription id, unpaid) and did not change since the cutoff.
|
||||
*/
|
||||
private function eligibleTeams(CarbonInterface $cutoff): Builder
|
||||
{
|
||||
return Team::query()
|
||||
->whereKeyNot(0)
|
||||
->whereHas('subscription', fn (Builder $query) => $query
|
||||
->where('stripe_invoice_paid', false)
|
||||
->whereNull('stripe_subscription_id')
|
||||
->whereNotNull('stripe_customer_id')
|
||||
->where('updated_at', '<', $cutoff));
|
||||
}
|
||||
|
||||
/**
|
||||
* Users that belong only to the given teams.
|
||||
*
|
||||
* @param array<int, int> $teamIds
|
||||
*/
|
||||
private function orphanedUsers(array $teamIds): Builder
|
||||
{
|
||||
return User::query()
|
||||
->whereKeyNot(0)
|
||||
->whereHas('teams', fn (Builder $query) => $query->whereIn('teams.id', $teamIds))
|
||||
->whereDoesntHave('teams', fn (Builder $query) => $query->whereNotIn('teams.id', $teamIds));
|
||||
}
|
||||
|
||||
private function deleteTeam(Team $team): void
|
||||
{
|
||||
foreach ($team->servers()->withTrashed()->get() as $server) {
|
||||
foreach ($server->definedResources() as $resource) {
|
||||
$resource->forceDelete();
|
||||
}
|
||||
$server->forceDelete();
|
||||
}
|
||||
|
||||
foreach ($team->projects()->get() as $project) {
|
||||
$project->delete();
|
||||
}
|
||||
|
||||
foreach ($team->members()->get() as $member) {
|
||||
$member->clearStoredTeamIfMatches($team->id);
|
||||
DB::table('sessions')->where('user_id', $member->id)->delete();
|
||||
Cache::forget("user:{$member->id}:team:{$team->id}");
|
||||
}
|
||||
$team->members()->detach();
|
||||
|
||||
$team->subscription()->delete();
|
||||
$team->delete();
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'stripe_invoice_paid',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Subscription>
|
||||
*/
|
||||
class SubscriptionFactory extends Factory
|
||||
{
|
||||
protected $model = Subscription::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'team_id' => Team::factory(),
|
||||
'stripe_customer_id' => 'cus_'.$this->faker->bothify('??????????'),
|
||||
'stripe_subscription_id' => null,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_plan_id' => null,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
});
|
||||
|
||||
function endedSubscription(Team $team, array $attributes = []): Subscription
|
||||
{
|
||||
return Subscription::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'updated_at' => now()->subMonths(7),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
test('it refuses to run outside Coolify Cloud', function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
$team = Team::factory()->create();
|
||||
endedSubscription($team);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
|
||||
->expectsOutput('This command can only be run on Coolify Cloud.')
|
||||
->assertFailed();
|
||||
|
||||
$this->assertModelExists($team);
|
||||
});
|
||||
|
||||
test('it rejects a months value that is not a positive integer', function (string $months) {
|
||||
$team = Team::factory()->create();
|
||||
endedSubscription($team);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--months' => $months, '--yes' => true])
|
||||
->expectsOutput('The --months option must be a positive integer.')
|
||||
->assertFailed();
|
||||
|
||||
$this->assertModelExists($team);
|
||||
})->with(['0', '-1', 'abc']);
|
||||
|
||||
test('it previews eligible teams without deleting them', function () {
|
||||
$team = Team::factory()->create(['name' => 'Acme Corp']);
|
||||
endedSubscription($team);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams')
|
||||
->expectsOutputToContain('Found 1 team')
|
||||
->expectsOutputToContain('Acme Corp')
|
||||
->expectsOutput('Dry run only. Use --yes to delete eligible teams.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($team);
|
||||
});
|
||||
|
||||
test('it deletes an eligible team with its subscription, servers, projects, and orphaned owner', function () {
|
||||
$owner = User::factory()->create();
|
||||
$team = $owner->teams()->first();
|
||||
$subscription = endedSubscription($team);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$trashedServer = Server::factory()->create(['team_id' => $team->id]);
|
||||
$trashedServer->delete();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
|
||||
->expectsOutput('Deleted 1 team and 1 user.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelMissing($team);
|
||||
$this->assertModelMissing($subscription);
|
||||
$this->assertModelMissing($project);
|
||||
$this->assertModelMissing($owner);
|
||||
expect(Server::withTrashed()->whereKey([$server->id, $trashedServer->id])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it keeps members with another team and detaches them from the deleted team', function () {
|
||||
$team = Team::factory()->create();
|
||||
endedSubscription($team);
|
||||
$member = User::factory()->create();
|
||||
$team->members()->attach($member, ['role' => 'member']);
|
||||
$member->update(['current_team_id' => $team->id]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful();
|
||||
|
||||
$this->assertModelMissing($team);
|
||||
$member->refresh();
|
||||
expect($member->current_team_id)->toBeNull()
|
||||
->and($member->teams()->count())->toBe(1);
|
||||
$this->assertDatabaseMissing('team_user', ['team_id' => $team->id]);
|
||||
});
|
||||
|
||||
test('it keeps teams that are not eligible', function (array $attributes) {
|
||||
$team = Team::factory()->create();
|
||||
endedSubscription($team, $attributes);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])
|
||||
->expectsOutput('Deleted 0 teams and 0 users.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertModelExists($team);
|
||||
})->with([
|
||||
'paid subscription' => [['stripe_invoice_paid' => true, 'stripe_subscription_id' => 'sub_paid']],
|
||||
'paused subscription that still exists in Stripe' => [['stripe_subscription_id' => 'sub_paused']],
|
||||
'subscription ended within the cutoff' => [['updated_at' => now()->subMonths(3)]],
|
||||
'subscription without a Stripe customer' => [['stripe_customer_id' => null]],
|
||||
]);
|
||||
|
||||
test('it respects the months option', function () {
|
||||
$team = Team::factory()->create();
|
||||
endedSubscription($team, ['updated_at' => now()->subMonths(4)]);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--yes' => true])->assertSuccessful();
|
||||
$this->assertModelExists($team);
|
||||
|
||||
$this->artisan('cloud:cleanup-unsubscribed-teams', ['--months' => 3, '--yes' => true])->assertSuccessful();
|
||||
$this->assertModelMissing($team);
|
||||
});
|
||||
Reference in New Issue
Block a user