mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user