feat(cloud): save deleted users' emails and keep unsubscribed teams for 60 days

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 23:12:34 +01:00
1 parent 927e3f0b42
commit 997396b926
4 files changed
+85 -39

No files matched your search

@@ -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<int, User> $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
+34 -35
View File
@@ -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<string>
*/
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<int, mixed> $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.');
+1 -1
View File
@@ -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' => [
@@ -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();
});