diff --git a/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php index 4e84f231d6..9233d4d9ad 100644 --- a/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php +++ b/app/Console/Commands/Cloud/CleanupUnsubscribedTeams.php @@ -12,8 +12,11 @@ use Carbon\CarbonInterface; use Illuminate\Console\Command; use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use RuntimeException; use Stripe\Subscription as StripeSubscription; @@ -25,7 +28,12 @@ class CleanupUnsubscribedTeams extends Command {--days= : Days since the subscription ended or the never-paid team was created (default: constants.cloud.data_retention_days)} {--yes : Delete eligible teams instead of running a dry run}'; - protected $description = 'Delete teams that stopped paying or never paid, with their servers, resources, projects, and users without another team. A personal team of a member of another team keeps only the team and its members'; + /** + * File on the backups disk that collects every deleted user, in the cloud:export-users format. + */ + public const DELETED_USERS_CSV = 'cloud-deleted-users.csv'; + + protected $description = 'Delete teams that stopped paying or never paid, with their servers, resources, projects, and users without another team (their emails are saved first). A personal team of a member of another team keeps only the team and its members'; public function handle(): int { @@ -129,6 +137,9 @@ class CleanupUnsubscribedTeams extends Command } $this->info("Deleted {$deletedTeamCount} ".Str::plural('team', $deletedTeamCount)." and {$deletedUserCount} ".Str::plural('user', $deletedUserCount).'.'); + if ($deletedUserCount > 0) { + $this->info('Saved the deleted users to '.Storage::disk('backups')->path(self::DELETED_USERS_CSV).'.'); + } if ($keptTeamCount > 0) { $this->info("Removed the data of {$keptTeamCount} personal ".Str::plural('team', $keptTeamCount).' and kept '.($keptTeamCount === 1 ? 'the team' : 'the teams').'.'); } @@ -146,8 +157,6 @@ class CleanupUnsubscribedTeams extends Command } return $failedTeams ? self::FAILURE : self::SUCCESS; - - return self::SUCCESS; } /** @@ -173,6 +182,7 @@ class CleanupUnsubscribedTeams extends Command $counts = app(DeleteTeamData::class)->handle($team); $teamlessMembers = User::query()->whereKey($memberIds)->whereKeyNot(0)->whereDoesntHave('teams')->get(); + $this->saveDeletedUsers($teamlessMembers); foreach ($teamlessMembers as $user) { app(DeleteTeamlessUser::class)->handle($user); } @@ -181,6 +191,41 @@ class CleanupUnsubscribedTeams extends Command }); } + /** + * Appends the users to DELETED_USERS_CSV before they are deleted, so the email platform can + * reach them later. A failed write throws and rolls back the deletion. + * + * @param EloquentCollection $users + */ + private function saveDeletedUsers(EloquentCollection $users): void + { + if ($users->isEmpty()) { + return; + } + + $path = Storage::disk('backups')->path(self::DELETED_USERS_CSV); + File::ensureDirectoryExists(dirname($path)); + $isNewFile = ! file_exists($path) || filesize($path) === 0; + $output = fopen($path, 'ab'); + if ($output === false) { + throw new RuntimeException("Unable to open {$path} for writing."); + } + + try { + if ($isNewFile) { + ExportUsers::writeCsvRow($output, ExportUsers::CSV_HEADER); + } + foreach ($users as $user) { + ExportUsers::writeCsvRow($output, ExportUsers::csvRow($user)); + } + if (! fflush($output)) { + throw new RuntimeException("Unable to write {$path}."); + } + } finally { + fclose($output); + } + } + /** * Checks the Stripe subscriptions of the team's customers and of every customer with the email of * an owner or admin. A subscription counts for the team unless it belongs to another team that diff --git a/app/Console/Commands/Cloud/ExportUsers.php b/app/Console/Commands/Cloud/ExportUsers.php index 5e383c4ce8..629085c54d 100644 --- a/app/Console/Commands/Cloud/ExportUsers.php +++ b/app/Console/Commands/Cloud/ExportUsers.php @@ -14,6 +14,23 @@ class ExportUsers extends Command protected $description = 'Export subscribed and unsubscribed verified Coolify Cloud users to separate CSV files'; + /** + * The import format of the email platform. cloud:cleanup-unsubscribed-teams writes deleted users in the same format. + */ + public const CSV_HEADER = [ + 'email', + 'first_name', + 'last_name', + 'lifetime_value_currency', + 'lifetime_value_amount', + 'utm_campaign', + 'utm_source', + 'utm_medium', + 'utm_content', + 'utm_term', + 'phone', + ]; + public function handle(): int { if (! isCloud()) { @@ -48,22 +65,8 @@ class ExportUsers extends Command $unsubscribedCount = 0; try { - $header = [ - 'email', - 'first_name', - 'last_name', - 'lifetime_value_currency', - 'lifetime_value_amount', - 'utm_campaign', - 'utm_source', - 'utm_medium', - 'utm_content', - 'utm_term', - 'phone', - ]; - - $this->writeCsvRow($subscribedOutput, $header); - $this->writeCsvRow($unsubscribedOutput, $header); + self::writeCsvRow($subscribedOutput, self::CSV_HEADER); + self::writeCsvRow($unsubscribedOutput, self::CSV_HEADER); foreach (User::query() ->select(['id', 'email', 'name']) @@ -74,28 +77,13 @@ class ExportUsers extends Command ->whereRelation('subscription', 'stripe_invoice_paid', true), ]) ->lazyById(500) as $user) { - $nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: []; - [$firstName, $lastName] = array_pad($nameParts, 2, ''); - - $row = [ - $user->email, - $firstName, - $lastName, - '', - '', - '', - '', - '', - '', - '', - '', - ]; + $row = self::csvRow($user); if ($user->is_subscribed) { - $this->writeCsvRow($subscribedOutput, $row); + self::writeCsvRow($subscribedOutput, $row); $subscribedCount++; } else { - $this->writeCsvRow($unsubscribedOutput, $row); + self::writeCsvRow($unsubscribedOutput, $row); $unsubscribedCount++; } } @@ -114,11 +102,22 @@ class ExportUsers extends Command return self::SUCCESS; } + /** + * @return list + */ + public static function csvRow(User $user): array + { + $nameParts = preg_split('/\s+/u', trim((string) $user->name), 2) ?: []; + [$firstName, $lastName] = array_pad($nameParts, 2, ''); + + return [$user->email, $firstName, $lastName, '', '', '', '', '', '', '', '']; + } + /** * @param resource $output * @param array $fields */ - private function writeCsvRow($output, array $fields): void + public static function writeCsvRow($output, array $fields): void { if (fputcsv($output, $fields, ',', '"', '') === false) { throw new RuntimeException('Unable to write the CSV file.'); diff --git a/config/constants.php b/config/constants.php index 4217921652..37a77a302b 100644 --- a/config/constants.php +++ b/config/constants.php @@ -32,7 +32,7 @@ return [ 'cloud' => [ // Days after a subscription ends (or after a never-paid team was created) before cloud:cleanup-unsubscribed-teams deletes the team's data. - 'data_retention_days' => (int) env('CLOUD_DATA_RETENTION_DAYS', 30), + 'data_retention_days' => (int) env('CLOUD_DATA_RETENTION_DAYS', 60), ], 'urls' => [ diff --git a/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php index c62be5f3e8..9a03f21468 100644 --- a/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php +++ b/tests/Feature/CleanupUnsubscribedTeamsCommandTest.php @@ -22,6 +22,8 @@ uses(RefreshDatabase::class); beforeEach(function () { config()->set('constants.coolify.self_hosted', false); + config()->set('constants.cloud.data_retention_days', 30); + Storage::fake('backups'); Process::fake(); fakeOpenStripeSubscriptions(); });